aboutsummaryrefslogtreecommitdiff
path: root/website/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'website/scripts')
-rw-r--r--website/scripts/check-built-docs.mjs100
-rw-r--r--website/scripts/docs-versions.integration.test.ts73
-rw-r--r--website/scripts/docs-versions.mjs308
-rw-r--r--website/scripts/docs-versions.test.ts33
-rw-r--r--website/scripts/prepare-docs.mjs173
5 files changed, 668 insertions, 19 deletions
diff --git a/website/scripts/check-built-docs.mjs b/website/scripts/check-built-docs.mjs
new file mode 100644
index 00000000..6f5dfa0b
--- /dev/null
+++ b/website/scripts/check-built-docs.mjs
@@ -0,0 +1,100 @@
+import { access, readFile, readdir, stat } from 'node:fs/promises';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const websiteDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const distDir = resolve(websiteDir, 'dist');
+const nonCanonicalDocsUrl = /https:\/\/herdr\.dev\/(?:ja\/|zh-cn\/)?docs\/(?:preview|\d+\.\d+\.\d+)(?:\/|<)/;
+const versions = JSON.parse(
+ await readFile(resolve(websiteDir, 'src/data/docs-versions.json'), 'utf8'),
+);
+
+for (const entry of versions.versions) {
+ const scope = versions.scopes[entry.version];
+ if (!scope) throw new Error(`missing generated scope for ${entry.version}`);
+ for (const [locale, pages] of Object.entries(scope.locales)) {
+ const localePrefix = locale === 'root' ? '' : `${locale}/`;
+ for (const page of pages) {
+ const output = resolve(
+ distDir,
+ localePrefix,
+ 'docs',
+ entry.version,
+ page,
+ 'index.html',
+ );
+ await access(output);
+ }
+ }
+}
+
+const stable = await readFile(resolve(distDir, 'docs/index.html'), 'utf8');
+const preview = await readFile(resolve(distDir, 'docs/preview/index.html'), 'utf8');
+const archived = await readFile(
+ resolve(distDir, 'docs', versions.current, 'index.html'),
+ 'utf8',
+);
+
+assertIncludes(stable, 'data-pagefind-filter="version[content]" content="stable"');
+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, 'name="robots" content="noindex, nofollow"');
+assertIncludes(archived, `data-pagefind-filter="version[content]" content="${versions.current}"`);
+assertIncludes(archived, 'name="robots" content="noindex, nofollow"');
+if (archived.includes(`This page documents Herdr ${versions.current}`)) {
+ throw new Error('the current immutable snapshot must not be labeled as outdated');
+}
+const versionSelect = stable.match(/<select[^>]*aria-label="Documentation version"[^>]*>([\s\S]*?)<\/select>/)?.[1];
+if (!versionSelect) throw new Error('stable docs are missing the version selector');
+if (versionSelect.includes(`value="/docs/${versions.current}/"`)) {
+ throw new Error('the current release is duplicated in the version selector');
+}
+
+const previous = versions.versions.find((entry) => entry.version !== versions.current);
+if (previous) {
+ const previousArchive = await readFile(
+ resolve(distDir, 'docs', previous.version, 'index.html'),
+ 'utf8',
+ );
+ assertIncludes(previousArchive, `This page documents Herdr ${previous.version}`);
+}
+
+const sitemap = await readFile(resolve(distDir, 'sitemap-0.xml'), 'utf8');
+assertIncludes(sitemap, 'https://herdr.dev/docs/');
+if (nonCanonicalDocsUrl.test(sitemap)) {
+ throw new Error('preview or immutable documentation URLs must not appear in the sitemap');
+}
+
+const build = await inspectFiles(distDir);
+if (build.count > 20_000) {
+ throw new Error(`website build has ${build.count} files, exceeding the Cloudflare Pages free-plan limit`);
+}
+if (build.largest.bytes > 25 * 1024 * 1024) {
+ throw new Error(`website asset ${build.largest.path} is ${build.largest.bytes} bytes, exceeding Cloudflare Pages' 25 MiB limit`);
+}
+process.stdout.write(`validated ${versions.versions.length} documentation versions in ${build.count} website files\n`);
+
+function assertIncludes(content, expected) {
+ if (!content.includes(expected)) throw new Error(`built documentation is missing ${expected}`);
+}
+
+async function inspectFiles(directory, root = directory) {
+ const result = { count: 0, largest: { path: '', bytes: 0 } };
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ const nested = await inspectFiles(path, root);
+ result.count += nested.count;
+ if (nested.largest.bytes > result.largest.bytes) result.largest = nested.largest;
+ } else if (entry.isFile()) {
+ result.count += 1;
+ const { size } = await stat(path);
+ if (size > result.largest.bytes) {
+ result.largest = { path: path.slice(root.length + 1), bytes: size };
+ }
+ }
+ }
+ return result;
+}
diff --git a/website/scripts/docs-versions.integration.test.ts b/website/scripts/docs-versions.integration.test.ts
new file mode 100644
index 00000000..aede1957
--- /dev/null
+++ b/website/scripts/docs-versions.integration.test.ts
@@ -0,0 +1,73 @@
+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-versions.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
+});
+
+describe('documentation release publishing', () => {
+ test('snapshots tagged next docs and promotes the same content', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'website/src/content/docs/index.mdx', 'stable docs\n');
+ await write(root, 'website/src/data/config-reference.json', '{"stable":true}\n');
+ await write(root, 'website/latest.json', '{"version":"0.9.0"}\n');
+ await write(root, 'README.md', 'stable readme\n');
+ await write(root, 'docs/next/website/src/content/docs/index.mdx', 'next docs\n');
+ await write(root, 'docs/next/website/src/data/config-reference.json', '{"next":true}\n');
+ await write(root, 'docs/next/README.md', 'next readme\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', 'release fixture']);
+ 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');
+ expect(await read(root, 'README.md')).toBe('next readme\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.versions[0]).toMatchObject({
+ version: '1.0.0',
+ tag: 'v1.0.0',
+ source: 'docs/next/website/src/content/docs',
+ });
+
+ await write(root, 'website/latest.json', '{"version":"1.0.0"}\n');
+ runScript(root, ['check']);
+ });
+});
+
+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[]) {
+ execFileSync('git', args, { cwd: root, 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-versions.mjs b/website/scripts/docs-versions.mjs
new file mode 100644
index 00000000..dbdccd77
--- /dev/null
+++ b/website/scripts/docs-versions.mjs
@@ -0,0 +1,308 @@
+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 { fileURLToPath, pathToFileURL } from 'node:url';
+
+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 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 VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)$/;
+
+export function normalizeVersion(value) {
+ const match = VERSION_PATTERN.exec(value);
+ if (!match) throw new Error(`version must look like 0.7.5 or v0.7.5, got ${value}`);
+ return match[1];
+}
+
+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;
+ }
+}
+
+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 }));
+ }
+}
+
+async function readManifest() {
+ try {
+ return JSON.parse(await readFile(manifestPath, 'utf8'));
+ } catch (error) {
+ if (error.code === 'ENOENT') return { schema_version: 1, current: null, versions: [] };
+ throw error;
+ }
+}
+
+async function writeManifest(manifest) {
+ manifest.versions = sortVersionsNewestFirst(manifest.versions).map(({ version, tag, source }) => ({
+ version,
+ tag,
+ 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}` };
+}
+
+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'));
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ if (gitPathExists(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 }));
+ }
+
+ return metadata;
+}
+
+export async function backfillVersions() {
+ const tags = git(['tag', '--list', 'v*', '--sort=v:refname']).split('\n').filter(Boolean);
+ const manifest = await readManifest();
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+
+ for (const tag of tags) {
+ const version = normalizeVersion(tag);
+ if (entries.has(version) || !gitPathExists(tag, 'website/src/content/docs')) continue;
+ const nextRoot = 'docs/next/website/src/content/docs';
+ const sourceRoot =
+ gitPathExists(tag, nextRoot) && !gitTreesEqual(tag, 'website/src/content/docs', nextRoot)
+ ? nextRoot
+ : 'website/src/content/docs';
+ const metadata = await snapshotTag(tag, sourceRoot);
+ entries.set(metadata.version, metadata);
+ process.stdout.write(`snapshotted ${tag}\n`);
+ }
+
+ const current = JSON.parse(await readFile(resolve(repoRoot, 'website/latest.json'), 'utf8')).version;
+ await writeManifest({
+ schema_version: 1,
+ current: normalizeVersion(current),
+ versions: [...entries.values()],
+ });
+}
+
+export async function checkVersions() {
+ const manifest = await readManifest();
+ if (manifest.schema_version !== 1 || typeof manifest.current !== 'string') {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+
+ const latest = JSON.parse(await readFile(resolve(repoRoot, 'website/latest.json'), 'utf8')).version;
+ if (manifest.current !== normalizeVersion(latest)) {
+ throw new Error(`docs current version ${manifest.current} does not match website latest ${latest}`);
+ }
+
+ const expectedOrder = sortVersionsNewestFirst(manifest.versions).map(({ version }) => version);
+ if (JSON.stringify(manifest.versions.map(({ version }) => version)) !== JSON.stringify(expectedOrder)) {
+ throw new Error('documentation versions are not sorted newest first');
+ }
+
+ const seen = new Set();
+ for (const entry of manifest.versions) {
+ if (seen.has(entry.version)) throw new Error(`duplicate docs version ${entry.version}`);
+ seen.add(entry.version);
+ if (normalizeVersion(entry.tag) !== entry.version) {
+ throw new Error(`docs version ${entry.version} has mismatched tag ${entry.tag}`);
+ }
+ 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}`);
+ }
+ }
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ const referenceSnapshot = resolve(
+ versionsDir,
+ entry.version,
+ 'website/src/data/config-reference.json',
+ );
+ if (gitPathExists(entry.tag, referenceSource)) {
+ const actualReference = await readFile(referenceSnapshot);
+ const taggedReference = git(['show', `${entry.tag}:${referenceSource}`], { binary: true });
+ if (!actualReference.equals(taggedReference)) {
+ throw new Error(`${entry.version} config reference differs from ${entry.tag}:${referenceSource}`);
+ }
+ } else {
+ try {
+ await readFile(referenceSnapshot);
+ throw new Error(`${entry.version} has an unexpected config reference snapshot`);
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+ }
+
+ if (!seen.has(manifest.current)) {
+ throw new Error(`current docs version ${manifest.current} has no snapshot`);
+ }
+ process.stdout.write(`validated ${manifest.versions.length} documentation snapshots\n`);
+}
+
+export async function publishVersion(tag) {
+ const release = releaseMetadata(tag);
+ if (!gitPathExists(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}`);
+ }
+
+ 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 });
+ }
+
+ const nextReadme = `docs/next/README.md`;
+ if (gitPathExists(tag, nextReadme)) {
+ await writeFile(resolve(repoRoot, 'README.md'), git(['show', `${tag}:${nextReadme}`], { binary: true }));
+ }
+
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ entries.set(metadata.version, metadata);
+ await writeManifest({
+ schema_version: 1,
+ 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) {
+ await backfillVersions();
+ return;
+ }
+ if (command === 'publish' && value) {
+ await publishVersion(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkVersions();
+ return;
+ }
+ throw new Error('usage: node website/scripts/docs-versions.mjs backfill | check | publish <tag>');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/website/scripts/docs-versions.test.ts b/website/scripts/docs-versions.test.ts
new file mode 100644
index 00000000..dc450b37
--- /dev/null
+++ b/website/scripts/docs-versions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from 'bun:test';
+import { normalizeVersion, sortVersionsNewestFirst } from './docs-versions.mjs';
+
+describe('normalizeVersion', () => {
+ test.each([
+ ['0.7.5', '0.7.5'],
+ ['v0.7.5', '0.7.5'],
+ ])('normalizes %s', (input, expected) => {
+ expect(normalizeVersion(input)).toBe(expected);
+ });
+
+ test('rejects non-release refs', () => {
+ expect(() => normalizeVersion('preview-123')).toThrow();
+ });
+});
+
+describe('sortVersionsNewestFirst', () => {
+ test('sorts semantic versions numerically', () => {
+ const versions = [
+ { version: '0.6.10' },
+ { version: '0.7.2' },
+ { version: '0.7.10' },
+ { version: '0.5.12' },
+ ];
+
+ expect(sortVersionsNewestFirst(versions).map(({ version }) => version)).toEqual([
+ '0.7.10',
+ '0.7.2',
+ '0.6.10',
+ '0.5.12',
+ ]);
+ });
+});
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index 2c6fd5f4..c7b3e9d8 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -1,5 +1,5 @@
-import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
-import { dirname, join, relative, resolve } from 'node:path';
+import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
@@ -9,6 +9,10 @@ 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 generatedVersionsDataPath = resolve(repoRoot, 'website/src/data/docs-versions.json');
const previewConfigReferenceSource = resolve(
repoRoot,
'docs/next/website/src/data/config-reference.json',
@@ -17,14 +21,29 @@ const previewConfigReferenceDestination = resolve(
repoRoot,
'website/src/data/config-reference-preview.json',
);
+const generatedVersionReferencesPath = resolve(
+ repoRoot,
+ 'website/src/data/config-reference-versions.json',
+);
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')));
+} else if (process.argv[2] === '--rewrite-version-doc-fixture') {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ process.stdout.write(
+ rewriteVersionDocContent(Buffer.concat(chunks).toString('utf8'), {
+ version: process.argv[3] ?? '0.7.5',
+ tag: `v${process.argv[3] ?? '0.7.5'}`,
+ sourceRoot: 'docs/next/website/src/content/docs',
+ relativePath: 'index.mdx',
+ }),
+ );
} else {
await preparePublicAssets();
- await preparePreviewDocs();
+ await prepareDocs();
}
async function preparePublicAssets() {
@@ -56,49 +75,165 @@ async function preparePublicAssets() {
}
}
-async function preparePreviewDocs() {
+async function prepareDocs() {
await rm(previewDocsDir, { recursive: true, force: true });
- await copyPreviewDocs(previewDocsSourceDir, previewDocsDir);
+ await rm(generatedVersionsDocsDir, { recursive: true, force: true });
+
+ await copyPreparedDocs(previewDocsSourceDir, previewDocsDir, (content, relativePath) =>
+ rewritePreviewDocContent(content, relativePath),
+ );
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`);
+ }
+
+ const scopes = {
+ stable: await collectDocsScope(stableDocsDir, new Set(['preview', '_versions'])),
+ preview: await collectDocsScope(previewDocsSourceDir),
+ };
+ const configReferences = {};
+
+ for (const entry of manifest.versions) {
+ const version = entry.version;
+ const snapshotDocsRoot = resolve(versionsDir, version, 'website/src/content/docs');
+ const destinationRoot = resolve(generatedVersionsDocsDir, version);
+ await copyPreparedDocs(snapshotDocsRoot, destinationRoot, (content, relativePath) =>
+ rewriteVersionDocContent(content, {
+ version,
+ tag: entry.tag,
+ sourceRoot: entry.source,
+ relativePath,
+ }),
+ );
+ scopes[version] = await collectDocsScope(snapshotDocsRoot);
+
+ const referencePath = resolve(
+ versionsDir,
+ version,
+ 'website/src/data/config-reference.json',
+ );
+ try {
+ configReferences[version] = JSON.parse(await readFile(referencePath, 'utf8'));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+
+ await writeFile(
+ generatedVersionsDataPath,
+ `${JSON.stringify({ ...manifest, scopes }, null, 2)}\n`,
+ 'utf8',
+ );
+ await writeFile(
+ generatedVersionReferencesPath,
+ `${JSON.stringify(configReferences, null, 2)}\n`,
+ 'utf8',
+ );
}
-async function copyPreviewDocs(sourceDir, destinationDir) {
+async function copyPreparedDocs(sourceDir, destinationDir, rewrite, pathPrefix = '') {
await mkdir(destinationDir, { recursive: true });
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
const source = join(sourceDir, entry.name);
const destination = join(destinationDir, entry.name);
+ const relativePath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
- await copyPreviewDocs(source, destination);
+ await copyPreparedDocs(source, destination, rewrite, relativePath);
continue;
}
if (!entry.isFile()) continue;
+ if (!['.md', '.mdx'].includes(extname(entry.name).toLowerCase())) {
+ await cp(source, destination);
+ continue;
+ }
const content = await readFile(source, 'utf8');
- const relativePath = relative(previewDocsSourceDir, source);
- await writeFile(destination, rewritePreviewDocContent(content, relativePath), 'utf8');
+ await writeFile(destination, rewrite(content, relativePath), 'utf8');
}
}
+async function collectDocsScope(sourceDir, excludedDirectories = new Set()) {
+ const locales = { root: [], ja: [], 'zh-cn': [] };
+
+ async function walk(directory, prefix = '') {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
+ const path = join(directory, entry.name);
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ await walk(path, relativePath);
+ continue;
+ }
+ if (!entry.isFile() || !['.md', '.mdx'].includes(extname(entry.name))) continue;
+
+ const segments = relativePath.replace(/\.(md|mdx)$/i, '').split('/');
+ const locale = segments[0] === 'ja' || segments[0] === 'zh-cn' ? segments.shift() : 'root';
+ const page = segments.join('/').replace(/(^|\/)index$/, '').replace(/\/$/, '');
+ locales[locale].push(page);
+ }
+ }
+
+ await walk(sourceDir);
+ for (const pages of Object.values(locales)) pages.sort();
+ for (const locale of ['ja', 'zh-cn']) {
+ if (locales[locale].length === 0) locales[locale] = [...locales.root];
+ }
+ return { locales };
+}
+
export function rewritePreviewDocContent(content, relativePath = '') {
- const rewritten = content
- .replaceAll('/docs/', '/docs/preview/')
- .replaceAll('../../../public/', '../../../../public/')
- // Preview docs live one directory deeper than stable docs, so component
- // imports need one more parent segment regardless of locale depth. Only
- // MDX import lines are rewritten; prose mentioning relative paths is not.
- .replace(/^(import .*from\s+['"])(?=(?:\.\.\/)+components\/)/gm, '$1../');
- return insertPreviewNotice(rewritten, relativePath);
+ const rewritten = rewriteRelativeDocPaths(
+ content.replaceAll('/docs/', '/docs/preview/'),
+ 1,
+ );
+ const withEditLink = setGeneratedEditUrl(
+ rewritten,
+ `https://github.com/ogulcancelik/herdr/edit/master/docs/next/website/src/content/docs/${relativePath}`,
+ );
+ return insertPreviewNotice(withEditLink, relativePath);
+}
+
+export function rewriteVersionDocContent(content, { version, tag, sourceRoot, relativePath }) {
+ const taggedContent = content
+ .replaceAll('/docs/', `/docs/${version}/`)
+ .replaceAll(
+ 'https://github.com/ogulcancelik/herdr/blob/master/',
+ `https://github.com/ogulcancelik/herdr/blob/${tag}/`,
+ )
+ .replaceAll(
+ 'https://raw.githubusercontent.com/ogulcancelik/herdr/master/',
+ `https://raw.githubusercontent.com/ogulcancelik/herdr/${tag}/`,
+ );
+ const rewritten = rewriteRelativeDocPaths(taggedContent, 2);
+ return setGeneratedEditUrl(
+ rewritten,
+ `https://github.com/ogulcancelik/herdr/blob/${tag}/${sourceRoot}/${relativePath}`,
+ );
+}
+
+function rewriteRelativeDocPaths(content, extraDepth) {
+ const parents = '../'.repeat(extraDepth);
+ return content
+ .replace(/((?:\.\.\/)+)(?=public\/)/g, `$1${parents}`)
+ .replace(/^(import .*from\s+['"])(?=(?:\.\.\/)+components\/)/gm, `$1${parents}`);
+}
+
+function setGeneratedEditUrl(content, editUrl) {
+ if (!content.startsWith('---\n') || /^editUrl:/m.test(content)) return content;
+ return content.replace(/^---\n/, `---\neditUrl: ${editUrl}\n`);
}
function insertPreviewNotice(content, relativePath) {
const notice = [
- '> Preview docs describe unreleased preview builds. Stable docs remain at [/docs/](/docs/).',
+ '> Next docs describe unreleased work from `master`. Stable docs remain at [/docs/](/docs/).',
'',
'',
].join('\n');
const indexPrefix =
relativePath === 'index.mdx'
- ? content.replace('title: Herdr documentation', 'title: Herdr preview documentation')
+ ? content.replace('title: Herdr documentation', 'title: Herdr next documentation')
: content;
const frontmatter = indexPrefix.match(/^---\n[\s\S]*?\n---\n/);
if (!frontmatter) {