diff --git a/__tests__/lib/stripComments.test.ts b/__tests__/lib/stripComments.test.ts index 6794ab628..e1d4753a9 100644 --- a/__tests__/lib/stripComments.test.ts +++ b/__tests__/lib/stripComments.test.ts @@ -356,6 +356,89 @@ end"`); expect(output).toContain('A name field'); }); + it('does not break an HTML block when an inner comment occupies a whole line', async () => { + const input = ` + + + + + +
Row 1
Row 3
`; + + const expected = ` + + + + +
Row 1
Row 3
`; + + const output = await stripComments(input, { mdxish: true }); + expect(output).toBe(expected); + }); + + it('removes only the comment line and preserves authorial blank lines around it', async () => { + const input = ` + + + + + + + + + + +
row 1
row 2
row 3
`; + + const expected = ` + + + + + + + + +
row 1
row 2
row 3
`; + + const output = await stripComments(input, { mdxish: true }); + expect(output).toBe(expected); + }); + + it('preserves authorial blank lines inside a table cell', async () => { + const input = ` + + + +
+ line 1 + + line 2 +
`; + + const output = await stripComments(input, { mdxish: true }); + expect(output).toBe(input); + }); + + it('preserves a fenced code block (with internal blank lines) inside a table cell', async () => { + const input = ` + + + +
+ +\`\`\`js +const a = 1; + +const b = 2; +\`\`\` + +
`; + + const output = await stripComments(input, { mdxish: true }); + expect(output).toBe(input); + }); + it('strips comments inside jsx tables in mdxish mode', async () => { const input = ` diff --git a/__tests__/parsers/tables.test.ts b/__tests__/parsers/tables.test.ts index bab7a7f28..02f81fb5f 100644 --- a/__tests__/parsers/tables.test.ts +++ b/__tests__/parsers/tables.test.ts @@ -6,6 +6,7 @@ import { removePosition } from 'unist-util-remove-position'; import { mdast } from '../../lib'; import { mdxish } from '../../lib/mdxish'; +import { collapseBlankLines } from '../../processor/transform/mdxish/tables/mdxish-tables'; import { collectNodes, findAllElementsByTagName, parseMdxishWithSource } from '../helpers'; describe('table parser', () => { @@ -1170,4 +1171,79 @@ None of the following content will get rendered!`; expect(html).toContain('another_name_here'); }); }); + + describe('lowercase fallback path', () => { + it('keeps a block-with-whitespace-only-line intact when the mdx-aware parser throws on a cell expression', () => { + const doc = ['
', ' ', ' ', ' ', ' ', '
{not valid jsx}
'].join('\n'); + + const hast = mdxish(doc); + const html = toHtml(hast); + + expect(html).not.toContain('
');
+      expect(html).toContain('{not valid jsx}');
+    });
+
+    it('keeps a block-with-empty-line intact when the mdx-aware parser throws on a cell expression', () => {
+      const doc = ['', '    ', '        ', '', '    ', '
{not valid jsx}
'].join('\n'); + + const hast = mdxish(doc); + const html = toHtml(hast); + + expect(html).not.toContain('
');
+      expect(html).toContain('{not valid jsx}');
+    });
+
+    it('keeps a block-with-many-blank-lines intact', () => {
+      const doc = [
+        '',
+        '    ',
+        '        ',
+        '',
+        '',
+        '',
+        '',
+        '',
+        '',
+        '    ',
+        '
{not valid jsx}
', + ].join('\n'); + + const hast = mdxish(doc); + const html = toHtml(hast); + + expect(html).not.toContain('
');
+      expect(html).toContain('{not valid jsx}');
+    });
+
+    it('collapseBlankLines: removes every blank line, leaves every other line byte-identical', () => {
+      const input = [
+        '',
+        '    ',
+        '        ',
+        '    ',
+        '        ',
+        '',
+        '        ',
+        '    ',
+        '
row 1row 2row 3
', + ].join('\n'); + + const output = collapseBlankLines(input); + const inputLines = input.split('\n'); + const nonBlankInputLines = inputLines.filter(line => !/^[ \t]*$/.test(line)); + + expect(output.split('\n')).toStrictEqual(nonBlankInputLines); + }); + + it('collapseBlankLines: collapses any run of consecutive blank lines to a single newline', () => { + expect(collapseBlankLines('a\n\n\nb')).toBe('a\nb'); + expect(collapseBlankLines('a\n\n\n\n\n\n\nb')).toBe('a\nb'); + expect(collapseBlankLines('a\n \n \nb')).toBe('a\nb'); + }); + + it('collapseBlankLines: does not strip trailing whitespace on non-empty lines', () => { + const input = 'a \nb'; + expect(collapseBlankLines(input)).toBe(input); + }); + }); }); diff --git a/example/Doc.tsx b/example/Doc.tsx index 7bcd9bffe..149afae40 100644 --- a/example/Doc.tsx +++ b/example/Doc.tsx @@ -53,6 +53,20 @@ const variables = { ], }; +interface StripState { + error: string | null; + stripped: string | null; +} + +type PipelineKey = 'legacy' | 'mdxish' | 'rmdx'; + +const EMPTY_STRIP_STATE: StripState = { error: null, stripped: null }; +const EMPTY_STRIP_STATE_MAP: Record = { + legacy: EMPTY_STRIP_STATE, + mdxish: EMPTY_STRIP_STATE, + rmdx: EMPTY_STRIP_STATE, +}; + const Doc = () => { const { fixture } = useParams(); const [searchParams] = useSearchParams(); @@ -61,7 +75,6 @@ const Doc = () => { const mdxish = searchParams.has('mdxish'); const showRmdx = searchParams.has('rmdx'); - type PipelineKey = 'legacy' | 'mdxish' | 'rmdx'; const selectedPipelines: PipelineKey[] = []; if (showRmdx) selectedPipelines.push('rmdx'); if (legacy) selectedPipelines.push('legacy'); @@ -88,8 +101,8 @@ const Doc = () => { const [rmdxHast, setRmdxHast] = useState(null); const [mdxishMdast, setMdxishMdast] = useState(null); const [mdxishHast, setMdxishHast] = useState(null); - const [strippedMarkdown, setStrippedMarkdown] = useState(null); - const [stripError, setStripError] = useState(null); + const [stripByPipeline, setStripByPipeline] = useState>(EMPTY_STRIP_STATE_MAP); + const hasAnyStripped = Object.values(stripByPipeline).some(s => s.stripped !== null); const [view, setView] = useState<'hast' | 'markdown' | 'mdast' | 'rendered'>('rendered'); const showToc = fixture === 'tableOfContentsTests'; @@ -101,8 +114,7 @@ const Doc = () => { useEffect(() => { const sanitize = async (mode: PipelineKey) => { if (!stripComments) { - setStrippedMarkdown(null); - setStripError(null); + setStripByPipeline(prev => ({ ...prev, [mode]: EMPTY_STRIP_STATE })); return doc; } try { @@ -110,15 +122,13 @@ const Doc = () => { mdx: mode === 'rmdx', mdxish: mode === 'mdxish', }); - setStrippedMarkdown(sanitized); - setStripError(null); + setStripByPipeline(prev => ({ ...prev, [mode]: { stripped: sanitized, error: null } })); return sanitized; } catch (e) { // eslint-disable-next-line no-console console.error(e); const message = e instanceof Error ? e.message : String(e); - setStripError(message); - setStrippedMarkdown(null); + setStripByPipeline(prev => ({ ...prev, [mode]: { stripped: null, error: message } })); return null; } }; @@ -261,7 +271,7 @@ const Doc = () => {
{!ci &&

{name}

} - {(strippedMarkdown !== null || showAst) && ( + {(hasAnyStripped || showAst) && (
- {strippedMarkdown !== null && ( + {hasAnyStripped && (
)} - {stripError && ( -
- stripComments error: {stripError} + {view === 'markdown' && hasAnyStripped ? ( +
+ {activePipelines.map(p => { + const { error, stripped } = stripByPipeline[p]; + return ( +
+
{pipelineLabels[p]}
+ {error && ( +
+ stripComments error: {error} +
+ )} + {stripped !== null ? ( +
{stripped}
+ ) : ( + !error &&
No stripped output for this pipeline
+ )} +
+ ); + })}
- )} - {view === 'markdown' && strippedMarkdown !== null ? ( -
{strippedMarkdown}
) : (view === 'mdast' || view === 'hast') && showAst ? (
{activePipelines.map(p => { diff --git a/package.json b/package.json index fa071476e..0b6fd595d 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ }, { "path": "dist/main.node.js", - "maxSize": "947KB" + "maxSize": "950KB" } ] }, diff --git a/processor/transform/mdxish/tables/mdxish-tables.ts b/processor/transform/mdxish/tables/mdxish-tables.ts index 46e217ff7..45590179f 100644 --- a/processor/transform/mdxish/tables/mdxish-tables.ts +++ b/processor/transform/mdxish/tables/mdxish-tables.ts @@ -61,6 +61,15 @@ const buildTableNodeProcessor = (withMdx: boolean) => const tableNodeProcessor = buildTableNodeProcessor(true); const fallbackTableNodeProcessor = buildTableNodeProcessor(false); +const BLANK_LINE_REGEX = /(\r?\n)(?:[ \t]*\r?\n)+/g; + +/** + * Collapses any run of blank lines (empty or whitespace-only) to a single + * newline so the CommonMark type-6 block isn't terminated mid-table. + */ +export const collapseBlankLines = (value: string): string => + value.replace(BLANK_LINE_REGEX, '$1'); + /** * Parse the HTML node that contains the full table substring * into the table parts (headers, rows, cells). @@ -355,8 +364,9 @@ const mdxishTables = (): Transform => tree => { } else if (node.value.startsWith('/g; +// Indented whole-line comment plus trailing newline; removing the whole line +// avoids leaving a whitespace-only line that terminates the surrounding block. +const WHOLE_LINE_HTML_COMMENT_REGEX = /^[ \t]+[ \t]*(?:\r?\n|$)/gm; export const MDX_COMMENT_REGEX = /\/\*(?:(?!\*\/)[\s\S])*\*\//g; /** @@ -14,7 +17,10 @@ export const stripCommentsTransformer = () => { if (parent && typeof index === 'number') { // Remove HTML comments if (node.type === 'html' && HTML_COMMENT_REGEX.test(node.value)) { - const newValue = node.value.replace(HTML_COMMENT_REGEX, '').trim(); + const newValue = node.value + .replace(WHOLE_LINE_HTML_COMMENT_REGEX, '') + .replace(HTML_COMMENT_REGEX, '') + .trim(); if (newValue) { node.value = newValue; } else {