diff --git a/__tests__/compilers/gfm.test.ts b/__tests__/compilers/gfm.test.ts index 8965c952f..638e243f9 100644 --- a/__tests__/compilers/gfm.test.ts +++ b/__tests__/compilers/gfm.test.ts @@ -272,5 +272,75 @@ describe('GFM footnotes', () => { | undefined; expect(footnoteDef).toBeDefined(); }); + + it('processes footnote reference inside a GFM pipe-table cell', () => { + const markdown = [ + '| Term | Definition |', + '| ------------ | ------------- |', + '| Example[^1] | See footnote |', + '', + '[^1]: This footnote should render.', + ].join('\n'); + const hast = mdxish(markdown); + + const findElement = (root: typeof hast, tag: string): Element | undefined => { + const stack: typeof hast.children = [...root.children]; + while (stack.length) { + const node = stack.shift(); + if (node?.type === 'element') { + if (node.tagName === tag) return node; + stack.unshift(...node.children); + } + } + return undefined; + }; + + const footnoteRef = findElement(hast, 'sup'); + expect(footnoteRef).toBeDefined(); + + const footnoteSection = hast.children.find( + child => child.type === 'element' && child.tagName === 'section', + ) as Element | undefined; + expect(footnoteSection).toBeDefined(); + }); + + it('processes footnote reference inside a JSX cell', () => { + const markdown = [ + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
TermDefinition
Example[^1]See footnote
', + '', + '[^1]: This footnote should render.', + ].join('\n'); + const hast = mdxish(markdown); + + const findElement = (root: typeof hast, tag: string): Element | undefined => { + const stack: typeof hast.children = [...root.children]; + while (stack.length) { + const node = stack.shift(); + if (node?.type === 'element') { + if (node.tagName === tag) return node; + stack.unshift(...node.children); + } + } + return undefined; + }; + + const footnoteRef = findElement(hast, 'sup'); + expect(footnoteRef).toBeDefined(); + + const footnoteSection = hast.children.find( + child => child.type === 'element' && child.tagName === 'section', + ) as Element | undefined; + expect(footnoteSection).toBeDefined(); + }); }); }); diff --git a/processor/transform/mdxish/tables/mdxish-tables.ts b/processor/transform/mdxish/tables/mdxish-tables.ts index 6f58e32a7..74e6a8277 100644 --- a/processor/transform/mdxish/tables/mdxish-tables.ts +++ b/processor/transform/mdxish/tables/mdxish-tables.ts @@ -1,4 +1,4 @@ -import type { Html, Node, Parents, Root, Table, TableCell, TableRow } from 'mdast'; +import type { FootnoteDefinition, Html, Node, Parents, Root, RootContent, Table, TableCell, TableRow } from 'mdast'; import type { Transform } from 'mdast-util-from-markdown'; import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; @@ -62,6 +62,29 @@ const buildTableNodeProcessor = (withMdx: boolean) => const tableNodeProcessor = buildTableNodeProcessor(true); const fallbackTableNodeProcessor = buildTableNodeProcessor(false); +/** + * Collect outer-tree footnote ids so cell re-parses can be primed with + * placeholder defs and recognize `[^id]` as a `footnoteReference`. + */ +const collectFootnoteIds = (tree: Node): string[] => { + const ids = new Set(); + visit(tree, 'footnoteDefinition', (definition: FootnoteDefinition) => { + if (definition.identifier) ids.add(definition.identifier); + }); + return [...ids]; +}; + +/** + * Append placeholder defs so the isolated cell parse tokenizes `[^id]` as a + * `footnoteReference`, `remark-gfm` requires the def in the same parse context. + */ +const appendFootnotePlaceholders = (value: string, ids: string[]): string => { + if (ids.length === 0) return value; + const placeholders = ids.map(id => `[^${id}]: x`).join('\n'); + const separator = value.endsWith('\n') ? '\n' : '\n\n'; + return `${value}${separator}${placeholders}`; +}; + /** * Parse the HTML node that contains the full table substring * into the table parts (headers, rows, cells). @@ -72,10 +95,12 @@ const parseTableNode = ( processor: typeof tableNodeProcessor, node: Html, repair?: { inserts: Insert[]; originalSource: string }, + outerFootnoteIds: string[] = [], ): Root | undefined => { + const value = appendFootnotePlaceholders(node.value, outerFootnoteIds); let parsed: Root; try { - parsed = processor.runSync(processor.parse(node.value)) as Root; + parsed = processor.runSync(processor.parse(value)) as Root; } catch { return undefined; } @@ -143,6 +168,7 @@ const processTableNode = ( index: number, parent: Parents, documentPosition?: Node['position'], + outerFootnoteIds: string[] = [], ): void => { if (node.name !== 'Table' && node.name !== 'table') return; @@ -178,10 +204,13 @@ const processTableNode = ( // gate this behind a try/catch to ensure that malformed syntaxes do not // crash the page try { - const parsed = tableNodeProcessor.runSync(tableNodeProcessor.parse(textContent)) as Root; - if (parsed.children.length > 0) { - cell.children = parsed.children as MdxJsxTableCell['children']; - if (hasFlowContent(parsed.children as Node[])) { + const inputForParse = appendFootnotePlaceholders(textContent, outerFootnoteIds); + const parsed = tableNodeProcessor.runSync(tableNodeProcessor.parse(inputForParse)) as Root; + // Synthetic placeholder definitions belong to the outer document, not the cell + const cleanedChildren = parsed.children.filter(child => child.type !== 'footnoteDefinition'); + if (cleanedChildren.length > 0) { + cell.children = cleanedChildren as MdxJsxTableCell['children']; + if (hasFlowContent(cleanedChildren as Node[])) { tableHasFlowContent = true; } } @@ -348,6 +377,7 @@ const processTableNode = ( * is kept as a JSX element so that remarkRehype can properly handle the flow content. */ const mdxishTables = (): Transform => tree => { + const outerFootnoteIds = collectFootnoteIds(tree as Node); visit(tree, 'html', (_node, index, parent) => { const node = _node as Html; if (typeof index !== 'number' || !parent || !('children' in parent)) return; @@ -357,7 +387,7 @@ const mdxishTables = (): Transform => tree => { // Because the processor uses remarkMdx, it is stricter in what it accepts // and only accepts valid MDX syntax. in the table node. // To get around that, we have some fallback logics after trying to repair the table content. - let parsed = parseTableNode(tableNodeProcessor, node); + let parsed = parseTableNode(tableNodeProcessor, node, undefined, outerFootnoteIds); if (!parsed) { // Try a sequence of targeted repairs and re-parse // after each, stopping at the first that yields a parseable tree: @@ -375,7 +405,12 @@ const mdxishTables = (): Transform => tree => { repairs.some(repair => { const { value, inserts } = repair(node.value); if (value !== node.value) { - parsed = parseTableNode(tableNodeProcessor, { ...node, value }, { inserts, originalSource: node.value }); + parsed = parseTableNode( + tableNodeProcessor, + { ...node, value }, + { inserts, originalSource: node.value }, + outerFootnoteIds, + ); } return Boolean(parsed); }); @@ -386,16 +421,23 @@ const mdxishTables = (): Transform => tree => { // to build on the markdown / JSX table visit(parsed as Node, isMDXElement, (tableNode: MdxJsxFlowElement | MdxJsxTextElement) => { if (tableNode.name !== 'Table' && tableNode.name !== 'table') return undefined; - processTableNode(tableNode, index, parent as Parents, node.position); + processTableNode(tableNode, index, parent as Parents, node.position, outerFootnoteIds); return EXIT; }); } else if (node.value.startsWith(' { + if (child.type !== 'footnoteDefinition') return true; + return !outerFootnoteIdSet.has((child as FootnoteDefinition).identifier); + }); + if (cleaned.length <= 1) return; + parent.children.splice(index, 1, ...(cleaned as typeof parent.children)); } // Otherwise, there's no point in trying to parse the table content further // More repairs are needed in that case