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