diff --git a/package-lock.json b/package-lock.json index aae2c573..838097cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@node-core/doc-kit", - "version": "1.3.10", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@node-core/doc-kit", - "version": "1.3.10", + "version": "1.4.0", "dependencies": { "@actions/core": "^3.0.0", "@heroicons/react": "^2.2.0", @@ -37,6 +37,7 @@ "rehype-recma": "^1.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", @@ -1112,6 +1113,7 @@ "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" @@ -7407,6 +7409,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -7760,6 +7779,108 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -7803,6 +7924,33 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-factory-space": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", @@ -8004,6 +8152,31 @@ ], "license": "MIT" }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", @@ -9195,6 +9368,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", diff --git a/package.json b/package.json index 5c7b8fef..40bb9023 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@node-core/doc-kit", "type": "module", - "version": "1.3.10", + "version": "1.4.0", "repository": { "type": "git", "url": "git+https://github.com/nodejs/doc-kit.git" @@ -74,6 +74,7 @@ "rehype-recma": "^1.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", diff --git a/src/generators/ast/__tests__/generate.test.mjs b/src/generators/ast/__tests__/generate.test.mjs new file mode 100644 index 00000000..3f2e3b22 --- /dev/null +++ b/src/generators/ast/__tests__/generate.test.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +import { STABILITY_INDEX_URL } from '../constants.mjs'; +import { processChunk } from '../generate.mjs'; + +let dir; + +// Writes `content` to `` in the temp dir and returns the +// `[path, parent]` tuple `processChunk` expects. +const file = async (name, content) => { + const path = join(dir, name); + await writeFile(path, content); + return [path, dir]; +}; + +// Runs a single file through `processChunk` and returns its result entry. +const process = async tuple => { + const [result] = await processChunk([tuple], [0]); + return result; +}; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), 'ast-generate-')); +}); + +after(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('processChunk', () => { + describe('MDX detection', () => { + it('parses a plain .md file as markdown (mdx: false)', async () => { + const result = await process(await file('plain.md', '# Title\n')); + + assert.strictEqual(result.mdx, false); + }); + + it('parses a .mdx file as MDX (mdx: true)', async () => { + const result = await process(await file('page.mdx', '# Title\n')); + + assert.strictEqual(result.mdx, true); + }); + + it('lets frontmatter `mdx: true` opt a .md file in', async () => { + const result = await process( + await file('optin.md', '---\nmdx: true\n---\n\n# Title\n') + ); + + assert.strictEqual(result.mdx, true); + }); + + it('lets frontmatter `mdx: false` opt a .mdx file out', async () => { + const result = await process( + await file('optout.mdx', '---\nmdx: false\n---\n\n# Title\n') + ); + + assert.strictEqual(result.mdx, false); + }); + + it('falls back to the extension when frontmatter fails to parse', async () => { + // Node.js core uses a non-standard frontmatter dialect; a YAML parse + // error must not throw and must defer to the `.mdx` extension. + const result = await process( + await file('weird.mdx', '---\n: : not: valid: yaml\n---\n\n# Title\n') + ); + + assert.strictEqual(result.mdx, true); + }); + + it('falls back to the extension when frontmatter lacks an mdx key', async () => { + const result = await process( + await file('nokey.md', '---\nadded: v1.0.0\n---\n\n# Title\n') + ); + + assert.strictEqual(result.mdx, false); + }); + }); + + describe('output shape', () => { + it('strips the extension from the relative path', async () => { + const result = await process(await file('fs.md', '# fs\n')); + + assert.strictEqual(result.path, '/fs'); + }); + + it('returns a parsed mdast tree', async () => { + const result = await process(await file('tree.md', '# Heading\n')); + + assert.strictEqual(result.tree.type, 'root'); + assert.strictEqual(result.tree.children[0].type, 'heading'); + }); + }); + + describe('frontmatter handling', () => { + it('rewrites .md frontmatter into a YAML html comment', async () => { + const result = await process( + await file('meta.md', '---\nadded: v1.0.0\n---\n\n# Title\n') + ); + + const html = result.tree.children.find(n => n.type === 'html'); + assert.ok(html, 'expected an html node'); + assert.match(html.value, //); + }); + + it('re-attaches MDX frontmatter as a leading YAML html node', async () => { + const result = await process( + await file('mdxmeta.mdx', '---\nadded: v1.0.0\n---\n\n# Title\n') + ); + + const [first] = result.tree.children; + assert.strictEqual(first.type, 'html'); + assert.match(first.value, //); + }); + + it('keeps MDX JSX as expression nodes rather than type annotations', async () => { + const result = await process( + await file('jsx.mdx', '# Title\n\n\n\n{1 + 1}\n') + ); + + const types = result.tree.children.map(n => n.type); + assert.ok(types.includes('mdxJsxFlowElement')); + assert.ok(types.includes('mdxFlowExpression')); + }); + }); + + describe('stability index', () => { + it('rewrites the stability prefix into a link in markdown mode', async () => { + const result = await process( + await file('stab.md', '# fs\n\n> Stability: 2 - Stable\n') + ); + + const blockquote = result.tree.children.find( + n => n.type === 'blockquote' + ); + const link = blockquote.children[0].children.find(n => n.type === 'link'); + + assert.strictEqual(link.url, STABILITY_INDEX_URL); + }); + + it('rewrites the stability prefix into a link in MDX mode', async () => { + const result = await process( + await file('stab.mdx', '# fs\n\n> Stability: 2 - Stable\n') + ); + + const blockquote = result.tree.children.find( + n => n.type === 'blockquote' + ); + const link = blockquote.children[0].children.find(n => n.type === 'link'); + + assert.strictEqual(link.url, STABILITY_INDEX_URL); + }); + }); + + describe('chunking', () => { + it('only processes the requested indices', async () => { + const a = await file('a.md', '# A\n'); + const b = await file('b.md', '# B\n'); + const c = await file('c.md', '# C\n'); + + const results = await processChunk([a, b, c], [0, 2]); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].path, '/a'); + assert.strictEqual(results[1].path, '/c'); + }); + }); +}); diff --git a/src/generators/ast/generate.mjs b/src/generators/ast/generate.mjs index e0c5a6c5..1808c6b1 100644 --- a/src/generators/ast/generate.mjs +++ b/src/generators/ast/generate.mjs @@ -5,12 +5,41 @@ import { relative, sep } from 'node:path/posix'; import globParent from 'glob-parent'; import { globSync } from 'tinyglobby'; +import { parse as parseYaml } from 'yaml'; import { STABILITY_INDEX_URL } from './constants.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { withExt } from '../../utils/file.mjs'; import { QUERIES } from '../../utils/queries/index.mjs'; -import { getRemark as remark } from '../../utils/remark.mjs'; +import { getRemark as remark, getRemarkMdx } from '../../utils/remark.mjs'; + +/** + * Determines whether a file should be parsed as MDX. A `.mdx` extension opts in + * by default, but an explicit `mdx` boolean in the file's `---` frontmatter + * always takes precedence (so a `.md` file can opt in, or a `.mdx` file out). + * + * @param {string} path - Absolute file path + * @param {string} content - Raw file contents + * @returns {boolean} + */ +const isMdxFile = (path, content) => { + const frontmatter = QUERIES.standardYamlFrontmatter.exec(content); + + if (frontmatter) { + try { + const { mdx } = parseYaml(frontmatter[1]) ?? {}; + + if (typeof mdx === 'boolean') { + return mdx; + } + } catch { + // Node.js core docs use a non-standard frontmatter dialect that may fail + // to parse here; fall back to the extension check below. + } + } + + return path.endsWith('.mdx'); +}; /** * Process a chunk of markdown files in a worker thread. @@ -25,23 +54,48 @@ export async function processChunk(inputSlice, itemIndices) { for (const [path, parent] of filePaths) { const content = await readFile(path, 'utf-8'); - const value = content - .replace( + + const mdx = isMdxFile(path, content); + + // Stability indexes become links in both modes. + const withStabilityLinks = content.replace( + QUERIES.stabilityIndexPrefix, + match => `[${match}](${STABILITY_INDEX_URL})` + ); + + // The path is the relative path minus the extension + const relativePath = sep + withExt(relative(parent, path)); + + let tree; + + if (mdx) { + // A `` HTML comment is invalid MDX, so the frontmatter can't be + // rewritten in-source. Strip it, parse as MDX, then re-attach it as the + // YAML `html` node the metadata stage expects. + const frontmatter = QUERIES.standardYamlFrontmatter.exec(content); + const source = withStabilityLinks.replace( QUERIES.standardYamlFrontmatter, - (_, yaml) => '\n' - ) - .replace( - QUERIES.stabilityIndexPrefix, - match => `[${match}](${STABILITY_INDEX_URL})` + '' ); - const relativePath = sep + withExt(relative(parent, path)); + tree = getRemarkMdx().parse(source); + + if (frontmatter) { + tree.children.unshift({ + type: 'html', + value: ``, + }); + } + } else { + const value = withStabilityLinks.replace( + QUERIES.standardYamlFrontmatter, + (_, yaml) => `` + ); + + tree = remark().parse(value); + } - results.push({ - tree: remark().parse(value), - // The path is the relative path minus the extension - path: relativePath, - }); + results.push({ tree, path: relativePath, mdx }); } return results; diff --git a/src/generators/jsx-ast/constants.mjs b/src/generators/jsx-ast/constants.mjs index 55d447e4..dddad667 100644 --- a/src/generators/jsx-ast/constants.mjs +++ b/src/generators/jsx-ast/constants.mjs @@ -108,6 +108,27 @@ export const AST_NODE_TYPES = { * @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxattributevalueexpression */ JSX_ATTRIBUTE_EXPRESSION: 'mdxJsxAttributeValueExpression', + + /** + * Block-level expression (e.g. a standalone `{1 + 1}` in MDX) + * + * @see https://github.com/syntax-tree/mdast-util-mdx-expression#mdxflowexpression + */ + FLOW_EXPRESSION: 'mdxFlowExpression', + + /** + * Text-level expression (e.g. an inline `{value}` in MDX) + * + * @see https://github.com/syntax-tree/mdast-util-mdx-expression#mdxtextexpression + */ + TEXT_EXPRESSION: 'mdxTextExpression', + + /** + * ESM `import`/`export` statement embedded in MDX + * + * @see https://github.com/syntax-tree/mdast-util-mdxjs-esm#mdxjsesm + */ + ESM: 'mdxjsEsm', }, ESTREE: { /** diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 3502b637..363d1eea 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -260,12 +260,15 @@ export const processEntry = entry => { transformHeadingNode(entry, ...args) ); - // Transform typed lists into property tables - visit( - entry.content, - UNIST.isStronglyTypedList, - (node, idx, parent) => (parent.children[idx] = createSignatureTable(node)) - ); + // Transform typed lists into property tables. Skipped for MDX pages, whose + // lists are authored prose rather than API type signatures. + if (!entry.mdx) { + visit( + entry.content, + UNIST.isStronglyTypedList, + (node, idx, parent) => (parent.children[idx] = createSignatureTable(node)) + ); + } return entry.content; }; diff --git a/src/generators/metadata/types.d.ts b/src/generators/metadata/types.d.ts index b3abe844..7b19b23f 100644 --- a/src/generators/metadata/types.d.ts +++ b/src/generators/metadata/types.d.ts @@ -128,6 +128,8 @@ export interface MetadataEntry extends YAMLProperties { basename: string; /** Whether this page was generated by a downstream generator. */ synthetic?: boolean; + /** Whether this page was authored as MDX (JSX-in-Markdown). */ + mdx?: boolean; /** Processed heading with metadata */ heading: HeadingNode; /** Stability classification information */ diff --git a/src/generators/metadata/utils/__tests__/parse.test.mjs b/src/generators/metadata/utils/__tests__/parse.test.mjs index b7b9d2f6..9197ce4c 100644 --- a/src/generators/metadata/utils/__tests__/parse.test.mjs +++ b/src/generators/metadata/utils/__tests__/parse.test.mjs @@ -222,6 +222,34 @@ describe('parseApiDoc', () => { }); }); + describe('MDX mode', () => { + it('defaults the mdx flag to false on entries', () => { + const tree = u('root', [h('fs')]); + const [entry] = parseApiDoc({ path, tree }, typeMap); + + assert.strictEqual(entry.mdx, false); + }); + + it('propagates the mdx flag onto entries', () => { + const tree = u('root', [h('fs')]); + const [entry] = parseApiDoc({ path, tree, mdx: true }, typeMap); + + assert.strictEqual(entry.mdx, true); + }); + + it('skips {type} reference transformation in MDX mode', () => { + // In MDX, a bare `{string}` is a real expression node, not a type + // annotation, so it must be left untouched (no link generated). + const tree = u('root', [ + h('fs'), + u('paragraph', [u('text', '{string}')]), + ]); + const [entry] = parseApiDoc({ path, tree, mdx: true }, typeMap); + + assert.strictEqual(findLink(entry), undefined); + }); + }); + describe('document without headings', () => { it('produces one entry for content with no headings', () => { const tree = u('root', [ diff --git a/src/generators/metadata/utils/parse.mjs b/src/generators/metadata/utils/parse.mjs index 29192653..840a2120 100644 --- a/src/generators/metadata/utils/parse.mjs +++ b/src/generators/metadata/utils/parse.mjs @@ -27,11 +27,11 @@ import { IGNORE_STABILITY_STEMS } from '../constants.mjs'; /** * This generator generates a flattened list of metadata entries from a API doc * - * @param {{ tree: import('mdast.Root') } & import('../types').MetadataEntry} input + * @param {{ tree: import('mdast.Root'), mdx?: boolean } & import('../types').MetadataEntry} input * @param {Record} typeMap * @returns {Promise>} */ -export const parseApiDoc = ({ path, tree }, typeMap) => { +export const parseApiDoc = ({ path, tree, mdx = false }, typeMap) => { /** * Collection of metadata entries for the file * @type {Array} @@ -83,6 +83,7 @@ export const parseApiDoc = ({ path, tree }, typeMap) => { const metadata = /** @type {import('../types').MetadataEntry} */ ({ api, path, + mdx, basename: basename(path), heading: headingNode, }); @@ -116,10 +117,13 @@ export const parseApiDoc = ({ path, tree }, typeMap) => { metadata.heading.data.type = metadata.type; } - // Process type references - visit(subTree, UNIST.isTextWithType, (node, _, parent) => - visitTextWithTypeNode(node, parent, relativeTypeMap) - ); + // Process type references. Skipped for MDX, where bare `<...>`/`{...}` are + // real JSX/expression nodes (not type annotations) and must not be rewritten. + if (!mdx) { + visit(subTree, UNIST.isTextWithType, (node, _, parent) => + visitTextWithTypeNode(node, parent, relativeTypeMap) + ); + } // Process Unix manual references visit(subTree, UNIST.isTextWithUnixManual, (node, _, parent) => diff --git a/src/generators/web/README.md b/src/generators/web/README.md index ff0ce1e6..9b9d0243 100644 --- a/src/generators/web/README.md +++ b/src/generators/web/README.md @@ -19,6 +19,7 @@ The `web` generator accepts the following configuration options: | `lightningcss` | `object` | `{}` | Options spread into LightningCSS while bundling CSS (see below) | | `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization | | `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build | +| `components` | `object` | `{}` | Maps JSX tag names to component imports, enabling JSX-in-MDX (see below) | | `rolldown` | `object` | `{}` | Options merged into the Rolldown build — extra plugins, etc. (see below) | #### `head` @@ -178,6 +179,56 @@ export default { }; ``` +### `components` + +`components` registers custom JSX components so they can be used directly in +content (see [JSX-in-MDX](#jsx-in-mdx) below). Each entry maps a JSX tag name to +an import descriptor (`{ name, source, isDefaultExport? }`, the same shape as the +built-in `JSX_IMPORTS`). A `Tag: 'source'` string shorthand expands to +`{ name: Tag, source }` with a default export. Registered components are merged +with the built-ins, and a matching `imports` alias resolves the `source` to a +real module path: + +```js +// doc-kit.config.mjs +export default { + web: { + components: { + // Shorthand — equivalent to { name: 'Hero', source: '#theme/Hero' } + Hero: '#theme/Hero', + // Full descriptor + Stats: { name: 'Stats', source: '#theme/Stats' }, + }, + imports: { + '#theme/Hero': './src/components/Hero.jsx', + '#theme/Stats': './src/components/Stats.jsx', + }, + }, +}; +``` + +### JSX-in-MDX + +By default every input file is parsed as Markdown, where bare `<` and `{` are +treated literally (Node.js core docs use ``-style type annotations). To +author real JSX — ``, `{expression}` — use an **`.mdx`** file, or set +`mdx: true` in a file's `---` frontmatter (frontmatter wins, so `mdx: false` +opts a `.mdx` file back out). MDX files are parsed with `remark-mdx` and skip the +API-doc type/signature parsing; headings, frontmatter, TOC, and sidebar still +work. Reference any component registered via `components`: + +```mdx +--- +title: Welcome +--- + +# Welcome + + + +There are {stats.length} APIs documented. +``` + ### `#theme/config` virtual module The `web` generator provides a `#theme/config` virtual module that exposes pre-computed configuration as named exports. Any component (including custom overrides) can import the values it needs, and tree-shaking removes the rest. diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 3695c864..a6343256 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -87,6 +87,10 @@ export default createLazyGenerator({ }, virtualImports: {}, + // Maps JSX tag names to component imports for JSX-in-MDX. Empty by default; + // see the web generator README for the shape and shorthand. + components: {}, + // Options merged into the Rolldown build (client and server), e.g. extra // `plugins`. See the README for the merge semantics. rolldown: {}, diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 2fb239e9..68588e60 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -10,6 +10,14 @@ export type TagAttributes = Record< string | number | boolean | null | undefined >; +// Describes how a JSX component is imported. Mirrors the `JSXImportConfig` +// JSDoc typedef in `constants.mjs`. +export type JSXImportConfig = { + name: string; + source: string; + isDefaultExport?: boolean; +}; + export type HeadConfig = { // `` tags, each an attribute bag (e.g. `{ name, content }`). meta: Array; @@ -34,6 +42,11 @@ export type Configuration = { >; imports: Record; virtualImports: Record; + // Maps a JSX tag name to its import, enabling JSX-in-MDX. The string shorthand + // `Tag: 'source'` expands to `{ name: Tag, source }`. Merged with the built-in + // `JSX_IMPORTS`. Pair each entry with a matching `imports` alias to resolve the + // `source` to a real module path. + components: Record; // Options merged into the Rolldown build for the client and server bundles. // See the web generator README for the merge semantics. rolldown: Partial; diff --git a/src/generators/web/utils/generate.mjs b/src/generators/web/utils/generate.mjs index 3c4069f6..0a735d1f 100644 --- a/src/generators/web/utils/generate.mjs +++ b/src/generators/web/utils/generate.mjs @@ -1,7 +1,20 @@ import { resolve } from 'node:path'; +import getConfig from '../../../utils/configuration/index.mjs'; import { JSX_IMPORTS, ROOT } from '../constants.mjs'; +/** + * Normalizes a `components` config entry into the `JSXImportConfig` shape. + * Accepts either the full descriptor or the `Tag: 'source'` string shorthand. + * + * @param {[string, import('../constants.mjs').JSXImportConfig | string]} entry + * @returns {import('../constants.mjs').JSXImportConfig} + */ +const normalizeComponent = ([tag, value]) => + typeof value === 'string' + ? { name: tag, source: value } + : { name: tag, isDefaultExport: true, ...value }; + /** * Creates an ES Module `import` statement as a string, based on parameters. * @@ -40,11 +53,16 @@ export const createImportDeclaration = ( * - `buildServerProgram`: Wraps component for server-side rendering */ export default () => { + // User-configured components (for JSX-in-MDX), merged with the built-ins. + const { components } = getConfig('web'); + // Generate import statements for all JSX components // TODO: Optimize by conditionally including server-only or client-only imports - const baseImports = Object.values(JSX_IMPORTS).map( - ({ name, source, isDefaultExport = true }) => - createImportDeclaration(name, source, isDefaultExport) + const baseImports = [ + ...Object.values(JSX_IMPORTS), + ...Object.entries(components).map(normalizeComponent), + ].map(({ name, source, isDefaultExport = true }) => + createImportDeclaration(name, source, isDefaultExport) ); /** diff --git a/src/utils/remark.mjs b/src/utils/remark.mjs index 61b5ba51..58550945 100644 --- a/src/utils/remark.mjs +++ b/src/utils/remark.mjs @@ -7,6 +7,7 @@ import rehypeRaw from 'rehype-raw'; import rehypeRecma from 'rehype-recma'; import rehypeStringify from 'rehype-stringify'; import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import remarkStringify from 'remark-stringify'; @@ -27,6 +28,18 @@ export const getRemark = lazy(() => unified().use(remarkParse).use(remarkGfm).use(remarkStringify) ); +/** + * Retrieves an instance of Remark configured to parse MDX (JSX-in-Markdown). + * + * Unlike {@link getRemark}, this understands `` and `{expression}` + * syntax as real JSX/expression nodes. It is only used for `.mdx` (or + * explicitly opted-in) files, since Node.js core `.md` files use bare `<` and + * `{` for type annotations that MDX would otherwise try to parse. + */ +export const getRemarkMdx = lazy(() => + unified().use(remarkParse).use(remarkMdx).use(remarkGfm) +); + /** * Retrieves an instance of Remark configured to output stringified HTML code */