diff --git a/README.md b/README.md index 24dfb85..5a41f0f 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,114 @@ This ensures documentation stays in sync with upstream webpack without manual in ## Scripts -| Script | Description | -| ----------------------- | ------------------------------------ | -| `npm run generate-docs` | Generate Markdown from webpack types | -| `npm run build-html` | Convert Markdown to HTML | -| `npm run build` | Generate docs + build HTML | -| `npm run lint` | Run ESLint | -| `npm run format:check` | Check Prettier formatting | +| Script | Description | +| --------------------------- | ----------------------------------------------------------------- | +| `npm run generate-docs` | Generate Markdown from webpack types | +| `npm run build-html` | Convert Markdown to HTML | +| `npm run build` | Generate docs + build HTML | +| `npm run bootstrap:webpack` | Clone/update local webpack source at `HEAD_COMMIT` | +| `npm run docs:quickstart` | Run bootstrap + markdown generation + HTML build with smart skips | +| `npm run docs:doctor` | Run quickstart pipeline in verbose mode | +| `npm run lint` | Run ESLint | +| `npm run format:check` | Check Prettier formatting | + +## Local Quickstart + +Install dependencies: + +```bash +npm install +``` + +Bootstrap local webpack source to the commit pinned in `HEAD_COMMIT`: + +```bash +npm run bootstrap:webpack +``` + +Generate markdown docs: + +```bash +npm run generate-docs +``` + +Build HTML output: + +```bash +npm run build-html +``` + +Or run everything in one command: + +```bash +npm run docs:quickstart +``` + +Useful flags for the quickstart runner: + +- `--force` reruns every step +- `--verbose` prints detailed diagnostics +- `--no-html` skips the HTML build + +Example: + +```bash +node scripts/docs-pipeline.mjs --force --verbose +``` + +## Troubleshooting + +### Missing webpack folder + +If `npm run generate-docs` fails with "Webpack source not found", run: + +```bash +npm run bootstrap:webpack +``` + +### Wrong webpack commit + +If local webpack is out of sync with `HEAD_COMMIT`, run: + +```bash +npm run bootstrap:webpack +``` + +This updates `./webpack` and checks out the exact pinned commit. + +### Stale output + +If you need to regenerate everything regardless of skip checks: + +```bash +node scripts/docs-pipeline.mjs --force +``` + +## Contributor Workflow + +1. Bootstrap webpack source: + +```bash +npm run bootstrap:webpack +``` + +2. Regenerate markdown docs: + +```bash +npm run generate-docs +``` + +3. Build HTML output: + +```bash +npm run build-html +``` + +Output locations: + +- Markdown: `pages/vX.x/` +- Type map: `pages/vX.x/type-map.json` +- HTML site: `out/` ## Contributing diff --git a/generate-md.mjs b/generate-md.mjs index b192ffa..39687e3 100644 --- a/generate-md.mjs +++ b/generate-md.mjs @@ -1,6 +1,21 @@ import { Application } from 'typedoc'; -import webpack from './webpack/package.json' with { type: 'json' }; import { major } from 'semver'; +import { existsSync, readFileSync } from 'node:fs'; + +if (!existsSync('./webpack')) { + throw new Error('Webpack source not found. Run: npm run bootstrap:webpack'); +} + +if ( + !existsSync('./webpack/package.json') || + !existsSync('./webpack/types.d.ts') +) { + throw new Error( + 'Webpack source is incomplete. Run: npm run bootstrap:webpack' + ); +} + +const webpack = JSON.parse(readFileSync('./webpack/package.json', 'utf8')); const app = await Application.bootstrapWithPlugins({ entryPoints: ['./webpack/types.d.ts'], diff --git a/package-lock.json b/package-lock.json index 2f22380..cbaf54d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -936,6 +936,7 @@ "resolved": "https://registry.npmjs.org/@orama/core/-/core-1.2.19.tgz", "integrity": "sha512-AVEI0eG/a1RUQK+tBloRMppQf46Ky4kIYKEVjo0V0VfIGZHdLOE2PJR4v949kFwiTnfSJCUaxgwM74FCA1uHUA==", "license": "AGPL-3.0", + "peer": true, "dependencies": { "@orama/cuid2": "2.2.3", "@orama/oramacore-events-parser": "0.0.5" @@ -2560,18 +2561,6 @@ "hast-util-to-html": "^9.0.5" } }, - "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", @@ -3068,6 +3057,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3317,6 +3307,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3587,6 +3578,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4157,6 +4149,7 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6702,6 +6695,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6749,6 +6743,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6891,6 +6886,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6983,6 +6979,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7401,8 +7398,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/schema-utils": { "version": "4.3.3", @@ -7428,6 +7424,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7950,6 +7947,7 @@ "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.18.tgz", "integrity": "sha512-NTWTUOFRQ9+SGKKTuWKUioUkjxNwtS3JDRPVKZAXGHZy2wCA8bdv2iJiyeePn0xkmK+TCCqZFT0X7+2+FLjngA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", @@ -7985,6 +7983,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 029a5c9..89d122b 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "generate-docs": "node generate-md.mjs", "build-html": "doc-kit generate -t web --config-file ./doc-kit.config.mjs", "build": "npm run generate-docs && npm run build-html", + "bootstrap:webpack": "node scripts/bootstrap-webpack.mjs", + "docs:quickstart": "node scripts/docs-pipeline.mjs", + "docs:doctor": "node scripts/docs-pipeline.mjs --verbose", "lint": "eslint .", "lint:fix": "eslint --fix .", "format": "prettier --write .", diff --git a/scripts/bootstrap-webpack.mjs b/scripts/bootstrap-webpack.mjs new file mode 100644 index 0000000..acafe88 --- /dev/null +++ b/scripts/bootstrap-webpack.mjs @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const ROOT = process.cwd(); +const WEBPACK_DIR = resolve(ROOT, 'webpack'); +const HEAD_COMMIT_FILE = resolve(ROOT, 'HEAD_COMMIT'); +const WEBPACK_REPO_URL = 'https://github.com/webpack/webpack.git'; + +const log = message => { + console.log(`[bootstrap:webpack] ${message}`); +}; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: 'inherit', + ...options, + }); + + if (result.status !== 0) { + const fullCommand = [command, ...args].join(' '); + throw new Error(`Command failed: ${fullCommand}`); + } +}; + +const runCapture = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + ...options, + }); + + if (result.status !== 0) { + const fullCommand = [command, ...args].join(' '); + throw new Error(`Command failed: ${fullCommand}`); + } + + return result.stdout.trim(); +}; + +if (!existsSync(HEAD_COMMIT_FILE)) { + throw new Error('Missing HEAD_COMMIT file.'); +} + +const targetCommit = readFileSync(HEAD_COMMIT_FILE, 'utf8').trim(); + +if (!/^[a-f0-9]{40}$/i.test(targetCommit)) { + throw new Error(`Invalid commit SHA in HEAD_COMMIT: ${targetCommit}`); +} + +let currentCommit = null; + +if (existsSync(WEBPACK_DIR)) { + if (!existsSync(resolve(WEBPACK_DIR, '.git'))) { + throw new Error('webpack directory exists but is not a git repository.'); + } + + currentCommit = runCapture('git', ['-C', WEBPACK_DIR, 'rev-parse', 'HEAD']); +} + +if (currentCommit === targetCommit) { + log(`Already up to date at ${targetCommit}.`); + process.exit(0); +} + +if (!existsSync(WEBPACK_DIR)) { + log('Cloning webpack repository...'); + run('git', ['clone', WEBPACK_REPO_URL, WEBPACK_DIR]); +} else { + log('Updating existing webpack repository...'); +} + +run('git', ['-C', WEBPACK_DIR, 'fetch', '--all', '--tags', '--prune']); +run('git', ['-C', WEBPACK_DIR, 'checkout', '--detach', targetCommit]); + +const checkedOutCommit = runCapture('git', [ + '-C', + WEBPACK_DIR, + 'rev-parse', + 'HEAD', +]); +log(`Checked out ${checkedOutCommit}.`); diff --git a/scripts/docs-pipeline.mjs b/scripts/docs-pipeline.mjs new file mode 100644 index 0000000..d68e57f --- /dev/null +++ b/scripts/docs-pipeline.mjs @@ -0,0 +1,222 @@ +import { + existsSync, + readFileSync, + statSync, + readdirSync, + writeFileSync, +} from 'node:fs'; +import { resolve, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const ROOT = process.cwd(); +const HEAD_COMMIT_FILE = resolve(ROOT, 'HEAD_COMMIT'); +const WEBPACK_DIR = resolve(ROOT, 'webpack'); +const WEBPACK_PKG = resolve(WEBPACK_DIR, 'package.json'); +const DOCS_STATE_FILE = resolve(ROOT, '.docs.generated.state.json'); +const OUT_DIR = resolve(ROOT, 'out'); + +const args = new Set(process.argv.slice(2)); +const force = args.has('--force'); +const verbose = args.has('--verbose'); +const noHtml = args.has('--no-html'); + +const log = message => { + console.log(`[docs:quickstart] ${message}`); +}; + +const debug = message => { + if (verbose) { + console.log(`[docs:quickstart:verbose] ${message}`); + } +}; + +const fail = message => { + console.error(`[docs:quickstart:error] ${message}`); + process.exit(1); +}; + +const run = (command, commandArgs) => { + const fullCommand = [command, ...commandArgs].join(' '); + debug(`Running: ${fullCommand}`); + + const result = spawnSync(command, commandArgs, { + cwd: ROOT, + stdio: 'inherit', + }); + + if (result.status !== 0) { + fail(`Command failed: ${fullCommand}`); + } +}; + +const runCapture = (command, commandArgs) => { + const result = spawnSync(command, commandArgs, { + cwd: ROOT, + encoding: 'utf8', + }); + + if (result.status !== 0) { + const fullCommand = [command, ...commandArgs].join(' '); + fail(`Command failed: ${fullCommand}`); + } + + return result.stdout.trim(); +}; + +const loadState = () => { + if (!existsSync(DOCS_STATE_FILE)) return {}; + + try { + return JSON.parse(readFileSync(DOCS_STATE_FILE, 'utf8')); + } catch { + return {}; + } +}; + +const saveState = state => { + writeFileSync(DOCS_STATE_FILE, JSON.stringify(state, null, 2) + '\n'); +}; + +const readTargetCommit = () => { + if (!existsSync(HEAD_COMMIT_FILE)) { + fail('HEAD_COMMIT file is missing.'); + } + + const commit = readFileSync(HEAD_COMMIT_FILE, 'utf8').trim(); + + if (!/^[a-f0-9]{40}$/i.test(commit)) { + fail(`Invalid commit SHA in HEAD_COMMIT: ${commit}`); + } + + return commit; +}; + +const getWebpackCommit = () => { + if (!existsSync(WEBPACK_DIR) || !existsSync(resolve(WEBPACK_DIR, '.git'))) { + return null; + } + + return runCapture('git', ['-C', WEBPACK_DIR, 'rev-parse', 'HEAD']); +}; + +const maxMtimeRecursive = targetPath => { + if (!existsSync(targetPath)) return 0; + + const stats = statSync(targetPath); + if (!stats.isDirectory()) { + return stats.mtimeMs; + } + + let max = stats.mtimeMs; + for (const entry of readdirSync(targetPath)) { + const entryPath = join(targetPath, entry); + const entryMax = maxMtimeRecursive(entryPath); + if (entryMax > max) max = entryMax; + } + + return max; +}; + +const sourceFingerprint = () => { + const inputs = [ + resolve(ROOT, 'generate-md.mjs'), + resolve(ROOT, 'tsconfig.json'), + resolve(ROOT, 'plugins'), + resolve(ROOT, 'HEAD_COMMIT'), + ]; + + return Math.max(...inputs.map(maxMtimeRecursive)); +}; + +const htmlFingerprint = docsDir => { + const inputs = [resolve(ROOT, 'doc-kit.config.mjs'), docsDir]; + return Math.max(...inputs.map(maxMtimeRecursive)); +}; + +const readWebpackMajorVersion = () => { + if (!existsSync(WEBPACK_PKG)) { + fail('webpack/package.json is missing. Run: npm run bootstrap:webpack'); + } + + const pkg = JSON.parse(readFileSync(WEBPACK_PKG, 'utf8')); + const major = String(pkg.version || '').split('.')[0]; + + if (!/^\d+$/.test(major)) { + fail(`Unable to detect webpack major version from: ${pkg.version}`); + } + + return major; +}; + +const verifyPreflight = () => { + const nodeMajor = Number(process.versions.node.split('.')[0]); + if (!Number.isInteger(nodeMajor) || nodeMajor < 20) { + fail(`Node.js >= 20 is required. Current: ${process.version}`); + } + + if (!existsSync(resolve(ROOT, 'node_modules'))) { + fail('Dependencies are missing. Run: npm install'); + } + + debug(`Node.js version: ${process.version}`); +}; + +verifyPreflight(); + +const state = loadState(); +const targetCommit = readTargetCommit(); +const currentWebpackCommit = getWebpackCommit(); + +if (force || currentWebpackCommit !== targetCommit) { + log('Step 1/3: bootstrap webpack source'); + run('node', ['scripts/bootstrap-webpack.mjs']); +} else { + log('Step 1/3: bootstrap webpack source (skipped; already at target commit)'); +} + +const webpackMajor = readWebpackMajorVersion(); +const docsDir = resolve(ROOT, `pages/v${webpackMajor}.x`); +const docsTypeMap = resolve(docsDir, 'type-map.json'); +const nextSourceFingerprint = sourceFingerprint(); + +const needsGenerate = + force || + !existsSync(docsDir) || + !existsSync(docsTypeMap) || + state.webpackCommit !== targetCommit || + state.sourceFingerprint !== nextSourceFingerprint; + +if (needsGenerate) { + log('Step 2/3: generate markdown docs'); + run('npm', ['run', 'generate-docs']); + state.webpackCommit = targetCommit; + state.docsDir = docsDir; + state.sourceFingerprint = sourceFingerprint(); + state.generatedAt = new Date().toISOString(); + saveState(state); +} else { + log('Step 2/3: generate markdown docs (skipped; no relevant changes)'); +} + +if (noHtml) { + log('Step 3/3: build html docs (skipped; --no-html)'); + process.exit(0); +} + +const nextHtmlFingerprint = htmlFingerprint(docsDir); +const needsHtmlBuild = + force || + !existsSync(OUT_DIR) || + state.webpackCommit !== targetCommit || + state.htmlInputFingerprint !== nextHtmlFingerprint; + +if (needsHtmlBuild) { + log('Step 3/3: build html docs'); + run('npm', ['run', 'build-html']); + state.webpackCommit = targetCommit; + state.htmlInputFingerprint = htmlFingerprint(docsDir); + state.htmlBuiltAt = new Date().toISOString(); + saveState(state); +} else { + log('Step 3/3: build html docs (skipped; output is up to date)'); +}