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 `