diff --git a/.changeset/html-all.md b/.changeset/html-all.md new file mode 100644 index 000000000..6159578fb --- /dev/null +++ b/.changeset/html-all.md @@ -0,0 +1,7 @@ +--- +'@doc-kit/core': minor +'@doc-kit/generator-react': minor +'@node-core/doc-kit': minor +--- + +`all.html` moves out of the `html` generator into a new Node.js generator, `html-all` (`@node-core/doc-kit`), and `html`'s `generateAllPage` option is removed. `html-all` is spliced between `jsx-ast` and `html`, and adds `all.html`, every module page's content on one page, as a page made of theirs. `html` renders such a page, given with `parts`, the pages it is made of, from their compiled content, one part at a time rather than building every part's elements first. Builds that want `all.html` add the `html-all` target. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 161032523..f2c83c26d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: path: node - name: Build Node.js API docs - run: node packages/cli/bin/cli.mjs generate -t web -i "./node/doc/api/assert.md" -o out --config-file ./e2e/doc-kit.config.mjs + run: node packages/cli/bin/cli.mjs generate -t web -t html-all -i "./node/doc/api/assert.md" -o out --config-file ./e2e/doc-kit.config.mjs - name: Install Playwright browsers run: pnpm exec playwright install --with-deps diff --git a/docs/generators.md b/docs/generators.md index ab7270bb7..2f757616f 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -48,6 +48,7 @@ Outputs consumed by the Node.js project's own build and release processes. | [`man-page`](./generators/man-page.md) | The `node.1` manual page. | | [`api-links`](./generators/api-links.md) | A map of API symbols to their source locations. | | [`addon-verify`](./generators/addon-verify.md) | Extracted addon code samples, arranged for testing. | +| [`html-all`](./generators/html-all.md) | `all.html`, every module page on one page. | ## Pipeline stages diff --git a/packages/core/src/generators/index.mjs b/packages/core/src/generators/index.mjs index 9ddae6619..92c30b4e5 100644 --- a/packages/core/src/generators/index.mjs +++ b/packages/core/src/generators/index.mjs @@ -15,6 +15,7 @@ export const publicGenerators = { 'json-simple': '@doc-kit/core/json-simple', 'legacy-html': '@node-core/doc-kit-legacy/legacy-html', 'legacy-html-all': '@node-core/doc-kit-legacy/legacy-html-all', + 'html-all': '@node-core/doc-kit/html-all', 'man-page': '@node-core/doc-kit/man-page', 'legacy-json': '@node-core/doc-kit-legacy/legacy-json', 'legacy-json-all': '@node-core/doc-kit-legacy/legacy-json-all', diff --git a/packages/node/README.md b/packages/node/README.md index 0de946314..dac631c06 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -20,6 +20,7 @@ npm install --save-dev @doc-kit/core @node-core/doc-kit | `man-page` | The `node.1` manual page, from the CLI options documented in `cli.md`. | | `api-links` | A mapping of API symbols to their source locations, powering source links on nodejs.org. | | `addon-verify` | Extracted C++ addon code samples from `addons.md`, arranged for compilation by the tests. | +| `html-all` | `all.html`, every module page's content on one page, rendered by the `html` generator. | ## Usage diff --git a/packages/node/package.json b/packages/node/package.json index b2c92ea16..c2aeb67ba 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -13,6 +13,7 @@ "./addon-verify": "./src/addon-verify/index.mjs", "./api-links": "./src/api-links/index.mjs", "./config": "./src/config/index.mjs", + "./html-all": "./src/html-all/index.mjs", "./man-page": "./src/man-page/index.mjs", "./package.json": "./package.json" }, @@ -26,6 +27,7 @@ ], "dependencies": { "@doc-kit/core": "workspace:*", + "@doc-kit/generator-react": "workspace:*", "estree-util-visit": "^2.0.0", "unist-util-visit": "^5.2.0" }, diff --git a/packages/node/src/html-all/README.md b/packages/node/src/html-all/README.md new file mode 100644 index 000000000..e91823c60 --- /dev/null +++ b/packages/node/src/html-all/README.md @@ -0,0 +1,19 @@ +# `html-all` Generator + +The `html-all` generator adds `all.html`, every module page's content on one +page, to the pages the [`html`](./html.md) generator renders. It builds nothing +itself: it is spliced between `jsx-ast` and `html`, passes the pages on, adds +`all.html`'s metadata to the manifest, and once every module page has passed, +adds a page made of theirs. `html` assembles it from their compiled content, +so no module is built twice. + +The module pages are its parts, in the order the sidebar lists them; the +index, section pages and the other synthetic pages are left out. + +```sh +npx @doc-kit/cli generate -t web -t html-all -i "doc/api/*.md" -o out +``` + +## Configuring + +It has no options of its own. diff --git a/packages/node/src/html-all/__tests__/generate.test.mjs b/packages/node/src/html-all/__tests__/generate.test.mjs new file mode 100644 index 000000000..633a30a7c --- /dev/null +++ b/packages/node/src/html-all/__tests__/generate.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { generate } from '../generate.mjs'; + +/** + * A page as `jsx-ast` gives it. + * + * @param {string} api + * @param {string} name + * @param {object} [extra] - More of its metadata + */ +const createPage = (api, name, extra = {}) => ({ + data: { api, heading: { depth: 1, data: { name } }, ...extra }, + headings: [{ slug: api }], + content: '<>', +}); + +/** + * Reads every batch the generator yields. + * + * @param {AsyncIterable> | Array} input + */ +const run = async input => { + const batches = []; + + for await (const batch of generate(input)) { + batches.push(batch); + } + + return batches; +}; + +describe('html-all', () => { + const zlib = createPage('zlib', 'Zlib'); + const fs = createPage('fs', 'File system'); + const index = createPage('index', 'Index'); + const chunk = createPage('fs/promises', 'fs.promises', { chunk: {} }); + const notFound = createPage('404', 'Page not found', { synthetic: true }); + + it('adds all.html right after its last part, in sidebar order', async () => { + async function* input() { + yield [zlib]; + yield [{ manifest: [zlib.data, fs.data, index.data, notFound.data] }]; + yield [index, fs]; + yield [chunk, notFound]; + } + + const batches = await run(input()); + + assert.deepEqual(batches[0], [zlib]); + + const [{ manifest }] = batches[1]; + + assert.deepEqual(manifest.slice(0, -1), [ + zlib.data, + fs.data, + index.data, + notFound.data, + ]); + assert.equal(manifest.at(-1).api, 'all'); + + const [, , all] = batches[2]; + + assert.deepEqual(batches[2].slice(0, 2), [index, fs]); + assert.equal(all.data.api, 'all'); + assert.deepEqual(all.parts, ['fs', 'zlib']); + assert.deepEqual(all.headings, [{ slug: 'fs' }, { slug: 'zlib' }]); + + assert.deepEqual(batches[3], [chunk, notFound]); + }); + + it('adds all.html after every page given at once, as with one thread', async () => { + const batches = await run([zlib, fs, { manifest: [zlib.data, fs.data] }]); + + const pages = batches.flat(); + + assert.equal(pages.at(-1).data.api, 'all'); + assert.deepEqual(pages.at(-1).parts, ['fs', 'zlib']); + }); +}); diff --git a/packages/node/src/html-all/generate.mjs b/packages/node/src/html-all/generate.mjs new file mode 100644 index 000000000..136f3d6a4 --- /dev/null +++ b/packages/node/src/html-all/generate.mjs @@ -0,0 +1,95 @@ +'use strict'; + +import { toBatches } from '@doc-kit/core/utils/generators.mjs'; +import { headingSortFn } from '@doc-kit/generator-react/jsx-ast/utils/getSortedHeadNodes'; + +// The metadata of `all.html` +const HEAD = { + api: 'all', + path: '/all', + basename: 'all', + synthetic: true, + heading: { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'All' }], + data: { name: 'All', text: 'All', slug: 'all' }, + }, +}; + +/** + * Whether a page is part of `all.html`: the module pages, minus the index. + * Chunk pages and the other synthetic pages are left out, as the module pages + * carry their content. + * + * @param {import('@doc-kit/core/generators/metadata/types').MetadataEntry} data + * @returns {boolean} + */ +const isPart = data => !data.synthetic && !data.chunk && data.api !== 'index'; + +/** + * Builds `all.html` from its parts: their content one after the other, and + * their tables of contents, in the order the sidebar lists them. + * + * @param {Array>} parts + */ +const buildAllPage = parts => { + const pages = parts.toSorted((a, b) => headingSortFn(a.data, b.data)); + + return { + data: HEAD, + headings: pages.flatMap(({ headings }) => headings), + parts: pages.map(({ data }) => data.api), + }; +}; + +/** + * Passes `jsx-ast`'s pages on, with `all.html`'s metadata in the manifest, and + * `all.html` itself right after the last of its parts. + * + * @type {import('./types').Generator['generate']} + */ +export async function* generate(input) { + const parts = []; + + // The parts still to come, once every page's metadata is known + let missing; + let added = false; + + for await (const batch of toBatches(input)) { + const pages = []; + + for (const item of batch) { + if ('manifest' in item) { + missing = new Set(item.manifest.filter(isPart).map(({ api }) => api)); + + for (const { data } of parts) { + missing.delete(data.api); + } + + pages.push({ manifest: [...item.manifest, HEAD] }); + + continue; + } + + pages.push(item); + + if (isPart(item.data)) { + parts.push({ data: item.data, headings: item.headings }); + missing?.delete(item.data.api); + } + } + + if (!added && missing?.size === 0) { + pages.push(buildAllPage(parts)); + added = true; + } + + yield pages; + } + + // Without a manifest, once every page has passed + if (!added) { + yield [buildAllPage(parts)]; + } +} diff --git a/packages/node/src/html-all/index.mjs b/packages/node/src/html-all/index.mjs new file mode 100644 index 000000000..e024a699a --- /dev/null +++ b/packages/node/src/html-all/index.mjs @@ -0,0 +1,26 @@ +'use strict'; + +import { generate } from './generate.mjs'; + +/** + * Adds `all.html`, every module page's content on one page, to the pages the + * `html` generator renders. It builds nothing itself: it passes `jsx-ast`'s + * pages on to `html`, and, once every module page has passed, a page made of + * theirs, which `html` assembles from their compiled content. + * + * @type {import('./types').Generator} + */ +export default { + name: 'html-all', + + description: + 'Adds all.html, every module page on one page, to the html generator', + + dependsOn: '@doc-kit/generator-react/jsx-ast', + + streamsInput: true, + + dependent: '@doc-kit/generator-react/html', + + generate, +}; diff --git a/packages/node/src/html-all/types.d.ts b/packages/node/src/html-all/types.d.ts new file mode 100644 index 000000000..7059190b5 --- /dev/null +++ b/packages/node/src/html-all/types.d.ts @@ -0,0 +1,8 @@ +import type { PageCode } from '@doc-kit/generator-react/jsx-ast/types'; + +type Pages = Array }>; + +export type Generator = GeneratorMetadata< + {}, + Generate, AsyncGenerator> +>; diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md index a929869c4..0e5ad7b40 100644 --- a/packages/react/src/html/README.md +++ b/packages/react/src/html/README.md @@ -13,8 +13,13 @@ library once and the client assets once, as soon as every page's metadata is known, and the worker pool imports, renders, templates, minifies and writes the pages one at a time as they are compiled, so memory scales with the largest page rather than with the site. With one thread, each step waits for the one -before it to finish. `all.html` is assembled from the module pages' compiled -content rather than built again from scratch. +before it to finish. + +A page can be made of other pages' content: an input item with `parts`, the +`api`s of the pages it is made of, which come before it in the input, and its +`headings`. Its program imports theirs, so they are not built again, and it is +rendered first, as it takes the longest. Node.js's `html-all` generator +(`@node-core/doc-kit`) adds `all.html` this way. ## Configuring @@ -50,10 +55,6 @@ content rather than built again from scratch. JSX-in-MDX. See [`components`](#components). **Default:** `{}`. - `navigation` {Object} Sidebar groups and navigation bar items. See [`navigation`](#navigation). **Default:** `{}`. -- `generateAllPage` {boolean} When `true`, writes `all.html`: every module - page's content on one page, in sidebar order, assembled from the module pages - rather than built again. Chunk pages and the index are left out. - **Default:** `true`. - `bundler` {WebBundler} Adapter that bundles the component library and the client assets, and compiles page programs. See [Bundler adapters](#bundler-adapters). **Default:** `createViteBundler()`. @@ -488,7 +489,7 @@ export default ({ metadata }) => ( - `headings` {Array} Pre-computed table of contents heading entries. - `readingTime` {string|undefined} Estimated reading time (e.g. `'5 min read'`). Only present when the `jsx-ast` generator's `showReadingTime` option is - enabled. `all.html` has none. + enabled. A page made of others has none. - `children` {ComponentChildren} Processed page content. The `Layout` component receives the props above. Custom Layout components can use diff --git a/packages/react/src/html/__tests__/generate.test.mjs b/packages/react/src/html/__tests__/generate.test.mjs index e0e8533b3..62d00fb7c 100644 --- a/packages/react/src/html/__tests__/generate.test.mjs +++ b/packages/react/src/html/__tests__/generate.test.mjs @@ -29,6 +29,29 @@ async function* streamInOneBatch(items) { yield items; } +/** + * A page made of the given pages' content, in that order, as Node.js's + * `html-all` generator gives `all.html`. + * + * @param {Array<{ data: { api: string }, headings: Array }>} pages + */ +const createAllPage = pages => ({ + data: { + api: 'all', + path: '/all', + basename: 'all', + synthetic: true, + heading: { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'All' }], + data: { name: 'All', text: 'All', slug: 'all' }, + }, + }, + headings: pages.flatMap(({ headings }) => headings), + parts: pages.map(({ data }) => data.api), +}); + /** * Runs the generator over the given pages, streamed in as one batch, as * `jsx-ast` yields them. @@ -105,9 +128,6 @@ const createTestConfiguration = async (context, target = ['html']) => { }, }); - // The pages under test are the module pages themselves - config.html.generateAllPage = false; - return { config, output }; }; @@ -251,9 +271,8 @@ describe('web generate', () => { } }); - it('assembles all.html from the module pages, in sidebar order', async context => { - const { config, output } = await createTestConfiguration(context); - config.html.generateAllPage = true; + it('assembles a page made of other pages from their content', async context => { + const { output } = await createTestConfiguration(context); const entries = [ createEntry('zlib', 'Zlib'), @@ -261,16 +280,18 @@ describe('web generate', () => { createEntry('index', 'Index'), ]; - await generate( - await Promise.all( - entries.map(entry => buildContent([entry], entry)) - ).then(contents => contents.map(toPage)) + const contents = await Promise.all( + entries.map(entry => buildContent([entry], entry)) ); + const [zlib, fs, index] = contents.map(toPage); + + await generate([zlib, fs, index, createAllPage([fs, zlib])]); + const html = await readFile(join(output, 'all.html'), 'utf8'); assert.match(html, /All \|/); - // Both modules' content, file system first, the index left out + // Its parts' content, in its order, and nothing else assert.match(html, /File system body[\s\S]*Zlib body/); assert.doesNotMatch(html, /Index body/); // Their tables of contents, concatenated @@ -278,6 +299,38 @@ describe('web generate', () => { assert.doesNotMatch(html, /View As/); }); + it('numbers the ids of a page made of others across its parts', async context => { + const { output } = await createTestConfiguration(context); + + // Each module has code tabs, whose elements take ids + const entries = ['zlib', 'fs'].map(api => { + const entry = createEntry(api, api); + + entry.content.children.push( + { type: 'code', lang: 'mjs', value: `import ${api};` }, + { type: 'code', lang: 'cjs', value: `require('${api}');` } + ); + + return entry; + }); + + const contents = await Promise.all( + entries.map(entry => buildContent([entry], entry)) + ); + + const pages = contents.map(toPage); + + await generate([...pages, createAllPage(pages)]); + + const html = await readFile(join(output, 'all.html'), 'utf8'); + const ids = [...html.matchAll(/ id="([^"]*P0-\d+[^"]*)"/g)].map( + ([, id]) => id + ); + + assert.ok(ids.length >= 4); + assert.equal(new Set(ids).size, ids.length); + }); + it('renders chunk pages with navigation back to their module', async context => { const { config, output } = await createTestConfiguration(context, [ 'section-pages', @@ -512,10 +565,11 @@ describe('web generate', () => { const zlib = createPage('zlib', 'Zlib'); const fs = createPage('fs', 'File system'); const index = createPage('index', 'Index'); - const manifest = [zlib, fs, index].map(({ data }) => data); + const all = createAllPage([fs, zlib]); + const manifest = [zlib, fs, index, all].map(({ data }) => data); it( - 'bundles once the manifest arrives, and compiles all.html once its parts are', + 'bundles once the manifest arrives, and compiles each page as it arrives', // The input waits for the bundles, so bundles that wait for the whole // input would never be built { timeout: 10_000 }, @@ -525,7 +579,6 @@ describe('web generate', () => { const bundled = Promise.withResolvers(); config.threads = 2; - config.html.generateAllPage = true; config.html.bundler = createStandInBundler(calls, { bundled }); async function* input() { @@ -535,13 +588,13 @@ describe('web generate', () => { // The bundles are built without waiting for the pages still to come await bundled.promise; - yield [fs]; + yield [fs, all]; yield [index]; } await generatePages(input()); - // all.html is compiled once, as soon as its last part has been + // The page made of others comes right after its last part assert.deepStrictEqual(calls, [ 'zlib.jsx', 'server', @@ -553,7 +606,7 @@ describe('web generate', () => { const html = await readFile(join(output, 'all.html'), 'utf8'); - // In sidebar order, the index left out + // In its order, the index left out assert.match(html, /File system body[\s\S]*Zlib body/); assert.doesNotMatch(html, /Index body/); } @@ -570,7 +623,6 @@ describe('web generate', () => { const { config, output } = await createTestConfiguration(context); config.threads = threads; - config.html.generateAllPage = true; config.html.bundler = createStandInBundler([]); await generatePages(input); @@ -581,12 +633,12 @@ describe('web generate', () => { async function* batches() { yield [zlib]; yield [{ manifest }]; - yield [fs]; + yield [fs, all]; yield [index]; } const streamed = await build(2, batches()); - const whole = await build(1, [zlib, fs, { manifest }, index]); + const whole = await build(1, [zlib, fs, all, { manifest }, index]); assert.deepStrictEqual(Object.keys(whole), [ 'all.html', diff --git a/packages/react/src/html/generate.mjs b/packages/react/src/html/generate.mjs index 85cdef2d8..04a89fe30 100644 --- a/packages/react/src/html/generate.mjs +++ b/packages/react/src/html/generate.mjs @@ -13,7 +13,6 @@ import { toBatches } from '@doc-kit/core/utils/generators.mjs'; import { headingSortFn } from '../jsx-ast/utils/getSortedHeadNodes.mjs'; import { resolveBundler } from './bundlers/index.mjs'; -import { buildAllPage, createAllPageHead, isModulePage } from './utils/all.mjs'; import { copyStaticAssets } from './utils/copying.mjs'; import createProgramBuilder, { moduleFileName } from './utils/generate.mjs'; import { createPageWriter } from './utils/render.mjs'; @@ -28,8 +27,9 @@ const htmlLogger = logger.child('html'); * page's program is compiled to a plain module as it arrives, the component * library and the client assets are bundled once every page's metadata is * known, and the worker pool renders the pages as they are compiled, one in - * memory per worker. `all.html` imports the module pages' content, so it is - * compiled once they all are. With one thread, the input arrives whole. + * memory per worker. A page made of other pages' content (with `parts`, which + * come before it) imports their compiled programs, and is rendered first. With + * one thread, the input arrives whole. * * @type {import('./types').Generator['generate']} */ @@ -138,7 +138,6 @@ const buildSite = async (input, options) => { const datas = [ ...heads.filter(data => !data.synthetic).toSorted(headingSortFn), ...heads.filter(data => data.synthetic), - ...(config.generateAllPage ? [createAllPageHead()] : []), ]; // An empty directory of its own: the compiled pages are already in `outDir` @@ -191,23 +190,6 @@ const buildSite = async (input, options) => { const compiled = []; - let allWritten = !config.generateAllPage; - - // The module pages `all.html` is made of that aren't compiled yet - let missingParts; - - /** - * Compiles `all.html`, which imports the module pages' compiled programs, - * and has the worker pool render it first, as it takes by far the longest. - */ - const writeAllPage = async () => { - const allTask = await compile(buildAllPage(compiled)); - - rendering.prepend([allTask]); - - allWritten = true; - }; - // With one thread, pages are rendered on this thread once all are compiled, // so the manifest is kept until then let manifest; @@ -232,32 +214,24 @@ const buildSite = async (input, options) => { manifest = item.manifest; } - missingParts = new Set( - item.manifest.filter(isModulePage).map(({ api }) => api) - ); - - for (const { data } of compiled) { - missingParts.delete(data.api); - } - continue; } const task = await compile(item); compiled.push(task); - tasks.push(task); - missingParts?.delete(task.data.api); + // A page made of others' content takes by far the longest to render + if ('parts' in item) { + rendering.prepend([task]); + } else { + tasks.push(task); + } } if (tasks.length > 0) { rendering.push(tasks); } - - if (!allWritten && missingParts?.size === 0) { - await writeAllPage(); - } } }; @@ -275,10 +249,6 @@ const buildSite = async (input, options) => { manifest = undefined; } - if (!allWritten) { - await writeAllPage(); - } - htmlLogger.debug(`Compiled ${compiled.length} page programs`); } catch (error) { // Pages still waiting for a worker aren't written diff --git a/packages/react/src/html/index.mjs b/packages/react/src/html/index.mjs index b65c912aa..7c469a497 100644 --- a/packages/react/src/html/index.mjs +++ b/packages/react/src/html/index.mjs @@ -17,8 +17,9 @@ import { processChunk } from './utils/render.mjs'; * known, the configured bundler builds the component library and the client * assets once each, and the worker pool renders, templates, minifies and writes * the pages one at a time as they are compiled, so memory scales with the - * largest page rather than with the site. `all.html` is assembled from the - * module pages' compiled content instead of being built again from scratch. + * largest page rather than with the site. A page made of other pages' content, + * such as Node.js's `all.html` (see its `html-all` generator), is assembled from + * their compiled content instead of being built again. * * This terminal generator writes to the output directory and does not return * an in-memory copy. @@ -85,10 +86,6 @@ export default { showCrossLinks: false, }, - // Whether to write `all.html`: every module page's content on one page, - // assembled from the module pages rather than built again. - generateAllPage: true, - // When omitted, the Vite adapter is loaded lazily during generation. bundler: undefined, }), diff --git a/packages/react/src/html/types.d.ts b/packages/react/src/html/types.d.ts index 73bbced67..87634a2f0 100644 --- a/packages/react/src/html/types.d.ts +++ b/packages/react/src/html/types.d.ts @@ -32,8 +32,8 @@ export type HeadConfig = { export type ResolvedWebConfiguration = Configuration & GlobalConfiguration; -// A page assembled from other pages' content (`all.html`): `parts` lists the -// `api`s whose compiled programs it imports, in order. +// A page assembled from other pages' content: `parts` lists the `api`s whose +// compiled programs it imports, in order. They come before it in the input. export type ComposedPage = Omit<PageCode, 'content' | 'readingTime'> & { parts: Array<string>; }; @@ -115,8 +115,6 @@ export type Configuration = { navbar?: ComponentProps<typeof NavBar>['navItems']; showCrossLinks?: boolean; }; - // Whether to write `all.html`, every module page's content on one page. - generateAllPage: boolean; // Optional bundler adapter. When omitted, the Vite adapter is loaded lazily. bundler?: WebBundler; }; diff --git a/packages/react/src/html/utils/__tests__/all.test.mjs b/packages/react/src/html/utils/__tests__/all.test.mjs deleted file mode 100644 index 7c0f23ab3..000000000 --- a/packages/react/src/html/utils/__tests__/all.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { buildAllPage } from '../all.mjs'; - -const createPage = (api, name, { depth = 1, chunk, synthetic } = {}) => ({ - data: { - api, - path: `/${api}`, - basename: api, - chunk, - synthetic, - heading: { depth, data: { name, text: name, slug: api } }, - }, - headings: [{ depth, value: name, slug: api }], - readingTime: '1 min read', - content: `<><h1>${name}</h1></>`, -}); - -describe('buildAllPage', () => { - it('returns a synthetic `all` page made of the module pages, in sidebar order', () => { - const page = buildAllPage([ - createPage('zlib', 'Zlib'), - createPage('index', 'Index'), - createPage('fs', 'File system'), - createPage('404', 'Page Not Found', { synthetic: true }), - createPage('fs-readfile', 'readFile', { chunk: { api: 'fs' } }), - ]); - - assert.equal(page.data.api, 'all'); - assert.equal(page.data.path, '/all'); - assert.equal(page.data.heading.data.name, 'All'); - assert.equal(page.data.synthetic, true); - // The index, the other synthetic pages and the chunk pages are left out - assert.deepEqual(page.parts, ['fs', 'zlib']); - assert.deepEqual( - page.headings.map(({ value }) => value), - ['File system', 'Zlib'] - ); - // A reading time makes no sense for the whole reference - assert.equal('readingTime' in page, false); - }); -}); diff --git a/packages/react/src/html/utils/__tests__/generate.test.mjs b/packages/react/src/html/utils/__tests__/generate.test.mjs new file mode 100644 index 000000000..65834f9c8 --- /dev/null +++ b/packages/react/src/html/utils/__tests__/generate.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; + +import { setConfig } from '@doc-kit/core/utils/configuration/index.mjs'; + +import { compile } from '../../bundlers/vite.mjs'; +import createProgramBuilder, { moduleFileName } from '../generate.mjs'; + +describe('buildPageProgram', () => { + it('imports no component into a composed page, whatever the components are named', async context => { + const config = await setConfig({ + target: ['html'], + version: 'v22.0.0', + changelog: [], + generators: { html: {} }, + }); + + // Named like the composed page's own wrapper + config.html.components = { ComposedPart: '/components/ComposedPart.jsx' }; + + const dir = await mkdtemp(join(tmpdir(), 'doc-kit-program-test-')); + context.after(() => rm(dir, { recursive: true, force: true })); + + // A stand-in for the built library, which exports every component, and + // the part the page is made of + const library = join(dir, 'library.mjs'); + + await writeFile( + library, + [ + 'export const h = (type, props, ...children) => ({ type, props, children });', + 'export const Fragment = "Fragment";', + 'export const Layout = "Layout";', + 'export const renderToStringAsync = async () => "";', + 'export const ComposedPart = () => null;', + ].join('\n') + ); + + await writeFile( + join(dir, moduleFileName('fs')), + 'export const content = () => "File system";' + ); + + const { buildPageProgram } = createProgramBuilder(); + const libraryURL = pathToFileURL(library).href; + const program = buildPageProgram( + { data: { api: 'all' }, headings: [], parts: ['fs'] }, + libraryURL + ); + + const imports = program + .split('\n') + .find(line => line.includes(JSON.stringify(libraryURL))); + + assert.doesNotMatch(imports, /ComposedPart/); + + const file = join(dir, moduleFileName('all')); + const code = await compile(program, 'all.jsx'); + + await writeFile(file, code); + + const { content } = await import(pathToFileURL(file).href); + + assert.equal(typeof content, 'function'); + }); +}); diff --git a/packages/react/src/html/utils/all.mjs b/packages/react/src/html/utils/all.mjs deleted file mode 100644 index 108357483..000000000 --- a/packages/react/src/html/utils/all.mjs +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -import { getSortedHeadNodes } from '../../jsx-ast/utils/getSortedHeadNodes.mjs'; -import { createSyntheticHead } from '../../jsx-ast/utils/synthetic/synthetic.mjs'; - -/** - * Whether a page's content belongs on `all.html`: the module pages, minus the - * index. Chunk pages and the other synthetic pages are left out, since the full - * module pages already carry their content. - * - * @param {import('@doc-kit/core/generators/metadata/types').MetadataEntry} data - */ -export const isModulePage = data => - !data.synthetic && !data.chunk && data.api !== 'index'; - -/** - * The metadata of `all.html`, which the bundles need before its parts are built. - * - * @returns {import('@doc-kit/core/generators/metadata/types').MetadataEntry} - */ -export const createAllPageHead = () => createSyntheticHead('all', 'All'); - -/** - * Builds the `all.html` page from the module pages already compiled. - * - * Its content is exactly the module pages' content, one after the other, so - * nothing is rebuilt: the page program imports each module's `content` export - * (see `buildPageProgram`) and the table of contents is the modules' own, - * concatenated. Modules are ordered as the sidebar lists them. - * - * @param {Array<Pick<import('../../jsx-ast/types').PageCode, 'data' | 'headings'>>} pages - The pages compiled, every module page among them - * @returns {import('../types').ComposedPage} - */ -export const buildAllPage = pages => { - const byApi = new Map(pages.map(page => [page.data.api, page])); - - const modules = pages.map(({ data }) => data).filter(isModulePage); - const parts = getSortedHeadNodes(modules).map(({ api }) => byApi.get(api)); - - return { - data: createAllPageHead(), - headings: parts.flatMap(({ headings }) => headings), - parts: parts.map(({ data }) => data.api), - }; -}; diff --git a/packages/react/src/html/utils/generate.mjs b/packages/react/src/html/utils/generate.mjs index 6f3a8bce4..17d220403 100644 --- a/packages/react/src/html/utils/generate.mjs +++ b/packages/react/src/html/utils/generate.mjs @@ -114,34 +114,48 @@ export default () => { * metadata, table of contents and reading time reach it as those props at * render time — only code. * - * A composed page (`all.html`) has no content of its own: it imports the - * `content` of the pages it is made of, so their JSX is never rebuilt. + * A composed page has no content of its own: it imports the + * `content` of the pages it is made of, so their JSX is never rebuilt. Each + * part's elements are only created as it is rendered, so they can go once + * it is: an element rendering a single element doesn't hold on to it, as + * one rendering a fragment does to its children. * * @param {import('../types').Page} page * @param {string} libraryURL - The `file:` URL the page imports the component library from. * @returns {string} The program, as JSX. */ const buildPageProgram = (page, libraryURL) => { - const { imports, content } = + const { imports, declarations, content } = 'parts' in page ? { imports: page.parts.map( (api, index) => `import { content as content${index} } from ${quote(`./${moduleFileName(api)}`)};` ), - content: `<>${page.parts.map((_, index) => `{content${index}()}`).join('')}</>`, + declarations: [ + 'const ComposedPartContent = ({ part }) => part();', + 'const ComposedPart = ({ part }) => <ComposedPartContent part={part} />;', + ], + content: `<>${page.parts.map((_, index) => `<ComposedPart part={content${index}} />`).join('')}</>`, } - : { imports: [], content: page.content }; + : { imports: [], declarations: [], content: page.content }; - // Import only the components the page actually renders - const used = componentImports - .filter(({ name }) => content.includes(`<${name}`)) - .map(({ name }) => name) - .filter(name => name !== JSX_IMPORTS.Layout.name); + // Import only the components the page actually renders. A composed page + // renders none of its own (its parts import theirs), and scanning it would + // take its wrappers for configured components of the same name + let used = []; + + if (!('parts' in page)) { + used = componentImports + .filter(({ name }) => content.includes(`<${name}`)) + .map(({ name }) => name) + .filter(name => name !== JSX_IMPORTS.Layout.name); + } return [ `import { ${[...RUNTIME_IMPORTS, ...used].join(', ')} } from ${quote(libraryURL)};`, ...imports, + ...declarations, `export const content = () => ${content};`, `export default props => renderToStringAsync(<${JSX_IMPORTS.Layout.name} {...props}>{content()}</${JSX_IMPORTS.Layout.name}>);`, ].join('\n'); diff --git a/packages/react/src/jsx-ast/README.md b/packages/react/src/jsx-ast/README.md index 1efdeaa76..64424f0a2 100644 --- a/packages/react/src/jsx-ast/README.md +++ b/packages/react/src/jsx-ast/README.md @@ -19,8 +19,8 @@ Each page is emitted as `{ data, headings, readingTime, content }`: the page's head entry, its table of contents, the optional reading time, and the processed content serialized to JSX code as one fragment. The page layout is not part of the content — the `html` generator wraps each page in `<Layout>`, and assembles -`all.html` from the module pages' content (see its `generateAllPage` option) -rather than building every module a second time here. +pages made of other pages' content, such as Node.js's `all.html`, rather than +building them a second time here. Pages are emitted as they are built, in the order their entries arrived, the synthetic pages last. Once every page has arrived, it also emits diff --git a/packages/react/src/jsx-ast/generate.mjs b/packages/react/src/jsx-ast/generate.mjs index e01e159c7..93d7b0db6 100644 --- a/packages/react/src/jsx-ast/generate.mjs +++ b/packages/react/src/jsx-ast/generate.mjs @@ -58,9 +58,8 @@ export async function processChunk(slicedInput, itemIndices) { * @type {import('./types').Generator['generate']} */ export async function* generate(input, worker) { - // `all.html` is not built here: it is the module pages concatenated, so the - // `html` generator assembles it from their content instead of building - // every module a second time. + // Pages made of other pages' content are not built here: the `html` + // generator assembles them from that content. const synthetic = getConfig('jsx-ast').generateNotFoundPage ? [buildNotFoundPage()] : []; diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index 1d2434ee8..b86b6ccaa 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -330,8 +330,8 @@ export const processEntry = entry => { * * The layout itself (`<Layout>`) is not part of the content. The `html` * generator wraps each page in it, which lets a page be assembled from other - * pages' content — `all.html` is the module pages concatenated — without - * building those modules a second time. + * pages' content, such as Node.js's `all.html`, without building those pages a + * second time. * * @param {Array<import('@doc-kit/core/generators/metadata/types').MetadataEntry>} entries - API documentation metadata entries */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e54d9efae..b2f2ae4ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@doc-kit/core': specifier: workspace:* version: link:../core + '@doc-kit/generator-react': + specifier: workspace:* + version: link:../react estree-util-visit: specifier: ^2.0.0 version: 2.0.0 diff --git a/www/doc-kit.config.mjs b/www/doc-kit.config.mjs index 275b717d7..45834e35f 100644 --- a/www/doc-kit.config.mjs +++ b/www/doc-kit.config.mjs @@ -28,6 +28,7 @@ const PUBLIC_GENERATORS = [ 'legacy-json-all', 'man-page', 'api-links', + 'html-all', 'addon-verify', ];