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
68 changes: 54 additions & 14 deletions site/base.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/<repo>" 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);
79 changes: 79 additions & 0 deletions site/test/base.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading