Skip to content

Commit 1b677aa

Browse files
TMHSDigitalclaude
andcommitted
site: stack check fixes from the starter stacks review
- The licence check also catches OFL, GPL, CC BY, cc0 in any case, public-domain and royalty free, as whole words; link targets are not scanned, so a path like x-cc0.md states nothing. - A byte-order mark or trailing spaces no longer break the frontmatter. - The build and the validator read the same list of stack files; a stack in a subfolder fails both instead of being checked but not built. - llms-full.txt gives each credit line, not just the entry name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ
1 parent ba8bf6e commit 1b677aa

5 files changed

Lines changed: 92 additions & 21 deletions

File tree

‎site/build.mjs‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntr
1414
import { checkPage } from "./lib/page-checks.mjs";
1515
import { checkStacks } from "./checks.mjs";
1616
import { stackPageHtml } from "./lib/stack-page.mjs";
17-
import { licenceTerms, owes, parseStack, pickPath } from "./lib/stacks.mjs";
17+
import { licenceTerms, listStackFiles, owes, parseStack, pickPath } from "./lib/stacks.mjs";
1818

1919
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2020
const ROOT = path.resolve(__dirname, "..");
@@ -24,7 +24,6 @@ const DIST = path.join(__dirname, "dist");
2424
const CONFIG_PATH = path.join(__dirname, "config.json");
2525
const VOCAB_PATH = path.join(__dirname, "license-vocabulary.json");
2626
const SPDX_ALLOWED_PATH = path.join(__dirname, "spdx-allowed.json");
27-
const STACKS = path.join(ROOT, "stacks");
2827
// Social preview image: a first-party screenshot of this site, kept with the
2928
// other first-party stills (the validator allows binaries there) and copied
3029
// into dist at build time.
@@ -401,15 +400,13 @@ function linkResolvers(entries, config, entryBase = "../") {
401400

402401
/** Reads stacks/*.md, checks them (V15) and resolves each pick to its entry. */
403402
function loadStacks(entries, vocab, spdxAllowed) {
404-
if (!fs.existsSync(STACKS)) return { stacks: [], errors: [] };
405-
const files = fs
406-
.readdirSync(STACKS)
407-
.filter((n) => n.endsWith(".md") && n !== "README.md")
408-
.sort()
409-
.map((n) => ({ rel: `stacks/${n}`, text: fs.readFileSync(path.join(STACKS, n), "utf8") }));
403+
const { files, stray } = listStackFiles(ROOT);
410404
const terms = licenceTerms(vocab, spdxAllowed);
411405
const byPath = new Map(entries.map((e) => [e.path, e]));
412-
const errors = checkStacks(files, byPath, terms, new Date().toISOString().slice(0, 10));
406+
const errors = [
407+
...stray.map((rel) => `${rel}: stacks live directly in stacks/; the build does not read subfolders`),
408+
...checkStacks(files, byPath, terms, new Date().toISOString().slice(0, 10)),
409+
];
413410
if (errors.length) return { stacks: [], errors };
414411
const stacks = files.map(({ rel, text }) => {
415412
const parsed = parseStack(text, { file: rel, terms });

‎site/lib/lib.test.mjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,33 @@ eq("bare url stops at a code span", renderInline("https://a.test/x`y`", passLink
332332
throws("malformed escape is a link error", () => resolve("bad%E0.md"), 'link target "bad%E0.md" is malformed');
333333
eq("query on a relative link is kept", resolve("../../docs/provenance.md?plain=1").href, "https://github.com/o/r/blob/main/docs/provenance.md?plain=1");
334334

335+
/* deferred minors, batch B ------------------------------------------------- */
336+
import fsTest from "node:fs";
337+
import osTest from "node:os";
338+
import pathTest from "node:path";
339+
import { listStackFiles } from "./stacks.mjs";
340+
eq("short name OFL", namedLicence("An OFL font.", []), "OFL");
341+
eq("short name GPL", namedLicence("a GPL editor", []), "GPL");
342+
eq("short name CC BY", namedLicence("CC BY 4.0 music", []), "CC BY");
343+
eq("lower-case cc0", namedLicence("a cc0 pack", []), "CC0");
344+
eq("royalty free without the hyphen", namedLicence("royalty free loops", []), "royalty free");
345+
eq("hyphenated public-domain", namedLicence("Public-domain art", []), "public-domain");
346+
eq("short names match whole words only", namedLicence("Wolf sprites and a gplus icon.", []), null);
347+
eq("link targets are not scanned", parseStack(stackMd.replace("Five loops.", "Five loops, see [x](../catalog/MIT/x.md)."), { file: "stacks/s1.md", terms: ["MIT", "CC0"] }).sections[1].picks[0].why.includes("MIT"), true);
348+
eq("a byte-order mark is ignored", parseStack(`${stackMd}`, { file: "stacks/s1.md", terms: [] }).meta.id, "s1");
349+
eq("trailing spaces on the fences are ignored", parseStack(stackMd.replace("---\nid", "--- \nid").replace("\n---\n\n#", "\n--- \n\n#"), { file: "stacks/s1.md", terms: [] }).meta.id, "s1");
350+
const tmpRoot = fsTest.mkdtempSync(pathTest.join(osTest.tmpdir(), "stacks-"));
351+
fsTest.mkdirSync(pathTest.join(tmpRoot, "stacks", "drafts"), { recursive: true });
352+
for (const [f, t] of [["stacks/b.md", "B"], ["stacks/a.md", "A"], ["stacks/README.md", "R"], ["stacks/drafts/c.md", "C"]]) fsTest.writeFileSync(pathTest.join(tmpRoot, f), t);
353+
const listed = listStackFiles(tmpRoot);
354+
eq("listStackFiles reads the top level, sorted, without README", listed.files.map((f) => f.rel).join(","), "stacks/a.md,stacks/b.md");
355+
eq("listStackFiles reports nested files", listed.stray.join(","), "stacks/drafts/c.md");
356+
eq("listStackFiles keeps the text", listed.files[0].text, "A");
357+
eq("listStackFiles without a stacks folder", listStackFiles(pathTest.join(tmpRoot, "nope")).files.length, 0);
358+
fsTest.rmSync(tmpRoot, { recursive: true, force: true });
359+
const lfsCredit = llmsFullTxt({ entries: llmsEntries, site, categories: cats, bodies: new Map(), stacks: [{ meta: { id: "s2", title: "Make Y", task: "A task.", walked: "2026-09-01" }, picked: [{ need: "Art", entry: { ...base, id: "b1", name: "Beta" } }] }] });
360+
has("full carries the credit line text", lfsCredit, 'Credits to ship:\n- Beta: Art by "X" </script>');
361+
335362
/* report (keep last) ------------------------------------------------------ */
336363
if (failures.length) {
337364
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/llms.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ export function llmsFullTxt({ entries, site, categories, bodies, resolverFor, st
6565
if (stacks.length) {
6666
out.push(`\n## Starter stacks\n`);
6767
for (const s of stacks) {
68-
const credits = owes(s.picked).credits.map((c) => c.entry.name);
68+
const credits = owes(s.picked).credits.map(
69+
(c) => `- ${c.entry.name}: ${c.line || "no canned credit line; see the entry page"}`
70+
);
6971
const picks = s.picked.map((p) => `- ${p.need}: ${p.entry.name} (${entryPageUrl(site, p.entry.id)}); ${facts(p.entry)}`);
7072
out.push(
71-
`### ${s.meta.title}\n\nPage: ${stackPageUrl(site, s.meta.id)}\nTask: ${s.meta.task}\nWalked: ${s.meta.walked}\n\n${picks.join("\n")}\n\nCredits to ship: ${credits.length ? credits.join(", ") : "none"}\n`
73+
`### ${s.meta.title}\n\nPage: ${stackPageUrl(site, s.meta.id)}\nTask: ${s.meta.task}\nWalked: ${s.meta.walked}\n\n${picks.join("\n")}\n\nCredits to ship:${credits.length ? `\n${credits.join("\n")}` : " none"}\n`
7274
);
7375
}
7476
}

‎site/lib/stacks.mjs‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* A stack names its picks; every licence fact on its page comes from the
44
* picked entries at build time, so a stack cannot disagree with an entry.
55
*/
6+
import fs from "node:fs";
67
import path from "node:path";
78
import { unquoteScalar } from "./shared.mjs";
89

@@ -15,7 +16,19 @@ const REQUIRED = ["id", "title", "task", "walked"];
1516
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1617
// - **Need:** [Entry name](path/to/entry.md). Why this pick.
1718
const PICK_RE = /^- \*\*([^*]+?):\*\* \[([^\]]+)\]\(([^)\s]+)\)\.\s+(\S.*)$/;
18-
const LICENCE_PHRASES = ["Creative Commons", "public domain", "royalty-free"];
19+
// Names and shorthands matched in any case, as whole words. The exact ids
20+
// from the vocabulary are matched case-sensitively in namedLicence.
21+
const LICENCE_PHRASES = [
22+
"Creative Commons",
23+
"public domain",
24+
"public-domain",
25+
"royalty-free",
26+
"royalty free",
27+
"CC BY",
28+
"CC0",
29+
"OFL",
30+
"GPL",
31+
];
1932
// Vocabulary keys that are ordinary words, not licence names.
2033
const PLAIN_WORD_IDS = new Set(["custom", "unknown", "varies"]);
2134

@@ -37,8 +50,9 @@ export function namedLicence(text, terms) {
3750
for (const t of terms) {
3851
if (new RegExp(`(^|[^\\w-])${escRe(t)}(?!\\w)`).test(text)) return t;
3952
}
40-
const lower = text.toLowerCase();
41-
for (const p of LICENCE_PHRASES) if (lower.includes(p.toLowerCase())) return p;
53+
for (const p of LICENCE_PHRASES) {
54+
if (new RegExp(`(^|[^\\w-])${escRe(p)}(?![\\w])`, "i").test(text)) return p;
55+
}
4256
return null;
4357
}
4458

@@ -55,16 +69,22 @@ export function pickPath(stackFile, href) {
5569
}
5670

5771
export function parseStack(text, { file, terms = [] }) {
58-
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
72+
// A byte-order mark and trailing spaces are invisible in an editor; ignore both.
73+
const lines = String(text)
74+
.replace(/^/, "")
75+
.replace(/\r\n?/g, "\n")
76+
.split("\n")
77+
.map((l) => l.replace(/\s+$/, ""));
5978
const fail = (i, msg) => {
6079
throw new StackError(`${file}:${i + 1}: ${msg}`);
6180
};
6281
if (lines[0] !== "---") fail(0, "a stack starts with frontmatter (---)");
6382
const end = lines.indexOf("---", 1);
6483
if (end === -1) fail(0, "frontmatter is not closed with ---");
6584
// Licence facts come only from the entries, so no prose in a stack may state one.
85+
// Link targets are paths, not prose: "../catalog/x-cc0.md" states nothing.
6686
const noLicence = (i, text, what) => {
67-
const named = namedLicence(text, terms);
87+
const named = namedLicence(text.replace(/\]\([^)]*\)/g, "]"), terms);
6888
if (named) fail(i, `the ${what} names a licence ("${named}"); licence facts come from the entry`);
6989
};
7090
const meta = {};
@@ -165,3 +185,29 @@ export function copyAllText(owed) {
165185
.map((c) => c.line)
166186
.join("\n");
167187
}
188+
189+
/**
190+
* The stack files under <root>/stacks: `files` are the top-level .md files
191+
* except README.md, sorted, as [{ rel, text }]; `stray` are .md files in
192+
* subfolders, which the build does not read. Build and validator share this.
193+
*/
194+
export function listStackFiles(root) {
195+
const dir = path.join(root, "stacks");
196+
const files = [];
197+
const stray = [];
198+
if (!fs.existsSync(dir)) return { files, stray };
199+
const walk = (d, rel) => {
200+
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
201+
const r = `${rel}/${ent.name}`;
202+
if (ent.isDirectory()) walk(path.join(d, ent.name), r);
203+
else if (ent.name.endsWith(".md") && ent.name !== "README.md") {
204+
if (rel === "stacks") files.push({ rel: r, text: fs.readFileSync(path.join(d, ent.name), "utf8") });
205+
else stray.push(r);
206+
}
207+
}
208+
};
209+
walk(dir, "stacks");
210+
files.sort((a, b) => a.rel.localeCompare(b.rel));
211+
stray.sort();
212+
return { files, stray };
213+
}

‎site/validate.mjs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
checkTaxonomyValues,
2020
checkValueSpellings,
2121
} from "./checks.mjs";
22-
import { licenceTerms } from "./lib/stacks.mjs";
22+
import { licenceTerms, listStackFiles } from "./lib/stacks.mjs";
2323

2424
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2525
const ROOT = path.resolve(__dirname, "..");
@@ -344,10 +344,9 @@ function main() {
344344
errors.push(...checkPublisherConsistency(records));
345345
errors.push(...checkValueSpellings(records));
346346

347-
// V15: starter stacks. README.md in stacks/ is the directory's guide, not a stack.
348-
const stackFiles = walkFiles(STACKS, [], (f) => f.endsWith(".md") && path.basename(f) !== "README.md").map(
349-
(f) => ({ rel: relFromRoot(f), text: fs.readFileSync(f, "utf8") })
350-
);
347+
// V15: starter stacks. The build reads the same list (lib/stacks.mjs).
348+
const { files: stackFiles, stray: strayStacks } = listStackFiles(ROOT);
349+
for (const rel of strayStacks) errors.push(`${rel}: stacks live directly in stacks/; the build does not read subfolders`);
351350
for (const { rel, text } of stackFiles) if (hasEmoji(text)) errors.push(`${rel} contains emoji`);
352351
const entriesByPath = new Map(records.map(({ rel, meta }) => [rel, { id: String(meta.id), status: String(meta.status) }]));
353352
errors.push(...checkStacks(stackFiles, entriesByPath, licenceTerms(vocab, [...spdxAllowed]), today));

0 commit comments

Comments
 (0)