diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-03 01:21:30 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-03 01:21:30 +0300 |
| commit | 0e434881b3f8da1db58dba8d103ddc234ad17238 (patch) | |
| tree | a25d6e4655710d2e0b4d5c3fb3ca72570e5403a7 /website/scripts | |
| parent | 0a800b8f2e9894433c6ef9d106d10766ac408218 (diff) | |
fix(docs): publish only released documentation
Diffstat (limited to 'website/scripts')
| -rw-r--r-- | website/scripts/check-built-docs.mjs | 4 | ||||
| -rw-r--r-- | website/scripts/docs-preview.integration.test.ts | 90 | ||||
| -rw-r--r-- | website/scripts/docs-preview.mjs | 75 | ||||
| -rw-r--r-- | website/scripts/docs-snapshot.mjs | 124 | ||||
| -rw-r--r-- | website/scripts/docs-versions.integration.test.ts | 54 | ||||
| -rw-r--r-- | website/scripts/docs-versions.mjs | 247 | ||||
| -rw-r--r-- | website/scripts/prepare-docs.mjs | 161 |
7 files changed, 589 insertions, 166 deletions
diff --git a/website/scripts/check-built-docs.mjs b/website/scripts/check-built-docs.mjs index 6f5dfa0b..8841bf74 100644 --- a/website/scripts/check-built-docs.mjs +++ b/website/scripts/check-built-docs.mjs @@ -40,6 +40,10 @@ if (stable.includes('name="robots" content="noindex')) { throw new Error('stable docs must remain indexable'); } assertIncludes(preview, 'data-pagefind-filter="version[content]" content="preview"'); +assertIncludes(preview, `Preview build <code dir="auto">${versions.preview.build_id}</code>`); +if (versions.preview.commit !== 'master') { + assertIncludes(preview, versions.preview.commit.slice(0, 12)); +} assertIncludes(preview, 'name="robots" content="noindex, nofollow"'); assertIncludes(archived, `data-pagefind-filter="version[content]" content="${versions.current}"`); assertIncludes(archived, 'name="robots" content="noindex, nofollow"'); diff --git a/website/scripts/docs-preview.integration.test.ts b/website/scripts/docs-preview.integration.test.ts new file mode 100644 index 00000000..f850689c --- /dev/null +++ b/website/scripts/docs-preview.integration.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +const script = resolve(import.meta.dir, 'docs-preview.mjs'); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe('preview documentation snapshots', () => { + test('snapshots and validates the selected commit exactly', async () => { + const root = await fixture(); + const commit = git(root, ['rev-parse', 'HEAD']).trim(); + await write(root, 'docs/preview/website/stale.mdx', 'stale\n'); + await writeManifest(root, commit); + + runScript(root, ['snapshot', commit]); + runScript(root, ['check']); + + expect(await read(root, 'docs/preview/website/src/content/docs/index.mdx')).toBe( + 'selected preview\n', + ); + await expect(read(root, 'docs/preview/website/stale.mdx')).rejects.toThrow(); + }); + + test('rejects snapshot drift', async () => { + const root = await fixture(); + const commit = git(root, ['rev-parse', 'HEAD']).trim(); + await writeManifest(root, commit); + runScript(root, ['snapshot', commit]); + await write(root, 'docs/preview/website/src/content/docs/index.mdx', 'changed\n'); + + expect(() => runScript(root, ['check'])).toThrow(); + }); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'herdr-preview-docs-')); + temporaryDirectories.push(root); + await write(root, 'docs/next/website/src/content/docs/index.mdx', 'selected preview\n'); + await write(root, 'docs/next/website/src/data/config-reference.json', '{"preview":true}\n'); + git(root, ['init', '-q']); + git(root, ['config', 'user.email', 'test@example.com']); + git(root, ['config', 'user.name', 'Test']); + git(root, ['add', '.']); + git(root, ['commit', '-qm', 'preview fixture']); + return root; +} + +async function writeManifest(root: string, commit: string) { + await write( + root, + 'website/preview.json', + `${JSON.stringify({ + schema_version: 1, + channel: 'preview', + build_id: 'test-build', + commit, + builds: { 'test-build': { commit, tag: 'preview-test-build' } }, + })}\n`, + ); +} + +async function write(root: string, path: string, content: string) { + const destination = resolve(root, path); + await mkdir(resolve(destination, '..'), { recursive: true }); + await writeFile(destination, content, 'utf8'); +} + +async function read(root: string, path: string) { + return readFile(resolve(root, path), 'utf8'); +} + +function git(root: string, args: string[]) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }); +} + +function runScript(root: string, args: string[]) { + execFileSync('node', [script, ...args], { + cwd: root, + env: { ...process.env, HERDR_DOCS_REPO_ROOT: root }, + stdio: 'pipe', + }); +} diff --git a/website/scripts/docs-preview.mjs b/website/scripts/docs-preview.mjs new file mode 100644 index 00000000..c0de1fcd --- /dev/null +++ b/website/scripts/docs-preview.mjs @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + compareGitTree, + createGit, + extractGitTree, + gitPathExists, + resolveCommit, +} from './docs-snapshot.mjs'; + +const websiteDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = process.env.HERDR_DOCS_REPO_ROOT + ? resolve(process.env.HERDR_DOCS_REPO_ROOT) + : resolve(websiteDir, '../..'); +const git = createGit(repoRoot); +const sourceRoot = 'docs/next/website'; +const snapshotRoot = resolve(repoRoot, 'docs/preview/website'); +const manifestPath = resolve(repoRoot, 'website/preview.json'); +const fullShaPattern = /^[0-9a-f]{40}$/; + +export async function snapshotPreview(ref) { + const commit = resolveCommit(git, ref); + if (!gitPathExists(git, commit, `${sourceRoot}/src/content/docs`)) { + throw new Error(`${commit} does not contain staged website documentation`); + } + if (!gitPathExists(git, commit, `${sourceRoot}/src/data/config-reference.json`)) { + throw new Error(`${commit} does not contain the staged config reference`); + } + await extractGitTree(git, commit, sourceRoot, snapshotRoot); + process.stdout.write(`snapshotted preview documentation from ${commit}\n`); +} + +export async function checkPreview() { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + if (manifest.schema_version !== 1 || manifest.channel !== 'preview') { + throw new Error(`${manifestPath} has an unsupported schema`); + } + if (typeof manifest.commit !== 'string' || !fullShaPattern.test(manifest.commit)) { + throw new Error(`${manifestPath} must contain a full preview commit SHA`); + } + const build = manifest.builds?.[manifest.build_id]; + if (!build || build.commit !== manifest.commit) { + throw new Error(`${manifestPath} active build does not match its preview commit`); + } + const expectedTag = `preview-${manifest.build_id}`; + if (build.tag !== expectedTag) { + throw new Error(`${manifestPath} active build tag does not match ${expectedTag}`); + } + if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/content/docs`)) { + throw new Error(`${manifest.commit} does not contain staged website documentation`); + } + if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/data/config-reference.json`)) { + throw new Error(`${manifest.commit} does not contain the staged config reference`); + } + await compareGitTree(git, manifest.commit, sourceRoot, snapshotRoot); + process.stdout.write(`validated preview documentation snapshot ${manifest.commit}\n`); +} + +async function main() { + const [command, value] = process.argv.slice(2); + if (command === 'snapshot' && value) { + await snapshotPreview(value); + return; + } + if (command === 'check' && !value) { + await checkPreview(); + return; + } + throw new Error('usage: node website/scripts/docs-preview.mjs check | snapshot <commit>'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/website/scripts/docs-snapshot.mjs b/website/scripts/docs-snapshot.mjs new file mode 100644 index 00000000..6a1d1bc6 --- /dev/null +++ b/website/scripts/docs-snapshot.mjs @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; + +export function createGit(repoRoot) { + return function git(args, options = {}) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: options.binary ? undefined : 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + }; +} + +export function resolveCommit(git, ref) { + return git(['rev-parse', `${ref}^{commit}`]).trim(); +} + +export function gitPathExists(git, ref, path) { + try { + git(['cat-file', '-e', `${ref}:${path}`]); + return true; + } catch { + return false; + } +} + +export function gitTreesEqual(git, ref, left, right) { + try { + git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]); + return true; + } catch { + return false; + } +} + +export function listGitEntries(git, ref, root) { + const output = git([ + 'ls-tree', + '-r', + '-z', + '--format=%(objectmode) %(objecttype) %(objectname)%x09%(path)', + ref, + '--', + root, + ]); + return output + .split('\0') + .filter(Boolean) + .map((line) => { + const separator = line.indexOf('\t'); + const [mode, type, object] = line.slice(0, separator).split(' '); + return { mode, type, object, path: line.slice(separator + 1) }; + }); +} + +export async function extractGitTree(git, ref, sourceRoot, destinationRoot) { + const entries = listGitEntries(git, ref, sourceRoot); + if (entries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`); + + await rm(destinationRoot, { recursive: true, force: true }); + for (const entry of entries) { + assertOrdinaryFile(entry, ref); + const relativePath = relative(sourceRoot, entry.path); + assertRelativePath(relativePath, entry.path); + const destination = resolve(destinationRoot, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, git(['cat-file', 'blob', entry.object], { binary: true })); + } +} + +export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) { + const expectedEntries = listGitEntries(git, ref, sourceRoot); + if (expectedEntries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`); + + const expected = new Map(); + for (const entry of expectedEntries) { + assertOrdinaryFile(entry, ref); + const relativePath = relative(sourceRoot, entry.path); + assertRelativePath(relativePath, entry.path); + expected.set(relativePath, entry); + } + const actualPaths = await listSnapshotPaths(snapshotRoot); + const expectedPaths = [...expected.keys()].sort(); + if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) { + throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`); + } + + for (const relativePath of expectedPaths) { + const entry = expected.get(relativePath); + const snapshotPath = resolve(snapshotRoot, relativePath); + const expectedContent = git(['cat-file', 'blob', entry.object], { binary: true }); + if (!(await readFile(snapshotPath)).equals(expectedContent)) { + throw new Error(`${relativePath} differs from ${ref}:${entry.path}`); + } + } +} + +async function listSnapshotPaths(root) { + const paths = []; + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) paths.push(relative(root, path)); + else throw new Error(`${path} is not an ordinary documentation file`); + } + } + await walk(root); + return paths.sort(); +} + +function assertOrdinaryFile(entry, ref) { + if (entry.type !== 'blob' || entry.mode !== '100644') { + throw new Error(`${ref}:${entry.path} is not an ordinary documentation file`); + } +} + +function assertRelativePath(relativePath, originalPath) { + if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`unsafe snapshot path ${originalPath}`); + } +} diff --git a/website/scripts/docs-versions.integration.test.ts b/website/scripts/docs-versions.integration.test.ts index c3f97bfe..5e2d36a5 100644 --- a/website/scripts/docs-versions.integration.test.ts +++ b/website/scripts/docs-versions.integration.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; const script = resolve(import.meta.dir, 'docs-versions.mjs'); +const prepareScript = resolve(import.meta.dir, 'prepare-docs.mjs'); const temporaryDirectories: string[] = []; afterEach(async () => { @@ -12,7 +13,7 @@ afterEach(async () => { }); describe('documentation release publishing', () => { - test('snapshots tagged next docs and promotes the same content', async () => { + test('snapshots tagged next docs and switches stable to generated snapshots', async () => { const root = await mkdtemp(join(tmpdir(), 'herdr-docs-')); temporaryDirectories.push(root); await write(root, 'website/src/content/docs/index.mdx', 'stable docs\n'); @@ -30,26 +31,63 @@ describe('documentation release publishing', () => { git(root, ['config', 'user.name', 'Test']); git(root, ['add', '.']); git(root, ['commit', '-qm', 'release fixture']); + git(root, ['tag', 'v0.9.0']); git(root, ['tag', 'v1.0.0']); runScript(root, ['publish', 'v1.0.0']); - expect(await read(root, 'website/src/content/docs/index.mdx')).toBe('next docs\n'); - expect(await read(root, 'website/src/data/config-reference.json')).toBe('{"next":true}\n'); + await expect(read(root, 'website/src/content/docs/index.mdx')).rejects.toThrow(); + await expect(read(root, 'website/src/data/config-reference.json')).rejects.toThrow(); expect(await read(root, 'README.md')).toBe('next readme\n'); expect(await read(root, 'README.zh-CN.md')).toBe('next readme zh-cn\n'); expect(await read(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe('next docs\n'); const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json')); expect(manifest.current).toBe('1.0.0'); + expect(manifest.stable_source).toBe('snapshot'); expect(manifest.versions[0]).toMatchObject({ version: '1.0.0', tag: 'v1.0.0', + commit: expect.stringMatching(/^[0-9a-f]{40}$/), source: 'docs/next/website/src/content/docs', }); + const previewCommit = git(root, ['rev-parse', 'HEAD']).trim(); + await write(root, 'docs/preview/website/src/content/docs/index.mdx', 'preview docs\n'); + await write( + root, + 'docs/preview/website/src/data/config-reference.json', + '{"preview":true}\n', + ); + await write( + root, + 'website/preview.json', + `${JSON.stringify({ build_id: 'preview-test', commit: previewCommit })}\n`, + ); + runPrepare(root); + expect(await read(root, 'website/src/content/docs/index.mdx')).toBe('next docs\n'); + expect(await read(root, 'website/src/content/docs/preview/index.mdx')).toContain( + 'Preview build `preview-test`', + ); + expect(await read(root, 'website/src/data/config-reference.json')).toBe('{"next":true}\n'); + await write(root, 'website/latest.json', '{"version":"1.0.0"}\n'); runScript(root, ['check']); + + await write(root, 'README.md', 'post-release correction\n'); + runScript(root, ['publish', 'v1.0.0']); + expect(await read(root, 'README.md')).toBe('post-release correction\n'); + + runScript(root, ['publish', 'v0.9.0']); + const archivedManifest = JSON.parse(await read(root, 'docs/versions/manifest.json')); + expect(archivedManifest.current).toBe('1.0.0'); + expect(archivedManifest.versions.map(({ version }) => version)).toEqual(['1.0.0', '0.9.0']); + expect(await read(root, 'README.md')).toBe('post-release correction\n'); + runScript(root, ['check']); + + delete archivedManifest.versions[0].commit; + await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`); + expect(() => runScript(root, ['check'])).toThrow(); }); }); @@ -64,7 +102,7 @@ async function read(root: string, path: string) { } function git(root: string, args: string[]) { - execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }); } function runScript(root: string, args: string[]) { @@ -74,3 +112,11 @@ function runScript(root: string, args: string[]) { stdio: 'pipe', }); } + +function runPrepare(root: string) { + execFileSync('node', [prepareScript, '--docs-only'], { + cwd: root, + env: { ...process.env, HERDR_DOCS_REPO_ROOT: root }, + stdio: 'pipe', + }); +} diff --git a/website/scripts/docs-versions.mjs b/website/scripts/docs-versions.mjs index 7b6b0c24..30cb099d 100644 --- a/website/scripts/docs-versions.mjs +++ b/website/scripts/docs-versions.mjs @@ -1,7 +1,14 @@ -import { execFileSync } from 'node:child_process'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, relative, resolve } from 'node:path'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + compareGitTree, + createGit, + extractGitTree, + gitPathExists, + gitTreesEqual, + resolveCommit, +} from './docs-snapshot.mjs'; const websiteDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = process.env.HERDR_DOCS_REPO_ROOT @@ -11,6 +18,7 @@ const versionsDir = resolve(repoRoot, 'docs/versions'); const manifestPath = resolve(versionsDir, 'manifest.json'); const stableDocsDir = resolve(repoRoot, 'website/src/content/docs'); const stableReferencePath = resolve(repoRoot, 'website/src/data/config-reference.json'); +const git = createGit(repoRoot); const VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)$/; @@ -21,94 +29,58 @@ export function normalizeVersion(value) { } export function sortVersionsNewestFirst(versions) { - return [...versions].sort((left, right) => { - const a = left.version.split('.').map(Number); - const b = right.version.split('.').map(Number); - for (let index = 0; index < 3; index += 1) { - if (a[index] !== b[index]) return b[index] - a[index]; - } - return 0; - }); -} - -function git(args, options = {}) { - return execFileSync('git', args, { - cwd: repoRoot, - encoding: options.binary ? undefined : 'utf8', - maxBuffer: 64 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function gitTreesEqual(ref, left, right) { - try { - git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]); - return true; - } catch { - return false; - } -} - -function gitPathExists(ref, path) { - try { - git(['cat-file', '-e', `${ref}:${path}`]); - return true; - } catch { - return false; - } + return [...versions].sort((left, right) => compareVersions(right.version, left.version)); } -function listGitFiles(ref, root) { - const output = git(['ls-tree', '-r', '--name-only', ref, '--', root]); - return output.split('\n').filter(Boolean); -} - -async function extractGitTree(ref, sourceRoot, destinationRoot) { - const files = listGitFiles(ref, sourceRoot); - if (files.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`); - - await rm(destinationRoot, { recursive: true, force: true }); - for (const file of files) { - const destination = resolve(destinationRoot, relative(sourceRoot, file)); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, git(['show', `${ref}:${file}`], { binary: true })); +function compareVersions(left, right) { + const a = left.split('.').map(Number); + const b = right.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] - b[index]; } + return 0; } async function readManifest() { try { return JSON.parse(await readFile(manifestPath, 'utf8')); } catch (error) { - if (error.code === 'ENOENT') return { schema_version: 1, current: null, versions: [] }; + if (error.code === 'ENOENT') { + return { schema_version: 1, stable_source: 'legacy', current: null, versions: [] }; + } throw error; } } async function writeManifest(manifest) { - manifest.versions = sortVersionsNewestFirst(manifest.versions).map(({ version, tag, source }) => ({ - version, - tag, - source, - })); + manifest.versions = sortVersionsNewestFirst(manifest.versions).map( + ({ version, tag, commit, source }) => ({ + version, + tag, + ...(commit ? { commit } : {}), + source, + }), + ); await mkdir(dirname(manifestPath), { recursive: true }); await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); } function releaseMetadata(tag) { const version = normalizeVersion(tag); - return { version, tag: `v${version}` }; + const normalizedTag = `v${version}`; + return { version, tag: normalizedTag, commit: resolveCommit(git, normalizedTag) }; } async function snapshotTag(tag, sourceRoot) { const metadata = { ...releaseMetadata(tag), source: sourceRoot }; const destination = resolve(versionsDir, metadata.version); await rm(destination, { recursive: true, force: true }); - await extractGitTree(tag, sourceRoot, resolve(destination, 'website/src/content/docs')); + await extractGitTree(git, tag, sourceRoot, resolve(destination, 'website/src/content/docs')); const referenceSource = sourceRoot.includes('docs/next/') ? 'docs/next/website/src/data/config-reference.json' : 'website/src/data/config-reference.json'; - if (gitPathExists(tag, referenceSource)) { + if (gitPathExists(git, tag, referenceSource)) { const referenceDestination = resolve(destination, 'website/src/data/config-reference.json'); await mkdir(dirname(referenceDestination), { recursive: true }); await writeFile(referenceDestination, git(['show', `${tag}:${referenceSource}`], { binary: true })); @@ -124,12 +96,16 @@ export async function backfillVersions() { for (const tag of tags) { const version = normalizeVersion(tag); - if (entries.has(version) || !gitPathExists(tag, 'website/src/content/docs')) continue; + if (entries.has(version)) continue; const nextRoot = 'docs/next/website/src/content/docs'; + const legacyRoot = 'website/src/content/docs'; + const hasNext = gitPathExists(git, tag, nextRoot); + const hasLegacy = gitPathExists(git, tag, legacyRoot); + if (!hasNext && !hasLegacy) continue; const sourceRoot = - gitPathExists(tag, nextRoot) && !gitTreesEqual(tag, 'website/src/content/docs', nextRoot) + hasNext && (!hasLegacy || !gitTreesEqual(git, tag, legacyRoot, nextRoot)) ? nextRoot - : 'website/src/content/docs'; + : legacyRoot; const metadata = await snapshotTag(tag, sourceRoot); entries.set(metadata.version, metadata); process.stdout.write(`snapshotted ${tag}\n`); @@ -138,6 +114,7 @@ export async function backfillVersions() { const current = JSON.parse(await readFile(resolve(repoRoot, 'website/latest.json'), 'utf8')).version; await writeManifest({ schema_version: 1, + stable_source: manifest.stable_source ?? 'legacy', current: normalizeVersion(current), versions: [...entries.values()], }); @@ -145,7 +122,11 @@ export async function backfillVersions() { export async function checkVersions() { const manifest = await readManifest(); - if (manifest.schema_version !== 1 || typeof manifest.current !== 'string') { + if ( + manifest.schema_version !== 1 || + typeof manifest.current !== 'string' || + !['legacy', 'snapshot'].includes(manifest.stable_source ?? 'legacy') + ) { throw new Error(`${manifestPath} has an unsupported schema`); } @@ -166,24 +147,29 @@ export async function checkVersions() { if (normalizeVersion(entry.tag) !== entry.version) { throw new Error(`docs version ${entry.version} has mismatched tag ${entry.tag}`); } + const requiresCommit = + entry.source === 'docs/next/website/src/content/docs' || + ((manifest.stable_source ?? 'legacy') === 'snapshot' && entry.version === manifest.current); + if (requiresCommit && !entry.commit) { + throw new Error(`docs version ${entry.version} is missing commit provenance`); + } + if (entry.commit) { + if (!/^[0-9a-f]{40}$/.test(entry.commit)) { + throw new Error(`docs version ${entry.version} has invalid commit ${entry.commit}`); + } + const taggedCommit = resolveCommit(git, entry.tag); + if (taggedCommit !== entry.commit) { + throw new Error( + `docs version ${entry.version} tag ${entry.tag} moved from ${entry.commit} to ${taggedCommit}`, + ); + } + } if (!['website/src/content/docs', 'docs/next/website/src/content/docs'].includes(entry.source)) { throw new Error(`docs version ${entry.version} has unsupported source ${entry.source}`); } const sourceRoot = entry.source; const snapshotRoot = resolve(versionsDir, entry.version, 'website/src/content/docs'); - const expected = listGitFiles(entry.tag, sourceRoot).map((path) => relative(sourceRoot, path)).sort(); - const actual = (await listFiles(snapshotRoot)).sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error(`documentation file list for ${entry.version} differs from ${entry.tag}:${sourceRoot}`); - } - for (const relativePath of expected) { - const actualContent = await readFile(resolve(snapshotRoot, relativePath)); - const taggedPath = `${sourceRoot}/${relativePath.split('\\').join('/')}`; - const taggedContent = git(['show', `${entry.tag}:${taggedPath}`], { binary: true }); - if (!actualContent.equals(taggedContent)) { - throw new Error(`${entry.version}/${relativePath} differs from ${entry.tag}:${sourceRoot}`); - } - } + await compareGitTree(git, entry.tag, sourceRoot, snapshotRoot); const referenceSource = sourceRoot.includes('docs/next/') ? 'docs/next/website/src/data/config-reference.json' @@ -193,7 +179,7 @@ export async function checkVersions() { entry.version, 'website/src/data/config-reference.json', ); - if (gitPathExists(entry.tag, referenceSource)) { + if (gitPathExists(git, entry.tag, referenceSource)) { const actualReference = await readFile(referenceSnapshot); const taggedReference = git(['show', `${entry.tag}:${referenceSource}`], { binary: true }); if (!actualReference.equals(taggedReference)) { @@ -217,32 +203,64 @@ export async function checkVersions() { export async function publishVersion(tag) { const release = releaseMetadata(tag); - if (!gitPathExists(tag, 'docs/next/website/src/content/docs')) { + if (!gitPathExists(git, tag, 'docs/next/website/src/content/docs')) { throw new Error(`${tag} does not contain staged website documentation`); } const manifest = await readManifest(); const existing = manifest.versions.find((entry) => entry.version === release.version); - if (existing && existing.tag !== release.tag) { - throw new Error(`version ${release.version} is already associated with ${existing.tag}`); + if (manifest.current === release.version) { + if ((manifest.stable_source ?? 'legacy') === 'legacy') { + process.stdout.write(`documentation ${release.tag} is already published with the legacy stable source\n`); + return; + } + if (!existing?.commit || existing.commit !== release.commit) { + throw new Error(`published documentation ${release.version} has mismatched commit provenance`); + } + await compareGitTree(git, release.commit, existing.source, resolve(versionsDir, release.version, 'website/src/content/docs')); + process.stdout.write(`documentation snapshot ${release.tag} is already published\n`); + return; + } + if (manifest.current && compareVersions(release.version, manifest.current) < 0) { + if (existing) { + if (existing.tag !== release.tag) { + throw new Error(`version ${release.version} is already associated with ${existing.tag}`); + } + if (existing.commit && existing.commit !== release.commit) { + throw new Error(`version ${release.version} has mismatched commit provenance`); + } + await compareGitTree( + git, + release.commit, + existing.source, + resolve(versionsDir, release.version, 'website/src/content/docs'), + ); + process.stdout.write(`documentation snapshot ${release.tag} is already archived\n`); + return; + } + const archived = await snapshotTag(tag, 'docs/next/website/src/content/docs'); + const archivedEntries = new Map(manifest.versions.map((entry) => [entry.version, entry])); + archivedEntries.set(archived.version, archived); + await writeManifest({ + ...manifest, + versions: [...archivedEntries.values()], + }); + process.stdout.write( + `archived documentation snapshot ${release.tag} without changing current ${manifest.current}\n`, + ); + return; + } + if (existing) { + throw new Error(`version ${release.version} already has a documentation snapshot`); } const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs'); - const snapshotRoot = resolve(versionsDir, metadata.version, 'website'); - await replaceDirectory(resolve(snapshotRoot, 'src/content/docs'), stableDocsDir); - - const snapshotReference = resolve(snapshotRoot, 'src/data/config-reference.json'); - try { - await mkdir(dirname(stableReferencePath), { recursive: true }); - await writeFile(stableReferencePath, await readFile(snapshotReference)); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - await rm(stableReferencePath, { force: true }); - } + await rm(stableDocsDir, { recursive: true, force: true }); + await rm(stableReferencePath, { force: true }); for (const readme of ['README.md', 'README.zh-CN.md']) { const nextReadme = `docs/next/${readme}`; - if (gitPathExists(tag, nextReadme)) { + if (gitPathExists(git, tag, nextReadme)) { await writeFile(resolve(repoRoot, readme), git(['show', `${tag}:${nextReadme}`], { binary: true })); } } @@ -251,43 +269,13 @@ export async function publishVersion(tag) { entries.set(metadata.version, metadata); await writeManifest({ schema_version: 1, + stable_source: 'snapshot', current: metadata.version, versions: [...entries.values()], }); process.stdout.write(`published documentation snapshot ${metadata.tag}\n`); } -async function replaceDirectory(source, destination) { - await rm(destination, { recursive: true, force: true }); - await copyDirectory(source, destination); -} - -async function listFiles(root) { - const files = []; - async function walk(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) await walk(path); - else if (entry.isFile()) files.push(relative(root, path)); - } - } - await walk(root); - return files; -} - -async function copyDirectory(source, destination) { - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - const sourcePath = join(source, entry.name); - const destinationPath = join(destination, entry.name); - if (entry.isDirectory()) { - await copyDirectory(sourcePath, destinationPath); - } else if (entry.isFile()) { - await writeFile(destinationPath, await readFile(sourcePath)); - } - } -} - async function main() { const [command, value] = process.argv.slice(2); if (command === 'backfill' && !value) { @@ -302,7 +290,14 @@ async function main() { await checkVersions(); return; } - throw new Error('usage: node website/scripts/docs-versions.mjs backfill | check | publish <tag>'); + if (command === 'current' && !value) { + const manifest = await readManifest(); + process.stdout.write(`${manifest.current ?? ''}\n`); + return; + } + throw new Error( + 'usage: node website/scripts/docs-versions.mjs backfill | check | current | publish <tag>', + ); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs index 6b5c4b5f..a96e2c41 100644 --- a/website/scripts/prepare-docs.mjs +++ b/website/scripts/prepare-docs.mjs @@ -4,18 +4,20 @@ import { fileURLToPath } from 'node:url'; import process from 'node:process'; const websiteDir = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(websiteDir, '../..'); +const repoRoot = process.env.HERDR_DOCS_REPO_ROOT + ? resolve(process.env.HERDR_DOCS_REPO_ROOT) + : resolve(websiteDir, '../..'); const publicDir = resolve(repoRoot, 'website/public'); const stableDocsDir = resolve(repoRoot, 'website/src/content/docs'); -const previewDocsSourceDir = resolve(repoRoot, 'docs/next/website/src/content/docs'); const previewDocsDir = resolve(stableDocsDir, 'preview'); const generatedVersionsDocsDir = resolve(stableDocsDir, '_versions'); const versionsDir = resolve(repoRoot, 'docs/versions'); const versionsManifestPath = resolve(versionsDir, 'manifest.json'); +const previewManifestPath = resolve(repoRoot, 'website/preview.json'); const generatedVersionsDataPath = resolve(repoRoot, 'website/src/data/docs-versions.json'); -const previewConfigReferenceSource = resolve( +const stableConfigReferenceDestination = resolve( repoRoot, - 'docs/next/website/src/data/config-reference.json', + 'website/src/data/config-reference.json', ); const previewConfigReferenceDestination = resolve( repoRoot, @@ -29,7 +31,12 @@ const generatedVersionReferencesPath = resolve( if (process.argv[2] === '--rewrite-preview-doc-fixture') { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); - process.stdout.write(rewritePreviewDocContent(Buffer.concat(chunks).toString('utf8'))); + process.stdout.write( + rewritePreviewDocContent(Buffer.concat(chunks).toString('utf8'), '', { + buildId: '2026-07-29-44b3adb12552', + commit: '44b3adb125524ea9a55739eee3776f922f2115ad', + }), + ); } else if (process.argv[2] === '--rewrite-version-doc-fixture') { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); @@ -42,8 +49,13 @@ if (process.argv[2] === '--rewrite-preview-doc-fixture') { }), ); } else { - await preparePublicAssets(); - await prepareDocs(); + const supported = new Set(['--docs-only', '--draft']); + const unsupported = process.argv.slice(2).filter((argument) => !supported.has(argument)); + if (unsupported.length > 0) { + throw new Error('usage: node website/scripts/prepare-docs.mjs [--docs-only] [--draft]'); + } + if (!process.argv.includes('--docs-only')) await preparePublicAssets(); + await prepareDocs({ draft: process.argv.includes('--draft') }); } async function preparePublicAssets() { @@ -75,22 +87,69 @@ async function preparePublicAssets() { } } -async function prepareDocs() { - await rm(previewDocsDir, { recursive: true, force: true }); - await rm(generatedVersionsDocsDir, { recursive: true, force: true }); +async function prepareDocs({ draft }) { + const manifest = JSON.parse(await readFile(versionsManifestPath, 'utf8')); + if ( + manifest.schema_version !== 1 || + typeof manifest.current !== 'string' || + !['legacy', 'snapshot'].includes(manifest.stable_source ?? 'legacy') + ) { + throw new Error(`${versionsManifestPath} has an unsupported schema`); + } + const currentEntry = manifest.versions.find((entry) => entry.version === manifest.current); + if (!currentEntry) throw new Error(`current docs version ${manifest.current} has no snapshot`); - await copyPreparedDocs(previewDocsSourceDir, previewDocsDir, (content, relativePath) => - rewritePreviewDocContent(content, relativePath), + const previewManifest = JSON.parse(await readFile(previewManifestPath, 'utf8')); + if (!/^[0-9a-f]{40}$/.test(previewManifest.commit ?? '')) { + throw new Error(`${previewManifestPath} must contain a full preview commit SHA`); + } + const previewWebsiteRoot = resolve(repoRoot, draft ? 'docs/next/website' : 'docs/preview/website'); + const previewDocsSourceDir = resolve(previewWebsiteRoot, 'src/content/docs'); + const previewConfigReferenceSource = resolve( + previewWebsiteRoot, + 'src/data/config-reference.json', ); - await cp(previewConfigReferenceSource, previewConfigReferenceDestination); - const manifest = JSON.parse(await readFile(versionsManifestPath, 'utf8')); - if (manifest.schema_version !== 1 || typeof manifest.current !== 'string') { - throw new Error(`${versionsManifestPath} has an unsupported schema`); + if ((manifest.stable_source ?? 'legacy') === 'snapshot') { + await rm(stableDocsDir, { recursive: true, force: true }); + const currentSnapshotRoot = resolve(versionsDir, manifest.current, 'website'); + await copyPreparedDocs( + resolve(currentSnapshotRoot, 'src/content/docs'), + stableDocsDir, + (content, relativePath) => + rewriteStableDocContent(content, { + tag: currentEntry.tag, + sourceRoot: currentEntry.source, + relativePath, + }), + ); + const currentReference = resolve(currentSnapshotRoot, 'src/data/config-reference.json'); + try { + await cp(currentReference, stableConfigReferenceDestination); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + await rm(stableConfigReferenceDestination, { force: true }); + } + } else { + await rm(previewDocsDir, { recursive: true, force: true }); + await rm(generatedVersionsDocsDir, { recursive: true, force: true }); } + await copyPreparedDocs(previewDocsSourceDir, previewDocsDir, (content, relativePath) => + rewritePreviewDocContent(content, relativePath, { + buildId: draft ? 'draft' : previewManifest.build_id, + commit: draft ? 'master' : previewManifest.commit, + }), + ); + await cp(previewConfigReferenceSource, previewConfigReferenceDestination); + const scopes = { - stable: await collectDocsScope(stableDocsDir, new Set(['preview', '_versions'])), + stable: await collectDocsScope( + (manifest.stable_source ?? 'legacy') === 'snapshot' + ? resolve(versionsDir, manifest.current, 'website/src/content/docs') + : stableDocsDir, + new Set(['preview', '_versions']), + ), preview: await collectDocsScope(previewDocsSourceDir), }; const configReferences = {}; @@ -123,7 +182,14 @@ async function prepareDocs() { await writeFile( generatedVersionsDataPath, - `${JSON.stringify({ ...manifest, scopes }, null, 2)}\n`, + `${JSON.stringify({ + ...manifest, + preview: { + build_id: draft ? 'draft' : previewManifest.build_id, + commit: draft ? 'master' : previewManifest.commit, + }, + scopes, + }, null, 2)}\n`, 'utf8', ); await writeFile( @@ -183,34 +249,56 @@ async function collectDocsScope(sourceDir, excludedDirectories = new Set()) { return { locales }; } -export function rewritePreviewDocContent(content, relativePath = '') { - const rewritten = rewriteRelativeDocPaths( +export function rewritePreviewDocContent( + content, + relativePath = '', + { buildId, commit } = { + buildId: 'preview', + commit: 'master', + }, +) { + const taggedContent = rewriteRepositoryLinks( content.replaceAll('/docs/', '/docs/preview/'), - 1, + commit, ); - const withEditLink = setGeneratedEditUrl( + const rewritten = rewriteRelativeDocPaths(taggedContent, 1); + const withSourceLink = setGeneratedEditUrl( rewritten, - `https://github.com/herdrdev/herdr/edit/master/docs/next/website/src/content/docs/${relativePath}`, + `https://github.com/herdrdev/herdr/blob/${commit}/docs/next/website/src/content/docs/${relativePath}`, + ); + return insertPreviewNotice(withSourceLink, relativePath, { buildId, commit }); +} + +export function rewriteStableDocContent(content, { tag, sourceRoot, relativePath }) { + const taggedContent = rewriteRepositoryLinks(content, tag); + return setGeneratedEditUrl( + taggedContent, + `https://github.com/herdrdev/herdr/blob/${tag}/${sourceRoot}/${relativePath}`, ); - return insertPreviewNotice(withEditLink, relativePath); } export function rewriteVersionDocContent(content, { version, tag, sourceRoot, relativePath }) { - const taggedContent = content - .replaceAll('/docs/', `/docs/${version}/`) + const taggedContent = rewriteRepositoryLinks( + content.replaceAll('/docs/', `/docs/${version}/`), + tag, + ); + const rewritten = rewriteRelativeDocPaths(taggedContent, 2); + return setGeneratedEditUrl( + rewritten, + `https://github.com/herdrdev/herdr/blob/${tag}/${sourceRoot}/${relativePath}`, + ); +} + +function rewriteRepositoryLinks(content, ref) { + return content .replaceAll( 'https://github.com/herdrdev/herdr/blob/master/', - `https://github.com/herdrdev/herdr/blob/${tag}/`, + `https://github.com/herdrdev/herdr/blob/${ref}/`, ) .replaceAll( 'https://raw.githubusercontent.com/herdrdev/herdr/master/', - `https://raw.githubusercontent.com/herdrdev/herdr/${tag}/`, + `https://raw.githubusercontent.com/herdrdev/herdr/${ref}/`, ); - const rewritten = rewriteRelativeDocPaths(taggedContent, 2); - return setGeneratedEditUrl( - rewritten, - `https://github.com/herdrdev/herdr/blob/${tag}/${sourceRoot}/${relativePath}`, - ); } function rewriteRelativeDocPaths(content, extraDepth) { @@ -225,15 +313,16 @@ function setGeneratedEditUrl(content, editUrl) { return content.replace(/^---\n/, `---\neditUrl: ${editUrl}\n`); } -function insertPreviewNotice(content, relativePath) { +function insertPreviewNotice(content, relativePath, { buildId, commit }) { + const source = commit === 'master' ? '`master` draft' : `[${commit.slice(0, 12)}](https://github.com/herdrdev/herdr/commit/${commit})`; const notice = [ - '> Next docs describe unreleased work from `master`. Stable docs remain at [/docs/](/docs/).', + `> Preview build \`${buildId}\`, published from ${source}. Stable docs remain at [/docs/](/docs/).`, '', '', ].join('\n'); const indexPrefix = relativePath === 'index.mdx' - ? content.replace('title: Herdr documentation', 'title: Herdr next documentation') + ? content.replace('title: Herdr documentation', 'title: Herdr preview documentation') : content; const frontmatter = indexPrefix.match(/^---\n[\s\S]*?\n---\n/); if (!frontmatter) { |
