diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 65217f7aee..a9d4329423 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -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 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 }} @@ -107,7 +108,7 @@ 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 @@ -115,8 +116,8 @@ jobs: 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 diff --git a/docs/site/README.md b/docs/site/README.md index 9f970a545d..47a0373335 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -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//` 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//` tree and its release asset are not changed. + ## Content Sources Data-backed reference tables are generated from: diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 64ef6e1494..31de327991 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -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) { @@ -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, }; @@ -76,7 +80,7 @@ const docsetsManifest = loadDocsetsManifest(); const { base, isArchivedBuild, - isHistoricalArchiveBuild, + isSearchExcludedBuild, internalRedirect, currentDocsetRedirect, } = resolveDocsetBuildContext(docsetsManifest); @@ -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, @@ -411,6 +415,6 @@ export default defineConfig({ }, ], }), - ...(isHistoricalArchiveBuild ? [disabledSitemap] : [sitemap()]), + ...(isSearchExcludedBuild ? [disabledSitemap] : [sitemap()]), ], }); diff --git a/docs/site/package.json b/docs/site/package.json index 5d7a399fd1..45906492f0 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -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", diff --git a/docs/site/scripts/apply-archive-seo.mjs b/docs/site/scripts/apply-archive-seo.mjs index 371bc68b04..d006e0b672 100644 --- a/docs/site/scripts/apply-archive-seo.mjs +++ b/docs/site/scripts/apply-archive-seo.mjs @@ -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 = ''; @@ -25,24 +26,28 @@ function addNoindex(html) { return html.replace('', `${robotsMeta}`); } -function removeNoindex(html) { - return html.replace( - /\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 '); + 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; + }); } diff --git a/docs/site/scripts/assemble-archives.test.mjs b/docs/site/scripts/assemble-archives.test.mjs index 92828fcf9a..6f409495ac 100644 --- a/docs/site/scripts/assemble-archives.test.mjs +++ b/docs/site/scripts/assemble-archives.test.mjs @@ -51,7 +51,7 @@ async function fixture(t) { ...docset, id: 'latest', label: 'Latest', - path: '/', + path: '/dev/', status: 'current', availability: 'unreleased', source: 'registry-stack-main', diff --git a/docs/site/scripts/astro-config.test.mjs b/docs/site/scripts/astro-config.test.mjs index 1e8fd90fd8..6b332c91f6 100644 --- a/docs/site/scripts/astro-config.test.mjs +++ b/docs/site/scripts/astro-config.test.mjs @@ -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/'; @@ -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}`, ); }); @@ -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\(\)\]/, ); }); diff --git a/docs/site/scripts/build-archive.mjs b/docs/site/scripts/build-archive.mjs index acff4ee74d..83fdf3221d 100644 --- a/docs/site/scripts/build-archive.mjs +++ b/docs/site/scripts/build-archive.mjs @@ -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); diff --git a/docs/site/scripts/build-archives.mjs b/docs/site/scripts/build-archives.mjs index 9867cc7685..65dcb01551 100644 --- a/docs/site/scripts/build-archives.mjs +++ b/docs/site/scripts/build-archives.mjs @@ -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`); @@ -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(); } @@ -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. diff --git a/docs/site/scripts/build-archives.test.mjs b/docs/site/scripts/build-archives.test.mjs index c62fb49e1c..b804c7bfdd 100644 --- a/docs/site/scripts/build-archives.test.mjs +++ b/docs/site/scripts/build-archives.test.mjs @@ -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'), - '', + '', ); await writeFile(resolve(root, 'sitemap-index.xml'), '\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'), - '\n', + assert.match(html, /noindex,follow/); + assert.doesNotMatch(html, /rel="sitemap"/); + await assert.rejects( + readFile(resolve(root, 'sitemap-index.xml'), 'utf8'), + /ENOENT/, ); }); @@ -111,7 +115,6 @@ 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, @@ -119,9 +122,8 @@ test('archived docset builds use isolated generation with release-bound environm runCommand: async (command, args, env) => { calls.push({ command, args, env }); }, - applySeo: async (path, options) => { + applySeo: async (path) => { seoPath = path; - seoOptions = options; }, }); @@ -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) => { diff --git a/docs/site/scripts/check-built-links.mjs b/docs/site/scripts/check-built-links.mjs index d0a2be327b..ff96887cf8 100644 --- a/docs/site/scripts/check-built-links.mjs +++ b/docs/site/scripts/check-built-links.mjs @@ -8,6 +8,7 @@ import { CURRENT_PRODUCTION_DOCSET_PATH } from '../src/lib/docset-path.mjs'; const distDir = resolve(process.env.DOCS_DIST_DIR || 'dist'); const attrPattern = /\s(?:href|src)=["']([^"']+)["']/g; const idPattern = /\sid=["']([^"']+)["']/g; +const LEGACY_PREVIEW_PATH = '/preview/'; function scopeFromArgs(args) { if (args.length === 0) return 'all'; @@ -93,17 +94,17 @@ function isWithinRoot(path, root) { function resolveTarget(path) { const target = targetPath(path); - if ( - productionCurrentMountExists || - !isWithinRoot(path, CURRENT_PRODUCTION_DOCSET_PATH) - ) { - return target; + for (const [mount, mountExists] of [ + [CURRENT_PRODUCTION_DOCSET_PATH, productionCurrentMountExists], + [LEGACY_PREVIEW_PATH, legacyPreviewMountExists], + ]) { + if (mountExists || !isWithinRoot(path, mount)) continue; + const relativePath = path === mount.slice(0, -1) + ? '/' + : `/${path.slice(mount.length)}`; + return targetPath(relativePath); } - - const relativePath = path === CURRENT_PRODUCTION_DOCSET_PATH.slice(0, -1) - ? '/' - : `/${path.slice(CURRENT_PRODUCTION_DOCSET_PATH.length)}`; - return targetPath(relativePath); + return target; } async function currentEvidencePaths() { @@ -127,6 +128,7 @@ const archivedRootPattern = /^\/v\/[^/]+\//; const productionCurrentMountExists = await exists( targetPath(CURRENT_PRODUCTION_DOCSET_PATH), ); +const legacyPreviewMountExists = await exists(targetPath(LEGACY_PREVIEW_PATH)); const docsets = await loadDocsets(); const archivedRoots = new Set( docsets.docsets @@ -135,6 +137,7 @@ const archivedRoots = new Set( ); const declaredArchiveDestinations = new Set([ CURRENT_PRODUCTION_DOCSET_PATH, + LEGACY_PREVIEW_PATH, ...archivedRoots, ]); diff --git a/docs/site/scripts/check-built-links.test.mjs b/docs/site/scripts/check-built-links.test.mjs index 907a994f4d..2d329fae54 100644 --- a/docs/site/scripts/check-built-links.test.mjs +++ b/docs/site/scripts/check-built-links.test.mjs @@ -35,7 +35,7 @@ released: v1 docsets: - id: latest label: Latest - path: / + path: /dev/ status: current availability: unreleased source: main @@ -85,13 +85,20 @@ test('allows an archived standards page to cite root-relative current evidence', assert.match(result.stdout, /Built link check passed/); }); -test('allows archived navigation to the production current-docset mount', (t) => { - const result = run(fixture(t, '/preview/explanation/current/')); +test('allows archived navigation to the production development mount', (t) => { + const result = run(fixture(t, '/dev/explanation/current/')); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Built link check passed/); }); -test('does not fall back when the production current-docset mount exists', (t) => { +test('allows archived navigation through the legacy preview redirect', (t) => { + const root = fixture(t, '/preview/'); + const result = run(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Built link check passed/); +}); + +test('does not fall back when the legacy preview mount exists', (t) => { const root = fixture(t, '/preview/explanation/current/'); write(root, 'dist/preview/index.html', ''); const result = run(root); @@ -99,6 +106,14 @@ test('does not fall back when the production current-docset mount exists', (t) = assert.match(result.stderr, /links to missing/); }); +test('does not fall back when the production current-docset mount exists', (t) => { + const root = fixture(t, '/dev/explanation/current/'); + write(root, 'dist/dev/index.html', ''); + const result = run(root); + assert.equal(result.status, 1); + assert.match(result.stderr, /links to missing/); +}); + test('allows archived navigation to another declared archive', (t) => { const result = run(fixture(t, '/v/v2/explanation/current/')); assert.equal(result.status, 0, result.stderr); @@ -112,7 +127,7 @@ test('keeps rejecting unrelated links that escape an archive', (t) => { }); test('rejects paths that only share the production mount prefix', (t) => { - const result = run(fixture(t, '/preview-escape/explanation/current/')); + const result = run(fixture(t, '/dev-escape/explanation/current/')); assert.equal(result.status, 1); assert.match(result.stderr, /links outside its archive/); }); diff --git a/docs/site/scripts/check-evidence-links.test.mjs b/docs/site/scripts/check-evidence-links.test.mjs index dfb598e803..d89a0d603b 100644 --- a/docs/site/scripts/check-evidence-links.test.mjs +++ b/docs/site/scripts/check-evidence-links.test.mjs @@ -238,7 +238,7 @@ test('release verification uses the resolved tag target without installing the d assert.doesNotMatch(verifyJob, /npm ci/); }); -test('Pages stages released-root routing before checking the mounted Main corpus', () => { +test('Pages promotes the released root before checking the mounted Main corpus', () => { const workflow = readFileSync( resolve(repositoryRoot, '.github/workflows/docs-pages.yml'), 'utf8', @@ -249,7 +249,8 @@ test('Pages stages released-root routing before checking the mounted Main corpus assert.ok(llmsCheck >= 0); assert.ok(rootStaging > archiveAssembly); assert.ok(llmsCheck > rootStaging); - assert.match(workflow, /DOCS_DIST_DIR: \$\{\{ github\.workspace \}\}\/docs\/site\/dist\/preview/); - assert.match(workflow, /DOCS_PUBLIC_BASE: \/preview\//); + assert.match(workflow, /node scripts\/apply-archive-seo\.mjs dist\/dev/); + assert.match(workflow, /DOCS_DIST_DIR: \$\{\{ github\.workspace \}\}\/docs\/site\/dist\/dev/); + assert.match(workflow, /DOCS_PUBLIC_BASE: \/dev\//); assert.equal(workflow.indexOf('npm run check:llms:built', llmsCheck + 1), -1); }); diff --git a/docs/site/scripts/check-seo.mjs b/docs/site/scripts/check-seo.mjs index 0a38b959b6..7bec792cea 100644 --- a/docs/site/scripts/check-seo.mjs +++ b/docs/site/scripts/check-seo.mjs @@ -1,12 +1,12 @@ import { readdir, readFile, stat } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; + import { loadDocsets } from './docsets.mjs'; +import { CURRENT_PRODUCTION_DOCSET_PATH } from '../src/lib/docset-path.mjs'; const distDir = resolve(process.env.DOCS_DIST_DIR || 'dist'); -// v0.13.0 was sealed before selected releases became indexable. Its immutable -// bundle cannot be rewritten. The exception disappears automatically when the -// released selector advances, and no later release is allowed to inherit it. -const immutableLegacyNoindexReleasedDocsets = new Set(['v0.13.0']); +const docsOrigin = 'https://docs.registrystack.org'; +const legacyPreviewPath = '/preview/'; function scopeFromArgs(args) { if (args.length === 0) return 'all'; @@ -36,113 +36,183 @@ async function htmlFiles(dir) { return files; } -function archiveRootForFile(file, archivedDocsets) { +function isWithinMount(file, mount) { const rel = relative(distDir, file).replaceAll('\\', '/'); - return archivedDocsets.find((docset) => rel.startsWith(docset.path.replace(/^\//, ''))); + const root = mount.replace(/^\/|\/$/g, ''); + return rel === `${root}.html` || rel.startsWith(`${root}/`); +} + +function archiveForFile(file, archivedDocsets) { + return archivedDocsets.find((docset) => isWithinMount(file, docset.path)); +} + +function canonicalFromHtml(html) { + return html.match( + /]*\brel=["']canonical["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/i, + )?.[1]; +} + +function isCanonicalRootUrl(value) { + let url; + try { + url = new URL(value); + } catch { + return false; + } + if (url.origin !== docsOrigin) return false; + const path = url.pathname || '/'; + return ![ + CURRENT_PRODUCTION_DOCSET_PATH, + legacyPreviewPath, + '/v/', + ].some((mount) => path === mount.slice(0, -1) || path.startsWith(mount)); +} + +async function rejectSitemap(dir, label, errors) { + for (const name of ['sitemap-index.xml', 'sitemap-0.xml']) { + if (await exists(join(dir, name))) { + errors.push(`${label} must not publish ${name}`); + } + } } const manifest = await loadDocsets(); -const releasedDocset = manifest.docsets.find((docset) => docset.id === manifest.released); const archivedDocsets = manifest.docsets.filter((docset) => docset.status === 'archived'); -const historicalDocsets = archivedDocsets.filter((docset) => docset.id !== manifest.released); -const releasedHasLegacyNoindex = - immutableLegacyNoindexReleasedDocsets.has(manifest.released); -const searchExcludedDocsets = archivedDocsets.filter( - (docset) => docset.id !== manifest.released || releasedHasLegacyNoindex, -); const scope = scopeFromArgs(process.argv.slice(2)); const errors = []; -let currentChecked = 0; -let archivedChecked = 0; -let releasedChecked = 0; -let redirectsChecked = 0; -const previewDir = join(distDir, 'preview'); -const productionLayout = await exists(join(previewDir, 'index.html')); -const currentOutput = productionLayout ? previewDir : distDir; - -if (!await exists(join(currentOutput, 'sitemap-index.xml'))) { - errors.push(`Main sitemap is missing: ${join(currentOutput, 'sitemap-index.xml')}`); -} +let developmentChecked = 0; +let archiveChecked = 0; +let canonicalChecked = 0; +let legacyRedirectsChecked = 0; +const developmentDir = join( + distDir, + CURRENT_PRODUCTION_DOCSET_PATH.replace(/^\/|\/$/g, ''), +); +const productionLayout = await exists(join(developmentDir, 'index.html')); +const developmentOutput = productionLayout ? developmentDir : distDir; -if (scope === 'all') { - for (const docset of searchExcludedDocsets) { - const archiveDir = join(distDir, docset.path); - const archiveSitemap = join(archiveDir, 'sitemap-index.xml'); - const archiveSitemapPage = join(archiveDir, 'sitemap-0.xml'); - if (await exists(archiveSitemap)) { - errors.push(`Archived docset ${docset.id} must not publish sitemap-index.xml`); +await rejectSitemap(developmentOutput, 'Unreleased Main documentation', errors); + +if (scope === 'all' && productionLayout) { + const rootSitemapIndex = join(distDir, 'sitemap-index.xml'); + const rootSitemapPages = join(distDir, 'sitemap-0.xml'); + if (!await exists(rootSitemapIndex)) { + errors.push(`Canonical sitemap is missing: ${rootSitemapIndex}`); + } + if (!await exists(rootSitemapPages)) { + errors.push(`Canonical sitemap page is missing: ${rootSitemapPages}`); + } else { + const sitemap = await readFile(rootSitemapPages, 'utf8'); + for (const match of sitemap.matchAll(/([^<]+)<\/loc>/g)) { + if (!isCanonicalRootUrl(match[1])) { + errors.push(`Canonical sitemap contains a non-root URL: ${match[1]}`); + continue; + } + const pathname = new URL(match[1]).pathname; + const page = pathname === '/' + ? join(distDir, 'index.html') + : join(distDir, pathname, 'index.html'); + if (!await exists(page)) { + errors.push(`Canonical sitemap points to a missing page: ${match[1]}`); + continue; + } + const html = await readFile(page, 'utf8'); + if (//.test(html); - if (scope === 'current' && (isArchived || isProductionRedirect)) continue; - const hasNoindex = //.test(html); + const archive = archiveForFile(file, archivedDocsets); + const isDevelopment = productionLayout + ? isWithinMount(file, CURRENT_PRODUCTION_DOCSET_PATH) + : !archive; + const isLegacyRedirect = productionLayout && isWithinMount(file, legacyPreviewPath); + const isCanonical = productionLayout && !archive && !isDevelopment && !isLegacyRedirect; + if ( + scope === 'current' && + (productionLayout ? !isDevelopment : Boolean(archive)) + ) { + continue; + } + + const hasNoindex = + //i.test(html); const hasSitemapLink = /]*\brel=["']sitemap["'])[^>]*>/i.test(html); + const canonical = canonicalFromHtml(html); - if (isProductionRedirect) { - redirectsChecked += 1; + if (isDevelopment) { + developmentChecked += 1; if (!hasNoindex) { - errors.push(`${relative('.', file)} is a root redirect but missing robots noindex,follow`); + errors.push(`${relative('.', file)} is unreleased Main but missing robots noindex,follow`); } if (hasSitemapLink) { - errors.push(`${relative('.', file)} is a root redirect but links a sitemap`); - } - const canonical = html.match( - /]*\brel=["']canonical["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/i, - )?.[1]; - if (!canonical?.startsWith(`https://docs.registrystack.org${releasedDocset.path}`)) { - errors.push( - `${relative('.', file)} must canonically redirect into released docset ${manifest.released}`, - ); - } - } else if (isReleasedArchive) { - releasedChecked += 1; - if (releasedHasLegacyNoindex && !hasNoindex) { - errors.push( - `${relative('.', file)} is immutable legacy release ${manifest.released} but is missing robots noindex,follow`, - ); - } else if (!releasedHasLegacyNoindex && hasNoindex) { - errors.push(`${relative('.', file)} is the released docset but has robots noindex,follow`); + errors.push(`${relative('.', file)} is unreleased Main but links a sitemap`); } - if (releasedHasLegacyNoindex && hasSitemapLink) { - errors.push( - `${relative('.', file)} is immutable legacy release ${manifest.released} but links a sitemap`, - ); - } - } else if (isArchived) { - archivedChecked += 1; + } else if (archive) { + archiveChecked += 1; if (!hasNoindex) { errors.push(`${relative('.', file)} is archived but missing robots noindex,follow`); } if (hasSitemapLink) { errors.push(`${relative('.', file)} is archived but links a sitemap`); } - } else { - currentChecked += 1; + } else if (isLegacyRedirect) { + legacyRedirectsChecked += 1; + if (!hasNoindex) { + errors.push(`${relative('.', file)} is a legacy redirect but missing robots noindex,follow`); + } + if (hasSitemapLink) { + errors.push(`${relative('.', file)} is a legacy redirect but links a sitemap`); + } + if (!html.includes('name="registry-legacy-preview-redirect"')) { + errors.push(`${relative('.', file)} is under /preview/ but is not a declared legacy redirect`); + } + if (!isCanonicalRootUrl(canonical)) { + errors.push(`${relative('.', file)} must canonicalize to the released root namespace`); + } + } else if (isCanonical) { + canonicalChecked += 1; + const isRedirectPage = / 0 && archivedChecked === 0) { - if (historicalDocsets.length > 0) errors.push('No historical archive HTML files were checked.'); -} -if (scope === 'all' && releasedDocset && releasedChecked === 0) { - errors.push('No released-docset HTML files were checked.'); +if (developmentChecked === 0) { + errors.push('No unreleased Main HTML files were checked.'); } -if (scope === 'all' && productionLayout && redirectsChecked === 0) { - errors.push('No released-root redirect HTML files were checked.'); +if (scope === 'all' && productionLayout) { + if (archiveChecked === 0) errors.push('No immutable archive HTML files were checked.'); + if (canonicalChecked === 0) errors.push('No canonical release HTML files were checked.'); + if (legacyRedirectsChecked === 0) errors.push('No legacy /preview/ redirects were checked.'); } if (errors.length) { @@ -151,5 +221,5 @@ if (errors.length) { } console.log( - `SEO check passed: ${currentChecked} Main HTML files, ${releasedChecked} released HTML files, ${archivedChecked} historical archive HTML files, and ${redirectsChecked} released-root redirects checked.`, + `SEO check passed: ${canonicalChecked} canonical release HTML files, ${developmentChecked} unreleased Main HTML files, ${archiveChecked} immutable archive HTML files, and ${legacyRedirectsChecked} legacy redirects checked.`, ); diff --git a/docs/site/scripts/check-seo.test.mjs b/docs/site/scripts/check-seo.test.mjs index c734473ba8..817c1fbc82 100644 --- a/docs/site/scripts/check-seo.test.mjs +++ b/docs/site/scripts/check-seo.test.mjs @@ -13,104 +13,197 @@ function write(root, path, contents) { writeFileSync(target, contents); } -function fixture(t, options = {}) { - const released = options.released ?? 'v1'; - const releasedPath = options.releasedPath ?? '/v/v1/'; - const canonical = - options.canonical ?? `https://docs.registrystack.org${releasedPath}`; - const root = mkdtempSync(resolve(tmpdir(), 'registry-seo-')); - t.after(() => rmSync(root, { recursive: true, force: true })); +function docsets(root) { write( root, 'src/data/docsets.yaml', `current: latest -released: ${released} +released: v1 docsets: - id: latest - label: Main - path: / + label: Development + path: /dev/ status: current availability: unreleased source: main published_at: 2026-07-27 description: Main docs. products: {} - - id: ${released} - label: ${released} - path: ${releasedPath} + - id: v1 + label: v1 + path: /v/v1/ status: archived availability: released - source: ${released} + source: v1 published_at: 2026-07-27 description: Released docs. products: {} `, ); - write(root, 'dist/preview/sitemap-index.xml', '\n'); - write(root, 'dist/preview/index.html', '\n'); +} + +function productionFixture(t) { + const root = mkdtempSync(resolve(tmpdir(), 'registry-seo-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + docsets(root); write( root, - `dist${releasedPath}index.html`, - '\n', + 'dist/dev/index.html', + '\n', + ); + write( + root, + 'dist/v/v1/index.html', + '\n', ); write( root, 'dist/index.html', - ` - - - - -`, + '\n', + ); + write( + root, + 'dist/preview/index.html', + '\n', + ); + write( + root, + 'dist/sitemap-index.xml', + 'https://docs.registrystack.org/sitemap-0.xml\n', + ); + write( + root, + 'dist/sitemap-0.xml', + 'https://docs.registrystack.org/\n', + ); + write( + root, + 'dist/robots.txt', + 'Sitemap: https://docs.registrystack.org/sitemap-index.xml\n', ); return root; } -function run(root) { - return spawnSync(process.execPath, [checker], { cwd: root, encoding: 'utf8' }); +function run(root, args = []) { + return spawnSync(process.execPath, [checker, ...args], { cwd: root, encoding: 'utf8' }); } -test('accepts preview, immutable archive, and released-root redirect SEO roles', (t) => { - const result = run(fixture(t)); +test('accepts canonical root, unreleased Main, immutable archives, and legacy redirects', (t) => { + const result = run(productionFixture(t)); assert.equal(result.status, 0, result.stderr); assert.match( result.stdout, - /1 Main HTML files, 1 released HTML files, 0 historical archive HTML files, and 1 released-root redirects/, + /1 canonical release HTML files, 1 unreleased Main HTML files, 1 immutable archive HTML files, and 1 legacy redirects/, + ); +}); + +test('accepts a local unreleased build at dist root', (t) => { + const root = mkdtempSync(resolve(tmpdir(), 'registry-seo-current-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + docsets(root); + write( + root, + 'dist/index.html', + '\n', ); + + const result = run(root, ['--scope', 'current']); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /1 unreleased Main HTML files/); }); -test('rejects a released-root redirect canonicalized outside the released docset', (t) => { - const result = run(fixture(t, { canonical: 'https://docs.registrystack.org/preview/' })); +test('accepts a canonical release redirect without a sitemap link', (t) => { + const root = productionFixture(t); + write( + root, + 'dist/old/index.html', + 'Redirect\n', + ); + + const result = run(root); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /2 canonical release HTML files/); +}); + +test('rejects indexable unreleased Main documentation', (t) => { + const root = productionFixture(t); + write(root, 'dist/dev/index.html', '\n'); + + const result = run(root); + + assert.equal(result.status, 1); + assert.match(result.stderr, /unreleased Main but missing robots noindex,follow/); +}); + +test('rejects an indexable immutable archive', (t) => { + const root = productionFixture(t); + write(root, 'dist/v/v1/index.html', '\n'); + + const result = run(root); + assert.equal(result.status, 1); - assert.match(result.stderr, /must canonically redirect into released docset v1/); + assert.match(result.stderr, /is archived but missing robots noindex,follow/); }); -test('rejects noindex on the selected released docset', (t) => { - const root = fixture(t); +test('rejects noindex on canonical release documentation', (t) => { + const root = productionFixture(t); write( root, - 'dist/v/v1/index.html', - '\n', + 'dist/index.html', + '\n', ); const result = run(root); assert.equal(result.status, 1); - assert.match(result.stderr, /is the released docset but has robots noindex,follow/); + assert.match(result.stderr, /canonical release documentation but has noindex/); }); -test('accepts the immutable v0.13.0 legacy noindex bundle only while it is selected', (t) => { - const root = fixture(t, { - released: 'v0.13.0', - releasedPath: '/v/0.13.0/', - }); +test('rejects a legacy redirect canonicalized to development documentation', (t) => { + const root = productionFixture(t); write( root, - 'dist/v/0.13.0/index.html', - '\n', + 'dist/preview/index.html', + '\n', ); const result = run(root); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 1); + assert.match(result.stderr, /must canonicalize to the released root namespace/); +}); + +test('rejects non-root URLs in the canonical sitemap', (t) => { + const root = productionFixture(t); + write( + root, + 'dist/sitemap-0.xml', + 'https://docs.registrystack.org/dev/\n', + ); + + const result = run(root); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Canonical sitemap contains a non-root URL/); +}); + +test('rejects redirect pages in the canonical sitemap', (t) => { + const root = productionFixture(t); + write( + root, + 'dist/old/index.html', + 'Redirect\n', + ); + write( + root, + 'dist/sitemap-0.xml', + 'https://docs.registrystack.org/old/\n', + ); + + const result = run(root); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Canonical sitemap must not include redirect page/); }); diff --git a/docs/site/scripts/deployment-documentation-truth.test.mjs b/docs/site/scripts/deployment-documentation-truth.test.mjs index 715b68d981..e89d0b20b5 100644 --- a/docs/site/scripts/deployment-documentation-truth.test.mjs +++ b/docs/site/scripts/deployment-documentation-truth.test.mjs @@ -22,7 +22,7 @@ const currentActivationPages = [ 'products/notary/docs/deployment-hardening-runbook.md', ]; -test('current docs stay preview while v0.15.2 advances and v0.13.0 stays released', async () => { +test('current docs stay under /dev/ while v0.15.2 is the released archive', async () => { const [docsets, repoDocs, generatedDocsets] = await Promise.all([ readYaml('docs/site/src/data/docsets.yaml'), readYaml('docs/site/src/data/repo-docs.yaml'), @@ -33,20 +33,21 @@ test('current docs stay preview while v0.15.2 advances and v0.13.0 stays release const released = docsets.docsets.find((docset) => docset.id === docsets.released); assert.equal(current.id, 'latest'); - assert.equal(docsets.released, 'v0.13.0'); + assert.equal(docsets.released, 'v0.15.2'); assert.notEqual(docsets.current, docsets.released); - assert.equal(current.label, 'Documentation preview'); + assert.equal(current.label, 'Development (unreleased)'); + assert.equal(current.path, '/dev/'); assert.equal(current.status, 'current'); assert.equal(current.availability, 'unreleased'); assert.equal(current.source, 'registry-stack-main'); assert.equal( current.description, - 'Draft Registry Stack documentation for content and navigation review.', + 'Unreleased Registry Stack documentation built from the main branch.', ); for (const product of Object.values(current.products)) { assert.equal(product.ref, 'HEAD'); assert.equal(product.version, 'main source (unreleased)'); - assert.doesNotMatch(product.version, /^v0\.13\.0$/); + assert.doesNotMatch(product.version, /^v0\.15\.2$/); } for (const [repoId, repo] of Object.entries(repoDocs.repos)) { @@ -59,42 +60,30 @@ test('current docs stay preview while v0.15.2 advances and v0.13.0 stays release ); } - assert.equal(released.path, '/v/0.13.0/'); + assert.equal(released.path, '/v/0.15.2/'); assert.equal(released.status, 'archived'); assert.equal(released.availability, 'released'); - assert.equal(released.source, 'registry-stack-v0.13.0'); - assert.match(released.description, /^Released RegistryStack v0\.13\.0/); + assert.equal(released.source, 'registry-stack-v0.15.2'); + assert.match(released.description, /^Released Registry Stack v0\.15\.2/); for (const [productId, product] of Object.entries(released.products)) { if (productId === 'crosswalk') continue; - assert.equal(product.version, 'v0.13.0'); + assert.equal(product.version, 'v0.15.2'); assert.equal( product.ref, - 'd45761a0104bd3d9c2e4b4db391d4223f289bd44', - `${productId} v0.13.0 docs must stay on the immutable release ref`, + '5da961bf965cc9bb0e962db8bd3b6055459a0d97', + `${productId} v0.15.2 docs must stay on the immutable prepared-source ref`, ); } for (const docset of docsets.docsets) { if (docset.id === 'latest') continue; - if (docset.id === 'v0.15.2') { - assert.match( - docset.status, - /^(draft|archived)$/, - 'v0.15.2 must expose a documented candidate lifecycle status', - ); - } else { - assert.equal(docset.status, 'archived', `${docset.id} must expose its release-train status`); - } + assert.equal(docset.status, 'archived', `${docset.id} must expose its release-train status`); const expectedAvailability = - docset.id === 'v0.15.2' - ? docset.status === 'draft' - ? 'unreleased' - : 'candidate' - : ['v0.15.1', 'v0.15.0'].includes(docset.id) - ? 'candidate' - : docset.id.startsWith('v') - ? 'released' - : 'candidate'; + ['v0.15.1', 'v0.15.0'].includes(docset.id) + ? 'candidate' + : docset.id.startsWith('v') + ? 'released' + : 'candidate'; assert.equal( docset.availability, expectedAvailability, diff --git a/docs/site/scripts/docset-path.test.mjs b/docs/site/scripts/docset-path.test.mjs index 2b8ba8726c..a0cdf226e2 100644 --- a/docs/site/scripts/docset-path.test.mjs +++ b/docs/site/scripts/docset-path.test.mjs @@ -3,28 +3,35 @@ import { test } from 'node:test'; import { pathForDocset } from '../src/lib/docset-path.mjs'; -test('removes a current preview base when linking to an archive', () => { +test('removes the development base when linking to an archive', () => { assert.equal( - pathForDocset('/preview/tutorials/example/', '/', '/v/0.8.4/', '/preview/'), + pathForDocset('/dev/tutorials/example/', '/dev/', '/v/0.8.4/', '/dev/'), '/v/0.8.4/tutorials/example/', ); }); -test('keeps the current preview path for the selected current docset', () => { +test('keeps the development path for the selected current docset', () => { assert.equal( - pathForDocset('/preview/tutorials/example/', '/', '/', '/preview/'), - '/preview/tutorials/example/', + pathForDocset('/dev/tutorials/example/', '/dev/', '/dev/', '/dev/'), + '/dev/tutorials/example/', ); }); test('removes an archive base when linking to current documentation', () => { + assert.equal( + pathForDocset('/v/0.8.4/tutorials/example/', '/v/0.8.4/', '/dev/', '/v/0.8.4/'), + '/dev/tutorials/example/', + ); +}); + +test('maps an old archive current-root option to the development namespace', () => { assert.equal( pathForDocset('/v/0.8.4/tutorials/example/', '/v/0.8.4/', '/', '/v/0.8.4/'), - '/preview/tutorials/example/', + '/dev/tutorials/example/', ); }); -test('preserves paths when switching docsets at the canonical root', () => { +test('preserves paths when switching from the canonical root to an archive', () => { assert.equal( pathForDocset('/tutorials/example/', '/', '/v/0.8.4/', '/'), '/v/0.8.4/tutorials/example/', diff --git a/docs/site/scripts/docsets.mjs b/docs/site/scripts/docsets.mjs index 7a9ee37cb5..6a321851b2 100644 --- a/docs/site/scripts/docsets.mjs +++ b/docs/site/scripts/docsets.mjs @@ -103,10 +103,10 @@ export function validateDocsets(manifest) { if ( current.status !== 'current' || current.availability !== 'unreleased' || - current.path !== '/' + current.path !== '/dev/' ) { throw new Error( - `docsets.yaml current "${manifest.current}" must be current, unreleased, and mounted at /`, + `docsets.yaml current "${manifest.current}" must be current, unreleased, and mounted at /dev/`, ); } diff --git a/docs/site/scripts/docsets.test.mjs b/docs/site/scripts/docsets.test.mjs index b04aca4475..b15c6bb81e 100644 --- a/docs/site/scripts/docsets.test.mjs +++ b/docs/site/scripts/docsets.test.mjs @@ -15,7 +15,7 @@ function validDocsets() { { id: 'latest', label: 'Latest', - path: '/', + path: '/dev/', status: 'current', availability: 'unreleased', source: 'main', diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 99e4716a77..e26d369c7d 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -174,9 +174,9 @@ test('does not expose source-assurance material as an adopter journey', () => { assert.match(configSource, /'\/journeys\/': internalRedirect\('\/'\)/); }); -test('archived pages send readers directly to the current preview docset', () => { - assert.match(registryBannerSource, /Latest<\/a>/); - assert.doesNotMatch(registryBannerSource, /Latest<\/a>/); +test('archived pages send readers to the canonical latest release', () => { + assert.match(registryBannerSource, /Latest release<\/a>/); + assert.doesNotMatch(registryBannerSource, /href="\/preview\//); }); test('keeps detailed product navigation under collapsed Reference', () => { diff --git a/docs/site/scripts/registryctl-release-provenance.test.mjs b/docs/site/scripts/registryctl-release-provenance.test.mjs index 86c0afcbc7..a51db9a98b 100644 --- a/docs/site/scripts/registryctl-release-provenance.test.mjs +++ b/docs/site/scripts/registryctl-release-provenance.test.mjs @@ -34,7 +34,7 @@ test('v0.15 release-form docs stay distinct from published v0.13 and current-sou const current = docsets.docsets.find((docset) => docset.id === docsets.current); const lastRelease = docsets.docsets.find((docset) => docset.id === 'v0.13.0'); - assert.equal(current.label, 'Documentation preview'); + assert.equal(current.label, 'Development (unreleased)'); assert.equal(current.availability, 'unreleased'); assert.equal(lastRelease.availability, 'released'); diff --git a/docs/site/scripts/stage-production-docsets.mjs b/docs/site/scripts/stage-production-docsets.mjs index 121aa3a450..2865a7902d 100644 --- a/docs/site/scripts/stage-production-docsets.mjs +++ b/docs/site/scripts/stage-production-docsets.mjs @@ -1,5 +1,13 @@ -import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; -import { dirname, relative, resolve, sep } from 'node:path'; +import { constants } from 'node:fs'; +import { + copyFile, + lstat, + mkdir, + readFile, + readdir, + writeFile, +} from 'node:fs/promises'; +import { dirname, extname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { archiveOutputDirectory, treeDigest } from './archive-bundle.mjs'; @@ -8,8 +16,27 @@ import { getDocset, loadDocsets } from './docsets.mjs'; import { CURRENT_PRODUCTION_DOCSET_PATH } from '../src/lib/docset-path.mjs'; const productionCurrentPath = CURRENT_PRODUCTION_DOCSET_PATH; -const reservedRootDirectories = new Set(['_archive-bundles', 'preview', 'v']); -const discoveryUrls = ['llms.txt', 'llms-full.txt', 'llms-small.txt', 'sitemap-index.xml']; +const legacyPreviewPath = '/preview/'; +const discoveryFiles = ['llms.txt', 'llms-full.txt', 'llms-small.txt']; +const reservedRootDirectories = new Set(['_archive-bundles', 'dev', 'preview', 'v']); +const generatedRootFiles = new Set([ + 'CNAME', + 'robots.txt', + 'sitemap-index.xml', + 'sitemap-0.xml', +]); +const textExtensions = new Set([ + '.css', + '.html', + '.js', + '.json', + '.md', + '.mjs', + '.svg', + '.txt', + '.webmanifest', + '.xml', +]); async function existingInfo(path) { try { @@ -34,7 +61,7 @@ async function requireRegularFile(path, label) { } } -async function collectIndexFiles(root, current = root) { +async function collectFiles(root, current = root) { const entries = await readdir(current, { withFileTypes: true }); const files = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { @@ -44,50 +71,8 @@ async function collectIndexFiles(root, current = root) { throw new Error(`released archive cannot contain symlinks: ${path}`); } if (info.isDirectory()) { - files.push(...await collectIndexFiles(root, path)); - } else if (info.isFile() && entry.name === 'index.html') { - files.push(path); - } - } - return files; -} - -async function collectMarkdownFiles(root, current = root) { - const entries = await readdir(current, { withFileTypes: true }); - const files = []; - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - const path = resolve(current, entry.name); - const info = await lstat(path); - if (info.isSymbolicLink()) { - throw new Error(`released archive cannot contain symlinks: ${path}`); - } - if (info.isDirectory()) { - files.push(...await collectMarkdownFiles(root, path)); - } else if (info.isFile() && entry.name.endsWith('.md')) { - files.push(path); - } - } - return files; -} - -async function collectPreviewTextFiles(root, current = root) { - const entries = await readdir(current, { withFileTypes: true }); - const files = []; - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - const path = resolve(current, entry.name); - const info = await lstat(path); - if (info.isSymbolicLink()) { - throw new Error(`Main-source preview cannot contain symlinks: ${path}`); - } - if (info.isDirectory()) { - files.push(...await collectPreviewTextFiles(root, path)); - } else if ( - info.isFile() && - (entry.name.endsWith('.html') || - entry.name.endsWith('.md') || - entry.name === 'robots.txt' || - /^llms(?:-(?:full|small))?\.txt$/.test(entry.name)) - ) { + files.push(...await collectFiles(root, path)); + } else if (info.isFile()) { files.push(path); } } @@ -107,7 +92,7 @@ function escapeHtml(value) { .replaceAll('>', '>'); } -function redirectDocument(docset, target) { +function legacyRedirectDocument(docset, target) { const escapedTarget = escapeHtml(target); const escapedId = escapeHtml(docset.id); return ` @@ -115,67 +100,160 @@ function redirectDocument(docset, target) { - + Registry Stack documentation - Continue to released documentation + Continue to the latest released documentation `; } -function rewritePreviewHtml(html, archivedPaths) { +function canonicalReleaseBanner(docset) { + const label = escapeHtml(docset.label); + const archivePath = escapeHtml(docset.path); + return ``; +} + +function removeNoindex(html) { return html.replace( - /(\s(?:href|src)=)(["'])(\/(?!\/)[^"']*)\2/g, - (match, attribute, quote, value) => { - const pathname = value.split(/[?#]/, 1)[0]; - if ( - pathname === '/preview' || - pathname.startsWith('/preview/') || - pathname === '/v' || - pathname.startsWith('/v/') || - pathname === '/_archive-bundles' || - pathname.startsWith('/_archive-bundles/') || - archivedPaths.some((path) => pathname === path.slice(0, -1) || pathname.startsWith(path)) - ) { - return match; - } - return `${attribute}${quote}/preview${value}${quote}`; - }, + /\s*/gi, + '', ); } -function rewriteDiscoveryUrls(contents) { +function removeSitemapLinks(html) { + return html.replace(/\s*]*\brel=["']sitemap["'])[^>]*>/gi, ''); +} + +function addRootSitemapLink(html) { + if (!html.includes('')) return html; + return html.replace( + '', + '', + ); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function rewriteMountedPath(contents, fromPath, toPath) { + const origin = 'https://docs.registrystack.org'; + const fromWithoutSlash = fromPath.replace(/\/$/, ''); + const toWithoutSlash = toPath === '/' ? '' : toPath.replace(/\/$/, ''); + const escapedFrom = fromPath.replaceAll('/', '\\/'); + const escapedTo = toPath.replaceAll('/', '\\/'); + const escapedFromWithoutSlash = fromWithoutSlash.replaceAll('/', '\\/'); + const escapedToWithoutSlash = toWithoutSlash.replaceAll('/', '\\/'); + const boundary = '(^|[\\s"\'(=,:>])'; + return contents + .replaceAll(`${origin}${fromPath}`, `${origin}${toPath}`) + .replaceAll(`${origin}${fromWithoutSlash}`, `${origin}${toWithoutSlash}`) + .replace( + new RegExp(`${boundary}${escapeRegExp(fromPath)}`, 'g'), + `$1${toPath}`, + ) + .replace( + new RegExp(`${boundary}${escapeRegExp(fromWithoutSlash)}(?=[\\s"'?#),<]|$)`, 'g'), + `$1${toWithoutSlash}`, + ) + .replace( + new RegExp(`${boundary}${escapeRegExp(escapedFrom)}`, 'g'), + `$1${escapedTo}`, + ) + .replace( + new RegExp( + `${boundary}${escapeRegExp(escapedFromWithoutSlash)}(?=[\\s"'?#),<]|$)`, + 'g', + ), + `$1${escapedToWithoutSlash}`, + ); +} + +function rewriteReleasedText(contents, released, file) { + let rewritten = rewriteMountedPath(contents, released.path, '/'); + rewritten = rewriteMountedPath(rewritten, legacyPreviewPath, productionCurrentPath); + if (file.endsWith('.html')) { + rewritten = removeNoindex(removeSitemapLinks(rewritten)); + rewritten = rewritten.replace( + /