From 410795ff44f8a0ffb6c9a293ea1352937e77c73c Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Fri, 4 Sep 2026 13:06:18 +0800 Subject: [PATCH 1/2] feat: add `preview:docs` & `preview:node` scripts --- package.json | 2 + scripts/preview.mjs | 140 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 scripts/preview.mjs diff --git a/package.json b/package.json index e5a2c0fbc..691e66252 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "run": "node packages/cli/bin/cli.mjs", "watch": "node --watch packages/cli/bin/cli.mjs", "docs:build": "bash scripts/vercel-docs-build.sh", + "preview:docs": "node scripts/preview.mjs docs", + "preview:node": "node scripts/preview.mjs node", "changeset": "changeset", "changeset:version": "changeset version", "release": "changeset publish" diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 000000000..12a79729d --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +// Builds one of the two doc sites in this repository, then serves it locally: +// +// node scripts/preview.mjs node -> ./out served on http://localhost:3000 +// node scripts/preview.mjs docs -> ./www/out served on http://localhost:3001 +// +// The `node` build mirrors `scripts/vercel-build.sh` and needs the Node.js +// source clone in `./node` (see `scripts/vercel-prepare.sh`); the `docs` build +// is `scripts/vercel-docs-build.sh`. Press Ctrl+C to stop the server. + +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +const ROOT = join(import.meta.dirname, '..'); +const CLI = join(ROOT, 'packages', 'cli', 'bin', 'cli.mjs'); +const NODE_API_DIR = join(ROOT, 'node', 'doc', 'api'); + +const SITES = { + node: { dir: './out', port: 3000 }, + docs: { dir: './www/out', port: 3001 }, +}; + +const mode = process.argv[2]; + +if (!SITES[mode]) { + console.error( + `Usage: node scripts/preview.mjs <${Object.keys(SITES).join('|')}>` + ); + process.exit(1); +} + +const run = (command, args) => { + const result = spawnSync(command, args, { cwd: ROOT, stdio: 'inherit' }); + + if (result.error) { + console.error(`Failed to run \`${command}\`: ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status); + } +}; + +const resolveVersion = () => { + const tagFile = join(ROOT, '.node-tag'); + + if (existsSync(tagFile)) { + return readFileSync(tagFile, 'utf8').trim(); + } + + const result = spawnSync( + 'git', + ['-C', join(ROOT, 'node'), 'describe', '--tags', '--abbrev=0'], + { encoding: 'utf8' } + ); + + return result.status === 0 ? result.stdout.trim() : undefined; +}; + +const buildNodeDocs = () => { + if ( + !existsSync(NODE_API_DIR) || + readdirSync(NODE_API_DIR).filter(file => file.endsWith('.md')).length === 0 + ) { + console.error( + 'No Node.js API sources in ./node/doc/api. Prepare them first with:\n' + + ' bash scripts/vercel-prepare.sh\n' + + '(clones the latest nodejs/node release into ./node)' + ); + process.exit(1); + } + + console.log('Building Node.js API docs into ./out …'); + + const args = [ + 'generate', + '-t', + 'orama-db', + '-t', + 'legacy-json', + '-t', + 'llms-txt', + '-t', + 'html', + '-i', + './node/doc/api/*.md', + '--ignore', + './node/doc/api/quic.md', + '-o', + './out', + '-c', + './node/CHANGELOG.md', + '--type-map', + './node/doc/type-map.json', + '--config-file', + './beta/doc-kit.config.mjs', + '--log-level', + 'debug', + ]; + const version = resolveVersion(); + + if (version) { + args.push('-v', version); + } + + run(process.execPath, [CLI, ...args]); +}; + +const buildDocsSite = () => { + console.log('Building doc-kit docs into ./www/out …'); + run('bash', [join(ROOT, 'scripts', 'vercel-docs-build.sh')]); +}; + +const { dir, port } = SITES[mode]; + +if (mode === 'node') { + buildNodeDocs(); +} else { + buildDocsSite(); +} + +console.log(`\nServing ${dir} on http://localhost:${port} …`); + +const server = spawn('npx', ['--yes', 'serve', dir, '-l', String(port)], { + cwd: ROOT, + stdio: 'inherit', +}); + +server.on('error', error => { + console.error(`Failed to start the server: ${error.message}`); + process.exit(1); +}); + +server.on('exit', code => { + process.exit(code ?? 0); +}); From 0af7f3df1d701246ada12280956c9fde43da5f29 Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Mon, 26 Oct 2026 09:43:21 +0800 Subject: [PATCH 2/2] feat: update --- .changeset/proud-geese-serve.md | 5 + docs/cli.md | 14 +++ package.json | 2 - packages/cli/README.md | 8 ++ packages/cli/bin/commands/generate.mjs | 81 ++++++------- packages/cli/bin/commands/index.mjs | 3 +- packages/cli/bin/commands/preview.mjs | 76 ++++++++++++ packages/cli/src/__tests__/serve.test.mjs | 75 ++++++++++++ packages/cli/src/serve.mjs | 130 ++++++++++++++++++++ scripts/preview.mjs | 140 ---------------------- 10 files changed, 347 insertions(+), 187 deletions(-) create mode 100644 .changeset/proud-geese-serve.md create mode 100644 packages/cli/bin/commands/preview.mjs create mode 100644 packages/cli/src/__tests__/serve.test.mjs create mode 100644 packages/cli/src/serve.mjs delete mode 100644 scripts/preview.mjs diff --git a/.changeset/proud-geese-serve.md b/.changeset/proud-geese-serve.md new file mode 100644 index 000000000..570e68ab0 --- /dev/null +++ b/.changeset/proud-geese-serve.md @@ -0,0 +1,5 @@ +--- +'@doc-kit/cli': minor +--- + +Add a `doc-kit preview` command that serves the generated docs locally, building first when any `generate` option is passed diff --git a/docs/cli.md b/docs/cli.md index e82748e7d..ea8ceef7b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,3 +40,17 @@ Runs the generators and writes their output. Requires a `target` and an links). - `-p, --threads ` {number} Worker threads to use (minimum 1). - `--chunk-size ` {number} Items per worker thread (minimum 1). + +## `doc-kit preview` + +```bash +npx @doc-kit/cli preview [options] +``` + +Serves the generated docs over HTTP for local verification. Every `generate` +option is accepted: when any of them is passed, the docs are built first +(same semantics as `doc-kit generate`); otherwise the existing output +directory is served as-is (`./out` by default). + +- `--port ` {number} Port to serve on. **Default:** `3000`. +- Every option of [`doc-kit generate`](#doc-kit-generate). diff --git a/package.json b/package.json index 7d6ba4c8f..c789c16fd 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "prepare": "husky || exit 0", "run": "node packages/cli/bin/cli.mjs", "watch": "node --watch packages/cli/bin/cli.mjs", - "preview:docs": "node scripts/preview.mjs docs", - "preview:node": "node scripts/preview.mjs node", "docs:build": "node --run build:site", "build:site": "bash scripts/deployments/site/main.sh", "build:beta": "bash scripts/deployments/beta/main.sh", diff --git a/packages/cli/README.md b/packages/cli/README.md index 7c570b3b0..1042594fe 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,6 +30,14 @@ generator packages, which must be installed alongside this one. Custom generators load by import specifier — any module whose default export is a generator works as a `--target`. +To verify the output locally, `preview` serves it over HTTP. Passing any +`generate` option builds first; otherwise the existing output directory +(`./out` by default) is served as-is. + +```sh +npx @doc-kit/cli preview --port 3000 +``` + ## Contributing This package lives in the [nodejs/doc-kit](https://github.com/nodejs/doc-kit) diff --git a/packages/cli/bin/commands/generate.mjs b/packages/cli/bin/commands/generate.mjs index 95c3cdb67..4122f6fb3 100644 --- a/packages/cli/bin/commands/generate.mjs +++ b/packages/cli/bin/commands/generate.mjs @@ -27,50 +27,43 @@ const { runGenerators } = createGenerator(); * @property {string} typeMap */ -export default new Command('generate') - .description('Generate API docs') - .addOption(new Option('--config-file ', 'Config file')) +/** + * The `generate` options, also shared with the `preview` command, which + * builds on demand when any of them is passed + */ +export const generateOptions = [ + new Option('--config-file ', 'Config file'), + new Option('-i, --input ', 'Input file patterns (glob)'), + new Option( + '-t, --target ', + 'Target generator(s): a built-in name ' + + `(${Object.keys(publicGenerators).join(', ')}) ` + + 'or an import specifier for a custom generator' + ), + new Option('--ignore ', 'Ignore file patterns (glob)'), + new Option('-o, --output ', 'The output directory'), + new Option('-p, --threads ', 'Number of threads to use (minimum: 1)'), + new Option( + '--chunk-size ', + 'Number of items to process per worker thread (minimum: 1)' + ), + new Option('-v, --version ', 'Target Node.js version'), + new Option('-c, --changelog ', 'Changelog URL or path'), + new Option('--git-ref ', 'Git ref'), + new Option('--index ', 'index.md URL or path'), + new Option('--minify', 'Minify?'), + new Option('--type-map ', 'Type map URL or path'), +]; + +const generate = new Command('generate').description('Generate API docs'); - // Options that need to be converted into a configuration - .addOption( - new Option('-i, --input ', 'Input file patterns (glob)') - ) - .addOption( - new Option( - '-t, --target ', - 'Target generator(s): a built-in name ' + - `(${Object.keys(publicGenerators).join(', ')}) ` + - 'or an import specifier for a custom generator' - ) - ) - .addOption( - new Option('--ignore ', 'Ignore file patterns (glob)') - ) - .addOption(new Option('-o, --output ', 'The output directory')) - .addOption( - new Option( - '-p, --threads ', - 'Number of threads to use (minimum: 1)' - ) - ) - .addOption( - new Option( - '--chunk-size ', - 'Number of items to process per worker thread (minimum: 1)' - ) - ) - .addOption(new Option('-v, --version ', 'Target Node.js version')) - .addOption(new Option('-c, --changelog ', 'Changelog URL or path')) - .addOption(new Option('--git-ref ', 'Git ref')) - .addOption(new Option('--index ', 'index.md URL or path')) - .addOption(new Option('--minify', 'Minify?')) - .addOption(new Option('--type-map ', 'Type map URL or path')) +generateOptions.forEach(option => generate.addOption(option)); - .action( - errorWrap(async opts => { - const config = await setConfig(opts); - assertRunnableOptions(config); +export default generate.action( + errorWrap(async opts => { + const config = await setConfig(opts); + assertRunnableOptions(config); - await runGenerators(config); - }) - ); + await runGenerators(config); + }) +); diff --git a/packages/cli/bin/commands/index.mjs b/packages/cli/bin/commands/index.mjs index 3e6d9d97c..31ab9e531 100644 --- a/packages/cli/bin/commands/index.mjs +++ b/packages/cli/bin/commands/index.mjs @@ -1,3 +1,4 @@ import generate from './generate.mjs'; +import preview from './preview.mjs'; -export default [generate]; +export default [generate, preview]; diff --git a/packages/cli/bin/commands/preview.mjs b/packages/cli/bin/commands/preview.mjs new file mode 100644 index 000000000..ddc7f1ce1 --- /dev/null +++ b/packages/cli/bin/commands/preview.mjs @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs'; + +import createGenerator from '@doc-kit/core'; +import logger from '@doc-kit/core/logger/index.mjs'; +import { + assertRunnableOptions, + setConfig, +} from '@doc-kit/core/utils/configuration/index.mjs'; +import { Command, Option } from 'commander'; + +import { serve } from '../../src/serve.mjs'; +import { errorWrap } from '../utils.mjs'; +import { generateOptions } from './generate.mjs'; + +const { runGenerators } = createGenerator(); + +const DEFAULT_OUTPUT = './out'; + +/** + * Whether any option besides `--port` was passed on the command line, meaning + * the docs should be (re)built before serving + * + * @param {Command} command - The `preview` command + * @returns {boolean} + */ +const shouldBuild = command => + command.options.some( + option => + option.attributeName() !== 'port' && + command.getOptionValueSource(option.attributeName()) === 'cli' + ); + +const preview = new Command('preview') + .description( + 'Serve the generated docs locally, building first when any ' + + '`generate` option is passed' + ) + .addOption( + new Option('--port ', 'Port to serve on') + .default(3000) + .argParser(Number) + ); + +generateOptions.forEach(option => preview.addOption(option)); + +export default preview.action( + errorWrap(async (opts, command) => { + if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) { + throw new Error(`Invalid port: ${opts.port}`); + } + + let output = opts.output ?? DEFAULT_OUTPUT; + + if (shouldBuild(command)) { + const config = await setConfig(opts); + assertRunnableOptions(config); + + await runGenerators(config); + + output = config.global.output ?? DEFAULT_OUTPUT; + } else if (!existsSync(output)) { + throw new Error( + `Nothing to serve: \`${output}\` does not exist. Run ` + + '`doc-kit generate` first, or pass `generate` options to ' + + '`doc-kit preview` to build before serving.' + ); + } + + const server = await serve(output, { port: opts.port }); + + logger.info( + `Serving ${output} at http://localhost:${server.address().port}/ ` + + '(Ctrl+C to stop)' + ); + }) +); diff --git a/packages/cli/src/__tests__/serve.test.mjs b/packages/cli/src/__tests__/serve.test.mjs new file mode 100644 index 000000000..18ed0c8a9 --- /dev/null +++ b/packages/cli/src/__tests__/serve.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +import { serve } from '../serve.mjs'; + +describe('serve', () => { + let root; + let server; + let base; + + before(async () => { + root = await mkdtemp(join(tmpdir(), 'doc-kit-serve-')); + + await mkdir(join(root, 'assets'), { recursive: true }); + await mkdir(join(root, 'empty'), { recursive: true }); + await writeFile(join(root, 'index.html'), '

