export type Term = { id: string; term: string; aliases: string[]; definition: string; }; export type Match = { termId: string; start: number; end: number; text: string; }; export type Coverage = { id: string; term: string; count: number }; export function validateGlossary(value: unknown): Term[] { const ids = new Set(), phrases = new Set(); return array(value, "glossary").map((x) => { if (!x || typeof x !== "object") throw Error("Term must be an object"); const id = nonempty(x.id, "id"), term = nonempty(x.term, "term"), definition = nonempty(x.definition, "definition"); if (!/^[a-z][a-z0-9-]*$/.test(id) || ids.has(id)) throw Error("Invalid or duplicate id"); ids.add(id); const aliases = x.aliases === undefined ? [] : array(x.aliases, "aliases").map((a) => nonempty(a, "alias")); for (const s of [term, ...aliases]) { const key = s.toLowerCase(); if (phrases.has(key)) throw Error("Ambiguous repeated phrase " + s); phrases.add(key); } return { id, term, aliases, definition }; }); } export function matchTerms(text: string, terms: Term[]): Match[] { const phrases = terms .flatMap((t) => [t.term, ...t.aliases].map((phrase) => ({ phrase, termId: t.id })), ) .sort( (a, b) => b.phrase.length - a.phrase.length || a.termId.localeCompare(b.termId), ); const word = (s: string) => /[\p{L}\p{N}_]/u.test(s); const matches: Match[] = []; for (let i = 0; i < text.length; ) { const found = phrases.find( (p) => text.slice(i, i + p.phrase.length).toLowerCase() === p.phrase.toLowerCase() && (i === 0 || !word(text[i - 1])) && (i + p.phrase.length === text.length || !word(text[i + p.phrase.length])), ); if (found) { matches.push({ termId: found.termId, start: i, end: i + found.phrase.length, text: text.slice(i, i + found.phrase.length), }); i += found.phrase.length; } else i++; } return matches; } export function annotateLesson( html: string, terms: Term[], ): { html: string; coverage: Coverage[] } { const allowed = new Set([ "p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "em", "strong", "span", "div", "section", "article", "blockquote", "code", "pre", "a", "br", "hr", ]); const voids = new Set(["br", "hr"]); const stack: string[] = []; const counts = new Map(terms.map((t) => [t.id, 0])); let output = ""; const parts = html.match(/|<[^>]*>|[^<]+|]*)>$/i); if (!tag) throw Error("Malformed HTML tag"); const name = tag[2].toLowerCase(); if (!allowed.has(name)) throw Error("Unsupported HTML element " + name); if (tag[1]) { if (tag[3].trim() || voids.has(name) || stack.pop() !== name) throw Error("Unbalanced HTML"); output += ``; continue; } let attrs = tag[3].replace(/\/$/, ""); const parsed = [ ...attrs.matchAll(/\s+([a-z][a-z0-9-]*)\s*=\s*("[^"]*"|'[^']*')/gi), ]; if ( attrs .replace(/\s+([a-z][a-z0-9-]*)\s*=\s*("[^"]*"|'[^']*')/gi, "") .trim() ) throw Error("Attributes must be quoted"); let safe = ""; for (const a of parsed) { const key = a[1].toLowerCase(), value = a[2].slice(1, -1); if ( !["class", "id", "title", "lang", "href"].includes(key) || (key === "href" && name !== "a") ) throw Error("Unsupported HTML attribute " + key); if (key === "href" && !/^(https?:\/\/|#[a-z0-9_-]+$)/i.test(value)) throw Error("Unsafe link"); if (value.includes("&") || value.includes("<")) throw Error("Attribute entities are outside this subset"); safe += ` ${key}="${escapeHTML(value)}"`; } output += `<${name}${safe}>`; if (!voids.has(name)) stack.push(name); continue; } if (stack.some((x) => ["pre", "code", "a"].includes(x))) { output += part; continue; } // Entities form text boundaries so their encoded characters are never split by matching. for (const segment of part.split(/(&[a-zA-Z0-9#]+;)/g)) { if (/^&[a-zA-Z0-9#]+;$/.test(segment)) { output += segment; continue; } let cursor = 0; for (const match of matchTerms(segment, terms)) { output += segment.slice(cursor, match.start); const t = terms.find((x) => x.id === match.termId)!; output += ``; counts.set(t.id, counts.get(t.id)! + 1); cursor = match.end; } output += segment.slice(cursor); } } if (stack.length) throw Error("Unclosed HTML element"); return { html: output, coverage: terms.map((t) => ({ id: t.id, term: t.term, count: counts.get(t.id)!, })), }; } export function publishGlossary( lesson: string, terms: Term[], ): { html: string; glossary: Term[]; coverage: Coverage[] } { const annotated = annotateLesson(lesson, terms); const content = `

Focus a term and press Enter or Space to reveal its reviewed definition. Code examples and links stay untouched.

${annotated.html}

Term coverage

${annotated.coverage.map((x) => ``).join("")}
TermMatches
${escapeHTML(x.term)}${x.count}
`; return { html: page( "Glossary Hovercard Publisher", content, `document.querySelectorAll('.term-trigger').forEach(button => { button.addEventListener('click', () => { const wasExpanded = button.getAttribute('aria-expanded') === 'true'; button.setAttribute('aria-expanded', String(!wasExpanded)); button.nextElementSibling.hidden = wasExpanded; }); }); document.querySelector('#export').onclick=()=>download('glossary.json',${embedded(terms)});`, ), glossary: structuredClone(terms), coverage: annotated.coverage, }; } export function escapeHTML(value: unknown): string { return String(value).replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ]!, ); } export function embedded(value: unknown): string { return JSON.stringify(value) .replace(/${escapeHTML(title)}

AI Engineering from Scratch ยท local project

${escapeHTML(title)}

${content}
`; } export function array(value: unknown, label: string): any[] { if (!Array.isArray(value)) throw new Error(label + " must be an array"); return value; } export function nonempty(value: unknown, label: string): string { if (typeof value !== "string" || !value.trim()) throw new Error(label + " must be a nonempty string"); return value; }