Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@node-core/doc-kit",
"type": "module",
"version": "1.3.2",
"version": "1.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/nodejs/doc-kit.git"
Expand Down
2 changes: 1 addition & 1 deletion src/generators/orama-db/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function generate(input) {
description: paragraph
? transformNodeToString(paragraph, true)
: undefined,
href: `${entry.path.slice(1)}.html#${entry.heading.data.slug}`,
href: `${entry.path}.html#${entry.heading.data.slug}`,
siteSection: headings[0].heading.data.name,
};
})
Expand Down
50 changes: 40 additions & 10 deletions src/generators/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ The `web` generator transforms JSX AST entries into complete web bundles, produc

The `web` generator accepts the following configuration options:

| Name | Type | Default | Description |
| ---------------- | -------- | --------------------------------------------- | --------------------------------------------------------------------- |
| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written |
| `templatePath` | `string` | `'template.html'` | Path to the HTML template file |
| `project` | `string` | `'Node.js'` | Project name used in page titles and the version selector |
| `title` | `string` | `'{project} v{version} Documentation'` | Title template for HTML pages (supports `{project}`, `{version}`) |
| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links |
| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links |
| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization |
| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build |
| Name | Type | Default | Description |
| ----------------- | --------- | --------------------------------------------- | --------------------------------------------------------------------- |
| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written |
| `templatePath` | `string` | `'template.html'` | Path to the HTML template file |
| `project` | `string` | `'Node.js'` | Project name used in page titles and the version selector |
| `title` | `string` | `'{project} v{version} Documentation'` | Title template for HTML pages (supports `{project}`, `{version}`) |
| `useAbsoluteURLs` | `boolean` | `false` | When `true`, all internal links use absolute URLs based on `baseURL` |
| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links |
| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links |
| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization |
| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build |

#### Default `imports`

Expand Down Expand Up @@ -60,6 +61,8 @@ All scalar (non-object) configuration values are automatically exported. The def
| `versions` | `Array<{ url, label, major }>` | Pre-computed version entries with labels and URL templates (only `{path}` remains for per-page use) |
| `editURL` | `string` | Partially populated "edit this page" URL template (only `{path}` remains) |
| `pages` | `Array<[string, string]>` | Sorted `[name, path]` tuples for sidebar navigation |
| `useAbsoluteURLs` | `boolean` | Whether internal links use absolute URLs (mirrors config value) |
| `baseURL` | `string` | Base URL for the documentation site (used when `useAbsoluteURLs` is `true`) |
| `languageDisplayNameMap` | `Map<string, string>` | Shiki language alias → display name map for code blocks |

#### Usage in custom components
Expand Down Expand Up @@ -96,3 +99,30 @@ The `Layout` component receives the following props:
| `children` | `ComponentChildren` | Processed page content |

Custom Layout components can use any combination of these props alongside `#theme/config` imports.

### HTML template

The HTML template file (set via `templatePath`) uses JavaScript template literal syntax (`${...}` placeholders) and is evaluated at build time with full expression support.

#### Available template variables

| Variable | Type | Description |
| ------------------ | -------- | ----------------------------------------------------------------- |
| `title` | `string` | Fully resolved page title (e.g. `'File system \| Node.js v22.x'`) |
| `dehydrated` | `string` | Server-rendered HTML for the page content |
| `importMap` | `string` | JSON import map for client-side module resolution |
| `entrypoint` | `string` | Client-side entry point filename with cache-bust query |
| `speculationRules` | `string` | Speculation rules JSON for prefetching |
| `root` | `string` | Relative or absolute path to the site root |
| `metadata` | `object` | Full page metadata (frontmatter, path, heading, etc.) |
| `config` | `object` | The resolved web generator configuration |

Since the template supports arbitrary JS expressions, you can use conditionals and method calls:

