diff --git a/astro.config.mjs b/astro.config.mjs index 58e008d..57e0b8c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -8,6 +8,7 @@ import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi'; import starlightBlog from 'starlight-blog'; import sitemap from '@astrojs/sitemap'; import { buildSitemapExclusion } from './src/lib/sitemap.ts'; +import citeDev from './src/integrations/cite-dev.ts'; const siteDomain = process.env.SITE_DOMAIN ?? 'textrefs.org'; const site = siteDomain.startsWith('http') @@ -20,6 +21,9 @@ export default defineConfig({ site, integrations: [ mermaid({ autoTheme: true }), + // `/cite/` has no route: `scripts/compile.ts` writes the redirect pages + // after `astro build`. This serves them in `astro dev` only. + citeDev(), // Declared explicitly so the sitemap can drop the pages `src/lib/sitemap.ts` // rules out; Starlight adds `@astrojs/sitemap` with default options only // when the project has not already registered it. diff --git a/package.json b/package.json index 718d399..6633aa4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "format:check": "prettier --check .", "verify": "npm run format:check && npm run check && npm test && npm run build", "verify:fast": "npm run format:check && npm run check:fast && npm test && npm run build:fast", - "check:links": "astro build && lychee --root-dir \"$(pwd)/dist\" --config lychee.toml dist", + "check:links": "npm run build && lychee --root-dir \"$(pwd)/dist\" --config lychee.toml dist", "changelog": "git-cliff -o CHANGELOG.md", "changelog:unreleased": "git-cliff --unreleased --prepend CHANGELOG.md", "prepare": "husky" diff --git a/scripts/compile.test.ts b/scripts/compile.test.ts index a45f7aa..516e2b7 100644 --- a/scripts/compile.test.ts +++ b/scripts/compile.test.ts @@ -6,13 +6,22 @@ // a hand-written CompiledRegistry. import { test, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + rmSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { v5 as uuidv5 } from 'uuid'; import { createHash } from 'node:crypto'; import { compileRegistry, + citeRedirectHtml, + writeCiteRedirects, dumpResources, describeResource, DUMP_MANIFEST, @@ -1030,3 +1039,108 @@ test('the JSONL bodies are unchanged by the alias-resource refactor', () => { '', ); }); + +// `/cite/` redirects. The pages are written here rather than rendered by Astro +// (see `writeCiteRedirects`), so `npm run build:fast` never exercises them and +// these tests are the only cover the writer has. + +test('a redirect page carries the target, a title, a lang and nothing else', () => { + const html = citeRedirectHtml('/id/ref/uuid/', false); + assert.equal( + html, + '' + + 'Redirecting' + + '' + + '' + + '

