Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
105 changes: 104 additions & 1 deletion scripts/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1030,3 +1039,97 @@ 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 states the target three times and nothing else', () => {
const html = citeRedirectHtml('/id/ref/uuid/', false);
assert.equal(
html,
'<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">' +
'<meta http-equiv="refresh" content="0; url=/id/ref/uuid/">' +
'<title>Redirecting\u2026</title>' +
'<link rel="canonical" href="/id/ref/uuid/">' +
'</head><body><p>Redirecting to ' +
'<a href="/id/ref/uuid/">/id/ref/uuid/</a>.</p></body></html>',
);
});

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(
'<meta name="robots" content="noindex">',
),
);
});

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'));
});
89 changes: 87 additions & 2 deletions scripts/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1058,6 +1065,83 @@ export function datapackageDescriptor(
/** The filename of the descriptor itself, which no resource entry describes. */
export const DATAPACKAGE_FILENAME = 'datapackage.json';

/**
* One `/cite/` redirect page.
*
* Byte-for-byte what `src/pages/cite/[...alias].astro` rendered before the
* route moved here, minus the module script Astro injects into every page: a
* page that leaves before it paints has no use for the prefetch runtime.
*
* `target` is always a site path that `iriToLocal` produced, so it holds a
* record type and a flat key or a UUID and 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 (
'<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">' +
`<meta http-equiv="refresh" content="0; url=${target}">` +
'<title>Redirecting\u2026</title>' +
`<link rel="canonical" href="${target}">` +
(noindex ? '<meta name="robots" content="noindex">' : '') +
'</head><body><p>Redirecting to ' +
`<a href="${target}">${target}</a>.</p></body></html>`
);
}

/**
* 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<string>();
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 });
Expand Down Expand Up @@ -1085,13 +1169,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(
Expand Down
52 changes: 52 additions & 0 deletions src/integrations/cite-dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// `/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 }) => {
// Both are derived from the whole registry, so they are built on
// the first `/cite/` request and kept for the life of the server —
// the same lifetime the compiled registry itself has.
let aliases: Record<string, string> | undefined;
let draft: Set<string> | undefined;

server.middlewares.use((req, res, next) => {
const path = (req.url ?? '/').split('?')[0];
if (!path.startsWith(PREFIX)) return next();

aliases ??= loadAliases();
draft ??= draftRecordIris();

const alias = decodeURIComponent(path.slice(PREFIX.length)).replace(
/\/$/,
'',
);
Comment thread
maehr marked this conversation as resolved.
Outdated
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)));
});
},
},
};
}
12 changes: 4 additions & 8 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
32 changes: 0 additions & 32 deletions src/pages/cite/[...alias].astro

This file was deleted.

16 changes: 16 additions & 0 deletions standard/iri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading