From 7d9abc60edf8405af43c1f02cab83c58e831b769 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Wed, 29 Apr 2026 16:43:07 -0400 Subject: [PATCH 1/4] feat(web): add all.html generation --- package-lock.json | 38 +-------------- src/generators/index.mjs | 2 + src/generators/web-all/README.md | 14 ++++++ src/generators/web-all/generate.mjs | 62 +++++++++++++++++++++++++ src/generators/web-all/index.mjs | 29 ++++++++++++ src/generators/web-all/types.d.ts | 10 ++++ src/generators/web/utils/processing.mjs | 9 +++- 7 files changed, 126 insertions(+), 38 deletions(-) create mode 100644 src/generators/web-all/README.md create mode 100644 src/generators/web-all/generate.mjs create mode 100644 src/generators/web-all/index.mjs create mode 100644 src/generators/web-all/types.d.ts diff --git a/package-lock.json b/package-lock.json index c84003e1..806644af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,7 +138,6 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -150,7 +149,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1080,7 +1078,6 @@ "resolved": "https://registry.npmjs.org/@orama/core/-/core-1.2.19.tgz", "integrity": "sha512-AVEI0eG/a1RUQK+tBloRMppQf46Ky4kIYKEVjo0V0VfIGZHdLOE2PJR4v949kFwiTnfSJCUaxgwM74FCA1uHUA==", "license": "AGPL-3.0", - "peer": true, "dependencies": { "@orama/cuid2": "2.2.3", "@orama/oramacore-events-parser": "0.0.5" @@ -2778,29 +2775,6 @@ "hast-util-to-html": "^9.0.5" } }, - "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, "node_modules/@shikijs/langs": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", @@ -3289,7 +3263,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3437,7 +3410,6 @@ "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", @@ -3837,7 +3809,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4609,7 +4580,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7420,7 +7390,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7500,7 +7469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7643,7 +7611,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7727,7 +7694,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8182,7 +8148,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.4", @@ -8929,7 +8896,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/src/generators/index.mjs b/src/generators/index.mjs index 24294f92..fbfd9be6 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -16,6 +16,7 @@ import metadata from './metadata/index.mjs'; import oramaDb from './orama-db/index.mjs'; import sitemap from './sitemap/index.mjs'; import web from './web/index.mjs'; +import webAll from './web-all/index.mjs'; export const publicGenerators = { 'json-simple': jsonSimple, @@ -30,6 +31,7 @@ export const publicGenerators = { 'llms-txt': llmsTxt, sitemap, web, + 'web-all': webAll, }; // These ones are special since they don't produce standard output, diff --git a/src/generators/web-all/README.md b/src/generators/web-all/README.md new file mode 100644 index 00000000..f411856d --- /dev/null +++ b/src/generators/web-all/README.md @@ -0,0 +1,14 @@ +## `web-all` Generator + +The `web-all` generator creates a single `all.html` file containing all API documentation modules rendered through the `web` generator pipeline. This is the modern equivalent of `legacy-html-all` for the new web tooling. + +It is intended for offline browsing and for environments where JavaScript is unavailable: the in-page searchbar shipped with `web` requires JS, so a single all-in-one page makes browser-native text search (`Ctrl`+`F`) usable across the full documentation set. + +### Configuring + +The `web-all` generator accepts the following configuration options: + +| Name | Type | Default | Description | +| -------------- | -------- | -------------------- | ---------------------------------------------- | +| `output` | `string` | - | The directory where `all.html` will be written | +| `templatePath` | `string` | Inherited from `web` | Path to the HTML template file | diff --git a/src/generators/web-all/generate.mjs b/src/generators/web-all/generate.mjs new file mode 100644 index 00000000..a5b09112 --- /dev/null +++ b/src/generators/web-all/generate.mjs @@ -0,0 +1,62 @@ +'use strict'; + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import getConfig from '../../utils/configuration/index.mjs'; +import { writeFile } from '../../utils/file.mjs'; +import buildContent from '../jsx-ast/utils/buildContent.mjs'; +import { processJSXEntries } from '../web/utils/processing.mjs'; + +/** + * Generates a single `all.html` file containing every API documentation + * module rendered through the `web` generator pipeline. + * + * @type {import('./types').Generator['generate']} + */ +export async function generate(input) { + const config = getConfig('web-all'); + + const template = await readFile(config.templatePath, 'utf-8'); + + // Match `legacy-html-all`: skip the synthetic `index` entries. + const entries = input.filter(entry => entry.api !== 'index'); + + // Build a single combined JSXContent that wraps every entry's content in + // one Layout component, mirroring how `jsx-ast` produces per-module pages. + const combined = await buildContent(entries, { + api: 'all', + path: '/all', + basename: 'all.html', + heading: { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'All' }], + data: { name: 'All', text: 'All', slug: 'all' }, + }, + }); + + // Pass the original metadata entries as the sidebar source so `all.html` + // exposes the same navigation as the per-module pages produced by `web`. + const sidebarEntries = entries.map(entry => ({ data: entry })); + + const { results, css, chunks } = await processJSXEntries( + [combined], + template, + sidebarEntries + ); + + if (config.output) { + for (const { html, path } of results) { + await writeFile(join(config.output, `${path}.html`), html, 'utf-8'); + } + + for (const chunk of chunks) { + await writeFile(join(config.output, chunk.fileName), chunk.code, 'utf-8'); + } + + await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); + } + + return { html: results[0].html.toString(), css }; +} diff --git a/src/generators/web-all/index.mjs b/src/generators/web-all/index.mjs new file mode 100644 index 00000000..5a0b378b --- /dev/null +++ b/src/generators/web-all/index.mjs @@ -0,0 +1,29 @@ +'use strict'; + +import { createLazyGenerator } from '../../utils/generators.mjs'; +import web from '../web/index.mjs'; + +/** + * Web "all" generator - produces a single `all.html` page that contains every + * API documentation module rendered into one combined web bundle. + * + * Mirrors the role of `legacy-html-all` for the new `web` generator. Useful + * for offline browsing and for reading the full documentation in environments + * where JavaScript is unavailable, since the `web` searchbar requires JS. + * + * @type {import('./types').Generator} + */ +export default createLazyGenerator({ + name: 'web-all', + + version: '1.0.0', + + description: + 'Generates the `all.html` file from the `web` generator, which includes all the modules in one single file', + + dependsOn: 'metadata', + + defaultConfiguration: { + templatePath: web.defaultConfiguration.templatePath, + }, +}); diff --git a/src/generators/web-all/types.d.ts b/src/generators/web-all/types.d.ts new file mode 100644 index 00000000..708e1c73 --- /dev/null +++ b/src/generators/web-all/types.d.ts @@ -0,0 +1,10 @@ +import type { MetadataEntry } from '../metadata/types'; + +export type Configuration = { + templatePath: string; +}; + +export type Generator = GeneratorMetadata< + Configuration, + Generate, Promise<{ html: string; css: string }>> +>; diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 7228ad6f..5b528ff5 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -96,13 +96,18 @@ async function executeServerCode(serverCodeMap, requireFn, virtualImports) { * * @param {Array} entries - The JSX AST entries to process. * @param {string} template - The HTML template string for the output pages. + * @param {Array<{ data: import('../../metadata/types').MetadataEntry }>} [sidebarEntries] - Entries used to build the sidebar page list. Defaults to `entries`. Pass the full set when rendering a subset (e.g. the `all` page) so the sidebar still links to every module. */ -export async function processJSXEntries(entries, template) { +export async function processJSXEntries( + entries, + template, + sidebarEntries = entries +) { const config = getConfig('web'); const astBuilders = createASTBuilder(); const requireFn = createRequire(import.meta.url); const virtualImports = { - '#theme/config': createConfigSource(entries), + '#theme/config': createConfigSource(sidebarEntries), ...config.virtualImports, }; // Step 1: Convert JSX AST to JavaScript From 5decb295b353773c55fccc451e108aa812351243 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Wed, 29 Apr 2026 17:31:40 -0400 Subject: [PATCH 2/4] fixup! --- .github/workflows/generate.yml | 3 + scripts/vercel-build.sh | 1 + src/generators/web-all/README.md | 21 ++++-- src/generators/web-all/generate.mjs | 45 ++++++------- src/generators/web-all/index.mjs | 9 +-- src/generators/web-all/types.d.ts | 2 +- src/generators/web-all/utils/404.mjs | 28 ++++++++ .../web-all/utils/__tests__/404.test.mjs | 42 ++++++++++++ .../web-all/utils/__tests__/all.test.mjs | 32 +++++++++ .../web-all/utils/__tests__/index.test.mjs | 65 +++++++++++++++++++ .../utils/__tests__/synthetic.test.mjs | 62 ++++++++++++++++++ src/generators/web-all/utils/all.mjs | 13 ++++ src/generators/web-all/utils/index.mjs | 55 ++++++++++++++++ src/generators/web-all/utils/synthetic.mjs | 37 +++++++++++ src/generators/web/generate.mjs | 9 ++- 15 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 src/generators/web-all/utils/404.mjs create mode 100644 src/generators/web-all/utils/__tests__/404.test.mjs create mode 100644 src/generators/web-all/utils/__tests__/all.test.mjs create mode 100644 src/generators/web-all/utils/__tests__/index.test.mjs create mode 100644 src/generators/web-all/utils/__tests__/synthetic.test.mjs create mode 100644 src/generators/web-all/utils/all.mjs create mode 100644 src/generators/web-all/utils/index.mjs create mode 100644 src/generators/web-all/utils/synthetic.mjs diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 800313ba..1191e49c 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -106,6 +106,9 @@ jobs: input: './node/doc/api/*.md' compare: file-size + - target: web-all + input: './node/doc/api/*.md' + - target: llms-txt input: './node/doc/api/*.md' compare: file-size diff --git a/scripts/vercel-build.sh b/scripts/vercel-build.sh index e1575b07..4af33684 100755 --- a/scripts/vercel-build.sh +++ b/scripts/vercel-build.sh @@ -3,6 +3,7 @@ node bin/cli.mjs generate \ -t legacy-json \ -t llms-txt \ -t web \ + -t web-all \ -i "./node/doc/api/*.md" \ -o "./out" \ -c "./node/CHANGELOG.md" \ diff --git a/src/generators/web-all/README.md b/src/generators/web-all/README.md index f411856d..ad531e1c 100644 --- a/src/generators/web-all/README.md +++ b/src/generators/web-all/README.md @@ -1,14 +1,23 @@ ## `web-all` Generator -The `web-all` generator creates a single `all.html` file containing all API documentation modules rendered through the `web` generator pipeline. This is the modern equivalent of `legacy-html-all` for the new web tooling. +The `web-all` generator complements `web` by producing the pages that the +per-module pipeline does not generate on its own: -It is intended for offline browsing and for environments where JavaScript is unavailable: the in-page searchbar shipped with `web` requires JS, so a single all-in-one page makes browser-native text search (`Ctrl`+`F`) usable across the full documentation set. +- `all.html`: every API module concatenated into a single page. Useful for + offline browsing and for environments where JavaScript is unavailable, since + the in-page searchbar shipped with `web` requires JS, so a single all-in-one + page makes browser-native text search (`Ctrl`+`F`) usable across the full + documentation set. +- `index.html`: a synthetic landing page listing every module alongside a + Stability Overview table. The `web` generator skips its `index` entry so + this file is the canonical index. +- `404.html`: a static not-found page wired into the same Layout, so hosts + serving the docs can use it as a fallback. ### Configuring The `web-all` generator accepts the following configuration options: -| Name | Type | Default | Description | -| -------------- | -------- | -------------------- | ---------------------------------------------- | -| `output` | `string` | - | The directory where `all.html` will be written | -| `templatePath` | `string` | Inherited from `web` | Path to the HTML template file | +| Name | Type | Default | Description | +| -------------- | -------- | -------------------- | ------------------------------ | +| `templatePath` | `string` | Inherited from `web` | Path to the HTML template file | diff --git a/src/generators/web-all/generate.mjs b/src/generators/web-all/generate.mjs index a5b09112..f919840e 100644 --- a/src/generators/web-all/generate.mjs +++ b/src/generators/web-all/generate.mjs @@ -3,45 +3,43 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { buildNotFoundPage } from './utils/404.mjs'; +import { buildAllPage } from './utils/all.mjs'; +import { buildIndexPage } from './utils/index.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { writeFile } from '../../utils/file.mjs'; import buildContent from '../jsx-ast/utils/buildContent.mjs'; import { processJSXEntries } from '../web/utils/processing.mjs'; /** - * Generates a single `all.html` file containing every API documentation - * module rendered through the `web` generator pipeline. + * Generates the `web-all` output: `all.html`, `index.html`, and `404.html`. * * @type {import('./types').Generator['generate']} */ export async function generate(input) { const config = getConfig('web-all'); - const template = await readFile(config.templatePath, 'utf-8'); - // Match `legacy-html-all`: skip the synthetic `index` entries. + // Drop the synthetic `index` entry — we re-generate `index.html` ourselves. const entries = input.filter(entry => entry.api !== 'index'); - // Build a single combined JSXContent that wraps every entry's content in - // one Layout component, mirroring how `jsx-ast` produces per-module pages. - const combined = await buildContent(entries, { - api: 'all', - path: '/all', - basename: 'all.html', - heading: { - type: 'heading', - depth: 1, - children: [{ type: 'text', value: 'All' }], - data: { name: 'All', text: 'All', slug: 'all' }, - }, - }); - - // Pass the original metadata entries as the sidebar source so `all.html` - // exposes the same navigation as the per-module pages produced by `web`. + const pages = [ + buildAllPage(entries), + buildIndexPage(entries), + buildNotFoundPage(), + ]; + + const jsxContents = await Promise.all( + pages.map(({ head, entries: pageEntries }) => + buildContent(pageEntries, head) + ) + ); + + // Sidebar still needs to link to every module page produced by `web`. const sidebarEntries = entries.map(entry => ({ data: entry })); const { results, css, chunks } = await processJSXEntries( - [combined], + jsxContents, template, sidebarEntries ); @@ -58,5 +56,8 @@ export async function generate(input) { await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); } - return { html: results[0].html.toString(), css }; + return { + html: results.map(({ html }) => html.toString()), + css, + }; } diff --git a/src/generators/web-all/index.mjs b/src/generators/web-all/index.mjs index 5a0b378b..dfa6f19c 100644 --- a/src/generators/web-all/index.mjs +++ b/src/generators/web-all/index.mjs @@ -4,12 +4,6 @@ import { createLazyGenerator } from '../../utils/generators.mjs'; import web from '../web/index.mjs'; /** - * Web "all" generator - produces a single `all.html` page that contains every - * API documentation module rendered into one combined web bundle. - * - * Mirrors the role of `legacy-html-all` for the new `web` generator. Useful - * for offline browsing and for reading the full documentation in environments - * where JavaScript is unavailable, since the `web` searchbar requires JS. * * @type {import('./types').Generator} */ @@ -18,8 +12,7 @@ export default createLazyGenerator({ version: '1.0.0', - description: - 'Generates the `all.html` file from the `web` generator, which includes all the modules in one single file', + description: 'Generates the additional files from the `web` generator', dependsOn: 'metadata', diff --git a/src/generators/web-all/types.d.ts b/src/generators/web-all/types.d.ts index 708e1c73..2435dfd7 100644 --- a/src/generators/web-all/types.d.ts +++ b/src/generators/web-all/types.d.ts @@ -6,5 +6,5 @@ export type Configuration = { export type Generator = GeneratorMetadata< Configuration, - Generate, Promise<{ html: string; css: string }>> + Generate, Promise<{ html: Array; css: string }>> >; diff --git a/src/generators/web-all/utils/404.mjs b/src/generators/web-all/utils/404.mjs new file mode 100644 index 00000000..f11295c8 --- /dev/null +++ b/src/generators/web-all/utils/404.mjs @@ -0,0 +1,28 @@ +'use strict'; + +import { createSyntheticHead, wrapAsEntry } from './synthetic.mjs'; + +/** + * Builds the page descriptor for `404.html` + */ +export const buildNotFoundPage = () => { + const head = createSyntheticHead('404', 'Page Not Found'); + + return { + head, + entries: [ + wrapAsEntry(head, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: + 'The page you requested could not be found. Use the navigation to find the documentation you are looking for.', + }, + ], + }, + ]), + ], + }; +}; diff --git a/src/generators/web-all/utils/__tests__/404.test.mjs b/src/generators/web-all/utils/__tests__/404.test.mjs new file mode 100644 index 00000000..c44584f5 --- /dev/null +++ b/src/generators/web-all/utils/__tests__/404.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildNotFoundPage } from '../404.mjs'; + +describe('buildNotFoundPage', () => { + it('uses a `404` head with a "Page Not Found" heading', () => { + const { head } = buildNotFoundPage(); + + assert.equal(head.api, '404'); + assert.equal(head.path, '/404'); + assert.equal(head.basename, '404'); + assert.equal(head.heading.data.name, 'Page Not Found'); + }); + + it('produces a single synthetic entry with a not-found paragraph', () => { + const { entries } = buildNotFoundPage(); + + assert.equal(entries.length, 1); + + const paragraph = entries[0].content.children.find( + child => child.type === 'paragraph' + ); + + assert.ok(paragraph, 'expected a paragraph node in the content tree'); + assert.match(paragraph.children[0].value, /could not be found/); + }); + + it('places the head heading at the start of the content tree', () => { + const { head, entries } = buildNotFoundPage(); + + assert.equal(entries[0].content.children[0], head.heading); + }); + + it('returns the same shape on every call', () => { + const a = buildNotFoundPage(); + const b = buildNotFoundPage(); + + assert.deepEqual(a.head, b.head); + assert.equal(a.entries.length, b.entries.length); + }); +}); diff --git a/src/generators/web-all/utils/__tests__/all.test.mjs b/src/generators/web-all/utils/__tests__/all.test.mjs new file mode 100644 index 00000000..d54de38b --- /dev/null +++ b/src/generators/web-all/utils/__tests__/all.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildAllPage } from '../all.mjs'; + +describe('buildAllPage', () => { + it('returns a synthetic `all` head with an "All" heading', () => { + const { head } = buildAllPage([]); + + assert.equal(head.api, 'all'); + assert.equal(head.path, '/all'); + assert.equal(head.basename, 'all'); + assert.equal(head.heading.data.name, 'All'); + }); + + it('forwards the input entries as the page entries', () => { + const a = { api: 'fs', heading: { depth: 1, data: {} } }; + const b = { api: 'http', heading: { depth: 1, data: {} } }; + + const { entries } = buildAllPage([a, b]); + + assert.deepEqual(entries, [a, b]); + }); + + it('does not mutate the input array', () => { + const input = [{ api: 'fs' }]; + + buildAllPage(input); + + assert.equal(input.length, 1); + }); +}); diff --git a/src/generators/web-all/utils/__tests__/index.test.mjs b/src/generators/web-all/utils/__tests__/index.test.mjs new file mode 100644 index 00000000..b0d417d3 --- /dev/null +++ b/src/generators/web-all/utils/__tests__/index.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildStabilityOverview } from '../index.mjs'; + +const fakeHead = (api, name, stabilityIndex, depth = 1) => ({ + api, + heading: { depth, data: { name, text: name, slug: api } }, + stability: + stabilityIndex == null + ? null + : { + data: { + index: String(stabilityIndex), + description: `${name} stable. Long-form description.`, + }, + }, +}); + +const findChild = (node, tagName) => + node.children.find(child => child.tagName === tagName); + +describe('buildStabilityOverview', () => { + it('renders a header row and one body row per entry', () => { + const table = buildStabilityOverview([ + fakeHead('fs', 'fs', 2), + fakeHead('crypto', 'crypto', 1), + ]); + + assert.equal(table.tagName, 'table'); + const headerRow = findChild(findChild(table, 'thead'), 'tr'); + assert.deepEqual( + headerRow.children.map(c => c.children[0].value), + ['API', 'Stability'] + ); + + assert.equal(findChild(table, 'tbody').children.length, 2); + }); + + it('formats the stability cell as `(index) `', () => { + const table = buildStabilityOverview([fakeHead('fs', 'fs', 1)]); + + const row = findChild(table, 'tbody').children[0]; + const stabilityCell = row.children[1]; + + assert.equal(stabilityCell.children[0].value, '(1) fs stable'); + }); + + it('builds a relative link to the module HTML page', () => { + const table = buildStabilityOverview([fakeHead('fs', 'fs', 2)]); + + const row = findChild(table, 'tbody').children[0]; + const link = row.children[0].children[0]; + + assert.equal(link.tagName, 'a'); + assert.equal(link.properties.href, 'fs.html'); + assert.equal(link.children[0].value, 'fs'); + }); + + it('renders an empty body when no entries are passed', () => { + const table = buildStabilityOverview([]); + + assert.equal(findChild(table, 'tbody').children.length, 0); + }); +}); diff --git a/src/generators/web-all/utils/__tests__/synthetic.test.mjs b/src/generators/web-all/utils/__tests__/synthetic.test.mjs new file mode 100644 index 00000000..cc224f16 --- /dev/null +++ b/src/generators/web-all/utils/__tests__/synthetic.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createSyntheticHead, wrapAsEntry } from '../synthetic.mjs'; + +describe('createSyntheticHead', () => { + it('derives api, path, and basename from the api slug', () => { + const head = createSyntheticHead('all', 'All'); + + assert.equal(head.api, 'all'); + assert.equal(head.path, '/all'); + assert.equal(head.basename, 'all'); + }); + + it('produces a depth-1 heading whose data is consistent with the name', () => { + const head = createSyntheticHead('index', 'Index'); + + assert.equal(head.heading.type, 'heading'); + assert.equal(head.heading.depth, 1); + assert.equal(head.heading.children[0].value, 'Index'); + assert.deepEqual(head.heading.data, { + name: 'Index', + text: 'Index', + slug: 'index', + }); + }); + + it('does not assign a heading type so no DataTag icon is rendered', () => { + const { heading } = createSyntheticHead('404', 'Page Not Found'); + + assert.equal(heading.data.type, undefined); + }); +}); + +describe('wrapAsEntry', () => { + it('places the head heading at the start of the content tree', () => { + const head = createSyntheticHead('foo', 'Foo'); + const paragraph = { type: 'paragraph', children: [] }; + + const entry = wrapAsEntry(head, [paragraph]); + + assert.equal(entry.content.type, 'root'); + assert.equal(entry.content.children[0], head.heading); + assert.equal(entry.content.children[1], paragraph); + }); + + it('marks the entry as stability-less so the metabar is not annotated', () => { + const entry = wrapAsEntry(createSyntheticHead('foo', 'Foo'), []); + + assert.equal(entry.stability, null); + }); + + it('forwards the api/path/basename from the head', () => { + const head = createSyntheticHead('foo', 'Foo'); + + const entry = wrapAsEntry(head, []); + + assert.equal(entry.api, head.api); + assert.equal(entry.path, head.path); + assert.equal(entry.basename, head.basename); + }); +}); diff --git a/src/generators/web-all/utils/all.mjs b/src/generators/web-all/utils/all.mjs new file mode 100644 index 00000000..2fee87d1 --- /dev/null +++ b/src/generators/web-all/utils/all.mjs @@ -0,0 +1,13 @@ +'use strict'; + +import { createSyntheticHead } from './synthetic.mjs'; + +/** + * Builds the page descriptor for `all.html` + * + * @param {Array} entries + */ +export const buildAllPage = entries => ({ + head: createSyntheticHead('all', 'All'), + entries, +}); diff --git a/src/generators/web-all/utils/index.mjs b/src/generators/web-all/utils/index.mjs new file mode 100644 index 00000000..a2cffba3 --- /dev/null +++ b/src/generators/web-all/utils/index.mjs @@ -0,0 +1,55 @@ +'use strict'; + +import { h as createElement } from 'hastscript'; + +import { createSyntheticHead, wrapAsEntry } from './synthetic.mjs'; + +/** + * Builds the Stability Overview table from module heads that declare a + * top-level stability index, mirroring the `legacy-html-all` overview. + * + * @param {Array} headEntries + */ +export const buildStabilityOverview = headEntries => + createElement('table', [ + createElement('thead', [ + createElement('tr', [ + createElement('th', 'API'), + createElement('th', 'Stability'), + ]), + ]), + createElement( + 'tbody', + headEntries.map(({ heading, api, stability }) => + createElement('tr', [ + createElement( + 'td', + createElement('a', { href: `${api}.html` }, heading.data.name) + ), + createElement( + 'td', + `(${stability.data.index}) ${stability.data.description.split('. ')[0]}` + ), + ]) + ) + ), + ]); + +/** + * Builds the page descriptor for `index.html` + * + * @param {Array} entries + */ +export const buildIndexPage = entries => { + const head = createSyntheticHead('index', 'Index'); + const moduleEntries = entries.filter(entry => entry.heading.depth === 1); + + return { + head, + entries: [ + wrapAsEntry(head, [ + buildStabilityOverview(moduleEntries.filter(entry => entry.stability)), + ]), + ], + }; +}; diff --git a/src/generators/web-all/utils/synthetic.mjs b/src/generators/web-all/utils/synthetic.mjs new file mode 100644 index 00000000..7e0d2c27 --- /dev/null +++ b/src/generators/web-all/utils/synthetic.mjs @@ -0,0 +1,37 @@ +'use strict'; + +import { u as createTree } from 'unist-builder'; + +/** + * Builds a metadata head shaped like the entries produced by the `metadata` + * generator. Used by pages that don't originate from a real markdown file + * (e.g. `all`, `index`, `404`). + * + * @param {string} api - File slug; doubles as the path and basename. + * @param {string} name - Heading display name. + */ +export const createSyntheticHead = (api, name) => ({ + api, + path: `/${api}`, + basename: api, + heading: { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: name }], + data: { name, text: name, slug: api }, + }, +}); + +/** + * Wraps a synthetic head into a full metadata-shaped entry by attaching a + * content tree. The head's heading is placed at the start of `content` so + * `buildContent`'s heading visit can transform it like any other entry. + * + * @param {ReturnType} head + * @param {Array} children + */ +export const wrapAsEntry = (head, children) => ({ + ...head, + stability: null, + content: createTree('root', [head.heading, ...children]), +}); diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index fab595e0..eba2fa3c 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -17,8 +17,15 @@ export async function generate(input) { const template = await readFile(config.templatePath, 'utf-8'); + // The `web-all` generator re-generates `index.html` with a stability + // overview, so skip the synthetic `index` entry here. + // + // TODO(@avivkeller): Once this lands in core, remove the `index.html` + // page from Core, then remove this check. + const entries = input.filter(entry => entry.data.api !== 'index'); + // Process all entries: convert JSX to HTML/CSS/JS - const { results, css, chunks } = await processJSXEntries(input, template); + const { results, css, chunks } = await processJSXEntries(entries, template); // Process all entries together (required for code-split bundles) if (config.output) { From cbf00f064bc104a25834eecfad81935c10f9c4b1 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Wed, 29 Apr 2026 18:28:25 -0400 Subject: [PATCH 3/4] fixup! --- .github/workflows/generate.yml | 3 - scripts/vercel-build.sh | 1 - src/generators/index.mjs | 2 - src/generators/jsx-ast/generate.mjs | 5 ++ src/generators/web-all/README.md | 23 ------- src/generators/web-all/generate.mjs | 63 ------------------- src/generators/web-all/index.mjs | 22 ------- src/generators/web-all/types.d.ts | 10 --- src/generators/web/README.md | 25 ++++---- src/generators/web/generate.mjs | 54 +++++++++++++--- src/generators/web/index.mjs | 3 + src/generators/web/types.d.ts | 5 +- .../utils => web/utils/synthetic}/404.mjs | 0 .../utils/synthetic}/__tests__/404.test.mjs | 0 .../utils/synthetic}/__tests__/all.test.mjs | 0 .../utils/synthetic}/__tests__/index.test.mjs | 0 .../synthetic}/__tests__/synthetic.test.mjs | 0 .../utils => web/utils/synthetic}/all.mjs | 2 +- .../utils => web/utils/synthetic}/index.mjs | 4 +- .../utils/synthetic}/synthetic.mjs | 0 20 files changed, 74 insertions(+), 148 deletions(-) delete mode 100644 src/generators/web-all/README.md delete mode 100644 src/generators/web-all/generate.mjs delete mode 100644 src/generators/web-all/index.mjs delete mode 100644 src/generators/web-all/types.d.ts rename src/generators/{web-all/utils => web/utils/synthetic}/404.mjs (100%) rename src/generators/{web-all/utils => web/utils/synthetic}/__tests__/404.test.mjs (100%) rename src/generators/{web-all/utils => web/utils/synthetic}/__tests__/all.test.mjs (100%) rename src/generators/{web-all/utils => web/utils/synthetic}/__tests__/index.test.mjs (100%) rename src/generators/{web-all/utils => web/utils/synthetic}/__tests__/synthetic.test.mjs (100%) rename src/generators/{web-all/utils => web/utils/synthetic}/all.mjs (75%) rename src/generators/{web-all/utils => web/utils/synthetic}/index.mjs (89%) rename src/generators/{web-all/utils => web/utils/synthetic}/synthetic.mjs (100%) diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 1191e49c..800313ba 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -106,9 +106,6 @@ jobs: input: './node/doc/api/*.md' compare: file-size - - target: web-all - input: './node/doc/api/*.md' - - target: llms-txt input: './node/doc/api/*.md' compare: file-size diff --git a/scripts/vercel-build.sh b/scripts/vercel-build.sh index 4af33684..e1575b07 100755 --- a/scripts/vercel-build.sh +++ b/scripts/vercel-build.sh @@ -3,7 +3,6 @@ node bin/cli.mjs generate \ -t legacy-json \ -t llms-txt \ -t web \ - -t web-all \ -i "./node/doc/api/*.md" \ -o "./out" \ -c "./node/CHANGELOG.md" \ diff --git a/src/generators/index.mjs b/src/generators/index.mjs index fbfd9be6..24294f92 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -16,7 +16,6 @@ import metadata from './metadata/index.mjs'; import oramaDb from './orama-db/index.mjs'; import sitemap from './sitemap/index.mjs'; import web from './web/index.mjs'; -import webAll from './web-all/index.mjs'; export const publicGenerators = { 'json-simple': jsonSimple, @@ -31,7 +30,6 @@ export const publicGenerators = { 'llms-txt': llmsTxt, sitemap, web, - 'web-all': webAll, }; // These ones are special since they don't produce standard output, diff --git a/src/generators/jsx-ast/generate.mjs b/src/generators/jsx-ast/generate.mjs index 614340ed..e350f67a 100644 --- a/src/generators/jsx-ast/generate.mjs +++ b/src/generators/jsx-ast/generate.mjs @@ -19,6 +19,11 @@ export async function processChunk(slicedInput, itemIndices) { const content = await buildContent(entries, head); + // Preserve the raw section entries so downstream generators (e.g. `web`) + // can build synthetic pages (all.html, index.html) without recomputing + // metadata. + content.sectionEntries = entries; + results.push(content); } diff --git a/src/generators/web-all/README.md b/src/generators/web-all/README.md deleted file mode 100644 index ad531e1c..00000000 --- a/src/generators/web-all/README.md +++ /dev/null @@ -1,23 +0,0 @@ -## `web-all` Generator - -The `web-all` generator complements `web` by producing the pages that the -per-module pipeline does not generate on its own: - -- `all.html`: every API module concatenated into a single page. Useful for - offline browsing and for environments where JavaScript is unavailable, since - the in-page searchbar shipped with `web` requires JS, so a single all-in-one - page makes browser-native text search (`Ctrl`+`F`) usable across the full - documentation set. -- `index.html`: a synthetic landing page listing every module alongside a - Stability Overview table. The `web` generator skips its `index` entry so - this file is the canonical index. -- `404.html`: a static not-found page wired into the same Layout, so hosts - serving the docs can use it as a fallback. - -### Configuring - -The `web-all` generator accepts the following configuration options: - -| Name | Type | Default | Description | -| -------------- | -------- | -------------------- | ------------------------------ | -| `templatePath` | `string` | Inherited from `web` | Path to the HTML template file | diff --git a/src/generators/web-all/generate.mjs b/src/generators/web-all/generate.mjs deleted file mode 100644 index f919840e..00000000 --- a/src/generators/web-all/generate.mjs +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; - -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { buildNotFoundPage } from './utils/404.mjs'; -import { buildAllPage } from './utils/all.mjs'; -import { buildIndexPage } from './utils/index.mjs'; -import getConfig from '../../utils/configuration/index.mjs'; -import { writeFile } from '../../utils/file.mjs'; -import buildContent from '../jsx-ast/utils/buildContent.mjs'; -import { processJSXEntries } from '../web/utils/processing.mjs'; - -/** - * Generates the `web-all` output: `all.html`, `index.html`, and `404.html`. - * - * @type {import('./types').Generator['generate']} - */ -export async function generate(input) { - const config = getConfig('web-all'); - const template = await readFile(config.templatePath, 'utf-8'); - - // Drop the synthetic `index` entry — we re-generate `index.html` ourselves. - const entries = input.filter(entry => entry.api !== 'index'); - - const pages = [ - buildAllPage(entries), - buildIndexPage(entries), - buildNotFoundPage(), - ]; - - const jsxContents = await Promise.all( - pages.map(({ head, entries: pageEntries }) => - buildContent(pageEntries, head) - ) - ); - - // Sidebar still needs to link to every module page produced by `web`. - const sidebarEntries = entries.map(entry => ({ data: entry })); - - const { results, css, chunks } = await processJSXEntries( - jsxContents, - template, - sidebarEntries - ); - - if (config.output) { - for (const { html, path } of results) { - await writeFile(join(config.output, `${path}.html`), html, 'utf-8'); - } - - for (const chunk of chunks) { - await writeFile(join(config.output, chunk.fileName), chunk.code, 'utf-8'); - } - - await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); - } - - return { - html: results.map(({ html }) => html.toString()), - css, - }; -} diff --git a/src/generators/web-all/index.mjs b/src/generators/web-all/index.mjs deleted file mode 100644 index dfa6f19c..00000000 --- a/src/generators/web-all/index.mjs +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -import { createLazyGenerator } from '../../utils/generators.mjs'; -import web from '../web/index.mjs'; - -/** - * - * @type {import('./types').Generator} - */ -export default createLazyGenerator({ - name: 'web-all', - - version: '1.0.0', - - description: 'Generates the additional files from the `web` generator', - - dependsOn: 'metadata', - - defaultConfiguration: { - templatePath: web.defaultConfiguration.templatePath, - }, -}); diff --git a/src/generators/web-all/types.d.ts b/src/generators/web-all/types.d.ts deleted file mode 100644 index 2435dfd7..00000000 --- a/src/generators/web-all/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MetadataEntry } from '../metadata/types'; - -export type Configuration = { - templatePath: string; -}; - -export type Generator = GeneratorMetadata< - Configuration, - Generate, Promise<{ html: Array; css: string }>> ->; diff --git a/src/generators/web/README.md b/src/generators/web/README.md index 17f8e138..8a2c3993 100644 --- a/src/generators/web/README.md +++ b/src/generators/web/README.md @@ -6,17 +6,20 @@ 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}`) | -| `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 | +| 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 | +| `generateAllPage` | `boolean` | `true` | When `true`, emits `all.html` containing every module's content | +| `generateIndexPage` | `boolean` | `true` | When `true`, emits `index.html` with a stability overview | +| `generateNotFoundPage` | `boolean` | `true` | When `true`, emits `404.html` | +| `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` diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index eba2fa3c..11a008c7 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -4,12 +4,20 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { processJSXEntries } from './utils/processing.mjs'; +import { buildNotFoundPage } from './utils/synthetic/404.mjs'; +import { buildAllPage } from './utils/synthetic/all.mjs'; +import { buildIndexPage } from './utils/synthetic/index.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { writeFile } from '../../utils/file.mjs'; +import buildContent from '../jsx-ast/utils/buildContent.mjs'; /** * Main generation function that processes JSX AST entries into web bundles. * + * Generates the regular per-module pages plus, when enabled by configuration, + * the synthetic `all`, `index`, and `404` pages. Everything is bundled in a + * single pass so shared component chunks and CSS are produced once. + * * @type {import('./types').Generator['generate']} */ export async function generate(input) { @@ -17,29 +25,57 @@ export async function generate(input) { const template = await readFile(config.templatePath, 'utf-8'); - // The `web-all` generator re-generates `index.html` with a stability - // overview, so skip the synthetic `index` entry here. + // The synthetic `index` entry from Core is replaced by our own + // `index.html` (with stability overview) when `generateIndexPage` is on. // // TODO(@avivkeller): Once this lands in core, remove the `index.html` // page from Core, then remove this check. - const entries = input.filter(entry => entry.data.api !== 'index'); + const moduleEntries = input.filter(entry => entry.data.api !== 'index'); + + // Reconstruct the flat metadata list from the per-module section entries + // attached by `jsx-ast`. Used to build the synthetic `all` and `index` + // pages without a separate `metadata` dependency. + const synthDescriptors = []; + + if (config.generateAllPage || config.generateIndexPage) { + const metadata = moduleEntries.flatMap(entry => entry.sectionEntries); + + if (config.generateAllPage) { + synthDescriptors.push(buildAllPage(metadata)); + } + if (config.generateIndexPage) { + synthDescriptors.push(buildIndexPage(metadata)); + } + } + + if (config.generateNotFoundPage) { + synthDescriptors.push(buildNotFoundPage()); + } + + const syntheticEntries = await Promise.all( + synthDescriptors.map(({ head, entries }) => buildContent(entries, head)) + ); + + // Sidebar lists only the real module pages. + const sidebarEntries = moduleEntries.map(entry => ({ data: entry.data })); + + const allEntries = [...moduleEntries, ...syntheticEntries]; - // Process all entries: convert JSX to HTML/CSS/JS - const { results, css, chunks } = await processJSXEntries(entries, template); + const { results, css, chunks } = await processJSXEntries( + allEntries, + template, + sidebarEntries + ); - // Process all entries together (required for code-split bundles) if (config.output) { - // Write HTML files for (const { html, path } of results) { await writeFile(join(config.output, `${path}.html`), html, 'utf-8'); } - // Write code-split JavaScript chunks for (const chunk of chunks) { await writeFile(join(config.output, chunk.fileName), chunk.code, 'utf-8'); } - // Write CSS bundle await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); } diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 7f86ba9b..90b5dc56 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -35,6 +35,9 @@ export default createLazyGenerator({ editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`, pageURL: '{baseURL}/latest-{version}/api{path}.html', remoteConfigUrl: 'https://nodejs.org/site.json', + generateAllPage: true, + generateIndexPage: true, + generateNotFoundPage: true, imports: { '#theme/Logo': '@node-core/ui-components/Common/NodejsLogo', '#theme/Navigation': join(import.meta.dirname, './ui/components/NavBar'), diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index a2b61ec9..4eddd223 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -4,11 +4,14 @@ export type Configuration = { templatePath: string; title: string; useAbsoluteURLs: boolean; + generateAllPage: boolean; + generateIndexPage: boolean; + generateNotFoundPage: boolean; imports: Record; virtualImports: Record; }; export type Generator = GeneratorMetadata< Configuration, - Generate, AsyncGenerator<{ html: string; css: string }>> + Generate, Promise>> >; diff --git a/src/generators/web-all/utils/404.mjs b/src/generators/web/utils/synthetic/404.mjs similarity index 100% rename from src/generators/web-all/utils/404.mjs rename to src/generators/web/utils/synthetic/404.mjs diff --git a/src/generators/web-all/utils/__tests__/404.test.mjs b/src/generators/web/utils/synthetic/__tests__/404.test.mjs similarity index 100% rename from src/generators/web-all/utils/__tests__/404.test.mjs rename to src/generators/web/utils/synthetic/__tests__/404.test.mjs diff --git a/src/generators/web-all/utils/__tests__/all.test.mjs b/src/generators/web/utils/synthetic/__tests__/all.test.mjs similarity index 100% rename from src/generators/web-all/utils/__tests__/all.test.mjs rename to src/generators/web/utils/synthetic/__tests__/all.test.mjs diff --git a/src/generators/web-all/utils/__tests__/index.test.mjs b/src/generators/web/utils/synthetic/__tests__/index.test.mjs similarity index 100% rename from src/generators/web-all/utils/__tests__/index.test.mjs rename to src/generators/web/utils/synthetic/__tests__/index.test.mjs diff --git a/src/generators/web-all/utils/__tests__/synthetic.test.mjs b/src/generators/web/utils/synthetic/__tests__/synthetic.test.mjs similarity index 100% rename from src/generators/web-all/utils/__tests__/synthetic.test.mjs rename to src/generators/web/utils/synthetic/__tests__/synthetic.test.mjs diff --git a/src/generators/web-all/utils/all.mjs b/src/generators/web/utils/synthetic/all.mjs similarity index 75% rename from src/generators/web-all/utils/all.mjs rename to src/generators/web/utils/synthetic/all.mjs index 2fee87d1..a25894ed 100644 --- a/src/generators/web-all/utils/all.mjs +++ b/src/generators/web/utils/synthetic/all.mjs @@ -5,7 +5,7 @@ import { createSyntheticHead } from './synthetic.mjs'; /** * Builds the page descriptor for `all.html` * - * @param {Array} entries + * @param {Array} entries */ export const buildAllPage = entries => ({ head: createSyntheticHead('all', 'All'), diff --git a/src/generators/web-all/utils/index.mjs b/src/generators/web/utils/synthetic/index.mjs similarity index 89% rename from src/generators/web-all/utils/index.mjs rename to src/generators/web/utils/synthetic/index.mjs index a2cffba3..86b55524 100644 --- a/src/generators/web-all/utils/index.mjs +++ b/src/generators/web/utils/synthetic/index.mjs @@ -8,7 +8,7 @@ import { createSyntheticHead, wrapAsEntry } from './synthetic.mjs'; * Builds the Stability Overview table from module heads that declare a * top-level stability index, mirroring the `legacy-html-all` overview. * - * @param {Array} headEntries + * @param {Array} headEntries */ export const buildStabilityOverview = headEntries => createElement('table', [ @@ -38,7 +38,7 @@ export const buildStabilityOverview = headEntries => /** * Builds the page descriptor for `index.html` * - * @param {Array} entries + * @param {Array} entries */ export const buildIndexPage = entries => { const head = createSyntheticHead('index', 'Index'); diff --git a/src/generators/web-all/utils/synthetic.mjs b/src/generators/web/utils/synthetic/synthetic.mjs similarity index 100% rename from src/generators/web-all/utils/synthetic.mjs rename to src/generators/web/utils/synthetic/synthetic.mjs From 468b6ce8f4fd83d47349e648390b9206a166bd75 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Mon, 4 May 2026 16:37:53 -0400 Subject: [PATCH 4/4] fixup! --- src/generators/web/constants.mjs | 4 +++ src/generators/web/utils/synthetic/404.mjs | 11 +++++++- .../utils/synthetic/__tests__/404.test.mjs | 5 ++++ .../utils/synthetic/__tests__/index.test.mjs | 25 +++++++++++++++-- src/generators/web/utils/synthetic/index.mjs | 27 ++++++++++++++++++- 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 5cbfe760..b78fd82c 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -49,6 +49,10 @@ export const JSX_IMPORTS = { name: 'AlertBox', source: '@node-core/ui-components/Common/AlertBox', }, + Badge: { + name: 'Badge', + source: '@node-core/ui-components/Common/Badge', + }, Blockquote: { name: 'Blockquote', source: '@node-core/ui-components/Common/Blockquote', diff --git a/src/generators/web/utils/synthetic/404.mjs b/src/generators/web/utils/synthetic/404.mjs index f11295c8..a543d415 100644 --- a/src/generators/web/utils/synthetic/404.mjs +++ b/src/generators/web/utils/synthetic/404.mjs @@ -18,7 +18,16 @@ export const buildNotFoundPage = () => { { type: 'text', value: - 'The page you requested could not be found. Use the navigation to find the documentation you are looking for.', + 'The page you requested could not be found. Use the navigation to find the documentation you are looking for, or return to the ', + }, + { + type: 'link', + url: 'index.html', + children: [{ type: 'text', value: 'API index' }], + }, + { + type: 'text', + value: '.', }, ], }, diff --git a/src/generators/web/utils/synthetic/__tests__/404.test.mjs b/src/generators/web/utils/synthetic/__tests__/404.test.mjs index c44584f5..4ef7435f 100644 --- a/src/generators/web/utils/synthetic/__tests__/404.test.mjs +++ b/src/generators/web/utils/synthetic/__tests__/404.test.mjs @@ -24,6 +24,11 @@ describe('buildNotFoundPage', () => { assert.ok(paragraph, 'expected a paragraph node in the content tree'); assert.match(paragraph.children[0].value, /could not be found/); + + const link = paragraph.children.find(child => child.type === 'link'); + + assert.equal(link.url, 'index.html'); + assert.equal(link.children[0].value, 'API index'); }); it('places the head heading at the start of the content tree', () => { diff --git a/src/generators/web/utils/synthetic/__tests__/index.test.mjs b/src/generators/web/utils/synthetic/__tests__/index.test.mjs index b0d417d3..485ca32c 100644 --- a/src/generators/web/utils/synthetic/__tests__/index.test.mjs +++ b/src/generators/web/utils/synthetic/__tests__/index.test.mjs @@ -37,13 +37,34 @@ describe('buildStabilityOverview', () => { assert.equal(findChild(table, 'tbody').children.length, 2); }); - it('formats the stability cell as `(index) `', () => { + it('formats the stability cell with a colored badge and first sentence', () => { const table = buildStabilityOverview([fakeHead('fs', 'fs', 1)]); const row = findChild(table, 'tbody').children[0]; const stabilityCell = row.children[1]; + const badge = stabilityCell.children[0]; - assert.equal(stabilityCell.children[0].value, '(1) fs stable'); + assert.equal(badge.name, 'Badge'); + assert.deepEqual( + badge.attributes.map(({ name, value }) => [name, value]), + [ + ['size', 'small'], + ['kind', 'warning'], + ['aria-label', 'Stability: 1'], + ] + ); + assert.equal(badge.children[0].value, '1'); + assert.equal(stabilityCell.children[1].value, ' fs stable'); + }); + + it('uses a default badge for stable entries', () => { + const table = buildStabilityOverview([fakeHead('fs', 'fs', 2)]); + + const row = findChild(table, 'tbody').children[0]; + const badge = row.children[1].children[0]; + const kind = badge.attributes.find(attr => attr.name === 'kind'); + + assert.equal(kind.value, 'default'); }); it('builds a relative link to the module HTML page', () => { diff --git a/src/generators/web/utils/synthetic/index.mjs b/src/generators/web/utils/synthetic/index.mjs index 86b55524..07a50f3b 100644 --- a/src/generators/web/utils/synthetic/index.mjs +++ b/src/generators/web/utils/synthetic/index.mjs @@ -3,6 +3,25 @@ import { h as createElement } from 'hastscript'; import { createSyntheticHead, wrapAsEntry } from './synthetic.mjs'; +import { createJSXElement } from '../../../jsx-ast/utils/ast.mjs'; +import { JSX_IMPORTS } from '../../constants.mjs'; + +const STABILITY_BADGE_KINDS = [ + 'error', + 'warning', + 'default', + 'info', + 'neutral', + 'neutral', +]; + +/** + * Maps a Node.js stability index to a UI badge kind. + * + * @param {string} index + */ +const getStabilityBadgeKind = index => + STABILITY_BADGE_KINDS[parseInt(index, 10)] ?? 'neutral'; /** * Builds the Stability Overview table from module heads that declare a @@ -28,7 +47,13 @@ export const buildStabilityOverview = headEntries => ), createElement( 'td', - `(${stability.data.index}) ${stability.data.description.split('. ')[0]}` + createJSXElement(JSX_IMPORTS.Badge.name, { + size: 'small', + kind: getStabilityBadgeKind(stability.data.index), + 'aria-label': `Stability: ${stability.data.index}`, + children: stability.data.index, + }), + ` ${stability.data.description.split('. ')[0]}` ), ]) )