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
13 changes: 7 additions & 6 deletions .github/workflows/docs-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ jobs:
working-directory: docs/site
run: node scripts/generate-standard-journeys.mjs --check

- name: Build Main-source preview
- name: Build unreleased Main documentation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

Commit bdd43f39706fd4d548f51367767a7f02b987f085 has no Signed-off-by trailer, so it does not satisfy the repository's mandatory DCO policy; recreate this commit with the required sign-off before merging.

AGENTS.md reference: AGENTS.md:L68-L70

Useful? React with 👍 / 👎.

working-directory: docs/site
run: |
npm run generate
DOCS_DOCSET=latest DOCS_BASE=/preview/ npx astro check
DOCS_DOCSET=latest DOCS_BASE=/preview/ npx astro build --outDir dist/preview
DOCS_DOCSET=latest DOCS_BASE=/dev/ npx astro check
DOCS_DOCSET=latest DOCS_BASE=/dev/ npx astro build --outDir dist/dev
node scripts/apply-archive-seo.mjs dist/dev
env:
PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.PUBLIC_UMAMI_WEBSITE_ID }}
PUBLIC_UMAMI_SCRIPT_SRC: ${{ vars.PUBLIC_UMAMI_SCRIPT_SRC }}
Expand All @@ -107,16 +108,16 @@ jobs:
working-directory: docs/site
run: npm run assemble:archives -- --bootstrap

- name: Stage released-root routing
- name: Promote released documentation to the canonical root
working-directory: docs/site
run: npm run stage:production-docsets

- name: Verify current machine-readable documentation
working-directory: docs/site
run: npm run check:llms:built
env:
DOCS_DIST_DIR: ${{ github.workspace }}/docs/site/dist/preview
DOCS_PUBLIC_BASE: /preview/
DOCS_DIST_DIR: ${{ github.workspace }}/docs/site/dist/dev
DOCS_PUBLIC_BASE: /dev/

- name: Verify assembled documentation tree
working-directory: docs/site
Expand Down
13 changes: 13 additions & 0 deletions docs/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ The release workflow repeats those steps from the exact annotated tag and
publishes `registry-docs-vX.Y.Z.tar.gz` with the other signed, SBOM-covered,
SLSA-provenanced release files.

## Published layout

The production site uses one indexable namespace:

- `/` serves the latest released documentation with self-canonical URLs and the public sitemap.
- `/dev/` serves unreleased documentation built from `main` with `noindex,follow`.
- `/v/<version>/` serves immutable release archives with `noindex,follow`.
- `/preview/` keeps old links working by redirecting matching pages to `/`.

The Pages workflow verifies the selected release archive against
`src/data/archive-lock.yaml`, copies that locked tree into `/`, and changes URLs and SEO metadata only
in the promoted copy. The immutable `/v/<version>/` tree and its release asset are not changed.

## Content Sources

Data-backed reference tables are generated from:
Expand Down
20 changes: 12 additions & 8 deletions docs/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function loadDocsetsManifest() {
}

