From 55c78ba39f5b5c7e63d7365add6e4b7635122574 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Mon, 1 Jun 2026 12:57:41 -0400 Subject: [PATCH] chore: more customizing --- src/generators/web/README.md | 78 +++++++++++++++++++ .../web/__tests__/generate.test.mjs | 29 +++++++ src/generators/web/index.mjs | 42 ++++++++++ src/generators/web/template.html | 6 +- src/generators/web/types.d.ts | 27 +++++++ .../web/utils/__tests__/processing.test.mjs | 56 ++++++++++++- src/generators/web/utils/config.mjs | 13 +++- src/generators/web/utils/css.mjs | 7 ++ src/generators/web/utils/processing.mjs | 40 ++++++++++ 9 files changed, 291 insertions(+), 7 deletions(-) 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 };