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 = `
+
+`;
+
+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",
+ );
+});