```html
<title>${title}</title>
<link rel="stylesheet" href="${root}styles.css" />
<script type="importmap">
${importMap}
</script>
```
1 change: 1 addition & 0 deletions src/generators/web/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default createLazyGenerator({
templatePath: join(import.meta.dirname, 'template.html'),
project: 'Node.js',
title: '{project} v{version} Documentation',
useAbsoluteURLs: false,
editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`,
pageURL: '{baseURL}/latest-{version}/api{path}.html',
imports: {
Expand Down
14 changes: 7 additions & 7 deletions src/generators/web/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="https://nodejs.org/static/images/favicons/favicon.png"/>
<title>{title}</title>
<title>${title}</title>
<meta name="description" content="Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.">
<link rel="stylesheet" href="{root}styles.css" />
<meta property="og:title" content="{title}">
<link rel="stylesheet" href="${root}styles.css" />
<meta property="og:title" content="${title}">
<meta property="og:description" content="Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.">
<meta property="og:image" content="https://nodejs.org/en/next-data/og/announcement/Node.js%20%E2%80%94%20Run%20JavaScript%20Everywhere" />
<meta property="og:type" content="website">
Expand All @@ -20,12 +20,12 @@

<!-- Apply theme before paint to avoid Flash of Unstyled Content -->
<script>document.documentElement.setAttribute("data-theme", document.documentElement.style.colorScheme = localStorage.getItem("theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));</script>
<script type="importmap">{importMap}</script>
<script type="speculationrules">{speculationRules}</script>
<script type="importmap">${importMap}</script>
<script type="speculationrules">${speculationRules}</script>
</head>

<body>
<div id="root">{dehydrated}</div>
<script type="module" src="{root}{entrypoint}"></script>
<div id="root">${dehydrated}</div>
<script type="module" src="${root}${entrypoint}"></script>
</body>
</html>
1 change: 1 addition & 0 deletions src/generators/web/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JSXContent } from '../jsx-ast/utils/buildContent.mjs';
export type Configuration = {
templatePath: string;
title: string;
useAbsoluteURLs: boolean;
Comment thread
avivkeller marked this conversation as resolved.
imports: Record<string, string>;
virtualImports: Record<string, string>;
};
Expand Down
4 changes: 2 additions & 2 deletions src/generators/web/ui/components/SearchBox/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import SearchResults from '@node-core/ui-components/Common/Search/Results';
import SearchHit from '@node-core/ui-components/Common/Search/Results/Hit';

import styles from './index.module.css';
import { relative } from '../../../../../utils/url.mjs';
import useOrama from '../../hooks/useOrama.mjs';
import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs';

const SearchBox = ({ pathname }) => {
const client = useOrama(pathname);
Expand All @@ -23,7 +23,7 @@ const SearchBox = ({ pathname }) => {
<SearchHit
document={{
...hit.document,
href: relative(hit.document.href, pathname),
href: relativeOrAbsolute(hit.document.href, pathname),
}}
/>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/generators/web/ui/components/SideBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Select from '@node-core/ui-components/Common/Select';
import SideBar from '@node-core/ui-components/Containers/Sidebar';

import styles from './index.module.css';
import { relative } from '../../../../../utils/url.mjs';
import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs';

import { project, version, versions, pages } from '#theme/config';

Expand Down Expand Up @@ -41,7 +41,7 @@ export default ({ metadata }) => {
link:
metadata.path === path
? `${metadata.basename}.html`
: `${relative(path, metadata.path)}.html`,
: `${relativeOrAbsolute(path, metadata.path)}.html`,
}));

return (
Expand Down
4 changes: 2 additions & 2 deletions src/generators/web/ui/hooks/useOrama.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create, search, load } from '@orama/orama';
import { useState, useEffect } from 'react';

import { relative } from '../../../../utils/url.mjs';
import { relativeOrAbsolute } from '../utils/relativeOrAbsolute.mjs';

/**
* Hook for initializing and managing Orama search database
Expand All @@ -26,7 +26,7 @@ export default pathname => {
setClient(db);

// Load the search data
fetch(relative('/orama-db.json', pathname))
fetch(relativeOrAbsolute('/orama-db.json', pathname))
.then(response => response.ok && response.json())
.then(data => load(db, data));
}, []);
Expand Down
1 change: 1 addition & 0 deletions src/generators/web/ui/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ declare module '#theme/config' {
// From web configuration
export const templatePath: Configuration['templatePath'];
export const title: Configuration['title'];
export const useAbsoluteURLs: Configuration['useAbsoluteURLs'];

// From config generation
export const version: string;
Expand Down
16 changes: 16 additions & 0 deletions src/generators/web/ui/utils/relativeOrAbsolute.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { relative } from '../../../../utils/url.mjs';

import { useAbsoluteURLs, baseURL } from '#theme/config';

/**
* Returns an absolute URL (based on baseURL) or a relative URL,
* depending on the useAbsoluteURLs configuration option.
*
* @param {string} to - Target path (e.g., '/fs', '/orama-db.json')
* @param {string} from - Current page path (e.g., '/api/fs')
* @returns {string}
*/
export const relativeOrAbsolute = (to, from) =>
useAbsoluteURLs
? new URL(`.${to}`, baseURL.replace(/\/?$/, '/')).href
: relative(to, from);
63 changes: 63 additions & 0 deletions src/generators/web/utils/__tests__/processing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { populateWithEvaluation } from '../processing.mjs';

describe('populateWithEvaluation', () => {
it('substitutes simple ${variable} placeholders', () => {
const result = populateWithEvaluation('Hello ${name}!', { name: 'World' });
assert.strictEqual(result, 'Hello World!');
});

it('supports multiple variables', () => {
const result = populateWithEvaluation('${greeting} ${name}!', {
greeting: 'Hi',
name: 'Node',
});
assert.strictEqual(result, 'Hi Node!');
});

it('supports JavaScript expressions', () => {
const result = populateWithEvaluation('${value > 5 ? "big" : "small"}', {
value: 10,
});
assert.strictEqual(result, 'big');
});

it('supports ternary expressions for conditional content', () => {
const result = populateWithEvaluation(
'${showExtra ? "extra content" : ""}',
{ showExtra: false }
);
assert.strictEqual(result, '');
});

it('handles JSON.stringify for objects', () => {
const obj = { key: 'value' };
const result = populateWithEvaluation('${JSON.stringify(data)}', {
data: obj,
});
assert.strictEqual(result, '{"key":"value"}');
});

it('preserves surrounding HTML content', () => {
const result = populateWithEvaluation(
'<title>${title}</title><link href="${root}styles.css" />',
{ title: 'Test Page', root: '../' }
);
assert.strictEqual(
result,
'<title>Test Page</title><link href="../styles.css" />'
);
});

it('handles empty string values', () => {
const result = populateWithEvaluation('[${content}]', { content: '' });
assert.strictEqual(result, '[]');
});

it('handles numeric values', () => {
const result = populateWithEvaluation('count: ${count}', { count: 42 });
assert.strictEqual(result, 'count: 42');
});
});
66 changes: 66 additions & 0 deletions src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';

import {
setConfig,
default as getConfig,
} from '../../../../utils/configuration/index.mjs';
import { relativeOrAbsolute } from '../relativeOrAbsolute.mjs';

await setConfig({
version: 'v22.0.0',
changelog: [],
generators: {
web: {
useAbsoluteURLs: false,
baseURL: 'https://nodejs.org/docs',
Comment thread
avivkeller marked this conversation as resolved.
},
},
});

describe('relativeOrAbsolute (relative mode)', () => {
beforeEach(() => {
getConfig('web').useAbsoluteURLs = false;
});

it('returns a relative path from a nested page to root', () => {
const result = relativeOrAbsolute('/', '/api/fs');
assert.strictEqual(result, '..');
});

it('returns a relative path between sibling pages', () => {
const result = relativeOrAbsolute('/http', '/fs');
assert.strictEqual(result, 'http');
});

it('returns a relative path for a deeper target', () => {
const result = relativeOrAbsolute('/orama-db.json', '/api/fs');
assert.strictEqual(result, '../orama-db.json');
});

it('returns "." when source and target resolve to the same path', () => {
const result = relativeOrAbsolute('/', '/');
assert.strictEqual(result, '.');
});
});

describe('relativeOrAbsolute (absolute mode)', () => {
beforeEach(() => {
getConfig('web').useAbsoluteURLs = true;
});

it('returns an absolute URL to root', () => {
const result = relativeOrAbsolute('/', '/api/fs');
assert.strictEqual(result, 'https://nodejs.org/docs/');
});

it('returns an absolute URL for a page path', () => {
const result = relativeOrAbsolute('/http', '/fs');
Comment thread
avivkeller marked this conversation as resolved.
assert.strictEqual(result, 'https://nodejs.org/docs/http');
});

it('returns an absolute URL for a resource', () => {
const result = relativeOrAbsolute('/orama-db.json', '/api/fs');
assert.strictEqual(result, 'https://nodejs.org/docs/orama-db.json');
});
});
Loading
Loading