diff --git a/scripts/llms-txt.test.mjs b/scripts/llms-txt.test.mjs index 978a9e9..9406621 100644 --- a/scripts/llms-txt.test.mjs +++ b/scripts/llms-txt.test.mjs @@ -100,6 +100,33 @@ export function agentCliChipNames(html) { return chipsIn(sectionByHeading(html, "agent-cli-title")).map((c) => c.name); } +// --- project list ------------------------------------------------------------- + +// llms.txt "## Projects" bullets: "- [Name](url): blurb" -> "Name". Names drop +// a trailing parenthetical alias ("Spektacular (Spek)" -> "Spektacular") so they +// compare against the card

. Links are not compared: llms.txt may point at a +// project's own site where the card points at its repo. Returns null when the +// section is absent. +export function parseLlmsProjects(text) { + const m = text.match(/^## Projects\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m); + if (!m) return null; + return [...m[1].matchAll(/^- \[([^\]]+)\]\([^)]+\)/gm)].map((b) => decode(b[1]).replace(/\s*\([^)]*\)\s*$/, "")); +} + +// Project carousel card names on index.html: each card's

text without its +// badge, in carousel order. Anchor cards and div cards +// (buttons inside) are both read; slides without an

are not projects. +export function projectCards(html) { + const section = sectionByHeading(html, "projects") ?? html; + const cards = []; + for (const li of section.matchAll(/
  • ([\s\S]*?)<\/li>/g)) { + const h3 = li[1].match(/

    ([\s\S]*?)<\/h3>/); + if (!h3) continue; + cards.push(decode(h3[1].replace(/]*>[\s\S]*?<\/span>/g, ""))); + } + return cards; +} + function setDiff(a, b) { const sb = new Set(b); return a.filter((x) => !sb.has(x)); @@ -190,6 +217,54 @@ test("assertSameSet flags drift in either direction with an actionable message", assert.doesNotThrow(() => assertSameSet(["B", "A"], ["A", "B"], "x")); }); +const FIXTURE_PROJECTS_HTML = ` +
    +

    The project family

    + +
    +`; + +test("parseLlmsProjects reads one name per bullet, strips the alias, stops at the next heading and reports a missing section as null", () => { + const txt = [ + "# X", + "## Projects", + "", + "- [Alpha](https://github.com/x/alpha): blurb. Docs: https://alpha.example", + "- [Beta (B)](https://beta.example): blurb (with parens) inside.", + "", + "## Next section", + "", + "- [Not a project](https://nope.example)", + ].join("\n"); + assert.deepEqual(parseLlmsProjects(txt), ["Alpha", "Beta"]); + assert.equal(parseLlmsProjects("# X\n\nNo projects."), null); + assert.deepEqual(parseLlmsProjects("## Projects\n\n- [Last](https://last.example): at EOF"), ["Last"]); +}); + +test("projectCards reads each carousel card's badge-free name from anchor and div cards alike, skipping heading-less slides and other carousels", () => { + assert.deepEqual(projectCards(FIXTURE_PROJECTS_HTML), ["Alpha", "Beta"]); + // Without a #projects section the whole document is scanned. + assert.deepEqual(projectCards('
  • Solo

  • '), ["Solo"]); + assert.deepEqual(projectCards("

    nothing

    "), []); +}); + // --- live checks ------------------------------------------------------------- const live = parseLlmsTxt(LLMS_TXT); @@ -233,3 +308,15 @@ test("llms.txt names every project the home page lists as a Hive Commons product assert.match(INDEX_HTML, /Spektacular/); assert.match(LLMS_TXT, /Spektacular \(Spek\)/); }); + +test("llms.txt Projects list names the project carousel cards on index.html, in the same order", () => { + const cards = projectCards(INDEX_HTML); + assert.ok(cards.length > 0, "no project cards under #projects"); + const projects = parseLlmsProjects(LLMS_TXT); + assert.ok(projects, "llms.txt lost its '## Projects' section — restore it or update this gate"); + assert.deepEqual( + projects, + cards, + "llms.txt '## Projects' must list the same projects as the index.html carousel, in carousel order", + ); +});