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/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/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/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/generate.mjs b/src/generators/web/generate.mjs index fab595e0..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,22 +25,57 @@ export async function generate(input) { const template = await readFile(config.templatePath, 'utf-8'); - // Process all entries: convert JSX to HTML/CSS/JS - const { results, css, chunks } = await processJSXEntries(input, template); + // 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 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]; + + 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/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 diff --git a/src/generators/web/utils/synthetic/404.mjs b/src/generators/web/utils/synthetic/404.mjs new file mode 100644 index 00000000..a543d415 --- /dev/null +++ b/src/generators/web/utils/synthetic/404.mjs @@ -0,0 +1,37 @@ +'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, 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 new file mode 100644 index 00000000..4ef7435f --- /dev/null +++ b/src/generators/web/utils/synthetic/__tests__/404.test.mjs @@ -0,0 +1,47 @@ +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/); + + 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', () => { + 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/utils/synthetic/__tests__/all.test.mjs b/src/generators/web/utils/synthetic/__tests__/all.test.mjs new file mode 100644 index 00000000..d54de38b --- /dev/null +++ b/src/generators/web/utils/synthetic/__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/utils/synthetic/__tests__/index.test.mjs b/src/generators/web/utils/synthetic/__tests__/index.test.mjs new file mode 100644 index 00000000..485ca32c --- /dev/null +++ b/src/generators/web/utils/synthetic/__tests__/index.test.mjs @@ -0,0 +1,86 @@ +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 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(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', () => { + 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/utils/synthetic/__tests__/synthetic.test.mjs b/src/generators/web/utils/synthetic/__tests__/synthetic.test.mjs new file mode 100644 index 00000000..cc224f16 --- /dev/null +++ b/src/generators/web/utils/synthetic/__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/utils/synthetic/all.mjs b/src/generators/web/utils/synthetic/all.mjs new file mode 100644 index 00000000..a25894ed --- /dev/null +++ b/src/generators/web/utils/synthetic/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/utils/synthetic/index.mjs b/src/generators/web/utils/synthetic/index.mjs new file mode 100644 index 00000000..07a50f3b --- /dev/null +++ b/src/generators/web/utils/synthetic/index.mjs @@ -0,0 +1,80 @@ +'use strict'; + +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 + * 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', + 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]}` + ), + ]) + ) + ), + ]); + +/** + * 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/utils/synthetic/synthetic.mjs b/src/generators/web/utils/synthetic/synthetic.mjs new file mode 100644 index 00000000..7e0d2c27 --- /dev/null +++ b/src/generators/web/utils/synthetic/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]), +});