Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-geese-serve.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,22 @@ Runs the generators and writes their output. Requires a `target` and an
links).
- `-p, --threads <n>` {number} Worker threads to use (minimum 1).
- `--chunk-size <n>` {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 <n>` {number} Port to serve on. **Default:** `3000`.
- `--host <host>` {string} Host to listen on (e.g. `localhost`, `0.0.0.0`).
**Default:** `'localhost'`.
- Every option of [`doc-kit generate`](#doc-kit-generate).
9 changes: 9 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 37 additions & 44 deletions packages/cli/bin/commands/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,50 +27,43 @@ const { runGenerators } = createGenerator();
* @property {string} typeMap
*/

export default new Command('generate')
.description('Generate API docs')
.addOption(new Option('--config-file <path>', '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 <path>', 'Config file'),
new Option('-i, --input <patterns...>', 'Input file patterns (glob)'),
new Option(
'-t, --target <generator...>',
'Target generator(s): a built-in name ' +
`(${Object.keys(publicGenerators).join(', ')}) ` +
'or an import specifier for a custom generator'
),
new Option('--ignore <patterns...>', 'Ignore file patterns (glob)'),
new Option('-o, --output <directory>', 'The output directory'),
new Option('-p, --threads <number>', 'Number of threads to use (minimum: 1)'),
new Option(
'--chunk-size <number>',
'Number of items to process per worker thread (minimum: 1)'
),
new Option('-v, --version <semver>', 'Target Node.js version'),
new Option('-c, --changelog <url>', 'Changelog URL or path'),
new Option('--git-ref <ref>', 'Git ref'),
new Option('--index <url>', 'index.md URL or path'),
new Option('--minify', 'Minify?'),
new Option('--type-map <url>', '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 <patterns...>', 'Input file patterns (glob)')
)
.addOption(
new Option(
'-t, --target <generator...>',
'Target generator(s): a built-in name ' +
`(${Object.keys(publicGenerators).join(', ')}) ` +
'or an import specifier for a custom generator'
)
)
.addOption(
new Option('--ignore <patterns...>', 'Ignore file patterns (glob)')
)
.addOption(new Option('-o, --output <directory>', 'The output directory'))
.addOption(
new Option(
'-p, --threads <number>',
'Number of threads to use (minimum: 1)'
)
)
.addOption(
new Option(
'--chunk-size <number>',
'Number of items to process per worker thread (minimum: 1)'
)
)
.addOption(new Option('-v, --version <semver>', 'Target Node.js version'))
.addOption(new Option('-c, --changelog <url>', 'Changelog URL or path'))
.addOption(new Option('--git-ref <ref>', 'Git ref'))
.addOption(new Option('--index <url>', 'index.md URL or path'))
.addOption(new Option('--minify', 'Minify?'))
.addOption(new Option('--type-map <url>', '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);
})
);
3 changes: 2 additions & 1 deletion packages/cli/bin/commands/index.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import generate from './generate.mjs';
import preview from './preview.mjs';

export default [generate];
export default [generate, preview];
84 changes: 84 additions & 0 deletions packages/cli/bin/commands/preview.mjs
Original file line number Diff line number Diff line change
@@ -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 {
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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this already defined somewhere? I feel that this command can't really run without an output flag being passed... Is it correct to assume the default should be "out" and if that's the case, then our generator command also should have a default out, no?

cc @avivkeller ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be defined as a default in config


/**
* 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 <number>', 'Port to serve on')
.default(3000)
.argParser(Number)
)
.addOption(
new Option('--host <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;

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, { host: opts.host, port: opts.port });

logger.info(
`Serving ${output} at http://${opts.host}:${server.address().port}/ ` +
'(Ctrl+C to stop)'
);
})
);
75 changes: 75 additions & 0 deletions packages/cli/src/__tests__/serve.test.mjs
Original file line number Diff line number Diff line change
@@ -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'), '<h1>Home</h1>');
await writeFile(join(root, '404.html'), '<h1>Lost</h1>');
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(), '<h1>Home</h1>');
});

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(), '<h1>Lost</h1>');
});

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(), '<h1>Lost</h1>');
});

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);
});
});
Loading
Loading