Redirecting to the record.', + ); +}); + +test('the redirect page stays small, because there are 172,794 of them', () => { + // A UUID target is the longest form the compiler writes. + const html = citeRedirectHtml( + '/id/ref/dc799d4b-9b17-5d76-85aa-dfd001c5321d/', + true, + ); + assert.ok( + Buffer.byteLength(html) <= 410, + `redirect page grew to ${Buffer.byteLength(html)} bytes`, + ); +}); + +test('only the alias of a draft record carries noindex', () => { + assert.ok(!citeRedirectHtml('/id/ref/uuid/', false).includes('noindex')); + assert.ok( + citeRedirectHtml('/id/ref/uuid/', true).includes( + '', + ), + ); +}); + +test('every /cite/ alias gets a page, and no external identifier does', () => { + const reg = compileFixture( + workWithMappings(` +additional_systems: + - citation_system: fallback-section + references: + - '5' +`), + ); + const root = mkdtempSync(join(tmpdir(), 'textrefs-cite-')); + try { + const written = writeCiteRedirects(reg, root); + + // Three alias paths, and two mapping identifiers that are not routes. + assert.equal(written, 3); + assert.deepEqual(readdirSync(root), ['test.work']); + + const page = (alias: string) => + readFileSync(join(root, alias, 'index.html'), 'utf-8'); + const primary = reg.aliases['test.work/primary-section/5']; + assert.equal(page('test.work/5'), page('test.work/primary-section/5')); + assert.ok(page('test.work/5').includes(`content="0;url=/id/ref/`)); + assert.ok(page('test.work/5').includes(primary.split('/').pop() ?? '')); + assert.notEqual( + page('test.work/fallback-section/5'), + page('test.work/primary-section/5'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a draft reference gets a noindex redirect, an active one does not', () => { + const draft = compileFixture({ + systems: twoSystems, + works: { + 'test.work': `${workHeader('draft')} +citation_system: primary-section +references: + - '5' +`, + }, + }); + const active = compileFixture({ + systems: twoSystems, + works: { + 'test.work': `${workHeader()} +citation_system: primary-section +references: + - '5' +`, + }, + }); + + const pageFor = (reg: CompiledRegistry) => { + const root = mkdtempSync(join(tmpdir(), 'textrefs-cite-')); + try { + writeCiteRedirects(reg, root); + return readFileSync(join(root, 'test.work', '5', 'index.html'), 'utf-8'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + + assert.ok(pageFor(draft).includes('noindex')); + assert.ok(!pageFor(active).includes('noindex')); +}); diff --git a/scripts/compile.ts b/scripts/compile.ts index 05c9efc..827103e 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -17,7 +17,14 @@ import { MappingAssertion, } from '../standard/schema/index.js'; import type { ResolverTargetEntry } from '../standard/schema/canonical-reference.js'; -import { workIri, systemIri, refIri, mappingIri } from '../standard/iri.js'; +import { + workIri, + systemIri, + refIri, + mappingIri, + iriToLocal, +} from '../standard/iri.js'; +import { isDraft } from '../src/lib/record-status.js'; import { parseSource, SystemSource, @@ -1058,6 +1065,89 @@ export function datapackageDescriptor( /** The filename of the descriptor itself, which no resource entry describes. */ export const DATAPACKAGE_FILENAME = 'datapackage.json'; +/** + * One `/cite/` redirect page, in 370 bytes. + * + * Four things earn their place, and nothing else does. The refresh performs the + * redirect, because GitHub Pages serves no `Location` header. The canonical + * link states the target for a client that ignores the refresh. The title and + * the `lang` attribute keep the page at the WCAG 2.2 AA baseline the site + * holds everywhere else (SC 2.4.2 and SC 3.1.1). The paragraph gives a person + * a link to follow when nothing redirects them. + * + * ``, `` and `` are implied and left out. The target is + * spelled twice, not three times: as the link text it added 44 bytes a page, + * and the reader already has it in the address bar. + * + * `target` is always a site path that `iriToLocal` produced, so it holds a + * record type and a flat key or a UUID, and it needs no escaping. + * + * ADR-0003: an alias of a draft record is `noindex`, exactly like the record it + * redirects to. + */ +export function citeRedirectHtml(target: string, noindex: boolean): string { + return ( + '' + + 'Redirecting' + + `` + + `` + + (noindex ? '' : '') + + `

Redirecting to the record.` + ); +} + +/** + * Write `dist/cite/{alias}/index.html` for every alias the compiler minted. + * + * These pages are output, not routes. Astro rendered 172,794 of them through + * the full route pipeline to produce four tags each, and Pagefind then opened + * every one of them to find no `data-pagefind-body`. Writing them here, after + * `astro build`, costs a `writeFileSync` per alias and hides them from both. + * `/dump/` already works this way. + * + * The alias table mixes two key kinds: a `/cite/` alias path, and an external + * mapping identifier. Only the first is a route, and an identifier is an IRI, + * so the `://` test that the Astro route applied still separates them. + * + * A draft target is `noindex`. The rule matches `draftRecordIris` in + * `src/lib/sitemap.ts`, which keeps the same pages out of the sitemap. + * + * Returns the number of pages written. `citeRootOverride` exists for the tests, + * like `dataRootOverride` on `compileRegistry`. + */ +export function writeCiteRedirects( + registry: CompiledRegistry, + citeRootOverride?: string, +): number { + const draft = new Set(); + for (const record of [ + ...registry.works, + ...registry.systems, + ...registry.references, + ...registry.mappings, + ]) { + if (isDraft(record.status)) draft.add(record.id); + } + + const citeDir = citeRootOverride ?? join(distRoot, 'cite'); + let written = 0; + for (const [alias, target] of Object.entries(registry.aliases)) { + if (alias.includes('://')) continue; + // An alias needs a directory of its own, because the URL it serves ends + // in a slash. `recursive` is what makes the call idempotent: the parent + // exists after the first sibling, and a bare alias may reuse a directory + // a qualified one already created. + const dir = join(citeDir, alias); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'index.html'), + citeRedirectHtml(iriToLocal(target), draft.has(target)), + ); + written++; + } + return written; +} + function writeDump(registry: CompiledRegistry, version: string): void { const dumpDir = join(distRoot, 'dump'); mkdirSync(dumpDir, { recursive: true }); @@ -1085,13 +1175,14 @@ if (isCliEntry) { const registry = compileRegistry(); const version = readPackageVersion(); writeDump(registry, version); + const redirects = writeCiteRedirects(registry); const totalRecords = registry.works.length + registry.systems.length + registry.references.length + registry.mappings.length; console.log( - `✓ compiled ${registry.systems.length} system(s), ${registry.works.length} work(s), ${registry.references.length} reference(s), ${registry.mappings.length} mapping(s); ${Object.keys(registry.aliases).length} alias(es); ${totalRecords} records in dump`, + `✓ compiled ${registry.systems.length} system(s), ${registry.works.length} work(s), ${registry.references.length} reference(s), ${registry.mappings.length} mapping(s); ${Object.keys(registry.aliases).length} alias(es), ${redirects} /cite/ redirect(s); ${totalRecords} records in dump`, ); if (registry.warnings > 0) { console.warn( diff --git a/src/integrations/cite-dev.ts b/src/integrations/cite-dev.ts new file mode 100644 index 0000000..08c58ff --- /dev/null +++ b/src/integrations/cite-dev.ts @@ -0,0 +1,60 @@ +// `/cite/` in `astro dev`. +// +// The redirect pages are output, not routes: `scripts/compile.ts` writes all +// 172,794 of them after `astro build`, which keeps them out of Astro's render +// pipeline and out of Pagefind's scan. `astro dev` runs neither, so without +// this integration every alias link on a reference page would 404 in +// development. +// +// The page body comes from `citeRedirectHtml`, the same function the compiler +// writes with, so the dev server and the published site cannot disagree. +import type { AstroIntegration } from 'astro'; +import { citeRedirectHtml } from '../../scripts/compile.js'; +import { iriToLocal } from '../../standard/iri.js'; +import { loadAliases } from '../lib/registry.js'; +import { draftRecordIris } from '../lib/sitemap.js'; + +const PREFIX = '/cite/'; + +export default function citeDev(): AstroIntegration { + return { + name: 'textrefs:cite-dev', + hooks: { + 'astro:server:setup': ({ server }) => { + // Kept for the life of the server, the same lifetime the compiled + // registry itself has. + let aliases: Record | undefined; + let draft: Set | undefined; + + server.middlewares.use((req, res, next) => { + const path = (req.url ?? '/').split('?')[0]; + if (!path.startsWith(PREFIX)) return next(); + + // `decodeURIComponent` throws on a malformed escape, and the URL + // belongs to whoever typed it. A request that cannot be decoded + // names no alias, so it takes the same path as an unknown one. + let alias: string; + try { + alias = decodeURIComponent(path.slice(PREFIX.length)); + } catch { + return next(); + } + alias = alias.replace(/\/$/, ''); + + // Both are derived from the whole registry, so the first + // `/cite/` request pays for them and the rest do not. + aliases ??= loadAliases(); + draft ??= draftRecordIris(); + + const target = aliases[alias]; + // Unknown alias, or an external identifier that never had a page: + // hand it back to Astro so the 404 looks like every other one. + if (target === undefined || alias.includes('://')) return next(); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(citeRedirectHtml(iriToLocal(target), draft.has(target))); + }); + }, + }, + }; +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 6a2a07d..29c3ab7 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -4,6 +4,10 @@ import { } from '../../scripts/compile.js'; import { fixtureRegistry } from './registry.fixture.js'; +// Re-exported, not defined here: `scripts/compile.ts` writes the `/cite/` +// redirects and needs it too, and it cannot import this module without a cycle. +export { iriToLocal } from '../../standard/iri.js'; + // Astro loads `astro.config.mjs` in a module graph of its own, separate from // the one the pages render in, so this module is instantiated twice in a single // build and a module-level memo is not shared between the two. Compiling the @@ -59,11 +63,3 @@ export function uuidOf(iri: string): string { export function workKeyOf(iri: string): string { return iri.replace(/^https:\/\/textrefs\.org\/id\/work\//, ''); } - -export function iriToLocal(iri: string): string { - const m = iri.match( - /^https:\/\/textrefs\.org\/id\/(work|system|ref|mapping)\/(.+)$/, - ); - if (m) return `/id/${m[1]}/${m[2]}/`; - return iri; -} diff --git a/src/pages/cite/[...alias].astro b/src/pages/cite/[...alias].astro deleted file mode 100644 index 870d570..0000000 --- a/src/pages/cite/[...alias].astro +++ /dev/null @@ -1,32 +0,0 @@ ---- -import { loadAliases, iriToLocal } from '../../lib/registry.ts'; -import { draftRecordIris } from '../../lib/sitemap.ts'; - -export async function getStaticPaths() { - const aliases = loadAliases(); - // ADR-0003: an alias of a draft record is `noindex` like the record itself. - const draft = draftRecordIris(); - return Object.entries(aliases) - .filter(([key]) => !key.includes('://')) - .map(([key, target]) => ({ - params: { alias: key }, - props: { target: iriToLocal(target), noindex: draft.has(target) }, - })); -} - -const { target, noindex } = Astro.props; ---- - - - - - - - Redirecting… - - {noindex && } - - -

Redirecting to {target}.

- - diff --git a/standard/iri.ts b/standard/iri.ts index dabaff0..511f4a0 100644 --- a/standard/iri.ts +++ b/standard/iri.ts @@ -36,3 +36,19 @@ export function refIri(uuid: string): string { export function mappingIri(uuid: string): string { return `${BASE}/id/mapping/${uuid}`; } + +/** + * The site path a record IRI is served at, or the IRI itself when it names no + * record — `https://textrefs.org/id/ref/{uuid}` → `/id/ref/{uuid}/`. + * + * The inverse of the four builders above, so it lives with them. It also has to + * live outside `src/lib/`: `scripts/compile.ts` writes the `/cite/` redirects + * and needs the target path, while `src/lib/registry.ts` imports the compiler. + */ +export function iriToLocal(iri: string): string { + const m = iri.match( + /^https:\/\/textrefs\.org\/id\/(work|system|ref|mapping)\/(.+)$/, + ); + if (m) return `/id/${m[1]}/${m[2]}/`; + return iri; +}