apps/web/src/lib/pageDirectory.ts

1import {
2	type EventPageContent,
3	fetchEventPagesWithContent,
4} from '~/lib/pageCatalogue'
5
6/**
7 * Deterministic page directory for the concierge: every published page of an
8 * event with its title, URL, description and outline. Complete and ordered,
9 * so "where do I find X / is there a page about Y" is answered by picking the
10 * most specific page from the full list instead of hoping similarity search
11 * ranks it into the top six.
12 */
13
14export type DirectoryPage = {
15	title: string
16	url: string
17	description: string | null
18	/** Section headings and labels — what the page covers. */
19	covers: string[]
20	/** Published calls to action, including external hotel booking destinations. */
21	links: Array<{ label: string; url: string }>
22}
23
24const DIRECTORY_CACHE_TTL_MS = 10 * 60_000
25let cache: { pages: EventPageContent[]; loadedAt: number } | null = null
26
27/** Pure shaping step, separated from the fetch so it can be unit-tested. */
28export function directoryForEvent(
29	eventSlug: string,
30	pages: Array<
31		Pick<
32			EventPageContent,
33			'slug' | 'title' | 'description' | 'outline' | 'content'
34		>
35	>,
36): DirectoryPage[] {
37	return pages
38		.filter((p) => p.slug === eventSlug || p.slug.startsWith(`${eventSlug}/`))
39		.map((p) => ({
40			title: p.title,
41			url: p.slug,
42			description: p.description,
43			covers: p.outline,
44			links: Array.from(
45				p.content.matchAll(/^\[([^\n]+)\]\(<(https?:\/\/[^\s<>]+)>\)$/gm),
46				([, label, url]) => ({ label, url }),
47			),
48		}))
49		.sort((a, b) => a.url.localeCompare(b.url))
50}
51
52export async function loadEventPageDirectory(
53	eventSlug: string,
54): Promise<DirectoryPage[]> {
55	if (!cache || Date.now() - cache.loadedAt > DIRECTORY_CACHE_TTL_MS) {
56		cache = { pages: await fetchEventPagesWithContent(), loadedAt: Date.now() }
57	}
58	return directoryForEvent(eventSlug, cache.pages)
59}
60