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/index-page-from-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@doc-kit/generator-react': patch
---

Generate `index.html` from the input `index` document instead of a synthetic page
5 changes: 5 additions & 0 deletions .changeset/one-shot-generator-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@doc-kit/core': patch
---

Resolve generator packages from the invoking project's `node_modules` and the npm global root when they are not installed alongside core, so one-shot runs (`npx @doc-kit/cli`) find locally or globally installed generators
5 changes: 5 additions & 0 deletions .changeset/show-reading-time-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@doc-kit/generator-react': patch
---

Add a `showReadingTime` option to the `jsx-ast` generator (default `true`); when disabled, the estimated reading time is not computed and the `Layout` component does not receive a `readingTime` prop
5 changes: 5 additions & 0 deletions .changeset/silent-log-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@doc-kit/core': patch
---

Add a `silent` log level, so `--log-level silent` suppresses all output
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ CLI tool to generate the Node.js API documentation

Options:
--log-level <level> Log level (choices: "debug", "info", "warn", "error",
"fatal", default: "info")
"fatal", "silent", default: "info")
-h, --help display help for command

Commands:
Expand Down
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ npx @doc-kit/cli [command] [options]

One option applies to every command:

- `--log-level <level>` {string} `debug`, `info`, `warn`, `error`, or
`fatal`. **Default:** `'info'`.
- `--log-level <level>` {string} `debug`, `info`, `warn`, `error`, `fatal`,
or `silent` (no output at all). **Default:** `'info'`.

## `doc-kit generate`

