/** * scripts/generate-docs/lib/search-index.ts * * Builds `.documentation/js/search-index.js` from the outline of every rendered * page. `ss docs` opens the site over `file://`, where `fetch()` of a JSON file * is refused — so the index ships as a script that sets `window.SS_DOCS_INDEX`. * * Entry shape (short keys keep the file small): * { s: slug, a: 'd' | 't' (discover / tech), t: {fr,en} title, g: {fr,en} group | null, * x: {fr,en} intro excerpt, h: [[anchorId, titleFr, titleEn, textFr, textEn], …] } */ import { localise, type Localised } from './i18n-types'; import type { LocalisedString } from './markdown-parser'; import type { PageOutline } from './page-outline'; import type { PagePlacement } from './sidebar-builder'; import type { Skill } from './skill-parser'; export interface SearchPageInput { slug: string; title: LocalisedString; placement?: PagePlacement; outline: PageOutline; } type SectionTuple = [string, string, string, string, string]; interface SearchEntry { s: string; a: 'd' | 't'; t: Localised; g: Localised | null; x: Localised; h: SectionTuple[]; } const SECTION_TEXT_MAX = 400; const INTRO_TEXT_MAX = 220; const SKILL_TEXT_MAX = 220; function clip(text: string, max: number): string { if (text.length <= max) return text; const cut = text.slice(0, max); const space = cut.lastIndexOf(' '); return (space > max * 0.6 ? cut.slice(0, space) : cut) + '…'; } function clipBoth(value: Localised, max: number): Localised { return { fr: clip(value.fr, max), en: clip(value.en, max) }; } /** The skill catalogue page lists every skill as an anchored row (`#skill-`). */ export const SKILL_CATALOGUE_PAGE = 'commands'; function toEntry(page: SearchPageInput, skills: Skill[]): SearchEntry { const h: SectionTuple[] = page.outline.sections.map((sec) => [ sec.id, sec.title.fr, sec.title.en, clip(sec.text.fr, SECTION_TEXT_MAX), clip(sec.text.en, SECTION_TEXT_MAX), ]); if (page.slug === SKILL_CATALOGUE_PAGE) { for (const skill of skills) { const text = clip(skill.description ?? '', SKILL_TEXT_MAX); h.push([`skill-${skill.slug}`, `/${skill.slug}`, `/${skill.slug}`, text, text]); } } return { s: page.slug, a: page.placement?.audience === 'discover' ? 'd' : 't', t: localise(page.placement?.label ?? page.title), g: page.placement?.group ? localise(page.placement.group) : null, x: clipBoth(page.outline.intro, INTRO_TEXT_MAX), h, }; } /** One page per line, so a content change shows up as a readable git diff. */ export function buildSearchIndex(pages: SearchPageInput[], skills: Skill[], version: string): string { const entries = pages.map((p) => JSON.stringify(toEntry(p, skills))); return ( '/* AUTO-GENERATED by scripts/generate-docs — do not edit. Rebuild with `npm run build:docs`. */\n' + `window.SS_DOCS_INDEX = {"version":${JSON.stringify(version)},"pages":[\n` + entries.join(',\n') + '\n]};\n' ); } /** * Throws when a search result would point to an anchor missing from its page — * the failure mode of the previous hand-written index (21 dead anchors out of 22). */ export function assertIndexAnchors(pages: SearchPageInput[], skills: Skill[], htmlBySlug: Map): void { const dead: string[] = []; for (const page of pages) { const html = htmlBySlug.get(page.slug) ?? ''; for (const [id] of toEntry(page, skills).h) { if (!html.includes(` id="${id}"`)) dead.push(`${page.slug}.html#${id}`); } } if (dead.length) { throw new Error(`search index points to anchors missing from their page:\n - ${dead.join('\n - ')}`); } }