Skip to content

Commit b17a8b6

Browse files
TMHSDigitalclaude
andcommitted
Credits audit page: paste a credits file or URLs, see what each owes (#187)
- audit/ (lib/audit-page.mjs, public/audit.js), linked from every footer and in the sitemap; everything runs in the browser - lib/credits-audit.mjs (served as credits-audit.js) matches each line: by the longest catalog URL prefix, else as a candidate list for a site the catalog knows, else by an entry name or a publisher named on the line (5+ characters, whole words). An entry whose url is a filtered listing (?series=...) matches only itself - per line: licence, credit line, non-commercial, per-file, engine lock, status and open question, verified date; unknown lines say to check by hand and link the new-source form - the settled matches' obligations, and CREDITS.md from the shared creditsFile (now told where it came from: madeFrom) Checked in a browser with a 20-line mixed file: 16 matched, 4 unknown, CREDITS.md downloads, no console errors. Closes #187 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent 134d29e commit b17a8b6

8 files changed

Lines changed: 306 additions & 2 deletions

File tree

‎site/build.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { freshnessLineHtml, freshnessPageHtml, freshnessStats, newEntriesLineHtm
3838
import { licenceTerms, listStackFiles, owes, parseStack, pickPath } from "./lib/stacks.mjs";
3939
import { createDistWriter } from "./lib/dist-writer.mjs";
4040
import { serviceWorkerJs, webManifest } from "./lib/offline.mjs";
41+
import { auditPageHtml } from "./lib/audit-page.mjs";
4142
import { ATTRIBUTION_RANK, categoryChipsHtml, guideRowsHtml, headMetaHtml, licenseFamily, notFoundHtml, robotsTxt, stackRowsHtml, starterRowsHtml } from "./lib/home-page.mjs";
4243

4344
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -573,6 +574,8 @@ function main() {
573574
writeOut("opensearch.xml", opensearchXml(config.site));
574575
writeOut("404.html", notFoundHtml(config.site));
575576
writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.listed, hasCard }));
577+
// Paste a credits file, see what it owes (#187).
578+
writeOut(path.join("audit", "index.html"), auditPageHtml({ site: config.site, stamp, total: stats.listed, hasCard }));
576579

577580
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn });
578581
const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now });