Expand Down
5 changes: 5 additions & 0 deletions docs/creating-generators.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ npx @doc-kit/cli generate -t @my-scope/my-package/my-format ...
npx @doc-kit/cli generate -t ./generators/my-format/index.mjs ...
```

Package specifiers are resolved from wherever doc-kit is installed, then from
the invoking project's `node_modules`, then from the npm global root — so
one-shot runs (`npx @doc-kit/cli`) find generator packages installed either
in your project or globally.

Built-in generators additionally get a shorthand alias in
`packages/core/src/generators/index.mjs`, which maps the name users type to
the import specifier it resolves to:
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/generators/__tests__/loader.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import { describe, it, before, after } from 'node:test';

import { loadGenerator } from '../loader.mjs';

// A package that only exists in the fake project's node_modules — never in
// the workspace — so a bare import() from core is guaranteed to miss and
// exercise the cwd fallback used by one-shot (`npx @doc-kit/cli`) runs.
const PACKAGE_NAME = '@doc-kit-test/fake-generator';

describe('loadGenerator', () => {
let projectDir;
let originalCwd;

before(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'doc-kit-loader-'));

const packageDir = join(projectDir, 'node_modules', PACKAGE_NAME);
await mkdir(packageDir, { recursive: true });

await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({
name: PACKAGE_NAME,
version: '1.0.0',
exports: { './gen': './gen.mjs' },
})
);

await writeFile(
join(packageDir, 'gen.mjs'),
'export default { name: "fake", generate: () => {} };\n'
);

originalCwd = process.cwd();
process.chdir(projectDir);
});

after(async () => {
process.chdir(originalCwd);
await rm(projectDir, { recursive: true, force: true });
});

it('should resolve packages from the invoking project', async () => {
const generator = await loadGenerator(`${PACKAGE_NAME}/gen`);

assert.equal(generator.name, 'fake');
});

it('should throw a friendly error when a package is not installed anywhere', async () => {
await assert.rejects(
loadGenerator('@doc-kit-test/does-not-exist'),
/Could not load generator "@doc-kit-test\/does-not-exist"/
);
});

it('should reject modules that are not generators', async () => {
const notAGenerator = join(projectDir, 'not-a-generator.mjs');
await writeFile(notAGenerator, 'export default { name: "broken" };\n');

await assert.rejects(loadGenerator(notAGenerator), /is not a generator/);
});
});
75 changes: 72 additions & 3 deletions packages/core/src/generators/loader.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
'use strict';

import { execSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { isAbsolute } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';

import { allGenerators } from './index.mjs';
Expand All @@ -27,6 +30,65 @@ export const resolveGeneratorSpecifier = target => {
return target;
};

let npmGlobalRoot;

/**
* Asking npm for its global root spawns a process, so only do it when a
* generator package is neither installed alongside core nor in the invoking
* project, and remember the answer (`''` = npm unavailable).
*
* @returns {string} The npm global `node_modules` directory, or `''`
*/
const getNpmGlobalRoot = () => {
if (npmGlobalRoot === undefined) {
try {
npmGlobalRoot = execSync('npm root -g', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
npmGlobalRoot = '';
}
}

return npmGlobalRoot;
};

/**
* Resolves a specifier starting from the given directory's `node_modules`
* hierarchy instead of core's own location.
*
* @param {string} specifier - Bare package specifier
* @param {string} base - Directory to resolve from
* @returns {string | undefined} File URL of the resolved module, if found
*/
const tryResolveFrom = (specifier, base) => {
if (!base) {
return undefined;
}

const require = createRequire(import.meta.url);

try {
return pathToFileURL(require.resolve(specifier, { paths: [base] })).href;
} catch {
return undefined;
}
};

/**
* Resolves a generator package from the invoking project or the npm global
* root. One-shot runs (`npx @doc-kit/cli`) install core into the npx cache,
* where a bare import() cannot see generator packages the user has installed
* locally or globally.
*
* @param {string} specifier - Bare package specifier that failed to import
* @returns {string | undefined} File URL of the resolved module, if found
*/
const resolveInstalledPackage = specifier =>
tryResolveFrom(specifier, process.cwd()) ??
tryResolveFrom(specifier, getNpmGlobalRoot());

/**
* Imports a generator by specifier and returns its default export.
*
Expand All @@ -42,15 +104,22 @@ export const loadGenerator = async specifier => {
try {
module = await import(resolved);
} catch (error) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
if (error.code !== 'ERR_MODULE_NOT_FOUND') {
throw error;
}

const installed = resolveInstalledPackage(resolved);

if (!installed) {
throw new Error(
`Could not load generator "${specifier}" (resolved to "${resolved}"). ` +
'If it lives in a separate package, make sure that package is installed.',
'If it lives in a separate package, make sure that package is ' +
'installed in your project or globally.',
{ cause: error }
);
}

throw error;
module = await import(installed);
}

const generator = module.default;
Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/logger/__tests__/logger.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,26 @@ describe('createLogger', () => {
});
});

it('should filter all messages when minimum level is set above FATAL', t => {
it('should filter all messages when level is SILENT', t => {
const transport = t.mock.fn();

// silent logs
const logger = createLogger(transport, 100);
const logger = createLogger(transport, LogLevel.silent);

Object.keys(LogLevel).forEach(level => {
['debug', 'info', 'warn', 'error', 'fatal'].forEach(level => {
logger[level]('Hello, World!');
});

strictEqual(transport.mock.callCount(), 0);
});

it('should filter all messages when SILENT is set by name at runtime', t => {
const transport = t.mock.fn();

const logger = createLogger(transport, LogLevel.info);

logger.setLogLevel('silent');

['debug', 'info', 'warn', 'error', 'fatal'].forEach(level => {
logger[level]('Hello, World!');
});

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/logger/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const LogLevel = {
warn: 30,
error: 40,
fatal: 50,
// Threshold-only level: no message is ever emitted at `silent` (there is no
// logger method for it), so setting it suppresses all output. It has no
// entry in the tag/color maps below for the same reason.
silent: Infinity,
};

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
],
"dependencies": {
"@11ty/is-land": "^5.0.1",
"@doc-kit/core": "workspace:*",
"@fontsource-variable/open-sans": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@heroicons/react": "^2.2.0",
"@doc-kit/core": "workspace:*",
"@node-core/rehype-shiki": "^1.4.3",
"@node-core/ui-components": "^1.7.4",
"@node-core/ui-components": "^1.7.5",
"@orama/orama": "^3.1.18",
"@orama/ui": "^1.5.4",
"estree-util-to-js": "^2.0.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ export default ({ metadata }) => (
- `metadata` {Object} Serialized page metadata — all YAML frontmatter properties
plus `addedIn`, `basename`, `path`, and any custom user-defined fields.
- `headings` {Array} Pre-computed table of contents heading entries.
- `readingTime` {string} Estimated reading time (e.g. `'5 min read'`).
- `readingTime` {string} Estimated reading time (e.g. `'5 min read'`). Only
passed when the `jsx-ast` generator's `showReadingTime` option is enabled.
- `children` {ComponentChildren} Processed page content.

The `Layout` component receives the props above. Custom Layout components can use
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/html/ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ main {
> h5,
> h6 {
flex: 1;
margin-top: 0;
margin-bottom: 8px;
overflow-wrap: anywhere;
word-break: break-word;
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/jsx-ast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ The `jsx-ast` generator converts MDAST (Markdown Abstract Syntax Tree) to JSX AS
documentation structure.
- `generateAllPage` {boolean} When `true`, creates a synthetic JSX AST entry
for `all.html`. **Default:** `true`.
- `generateIndexPage` {boolean} When `true`, creates a synthetic JSX AST entry
for `index.html`. **Default:** `true`.
- `generateNotFoundPage` {boolean} When `true`, creates a synthetic JSX AST
entry for `404.html`. **Default:** `true`.
- `showReadingTime` {boolean} When `true`, computes an estimated reading time
for each page and passes it to the `Layout` component as the `readingTime`
prop, shown in the MetaBar. **Default:** `true`.

## Index page

`index.html` is generated when an `index` document is part of the input, and
is rendered from that document like any other page. A section containing a
`<!-- DOCUMENTATION_INDEX -->` comment additionally receives the Stability
Overview table of all modules.
47 changes: 46 additions & 1 deletion packages/react/src/jsx-ast/__tests__/generate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ describe('jsx-ast generate', () => {

const jsxAstConfig = getConfig('jsx-ast');
jsxAstConfig.generateAllPage = false;
jsxAstConfig.generateIndexPage = false;
jsxAstConfig.generateNotFoundPage = false;

const seenItems = [];
Expand All @@ -95,4 +94,50 @@ describe('jsx-ast generate', () => {
['index', 'fs']
);
});

it('only generates an index page when an index document is an input', async () => {
await setConfig({ target: ['jsx-ast'] });

const jsxAstConfig = getConfig('jsx-ast');
jsxAstConfig.generateAllPage = false;
jsxAstConfig.generateNotFoundPage = false;

const seenItems = [];
await collect(
generate([createEntry('fs', 'File system')], createWorker(seenItems))
);

assert.deepEqual(
seenItems.map(({ head }) => head.api),
['fs']
);
});

it('places the stability overview at the DOCUMENTATION_INDEX comment', async () => {
await setConfig({ target: ['jsx-ast'] });

const jsxAstConfig = getConfig('jsx-ast');
jsxAstConfig.generateAllPage = false;
jsxAstConfig.generateNotFoundPage = false;

const index = createEntry('index', 'Index', { stabilityIndex: null });
// The metadata parser turns a `<!-- DOCUMENTATION_INDEX -->` comment into
// this tag on the entry of the section containing it.
index.tags = ['DOCUMENTATION_INDEX'];

const seenItems = [];
await collect(
generate(
[index, createEntry('fs', 'File system')],
createWorker(seenItems)
)
);

const [{ entries }] = seenItems;
const table = entries[0].content.children.at(-1);

assert.equal(table.tagName, 'table');
const [row] = table.children.at(-1).children;
assert.equal(row.children[0].children[0].properties.href, 'fs.html');
});
});
Loading
Loading