Skip to content

Commit cd6865d

Browse files
TMHSDigitalclaude
andcommitted
Licence-change feeds per entry, and one OPML for the whole shortlist (#186)
- lib/changes.mjs reads the catalog's git history once (with patches, following moves by file name) for changes to license, commercial, attribution_required and status; a file's creating commit is not a change. 136 changes on 80 entries today - the build writes entry/<id>/changes.xml (Atom, newest first; an entry with no changes is a valid empty feed), linked from each entry page - the shortlist panel's "Follow licence changes (OPML)" bundles the shortlisted entries' feeds for a feed reader, in one step - build.mjs: one gitCached() helper (per-HEAD cache inside .git) now serves both the sitemap dates and the change history Checked in a browser: the OPML lists each shortlisted feed; a feed shows its recorded changes. Closes #186 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent f846f4c commit cd6865d

7 files changed

Lines changed: 185 additions & 13 deletions

File tree

‎site/build.mjs‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { licenceTerms, listStackFiles, owes, parseStack, pickPath } from "./lib/
3939
import { createDistWriter } from "./lib/dist-writer.mjs";
4040
import { serviceWorkerJs, webManifest } from "./lib/offline.mjs";
4141
import { auditPageHtml } from "./lib/audit-page.mjs";
42+
import { changeEventsFromLog, changesFeedXml } from "./lib/changes.mjs";
4243
import { ATTRIBUTION_RANK, categoryChipsHtml, guideRowsHtml, headMetaHtml, licenseFamily, notFoundHtml, robotsTxt, stackRowsHtml, starterRowsHtml } from "./lib/home-page.mjs";
4344

4445
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -141,31 +142,48 @@ function loadEntries(vocab) {
141142
* git is unavailable (a tarball); a shallow checkout dates every file to HEAD.
142143
*/
143144
function lastCommitDates() {
145+
// No git: the sitemap falls back to the build date.
146+
const dates = gitCached("fgda-commit-dates.json", DATED_PATHS.join(","), (git) =>
147+
Object.fromEntries(commitDatesFromLog(git(["log", "--format=%x00%cs", "--name-only", "--", ...DATED_PATHS])))
148+
);
149+
return new Map(Object.entries(dates || {}));
150+
}
151+
152+
/** Entry id => its recorded licence changes (lib/changes.mjs, #186); empty without git. */
153+
function licenceChanges() {
154+
const events = gitCached("fgda-licence-changes.json", "catalog", (git) =>
155+
Object.fromEntries(changeEventsFromLog(git(["log", "--reverse", "-p", "-M", "--unified=0", "--format=%x00%H %cs", "--", "catalog"])))
156+
);
157+
return new Map(Object.entries(events || {}));
158+
}
159+
160+
/**
161+
* `compute(git)` over the commits, cached per HEAD inside .git (never
162+
* published, never committed): a no-change rebuild skips full-history logs,
163+
* whose cost grows with every commit (#262). null when git is unavailable.
164+
*/
165+
function gitCached(name, keyExtra, compute) {
144166
const git = (args) =>
145-
execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
167+
execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
146168
try {
147-
// The dates depend only on the commits, so they are cached per HEAD inside
148-
// .git (never published, never committed): a no-change rebuild skips the
149-
// full-history log, whose cost grows with every commit (#262).
150169
const [gitDir, head] = git(["rev-parse", "--git-dir", "HEAD"]).trim().split("\n");
151-
const cacheFile = path.resolve(ROOT, gitDir, "fgda-commit-dates.json");
152-
const key = `${head} ${DATED_PATHS.join(",")}`;
170+
const cacheFile = path.resolve(ROOT, gitDir, name);
171+
const key = `${head} ${keyExtra}`;
153172
try {
154173
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
155-
if (cached.key === key) return new Map(Object.entries(cached.dates));
174+
if (cached.key === key && "value" in cached) return cached.value;
156175
} catch {
157176
// No cache yet, or an unreadable one: rebuild it below.
158177
}
159-
const dates = commitDatesFromLog(git(["log", "--format=%x00%cs", "--name-only", "--", ...DATED_PATHS]));
178+
const value = compute(git);
160179
try {
161-
fs.writeFileSync(cacheFile, JSON.stringify({ key, dates: Object.fromEntries(dates) }));
180+
fs.writeFileSync(cacheFile, JSON.stringify({ key, value }));
162181
} catch {
163182
// A read-only .git still builds; it just reads the log every time.
164183
}
165-
return dates;
184+
return value;
166185
} catch {
167-
// No git: the sitemap falls back to the build date.
168-
return new Map();
186+
return null;
169187
}
170188
}
171189

@@ -578,6 +596,11 @@ function main() {
578596
writeOut(path.join("audit", "index.html"), auditPageHtml({ site: config.site, stamp, total: stats.listed, hasCard }));
579597

580598
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn });
599+
// One licence-change feed per entry, beside its page (#186).
600+
const changes = licenceChanges();
601+
for (const entry of entries) {
602+
writeOut(path.join("entry", entry.id, "changes.xml"), changesFeedXml({ entry, events: changes.get(entry.id) || [], siteUrl: config.site.siteUrl, generatedAt: payload.generatedAt }));
603+
}
581604
const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now });
582605
const categoryPages = writeCategoryPages({ entries: visible, config, stats, stamp, hasCard, now });
583606
const guidePages = writeGuidePages({ entries, config, stats, stamp, hasCard, modified });