‎site/lib/audit-page.mjs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/** The credits audit page, audit/ (#187). The work happens in the browser (audit.js). Pure. */
2+
import { baseHeadHtml, esc, PAGE_MODULES, siteFooterHtml, siteHeaderHtml } from "./shared.mjs";
3+
4+
export function auditPageUrl(site) {
5+
return `${String(site.siteUrl).replace(/\/+$/, "")}/audit/`;
6+
}
7+
8+
export function auditPageHtml({ site, stamp, total, hasCard }) {
9+
const title = `Credits audit | ${site.title}`;
10+
const desc = "Paste your credits file or the URLs your game's assets came from, and see what each one owes, in your browser.";
11+
const repo = String(site.repo);
12+
return `<!DOCTYPE html>
13+
<html lang="en">
14+
<head>
15+
<meta charset="utf-8" />
16+
<meta name="viewport" content="width=device-width, initial-scale=1" />
17+
<title>${esc(title)}</title>
18+
<meta name="description" content="${esc(desc)}" />
19+
${baseHeadHtml({ site, title, description: desc, url: auditPageUrl(site), hasCard, root: "../", modules: PAGE_MODULES.audit })}
20+
</head>
21+
<body>
22+
<a class="skip-link" href="#content">Skip to the audit</a>
23+
${siteHeaderHtml({ root: "../", repo })}
24+
<main class="section entry-page audit-page" id="content">
25+
<nav class="breadcrumb" aria-label="Breadcrumb">
26+
<a href="../#catalog">Catalog</a> <span aria-hidden="true">/</span>
27+
<span aria-current="page">Credits audit</span>
28+
</nav>
29+
<h1>Credits audit</h1>
30+
<div class="entry-lead"><p>Paste a credits file, or one asset URL per line. Each line is matched to the catalog: by the source's URL, by the site it came from, or by the source's name. Nothing leaves your browser.</p></div>
31+
<noscript><p>The audit runs in your browser and needs JavaScript. Without it, search the <a href="../#catalog">catalog</a> for each source.</p></noscript>
32+
<form class="audit-form" id="audit-form">
33+
<label for="audit-input">Credits file or URLs</label>
34+
<textarea id="audit-input" rows="12" spellcheck="false" placeholder="https://kenney.nl/assets/ui-pack&#10;Music: Kevin MacLeod (incompetech.com)"></textarea>
35+
<p><button type="submit" class="btn">Check what these owe</button></p>
36+
</form>
37+
<section id="audit-results" aria-live="polite" hidden>
38+
<h2>Line by line</h2>
39+
<ul class="audit-lines" id="audit-lines"></ul>
40+
<h2>What the matched sources owe</h2>
41+
<div class="shortlist-owes" id="audit-owes"></div>
42+
<p class="shortlist-actions"><button type="button" class="btn-ghost" id="audit-download">Download CREDITS.md</button></p>
43+
<p class="filter-note">A line the catalog does not know is not a verdict on it: read its licence yourself, or <a href="${esc(repo)}/issues/new?template=new-source.yml" rel="noopener noreferrer">suggest the source</a>.</p>
44+
</section>
45+
</main>
46+
${siteFooterHtml({ root: "../", stamp, total, current: "audit" })}
47+
<script src="../data.js" defer></script>
48+
<script type="module" src="../audit.js"></script>
49+
</body>
50+
</html>
51+
`;
52+
}

