Skip to content

Commit fee00d5

Browse files
TMHSDigitalclaude
andcommitted
new-entry: draft an entry from a "New source" issue form
The New source form already collects the name, url, category, licence, dated quote, commercial and credit answers and the credit line, and a maintainer re-typed them by hand. `npm run new-entry -- --from-issue <n> [--id <id>]` reads the form with a read-only `gh issue view`. It maps the dropdowns to commercial and attribution_required, and keeps a licence only if it is in the vocabulary; anything else becomes unknown, with a warning. It fills license_spdx from the vocabulary and puts the reporter's quote in Evidence, marked as reported. The draft always starts needs-review, with subcategories, formats and tags empty, so validate fails until a maintainer has read the source and finished the entry. The form parsing and draft are pure (scaffold.mjs) and tested, including that the draft parses, has no unknown keys, and fails validate on the empty lists. Checked: a non-form issue is refused, and the classic mode still works. Closes #212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 25cf75b commit fee00d5

4 files changed

Lines changed: 200 additions & 14 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
3232
The website rebuilds from frontmatter on deploy. Node 22 or newer is all you need (CI and
3333
`.nvmrc` use 24); there are no dependencies to install.
3434

35-
1. Run `node site/new-entry.mjs <category> <id>` (or `npm run new-entry -- <category> <id>`). It copies [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md) to `catalog/<category>/<id>.md` with the id, category and today's date filled in, and refuses an id already used anywhere in the catalog. Use a short kebab-case `id`; a mixed kit already listed in another category is a duplicate, not a second entry.
35+
1. Run `node site/new-entry.mjs <category> <id>` (or `npm run new-entry -- <category> <id>`). It copies [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md) to `catalog/<category>/<id>.md` with the id, category and today's date filled in, and refuses an id already used anywhere in the catalog. Use a short kebab-case `id`; a mixed kit already listed in another category is a duplicate, not a second entry. Maintainers can start from a "New source" issue instead: `npm run new-entry -- --from-issue <number> [--id <id>]` reads the form with `gh` and writes a `needs-review` draft with the reporter's answers and quote. Subcategories, formats and tags are left empty, so validate fails until someone has read the source and finished the entry.
3636
2. Verify the license on the live source page the day you submit, and fill every frontmatter field from it (rules below). Prefer primary URLs over mirror/aggregator pages.
3737
3. Write the body: a one-paragraph summary, `## Notes`, `## Evidence` with a dated line quoting the source, such as `- Live page (2026-09-25): "Free for commercial use"`, and optionally `## Related` linking sibling entries. Every Evidence section needs at least one date, and `verified` may not be newer than the newest one. The template (and so the scaffold) starts at `status: needs-review`; set `active` once the licence, the commercial stance and the credit requirement are all settled.
3838
4. Add a row to the matching category `README.md`. This is required: the validator fails an entry that is not listed there, and the row's licence cell must match your frontmatter.

‎site/lib/lib.test.mjs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,53 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
12061206
has("guide: images refused", threw, "g.md:3: an image is not supported");
12071207
}
12081208

