Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions __tests__/lib/mdxish/blank-lines.test.ts
Original file line number Diff line number Diff line change
@@ -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 <br> elements', () => {
const md = 'Hello\n\n\n\nWorld';
const ast = mdxish(md);
const html = toHtml(ast);

expect(html).toContain('<br>');
expect(html).toContain('Hello');
expect(html).toContain('World');
});

it('does not insert <br> 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 <br> 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('<br>');
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('<br>');
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('<br>');
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);
});
});
6 changes: 5 additions & 1 deletion lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -261,6 +264,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
.use(rehypeRaw, { passThrough: ['html-block'] })
.use(restoreBooleanProperties)
.use(rehypeFlattenTableCellParagraphs) // Remove <p> wrappers inside table cells to prevent margin issues
.use(rehypeEmptyParagraphsToBr) // Convert empty <p> from blank lines into visible <br> spacing
.use(mdxishMermaidTransformer) // Add mermaid-render className to pre wrappers
.use(generateSlugForHeadings)
.use(rehypeMdxishComponents, {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
},
{
"path": "dist/main.node.js",
"maxSize": "855KB"
"maxSize": "860KB"
}
]
},
Expand Down
32 changes: 32 additions & 0 deletions processor/plugin/empty-paragraphs-to-br.ts
Original file line number Diff line number Diff line change
@@ -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 `<p>` elements into `<br>` elements.
*
* Empty paragraphs are inserted by `remarkRestoreBlankLines` to preserve
* vertical spacing from blank lines in the source markdown. After
* `remarkRehype` converts them to `<p></p>`, they render as invisible
* because empty block elements have no content height and their margins
* collapse. This plugin replaces them with `<br>` elements so the spacing
* is visible in the rendered output.
*/
export const rehypeEmptyParagraphsToBr = (): Transformer<Root, Root> => {
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: [],
};
}
});
};
};
31 changes: 31 additions & 0 deletions processor/transform/mdxish/restore-blank-lines.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading