Ctrl K
ComunidadGuías…

tests/tags.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<string, number>();

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