aboutsummaryrefslogtreecommitdiff
path: root/website/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-08-03 01:21:30 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-08-03 01:21:30 +0300
commit0e434881b3f8da1db58dba8d103ddc234ad17238 (patch)
treea25d6e4655710d2e0b4d5c3fb3ca72570e5403a7 /website/scripts
parent0a800b8f2e9894433c6ef9d106d10766ac408218 (diff)
fix(docs): publish only released documentation
Diffstat (limited to 'website/scripts')
-rw-r--r--website/scripts/check-built-docs.mjs4
-rw-r--r--website/scripts/docs-preview.integration.test.ts90
-rw-r--r--website/scripts/docs-preview.mjs75
-rw-r--r--website/scripts/docs-snapshot.mjs124
-rw-r--r--website/scripts/docs-versions.integration.test.ts54
-rw-r--r--website/scripts/docs-versions.mjs247
-rw-r--r--website/scripts/prepare-docs.mjs161
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) {