Home

'); + await writeFile(join(root, '404.html'), '

Lost

'); + await writeFile(join(root, 'assets', 'style.css'), 'body {}'); + + server = await serve(root, { port: 0 }); + base = `http://localhost:${server.address().port}`; + }); + + after(async () => { + server.close(); + await rm(root, { recursive: true, force: true }); + }); + + it('serves index.html for the root path', async () => { + const response = await fetch(`${base}/`); + + assert.equal(response.status, 200); + assert.equal( + response.headers.get('content-type'), + 'text/html; charset=utf-8' + ); + assert.equal(await response.text(), '

Home

'); + }); + + it('serves files with their MIME type', async () => { + const response = await fetch(`${base}/assets/style.css`); + + assert.equal(response.status, 200); + assert.equal( + response.headers.get('content-type'), + 'text/css; charset=utf-8' + ); + assert.equal(await response.text(), 'body {}'); + }); + + it('falls back to 404.html for missing paths', async () => { + const response = await fetch(`${base}/missing`); + + assert.equal(response.status, 404); + assert.equal(await response.text(), '

Lost

'); + }); + + it('falls back to 404.html for directories without index.html', async () => { + const response = await fetch(`${base}/empty`); + + assert.equal(response.status, 404); + assert.equal(await response.text(), '