/**
* @param {{ current: string, released: string, docsets: Array<{ id: string, status: string }> }} docsets
* @param {{ current: string, released: string, docsets: Array<{ id: string, status: string, availability: string, path: string }> }} docsets
* @param {NodeJS.ProcessEnv} env
*/
export function resolveDocsetBuildContext(docsets, env = process.env) {
Expand All @@ -54,19 +54,23 @@ export function resolveDocsetBuildContext(docsets, env = process.env) {
const base = env.DOCS_BASE || undefined;
const basePath = base?.replace(/\/$/, '');
const isArchivedBuild = selectedDocset.status === 'archived';
const isHistoricalArchiveBuild =
isArchivedBuild && selectedDocset.id !== docsets.released;
const isSearchExcludedBuild =
isArchivedBuild || selectedDocset.availability === 'unreleased';
const currentDocset = docsets.docsets.find((entry) => entry.id === docsets.current);
if (!currentDocset) throw new Error(`current docs docset "${docsets.current}" not found`);
/** @param {string} path */
const internalRedirect = (path) => basePath ? `${basePath}${path}` : path;
/** @param {string} path */
const currentDocsetRedirect = (path) =>
isArchivedBuild ? `https://docs.registrystack.org/preview${path}` : internalRedirect(path);
isArchivedBuild
? `https://docs.registrystack.org${currentDocset.path.replace(/\/$/, '')}${path}`
: internalRedirect(path);

return {
base,
basePath,
isArchivedBuild,
isHistoricalArchiveBuild,
isSearchExcludedBuild,
internalRedirect,
currentDocsetRedirect,
};
Expand All @@ -76,7 +80,7 @@ const docsetsManifest = loadDocsetsManifest();
const {
base,
isArchivedBuild,
isHistoricalArchiveBuild,
isSearchExcludedBuild,
internalRedirect,
currentDocsetRedirect,
} = resolveDocsetBuildContext(docsetsManifest);
Expand Down Expand Up @@ -222,7 +226,7 @@ export default defineConfig({
// minimal prose; they are excluded from llms-small.txt to keep the
// compact version useful, but remain in llms-full.txt.
// Only registered for current builds. Archived docsets do not publish
// a separate machine-readable corpus; preview bases remain current.
// a separate machine-readable corpus; the /dev/ build remains current.
...(isArchivedBuild ? [] : [starlightLlmsTxt({
description: 'Documentation for Registry Stack: tutorials, product docs, explanation, and API reference for Registry Relay and Registry Notary.',
details: DISCOVERY_HEADER,
Expand Down Expand Up @@ -411,6 +415,6 @@ export default defineConfig({
},
],
}),
...(isHistoricalArchiveBuild ? [disabledSitemap] : [sitemap()]),
...(isSearchExcludedBuild ? [disabledSitemap] : [sitemap()]),
],
});
2 changes: 1 addition & 1 deletion docs/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"dev": "npm run generate && astro dev",
"build": "npm run generate && astro check && astro build",
"build": "npm run generate && astro check && astro build && node scripts/apply-archive-seo.mjs dist",
"build:archive": "node scripts/build-archive.mjs",
"build:archives": "node scripts/build-archives.mjs",
"assemble:archives": "node scripts/assemble-archives.mjs",
Expand Down
37 changes: 21 additions & 16 deletions docs/site/scripts/apply-archive-seo.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const robotsMeta = '<meta name="robots" content="noindex,follow">';

Expand All @@ -25,24 +26,28 @@ function addNoindex(html) {
return html.replace('</head>', `${robotsMeta}</head>`);
}

function removeNoindex(html) {
return html.replace(
/\s*<meta\s+name=["']robots["']\s+content=["']noindex,follow["']\s*\/?>/gi,
'',
);
}

export async function applyArchiveSeo(outDir, { indexable = false } = {}) {
export async function applyArchiveSeo(outDir) {
for (const file of await htmlFiles(outDir)) {
const html = await readFile(file, 'utf8');
const updated = indexable
? removeNoindex(html)
: addNoindex(removeSitemapLinks(html));
const updated = addNoindex(removeSitemapLinks(html));
if (updated !== html) await writeFile(file, updated);
}

if (!indexable) {
await rm(join(outDir, 'sitemap-index.xml'), { force: true });
await rm(join(outDir, 'sitemap-0.xml'), { force: true });
}
await rm(join(outDir, 'sitemap-index.xml'), { force: true });
await rm(join(outDir, 'sitemap-0.xml'), { force: true });
}

async function main() {
const output = process.argv[2];
if (!output) throw new Error('usage: apply-archive-seo.mjs <output-directory>');
const outDir = resolve(output);
await applyArchiveSeo(outDir);
console.log(`Applied noindex SEO policy to ${outDir}.`);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
2 changes: 1 addition & 1 deletion docs/site/scripts/assemble-archives.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function fixture(t) {
...docset,
id: 'latest',
label: 'Latest',
path: '/',
path: '/dev/',
status: 'current',
availability: 'unreleased',
source: 'registry-stack-main',
Expand Down
22 changes: 11 additions & 11 deletions docs/site/scripts/astro-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const docsets = {
current: 'latest',
released: 'v0.8.4',
docsets: [
{ id: 'latest', status: 'current' },
{ id: 'v0.8.4', status: 'archived' },
{ id: 'latest', status: 'current', availability: 'unreleased', path: '/dev/' },
{ id: 'v0.8.4', status: 'archived', availability: 'released', path: '/v/0.8.4/' },
],
};
const currentOnlyPath = '/products/registry-notary/opencrvs-onboarding/';
Expand All @@ -29,34 +29,34 @@ test('current docset without a base keeps current-only redirects internal', () =

assert.equal(context.base, undefined);
assert.equal(context.isArchivedBuild, false);
assert.equal(context.isHistoricalArchiveBuild, false);
assert.equal(context.isSearchExcludedBuild, true);
assert.equal(context.currentDocsetRedirect(currentOnlyPath), currentOnlyPath);
});

test('current docset with a preview base remains current', () => {
test('current docset with the development base remains current', () => {
const context = resolveDocsetBuildContext(docsets, {
DOCS_DOCSET: 'latest',
DOCS_BASE: '/preview',
DOCS_BASE: '/dev',
});

assert.equal(context.isArchivedBuild, false);
assert.equal(
context.currentDocsetRedirect(currentOnlyPath),
`/preview${currentOnlyPath}`,
`/dev${currentOnlyPath}`,
);
});

test('archived docset redirects current-only pages to the Main-source preview', () => {
test('archived docset redirects current-only pages to unreleased Main', () => {
const context = resolveDocsetBuildContext(docsets, {
DOCS_DOCSET: 'v0.8.4',
DOCS_BASE: '/v/0.8.4/',
});

assert.equal(context.isArchivedBuild, true);
assert.equal(context.isHistoricalArchiveBuild, false);
assert.equal(context.isSearchExcludedBuild, true);
assert.equal(
context.currentDocsetRedirect(currentOnlyPath),
`https://docs.registrystack.org/preview${currentOnlyPath}`,
`https://docs.registrystack.org/dev${currentOnlyPath}`,
);
});

Expand All @@ -75,9 +75,9 @@ test('archived builds disable platform-dependent Pagefind output', () => {
assert.match(configSource, /pagefind:\s*!isArchivedBuild/);
});

test('only historical archives disable sitemap output', () => {
test('all search-excluded builds disable sitemap output', () => {
assert.match(
configSource,
/isHistoricalArchiveBuild\s*\?\s*\[disabledSitemap\]\s*:\s*\[sitemap\(\)\]/,
/isSearchExcludedBuild\s*\?\s*\[disabledSitemap\]\s*:\s*\[sitemap\(\)\]/,
);
});
2 changes: 1 addition & 1 deletion docs/site/scripts/build-archive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ if (docset.id === docsets.current || docset.status !== 'archived') {
process.exit(1);
}

await buildDocsetArchive(docset, { indexable: docset.id === docsets.released });
await buildDocsetArchive(docset);
7 changes: 2 additions & 5 deletions docs/site/scripts/build-archives.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ export async function buildDocsetArchive(docset, {
runCommand = run,
applySeo = applyArchiveSeo,
stageGeneratedArtifacts = stagePinnedGeneratedArtifacts,
indexable = false,
} = {}) {
if (docset.status !== 'archived') {
throw new Error(`Docset "${docset.id}" is not archived`);
Expand Down Expand Up @@ -184,7 +183,7 @@ export async function buildDocsetArchive(docset, {
['astro', 'build', '--outDir', archiveOutputDirectory(docsRoot, docset)],
env,
);
await applySeo(outDir, { indexable });
await applySeo(outDir);
} finally {
await restoreGeneratedArtifacts();
}
Expand All @@ -203,9 +202,7 @@ export async function buildArchivedDocsets({
}

for (const docset of archived) {
await buildDocsetArchive(docset, {
indexable: docset.id === manifest.released,
});
await buildDocsetArchive(docset);
}

// Return generated files to the current docset so local worktrees stay sane.
Expand Down
27 changes: 14 additions & 13 deletions docs/site/scripts/build-archives.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,25 +84,29 @@ test('archive generation excludes current-source generators', async () => {
new RegExp(`scripts/${script.replace('.', '\\.')}`),
);
}
assert.match(
packageJson.scripts.build,
/node scripts\/apply-archive-seo\.mjs dist/,
);
});

test('selected released archive stays indexable and keeps its sitemap', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'registry-docs-released-seo-'));
test('every versioned archive is noindex and has no sitemap', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'registry-docs-archive-seo-'));
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(
resolve(root, 'index.html'),
'<html><head><meta name="robots" content="noindex,follow"><link rel="sitemap" href="sitemap-index.xml"></head></html>',
'<html><head><link rel="sitemap" href="sitemap-index.xml"></head></html>',
);
await writeFile(resolve(root, 'sitemap-index.xml'), '<sitemapindex/>\n');

await applyArchiveSeo(root, { indexable: true });
await applyArchiveSeo(root);

const html = await readFile(resolve(root, 'index.html'), 'utf8');
assert.doesNotMatch(html, /noindex,follow/);
assert.match(html, /rel="sitemap"/);
assert.equal(
await readFile(resolve(root, 'sitemap-index.xml'), 'utf8'),
'<sitemapindex/>\n',
assert.match(html, /noindex,follow/);
assert.doesNotMatch(html, /rel="sitemap"/);
await assert.rejects(
readFile(resolve(root, 'sitemap-index.xml'), 'utf8'),
/ENOENT/,
);
});

Expand All @@ -111,17 +115,15 @@ test('archived docset builds use isolated generation with release-bound environm
t.after(() => rm(root, { recursive: true, force: true }));
const calls = [];
let seoPath;
let seoOptions;

await buildDocsetArchive(archivedDocset, {
docsRoot: root,
stageGeneratedArtifacts: async () => async () => {},
runCommand: async (command, args, env) => {
calls.push({ command, args, env });
},
applySeo: async (path, options) => {
applySeo: async (path) => {
seoPath = path;
seoOptions = options;
},
});

Expand All @@ -142,7 +144,6 @@ test('archived docset builds use isolated generation with release-bound environm
assert.equal(env.PUBLIC_UMAMI_DOMAINS, '');
}
assert.equal(seoPath, resolve(root, 'dist/v/1.2.3'));
assert.deepEqual(seoOptions, { indexable: false });
});

test('archive output uses pinned generated artifacts and restores current files', async (t) => {
Expand Down
Loading
Loading