Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions site/checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

import path from "node:path";
import { parseStack, pickPath, StackError } from "./lib/stacks.mjs";
import { isRealDate } from "./lib/shared.mjs";

const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const DATE_ANY_RE = /\d{4}-\d{2}-\d{2}/g;

/** Generic hosts and distributors that are never a rights holder. */
Expand Down Expand Up @@ -144,7 +144,7 @@ export function checkAttributionConsistency(rel, meta, body, vocab) {
* The check that defends the repo's central promise: a `verified` date may
* never be newer than the evidence it claims to rest on. Bumping `verified`
* without adding a dated Evidence line is exactly the failure this catches.
* Also rejects Evidence dates in the future, and (V10) an Evidence section
* Also rejects impossible or future Evidence dates, and (V10) an Evidence section
* that carries no date at all, whatever the entry's status.
*/
export function checkEvidenceDates(rel, meta, body, today) {
Expand All @@ -156,12 +156,18 @@ export function checkEvidenceDates(rel, meta, body, today) {
errors.push(`${rel} ## Evidence section carries no YYYY-MM-DD date`);
return errors;
}
const newest = dates[dates.length - 1];
const validDates = dates.filter((date) => {
if (isRealDate(date)) return true;
errors.push(`${rel} Evidence date ${date} is not a real YYYY-MM-DD date`);
return false;
});
if (!validDates.length) return errors;
const newest = validDates[validDates.length - 1];
if (newest > today) {
errors.push(`${rel} Evidence date ${newest} is in the future`);
}
const verified = empty(meta.verified) ? null : String(meta.verified);
if (verified && DATE_RE.test(verified) && verified > newest) {
if (isRealDate(verified) && verified > newest) {
errors.push(
`${rel} verified ${verified} is newer than its newest Evidence date ${newest}. ` +
`A new verified date needs a dated Evidence line from the same check`
Expand Down
16 changes: 16 additions & 0 deletions site/checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ accepts(
);

/* V8 / V10 -------------------------------------------------------------- */
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
rejects(
`V8 rejects impossible Evidence date ${date}`,
checkEvidenceDates("bad.md", { verified: "2026-01-01" }, `# X${EV(date)}`, TODAY),
`Evidence date ${date} is not a real YYYY-MM-DD date`
);
}
rejects(
"V8 rejects an impossible date even alongside valid Evidence",
checkEvidenceDates("bad.md", { verified: "2026-09-20" }, `# X${EV("2026-02-30")}\n- Rechecked 2026-09-20.\n`, TODAY),
"Evidence date 2026-02-30 is not a real YYYY-MM-DD date"
);
accepts(
"V8 accepts real leap-day Evidence",
checkEvidenceDates("ok.md", { verified: "2000-02-29" }, `# X${EV("2000-02-29")}`, TODAY)
);
rejects(
"V8 rejects a verified date newer than its newest Evidence date",
checkEvidenceDates(
Expand Down
14 changes: 14 additions & 0 deletions site/lib/lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
commercialLabel,
entryPageUrl,
esc,
isRealDate,
STATUS_NOTES,
verifiedAge,
} from "./shared.mjs";
Expand Down Expand Up @@ -37,12 +38,21 @@ function throws(label, fn, needle) {
}

/* shared ----------------------------------------------------------------- */
for (const date of ["2026-01-01", "2026-12-31", "2026-04-30", "2024-02-29", "2000-02-29", "0000-02-29", "0099-12-31"]) {
eq(`isRealDate accepts ${date}`, isRealDate(date), true);
}
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2100-02-29", "2026-04-31", "2026-00-01", "2026-01-00", "2026-01-32", "0099-02-29", "2026-1-01", "2026-01-1", "2026-01-01\n", " 2026-01-01", "2026-01-01T00:00:00Z", "", null, undefined, 20260101]) {
eq(`isRealDate rejects ${JSON.stringify(date)}`, isRealDate(date), false);
}
eq("esc escapes all five characters", esc(`<a href="x">'&'</a>`), "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;");
eq("commercialLabel true", commercialLabel(true), "commercial OK");
eq("commercialLabel varies", commercialLabel("varies"), "per-file review");
eq("commercialLabel unknown", commercialLabel("unknown"), "commercial ?");
eq("verifiedAge fresh", verifiedAge("2026-09-01", Date.parse("2026-09-24T00:00:00Z")).bucket, "fresh");
eq("verifiedAge missing", verifiedAge(null, Date.now()).bucket, "unknown");
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
eq(`verifiedAge rejects ${date}`, verifiedAge(date, Date.parse("2026-09-24T00:00:00Z")).bucket, "unknown");
}
eq("entryPageUrl trims the site slash", entryPageUrl({ siteUrl: "https://x.test/site/" }, "a-b"), "https://x.test/site/entry/a-b/");
has("STATUS_NOTES covers needs-review", STATUS_NOTES["needs-review"], "open");

Expand Down Expand Up @@ -241,6 +251,10 @@ throws("licence in a why sentence", badStack((m) => m.replace("Five loops.", "Fi
throws("licence phrase in a why sentence", badStack((m) => m.replace("Five loops.", "Five public domain loops.")), '("public domain")');
throws("missing walked", badStack((m) => m.replace("walked: 2026-09-20\n", "")), "frontmatter is missing walked");
throws("bad walked date", badStack((m) => m.replace("2026-09-20", "20 Sept")), "walked is not YYYY-MM-DD");
for (const date of ["2025-13-01", "2026-02-30", "2025-02-29", "1900-02-29", "2026-04-31"]) {
throws(`impossible walked date ${date}`, badStack((m) => m.replace("2026-09-20", date)), "walked is not YYYY-MM-DD");
}
eq("leap-day walked date", parseStack(stackMd.replace("2026-09-20", "2000-02-29"), { file: "stacks/s1.md" }).meta.walked, "2000-02-29");
throws("empty pick section", badStack((m) => m.replace("- **Music, no credit:** [Chips](../catalog/audio/subspaceaudio-5-chiptunes.md). Five loops.", "")), 'section "Audio" has no picks');
throws("gaps line not a bullet", badStack((m) => m.replace("- No parallax layers yet.", "No parallax layers yet.")), "stacks/s1.md:24: a Gaps line is a bullet");
eq("licence terms skip plain words", licenceTerms({ licenses: { custom: {}, MIT: { spdx: "MIT" }, CC0: { spdx: "CC0-1.0" } } }, ["OFL-1.1"]).sort().join(","), "CC0,CC0-1.0,MIT,OFL-1.1");
Expand Down
12 changes: 11 additions & 1 deletion site/lib/shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ export const STATUS_NOTES = {
const VERIFIED_FRESH_DAYS = 180;
const VERIFIED_AGING_DAYS = 365;

/** A real calendar date in strict YYYY-MM-DD form, independent of local time. */
export function isRealDate(value) {
if (typeof value !== "string" || value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split("-").map(Number);
const date = new Date(0);
// Unlike Date.UTC, this preserves years 0000-0099.
date.setUTCFullYear(year, month - 1, day);
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}

export function verifiedAge(verified, now) {
if (!verified) return { days: null, bucket: "unknown" };
if (!isRealDate(verified)) return { days: null, bucket: "unknown" };
const t = Date.parse(`${verified}T00:00:00Z`);
if (Number.isNaN(t)) return { days: null, bucket: "unknown" };
const days = Math.max(0, Math.floor((now - t) / 86400000));
Expand Down
5 changes: 2 additions & 3 deletions site/lib/stacks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
*/
import fs from "node:fs";
import path from "node:path";
import { unquoteScalar } from "./shared.mjs";
import { isRealDate, unquoteScalar } from "./shared.mjs";

export class StackError extends Error {}

/** The `##` sections a stack may have, in the order they must appear. */
export const STACK_SECTIONS = ["Art", "Audio", "Fonts", "Tools", "Gaps"];

const REQUIRED = ["id", "title", "task", "walked"];
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// - **Need:** [Entry name](path/to/entry.md). Why this pick.
const PICK_RE = /^- \*\*([^*]+?):\*\* \[([^\]]+)\]\(([^)\s]+)\)\.\s+(\S.*)$/;
// Names and shorthands matched in any case, as whole words. The exact ids
Expand Down Expand Up @@ -99,7 +98,7 @@ export function parseStack(text, { file, terms = [] }) {
metaLine[key] = i;
}
for (const key of REQUIRED) if (!meta[key]) fail(0, `frontmatter is missing ${key}`);
if (!DATE_RE.test(meta.walked)) fail(0, "walked is not YYYY-MM-DD");
if (!isRealDate(meta.walked)) fail(0, "walked is not YYYY-MM-DD (a real calendar date is required)");
noLicence(metaLine.title, meta.title, "title");
noLicence(metaLine.task, meta.task, "task");

Expand Down
4 changes: 2 additions & 2 deletions site/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
checkValueSpellings,
} from "./checks.mjs";
import { licenceTerms, listStackFiles } from "./lib/stacks.mjs";
import { isRealDate } from "./lib/shared.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
Expand Down Expand Up @@ -81,7 +82,6 @@ const SKIP_WALK = new Set([".git", "node_modules", "dist", "RESEARCH"]);
const ALLOWED_BINARY_PREFIXES = ["site/dist/", "docs/images/readme/"];
const EMOJI_RE = /\p{Extended_Pictographic}/u;
const MD_LINK_RE = /!\[[^\]]*\]\(([^)]+)\)|\[[^\]]*\]\(([^)]+)\)/g;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const EVIDENCE_DATE_RE = /\d{4}-\d{2}-\d{2}/;
const COMMERCIAL_VALUES = new Set(["true", "false", "unknown", "varies"]);
const STATUS_VALUES = new Set(["active", "needs-review", "deprecated"]);
Expand Down Expand Up @@ -315,7 +315,7 @@ function main() {

if (meta.verified) {
const v = String(meta.verified);
if (!DATE_RE.test(v)) errors.push(`${rel} verified is not YYYY-MM-DD`);
if (!isRealDate(v)) errors.push(`${rel} verified is not a real YYYY-MM-DD date`);
else if (v > today) errors.push(`${rel} verified ${v} is in the future`);
}

Expand Down