Lost

'); + }); + + it('rejects path traversal outside the served directory', async () => { + // The URL parser normalizes dot segments, but encoded slashes are kept; + // decoding them turns the path into one that escapes the root + const response = await fetch(`${base}/a%2F..%2F..%2Fsecret.txt`); + + assert.equal(response.status, 403); + }); +}); diff --git a/packages/cli/src/serve.mjs b/packages/cli/src/serve.mjs new file mode 100644 index 000000000..6e71a62bb --- /dev/null +++ b/packages/cli/src/serve.mjs @@ -0,0 +1,130 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, resolve, sep } from 'node:path'; + +const MIME_TYPES = { + '.avif': 'image/avif', + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.map': 'application/json', + '.md': 'text/markdown; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webmanifest': 'application/manifest+json', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.xml': 'application/xml', +}; + +/** + * Sends a file over the response, if present + * + * @param {import('node:http').ServerResponse} response - The response to write to + * @param {string} filePath - The file to send + * @param {number} status - The status code to send it with + */ +const sendFile = (response, filePath, status) => { + const stream = createReadStream(filePath); + + stream.on('error', () => { + if (!response.headersSent) { + response.writeHead(500); + } + + response.end(); + }); + + response.writeHead(status, { + 'content-type': MIME_TYPES[extname(filePath)] ?? 'application/octet-stream', + }); + + stream.pipe(response); +}; + +/** + * Creates an HTTP server that serves static files from a directory. Directories + * resolve to their `index.html`; missing paths fall back to a `404.html` at the + * root of the directory, if one exists. + * + * @param {string} directory - The directory to serve files from + * @returns {import('node:http').Server} + */ +export const createStaticServer = directory => { + const root = resolve(directory); + + return createServer(async (request, response) => { + let pathname; + + try { + pathname = decodeURIComponent( + new URL(request.url, 'http://localhost').pathname + ); + } catch { + response.writeHead(400); + response.end('Bad Request'); + + return; + } + + const filePath = resolve(root, `.${pathname}`); + + // Requests must not escape the served directory + if (filePath !== root && !filePath.startsWith(root + sep)) { + response.writeHead(403); + response.end('Forbidden'); + + return; + } + + let file = filePath; + const stats = await stat(file).catch(() => null); + + if (stats?.isDirectory()) { + file = resolve(file, 'index.html'); + } + + if (stats?.isFile() || (await stat(file).catch(() => null))?.isFile()) { + sendFile(response, file, 200); + + return; + } + + const notFound = resolve(root, '404.html'); + + if ((await stat(notFound).catch(() => null))?.isFile()) { + sendFile(response, notFound, 404); + + return; + } + + response.writeHead(404); + response.end('Not Found'); + }); +}; + +/** + * Serves a directory over HTTP + * + * @param {string} directory - The directory to serve files from + * @param {Object} [options] - Serving options + * @param {number} [options.port=3000] - The port to listen on (`0` picks a free one) + * @returns {Promise} The listening server + */ +export const serve = (directory, { port = 3000 } = {}) => + new Promise((resolvePromise, reject) => { + const server = createStaticServer(directory); + + server.on('error', reject); + server.listen(port, () => resolvePromise(server)); + }); diff --git a/scripts/preview.mjs b/scripts/preview.mjs deleted file mode 100644 index 12a79729d..000000000 --- a/scripts/preview.mjs +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node - -// Builds one of the two doc sites in this repository, then serves it locally: -// -// node scripts/preview.mjs node -> ./out served on http://localhost:3000 -// node scripts/preview.mjs docs -> ./www/out served on http://localhost:3001 -// -// The `node` build mirrors `scripts/vercel-build.sh` and needs the Node.js -// source clone in `./node` (see `scripts/vercel-prepare.sh`); the `docs` build -// is `scripts/vercel-docs-build.sh`. Press Ctrl+C to stop the server. - -import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import process from 'node:process'; - -const ROOT = join(import.meta.dirname, '..'); -const CLI = join(ROOT, 'packages', 'cli', 'bin', 'cli.mjs'); -const NODE_API_DIR = join(ROOT, 'node', 'doc', 'api'); - -const SITES = { - node: { dir: './out', port: 3000 }, - docs: { dir: './www/out', port: 3001 }, -}; - -const mode = process.argv[2]; - -if (!SITES[mode]) { - console.error( - `Usage: node scripts/preview.mjs <${Object.keys(SITES).join('|')}>` - ); - process.exit(1); -} - -const run = (command, args) => { - const result = spawnSync(command, args, { cwd: ROOT, stdio: 'inherit' }); - - if (result.error) { - console.error(`Failed to run \`${command}\`: ${result.error.message}`); - process.exit(1); - } - - if (result.status !== 0) { - process.exit(result.status); - } -}; - -const resolveVersion = () => { - const tagFile = join(ROOT, '.node-tag'); - - if (existsSync(tagFile)) { - return readFileSync(tagFile, 'utf8').trim(); - } - - const result = spawnSync( - 'git', - ['-C', join(ROOT, 'node'), 'describe', '--tags', '--abbrev=0'], - { encoding: 'utf8' } - ); - - return result.status === 0 ? result.stdout.trim() : undefined; -}; - -const buildNodeDocs = () => { - if ( - !existsSync(NODE_API_DIR) || - readdirSync(NODE_API_DIR).filter(file => file.endsWith('.md')).length === 0 - ) { - console.error( - 'No Node.js API sources in ./node/doc/api. Prepare them first with:\n' + - ' bash scripts/vercel-prepare.sh\n' + - '(clones the latest nodejs/node release into ./node)' - ); - process.exit(1); - } - - console.log('Building Node.js API docs into ./out …'); - - const args = [ - 'generate', - '-t', - 'orama-db', - '-t', - 'legacy-json', - '-t', - 'llms-txt', - '-t', - 'html', - '-i', - './node/doc/api/*.md', - '--ignore', - './node/doc/api/quic.md', - '-o', - './out', - '-c', - './node/CHANGELOG.md', - '--type-map', - './node/doc/type-map.json', - '--config-file', - './beta/doc-kit.config.mjs', - '--log-level', - 'debug', - ]; - const version = resolveVersion(); - - if (version) { - args.push('-v', version); - } - - run(process.execPath, [CLI, ...args]); -}; - -const buildDocsSite = () => { - console.log('Building doc-kit docs into ./www/out …'); - run('bash', [join(ROOT, 'scripts', 'vercel-docs-build.sh')]); -}; - -const { dir, port } = SITES[mode]; - -if (mode === 'node') { - buildNodeDocs(); -} else { - buildDocsSite(); -} - -console.log(`\nServing ${dir} on http://localhost:${port} …`); - -const server = spawn('npx', ['--yes', 'serve', dir, '-l', String(port)], { - cwd: ROOT, - stdio: 'inherit', -}); - -server.on('error', error => { - console.error(`Failed to start the server: ${error.message}`); - process.exit(1); -}); - -server.on('exit', code => { - process.exit(code ?? 0); -});