diff --git a/site/base.mjs b/site/base.mjs index a7ca4ed8..8b0e29d0 100644 --- a/site/base.mjs +++ b/site/base.mjs @@ -5,20 +5,60 @@ * base-dependent output. It lives in its own module so none of those has to * import the full Astro config just to learn one string. * - * This file exists because the literal was previously copied into four places - * — astro.config.mjs, link-audit/lib.ts, and three test files — each with a - * comment saying it had to match the others. Moving the site to the - * codearbiter.dev apex domain desynced all four at once: four test failures and - * 19,940 link-audit failures from a one-line change. - * - * MUST be "" for an apex domain, never "/". rehypeBaseLinks prefixes every - * root-absolute href/src with this value, so "/" would rewrite - * "/diagrams/x.svg" into "//diagrams/x.svg" — a protocol-relative URL that - * resolves against a different host and fails silently rather than 404-ing. - * Empty string makes the prefixing a correct no-op. Consumers that need a real - * path (Astro's own `base`) use `BASE || "/"`. + * This file exists because the literal was previously copied into six places — + * astro.config.mjs, link-audit/lib.ts, and four test files — each with a comment + * saying it had to match the others. Moving the site to the codearbiter.dev apex + * domain desynced all six at once: four test failures and 19,940 link-audit + * failures from a one-line change. * * For a project subpath the value is "/" with no trailing slash, e.g. - * "/codeArbiter" when served from arbiterforge.github.io/codeArbiter/. + * "/codeArbiter" when served from arbiterforge.github.io/codeArbiter/. For an + * apex domain it is the empty string. + */ + +/** The configured value, before validation. */ +const RAW_BASE = ""; + +/** Reject any base that would corrupt links, loudly and at import time. + * + * The dangerous value is "/". rehypeBaseLinks prefixes every root-absolute + * href/src with the base, so a base of "/" rewrites "/diagrams/x.svg" into + * "//diagrams/x.svg" — a protocol-relative URL that resolves against a + * DIFFERENT HOST. It does not 404. The build succeeds, the link audit passes + * (the target still parses), and the page quietly fetches from somewhere else. + * That failure mode is why this is a thrown error and not a comment: prose + * cannot stop anyone, and "/" is the obvious thing to reach for when moving a + * site to a domain root. + * + * Exported for the tests; callers should import BASE. + * + * @param {string} value + * @returns {string} the validated base */ -export const BASE = ""; +export function validateBase(value) { + if (typeof value !== "string") { + throw new TypeError(`site base must be a string, got ${typeof value}`); + } + // The apex-domain form. Makes rehypeBaseLinks a correct no-op, because a + // root-absolute target already starts with `${BASE}/`. + if (value === "") return value; + if (!value.startsWith("/")) { + throw new Error( + `site base ${JSON.stringify(value)} must be "" or start with "/" ` + + `(a base is a root-absolute path, e.g. "/codeArbiter")`, + ); + } + // Catches "/" as well as "/codeArbiter/". A trailing slash doubles the + // separator when the base is prefixed onto a root-absolute target. + if (value.endsWith("/")) { + const hint = + value === "/" + ? ` Use "" for a domain root: a base of "/" turns "/diagrams/x.svg" into ` + + `"//diagrams/x.svg", a protocol-relative URL pointing at another host.` + : ` Drop the trailing slash.`; + throw new Error(`site base ${JSON.stringify(value)} must not end with "/".${hint}`); + } + return value; +} + +export const BASE = validateBase(RAW_BASE); diff --git a/site/test/base.test.ts b/site/test/base.test.ts new file mode 100644 index 00000000..6bf54fbd --- /dev/null +++ b/site/test/base.test.ts @@ -0,0 +1,79 @@ +/** base.test.ts — the site base path is the one value that silently corrupts + * every internal link when it is wrong, so its invariants are gated rather than + * documented. + * + * The motivating failure: a base of "/" makes rehypeBaseLinks rewrite + * "/diagrams/x.svg" into "//diagrams/x.svg", a protocol-relative URL that + * resolves against a DIFFERENT HOST. Nothing 404s. The build succeeds and the + * link audit passes, because the target still parses as a valid reference — the + * page just quietly fetches from somewhere else. A comment saying "never use + * '/'" stops nobody; "/" is the obvious thing to reach for when moving a site to + * a domain root. + */ +import { describe, it, expect } from "vitest"; +// @ts-expect-error -- untyped .mjs module +import { BASE, validateBase } from "../base.mjs"; + +/** The link-prefixing rule from scripts/rehype-base-links.ts, reproduced so this + * file can demonstrate the corruption a bad base causes without depending on the + * plugin's internals. */ +function prefix(value: string, base: string): string { + if (value === "" || value.startsWith("#") || value.startsWith("//") || !value.startsWith("/")) { + return value; + } + if (value === base || value.startsWith(`${base}/`)) return value; + return `${base}${value}`; +} + +describe("validateBase", () => { + it("accepts the apex-domain form", () => { + expect(validateBase("")).toBe(""); + }); + + it("accepts a project subpath", () => { + expect(validateBase("/codeArbiter")).toBe("/codeArbiter"); + }); + + it("rejects '/' — the value that produces protocol-relative URLs", () => { + expect(() => validateBase("/")).toThrow(/protocol-relative/); + }); + + it("rejects a trailing slash", () => { + expect(() => validateBase("/codeArbiter/")).toThrow(/must not end with/); + }); + + it("rejects a base that is not root-absolute", () => { + expect(() => validateBase("codeArbiter")).toThrow(/must be "" or start with/); + }); + + it("rejects a non-string", () => { + expect(() => validateBase(42 as unknown as string)).toThrow(TypeError); + }); +}); + +describe("why '/' is rejected", () => { + it("would rewrite a root-absolute asset into a protocol-relative URL", () => { + // This is the corruption the guard exists to prevent. Pinned as an + // executable statement of the hazard, not a comment about it. + expect(prefix("/diagrams/x.svg", "/")).toBe("//diagrams/x.svg"); + expect(prefix("/diagrams/x.svg", "/").startsWith("//")).toBe(true); + }); + + it("is a correct no-op under the sanctioned apex form", () => { + expect(prefix("/diagrams/x.svg", "")).toBe("/diagrams/x.svg"); + }); + + it("prefixes correctly under a sanctioned subpath", () => { + expect(prefix("/diagrams/x.svg", "/codeArbiter")).toBe("/codeArbiter/diagrams/x.svg"); + }); +}); + +describe("the configured BASE", () => { + it("is itself valid — the guard runs at import time, so this cannot regress", () => { + expect(() => validateBase(BASE)).not.toThrow(); + }); + + it("never ends with a slash", () => { + expect(BASE.endsWith("/")).toBe(false); + }); +});