From 11dad76f3e64fff65ad1473518b11b7c48913f63 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Tue, 11 Aug 2026 16:08:59 +0000 Subject: [PATCH 1/6] feat(metadata): add structured site identity --- PROVENANCE.md | 7 ++ README.md | 20 +++- THIRD_PARTY_NOTICES.md | 4 + contracts/icon.schema.json | 44 +++++++ docs/ARCHITECTURE.md | 37 ++++-- public/favicon.svg | 8 ++ public/mask-icon.svg | 4 + src/data/icons.json | 39 +++++++ src/layouts/Base.astro | 101 ++++++++++------- src/lib/metadata.ts | 128 +++++++++++++++++++++ src/pages/404.astro | 13 ++- src/pages/about.astro | 14 ++- src/pages/downloads.astro | 12 +- src/pages/index.astro | 9 +- src/pages/licenses.astro | 14 ++- tools/site-contract.mjs | 214 +++++++++++++++++++++++++++++++++-- tools/site-contract.test.mjs | 80 +++++++++++++ tools/validate.mjs | 75 ++++++++++++ 18 files changed, 750 insertions(+), 73 deletions(-) create mode 100644 contracts/icon.schema.json create mode 100644 public/favicon.svg create mode 100644 public/mask-icon.svg create mode 100644 src/data/icons.json create mode 100644 src/lib/metadata.ts diff --git a/PROVENANCE.md b/PROVENANCE.md index a6b984e..650c53a 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -23,6 +23,13 @@ immutable Git blob object IDs, source and published digests, published dimensions, transformations, license, notices, and alternative text in `src/data/media.json`. +The crystal favicon and pinned-tab mask under `public/` are new MIT-licensed +vector artwork authored for website issue #27 by Zoey Rose with Codex +implementation assistance. Their exact source paths, Git blob object IDs, +SHA-256 digests, dimensions, transformations, purposes, and notices are closed +records in `src/data/icons.json`; the checked-in SVG is both retained source and +published file. + The synthetic download and media records under `tools/` are contract-only test data. They do not name or contain a real release or asset and are never published by Astro. `src/data/downloads.json` remains empty until exact diff --git a/README.md b/README.md index 4ea8610..21b4816 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,20 @@ from `public/media/`; their exact source Git blob object IDs, digests, dimensions, transformations, licenses, notices, and alternative text are recorded in `src/data/media.json`. +Every indexable route constructs a closed, typed page identity through +`src/lib/metadata.ts`: one unique title and description, canonical URL, robots +policy, internally consistent Open Graph/Twitter fields, preview image +dimensions, and alternative text. `atrinik-now` is the documented sitewide +preview fallback until issue #22 supplies approved replacement artwork; routes +can select a more relevant record from the same validated media catalog. The +homepage also emits the canonical Atrinik `WebSite` identity as safely +serialized inert JSON-LD. The 404 is noindex and deliberately has no canonical, +social-preview, or structured identity. + +The new crystal favicon and pinned-tab mask are compact repository-authored SVG +files. Their closed authorship, license, source, hash, Git blob, dimensions, +transformation, purpose, and notice records live in `src/data/icons.json`. + ## Development Use Node 24.18.1 and npm 11.16.0: @@ -67,8 +81,10 @@ npm run deploy:dry-run The build writes a self-contained static site to `dist/`. Validation rejects unproven media, mutable download coordinates, missing attribution or alt text, unsafe links, repository-authored client JavaScript, broken internal links, and -page-weight budget violations. Generated release evidence belongs under ignored -`build/`. +page-weight budget violations. It permits only inert, locally serialized +`type="application/ld+json"` data blocks; executable scripts, script sources, +event handlers, and JavaScript assets remain forbidden. Generated release +evidence belongs under ignored `build/`. `src/data/downloads.json` is a reviewed immutable catalog, not a live release feed. Its closed schema keeps a release repository separate from an artifact diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 27ced8b..b0e684d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -26,3 +26,7 @@ lockfile. Linked game packages and authored content are not distributed by this source repository and retain their own exact licenses and notices. + +The favicon and pinned-tab mask are new MIT-licensed repository artwork, not +third-party game assets. Their authorship and immutable provenance bindings are +recorded in `src/data/icons.json` and `PROVENANCE.md`. diff --git a/contracts/icon.schema.json b/contracts/icon.schema.json new file mode 100644 index 0000000..16a0afe --- /dev/null +++ b/contracts/icon.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://atrinik.org/contracts/icon.schema.json", + "title": "Atrinik website icon provenance record", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "publicPath", + "sourceRepository", + "sourcePath", + "sourceRevision", + "sourceSha256", + "publishedSha256", + "width", + "height", + "author", + "license", + "transformations", + "purpose", + "notice" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "publicPath": { "enum": ["/favicon.svg", "/mask-icon.svg"] }, + "sourceRepository": { "const": "atrinik/website" }, + "sourcePath": { "type": "string", "pattern": "^public/[a-z-]+\\.svg$" }, + "sourceRevision": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sourceSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "publishedSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "width": { "const": 64 }, + "height": { "const": 64 }, + "author": { "type": "string", "minLength": 1, "maxLength": 200 }, + "license": { "const": "MIT" }, + "transformations": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { "type": "string", "minLength": 1, "maxLength": 300 } + }, + "purpose": { "type": "string", "minLength": 1, "maxLength": 300 }, + "notice": { "type": "string", "minLength": 1, "maxLength": 500 } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11fc770..0df485c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,12 +1,25 @@ # Static website architecture Astro compiles typed local data and `.astro` templates to static HTML and CSS. -No browser JavaScript or server runtime is emitted by the build. `src/data` is -the schema-validated structured metadata and catalog input, while `.astro` -templates contain authored page prose. Closed validators reject unknown -download/media fields and unsafe coordinates before rendering. Cloudflare can -transform a deployed response after this build boundary; provider-injected -security or performance code is not part of `dist/` and is audited separately. +No browser JavaScript or server runtime is emitted by the build. The only +permitted `script` element is inert `type="application/ld+json"` data generated +locally with `<`, `>`, `&`, and JavaScript line separators escaped before raw +HTML insertion. `src/data` is the schema-validated catalog input, the typed +metadata factory owns page identity, and `.astro` templates contain authored +page prose. Closed validators reject unknown download/media/icon fields and +unsafe coordinates before rendering. Cloudflare can transform a deployed +response after this build boundary; provider-injected security or performance +code is not part of `dist/` and is audited separately. + +Every indexable page supplies an explicit metadata object with a unique title, +description, canonical route, index policy, and matched Open Graph/Twitter +identity. Social images resolve only through `src/data/media.json`, including +their canonical local URL, dimensions, and alternative text. The temporary +`atrinik-now` concept image is the explicit sitewide fallback pending issue #22; +pages may choose a more relevant proven catalog record. The homepage alone owns +the canonical `WebSite` JSON-LD record for `https://atrinik.org/` and its two +verified Atrinik GitHub identities. The 404 emits no canonical, preview, or +structured identity and retains `noindex, nofollow`. Downloads remain in their owning GitHub releases. Catalog schema version 2 separates the release repository from the artifact's logical role and marks at @@ -32,6 +45,13 @@ exact license, transformations, alt text, and notice. Same-repository sources are digest-checked from a traversal-safe path. The website never imports an asset tree by implication. +The two SVG site icons use a separate closed catalog because they are +repository-native vector interface artwork rather than page media. Each record +binds the exact checked-in source/published bytes to a Git blob object ID and +SHA-256, 64×64 view box, author, MIT license, transformation, purpose, and +notice. Source validation rejects SVG scripts, event attributes, and external +references; built pages must link both canonical local files. + The executable validator checks the JSON Schema's locally expressible field sets, patterns, formats, bounds, constants, primary-artifact restrictions, and archive suffix rules on every run, without adding a general-purpose runtime @@ -43,7 +63,10 @@ the reviewed Classic release. Tests exercise valid and adversarial forms and contract changes must update both representations and their fixtures together. `public/_headers` supplies a no-script CSP and browser hardening for every -static response. The built-output validator additionally enforces at most 16 +static response. Inert JSON-LD does not relax `script-src 'none'`. The +built-output validator rejects every other script element or script attribute, +parses the JSON-LD, compares it with visible metadata and canonical identity, +and additionally enforces at most 16 generated files, 900,000 bytes total, 140,000 aggregate HTML bytes, 40,000 aggregate CSS bytes, 700,000 aggregate raster image bytes, and zero JavaScript. Published image filenames are content-addressed for immutable caching, and diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..b8ce07b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + Atrinik crystal + + + + + + diff --git a/public/mask-icon.svg b/public/mask-icon.svg new file mode 100644 index 0000000..96bc7f2 --- /dev/null +++ b/public/mask-icon.svg @@ -0,0 +1,4 @@ + + Atrinik crystal mask + + diff --git a/src/data/icons.json b/src/data/icons.json new file mode 100644 index 0000000..a47af6d --- /dev/null +++ b/src/data/icons.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "id": "atrinik-favicon", + "publicPath": "/favicon.svg", + "sourceRepository": "atrinik/website", + "sourcePath": "public/favicon.svg", + "sourceRevision": "b8ce07b4d6eb9d134268b1f13fba95290f6bb5c2", + "sourceSha256": "8b5a901b27d14d2c57bcd844febea646e026cb7ccb34a611837212a2da18ef45", + "publishedSha256": "8b5a901b27d14d2c57bcd844febea646e026cb7ccb34a611837212a2da18ef45", + "width": 64, + "height": 64, + "author": "Zoey Rose with Codex implementation assistance", + "license": "MIT", + "transformations": ["Authored directly as a compact SVG favicon."], + "purpose": "Browser favicon depicting the Atrinik crystal mark.", + "notice": "New repository-native vector artwork created for atrinik/website issue #27." + }, + { + "id": "atrinik-mask-icon", + "publicPath": "/mask-icon.svg", + "sourceRepository": "atrinik/website", + "sourcePath": "public/mask-icon.svg", + "sourceRevision": "96bc7f293ea2a809ac45cdb0094cb82862cb2dde", + "sourceSha256": "84a60770868f27e7d1ac169d90c62f9293bd57e2b238117a699673f2e92535a2", + "publishedSha256": "84a60770868f27e7d1ac169d90c62f9293bd57e2b238117a699673f2e92535a2", + "width": 64, + "height": 64, + "author": "Zoey Rose with Codex implementation assistance", + "license": "MIT", + "transformations": [ + "Simplified from the new favicon geometry into a monochrome SVG mask." + ], + "purpose": "Pinned-tab mask icon depicting the Atrinik crystal mark.", + "notice": "New repository-native vector artwork created for atrinik/website issue #27." + } + ] +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 18bf9c3..dc91e4e 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,29 +1,16 @@ --- import site from "../data/site.json"; -import media from "../data/media.json"; import "../styles/global.css"; +import type { PageMetadata } from "../lib/metadata"; +import { serializeJsonLd } from "../lib/metadata"; interface Props { - title?: string; - description?: string; - canonicalPath?: string; + metadata: PageMetadata; preloadImage?: string; - noindex?: boolean; } -const title = Astro.props.title - ? `${Astro.props.title} · ${site.title}` - : site.title; -const description = Astro.props.description ?? site.description; -const canonical = new URL( - Astro.props.canonicalPath ?? Astro.url.pathname, - site.canonicalOrigin, -); +const { metadata } = Astro.props; const currentPath = Astro.url.pathname; -const socialImage = media.entries.find((entry) => entry.id === "atrinik-now"); -if (!socialImage) throw new Error("missing social media record: atrinik-now"); -const socialImageUrl = new URL(socialImage.publicPath, site.canonicalOrigin); -const socialImageAlt = `Temporary OpenAI-generated website concept artwork: ${socialImage.alt}`; const navigation = [ { label: "Home", href: "/" }, { label: "About", href: "/about/" }, @@ -39,29 +26,67 @@ const isCurrent = (href: string) => - + + + + + + { - Astro.props.noindex ? ( - + metadata.canonicalUrl ? ( + ) : null } - - - - - - - - - - - - - - - - - + { + metadata.openGraph ? ( + <> + + + + + + + + + + + ) : null + } + { + metadata.twitter ? ( + <> + + + + + + + ) : null + } + { + metadata.structuredData?.map((record) => ( + ', + ), + { canonicalUrl: "https://atrinik.org/about/" }, + ), + /script block/u, + ); + const noindex = + 'Not found'; + assert.doesNotThrow(() => validatePageMetadata(noindex)); + assert.throws( + () => validatePageMetadata(`${noindex}`), + /script block/u, + ); +}); + test("same-repository media binds source bytes and Git blob object", async (context) => { const root = await mkdtemp(join(tmpdir(), "atrinik-website-source-test-")); context.after(async () => rm(root, { recursive: true })); diff --git a/tools/validate.mjs b/tools/validate.mjs index be39813..f529874 100644 --- a/tools/validate.mjs +++ b/tools/validate.mjs @@ -10,8 +10,10 @@ import { validateDownloadCatalog, validateDownloadSchemaDefinition, validateDownloadsPresentation, + validateIcon, validateLocalMediaSource, validateMedia, + validatePageMetadata, validateRedirects, } from "./site-contract.mjs"; @@ -22,6 +24,7 @@ if (!new Set(["source", "dist"]).has(mode)) const downloads = await readJson(resolve(root, "src/data/downloads.json")); const media = await readJson(resolve(root, "src/data/media.json")); +const icons = await readJson(resolve(root, "src/data/icons.json")); validateDownloadCatalog(downloads); if ( media.schemaVersion !== 1 || @@ -30,6 +33,13 @@ if ( ) throw new Error("invalid media catalog envelope"); media.entries.forEach(validateMedia); +if ( + icons.schemaVersion !== 1 || + !Array.isArray(icons.entries) || + icons.entries.length !== 2 +) + throw new Error("invalid icon catalog envelope"); +icons.entries.forEach(validateIcon); if ( new Set(media.entries.map((record) => record.id)).size !== media.entries.length @@ -70,6 +80,24 @@ for (const record of media.entries) { ) throw new Error(`published media digest mismatch: ${record.id}`); } +for (const record of icons.entries) { + await validateLocalMediaSource(root, record); + if ( + (await digest(resolve(root, `public${record.publicPath}`))) !== + record.publishedSha256 + ) + throw new Error(`published icon digest mismatch: ${record.id}`); + const source = await readFile( + resolve(root, `public${record.publicPath}`), + "utf8", + ); + if ( + !source.startsWith(" identity[field]) + .filter((value) => value !== null); + if (new Set(values).size !== values.length) + throw new Error(`indexable page ${field} values are not unique`); + } validateDownloadsPresentation( await readFile(resolve(root, "dist/downloads/index.html"), "utf8"), downloads, From 3818a4a85ec8c42f8bc285f9dcc2afcf4cacffbc Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Tue, 11 Aug 2026 16:19:37 +0000 Subject: [PATCH 2/6] fix(metadata): close validation bypasses --- src/lib/metadata.ts | 4 +- src/pages/licenses.astro | 1 - tools/site-contract.mjs | 95 +++++++++++++++++++++++++---- tools/site-contract.test.mjs | 115 ++++++++++++++++++++++++++++++++++- tools/validate.mjs | 81 ++++++++++++++++-------- 5 files changed, 254 insertions(+), 42 deletions(-) diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts index 89f65da..7bd4fed 100644 --- a/src/lib/metadata.ts +++ b/src/lib/metadata.ts @@ -61,7 +61,9 @@ function socialImage(id: string): SocialImageMetadata { url: new URL(record.publicPath, site.canonicalOrigin).href, width: record.width, height: record.height, - alt: `Temporary OpenAI-generated website concept artwork: ${record.alt}`, + alt: record.author.includes("OpenAI image generation") + ? `Temporary OpenAI-generated website concept artwork: ${record.alt}` + : record.alt, }; } diff --git a/src/pages/licenses.astro b/src/pages/licenses.astro index 8bcfecd..e6813c1 100644 --- a/src/pages/licenses.astro +++ b/src/pages/licenses.astro @@ -8,7 +8,6 @@ const metadata = indexablePage({ description: "Understand the distinct authorship, provenance, attribution, and license boundaries for Atrinik software, game content, and website presentation media.", canonicalPath: "/licenses/", - socialImageId: "atrinik-beyond", }); --- diff --git a/tools/site-contract.mjs b/tools/site-contract.mjs index 80efa6d..493f24f 100644 --- a/tools/site-contract.mjs +++ b/tools/site-contract.mjs @@ -483,12 +483,13 @@ export function validateIcon(record) { throw new Error("invalid icon digest"); if (record.width !== 64 || record.height !== 64) throw new Error("invalid icon dimensions"); - for (const field of ["author", "purpose", "notice"]) + const stringBounds = { author: 200, purpose: 300, notice: 500 }; + for (const [field, maximum] of Object.entries(stringBounds)) if ( typeof record[field] !== "string" || record[field].trim() !== record[field] || record[field].length === 0 || - record[field].length > 500 + record[field].length > maximum ) throw new Error(`invalid icon ${field}`); if ( @@ -507,6 +508,35 @@ export function validateIcon(record) { throw new Error("invalid icon license or transformations"); } +export function validateIconCatalog(entries) { + if (!Array.isArray(entries) || entries.length !== 2) + throw new Error("invalid icon catalog envelope"); + entries.forEach(validateIcon); + const ids = new Set(entries.map(({ id }) => id)); + const paths = new Set(entries.map(({ publicPath }) => publicPath)); + if ( + ids.size !== entries.length || + paths.size !== entries.length || + [...paths].sort().join("\n") !== + ["/favicon.svg", "/mask-icon.svg"].sort().join("\n") + ) + throw new Error( + "icon catalog identities and paths must be unique and complete", + ); +} + +export function validateSvgIconSource(source, id = "icon") { + if ( + typeof source !== "string" || + !source.startsWith("]*)>([\s\S]*?)<\/script>/giu), - ]; - if ([...html.matchAll(/ { + const lower = html.toLowerCase(); + const scripts = []; + let cursor = 0; + while (cursor < html.length) { + let opening = lower.indexOf("]/u.test(lower[opening + "", opening + "", closingNameEnd); + if ( + closingEnd === -1 || + lower.slice(closingNameEnd, closingEnd).trim() !== "" + ) + throw new Error("malformed or unclosed script block"); + scripts.push({ + rawAttributes: html.slice(opening + " { if (rawAttributes.trim() !== 'type="application/ld+json"') throw new Error("executable or attributed script block forbidden"); if (/[<>&\u2028\u2029]/u.test(body)) @@ -717,7 +777,7 @@ export function parseInertJsonLd(html) { export function validatePageMetadata( html, - { canonicalUrl = null, websiteIdentity = false } = {}, + { canonicalUrl = null, websiteIdentity = false, allowedMedia = null } = {}, ) { const titleMatches = [...html.matchAll(/([^<]+)<\/title>/gu)]; if (titleMatches.length !== 1) @@ -768,12 +828,20 @@ export function validatePageMetadata( const twitterImage = metadataContent(html, "name", "twitter:image"); const twitterImageAlt = metadataContent(html, "name", "twitter:image:alt"); const imageUrl = new URL(ogImage); + const expectedImage = allowedMedia?.get(imageUrl.pathname); + const expectedImageAlt = expectedImage + ? expectedImage.author.includes("OpenAI image generation") + ? `Temporary OpenAI-generated website concept artwork: ${expectedImage.alt}` + : expectedImage.alt + : null; if ( ogType !== "website" || ogTitle !== title || ogDescription !== description || ogUrl !== canonicalUrl || imageUrl.origin !== "https://atrinik.org" || + imageUrl.search !== "" || + imageUrl.hash !== "" || !imageUrl.pathname.startsWith("/media/") || !/^[1-9][0-9]*$/u.test(ogWidth) || !/^[1-9][0-9]*$/u.test(ogHeight) || @@ -782,7 +850,12 @@ export function validatePageMetadata( twitterTitle !== title || twitterDescription !== description || twitterImage !== ogImage || - twitterImageAlt !== ogImageAlt + twitterImageAlt !== ogImageAlt || + (allowedMedia !== null && + (!expectedImage || + Number(ogWidth) !== expectedImage.width || + Number(ogHeight) !== expectedImage.height || + ogImageAlt !== expectedImageAlt)) ) throw new Error("Open Graph and Twitter metadata are inconsistent"); @@ -867,7 +940,7 @@ export async function validateDist( ) throw new Error("404 page must be excluded from indexing"); parseInertJsonLd(html); - if (/\son[a-z]+=/iu.test(html)) + if (/\son[a-z][a-z0-9_-]*\s*=/iu.test(html)) throw new Error(`client script/event handler forbidden in ${path}`); for (const match of html.matchAll(/]*>/giu)) { containsImages = true; diff --git a/tools/site-contract.test.mjs b/tools/site-contract.test.mjs index 95e75c6..f325c04 100644 --- a/tools/site-contract.test.mjs +++ b/tools/site-contract.test.mjs @@ -21,6 +21,7 @@ import { limits, digest, gitBlobObjectId, + parseInertJsonLd, readWebpDimensions, validateDist, validateDownload, @@ -28,11 +29,13 @@ import { validateDownloadSchemaDefinition, validateDownloadsPresentation, validateIcon, + validateIconCatalog, validateLocalMediaSource, validateMedia, validatePageMetadata, validatePresentationCss, validateRedirects, + validateSvgIconSource, } from "./site-contract.mjs"; const root = resolve(import.meta.dirname, ".."); @@ -43,6 +46,18 @@ const accessibleShell = const completeMetadataShell = 'About Atrinik'; +const allowedSocialMedia = new Map([ + [ + "/media/social.00000000.webp", + { + width: 1120, + height: 630, + alt: "A useful social image alternative", + author: "Example Human", + }, + ], +]); + const validMedia = { id: "licensed-image", publicPath: "/media/licensed.44444444.webp", @@ -450,12 +465,49 @@ test("icon records require closed local SVG provenance", () => { () => validateIcon({ ...validIcon, width: 32 }), /icon dimensions/u, ); + assert.doesNotThrow(() => + validateIconCatalog([ + validIcon, + { + ...validIcon, + id: "atrinik-mask-icon", + publicPath: "/mask-icon.svg", + sourcePath: "public/mask-icon.svg", + }, + ]), + ); + assert.throws( + () => validateIconCatalog([validIcon, { ...validIcon }]), + /unique and complete/u, + ); + assert.throws( + () => validateIcon({ ...validIcon, author: "a".repeat(201) }), + /icon author/u, + ); + assert.doesNotThrow(() => + validateSvgIconSource(''), + ); + assert.throws( + () => + validateSvgIconSource( + '', + ), + /unsafe icon/u, + ); + assert.throws( + () => + validateSvgIconSource( + '', + ), + /unsafe icon/u, + ); }); test("page metadata stays complete, consistent, and safely inert", () => { assert.deepEqual( validatePageMetadata(completeMetadataShell, { canonicalUrl: "https://atrinik.org/about/", + allowedMedia: allowedSocialMedia, }), { title: "About Atrinik", @@ -470,7 +522,10 @@ test("page metadata stays complete, consistent, and safely inert", () => { '', '', ), - { canonicalUrl: "https://atrinik.org/about/" }, + { + canonicalUrl: "https://atrinik.org/about/", + allowedMedia: allowedSocialMedia, + }, ), /inconsistent/u, ); @@ -481,17 +536,67 @@ test("page metadata stays complete, consistent, and safely inert", () => { "", '', ), - { canonicalUrl: "https://atrinik.org/about/" }, + { + canonicalUrl: "https://atrinik.org/about/", + allowedMedia: allowedSocialMedia, + }, ), /script block/u, ); const noindex = 'Not found'; assert.doesNotThrow(() => validatePageMetadata(noindex)); + assert.throws( + () => + validatePageMetadata( + completeMetadataShell.replace('content="1120"', 'content="1119"'), + { + canonicalUrl: "https://atrinik.org/about/", + allowedMedia: allowedSocialMedia, + }, + ), + /inconsistent/u, + ); assert.throws( () => validatePageMetadata(`${noindex}`), /script block/u, ); + assert.deepEqual( + parseInertJsonLd( + '', + ), + [{ text: "" }], + ); + assert.throws( + () => + parseInertJsonLd( + '"}', + ), + /safely serialized|valid JSON|script/u, + ); + assert.deepEqual( + parseInertJsonLd( + '', + ), + [{ safe: true }], + ); + assert.deepEqual( + parseInertJsonLd( + '', + ), + [{ safe: true }], + ); + assert.throws( + () => + parseInertJsonLd( + '', + ), + /malformed/u, + ); + assert.throws( + () => parseInertJsonLd("

