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/README.md b/src/generators/jsx-ast/README.md index a0c0492a..fcc902a5 100644 --- a/src/generators/jsx-ast/README.md +++ b/src/generators/jsx-ast/README.md @@ -6,7 +6,10 @@ The `jsx-ast` generator converts MDAST (Markdown Abstract Syntax Tree) to JSX AS The `jsx-ast` generator accepts the following configuration options: -| Name | Type | Default | Description | -| ------- | -------- | -------- | ------------------------------------------------------------------------ | -| `ref` | `string` | `'main'` | Git reference/branch for linking to source files | -| `index` | `array` | - | Array of `{ section, api }` objects defining the documentation structure | +| Name | Type | Default | Description | +| ---------------------- | --------- | -------- | ------------------------------------------------------------------------ | +| `ref` | `string` | `'main'` | Git reference/branch for linking to source files | +| `index` | `array` | - | Array of `{ section, api }` objects defining the documentation structure | +| `generateAllPage` | `boolean` | `true` | When `true`, creates a synthetic JSX AST entry for `all.html` | +| `generateIndexPage` | `boolean` | `true` | When `true`, creates a synthetic JSX AST entry for `index.html` | +| `generateNotFoundPage` | `boolean` | `true` | When `true`, creates a synthetic JSX AST entry for `404.html` | diff --git a/src/generators/jsx-ast/__tests__/generate.test.mjs b/src/generators/jsx-ast/__tests__/generate.test.mjs new file mode 100644 index 00000000..c1822024 --- /dev/null +++ b/src/generators/jsx-ast/__tests__/generate.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import getConfig, { setConfig } from '../../../utils/configuration/index.mjs'; +import { generate, processChunk } from '../generate.mjs'; + +const createEntry = (api, name, { stabilityIndex = '2' } = {}) => { + const heading = { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: name }], + data: { name, text: name, slug: api }, + }; + + return { + api, + path: `/${api}`, + basename: api, + heading, + stability: + stabilityIndex == null + ? null + : { + data: { + index: stabilityIndex, + description: `${name} stable. Longer description.`, + }, + }, + content: { + type: 'root', + children: [ + heading, + { + type: 'paragraph', + children: [{ type: 'text', value: `${name} body` }], + }, + ], + }, + }; +}; + +const collect = async generator => { + const results = []; + + for await (const chunk of generator) { + results.push(...chunk); + } + + return results; +}; + +const createWorker = seenItems => ({ + async *stream(items) { + seenItems.push(...items); + yield items.map(({ head }) => ({ type: 'JSXElement', data: head })); + }, +}); + +describe('jsx-ast generate', () => { + it('does not attach raw section entries to regular JSX content', async () => { + await setConfig({}); + + const fs = createEntry('fs', 'File system'); + const [content] = await processChunk([{ head: fs, entries: [fs] }], [0]); + + assert.equal(content.data.api, 'fs'); + assert.equal('sectionEntries' in content, false); + }); + + it('respects jsx-ast synthetic page flags', async () => { + await setConfig({}); + + const jsxAstConfig = getConfig('jsx-ast'); + jsxAstConfig.generateAllPage = false; + jsxAstConfig.generateIndexPage = false; + jsxAstConfig.generateNotFoundPage = false; + + const seenItems = []; + const results = await collect( + generate( + [createEntry('index', 'Index'), createEntry('fs', 'File system')], + createWorker(seenItems) + ) + ); + + assert.deepEqual( + seenItems.map(({ head }) => head.api), + ['index', 'fs'] + ); + assert.deepEqual( + results.map(({ data }) => data.api), + ['index', 'fs'] + ); + }); +}); diff --git a/src/generators/jsx-ast/generate.mjs b/src/generators/jsx-ast/generate.mjs index 614340ed..bd268021 100644 --- a/src/generators/jsx-ast/generate.mjs +++ b/src/generators/jsx-ast/generate.mjs @@ -1,7 +1,30 @@ import buildContent from './utils/buildContent.mjs'; import { getSortedHeadNodes } from './utils/getSortedHeadNodes.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 { groupNodesByModule } from '../../utils/generators.mjs'; +/** + * Builds JSX content for all configured synthetic pages. + * + * @param {Array} input + */ +const buildSyntheticEntries = async input => { + const config = getConfig('jsx-ast'); + + const descriptors = [ + config.generateAllPage && buildAllPage(input), + config.generateIndexPage && buildIndexPage(input), + config.generateNotFoundPage && buildNotFoundPage(), + ].filter(Boolean); + + return Promise.all( + descriptors.map(({ head, entries }) => buildContent(entries, head)) + ); +}; + /** * Process a chunk of items in a worker thread. * Transforms metadata entries into JSX AST nodes. @@ -31,13 +54,13 @@ export async function processChunk(slicedInput, itemIndices) { * @type {import('./types').Generator['generate']} */ export async function* generate(input, worker) { - const groupedModules = groupNodesByModule(input); - - const headNodes = getSortedHeadNodes(input); + // The synthetic `index` page replaces the Core `index` document. + const moduleInput = input.filter(entry => entry.api !== 'index'); // Create sliced input: each item contains head + its module's entries // This avoids sending all 4700+ entries to every worker - const entries = headNodes.map(head => ({ + const groupedModules = groupNodesByModule(input); + const entries = getSortedHeadNodes(input).map(head => ({ head, entries: groupedModules.get(head.api), })); @@ -45,4 +68,10 @@ export async function* generate(input, worker) { for await (const chunkResult of worker.stream(entries)) { yield chunkResult; } + + const syntheticEntries = await buildSyntheticEntries(moduleInput); + + if (syntheticEntries.length > 0) { + yield syntheticEntries; + } } diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index da61cb56..d6711061 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -18,6 +18,9 @@ export default createLazyGenerator({ defaultConfiguration: { ref: 'main', + generateAllPage: true, + generateIndexPage: true, + generateNotFoundPage: true, }, hasParallelProcessor: true, diff --git a/src/generators/jsx-ast/types.d.ts b/src/generators/jsx-ast/types.d.ts index c6576d57..672c5a4f 100644 --- a/src/generators/jsx-ast/types.d.ts +++ b/src/generators/jsx-ast/types.d.ts @@ -3,8 +3,10 @@ import type { JSXContent } from './utils/buildContent.mjs'; export type Generator = GeneratorMetadata< { - pageURL: string; - editURL: string; + ref: string; + generateAllPage: boolean; + generateIndexPage: boolean; + generateNotFoundPage: boolean; }, Generate, AsyncGenerator>, ProcessChunk< diff --git a/src/generators/jsx-ast/utils/synthetic/404.mjs b/src/generators/jsx-ast/utils/synthetic/404.mjs new file mode 100644 index 00000000..a543d415 --- /dev/null +++ b/src/generators/jsx-ast/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/jsx-ast/utils/synthetic/__tests__/404.test.mjs b/src/generators/jsx-ast/utils/synthetic/__tests__/404.test.mjs new file mode 100644 index 00000000..ab006639 --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/__tests__/404.test.mjs @@ -0,0 +1,48 @@ +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'); + assert.equal(head.synthetic, true); + }); + + 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/jsx-ast/utils/synthetic/__tests__/all.test.mjs b/src/generators/jsx-ast/utils/synthetic/__tests__/all.test.mjs new file mode 100644 index 00000000..4c3daf4d --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/__tests__/all.test.mjs @@ -0,0 +1,33 @@ +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'); + assert.equal(head.synthetic, true); + }); + + 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/jsx-ast/utils/synthetic/__tests__/index.test.mjs b/src/generators/jsx-ast/utils/synthetic/__tests__/index.test.mjs new file mode 100644 index 00000000..8d9fc494 --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/__tests__/index.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildIndexPage, 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('buildIndexPage', () => { + it('returns a synthetic `index` head with an "Index" heading', () => { + const { head } = buildIndexPage([]); + + assert.equal(head.api, 'index'); + assert.equal(head.path, '/index'); + assert.equal(head.basename, 'index'); + assert.equal(head.heading.data.name, 'Index'); + assert.equal(head.synthetic, true); + }); +}); + +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/jsx-ast/utils/synthetic/__tests__/synthetic.test.mjs b/src/generators/jsx-ast/utils/synthetic/__tests__/synthetic.test.mjs new file mode 100644 index 00000000..e940239b --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/__tests__/synthetic.test.mjs @@ -0,0 +1,69 @@ +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('marks synthetic heads so web UI can treat them differently', () => { + const head = createSyntheticHead('index', 'Index'); + + assert.equal(head.synthetic, true); + }); + + 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); + assert.equal(entry.synthetic, true); + }); +}); diff --git a/src/generators/jsx-ast/utils/synthetic/all.mjs b/src/generators/jsx-ast/utils/synthetic/all.mjs new file mode 100644 index 00000000..a25894ed --- /dev/null +++ b/src/generators/jsx-ast/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/jsx-ast/utils/synthetic/index.mjs b/src/generators/jsx-ast/utils/synthetic/index.mjs new file mode 100644 index 00000000..fbe9ccf2 --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/index.mjs @@ -0,0 +1,80 @@ +'use strict'; + +import { h as createElement } from 'hastscript'; + +import { createSyntheticHead, wrapAsEntry } from './synthetic.mjs'; +import { JSX_IMPORTS } from '../../../web/constants.mjs'; +import { createJSXElement } from '../ast.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/jsx-ast/utils/synthetic/synthetic.mjs b/src/generators/jsx-ast/utils/synthetic/synthetic.mjs new file mode 100644 index 00000000..7067b27a --- /dev/null +++ b/src/generators/jsx-ast/utils/synthetic/synthetic.mjs @@ -0,0 +1,38 @@ +'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, + synthetic: true, + 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/metadata/types.d.ts b/src/generators/metadata/types.d.ts index 443b7bea..b3abe844 100644 --- a/src/generators/metadata/types.d.ts +++ b/src/generators/metadata/types.d.ts @@ -126,6 +126,8 @@ export interface MetadataEntry extends YAMLProperties { api: string; path: string; // Note: this is extensionless basename: string; + /** Whether this page was generated by a downstream generator. */ + synthetic?: boolean; /** Processed heading with metadata */ heading: HeadingNode; /** Stability classification information */ diff --git a/src/generators/web/__tests__/generate.test.mjs b/src/generators/web/__tests__/generate.test.mjs new file mode 100644 index 00000000..c4ba50db --- /dev/null +++ b/src/generators/web/__tests__/generate.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { setConfig } from '../../../utils/configuration/index.mjs'; +import buildContent from '../../jsx-ast/utils/buildContent.mjs'; +import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs'; +import { generate } from '../generate.mjs'; + +const createEntry = (api, name) => { + const heading = { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: name }], + data: { name, text: name, slug: api }, + }; + + return { + api, + path: `/${api}`, + basename: api, + heading, + stability: null, + content: { + type: 'root', + children: [ + heading, + { + type: 'paragraph', + children: [{ type: 'text', value: `${name} body` }], + }, + ], + }, + }; +}; + +describe('web generate', () => { + it('omits View As links for synthetic pages only', async () => { + await setConfig({}); + + const fs = createEntry('fs', 'File system'); + const notFoundPage = buildNotFoundPage(); + const input = await Promise.all([ + buildContent([fs], fs), + buildContent(notFoundPage.entries, notFoundPage.head), + ]); + + const [fsPage, notFoundResult] = await generate(input); + + assert.match(fsPage.html, /View As/); + assert.match(fsPage.html, /href=fs\.json/); + assert.match(fsPage.html, /href=fs\.md/); + + assert.doesNotMatch(notFoundResult.html, /View As/); + assert.doesNotMatch(notFoundResult.html, /href=404\.json/); + assert.doesNotMatch(notFoundResult.html, /href=404\.md/); + }); +}); 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..7ec803ef 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -10,6 +10,9 @@ import { writeFile } from '../../utils/file.mjs'; /** * Main generation function that processes JSX AST entries into web bundles. * + * Bundles all JSX AST entries 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 +20,24 @@ 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); + // Sidebar lists only the real module pages. + const sidebarEntries = input.filter(entry => entry.data.synthetic !== true); + + const { results, css, chunks } = await processJSXEntries( + input, + 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/types.d.ts b/src/generators/web/types.d.ts index a2b61ec9..0506b4be 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -10,5 +10,5 @@ export type Configuration = { export type Generator = GeneratorMetadata< Configuration, - Generate, AsyncGenerator<{ html: string; css: string }>> + Generate, Promise>> >; diff --git a/src/generators/web/ui/components/MetaBar/index.jsx b/src/generators/web/ui/components/MetaBar/index.jsx index cc640f98..261498ea 100644 --- a/src/generators/web/ui/components/MetaBar/index.jsx +++ b/src/generators/web/ui/components/MetaBar/index.jsx @@ -71,7 +71,7 @@ export default ({ metadata, headings = [], readingTime }) => { items={{ 'Reading Time': readingTime, 'Added In': metadata.added ?? metadata.introduced_in, - 'View As': ( + 'View As': !metadata.synthetic && (
    {viewAs.map(([viewTitle, path]) => { const Icon = iconMap[viewTitle]; @@ -88,7 +88,7 @@ export default ({ metadata, headings = [], readingTime }) => { })}
), - Contribute: ( + Contribute: !metadata.synthetic && ( <> 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