diff options
Diffstat (limited to 'website/scripts')
| -rw-r--r-- | website/scripts/check-built-docs.mjs | 100 | ||||
| -rw-r--r-- | website/scripts/docs-versions.integration.test.ts | 73 | ||||
| -rw-r--r-- | website/scripts/docs-versions.mjs | 308 | ||||
| -rw-r--r-- | website/scripts/docs-versions.test.ts | 33 | ||||
| -rw-r--r-- | website/scripts/prepare-docs.mjs | 173 |
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) { |
