From f3c551a92e308296cca74bda675d5201085c0171 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Sat, 29 Aug 2026 02:50:33 +0000 Subject: [PATCH] fix(remark-stringify): omit trailing breaks that cannot round-trip CommonMark cannot parse a trailing `\`+newline as a hard break, so emitting one turned a trailing break into a literal backslash on parse. Drop trailing breaks on stringify; keep mid-phrasing breaks unchanged. Fixes remarkjs/remark#1477 --- packages/remark-stringify/lib/index.js | 43 ++++++++++++-- test.js | 77 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/remark-stringify/lib/index.js b/packages/remark-stringify/lib/index.js index 00118166..1dcdd940 100644 --- a/packages/remark-stringify/lib/index.js +++ b/packages/remark-stringify/lib/index.js @@ -1,6 +1,6 @@ /** - * @import {Root} from 'mdast' - * @import {Options as ToMarkdownOptions} from 'mdast-util-to-markdown' + * @import {Break, Parents, Root} from 'mdast' + * @import {Info, Options as ToMarkdownOptions, State} from 'mdast-util-to-markdown' * @import {Processor} from 'unified' */ @@ -8,7 +8,7 @@ * @typedef {Omit} Options */ -import {toMarkdown} from 'mdast-util-to-markdown' +import {defaultHandlers, toMarkdown} from 'mdast-util-to-markdown' /** * Add support for serializing to markdown. @@ -34,7 +34,42 @@ export default function remarkStringify(options) { // Note: this option is not in the readme. // The goal is for it to be set by plugins on `data` instead of being // passed by users. - extensions: self.data('toMarkdownExtensions') || [] + extensions: [ + {handlers: {break: hardBreak}}, + ...(self.data('toMarkdownExtensions') || []) + ] }) } } + +/** + * Serialize a `break`. + * + * CommonMark cannot represent a break at the end of phrasing, so trailing + * breaks are omitted instead of emitting a `\` that would parse as text. + * + * @param {Break} node + * Break to serialize. + * @param {Parents | undefined} parent + * Parent of `node`. + * @param {State} state + * Info passed around. + * @param {Info} info + * Info on the surrounding of the node. + * @returns {string} + * Serialized markdown. + */ +function hardBreak(node, parent, state, info) { + const siblings = /** @type {Array<{type: string}>} */ ( + /** @type {Parents} */ (parent).children + ) + let index = siblings.indexOf(node) + + while (++index < siblings.length) { + if (siblings[index].type !== 'break') { + return defaultHandlers.break(node, parent, state, info) + } + } + + return '' +} diff --git a/test.js b/test.js index 7f3d0b63..801155e0 100644 --- a/test.js +++ b/test.js @@ -205,6 +205,83 @@ test('remark-stringify', async function (t) { ) }) + await t.test( + 'should not serialize a trailing break as a backslash (#1477)', + async function () { + const md = unified() + .use(remarkStringify) + .stringify({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [{type: 'text', value: 'test'}, {type: 'break'}] + } + ] + }) + + assert.equal(md, 'test\n') + + const tree = unified().use(remarkParse).parse(md) + removePosition(tree, {force: true}) + + assert.deepEqual(tree, { + type: 'root', + children: [ + {type: 'paragraph', children: [{type: 'text', value: 'test'}]} + ] + }) + } + ) + + await t.test( + 'should still serialize a break between phrasing', + async function () { + assert.equal( + unified() + .use(remarkStringify) + .stringify({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + {type: 'text', value: 'alpha'}, + {type: 'break'}, + {type: 'text', value: 'bravo'} + ] + } + ] + }), + 'alpha\\\nbravo\n' + ) + } + ) + + await t.test( + 'should not serialize multiple trailing breaks as backslashes', + async function () { + assert.equal( + unified() + .use(remarkStringify) + .stringify({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + {type: 'text', value: 'test'}, + {type: 'break'}, + {type: 'break'} + ] + } + ] + }), + 'test\n' + ) + } + ) + await t.test('should support extensions', async function () { const result = unified() .data('toMarkdownExtensions', [gfmToMarkdown()])