From 571b9da89ee36033d5f46bd2acd85db0db6f036d Mon Sep 17 00:00:00 2001 From: Silas Santini <70163606+pancakereport@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:47:33 -0700 Subject: [PATCH] fix: parse table cells through MyST's prose parser, not literal text astra-directive table output (CSV/JSON artifacts) built cells with the raw text() AST helper, bypassing myst-parser entirely. LaTeX ($...$), emphasis, and links in cell data rendered as literal characters instead of real inline nodes, unlike MyST's native csv-table/list-table directives, which nested-parse every cell as prose. tableNodeFromData now runs each header/cell through prose.inline(), the same parser already used for captions and the input/output registry's description column, so table output stays consistent with the rest of MyST rather than reimplementing its own cell rendering. Co-Authored-By: Claude Sonnet 5 --- src/transform/render-evidence.ts | 40 ++++++++++++++------ tests/render-evidence.test.ts | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 tests/render-evidence.test.ts diff --git a/src/transform/render-evidence.ts b/src/transform/render-evidence.ts index 4967684..bb11c56 100644 --- a/src/transform/render-evidence.ts +++ b/src/transform/render-evidence.ts @@ -212,7 +212,7 @@ export function renderOneOutput( const tableLabel = output.label ?? artifactId; const captionChildren = output.description ? prose.inline(output.description) : [text(tableLabel)]; return [ - container('table', [tableNodeFromData(data), caption([paragraph(captionChildren)])], identifier), + container('table', [tableNodeFromData(data, prose), caption([paragraph(captionChildren)])], identifier), ]; } const fallback: any = paragraph([text('Table: '), inlineCode(artifactId)]); @@ -225,11 +225,11 @@ export function renderOneOutput( // "label: value ± uncertainty unit" sentence), so a rich theme that // overrides the carrier renders its big-stat from `store.metric` and // replaces the fallback wholesale — the #11 pattern. - return [carrierDiv(metricFallback(output, artifactId, resultPath, opts?.vfile), identifier)]; + return [carrierDiv(metricFallback(output, artifactId, resultPath, prose, opts?.vfile), identifier)]; default: { // data / report: render inline, then tag the first node with // the `output-` carrier so cross-references resolve to it. - const nodes = renderInlineArtifact(output, artifactId, resultPath, undefined, opts?.vfile); + const nodes = renderInlineArtifact(output, artifactId, resultPath, prose, undefined, opts?.vfile); if (nodes.length > 0 && !nodes[0].identifier) { nodes[0].identifier = identifier; nodes[0].label = identifier; @@ -250,6 +250,7 @@ function metricFallback( output: Output, artifactId: string, resultPath: string, + prose: ProseParser, vfile?: any, ): any[] { const metric = readMetric(resultPath); @@ -266,7 +267,7 @@ function metricFallback( if (unit) parts.push(text(` ${unit}`)); return [paragraph(parts)]; } - return renderTabularFile(resultPath, artifactId, output.label ?? artifactId, vfile); + return renderTabularFile(resultPath, artifactId, output.label ?? artifactId, prose, vfile); } function renderArtifactEvidence( @@ -311,12 +312,12 @@ function renderArtifactEvidence( ); break; case 'table': - nodes.push(...renderTableArtifact(output, artifactId, resultPath, opts?.vfile)); + nodes.push(...renderTableArtifact(output, artifactId, resultPath, prose, opts?.vfile)); break; case 'metric': case 'data': case 'report': - nodes.push(...renderInlineArtifact(output, artifactId, resultPath, evidence, opts?.vfile)); + nodes.push(...renderInlineArtifact(output, artifactId, resultPath, prose, evidence, opts?.vfile)); break; } @@ -327,11 +328,12 @@ function renderTableArtifact( output: Output, artifactId: string, resultPath: string, + prose: ProseParser, vfile?: any, ): any[] { const tableLabel = output.label ?? artifactId; const ext = fileExt(resultPath); - if (ext === 'json' || ext === 'csv') return renderTabularFile(resultPath, artifactId, tableLabel, vfile); + if (ext === 'json' || ext === 'csv') return renderTabularFile(resultPath, artifactId, tableLabel, prose, vfile); // Output declared as a table but the produced artifact isn't // a known tabular extension — fall back to a labelled reference. return [paragraph([text('Table: '), inlineCode(artifactId)])]; @@ -341,6 +343,7 @@ function renderInlineArtifact( output: Output, artifactId: string, resultPath: string, + prose: ProseParser, evidence?: Evidence, vfile?: any, ): any[] { @@ -349,7 +352,7 @@ function renderInlineArtifact( // (when present) the author's quote as a blockquote. const ext = fileExt(resultPath); if (ext === 'json' || ext === 'csv') { - return renderTabularFile(resultPath, artifactId, output.label ?? artifactId, vfile); + return renderTabularFile(resultPath, artifactId, output.label ?? artifactId, prose, vfile); } const nodes: any[] = []; @@ -378,6 +381,7 @@ function renderTabularFile( filePath: string, artifactId: string, tableLabel: string, + prose: ProseParser, vfile?: any, ): any[] { const data = parseTableData(filePath); @@ -394,7 +398,7 @@ function renderTabularFile( if (data.headers.length === 0 || data.rows.length === 0) { return [paragraph([text(`Empty table: ${artifactId}`)])]; } - return [details([summary([text(tableLabel)]), tableNodeFromData(data)], false)]; + return [details([summary([text(tableLabel)]), tableNodeFromData(data, prose)], false)]; } /** @@ -402,18 +406,30 @@ function renderTabularFile( * tables (parseTableData sets `headers[0] === ''`) render the outer key in * the first column as bold. No wrapper — callers decide whether to place it * in a `details`, a `container[table]`, etc. + * + * Each cell is parsed as inline MyST (`prose.inline`), the same engine every + * other authored field in MySTRA goes through (captions, descriptions) — so + * `$\chi^2$`-style LaTeX, emphasis, links, etc. in a CSV/JSON artifact render + * the same way they would in MyST's own `csv-table` / `list-table`, instead + * of printing as literal text. */ -export function tableNodeFromData(data: TableData): any { +export function tableNodeFromData(data: TableData, prose: ProseParser): any { const isNestedObject = data.headers[0] === ''; const displayHeaders = isNestedObject ? ['', ...data.headers.slice(1)] : data.headers; + const cellChildren = (raw: string): any[] => { + const parsed = prose.inline(raw); + return parsed.length > 0 ? parsed : [text('')]; + }; const headerRow = tableRow( - displayHeaders.map((c) => tableCell([text(c)], true)), + displayHeaders.map((c) => tableCell(cellChildren(c), true)), true, ); const rows = data.rows.map((row) => tableRow( row.map((cell, i) => - isNestedObject && i === 0 ? tableCell([strong([text(cell)])]) : tableCell([text(cell)]), + isNestedObject && i === 0 + ? tableCell([strong(cellChildren(cell))]) + : tableCell(cellChildren(cell)), ), ), ); diff --git a/tests/render-evidence.test.ts b/tests/render-evidence.test.ts new file mode 100644 index 0000000..9231e61 --- /dev/null +++ b/tests/render-evidence.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for tableNodeFromData: table cells must parse as inline MyST (like + * MyST's native csv-table / list-table directives), not render as literal text. + */ + +import { describe, it, expect } from 'vitest'; +import { tableNodeFromData } from '../src/transform/render-evidence.js'; +import { proseParser } from '../src/transform/prose.js'; +import type { TableData } from '../src/transform/parse-table-data.js'; + +function collectNodes(node: any, type: string): any[] { + const collected: any[] = []; + const walk = (n: any) => { + if (Array.isArray(n)) return n.forEach(walk); + if (!n || typeof n !== 'object') return; + if (n.type === type) collected.push(n); + Object.values(n).forEach(walk); + }; + walk(node); + return collected; +} + +describe('tableNodeFromData', () => { + it('parses LaTeX math in a cell to an inlineMath node, not literal text', () => { + const data: TableData = { + headers: ['tracer', 'value'], + rows: [['lrg', '$\\chi^2_{red}$']], + }; + const node = tableNodeFromData(data, proseParser); + const math = collectNodes(node, 'inlineMath'); + expect(math).toHaveLength(1); + expect(math[0].value).toBe('\\chi^2_{red}'); + // The raw dollar-quoted string must not survive as a literal text node. + const texts = collectNodes(node, 'text').map((t) => t.value); + expect(texts.join('')).not.toContain('$'); + }); + + it('parses markdown emphasis/links in headers and cells', () => { + const data: TableData = { + headers: ['**Tracer**', 'note'], + rows: [['lrg', 'see [docs](https://example.org)']], + }; + const node = tableNodeFromData(data, proseParser); + expect(collectNodes(node, 'strong')).toHaveLength(1); + expect(collectNodes(node, 'link')).toHaveLength(1); + }); + + it('keeps the nested-object first column bold around parsed content', () => { + const data: TableData = { + headers: ['', 'value'], + rows: [['$\\Omega_m$', '0.3']], + }; + const node = tableNodeFromData(data, proseParser); + const firstCell = node.children[1].children[0]; + expect(firstCell.children[0].type).toBe('strong'); + expect(collectNodes(firstCell, 'inlineMath')).toHaveLength(1); + }); + + it('renders an empty cell as a single empty text node, not a crash', () => { + const data: TableData = { headers: ['a', 'b'], rows: [['x', '']] }; + const node = tableNodeFromData(data, proseParser); + const cells = node.children[1].children; + expect(cells[1].children).toEqual([{ type: 'text', value: '' }]); + }); +});