‎site/lib/credits-audit.mjs‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* The credits audit (#187): match the lines of a pasted credits file or URL
3+
* list to catalog entries, so a developer can see what an existing pile of
4+
* assets owes. Pure and browser-safe (served as credits-audit.js).
5+
*
6+
* A line matches by URL first: the entry whose own url is the longest prefix
7+
* of a URL on the line. A URL on a site with catalog entries but no prefix
8+
* match lists that site's entries as candidates. A line with no URL matches
9+
* an entry whose name it contains as whole words, or else the entries of a
10+
* publisher it names (five characters or more, so "Kit" does not match
11+
* everything).
12+
*/
13+
14+
/**
15+
* A URL reduced for comparison: host without www., path without a trailing
16+
* slash, no fragment. `query` is kept apart: an entry whose url carries one
17+
* is a filtered listing (`/assets?series=...`), which matches only itself.
18+
*/
19+
export function urlKey(raw) {
20+
let u;
21+
try {
22+
u = new URL(String(raw).trim());
23+
} catch {
24+
return null;
25+
}
26+
if (!/^https?:$/.test(u.protocol)) return null;
27+
const host = u.hostname.toLowerCase().replace(/^www\./, "");
28+
let path = u.pathname;
29+
try {
30+
path = decodeURIComponent(path);
31+
} catch {
32+
// A malformed escape is compared as written.
33+
}
34+
path = path.replace(/\/+$/, "").toLowerCase();
35+
return { host, path, query: u.search, key: `${host}${path}` };
36+
}
37+
38+
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/g;
39+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40+
41+
/** An index of entries by URL key and by host, built once per audit. */
42+
export function auditIndex(entries) {
43+
const byHost = new Map();
44+
const keyed = [];
45+
for (const e of entries) {
46+
const k = urlKey(e.url);
47+
if (!k) continue;
48+
keyed.push({ entry: e, ...k });
49+
if (!byHost.has(k.host)) byHost.set(k.host, []);
50+
byHost.get(k.host).push(e);
51+
}
52+
// Names and publishers, five characters or more, as whole words.
53+
const wordRe = (text) => new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(text.trim().toLowerCase())}(?![\\p{L}\\p{N}])`, "u");
54+
const named = entries.filter((e) => String(e.name || "").trim().length >= 5).map((e) => ({ entry: e, re: wordRe(e.name) }));
55+
const byPublisher = new Map();
56+
for (const e of entries) {
57+
const p = String(e.publisher || "").trim();
58+
if (p.length < 5) continue;
59+
if (!byPublisher.has(p)) byPublisher.set(p, []);
60+
byPublisher.get(p).push(e);
61+
}
62+
const publishers = [...byPublisher].map(([name, list]) => ({ entries: list, re: wordRe(name) }));
63+
return { keyed, byHost, named, publishers };
64+
}
65+
66+
/**
67+
* One verdict per non-empty line: `{ line, via, entries }` where `via` is
68+
* "url" (a confident match), "site" (candidates on the same site), "name",
69+
* or "none".
70+
*/
71+
export function auditLines(text, index) {
72+
const out = [];
73+
for (const raw of String(text).split(/\r?\n/)) {
74+
const line = raw.trim();
75+
if (!line) continue;
76+
const urls = (line.match(URL_RE) || []).map((u) => u.replace(/[.,;:!?]+$/, ""));
77+
let verdict = null;
78+
for (const u of urls) {
79+
const k = urlKey(u);
80+
if (!k) continue;
81+
const hits = index.keyed.filter((c) =>
82+
c.host === k.host && (c.query ? c.path === k.path && c.query === k.query : k.path === c.path || k.path.startsWith(`${c.path}/`) || c.path === "")
83+
);
84+
if (hits.length) {
85+
const best = Math.max(...hits.map((h) => h.path.length));
86+
verdict = { line, via: "url", entries: hits.filter((h) => h.path.length === best).map((h) => h.entry) };
87+
break;
88+
}
89+
const site = index.byHost.get(k.host);
90+
if (site && !verdict) verdict = { line, via: "site", entries: site };
91+
}
92+
if (!verdict && !urls.length) {
93+
const lower = line.toLowerCase();
94+
const hits = index.named.filter((n) => n.re.test(lower)).map((n) => n.entry);
95+
if (hits.length) verdict = { line, via: "name", entries: hits };
96+
else {
97+
// A publisher named on the line: their entries are the candidates.
98+
const pubs = (index.publishers || []).filter((p) => p.re.test(lower)).flatMap((p) => p.entries);
99+
if (pubs.length) verdict = { line, via: "name", entries: pubs };
100+
}
101+
}
102+
out.push(verdict || { line, via: "none", entries: [] });
103+
}
104+
return out;
105+
}
106+
107+
/** The entries an audit settled on (url and name matches, each once), for owes() and CREDITS.md. */
108+
export function auditedEntries(verdicts) {
109+
const seen = new Map();
110+
for (const v of verdicts) {
111+
if ((v.via === "url" || v.via === "name") && v.entries.length === 1) seen.set(v.entries[0].id, v.entries[0]);
112+
}
113+
return [...seen.values()];
114+
}

‎site/lib/exports.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function sitemapXml({ site, entries, stacks = [], stamp, modified = new M
4444
const urls = [
4545
url(`${base}/`, stamp),
4646
url(`${base}/freshness/`, stamp),
47+
url(`${base}/audit/`, stamp),
4748
...categories.map((c) => url(`${base}/category/${c.id}/`, c.lastmod || stamp)),
4849
...guides.map((g) => url(`${base}/guide/${g.id}/`, modified.get(g.path) || stamp)),
4950
...stacks.map((s) => url(`${base}/stack/${s.meta.id}/`, latest(s.meta.walked, modified.get(s.rel)))),

‎site/lib/lib.test.mjs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2522,6 +2522,57 @@ await section("theme, manifest and offline worker (#111)", async () => {
25222522
has("header: theme toggle, hidden until theme.js runs", siteHeaderHtml({ root: "", repo: "https://g.test/o/r" }), 'id="theme-toggle" hidden');
25232523
});
25242524

2525+
/* credits audit (#187) ----------------------------------------------------- */
2526+
await section("credits audit (#187)", async () => {
2527+
const { urlKey, auditIndex, auditLines, auditedEntries } = await import("./credits-audit.mjs");
2528+
eq("urlKey: www, case, trailing slash and query dropped", urlKey("https://WWW.Kenney.nl/Assets/?x=1#y").key, "kenney.nl/assets");
2529+
eq("urlKey: not http", urlKey("ftp://x.test/a"), null);
2530+
eq("urlKey: not a URL", urlKey("nope"), null);
2531+
const entries = [
2532+
{ id: "kenney", name: "Kenney", url: "https://kenney.nl/assets" },
2533+
{ id: "kenney-ui", name: "Kenney UI Pack", url: "https://kenney.nl/assets/ui-pack" },
2534+
{ id: "incompetech", name: "Incompetech", url: "https://incompetech.com/music/" },
2535+
{ id: "kit", name: "Kit", url: "https://kit.test/" },
2536+
];
2537+
const v = auditLines([
2538+
"UI: https://www.kenney.nl/assets/ui-pack/download.",
2539+
"Props https://kenney.nl/assets/other-pack",
2540+
"Music: Kevin MacLeod (https://incompetech.com/)",
2541+
"https://kenney.nl/blog/post",
2542+
"Music by incompetech, licensed CC-BY",
2543+
"my own sprites, kit included",
2544+
"https://unknown.test/thing",
2545+
"",
2546+
].join("\n"), auditIndex(entries));
2547+
eq("audit: empty lines skipped", v.length, 7);
2548+
eq("audit: the longest URL prefix wins", v[0].entries.map((e) => e.id).join(), "kenney-ui");
2549+
eq("audit: a deeper URL under an entry's url matches it", `${v[1].via}:${v[1].entries[0].id}`, "url:kenney");
2550+
eq("audit: a bare site root names the site, not one source", `${v[2].via}:${v[2].entries[0].id}`, "site:incompetech");
2551+
eq("audit: same site, no prefix: candidates", `${v[3].via}:${v[3].entries.length}`, "site:2");
2552+
eq("audit: a name without a URL", `${v[4].via}:${v[4].entries[0].id}`, "name:incompetech");
2553+
eq("audit: short names never match by name", v[5].via, "none");
2554+
eq("audit: unknown site", v[6].via, "none");
2555+
eq("audit: settled entries, each once, no candidates", auditedEntries(v).map((e) => e.id).sort().join(), "incompetech,kenney,kenney-ui");
2556+
});
2557+
2558+
/* credits audit: listings and publishers (#187) ----------------------------- */
2559+
await section("credits audit: listings and publishers (#187)", async () => {
2560+
const { auditIndex, auditLines } = await import("./credits-audit.mjs");
2561+
const { creditsFile, owes } = await import("./owes.mjs");
2562+
const entries = [
2563+
{ id: "lib", name: "Library", url: "https://pub.test" },
2564+
{ id: "series", name: "Series", url: "https://pub.test/assets?series=A" },
2565+
{ id: "pack", name: "Pack One", url: "https://pub.test/assets/pack-one", publisher: "Pubhouse" },
2566+
{ id: "pack2", name: "Pack Two", url: "https://pub.test/assets/pack-two", publisher: "Pubhouse" },
2567+
];
2568+
const v = auditLines("https://pub.test/assets/other\nhttps://pub.test/assets?series=A\nSprites by Pubhouse", auditIndex(entries));
2569+
eq("audit: a filtered listing is not a prefix of other pages", v[0].entries.map((e) => e.id).join(), "lib");
2570+
eq("audit: a filtered listing matches itself", v[1].entries.map((e) => e.id).join(), "series");
2571+
eq("audit: a named publisher lists its entries", `${v[2].via}:${v[2].entries.map((e) => e.id).join("+")}`, "name:pack+pack2");
2572+
has("credits file: the header says where it came from", creditsFile(owes([]), { madeFrom: "a credits audit" }), "Made from a credits audit on");
2573+
has("credits file: shortlist by default", creditsFile(owes([])), "Made from a shortlist on");
2574+
});
2575+
25252576
/* report (keep last) ------------------------------------------------------ */
25262577
if (failures.length) {
25272578
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/owes.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export function copyAllText(owed) {
9797
* Every section a stack page shows is here, so nothing owed is dropped on the
9898
* way from the shortlist to the game's repository.
9999
*/
100-
export function creditsFile(owed, { format = "md", pageUrl = () => "", date = "", source = "Free Game Dev Assets" } = {}) {
100+
export function creditsFile(owed, { format = "md", pageUrl = () => "", date = "", source = "Free Game Dev Assets", madeFrom = "a shortlist" } = {}) {
101101
const md = format === "md";
102102
const out = [];
103103
const heading = (text) => {
@@ -112,7 +112,7 @@ export function creditsFile(owed, { format = "md", pageUrl = () => "", date = ""
112112
out.push(md ? "# Credits" : "CREDITS\n=======");
113113
out.push(
114114
"",
115-
`Made from a shortlist on ${source}${date ? ` on ${date}` : ""}. Each licence was checked at its source on the date its catalog entry shows. This is the catalog's reading, not legal advice: re-read the source before you ship.`
115+
`Made from ${madeFrom} on ${source}${date ? ` on ${date}` : ""}. Each licence was checked at its source on the date its catalog entry shows. This is the catalog's reading, not legal advice: re-read the source before you ship.`
116116
);
117117

