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 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 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 Redirecting to {target}.