Skip to content

Commit d1f52c7

Browse files
TMHSDigitalclaude
andcommitted
site: stack page template, owed list and Copy script
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ
1 parent 10151a9 commit d1f52c7

7 files changed

Lines changed: 444 additions & 2 deletions

File tree

‎site/lib/lib.test.mjs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,63 @@ eq("licence in the label is not checked", parseStack(stackMd.replace("[Chips]",
250250
eq("pickPath resolves from stacks/", pickPath("stacks/s1.md", "../catalog/2d/x.md"), "catalog/2d/x.md");
251251
eq("section order constant", STACK_SECTIONS.join(","), "Art,Audio,Fonts,Tools,Gaps");
252252

253+
/* stacks: owes and page ---------------------------------------------------- */
254+
import { copyAllText, owes } from "./stacks.mjs";
255+
import { stackJsonLd, stackPageHtml } from "./stack-page.mjs";
256+
import { stackPageUrl } from "./shared.mjs";
257+
const resolveDeep = makeLinkResolver({ entryPath: "stacks/s1.md", idByPath, repo: "https://github.com/o/r", kindOf: (p) => files.get(p) || null, entryBase: "../../entry/" });
258+
eq("stack links reach entry pages two levels up", resolveDeep("../catalog/2d/kenney-ui-pack.md").href, "../../entry/kenney-ui-pack/");
259+
eq("default entry base unchanged", resolve("kenney-ui-pack.md").href, "../kenney-ui-pack/");
260+
eq("stackPageUrl", stackPageUrl({ siteUrl: "https://x.test/s/" }, "s1"), "https://x.test/s/stack/s1/");
261+
const eA = { ...base, id: "a", name: "Alpha", attribution_required: true, attribution_string: 'Art by "A" <x>', status: "active", commercial: true };
262+
const eB = { ...base, id: "b", name: "Beta", attribution_required: true, attribution_string: "Music by B", status: "active", commercial: true };
263+
const eC = { ...base, id: "c", name: "Gamma", attribution_required: false, attribution_string: undefined, commercial: "varies", status: "needs-review" };
264+
const eD = { ...base, id: "d", name: "Delta", attribution_required: "unknown", attribution_string: undefined, status: "active" };
265+
const eE = { ...base, id: "e", name: "Eps", attribution_required: true, attribution_string: undefined, status: "active" };
266+
const pickedFix = [{ need: "Tiles", entry: eA }, { need: "Music", entry: eB }, { need: "Sprites", entry: eA }, { need: "Sounds", entry: eC }, { need: "Font", entry: eD }, { need: "Icons", entry: eE }];
267+
const ow = owes(pickedFix);
268+
eq("credits once per entry", ow.credits.map((c) => c.entry.id).join(","), "a,b,e");
269+
eq("an entry picked twice keeps both needs", ow.credits[0].needs.join(","), "Tiles,Sprites");
270+
eq("a required credit with no canned line", ow.credits[2].line, null);
271+
eq("no credit bucket", ow.noCredit.map((i) => i.entry.id).join(","), "c");
272+
eq("per-file bucket", ow.perFile.map((i) => i.entry.id).join(","), "c");
273+
eq("open questions bucket", ow.openQuestions.map((i) => i.entry.id).join(","), "c");
274+
eq("unclear bucket", ow.unclear.map((i) => i.entry.id).join(","), "d");
275+
eq("copy all joins canned lines in pick order", copyAllText(ow), 'Art by "A" <x>\nMusic by B');
276+
const stackMeta = { id: "s1", title: 'Make a "thing" </script>', task: "A task <b>.", walked: "2026-09-20" };
277+
const sp = (over = {}) =>
278+
stackPageHtml({
279+
stack: { meta: stackMeta },
280+
sections: [{ name: "Art", rows: [{ need: "Tiles", entry: eA, whyHtml: "Why <em>a</em>." }, { need: "Sounds", entry: eC, whyHtml: "Why c." }] }],
281+
gapsHtml: ["No parallax."],
282+
leadHtml: "<p>Lead</p>",
283+
owed: ow,
284+
site,
285+
stamp: "2026-09-24",
286+
total: 319,
287+
hasCard: true,
288+
now: Date.parse("2026-09-24T00:00:00Z"),
289+
...over,
290+
});
291+
const spage = sp();
292+
has("stack title escaped", spage, "<title>Make a &quot;thing&quot; &lt;/script&gt; | Free Game Dev Assets</title>");
293+
has("stack canonical", spage, '<link rel="canonical" href="https://x.test/s/stack/s1/" />');
294+
eq("stack one h1", (spage.match(/<h1[\s>]/g) || []).length, 1);
295+
has("stack pick links its entry page", spage, 'href="../../entry/a/"');
296+
has("stack credit line escaped", spage, "Art by &quot;A&quot; &lt;x&gt;");
297+
has("copy all carries every canned line", spage, 'data-copy="Art by &quot;A&quot; &lt;x&gt;\nMusic by B"');
298+
has("no canned line note", spage, "No canned credit line: see the entry.");
299+
has("per-file note", spage, "Some files qualify, some do not.");
300+
has("needs-review flag on the pick", spage, 'class="pick-flag"');
301+
has("gaps rendered", spage, "<li>No parallax.</li>");
302+
has("walked line", spage, "Stack walked 2026-09-20. Each licence is only as current as its entry's verified date.");
303+
has("copy script with canned lines", spage, '<script src="../../stack.js" defer></script>');
304+
lacks("no copy script without canned lines", sp({ owed: owes([{ need: "Sounds", entry: eC }]) }), "stack.js");
305+
const sld = spage.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1];
306+
lacks("stack json-ld cannot close its script", sld, "</script");
307+
eq("stack json-ld lists the picks", JSON.parse(sld).itemListElement[0].url, "https://x.test/s/entry/a/");
308+
eq("stack json-ld name survives", stackJsonLd(stackMeta, [{ rows: [] }], site).name, 'Make a "thing" </script>');
309+
253310
/* report (keep last) ------------------------------------------------------ */
254311
if (failures.length) {
255312
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/links.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "node:path";
33

44
export class LinkError extends Error {}
55

6-
export function makeLinkResolver({ entryPath, idByPath, repo, kindOf }) {
6+
export function makeLinkResolver({ entryPath, idByPath, repo, kindOf, entryBase = "../" }) {
77
const dir = path.posix.dirname(entryPath);
88
return (raw) => {
99
if (/^(https?:|mailto:)/i.test(raw)) return { href: raw, external: true };
@@ -13,7 +13,7 @@ export function makeLinkResolver({ entryPath, idByPath, repo, kindOf }) {
1313
const hash = at === -1 ? "" : raw.slice(at);
1414
const repoPath = path.posix.normalize(path.posix.join(dir, decodeURI(target))).replace(/\/$/, "");
1515
const id = idByPath.get(repoPath);
16-
if (id) return { href: `../${id}/${hash}`, external: false };
16+
if (id) return { href: `${entryBase}${id}/${hash}`, external: false };
1717
const kind = repoPath.startsWith("..") ? null : kindOf(repoPath);
1818
if (!kind) throw new LinkError(`${entryPath}: link target "${raw}" does not exist`);
1919
const mode = kind === "dir" ? "tree" : "blob";

‎site/lib/shared.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,8 @@ export function unquoteScalar(v) {
5858
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replaceAll("''", "'");
5959
return v;
6060
}
61+
62+
/** Absolute URL of a starter stack's page. */
63+
export function stackPageUrl(site, id) {
64+
return `${String(site.siteUrl).replace(/\/+$/, "")}/stack/${id}/`;
65+
}

‎site/lib/stack-page.mjs‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/** A starter stack's page. Pure: everything it needs is passed in. */
2+
import { scriptJson } from "./entry-page.mjs";
3+
import {
4+
commercialLabel,
5+
entryPageUrl,
6+
esc,
7+
STATUS_NOTES,
8+
stackPageUrl,
9+
verifiedAge,
10+
} from "./shared.mjs";
11+
import { copyAllText } from "./stacks.mjs";
12+
13+
export function stackJsonLd(meta, sections, site) {
14+
const rows = sections.flatMap((s) => s.rows);
15+
return {
16+
"@context": "https://schema.org",
17+
"@type": "ItemList",
18+
name: meta.title,
19+
description: meta.task,
20+
url: stackPageUrl(site, meta.id),
21+
numberOfItems: rows.length,
22+
itemListElement: rows.map((r, i) => ({
23+
"@type": "ListItem",
24+
position: i + 1,
25+
name: `${r.need}: ${r.entry.name}`,
26+
url: entryPageUrl(site, r.entry.id),
27+
})),
28+
};
29+
}
30+
31+
const entryLink = (entry) => `<a href="../../entry/${esc(entry.id)}/">${esc(entry.name)}</a>`;
32+
const names = (items) => items.map((i) => entryLink(i.entry)).join(", ");
33+
34+
function owesHtml(owed) {
35+
const groups = [];
36+
if (owed.credits.length) {
37+
const items = owed.credits
38+
.map((c, n) => {
39+
const label = `<p class="attribution-label">${esc(c.needs.join(", "))}: ${entryLink(c.entry)}</p>`;
40+
if (!c.line) {
41+
return `<li class="credit-item">${label}<p class="attribution-note">No canned credit line: see the entry.</p></li>`;
42+
}
43+
return `<li class="credit-item">${label}
44+
<p class="attribution-string" id="credit-${n}">${esc(c.line)}</p>
45+
<button type="button" class="btn-ghost" data-copy="${esc(c.line)}" data-target="credit-${n}" hidden>Copy credit line</button>
46+
<span class="copy-status" role="status" aria-live="polite"></span></li>`;
47+
})
48+
.join("\n");
49+
const all = copyAllText(owed);
50+
const allButton = all
51+
? `<p class="copy-all"><button type="button" class="btn-ghost" data-copy="${esc(all)}" data-target="credits-list" hidden>Copy all credits</button>
52+
<span class="copy-status" role="status" aria-live="polite"></span></p>`
53+
: "";
54+
groups.push(`<div class="owes-group"><h3>Credits to ship</h3><ul class="credit-list" id="credits-list">${items}</ul>${allButton}</div>`);
55+
}
56+
if (owed.noCredit.length) {
57+
const n = owed.noCredit.length;
58+
groups.push(`<div class="owes-group"><h3>No credit needed</h3><p>${n} ${n === 1 ? "pick" : "picks"}: ${names(owed.noCredit)}.</p></div>`);
59+
}
60+
if (owed.perFile.length) {
61+
groups.push(`<div class="owes-group"><h3>Check each file</h3><p>Some files qualify, some do not. Check each file you take from ${names(owed.perFile)}.</p></div>`);
62+
}
63+
if (owed.openQuestions.length) {
64+
const items = owed.openQuestions.map((i) => `<li>${entryLink(i.entry)}: ${esc(STATUS_NOTES["needs-review"])}</li>`).join("");
65+
groups.push(`<div class="owes-group"><h3>Open questions</h3><ul>${items}</ul></div>`);
66+
}
67+
if (owed.unclear.length) {
68+
groups.push(`<div class="owes-group"><h3>Unclear credit</h3><p>Read the Notes on ${names(owed.unclear)}.</p></div>`);
69+
}
70+
return groups.join("\n ");
71+
}
72+
73+
function rowHtml(r, now) {
74+
const age = verifiedAge(r.entry.verified, now);
75+
const ageText = age.days === null ? "verified date unknown" : `verified ${r.entry.verified} (${age.days}d ago)`;
76+
const flag = r.entry.status === "needs-review" ? ' <span class="pick-flag">needs review</span>' : "";
77+
return `<li class="pick">
78+
<p class="pick-need">${esc(r.need)}</p>
79+
<p class="pick-name">${entryLink(r.entry)}${flag}</p>
80+
<p class="pick-licence">${esc(r.entry.license)} &middot; ${esc(commercialLabel(r.entry.commercial))} &middot; <span class="verified is-${age.bucket}">${esc(ageText)}</span></p>
81+
<p class="pick-why">${r.whyHtml}</p>
82+
</li>`;
83+
}
84+
85+
export function stackPageHtml({ stack, sections, gapsHtml, leadHtml, owed, site, stamp, total, hasCard, now }) {
86+
const { meta } = stack;
87+
const repo = site.repo;
88+
const url = stackPageUrl(site, meta.id);
89+
const title = `${meta.title} | ${site.title}`;
90+
const card = `${String(site.siteUrl).replace(/\/+$/, "")}/og-card.png`;
91+
const cardMeta = hasCard
92+
? `<meta property="og:image" content="${esc(card)}" />
93+
<meta name="twitter:card" content="summary_large_image" />
94+
<meta name="twitter:image" content="${esc(card)}" />`
95+
: `<meta name="twitter:card" content="summary" />`;
96+
const sectionHtml = sections
97+
.map(
98+
(s) => `<section class="stack-section" aria-labelledby="sec-${esc(s.name.toLowerCase())}">
99+
<h2 id="sec-${esc(s.name.toLowerCase())}">${esc(s.name)}</h2>
100+
<ul class="pick-list">
101+
${s.rows.map((r) => rowHtml(r, now)).join("\n ")}
102+
</ul>
103+
</section>`
104+
)
105+
.join("\n ");
106+
const gaps = gapsHtml.length
107+
? `<section class="stack-section" aria-labelledby="sec-gaps">
108+
<h2 id="sec-gaps">Gaps</h2>
109+
<ul class="gap-list">${gapsHtml.map((g) => `<li>${g}</li>`).join("")}</ul>
110+
</section>`
111+
: "";
112+
const script = copyAllText(owed) ? '<script src="../../stack.js" defer></script>' : "";
113+
return `<!DOCTYPE html>
114+
<html lang="en">
115+
<head>
116+
<meta charset="utf-8" />
117+
<meta name="viewport" content="width=device-width, initial-scale=1" />
118+
<title>${esc(title)}</title>
119+
<meta name="description" content="${esc(meta.task)}" />
120+
<link rel="canonical" href="${esc(url)}" />
121+
<meta property="og:type" content="article" />
122+
<meta property="og:site_name" content="${esc(site.title)}" />
123+
<meta property="og:title" content="${esc(title)}" />
124+
<meta property="og:description" content="${esc(meta.task)}" />
125+
<meta property="og:url" content="${esc(url)}" />
126+
${cardMeta}
127+
<meta name="twitter:title" content="${esc(title)}" />
128+
<meta name="twitter:description" content="${esc(meta.task)}" />
129+
<link rel="icon" href="../../favicon.svg" type="image/svg+xml" />
130+
<link rel="preconnect" href="https://fonts.googleapis.com" />
131+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
132+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
133+
<link rel="stylesheet" href="../../styles.css" />
134+
<script type="application/ld+json">${scriptJson(stackJsonLd(meta, sections, site))}</script>
135+
</head>
136+
<body>
137+
<a class="skip-link" href="#content">Skip to stack</a>
138+
<header class="topbar">
139+
<a class="brand" href="../../">Free Game Dev Assets</a>
140+
<nav class="topnav" aria-label="Primary">
141+
<a href="../../#catalog">Catalog</a>
142+
<a href="../../#stacks">Stacks</a>
143+
<a href="../../#starters">Starters</a>
144+
<a href="../../#guides">Guides</a>
145+
<a href="${esc(repo)}" rel="noopener noreferrer">GitHub</a>
146+
</nav>
147+
</header>
148+
<main class="section entry-page stack-page" id="content">
149+
<nav class="breadcrumb" aria-label="Breadcrumb">
150+
<a href="../../#catalog">Catalog</a> <span aria-hidden="true">/</span>
151+
<a href="../../#stacks">Stacks</a> <span aria-hidden="true">/</span>
152+
<span aria-current="page">${esc(meta.title)}</span>
153+
</nav>
154+
<h1>${esc(meta.title)}</h1>
155+
<div class="entry-lead"><p>${esc(meta.task)}</p>${leadHtml}</div>
156+
<section class="owes" aria-labelledby="owes-title">
157+
<h2 id="owes-title">What this stack owes</h2>
158+
${owesHtml(owed)}
159+
</section>
160+
${sectionHtml}
161+
${gaps}
162+
<p class="stack-note">Stack walked ${esc(meta.walked)}. Each licence is only as current as its entry's verified date.</p>
163+
</main>
164+
<footer class="footer">
165+
<p>Catalog metadata is CC0. Linked assets keep their own licenses: re-check the live source before shipping.</p>
166+
<p class="footer-stamp">Built ${esc(stamp)} from ${total} catalog entries.</p>
167+
</footer>
168+
${script}
169+
</body>
170+
</html>
171+
`;
172+
}

‎site/lib/stacks.mjs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,32 @@ export function parseStack(text, { file, terms = [] }) {
125125
gaps,
126126
};
127127
}
128+
129+
/**
130+
* What the picks owe, one row per entry (an entry picked for two needs is
131+
* listed once with both). `picked` is [{ need, entry }] with payload entries.
132+
*/
133+
export function owes(picked) {
134+
const byId = new Map();
135+
for (const { need, entry } of picked) {
136+
if (byId.has(entry.id)) byId.get(entry.id).needs.push(need);
137+
else byId.set(entry.id, { entry, needs: [need] });
138+
}
139+
const items = [...byId.values()];
140+
const credit = (i) => i.entry.attribution_required;
141+
return {
142+
credits: items.filter((i) => credit(i) === true).map((i) => ({ ...i, line: i.entry.attribution_string || null })),
143+
noCredit: items.filter((i) => credit(i) === false),
144+
unclear: items.filter((i) => credit(i) !== true && credit(i) !== false),
145+
perFile: items.filter((i) => i.entry.commercial === "varies"),
146+
openQuestions: items.filter((i) => i.entry.status === "needs-review"),
147+
};
148+
}
149+
150+
/** Every canned credit line, one per line, in pick order. */
151+
export function copyAllText(owed) {
152+
return owed.credits
153+
.filter((c) => c.line)
154+
.map((c) => c.line)
155+
.join("\n");
156+
}

‎site/public/stack.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** Copy buttons on a stack page. The page works without them. */
2+
(() => {
3+
for (const btn of document.querySelectorAll("button[data-copy]")) {
4+
btn.hidden = false;
5+
btn.addEventListener("click", async () => {
6+
const status = btn.parentElement.querySelector(".copy-status");
7+
try {
8+
await navigator.clipboard.writeText(btn.getAttribute("data-copy"));
9+
status.textContent = "Copied";
10+
} catch {
11+
// Clipboard access can be refused; select the text so it can be
12+
// copied by hand rather than failing silently.
13+
const target = document.getElementById(btn.getAttribute("data-target"));
14+
const range = document.createRange();
15+
range.selectNodeContents(target);
16+
const sel = getSelection();
17+
sel.removeAllRanges();
18+
sel.addRange(range);
19+
status.textContent = "Select and copy";
20+
}
21+
});
22+
}
23+
})();

0 commit comments

Comments
 (0)