1209+
/* new-entry --from-issue (scaffold.mjs) ------------------------------------ */
1210+
{
1211+
const { parseIssueForm, entryFromIssue } = await import("./scaffold.mjs");
1212+
const { checkKnownKeys, checkEntryBasics } = await import("../checks.mjs");
1213+
const template = nodeFs.readFileSync(new URL("../../catalog/TEMPLATE.md", import.meta.url), "utf8");
1214+
const vocab = JSON.parse(nodeFs.readFileSync(new URL("../license-vocabulary.json", import.meta.url), "utf8"));
1215+
const body = [
1216+
"### Name", "", 'Sound "Pack" One', "",
1217+
"### Publisher URL", "", "https://sounds.example/pack", "",
1218+
"### Category", "", "audio", "",
1219+
"### License as stated on the live page", "", "CC-BY-4.0", "",
1220+
"### Licence quote and date", "", 'Live licence page (2026-10-30): "Use it in games, credit Sound Co."', "",
1221+
"### Commercial / game use", "", "Explicitly allowed", "",
1222+
"### Credit required?", "", "Yes", "",
1223+
"### Credit line as the source words it", "", "Sounds by Sound Co.", "",
1224+
"### Relationship to the source", "", "- [X] I publish or maintain this source (see CONTRIBUTING, \"Submitting your own source\")", "",
1225+
"### Why it belongs", "", "_No response_", "",
1226+
].join("\n");
1227+
const form = parseIssueForm(body);
1228+
eq("issue form: a field by its label", form["Publisher URL"], "https://sounds.example/pack");
1229+
eq("issue form: _No response_ is empty", form["Why it belongs"], "");
1230+
const { text, warnings } = entryFromIssue(template, form, { id: "sound-pack-one", issue: 42, today: "2026-11-03", vocab });
1231+
const parsed = parseFrontmatter(text);
1232+
eq("from-issue: the draft parses", parsed.errors.join(" | "), "");
1233+
const m = parsed.meta;
1234+
eq("from-issue: name with quotes survives", m.name, 'Sound "Pack" One');
1235+
eq("from-issue: licence in the vocabulary is kept", m.license, "CC-BY-4.0");
1236+
eq("from-issue: SPDX filled from the vocabulary", m.license_spdx, "CC-BY-4.0");
1237+
eq("from-issue: commercial from the dropdown", m.commercial, true);
1238+
eq("from-issue: credit from the dropdown", m.attribution_required, true);
1239+
eq("from-issue: credit line", m.attribution_string, "Sounds by Sound Co.");
1240+
eq("from-issue: verified is the quote's date", m.verified, "2026-10-30");
1241+
eq("from-issue: always starts needs-review", m.status, "needs-review");
1242+
eq("from-issue: no warnings for a clean form", warnings.length, 0);
1243+
has("from-issue: the quote is the Evidence line", parsed.body, '- Reported in #42: Live licence page (2026-10-30): "Use it in games, credit Sound Co."');
1244+
has("from-issue: a publisher submission is noted", parsed.body, "Submitted by the publisher (#42).");
1245+
eq("from-issue: no unknown keys", checkKnownKeys("d.md", m).length, 0);
1246+
has("from-issue: validate fails until lists are filled", checkEntryBasics("catalog/audio/sound-pack-one.md", m, parsed.body, { stem: "sound-pack-one", dirName: "audio", categories: new Set(["audio"]), readmeText: "(sound-pack-one.md)", today: "2026-11-03" }).join(" "), "missing/empty: subcategories, formats, tags");
1247+
const odd = entryFromIssue(template, { ...form, "License as stated on the live page": "Royalty free!", "Commercial / game use": "maybe", "Licence quote and date": "no date here" }, { id: "x", issue: 7, today: "2026-11-03", vocab });
1248+
const om = parseFrontmatter(odd.text).meta;
1249+
eq("from-issue: an unknown licence becomes unknown", om.license, "unknown");
1250+
eq("from-issue: no SPDX for unknown", om.license_spdx, undefined);
1251+
eq("from-issue: an unexpected dropdown answer is unknown", om.commercial, "unknown");
1252+
eq("from-issue: no quote date means verified today", om.verified, "2026-11-03");
1253+
eq("from-issue: both problems are warned about", odd.warnings.length, 2);
1254+
}
1255+
12091256
/* PR licence-field summary (licence-diff.mjs) ------------------------------ */
12101257
{
12111258
const { licenceChanges, licenceDiffMarkdown, newestEvidenceLine } = await import("./licence-diff.mjs");

‎site/lib/scaffold.mjs‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,100 @@ export function newEntryText(template, { id, category, today }) {
4949
// Set active once the licence is read and quoted.
5050
.replace(/^status: .*$/m, "status: needs-review");
5151
}
52+
53+
/* ------------------------------------------------ draft from a New source issue */
54+
55+
/**
56+
* The fields of a GitHub issue-form body, by label. Forms render each answer
57+
* as "### <label>" then the value; an empty answer is "_No response_".
58+
*/
59+
export function parseIssueForm(body) {
60+
const out = {};
61+
const parts = String(body || "").replace(/\r\n/g, "\n").split(/^### +/m).slice(1);
62+
for (const part of parts) {
63+
const nl = part.indexOf("\n");
64+
const label = (nl === -1 ? part : part.slice(0, nl)).trim();
65+
const value = (nl === -1 ? "" : part.slice(nl + 1)).trim();
66+
out[label] = value === "_No response_" ? "" : value;
67+
}
68+
return out;
69+
}
70+
71+
/** The New source form's dropdown answers, as frontmatter values. Anything else is "unknown". */
72+
export const FORM_COMMERCIAL = {
73+
"Explicitly allowed": true,
74+
"Explicitly forbidden": false,
75+
"Video/YouTube only (not games)": false,
76+
"Unclear / per-file": "unknown",
77+
};
78+
export const FORM_ATTRIBUTION = {
79+
Yes: true,
80+
No: false,
81+
"Credit appreciated but not required": false,
82+
Unclear: "unknown",
83+
};
84+
85+
/** A YAML double-quoted scalar. */
86+
const yamlString = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim()}"`;
87+
88+
/**
89+
* A draft entry from a parsed New source form. It always starts
90+
* `needs-review`, and subcategories, formats and tags are left empty, so
91+
* validate fails until a maintainer has read the source and filled them in.
92+
* A licence outside the vocabulary becomes `unknown`. The reporter's quote
93+
* goes into Evidence, marked as reported, for the maintainer to re-read.
94+
* Returns `{ text, warnings }`.
95+
*/
96+
export function entryFromIssue(template, form, { id, issue, today, vocab }) {
97+
const warnings = [];
98+
const name = form["Name"] || id;
99+
const url = form["Publisher URL"] || "";
100+
const category = form["Category"] || "";
101+
const reported = (form["License as stated on the live page"] || "").trim();
102+
const term = vocab.licenses?.[reported];
103+
const license = term ? reported : "unknown";
104+
if (!term) warnings.push(`licence "${reported}" is not in site/license-vocabulary.json; set to unknown`);
105+
const commercial = Object.prototype.hasOwnProperty.call(FORM_COMMERCIAL, form["Commercial / game use"]) ? FORM_COMMERCIAL[form["Commercial / game use"]] : "unknown";
106+
const attribution = Object.prototype.hasOwnProperty.call(FORM_ATTRIBUTION, form["Credit required?"]) ? FORM_ATTRIBUTION[form["Credit required?"]] : "unknown";
107+
const credit = (form["Credit line as the source words it"] || "").trim();
108+
const quote = (form["Licence quote and date"] || "").replace(/\s+/g, " ").trim();
109+
const quoteDate = quote.match(/\b(\d{4}-\d{2}-\d{2})\b/)?.[1];
110+
const verified = quoteDate && quoteDate <= today ? quoteDate : today;
111+
if (!quoteDate) warnings.push("the quote has no YYYY-MM-DD date; verified is set to today");
112+
const isPublisher = /- \[x\] I publish or maintain this source/i.test(form["Relationship to the source"] || "");
113+
114+
let fm = newEntryText(template, { id, category, today })
115+
.replace(/^name: .*$/m, `name: ${yamlString(name)}`)
116+
.replace(/^url: .*$/m, `url: ${url}`)
117+
.replace(/^subcategories: .*$/m, "subcategories: []")
118+
.replace(/^license: .*$/m, `license: ${yamlString(license)}`)
119+
.replace(/^commercial: .*$/m, `commercial: ${commercial}`)
120+
.replace(/^attribution_required: .*$/m, `attribution_required: ${attribution}${credit ? `\nattribution_string: ${yamlString(credit)}` : ""}`)
121+
.replace(/^formats: .*$/m, "formats: []")
122+
.replace(/^tags: .*$/m, "tags: []")
123+
.replace(/^verified: .*$/m, `verified: ${verified}`);
124+
fm = term?.spdx ? fm.replace(/^license_spdx: .*$/m, `license_spdx: ${term.spdx}`) : fm.replace(/^license_spdx: .*\n/m, "");
125+
126+
const head = fm.slice(0, fm.indexOf("\n---", 4) + 4);
127+
const notes = [
128+
`- Drafted from #${issue}. Every field below came from the reporter: re-read the source before setting \`active\`.`,
129+
...(isPublisher ? [`- Submitted by the publisher (#${issue}).`] : []),
130+
...((form["Why it belongs"] || "").trim() ? [`- Reporter's note: ${form["Why it belongs"].replace(/\s+/g, " ").trim()}`] : []),
131+
];
132+
const body = [
133+
"",
134+
`# ${name}`,
135+
"",
136+
"One-paragraph summary: what you get, why it's useful, any caveats.",
137+
"",
138+
"## Notes",
139+
"",
140+
...notes,
141+
"",
142+
"## Evidence",
143+
"",
144+
quote ? `- Reported in #${issue}: ${quote}` : `- Reported in #${issue} without a quote: read the licence page and quote it here.`,
145+
"",
146+
];
147+
return { text: `${head}\n${body.join("\n")}`, warnings };
148+
}

‎site/new-entry.mjs‎

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55
* Writes catalog/<category>/<id>.md with the id, category and today's date
66
* filled in, then prints the steps that still need a person: the licence
77
* research, the category README row, and the checks.
8+
*
9+
* Or drafts one from a "New source" issue form (read-only `gh issue view`):
10+
* node site/new-entry.mjs --from-issue <number> [--id <id>]
11+
* The draft starts needs-review with empty subcategories, formats and tags,
12+
* so validate fails until a maintainer has read the source and finished it.
813
*/
14+
import { execFileSync } from "node:child_process";
915
import fs from "node:fs";
1016
import path from "node:path";
1117
import { fileURLToPath } from "node:url";
12-
import { newEntryProblem, newEntryText } from "./lib/scaffold.mjs";
18+
import { entryFromIssue, newEntryProblem, newEntryText, parseIssueForm } from "./lib/scaffold.mjs";
19+
import { slugOf } from "./lib/search.mjs";
1320

1421
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1522
const ROOT = path.resolve(__dirname, "..");
@@ -20,29 +27,64 @@ function fail(msg) {
2027
process.exit(1);
2128
}
2229

23-
const [category, id] = process.argv.slice(2);
30+
function flag(name) {
31+
const i = process.argv.indexOf(name);
32+
return i === -1 ? null : process.argv[i + 1] || "";
33+
}
34+
2435
const config = JSON.parse(fs.readFileSync(path.join(__dirname, "config.json"), "utf8"));
36+
const today = new Date().toISOString().slice(0, 10);
37+
const template = fs.readFileSync(path.join(CATALOG, "TEMPLATE.md"), "utf8");
2538
// Ids are unique across the whole catalog, not per category.
26-
const taken = id
27-
? fs.readdirSync(CATALOG, { withFileTypes: true }).find((d) => d.isDirectory() && fs.existsSync(path.join(CATALOG, d.name, `${id}.md`)))?.name
28-
: undefined;
29-
const problem = newEntryProblem({ category, id, categories: Object.keys(config.categories || {}), taken });
30-
if (problem) fail(problem);
39+
const takenBy = (id) =>
40+
id ? fs.readdirSync(CATALOG, { withFileTypes: true }).find((d) => d.isDirectory() && fs.existsSync(path.join(CATALOG, d.name, `${id}.md`)))?.name : undefined;
3141

32-
const today = new Date().toISOString().slice(0, 10);
33-
const text = newEntryText(fs.readFileSync(path.join(CATALOG, "TEMPLATE.md"), "utf8"), { id, category, today });
42+
let category;
43+
let id;
44+
let text;
45+
let fromIssue = null;
46+
const warnings = [];
47+
48+
const issueArg = flag("--from-issue");
49+
if (issueArg !== null) {
50+
if (!/^\d+$/.test(issueArg)) fail("usage: node site/new-entry.mjs --from-issue <number> [--id <id>]");
51+
const repo = String(config.site?.repo || "").replace(/^https:\/\/github\.com\//, "").replace(/\/+$/, "");
52+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) fail(`site/config.json site.repo is not a GitHub repository URL: ${config.site?.repo}`);
53+
let issue;
54+
try {
55+
issue = JSON.parse(execFileSync("gh", ["issue", "view", issueArg, "--repo", repo, "--json", "number,title,body,labels"], { encoding: "utf8" }));
56+
} catch (err) {
57+
fail(`could not read issue #${issueArg} with gh (is it installed and signed in?): ${String(err.stderr || err.message).trim()}`);
58+
}
59+
const form = parseIssueForm(issue.body);
60+
if (!form["Publisher URL"] || !form["Category"]) fail(`issue #${issue.number} is not a "New source" form (no Publisher URL or Category answer)`);
61+
category = form["Category"];
62+
id = flag("--id") || slugOf(form["Name"] || "");
63+
fromIssue = issue.number;
64+
const vocab = JSON.parse(fs.readFileSync(path.join(__dirname, "license-vocabulary.json"), "utf8"));
65+
const problem = newEntryProblem({ category, id, categories: Object.keys(config.categories || {}), taken: takenBy(id) });
66+
if (problem) fail(`${problem}${flag("--id") ? "" : " (pass --id <id> to choose another)"}`);
67+
const draft = entryFromIssue(template, form, { id, issue: issue.number, today, vocab });
68+
text = draft.text;
69+
warnings.push(...draft.warnings);
70+
} else {
71+
[category, id] = process.argv.slice(2);
72+
const problem = newEntryProblem({ category, id, categories: Object.keys(config.categories || {}), taken: takenBy(id) });
73+
if (problem) fail(problem);
74+
text = newEntryText(template, { id, category, today });
75+
}
3476

3577
const rel = `catalog/${category}/${id}.md`;
3678
// A category can be in config.json before its folder exists.
3779
fs.mkdirSync(path.join(CATALOG, category), { recursive: true });
3880
fs.writeFileSync(path.join(ROOT, rel), text);
3981

40-
console.log(`wrote ${rel}
82+
for (const w of warnings) console.log(`warning: ${w}`);
83+
console.log(`wrote ${rel}${fromIssue ? ` (draft from #${fromIssue})` : ""}
4184
4285
Next:
43-
1. Fill every frontmatter field from the live source page (CONTRIBUTING.md,
44-
"Frontmatter rules"), and replace the example body. Quote the licence in
45-
## Evidence with today's date: (${today}).
86+
1. ${fromIssue ? "Read the live source yourself and check every field the reporter gave; fill" : "Fill every frontmatter field from the live source page (CONTRIBUTING.md,\n \"Frontmatter rules\"), and replace the example body. Fill"} subcategories,
87+
formats and tags. Quote the licence in ## Evidence with today's date: (${today}).
4688
2. Add a row for [${id}.md](${id}.md) to catalog/${category}/README.md.
4789
3. node site/sync-counts.mjs (README counts, badge, expectedEntryCount)
4890
4. npm test && npm run validate (quick; every message names ${rel} by

0 commit comments

Comments
 (0)