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
78 changes: 78 additions & 0 deletions src/generators/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta>`, `<link>`, 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 `<head>` (rendered into the template's `${head}` placeholder). It has
three keys:

| Key | Type | Description |
| ------- | ------- | ------------------------------------------------------------------------------------------- |
| `meta` | `array` | `<meta>` tags. Each entry is an attribute bag, e.g. `{ name: 'description', content: '…' }` |
| `links` | `array` | `<link>` 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: ['<meta name="theme-color" content="#000" />'],
},
},
};
```

> 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: '<custom-ident>', 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 |
Expand Down Expand Up @@ -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 `<meta>`/`<link>`/raw markup from the `head` config |

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

Expand Down
29 changes: 29 additions & 0 deletions src/generators/web/__tests__/generate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<meta name="theme-color" content="#abcdef" />'],
};

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/);
});
});
42 changes: 42 additions & 0 deletions src/generators/web/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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'),
Expand Down
6 changes: 2 additions & 4 deletions src/generators/web/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
<head>
<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>
<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}">
<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">

${head}

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet"
Expand Down
27 changes: 27 additions & 0 deletions src/generators/web/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
import type { BundleAsyncOptions, CustomAtRules } from 'lightningcss-wasm';

import type { JSXContent } from '../jsx-ast/utils/buildContent.mjs';

// An attribute bag rendered into an HTML tag. `true` becomes a valueless
// attribute (e.g. `crossorigin`); `false`/`null`/`undefined` are omitted.
export type TagAttributes = Record<
string,
string | number | boolean | null | undefined
>;

export type HeadConfig = {
// `<meta>` tags, each an attribute bag (e.g. `{ name, content }`).
meta: Array<TagAttributes>;
// `<link>` tags, each an attribute bag (e.g. `{ rel, href, crossorigin }`).
links: Array<TagAttributes>;
// Arbitrary raw HTML appended to the document head.
html: Array<string>;
};

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<CustomAtRules>,
'filename' | 'code' | 'cssModules' | 'resolver'
>
>;
imports: Record<string, string>;
virtualImports: Record<string, string>;
};
Expand Down
56 changes: 55 additions & 1 deletion src/generators/web/utils/__tests__/processing.test.mjs
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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, /<meta name="description" content="Docs" \/>/);
assert.match(result, /<meta property="og:type" content="website" \/>/);
});

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,
/<link rel="preconnect" href="https:\/\/a\.example" \/>/
);
assert.match(
result,
/<link rel="preconnect" href="https:\/\/b\.example" crossorigin \/>/
);
// `integrity: null` is dropped entirely.
assert.match(result, /<link rel="icon" href="https:\/\/c\.example" \/>/);
});

it('appends raw HTML strings verbatim', () => {
const result = buildHead({
meta: [],
links: [],
html: ['<meta name="theme-color" content="#000" />'],
});

assert.match(result, /<meta name="theme-color" content="#000" \/>/);
});

it('returns an empty string when nothing is configured', () => {
assert.strictEqual(buildHead({ meta: [], links: [], html: [] }), '');
});
});
13 changes: 11 additions & 2 deletions src/generators/web/utils/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions src/generators/web/utils/css.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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'),
Expand Down
Loading