diff --git a/.changeset/fix-typed-list-split.md b/.changeset/fix-typed-list-split.md new file mode 100644 index 00000000..ced5172d --- /dev/null +++ b/.changeset/fix-typed-list-split.md @@ -0,0 +1,10 @@ +--- +'@doc-kit/core': patch +'@doc-kit/generator-react': patch +--- + +Fix empty paragraphs in API docs when a typed parameter list contains trailing non-parameter items + +When a loose markdown list in the API docs starts with typed parameters (e.g. `actual`, `expected`, `Returns`) but also contains plain prose bullets (e.g. algorithm complexity notes), the entire list was previously treated as a parameter signature table. The non-parameter items had no name or type, causing them to render as empty `
` blocks on the built site. + +The fix splits the list at the first non-parameter item: typed items become the `FunctionSignature` table, and the remaining items render as regular markdown content. diff --git a/packages/core/src/utils/queries/__tests__/index.test.mjs b/packages/core/src/utils/queries/__tests__/index.test.mjs index a57762a0..5bca0411 100644 --- a/packages/core/src/utils/queries/__tests__/index.test.mjs +++ b/packages/core/src/utils/queries/__tests__/index.test.mjs @@ -178,4 +178,67 @@ describe('UNIST', () => { }); }); }); + + describe('isTypedListItem', () => { + it('returns false for undefined/null items', () => { + strictEqual(UNIST.isTypedListItem(undefined), false); + strictEqual(UNIST.isTypedListItem(null), false); + }); + + it('returns false for items without children', () => { + strictEqual(UNIST.isTypedListItem({}), false); + }); + + const cases = [ + { + name: 'inlineCode with valid property name', + item: createTree('listItem', [ + createTree('paragraph', [ + createTree('inlineCode', 'actual'), + createTree('text', ' '), + createTree('typeAnnotation', 'Array|string'), + ]), + ]), + expected: true, + }, + { + name: 'Returns prefix', + item: createTree('listItem', [ + createTree('paragraph', [createTree('text', 'Returns: some value')]), + ]), + expected: true, + }, + { + name: 'direct type annotation', + item: createTree('listItem', [ + createTree('paragraph', [createTree('typeAnnotation', 'Type')]), + ]), + expected: true, + }, + { + name: 'plain prose text (no typed prefix)', + item: createTree('listItem', [ + createTree('paragraph', [ + createTree('text', 'Algorithm complexity: O(N*D), where:'), + ]), + ]), + expected: false, + }, + { + name: 'inlineCode with invalid property name', + item: createTree('listItem', [ + createTree('paragraph', [ + createTree('inlineCode', 'not a valid prop'), + ]), + ]), + expected: false, + }, + ]; + + cases.forEach(({ name, item, expected }) => { + it(`returns ${expected} for ${name}`, () => { + strictEqual(UNIST.isTypedListItem(item), expected); + }); + }); + }); }); diff --git a/packages/core/src/utils/queries/index.mjs b/packages/core/src/utils/queries/index.mjs index abe289fb..488ce6f5 100644 --- a/packages/core/src/utils/queries/index.mjs +++ b/packages/core/src/utils/queries/index.mjs @@ -1,7 +1,7 @@ 'use strict'; import { transformNodesToString } from '../unist.mjs'; -import { isTypedList } from './utils.mjs'; +import { isTypedListItem, isTypedList } from './utils.mjs'; // This defines the actual REGEX Queries export const QUERIES = { @@ -71,6 +71,12 @@ export const UNIST = { */ isLooselyTypedList: list => Boolean(isTypedList(list)), + /** + * @param {import('@types/mdast').ListItem} item + * @returns {boolean} + */ + isTypedListItem, + /** * @param {import('@types/mdast').List} list * @returns {boolean} diff --git a/packages/core/src/utils/queries/utils.mjs b/packages/core/src/utils/queries/utils.mjs index 698294f1..3dea1436 100644 --- a/packages/core/src/utils/queries/utils.mjs +++ b/packages/core/src/utils/queries/utils.mjs @@ -2,20 +2,17 @@ import { VALID_JAVASCRIPT_PROPERTY } from './constants.mjs'; import { QUERIES } from './index.mjs'; /** - * @param {import('@types/mdast').List} list + * Inspects the first phrasing node of a paragraph and returns how confidently + * it looks like the start of a typed parameter. + * + * @param {import('@types/mdast').PhrasingContent | undefined} firstNode * @returns {0 | 1 | 2} confidence * - * 0: This is not a typed list - * 1: This is a loosely typed list - * 2: This is a strongly typed list + * 0: Not a typed parameter + * 1: Loosely typed (inlineCode + valid property name) + * 2: Strongly typed (typed list starter or direct type annotation) */ -export const isTypedList = list => { - if (!list || list.type !== 'list') { - return 0; - } - - const firstNode = list.children?.[0]?.children?.[0]?.children[0]; - +const getTypedConfidence = firstNode => { if (!firstNode) { return 0; } @@ -43,3 +40,30 @@ export const isTypedList = list => { return 0; }; + +/** + * Checks whether a single list item looks like a typed parameter — i.e. it + * starts with a property name (`inlineCode`), a Returns/Extends/Type prefix, + * or a direct type annotation. + * + * @param {import('@types/mdast').ListItem} item + * @returns {boolean} + */ +export const isTypedListItem = item => + Boolean(getTypedConfidence(item?.children?.[0]?.children?.[0])); + +/** + * @param {import('@types/mdast').List} list + * @returns {0 | 1 | 2} confidence + * + * 0: This is not a typed list + * 1: This is a loosely typed list + * 2: This is a strongly typed list + */ +export const isTypedList = list => { + if (!list || list.type !== 'list') { + return 0; + } + + return getTypedConfidence(list.children?.[0]?.children?.[0]?.children?.[0]); +}; diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index 5d10ad9a..bc92b7be 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -264,11 +264,33 @@ export const processEntry = entry => { // 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)) - ); + visit(entry.content, UNIST.isStronglyTypedList, (node, idx, parent) => { + // A typed list may contain trailing non-parameter items (e.g. prose + // bullets that happen to share the same loose list in the source + // markdown). Split those off so they render as regular content instead + // of being silently swallowed by the signature table. + const firstNonTyped = node.children.findIndex( + item => !UNIST.isTypedListItem(item) + ); + + if (firstNonTyped === -1) { + parent.children[idx] = createSignatureTable(node); + return; + } + + const typedItems = node.children.slice(0, firstNonTyped); + const restItems = node.children.slice(firstNonTyped); + + const replacements = []; + if (typedItems.length > 0) { + replacements.push( + createSignatureTable({ ...node, children: typedItems }) + ); + } + replacements.push({ ...node, children: restItems }); + + parent.children.splice(idx, 1, ...replacements); + }); } return entry.content;