diff --git a/.changeset/proud-geese-serve.md b/.changeset/proud-geese-serve.md new file mode 100644 index 000000000..43077be71 --- /dev/null +++ b/.changeset/proud-geese-serve.md @@ -0,0 +1,6 @@ +--- +'@doc-kit/cli': minor +'@doc-kit/core': patch +--- + +Add a `doc-kit preview` command that serves the generated docs locally, building first when any `generate` option is passed. The default output directory (`./out`) is now defined in the shared configuration, so `generate` also writes there when no `--output` is given. diff --git a/docs/cli.md b/docs/cli.md index e82748e7d..499765e4e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,3 +40,22 @@ 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). + +> **Note:** The preview server is meant for local testing only — it is not +> production grade. Do not use it to deploy your documentation. + +- `--port ` {number} Port to serve on. **Default:** `3000`. +- `--host ` {string} Host to listen on (e.g. `localhost`, `0.0.0.0`). + **Default:** `'localhost'`. +- Every option of [`doc-kit generate`](#doc-kit-generate). diff --git a/packages/cli/README.md b/packages/cli/README.md index 7c570b3b0..67129cb63 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,6 +30,15 @@ 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 — the built-in +server is meant for local testing only and is not production grade. Passing +any `generate` option builds first; otherwise the existing output directory +(`./out` by default) is served as-is. + +```sh +npx @doc-kit/cli preview --host localhost --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..84272fe67 --- /dev/null +++ b/packages/cli/bin/commands/preview.mjs @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; + +import createGenerator from '@doc-kit/core'; +import logger from '@doc-kit/core/logger/index.mjs'; +import { DEFAULT_OUTPUT_DIRECTORY } from '@doc-kit/core/utils/configuration/constants.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(); + +/** + * Options that only affect serving, and therefore don't trigger a build + */ +const SERVE_OPTIONS = ['host', 'port']; + +/** + * Whether any option besides the serving ones (`--host`, `--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 => + !SERVE_OPTIONS.includes(option.attributeName()) && + 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) + ) + .addOption( + new Option('--host ', 'Host to listen on').default('localhost') + ); + +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_DIRECTORY; + + if (shouldBuild(command)) { + const config = await setConfig(opts); + assertRunnableOptions(config); + + await runGenerators(config); + + // With the default coming from the configuration, this is always set + output = config.global.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, { host: opts.host, port: opts.port }); + + logger.info( + `Serving ${output} at http://${opts.host}:${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..89cbc73d5 --- /dev/null +++ b/packages/cli/src/serve.mjs @@ -0,0 +1,124 @@ +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 = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.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 + // Note: This is not intended to be a secure server, and + // this is a best-effort implementation of a secure file- + // serving. + 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. This is a minimal server for local testing + * and previewing only — it is not production grade. + * + * @param {string} directory - The directory to serve files from + * @param {Object} [options] - Serving options + * @param {string} [options.host='localhost'] - The host to listen on + * @param {number} [options.port=3000] - The port to listen on (`0` picks a free one) + * @returns {Promise} The listening server + */ +export const serve = (directory, { host = 'localhost', port = 3000 } = {}) => + new Promise((resolvePromise, reject) => { + const server = createStaticServer(directory); + + server.on('error', reject); + server.listen(port, host, () => resolvePromise(server)); + }); diff --git a/packages/core/src/utils/configuration/constants.mjs b/packages/core/src/utils/configuration/constants.mjs index 393cd9d73..b7ba35695 100644 --- a/packages/core/src/utils/configuration/constants.mjs +++ b/packages/core/src/utils/configuration/constants.mjs @@ -11,3 +11,8 @@ export const DEFAULT_MAX_THREADS = 4; * The default number of items each worker task processes. */ export const DEFAULT_CHUNK_SIZE = 10; + +/** + * The default directory generators write to when no `output` is configured. + */ +export const DEFAULT_OUTPUT_DIRECTORY = './out'; diff --git a/packages/core/src/utils/configuration/index.mjs b/packages/core/src/utils/configuration/index.mjs index c36183180..298b4877d 100644 --- a/packages/core/src/utils/configuration/index.mjs +++ b/packages/core/src/utils/configuration/index.mjs @@ -22,7 +22,11 @@ import { import { resolveMarkdown } from '#utils/markdown/plugins.mjs'; import { deepMerge } from '#utils/misc.mjs'; -import { DEFAULT_CHUNK_SIZE, DEFAULT_MAX_THREADS } from './constants.mjs'; +import { + DEFAULT_CHUNK_SIZE, + DEFAULT_MAX_THREADS, + DEFAULT_OUTPUT_DIRECTORY, +} from './constants.mjs'; const configExplorer = cosmiconfig('doc-kit'); @@ -63,6 +67,7 @@ export const getDefaultConfig = (generators, config) => project: detectProject() ?? 'API Docs', version: process.version, minify: false, + output: DEFAULT_OUTPUT_DIRECTORY, ref: 'HEAD', // Without release history there is nothing to build a version picker // from, so generators render single-version output.