stray closing tag

"), + /malformed/u, + ); }); test("same-repository media binds source bytes and Git blob object", async (context) => { @@ -785,6 +890,12 @@ test("static output rejects scripts, broken links, and excessive files", async ( ); await assert.rejects(validateDist(root), /exactly one h1/u); await writeFile(join(root, "index.html"), accessibleShell); + await writeFile( + join(root, "index.html"), + accessibleShell.replace(" record.id)).size !== media.entries.length @@ -91,12 +87,7 @@ for (const record of icons.entries) { resolve(root, `public${record.publicPath}`), "utf8", ); - if ( - !source.startsWith(" + path.endsWith(".html"), + ); const identities = []; - for (const [path, canonicalUrl, websiteIdentity] of pageContracts) { - const html = await readFile(resolve(root, `dist/${path}`), "utf8"); - for (const icon of icons.entries) - if (!html.includes(`href="${icon.publicPath}"`)) - throw new Error(`${path} omits icon ${icon.id}`); + const allowedMedia = new Map( + media.entries.map((record) => [record.publicPath, record]), + ); + for (const path of htmlPaths) { + const relativePath = relative(resolve(root, "dist"), path).replaceAll( + sep, + "/", + ); + let canonicalUrl = null; + if (relativePath !== "404.html") { + const canonicalPath = + relativePath === "index.html" + ? "/" + : relativePath.endsWith("/index.html") + ? `/${relativePath.slice(0, -"index.html".length)}` + : `/${relativePath}`; + canonicalUrl = new URL(canonicalPath, site.canonicalOrigin).href; + if (!sitemapUrls.includes(canonicalUrl)) + throw new Error( + `indexable generated route is absent from sitemap: ${relativePath}`, + ); + } + const html = await readFile(path, "utf8"); + const faviconLinks = [ + ...html.matchAll( + /

stray closing tag

"), /malformed/u, ); + assert.throws(() => parseInertJsonLd("