118118
if (owed.credits.length) {

‎site/lib/shared.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export function siteFooterHtml({ root, stamp, total, credit = true, current = nu
270270
`<a href="${root}feed.xml">feed</a>`,
271271
`<a href="${root}guide/data/">Use the data</a>`,
272272
`<a href="${root}guide/changelog/">Changelog</a>`,
273+
current === "audit" ? null : `<a href="${root}audit/">Credits audit</a>`,
273274
].filter(Boolean);
274275
return `<footer class="footer">
275276
<p>Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.</p>
@@ -294,6 +295,7 @@ export const BROWSER_MODULES = {
294295
"shared.mjs": "shared.mjs",
295296
"client-state.js": "client-state.mjs", // shortlist parsing, escaping and the entry back link
296297
"exports.js": "exports.mjs", // "Download these" on the catalog: the same CSV as catalog.csv
298+
"credits-audit.js": "credits-audit.mjs", // the credits audit page's matcher
297299
};
298300

299301
/**
@@ -307,6 +309,7 @@ export const PAGE_MODULES = {
307309
entry: ["shortlist.js", "owes.js", "client-state.js"],
308310
category: ["category.js", "views.js", "shortlist.js", "owes.js", "client-state.js"],
309311
notFound: ["not-found.js", "search.js", "shared.mjs"],
312+
audit: ["audit.js", "credits-audit.js", "owes.js", "client-state.js"],
310313
};
311314

312315
/**

‎site/public/audit.js‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* The credits audit page (#187): matches pasted lines to the catalog
3+
* (credits-audit.js), shows a verdict per line and what the matched sources
4+
* owe (owes.js), and offers the result as CREDITS.md. Everything stays in
5+
* the browser.
6+
*/
7+
import { auditIndex, auditLines, auditedEntries } from "./credits-audit.js";
8+
import { creditsFile, owes, owesGroups } from "./owes.js";
9+
import { escapeHtml } from "./client-state.js";
10+
11+
const run = () => {
12+
const data = window.__CATALOG__;
13+
const form = document.getElementById("audit-form");
14+
if (!data || !form) return;
15+
const index = auditIndex(data.entries);
16+
const siteUrl = String(data.site?.siteUrl || "").replace(/\/+$/, "");
17+
let matched = [];
18+
19+
const entryLink = (e) => `<a href="../entry/${encodeURIComponent(e.id)}/">${escapeHtml(e.name)}</a>`;
20+
const facts = (e) => {
21+
const bits = [escapeHtml(e.license)];
22+
if (e.attribution_required === true) bits.push(e.attribution_string ? `credit: <code>${escapeHtml(e.attribution_string)}</code>` : "credit required: see the entry");
23+
else if (e.attribution_required === false) bits.push("no credit line");
24+
if (e.commercial === false) bits.push("<strong>not for commercial use</strong>");
25+
if (e.commercial === "varies") bits.push("check each file");
26+
if (e.engine_lock) bits.push(`<strong>locked to ${escapeHtml(e.engine_lock)}</strong>`);
27+
if (e.status !== "active") bits.push(`<strong>${escapeHtml(e.status)}</strong>${e.review ? `: ${escapeHtml(e.review)}` : ""}`);
28+
bits.push(`licence read ${escapeHtml(e.verified || "never")}`);
29+
return bits.join(" · ");
30+
};
31+
const verdictHtml = (v) => {
32+
const line = `<code>${escapeHtml(v.line.length > 140 ? `${v.line.slice(0, 140)}…` : v.line)}</code>`;
33+
if (v.via === "none") return `<li class="audit-none">${line}<br />Not in the catalog: check this one by hand.</li>`;
34+
if (v.via === "site" || v.entries.length > 1) {
35+
const n = v.entries.length;
36+
const lead = v.via !== "site" ? "More than one source matches:" : n === 1 ? "The catalog has one source on this site; is it this one?" : `The catalog has ${n} sources on this site; which one?`;
37+
return `<li class="audit-maybe">${line}<br />${lead} ${v.entries.slice(0, 8).map(entryLink).join(", ")}${n > 8 ? ", …" : ""}</li>`;
38+
}
39+
const e = v.entries[0];
40+
return `<li class="audit-match">${line}<br />${entryLink(e)}${v.via === "name" ? " (matched by name)" : ""}: ${facts(e)}</li>`;
41+
};
42+
43+
form.addEventListener("submit", (event) => {
44+
event.preventDefault();
45+
const verdicts = auditLines(document.getElementById("audit-input").value, index);
46+
matched = auditedEntries(verdicts);
47+
document.getElementById("audit-lines").innerHTML = verdicts.map(verdictHtml).join("");
48+
document.getElementById("audit-owes").innerHTML = matched.length
49+
? owesGroups(owes(matched.map((entry) => ({ need: "your game", entry }))))
50+
.map((g) => `<p><strong>${escapeHtml(g.heading)} (${g.entries.length}):</strong> ${g.entries.map(entryLink).join(", ")}</p>`)
51+
.join("")
52+
: "<p>No line matched one source for certain.</p>";
53+
document.getElementById("audit-download").hidden = !matched.length;
54+
const results = document.getElementById("audit-results");
55+
results.hidden = false;
56+
results.querySelector("h2").focus?.();
57+
});
58+
59+
document.getElementById("audit-download").addEventListener("click", () => {
60+
const text = creditsFile(owes(matched.map((entry) => ({ need: "your game", entry }))), {
61+
format: "md",
62+
pageUrl: (e) => (siteUrl ? `${siteUrl}/entry/${encodeURIComponent(e.id)}/` : ""),
63+
date: new Date().toISOString().slice(0, 10),
64+
source: data.site?.title || "Free Game Dev Assets",
65+
madeFrom: "a credits audit",
66+
});
67+
const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" }));
68+
const link = document.createElement("a");
69+
link.href = url;
70+
link.download = "CREDITS.md";
71+
document.body.append(link);
72+
link.click();
73+
link.remove();
74+
setTimeout(() => URL.revokeObjectURL(url), 1000);
75+
});
76+
};
77+
78+
// data.js is deferred, so it has run by DOMContentLoaded; a module runs after it either way.
79+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
80+
else run();

0 commit comments

Comments
 (0)