diff --git a/src/generators/web/README.md b/src/generators/web/README.md
index 17f8e138..428c4f4e 100644
--- a/src/generators/web/README.md
+++ b/src/generators/web/README.md
@@ -15,9 +15,86 @@ The `web` generator accepts the following configuration options:
| `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 |
+| `head` | `object` | See below | Configurable ``, ``, and raw markup for the document head |
+| `lightningcss` | `object` | `{}` | Options spread into LightningCSS while bundling CSS (see below) |
| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization |
| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build |
+#### `head`
+
+The `head` object controls the project-specific markup injected into the
+document `
` (rendered into the template's `${head}` placeholder). It has
+three keys:
+
+| Key | Type | Description |
+| ------- | ------- | ------------------------------------------------------------------------------------------- |
+| `meta` | `array` | `` tags. Each entry is an attribute bag, e.g. `{ name: 'description', content: '…' }` |
+| `links` | `array` | `` tags. Each entry is an attribute bag, e.g. `{ rel: 'icon', href: '…' }` |
+| `html` | `array` | Raw HTML strings appended verbatim — an escape hatch for anything not expressible above |
+
+Each attribute bag is rendered as a tag: a boolean `true` becomes a valueless
+attribute (e.g. `crossorigin`), and `false`/`null`/`undefined` attributes are
+omitted. Using arrays of attribute bags (rather than `name → value` maps) means
+you can emit repeated tags (e.g. two `preconnect` links) and pick the right
+attribute (`name` vs `property`) per tag.
+
+The defaults are Node.js-branded — override `head` entirely to brand the output
+for any project:
+
+```js
+// doc-kit.config.mjs
+export default {
+ web: {
+ head: {
+ meta: [
+ { name: 'description', content: 'My project documentation' },
+ { property: 'og:image', content: 'https://example.com/og.png' },
+ ],
+ links: [
+ { rel: 'icon', href: 'https://example.com/favicon.ico' },
+ { rel: 'stylesheet', href: 'https://example.com/fonts.css' },
+ ],
+ html: [''],
+ },
+ },
+};
+```
+
+> Structural and theme-bound tags are emitted by the template itself rather than
+> via `head`: `og:title` (mirrors the per-page title), `og:type`, and the font
+> preconnects/stylesheet the bundled UI components rely on.
+
+#### Custom LightningCSS options
+
+The `lightningcss` object is spread directly into [LightningCSS][lightningcss]
+while CSS is bundled, so any of its options — `visitor` (custom plugins),
+`customAtRules`, `targets`, `drafts`, and so on — are supported. The generator
+manages `filename`, `code`, `cssModules`, and `resolver`, so those are ignored.
+
+```js
+// doc-kit.config.mjs
+export default {
+ web: {
+ lightningcss: {
+ customAtRules: {
+ mixin: { prelude: '', body: 'style-block' },
+ },
+ visitor: {
+ Color(color) {
+ // e.g. transform every color through a design-token map
+ return color;
+ },
+ },
+ },
+ },
+};
+```
+
+To apply more than one visitor, compose them with LightningCSS's
+`composeVisitors` helper and pass the result as `visitor`.
+
+[lightningcss]: https://lightningcss.dev/transforms.html
+
#### Default `imports`
| Alias | Default | Description |
@@ -118,6 +195,7 @@ The HTML template file (set via `templatePath`) uses JavaScript template literal
| `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 |
+| `head` | `string` | Pre-rendered ``/``/raw markup from the `head` config |
Since the template supports arbitrary JS expressions, you can use conditionals and method calls:
diff --git a/src/generators/web/__tests__/generate.test.mjs b/src/generators/web/__tests__/generate.test.mjs
index c4ba50db..cde610ae 100644
--- a/src/generators/web/__tests__/generate.test.mjs
+++ b/src/generators/web/__tests__/generate.test.mjs
@@ -54,4 +54,33 @@ describe('web generate', () => {
assert.doesNotMatch(notFoundResult.html, /href=404\.json/);
assert.doesNotMatch(notFoundResult.html, /href=404\.md/);
});
+
+ it('renders the configurable head without hardcoded defaults', async () => {
+ // `setConfig` resolves generator defaults; mutate the live config to apply
+ // per-generator overrides (the same object `getConfig('web')` returns).
+ const config = await setConfig({});
+ config.web.head = {
+ meta: [
+ { name: 'description', content: 'Custom project docs' },
+ { property: 'og:image', content: 'https://example.com/og.png' },
+ ],
+ links: [{ rel: 'icon', href: 'https://example.com/favicon.ico' }],
+ html: [''],
+ };
+
+ const fs = createEntry('fs', 'File system');
+ const [fsPage] = await generate([await buildContent([fs], fs)]);
+
+ assert.match(fsPage.html, /Custom project docs/);
+ assert.match(fsPage.html, /https:\/\/example\.com\/og\.png/);
+ assert.match(fsPage.html, /href=https:\/\/example\.com\/favicon\.ico/);
+ assert.match(fsPage.html, /content=#abcdef/);
+
+ // Project-branding `head` config no longer leaks Node.js defaults.
+ assert.doesNotMatch(fsPage.html, /nodejs\.org/);
+
+ // Structural/theme tags stay hardcoded in the template regardless.
+ assert.match(fsPage.html, /property=og:type content=website/);
+ assert.match(fsPage.html, /href=https:\/\/fonts\.googleapis\.com/);
+ });
});
diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs
index 7f86ba9b..0d53c592 100644
--- a/src/generators/web/index.mjs
+++ b/src/generators/web/index.mjs
@@ -35,6 +35,48 @@ export default createLazyGenerator({
editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`,
pageURL: '{baseURL}/latest-{version}/api{path}.html',
remoteConfigUrl: 'https://nodejs.org/site.json',
+
+ // Project-specific document `` contents. `meta` and `links` are
+ // arrays of attribute bags (boolean `true` renders a valueless attribute,
+ // e.g. `crossorigin`); `html` holds arbitrary raw markup as an escape
+ // hatch. Structural/theme tags (`og:type`, font preconnects/stylesheets)
+ // are hardcoded in the template instead.
+ head: {
+ 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.',
+ },
+ {
+ 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.',
+ },
+ {
+ property: 'og:image',
+ content:
+ 'https://nodejs.org/en/next-data/og/announcement/Node.js%20%E2%80%94%20Run%20JavaScript%20Everywhere',
+ },
+ ],
+ links: [
+ {
+ rel: 'icon',
+ href: 'https://nodejs.org/static/images/favicons/favicon.png',
+ },
+ ],
+ html: [],
+ },
+
+ // Options spread directly into LightningCSS when bundling CSS, e.g.
+ // `visitor`, `customAtRules`, `targets`, or `drafts`. See
+ // https://lightningcss.dev/transforms.html for the full set.
+ lightningcss: {},
+
imports: {
'#theme/Logo': '@node-core/ui-components/Common/NodejsLogo',
'#theme/Navigation': join(import.meta.dirname, './ui/components/NavBar'),
diff --git a/src/generators/web/template.html b/src/generators/web/template.html
index fe6a5823..48081ef7 100644
--- a/src/generators/web/template.html
+++ b/src/generators/web/template.html
@@ -4,15 +4,13 @@
-
${title}
-
-
-
+ ${head}
+
;
+
+export type HeadConfig = {
+ // `` tags, each an attribute bag (e.g. `{ name, content }`).
+ meta: Array;
+ // `` tags, each an attribute bag (e.g. `{ rel, href, crossorigin }`).
+ links: Array;
+ // Arbitrary raw HTML appended to the document head.
+ html: Array;
+};
+
export type Configuration = {
templatePath: string;
title: string;
useAbsoluteURLs: boolean;
+ head: HeadConfig;
+ // Options spread into LightningCSS while bundling CSS. `filename`, `code`,
+ // `cssModules`, and `resolver` are managed by the generator and ignored here.
+ lightningcss: Partial<
+ Omit<
+ BundleAsyncOptions,
+ 'filename' | 'code' | 'cssModules' | 'resolver'
+ >
+ >;
imports: Record;
virtualImports: Record;
};
diff --git a/src/generators/web/utils/__tests__/processing.test.mjs b/src/generators/web/utils/__tests__/processing.test.mjs
index afbc64c3..e4a76154 100644
--- a/src/generators/web/utils/__tests__/processing.test.mjs
+++ b/src/generators/web/utils/__tests__/processing.test.mjs
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
-import { populateWithEvaluation } from '../processing.mjs';
+import { buildHead, populateWithEvaluation } from '../processing.mjs';
describe('populateWithEvaluation', () => {
it('substitutes simple ${variable} placeholders', () => {
@@ -61,3 +61,57 @@ describe('populateWithEvaluation', () => {
assert.strictEqual(result, 'count: 42');
});
});
+
+describe('buildHead', () => {
+ it('renders meta tags from attribute bags', () => {
+ const result = buildHead({
+ meta: [
+ { name: 'description', content: 'Docs' },
+ { property: 'og:type', content: 'website' },
+ ],
+ links: [],
+ html: [],
+ });
+
+ assert.match(result, //);
+ assert.match(result, //);
+ });
+
+ it('renders boolean attributes as valueless and omits nullish ones', () => {
+ const result = buildHead({
+ meta: [],
+ links: [
+ { rel: 'preconnect', href: 'https://a.example' },
+ { rel: 'preconnect', href: 'https://b.example', crossorigin: true },
+ { rel: 'icon', href: 'https://c.example', integrity: null },
+ ],
+ html: [],
+ });
+
+ // Two distinct preconnect tags prove arrays beat a `rel → href` map.
+ assert.match(
+ result,
+ //
+ );
+ assert.match(
+ result,
+ //
+ );
+ // `integrity: null` is dropped entirely.
+ assert.match(result, //);
+ });
+
+ it('appends raw HTML strings verbatim', () => {
+ const result = buildHead({
+ meta: [],
+ links: [],
+ html: [''],
+ });
+
+ assert.match(result, //);
+ });
+
+ it('returns an empty string when nothing is configured', () => {
+ assert.strictEqual(buildHead({ meta: [], links: [], html: [] }), '');
+ });
+});
diff --git a/src/generators/web/utils/config.mjs b/src/generators/web/utils/config.mjs
index 5712b6ff..8262a660 100644
--- a/src/generators/web/utils/config.mjs
+++ b/src/generators/web/utils/config.mjs
@@ -84,8 +84,17 @@ export default function createConfigSource(input) {
const exports = {
...omitKeys(
config,
- // These are keys that are large, and not needed by components, so we ignore them
- ['changelog', 'index', 'imports', 'virtualImports']
+ // These are keys that are large, build-time only (e.g. the document head
+ // and CSS options), or not serializable (e.g. `lightningcss` visitor
+ // functions), so they are never exposed to client components.
+ [
+ 'changelog',
+ 'index',
+ 'imports',
+ 'virtualImports',
+ 'head',
+ 'lightningcss',
+ ]
),
version: configVersion,
versions: buildVersionEntries(config.changelog, pageURL),
diff --git a/src/generators/web/utils/css.mjs b/src/generators/web/utils/css.mjs
index da851ded..8609a90f 100644
--- a/src/generators/web/utils/css.mjs
+++ b/src/generators/web/utils/css.mjs
@@ -2,6 +2,8 @@ import { readFile } from 'node:fs/promises';
import { bundleAsync } from 'lightningcss-wasm';
+import getConfig from '../../../utils/configuration/index.mjs';
+
// Since we use rolldown to bundle multiple times,
// we re-use a lot of CSS files, so there is no
// need to re-transpile.
@@ -21,6 +23,10 @@ const fileCache = new Map();
export default () => {
const cssChunks = new Set();
+ // User-supplied LightningCSS options (e.g. `visitor`, `customAtRules`),
+ // spread into every bundle call below.
+ const { lightningcss = {} } = getConfig('web');
+
return {
name: 'css-loader', // Required plugin name for debugging
@@ -57,6 +63,7 @@ export default () => {
// Use Lightning CSS to compile the file with CSS Modules enabled
const { code, exports } = await bundleAsync({
+ ...lightningcss,
filename: id,
code: Buffer.from(source),
cssModules: id.endsWith('module.css'),
diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs
index 5b528ff5..66073a8c 100644
--- a/src/generators/web/utils/processing.mjs
+++ b/src/generators/web/utils/processing.mjs
@@ -31,6 +31,40 @@ export const populateWithEvaluation = (template, config) => {
return fn(...values);
};
+/**
+ * Renders a self-closing HTML tag from an attribute bag.
+ *
+ * Boolean `true` renders a valueless attribute (e.g. `crossorigin`); `false`,
+ * `null`, and `undefined` are omitted; all other values are stringified.
+ *
+ * @param {string} tag - The tag name (e.g. `'meta'`, `'link'`).
+ * @param {Record} attrs - Attribute name/value pairs.
+ * @returns {string} The rendered tag.
+ */
+const renderTag = (tag, attrs) => {
+ const rendered = Object.entries(attrs)
+ .filter(([, value]) => value != null && value !== false)
+ .map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${value}"`))
+ .join('');
+
+ return `<${tag}${rendered} />`;
+};
+
+/**
+ * Builds the configurable `` markup shared by every page from the
+ * structured `head` config: `` tags, `` tags, and raw HTML. None
+ * of the rendered content is project-specific beyond the configured values.
+ *
+ * @param {import('../types').Configuration['head']} head - The `head` config.
+ * @returns {string} The concatenated HTML for the document head.
+ */
+export const buildHead = ({ meta = [], links = [], html = [] }) =>
+ [
+ ...meta.map(attrs => renderTag('meta', attrs)),
+ ...links.map(attrs => renderTag('link', attrs)),
+ ...html,
+ ].join('\n ');
+
/**
* Converts JSX AST entries to server and client JavaScript code.
*
@@ -128,6 +162,11 @@ export async function processJSXEntries(
version: config.version.version,
});
+ // Pre-render the configurable `` markup once, since it is identical
+ // across every page. Computed here (rather than inline in the template) so
+ // template authors avoid nested template-literal escaping.
+ const head = buildHead(config.head);
+
// Step 3: Render final HTML pages
const results = await Promise.all(
entries.map(async ({ data }) => {
@@ -146,6 +185,7 @@ export async function processJSXEntries(
root,
metadata: data,
config,
+ head,
});
return { html: await minifyHTML(renderedHtml), path: data.path };