Skip to content

Commit a3a79be

Browse files
committed
feat(web): more customizable templates
1 parent e45645a commit a3a79be

9 files changed

Lines changed: 80 additions & 75 deletions

File tree

src/generators/web/README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ The `web` generator transforms JSX AST entries into complete web bundles, produc
66

77
The `web` generator accepts the following configuration options:
88

9-
| Name | Type | Default | Description |
10-
| -------------- | -------- | --------------------------------------------- | --------------------------------------------------------------------- |
11-
| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written |
12-
| `templatePath` | `string` | `'template.html'` | Path to the HTML template file |
13-
| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links |
14-
| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links |
15-
| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization |
9+
| Name | Type | Default | Description |
10+
| ---------------- | -------- | --------------------------------------------- | --------------------------------------------------------------------- |
11+
| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written |
12+
| `templatePath` | `string` | `'template.html'` | Path to the HTML template file |
13+
| `project` | `string` | `'Node.js'` | Project name used in page titles and the version selector |
14+
| `title` | `string` | `'{project} v{version} Documentation'` | Title template for HTML pages (supports `{project}`, `{version}`) |
15+
| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links |
16+
| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links |
17+
| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization |
18+
| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build |
1619

1720
#### Default `imports`
1821

@@ -42,14 +45,16 @@ export default {
4245
The `web` generator provides a `#theme/config` virtual module that exposes pre-computed configuration as named exports. Any component (including custom overrides) can import the values it needs, and tree-shaking removes the rest.
4346

4447
```js
45-
import { title, repository, editURL } from '#theme/config';
48+
import { project, repository, editURL } from '#theme/config';
4649
```
4750

4851
#### Available exports
4952

53+
All scalar (non-object) configuration values are automatically exported. The defaults include:
54+
5055
| Export | Type | Description |
5156
| ------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------- |
52-
| `title` | `string` | Site title (e.g. `'Node.js'`) |
57+
| `project` | `string` | Project name (e.g. `'Node.js'`) |
5358
| `repository` | `string` | GitHub repository in `owner/repo` format |
5459
| `version` | `string` | Current version label (e.g. `'v22.x'`) |
5560
| `versions` | `Array<{ url, label, major }>` | Pre-computed version entries with labels and URL templates (only `{path}` remains for per-page use) |

src/generators/web/index.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export default createLazyGenerator({
2929

3030
defaultConfiguration: {
3131
templatePath: join(import.meta.dirname, 'template.html'),
32-
title: 'Node.js',
32+
project: 'Node.js',
33+
title: '{project} v{version} Documentation',
3334
editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`,
3435
pageURL: '{baseURL}/latest-{version}/api{path}.html',
3536
imports: {
@@ -40,5 +41,6 @@ export default createLazyGenerator({
4041
'#theme/Footer': join(import.meta.dirname, './ui/components/NoOp'),
4142
'#theme/Layout': join(import.meta.dirname, './ui/components/Layout'),
4243
},
44+
virtualImports: {},
4345
},
4446
});

src/generators/web/template.html

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
<meta charset="UTF-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<link rel="icon" href="https://nodejs.org/static/images/favicons/favicon.png"/>
8-
<title>{{title}}</title>
8+
<title>{title}</title>
99
<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.">
10-
<link rel="stylesheet" href="{{root}}styles.css" />
11-
<meta property="og:title" content="{{ogTitle}}">
10+
<link rel="stylesheet" href="{root}styles.css" />
11+
<meta property="og:title" content="{title}">
1212
<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.">
1313
<meta property="og:image" content="https://nodejs.org/en/next-data/og/announcement/Node.js%20%E2%80%94%20Run%20JavaScript%20Everywhere" />
1414
<meta property="og:type" content="website">
@@ -20,12 +20,12 @@
2020

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

2727
<body>
28-
<div id="root">{{dehydrated}}</div>
29-
<script type="module" src="{{root}}{{entrypoint}}"></script>
28+
<div id="root">{dehydrated}</div>
29+
<script type="module" src="{root}{entrypoint}"></script>
3030
</body>
3131
</html>

src/generators/web/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type Generator = GeneratorMetadata<
55
templatePath: string;
66
title: string;
77
imports: Record<string, string>;
8+
virtualImports: Record<string, string>;
89
},
910
Generate<Array<JSXContent>, AsyncGenerator<{ html: string; css: string }>>
1011
>;

src/generators/web/ui/components/NavBar.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
66
import SearchBox from './SearchBox';
77
import { useTheme } from '../hooks/useTheme.mjs';
88

9-
import { title, repository } from '#theme/config';
9+
import { repository } from '#theme/config';
1010
import Logo from '#theme/Logo';
1111

1212
/**
@@ -28,7 +28,7 @@ export default () => {
2828
/>
2929
<a
3030
href={`https://github.com/${repository}`}
31-
aria-label={`${title} GitHub`}
31+
aria-label={repository}
3232
className={styles.ghIconWrapper}
3333
>
3434
<GitHubIcon />

src/generators/web/ui/components/SideBar/index.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import SideBar from '@node-core/ui-components/Containers/Sidebar';
44
import styles from './index.module.css';
55
import { relative } from '../../../../../utils/url.mjs';
66

7-
import { title, version, versions, pages } from '#theme/config';
7+
import { project, version, versions, pages } from '#theme/config';
88

99
/**
1010
* Extracts the major version number from a version string.
@@ -54,7 +54,7 @@ export default ({ metadata }) => {
5454
>
5555
<div>
5656
<Select
57-
label={`${title} version`}
57+
label={`${project} version`}
5858
values={compatibleVersions}
5959
inline={true}
6060
className={styles.select}

src/generators/web/utils/__tests__/config.test.mjs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,25 +75,19 @@ describe('buildVersionEntries', () => {
7575
});
7676

7777
it('does not append a label suffix for versions that are neither LTS nor Current', () => {
78-
const config = {
79-
changelog: [
80-
{ version: new SemVer('18.0.0'), isLts: false, isCurrent: false },
81-
],
82-
};
83-
84-
const result = buildVersionEntries(config, '{version}');
78+
const result = buildVersionEntries(
79+
[{ version: new SemVer('18.0.0'), isLts: false, isCurrent: false }],
80+
'{version}'
81+
);
8582

8683
assert.equal(result[0].label, 'v18.x');
8784
});
8885

8986
it('formats minor versions when minor is non-zero', () => {
90-
const config = {
91-
changelog: [
92-
{ version: new SemVer('21.7.0'), isLts: false, isCurrent: false },
93-
],
94-
};
95-
96-
const result = buildVersionEntries(config, '{version}');
87+
const result = buildVersionEntries(
88+
[{ version: new SemVer('21.7.0'), isLts: false, isCurrent: false }],
89+
'{version}'
90+
);
9791

9892
assert.equal(result[0].label, 'v21.7.x');
9993
assert.equal(result[0].major, 21);

src/generators/web/utils/config.mjs

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { getSortedHeadNodes } from '../../jsx-ast/utils/getSortedHeadNodes.mjs';
1111
* Pre-compute version entries with labels and URL templates.
1212
* Each entry's `url` still contains `{path}` for per-page resolution.
1313
*
14-
* @param {object} config
14+
* @param {object} changelog
1515
* @param {string} pageURLBase
1616
* @returns {Array<{url: string, label: string, major: number}>}
1717
*/
18-
export function buildVersionEntries(config, pageURLBase) {
19-
return config.changelog.map(({ version, isLts, isCurrent }) => {
18+
export function buildVersionEntries(changelog, pageURLBase) {
19+
return changelog.map(({ version, isLts, isCurrent }) => {
2020
let label = `v${getVersionFromSemVer(version)}`;
2121
const url = pageURLBase.replace('{version}', label);
2222
if (isLts) {
@@ -72,32 +72,33 @@ export function buildLanguageDisplayNameMap() {
7272
* @returns {string} JavaScript source code string with named exports
7373
*/
7474
export default function createConfigSource(input) {
75-
const config = getConfig('web');
75+
const {
76+
version: versionMeta,
77+
changelog,
78+
editURL: editURLTemplate,
79+
pageURL: pageURLTemplate,
80+
...rest
81+
} = getConfig('web');
7682

77-
const currentVersion = `v${config.version.version}`;
78-
const templateVars = { ...config, version: currentVersion };
83+
const version = `v${versionMeta.version}`;
84+
const editURL = populate(editURLTemplate, { ...rest, version });
85+
const pageURL = populate(pageURLTemplate, rest);
7986

80-
// Partially populate URL templates: resolve config-level placeholders,
81-
// leave {path} for per-page resolution by components
82-
const editURL = populate(config.editURL, templateVars);
87+
const lines = [];
8388

84-
// Resolve the pageURL template once with config-level values, leaving
85-
// {version} and {path} as the only remaining placeholders
86-
// eslint-disable-next-line no-unused-vars
87-
const { version, ...configWithoutVersion } = config;
88-
const pageURLBase = populate(config.pageURL, configWithoutVersion);
89-
90-
const versions = buildVersionEntries(config, pageURLBase);
91-
const pages = buildPageList(input);
92-
const shikiDisplayNameMap = buildLanguageDisplayNameMap();
89+
for (const [k, v] of Object.entries(rest)) {
90+
if (v === null || typeof v !== 'object') {
91+
lines.push(`export const ${k} = ${JSON.stringify(v)};`);
92+
}
93+
}
9394

94-
return [
95-
`export const title = ${JSON.stringify(config.title)};`,
96-
`export const repository = ${JSON.stringify(config.repository)};`,
97-
`export const version = ${JSON.stringify(currentVersion)};`,
98-
`export const versions = ${JSON.stringify(versions)};`,
95+
lines.push(
96+
`export const version = ${JSON.stringify(version)};`,
97+
`export const versions = ${JSON.stringify(buildVersionEntries(changelog, pageURL))};`,
9998
`export const editURL = ${JSON.stringify(editURL)};`,
100-
`export const pages = ${JSON.stringify(pages)};`,
101-
`export const languageDisplayNameMap = new Map(${JSON.stringify(shikiDisplayNameMap)});`,
102-
].join('\n');
99+
`export const pages = ${JSON.stringify(buildPageList(input))};`,
100+
`export const languageDisplayNameMap = new Map(${JSON.stringify(buildLanguageDisplayNameMap())});`
101+
);
102+
103+
return lines.join('\n');
103104
}

src/generators/web/utils/processing.mjs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createChunkedRequire } from './chunks.mjs';
99
import createConfigSource from './config.mjs';
1010
import createASTBuilder from './generate.mjs';
1111
import getConfig from '../../../utils/configuration/index.mjs';
12+
import { populate } from '../../../utils/configuration/templates.mjs';
1213
import { minifyHTML } from '../../../utils/html-minifier.mjs';
1314
import { relative } from '../../../utils/url.mjs';
1415
import { SPECULATION_RULES } from '../constants.mjs';
@@ -79,11 +80,12 @@ async function executeServerCode(serverCodeMap, requireFn, virtualImports) {
7980
* @param {string} template - The HTML template string for the output pages.
8081
*/
8182
export async function processJSXEntries(entries, template) {
82-
const { version } = getConfig('web');
83+
const config = getConfig('web');
8384
const astBuilders = createASTBuilder();
8485
const requireFn = createRequire(import.meta.url);
8586
const virtualImports = {
8687
'#theme/config': createConfigSource(entries),
88+
...config.virtualImports,
8789
};
8890
// Step 1: Convert JSX AST to JavaScript
8991
const { serverCodeMap, clientCodeMap } = convertJSXToCode(
@@ -98,26 +100,26 @@ export async function processJSXEntries(entries, template) {
98100
bundleCode(clientCodeMap, virtualImports),
99101
]);
100102

101-
const titleSuffix = `Node.js v${version.version} Documentation`;
103+
const titleSuffix = populate(config.title, {
104+
...config,
105+
version: `v${config.version.version}`,
106+
});
102107

103108
// Step 3: Render final HTML pages
104109
const results = await Promise.all(
105110
entries.map(async ({ data: { api, path, heading } }) => {
106-
const title = `${heading.data.name} | ${titleSuffix}`;
107111
const root = `${relative('/', path)}/`;
108112

109113
// Replace template placeholders with actual content
110-
const renderedHtml = template
111-
.replace('{{title}}', title)
112-
.replace('{{dehydrated}}', serverBundle.pages.get(`${api}.js`) ?? '')
113-
.replace(
114-
'{{importMap}}',
115-
clientBundle.importMap?.replaceAll('/', root) ?? ''
116-
)
117-
.replace('{{entrypoint}}', `${api}.js?${randomUUID()}`)
118-
.replace('{{speculationRules}}', SPECULATION_RULES)
119-
.replace('{{ogTitle}}', title)
120-
.replaceAll('{{root}}', root);
114+
const renderedHtml = populate(template, {
115+
title: `${heading.data.name} | ${titleSuffix}`,
116+
dehydrated: serverBundle.pages.get(`${api}.js`) ?? '',
117+
importMap: clientBundle.importMap?.replaceAll('/', root) ?? '',
118+
entrypoint: `${api}.js?${randomUUID()}`,
119+
speculationRules: SPECULATION_RULES,
120+
root,
121+
path,
122+
});
121123

122124
return { html: await minifyHTML(renderedHtml), path };
123125
})

0 commit comments

Comments
 (0)