// Tag extraction and the EXCLUDE_TAGS curation layer (server/indexer.ts + // server/site.ts). Tags are the blog's topic navigation, so a false positive // does not stay quiet: it becomes a heading, a pill and a /topic/ page. import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { initIndexer, tags as tagsRaw } from "../server/indexer.ts"; import { initSite } from "../server/site.ts"; import { initVault } from "../server/vault.ts"; import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts"; const data = makeDir(); const root = makeVault({ "Inline.md": note( { publish: "true" }, [ "Prose with #draft and #zettel/seed and (#paren) tags.", "A repeat of #draft counts once.", "Case folds: #Draft #DRAFT.", "Arabic tags work: #مسودة and #كتب_عربية.", "Digits are fine: #2026 and #v2.", "", "Not tags: https://example.com/#anchor, C#, a#b, # heading-ish, #.", ].join("\n"), ), "Flow list.md": note({ publish: "true", tags: "[alpha, beta]" }, "body\n"), "Scalar.md": note({ publish: "true", tags: "gamma" }, "body\n"), "Quoted.md": note({ publish: "true", tags: '["#delta", \'epsilon\']' }, "body\n"), "Block list.md": `---\npublish: true\ntags:\n - zeta\n - eta\nother: x\n---\nbody\n`, "Private.md": note({ tags: "[secret]" }, "unpublished #hidden\n"), "Fence.md": note({ publish: "true" }, "```sh\n#define MACRO 1\n```\n"), // A reader's own asides, in all three spellings the writer supports. "Commented.md": `---\npublish: true\ntags:\n - theta # why this one\n - iota\n---\nbody\n`, "Commented flow.md": `---\npublish: true\ntags: [kappa, lambda] # the pair\n---\nbody\n`, "Hash inside quotes.md": `---\npublish: true\ntags: ["mu#nu"]\n---\nbody\n`, "Color.md": note({ publish: "true" }, "The accent is #c9a227 in the dark themes.\n"), }); // `tags()` is scoped by audience AND by language (a visitor's tag list drops // the tags only language-hidden notes carry). These cases are about counting // and normalization, so the language scope is pinned to `null` — unfiltered. const tags = (publishedOnly = false) => tagsRaw(publishedOnly, null); const count = new Map(); before(async () => { initSite({ ASTROLABE_DATA: data }); initVault(root); await initIndexer(); for (const entry of tags()) count.set(entry.tag, entry.count); }); after(() => { removeVault(root); removeVault(data); }); describe("inline #tags", () => { it("finds them at a word start, after whitespace or after '('", () => { for (const tag of ["draft", "zettel/seed", "paren"]) { assert.ok(count.has(tag), `missing #${tag}`); } }); it("folds case and counts a note once per tag", () => { assert.equal(count.get("draft"), 1, "one note, one count, however often it appears"); assert.ok(!count.has("Draft")); }); it("accepts unicode letters, underscores and digits", () => { for (const tag of ["مسودة", "كتب_عربية", "2026", "v2"]) { assert.ok(count.has(tag), `missing #${tag}`); } }); it("does not invent tags from URLs, C#, mid-word # or a bare #", () => { for (const notTag of ["anchor", "b", "heading-ish"]) { assert.ok(!count.has(notTag), `#${notTag} should not be a tag`); } }); it("KNOWN BUG: a fenced code block and a hex color still mint tags", () => { // parseTags scans the whole body, fences included, and cannot tell a CSS // color from a tag. Both end up on the public topic list unless the admin // adds them to EXCLUDE_TAGS. assert.ok(count.has("define"), "a #define inside a shell fence became a topic"); assert.ok(count.has("c9a227"), "a hex color became a topic"); }); }); describe("frontmatter tags:", () => { it("reads a [flow, list]", () => { assert.ok(count.has("alpha") && count.has("beta")); }); it("reads a bare scalar", () => { assert.ok(count.has("gamma")); }); it("strips quotes and a leading #", () => { assert.ok(count.has("delta"), 'a "#delta" entry must become "delta"'); assert.ok(count.has("epsilon")); }); it("reads a block list and stops at the next key", () => { assert.ok(count.has("zeta") && count.has("eta")); assert.ok(!count.has("x"), "the next frontmatter key was swallowed as a tag"); }); it("drops a trailing # comment rather than filing it as part of the tag", () => { // Found by the v1.8 browser verification: the sidebar was showing a pill // reading "alpha # a comment inside a block list". A tag the author never // wrote is a topic page nobody asked for. assert.ok(count.has("theta"), [...count.keys()].filter((k) => k.startsWith("theta")).join("|")); assert.ok(count.has("iota")); assert.ok(count.has("kappa") && count.has("lambda")); assert.ok( ![...count.keys()].some((k) => /\s/.test(k) || k.includes("why") || k.includes("pair")), `a comment reached the tag list: ${[...count.keys()].join(", ")}`, ); }); it("keeps a # that lives INSIDE quotes — that one is the tag", () => { assert.ok(count.has("mu#nu"), [...count.keys()].join(", ")); }); }); describe("visitor scoping", () => { it("an unpublished note's tags are invisible to visitors", () => { const visitor = new Set(tags(true).map((entry) => entry.tag)); assert.ok(visitor.has("draft")); assert.ok(!visitor.has("secret"), "an unpublished note leaked a topic"); assert.ok(!visitor.has("hidden")); // …while the admin list sees them. assert.ok(count.has("secret") && count.has("hidden")); }); it("EXCLUDE_TAGS removes a topic from the visitor list only", () => { initSite({ ASTROLABE_DATA: data, EXCLUDE_TAGS: "#Draft, zettel/seed" }); const visitor = new Set(tags(true).map((entry) => entry.tag)); assert.ok(!visitor.has("draft"), "EXCLUDE_TAGS is case-insensitive and #-tolerant"); assert.ok(!visitor.has("zettel/seed")); assert.ok(visitor.has("alpha"), "unrelated topics stay"); assert.ok(new Set(tags().map((e) => e.tag)).has("draft"), "the admin view is never filtered"); initSite({ ASTROLABE_DATA: data }); }); }); describe("ordering", () => { it("sorts by count, then alphabetically", () => { const list = tags(); for (let i = 1; i < list.length; i++) { const prev = list[i - 1]; const cur = list[i]; assert.ok( prev.count > cur.count || (prev.count === cur.count && prev.tag.localeCompare(cur.tag) <= 0), `out of order: ${prev.tag}(${prev.count}) before ${cur.tag}(${cur.count})`, ); } }); });