diff --git a/__tests__/lib/mdxish/blank-lines.test.ts b/__tests__/lib/mdxish/blank-lines.test.ts new file mode 100644 index 000000000..71ce23781 --- /dev/null +++ b/__tests__/lib/mdxish/blank-lines.test.ts @@ -0,0 +1,76 @@ +import type { Element } from 'hast'; + +import { toHtml } from 'hast-util-to-html'; + +import { mdxish } from '../../../lib/mdxish'; + +describe('blank line preservation', () => { + it('converts blank lines between paragraphs into
elements', () => { + const md = 'Hello\n\n\n\nWorld'; + const ast = mdxish(md); + const html = toHtml(ast); + + expect(html).toContain('
'); + expect(html).toContain('Hello'); + expect(html).toContain('World'); + }); + + it('does not insert
for a single blank line between paragraphs', () => { + const md = 'Hello\n\nWorld'; + const ast = mdxish(md); + + const brElements = ast.children.filter( + node => node.type === 'element' && (node as Element).tagName === 'br', + ); + expect(brElements).toHaveLength(0); + }); + + it('inserts multiple
elements for multiple blank lines', () => { + const md = 'First\n\n\n\n\n\nSecond'; + const ast = mdxish(md); + + const brElements = ast.children.filter( + node => node.type === 'element' && (node as Element).tagName === 'br', + ); + expect(brElements.length).toBeGreaterThanOrEqual(2); + }); + + it('preserves blank lines between headings and paragraphs', () => { + const md = '# Heading\n\n\n\nParagraph'; + const ast = mdxish(md); + const html = toHtml(ast); + + expect(html).toContain('
'); + expect(html).toContain('Heading'); + expect(html).toContain('Paragraph'); + }); + + it('preserves blank lines between a paragraph and a list', () => { + const md = 'Some text\n\n\n\n- item one\n- item two'; + const ast = mdxish(md); + const html = toHtml(ast); + + expect(html).toContain('
'); + expect(html).toContain('Some text'); + expect(html).toContain('item one'); + }); + + it('preserves blank lines between code blocks and paragraphs', () => { + const md = '```\ncode\n```\n\n\n\nAfter code'; + const ast = mdxish(md); + const html = toHtml(ast); + + expect(html).toContain('
'); + expect(html).toContain('After code'); + }); + + it('does not affect content without extra blank lines', () => { + const md = '# Title\n\nParagraph one\n\nParagraph two'; + const ast = mdxish(md); + + const brElements = ast.children.filter( + node => node.type === 'element' && (node as Element).tagName === 'br', + ); + expect(brElements).toHaveLength(0); + }); +}); diff --git a/lib/mdxish.ts b/lib/mdxish.ts index 2fa7038c7..2910c8178 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -19,6 +19,7 @@ import { VFile } from 'vfile'; import { mdxJsxToMarkdown } from 'mdast-util-mdx-jsx'; import { mdxishCompilers } from '../processor/compile'; +import { rehypeEmptyParagraphsToBr } from '../processor/plugin/empty-paragraphs-to-br'; import { rehypeFlattenTableCellParagraphs } from '../processor/plugin/flatten-table-cell-paragraphs'; import { rehypeMdxishComponents } from '../processor/plugin/mdxish-components'; import { mdxComponentHandlers } from '../processor/plugin/mdxish-handlers'; @@ -46,6 +47,7 @@ import { removeJSXComments, type JSXContext, } from '../processor/transform/mdxish/preprocess-jsx-expressions'; +import remarkRestoreBlankLines from '../processor/transform/mdxish/restore-blank-lines'; import restoreSnakeCaseComponentNames from '../processor/transform/mdxish/restore-snake-case-component-name'; import { preserveBooleanProperties, @@ -192,7 +194,8 @@ export function mdxishAstProcessor(mdContent: string, opts: MdxishOpts = {}) { .use(newEditorTypes ? mdxishJsxToMdast : undefined) // Convert block JSX elements to MDAST types .use(variablesTextTransformer) // Parse {user.*} patterns from text nodes .use(useTailwind ? tailwindTransformer : undefined, { components: tempComponentsMap }) - .use(remarkGfm); + .use(remarkGfm) + .use(remarkRestoreBlankLines); return { processor, @@ -261,6 +264,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { .use(rehypeRaw, { passThrough: ['html-block'] }) .use(restoreBooleanProperties) .use(rehypeFlattenTableCellParagraphs) // Remove

wrappers inside table cells to prevent margin issues + .use(rehypeEmptyParagraphsToBr) // Convert empty

from blank lines into visible
spacing .use(mdxishMermaidTransformer) // Add mermaid-render className to pre wrappers .use(generateSlugForHeadings) .use(rehypeMdxishComponents, { diff --git a/package.json b/package.json index 3ae430abb..20381743a 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ }, { "path": "dist/main.node.js", - "maxSize": "855KB" + "maxSize": "860KB" } ] }, diff --git a/processor/plugin/empty-paragraphs-to-br.ts b/processor/plugin/empty-paragraphs-to-br.ts new file mode 100644 index 000000000..e80379780 --- /dev/null +++ b/processor/plugin/empty-paragraphs-to-br.ts @@ -0,0 +1,32 @@ +import type { Element, Root } from 'hast'; +import type { Transformer } from 'unified'; + +import { visit } from 'unist-util-visit'; + +/** + * Rehype plugin that converts empty `

` elements into `
` elements. + * + * Empty paragraphs are inserted by `remarkRestoreBlankLines` to preserve + * vertical spacing from blank lines in the source markdown. After + * `remarkRehype` converts them to `

`, they render as invisible + * because empty block elements have no content height and their margins + * collapse. This plugin replaces them with `
` elements so the spacing + * is visible in the rendered output. + */ +export const rehypeEmptyParagraphsToBr = (): Transformer => { + return (tree: Root) => { + visit(tree, 'element', (node: Element, index, parent) => { + if (index === undefined || !parent) return; + if (node.tagName !== 'p') return; + + if (node.children.length === 0) { + parent.children[index] = { + type: 'element', + tagName: 'br', + properties: {}, + children: [], + }; + } + }); + }; +}; diff --git a/processor/transform/mdxish/restore-blank-lines.ts b/processor/transform/mdxish/restore-blank-lines.ts new file mode 100644 index 000000000..f08887164 --- /dev/null +++ b/processor/transform/mdxish/restore-blank-lines.ts @@ -0,0 +1,31 @@ +import type { Root, RootContent } from 'mdast'; +import type { Plugin } from 'unified'; + +/** + * The markdown parser collapses multiple blank lines between adjacent + * flow-level elements into a single paragraph break. This remark plugin + * restores empty paragraph nodes by detecting position gaps larger than the + * standard block separator (gap of 2 lines = single `\n\n`). + */ +const remarkRestoreBlankLines: Plugin<[], Root> = () => tree => { + const newChildren: RootContent[] = []; + + for (let i = 0; i < tree.children.length; i += 1) { + const curr = tree.children[i]; + newChildren.push(curr); + + const next = tree.children[i + 1]; + if (next?.position && curr.position) { + const lineGap = next.position.start.line - curr.position.end.line; + const emptyParagraphs = Math.floor((lineGap - 2) / 2); + + for (let j = 0; j < emptyParagraphs; j += 1) { + newChildren.push({ type: 'paragraph', children: [] }); + } + } + } + + tree.children = newChildren; +}; + +export default remarkRestoreBlankLines;