‎site/lib/changes.mjs‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Licence changes per entry, from git history (#186): when an entry's
3+
* `license`, `commercial`, `attribution_required` or `status` changed, and
4+
* from what to what. The build publishes them as one Atom feed per entry
5+
* (entry/<id>/changes.xml), and the shortlist bundles its entries' feeds
6+
* as OPML, so a reader can follow the terms of the assets their game uses.
7+
* Pure; build.mjs runs git.
8+
*/
9+
import { esc } from "./shared.mjs";
10+
11+
export const WATCHED_FIELDS = ["license", "commercial", "attribution_required", "status"];
12+
const FIELD_RE = new RegExp(`^([+-])(${WATCHED_FIELDS.join("|")}):\\s*(.*?)\\s*$`);
13+
14+
/**
15+
* Entry id => [{ date, commit, field, from, to }], oldest first, from
16+
* `git log --reverse -p -M --unified=0 --format=%x00%H %cs -- catalog`.
17+
* Entries are keyed by file name, so a category move keeps its history. A
18+
* file's first commit is its creation, not a change, and is skipped.
19+
*/
20+
export function changeEventsFromLog(log) {
21+
const events = new Map();
22+
const seen = new Set();
23+
let commit = null;
24+
let date = null;
25+
let id = null;
26+
let isNew = false;
27+
let removed = {};
28+
const flush = () => {
29+
removed = {};
30+
};
31+
for (const line of String(log).split("\n")) {
32+
if (line.startsWith("\0")) {
33+
[commit, date] = line.slice(1).trim().split(" ");
34+
id = null;
35+
flush();
36+
continue;
37+
}
38+
if (line.startsWith("diff --git ")) {
39+
const m = line.match(/ b\/catalog\/[^/]+\/([^/]+)\.md$/);
40+
id = m && m[1] !== "README" && m[1] !== "TEMPLATE" ? m[1] : null;
41+
isNew = Boolean(id) && !seen.has(id);
42+
if (id) seen.add(id);
43+
flush();
44+
continue;
45+
}
46+
if (!id || isNew) continue;
47+
const m = line.match(FIELD_RE);
48+
if (!m) continue;
49+
const [, sign, field, value] = m;
50+
if (sign === "-") removed[field] = value;
51+
else if (field in removed && removed[field] !== value) {
52+
if (!events.has(id)) events.set(id, []);
53+
events.get(id).push({ date, commit, field, from: removed[field], to: value });
54+
delete removed[field];
55+
}
56+
}
57+
return events;
58+
}
59+
60+
const LABEL = { license: "Licence", commercial: "Commercial use", attribution_required: "Credit required", status: "Status" };
61+
62+
/** One entry's changes.xml: newest change first; an entry with none is a valid, empty feed. */
63+
export function changesFeedXml({ entry, events = [], siteUrl, generatedAt }) {
64+
const base = String(siteUrl).replace(/\/+$/, "");
65+
const page = `${base}/entry/${entry.id}/`;
66+
const feedUrl = `${page}changes.xml`;
67+
const items = [...events].reverse().map((e) => {
68+
const title = `${entry.name}: ${LABEL[e.field] || e.field} ${e.from} → ${e.to}`;
69+
return ` <entry>
70+
<id>${esc(`${feedUrl}#${e.commit}-${e.field}`)}</id>
71+
<title>${esc(title)}</title>
72+
<updated>${esc(e.date)}T00:00:00Z</updated>
73+
<link href="${esc(page)}" />
74+
<summary>${esc(`${title}, recorded ${e.date}. The entry page quotes the source.`)}</summary>
75+
</entry>`;
76+
});
77+
const updated = events.length ? `${events[events.length - 1].date}T00:00:00Z` : generatedAt;
78+
return `<?xml version="1.0" encoding="utf-8"?>
79+
<feed xmlns="http://www.w3.org/2005/Atom">
80+
<id>${esc(feedUrl)}</id>
81+
<title>${esc(`${entry.name}: licence changes`)}</title>
82+
<subtitle>Changes to the licence, commercial use, credit requirement or status recorded for this source.</subtitle>
83+
<updated>${esc(updated)}</updated>
84+
<link rel="self" href="${esc(feedUrl)}" />
85+
<link href="${esc(page)}" />
86+
${items.join("\n")}
87+
</feed>
88+
`;
89+
}

‎site/lib/client-state.mjs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,17 @@ export function backLinkHref(qs) {
4242
if (!qs || !/^[\w.~%+=&-]+$/.test(qs)) return null;
4343
return `../../?${qs}#catalog`;
4444
}
45+
46+
/** An OPML bundle of the change feeds of `entries`, for a feed reader to import in one step. */
47+
export function changesOpml(entries, siteUrl, title = "Free Game Dev Assets: licence changes") {
48+
const base = String(siteUrl).replace(/\/+$/, "");
49+
const esx = (s) => escapeHtml(s);
50+
return `<?xml version="1.0" encoding="utf-8"?>
51+
<opml version="2.0">
52+
<head><title>${esx(title)}</title></head>
53+
<body>
54+
${entries.map((e) => ` <outline type="rss" text="${esx(e.name)}" title="${esx(e.name)}" xmlUrl="${esx(`${base}/entry/${e.id}/changes.xml`)}" htmlUrl="${esx(`${base}/entry/${e.id}/`)}" />`).join("\n")}
55+
</body>
56+
</opml>
57+
`;
58+
}

‎site/lib/entry-page.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
175175
${c.block}
176176
</section>
177177
<p class="page-actions"><a class="btn" href="${esc(entry.url)}" rel="noopener noreferrer">Go to source</a></p>
178+
<p class="entry-feed"><a href="changes.xml" type="application/atom+xml">Follow licence changes</a> (an Atom feed of changes to this licence, its commercial use, credit or status)</p>
178179
<p class="page-links">
179180
<a href="${esc(`${repo}/blob/main/${entry.path}`)}" rel="noopener noreferrer">View the file on GitHub</a>
180181
<a href="${esc(issue)}" rel="noopener noreferrer">Report a problem with this entry</a>

‎site/lib/lib.test.mjs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2630,6 +2630,46 @@ await section("catalog queries, CLI and MCP (#115)", async () => {
26302630
}
26312631
});
26322632

2633+
/* licence-change feeds (#186) --------------------------------------------- */
2634+
await section("licence-change feeds (#186)", async () => {
2635+
const { changeEventsFromLog, changesFeedXml } = await import("./changes.mjs");
2636+
const { changesOpml } = await import("./client-state.mjs");
2637+
const log = [
2638+
"\0aaa 2026-07-01",
2639+
"diff --git a/catalog/2d/a.md b/catalog/2d/a.md",
2640+
"+license: CC0",
2641+
"+status: needs-review",
2642+
"\0bbb 2026-08-01",
2643+
"diff --git a/catalog/2d/a.md b/catalog/3d/a.md",
2644+
"-license: CC0",
2645+
"+license: CC-BY-4.0",
2646+
"-status: needs-review",
2647+
"+status: needs-review",
2648+
"-verified: 2026-07-01",
2649+
"+verified: 2026-08-01",
2650+
"diff --git a/catalog/2d/README.md b/catalog/2d/README.md",
2651+
"-status: x",
2652+
"+status: y",
2653+
"\0ccc 2026-09-01",
2654+
"diff --git a/catalog/3d/a.md b/catalog/3d/a.md",
2655+
"-commercial: true",
2656+
"+commercial: false",
2657+
].join("\n");
2658+
const ev = changeEventsFromLog(log);
2659+
eq("changes: the creating commit is not a change", (ev.get("a") || []).some((e) => e.commit === "aaa"), false);
2660+
eq("changes: a moved file keeps its history, unchanged values skipped", ev.get("a").map((e) => `${e.date} ${e.field} ${e.from}>${e.to}`).join("|"), "2026-08-01 license CC0>CC-BY-4.0|2026-09-01 commercial true>false");
2661+
eq("changes: README files are not entries", ev.has("README"), false);
2662+
const feed = changesFeedXml({ entry: { id: "a", name: "A & B" }, events: ev.get("a"), siteUrl: "https://x.test/s/", generatedAt: "2026-11-07T00:00:00Z" });
2663+
has("feed: newest first", feed.indexOf("Commercial use true → false") < feed.indexOf("Licence CC0 → CC-BY-4.0") ? "ok" : "no", "ok");
2664+
has("feed: self link", feed, '<link rel="self" href="https://x.test/s/entry/a/changes.xml" />');
2665+
has("feed: escaped title", feed, "A &amp; B: licence changes");
2666+
has("feed: updated is the last change", feed, "<updated>2026-09-01T00:00:00Z</updated>");
2667+
has("feed: an entry without changes is a valid empty feed", changesFeedXml({ entry: { id: "z", name: "Z" }, events: [], siteUrl: "https://x.test/s/", generatedAt: "2026-11-07T00:00:00Z" }), "<updated>2026-11-07T00:00:00Z</updated>");
2668+
const opml = changesOpml([{ id: "a", name: "A <1>" }, { id: "b", name: "B" }], "https://x.test/s/");
2669+
has("opml: one outline per entry", opml, 'xmlUrl="https://x.test/s/entry/b/changes.xml"');
2670+
has("opml: names escaped", opml, 'text="A &lt;1&gt;"');
2671+
});
2672+
26332673
/* report (keep last) ------------------------------------------------------ */
26342674
if (failures.length) {
26352675
console.error(`lib.test failed (${failures.length}):`);

‎site/public/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ <h3 id="shortlist-title">Your shortlist (<span id="shortlist-count">0</span>)</h
193193
<p class="shortlist-actions">
194194
<button type="button" class="btn-ghost" data-shortlist-action="md">Download CREDITS.md</button>
195195
<button type="button" class="btn-ghost" data-shortlist-action="txt">Download CREDITS.txt</button>
196+
<button type="button" class="btn-ghost" data-shortlist-action="opml" title="Import into a feed reader to hear when any of these licences changes">Follow licence changes (OPML)</button>
196197
<button type="button" class="link-button" data-shortlist-action="clear">Clear shortlist</button>
197198
</p>
198199
<p class="filter-note" id="shortlist-storage" hidden>This browser is not saving site data, so the shortlist lasts only until you leave the page.</p>

‎site/public/shortlist.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* same code as the stack pages' "What this stack owes" (owes.js).
55
*/
66
import { creditsFile, owes, owesGroups } from "./owes.js";
7-
import { escapeHtml, parseShortlist } from "./client-state.js";
7+
import { changesOpml, escapeHtml, parseShortlist } from "./client-state.js";
88

99
const KEY = "fgda:shortlist";
1010

@@ -157,6 +157,10 @@ document.addEventListener("click", (e) => {
157157
if (!action) return;
158158
const what = action.getAttribute("data-shortlist-action");
159159
if (what === "md" || what === "txt") exportFile(what);
160+
if (what === "opml") {
161+
const siteUrl = data.site?.siteUrl || "";
162+
download("licence-changes.opml", changesOpml(ids.map((id) => byId[id]), siteUrl), "text/x-opml");
163+
}
160164
if (what === "clear") {
161165
update([]);
162166
announce("Shortlist cleared");

0 commit comments

Comments
 (0)