diff --git a/lib/handle/emphasis.js b/lib/handle/emphasis.js index 92be547..506a308 100644 --- a/lib/handle/emphasis.js +++ b/lib/handle/emphasis.js @@ -3,7 +3,7 @@ * @import {Emphasis, Parents} from 'mdast' */ -import {checkEmphasis} from '../util/check-emphasis.js' +import {emphasisMarker} from '../util/emphasis-marker.js' import {encodeCharacterReference} from '../util/encode-character-reference.js' import {encodeInfo} from '../util/encode-info.js' @@ -11,13 +11,13 @@ emphasis.peek = emphasisPeek /** * @param {Emphasis} node - * @param {Parents | undefined} _ + * @param {Parents | undefined} parent * @param {State} state * @param {Info} info * @returns {string} */ -export function emphasis(node, _, state, info) { - const marker = checkEmphasis(state) +export function emphasis(node, parent, state, info) { + const marker = emphasisMarker(node, parent, state, info) const exit = state.enter('emphasis') const tracker = state.createTracker(info) const before = tracker.move(marker) @@ -59,11 +59,11 @@ export function emphasis(node, _, state, info) { } /** - * @param {Emphasis} _ - * @param {Parents | undefined} _1 + * @param {Emphasis} node + * @param {Parents | undefined} parent * @param {State} state * @returns {string} */ -function emphasisPeek(_, _1, state) { - return state.options.emphasis || '*' +function emphasisPeek(node, parent, state) { + return emphasisMarker(node, parent, state, {before: '', after: ''}) } diff --git a/lib/util/emphasis-marker.js b/lib/util/emphasis-marker.js new file mode 100644 index 0000000..7be3d48 --- /dev/null +++ b/lib/util/emphasis-marker.js @@ -0,0 +1,77 @@ +/** + * @import {Emphasis, Parents} from 'mdast' + * @import {State} from 'mdast-util-to-markdown' + */ + +import {checkEmphasis} from './check-emphasis.js' + +/** + * Pick the marker to use for an emphasis node, flipping from the configured + * marker to its opposite when the configured marker would fuse with an + * adjacent attention delimiter and re-parse as a different construct. + * + * Only emphasis gets the flip. Strong already round-trips through the + * spec's attention algorithm because a run of 4 asterisks pairs as two + * strong delimiters, and a run of 6 as three, and so on. Nested emphasis + * is the asymmetric case: a run of 2 asterisks pairs as one strong, not as + * two nested emphases, so without a flip `emphasis > emphasis > text` + * round-trips as `strong > text`. + * + * Two situations drive a flip, both narrowly scoped to avoid disturbing + * shapes the serializer already handles via fusion: + * + * 1. The emphasis is an only child of an attention parent (emphasis or + * strong), and both its opening and closing markers would be adjacent + * to the parent's primary marker. Using the opposite marker (for + * example, `*_a_*` for `emphasis > emphasis > text` with primary + * `*`) breaks the fusion. + * + * 2. The emphasis sits at the top of a strict same-type chain of depth at + * least 2 (each link has exactly one emphasis child), with primary + * `*`. Three-deep emphasis collapses under rule 17 unless the + * outermost marker is `_`, because `_`'s flanking rules are stricter + * than `*`'s. The check is asymmetric by design: when the configured + * marker is already `_`, the adjacency flip in rule 1 alone is enough. + * + * @param {Emphasis} node + * @param {Parents | undefined} parent + * @param {State} state + * @param {{before: string, after: string}} info + * Only the `before` and `after` fields are read. + * @returns {'*' | '_'} + */ +export function emphasisMarker(node, parent, state, info) { + const primary = checkEmphasis(state) + const other = primary === '*' ? '_' : '*' + + if ( + parent && + (parent.type === 'emphasis' || parent.type === 'strong') && + 'children' in parent && + parent.children.length === 1 && + info.before.charAt(info.before.length - 1) === primary && + info.after.charAt(0) === primary + ) { + return other + } + + if (primary === '*' && strictChainDepth(node) >= 2) return other + + return primary +} + +/** + * Count the depth of a strict single-child emphasis chain descending from + * `node`. A chain is strict when every link has exactly one child and that + * child is also `emphasis`. + * + * @param {Emphasis} node + * @returns {number} + */ +function strictChainDepth(node) { + const children = node.children + if (!children || children.length !== 1) return 0 + const only = children[0] + if (only.type !== 'emphasis') return 0 + return 1 + strictChainDepth(only) +} diff --git a/test/index.js b/test/index.js index e47facd..b317b69 100644 --- a/test/index.js +++ b/test/index.js @@ -1262,6 +1262,306 @@ test('emphasis', async function (t) { ) } ) + + await t.test( + 'should flip the inner marker for emphasis nested in emphasis', + async function () { + assert.equal( + to({ + type: 'emphasis', + children: [{type: 'emphasis', children: [{type: 'text', value: 'a'}]}] + }), + '*_a_*\n' + ) + } + ) + + await t.test( + 'should flip the inner marker for emphasis nested in strong', + async function () { + assert.equal( + to({ + type: 'strong', + children: [{type: 'emphasis', children: [{type: 'text', value: 'a'}]}] + }), + '**_a_**\n' + ) + } + ) + + await t.test( + 'should not flip strong nested in emphasis (spec fusion handles it)', + async function () { + assert.equal( + to({ + type: 'emphasis', + children: [{type: 'strong', children: [{type: 'text', value: 'a'}]}] + }), + '***a***\n' + ) + } + ) + + await t.test( + 'should not flip strong nested in strong (spec fusion handles it)', + async function () { + assert.equal( + to({ + type: 'strong', + children: [{type: 'strong', children: [{type: 'text', value: 'a'}]}] + }), + '****a****\n' + ) + } + ) + + await t.test( + 'should alternate markers for a strict three-deep emphasis chain', + async function () { + assert.equal( + to({ + type: 'emphasis', + children: [ + { + type: 'emphasis', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + } + ] + }), + '_*_a_*_\n' + ) + } + ) + + await t.test( + 'should flip emphasis-in-emphasis when primary is `_`', + async function () { + assert.equal( + to( + { + type: 'emphasis', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + }, + {emphasis: '_'} + ), + '_*a*_\n' + ) + } + ) + + await t.test( + 'should alternate markers for a three-deep chain when primary is `_`', + async function () { + assert.equal( + to( + { + type: 'emphasis', + children: [ + { + type: 'emphasis', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + } + ] + }, + {emphasis: '_'} + ), + '_*_a_*_\n' + ) + } + ) + + await t.test( + 'should not flip when the emphasis parent has more than one child', + async function () { + assert.equal( + to({ + type: 'emphasis', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]}, + {type: 'text', value: 'x'} + ] + }), + '**a*x*\n' + ) + } + ) + + await t.test( + 'should not flip when the nested emphasis is a middle sibling', + async function () { + assert.equal( + to({ + type: 'emphasis', + children: [ + {type: 'text', value: 'x'}, + {type: 'emphasis', children: [{type: 'text', value: 'a'}]}, + {type: 'text', value: 'y'} + ] + }), + '*x*a*y*\n' + ) + } + ) + + await t.test( + 'should keep adjacent attention siblings on their configured marker', + async function () { + assert.equal( + to({ + type: 'paragraph', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]}, + {type: 'strong', children: [{type: 'text', value: 'a'}]}, + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + }), + '*a***a***a*\n' + ) + } + ) + + await t.test( + 'should roundtrip parsed emphasis-in-emphasis', + async function () { + const tree = from('*_a_*') + removePosition(tree, {force: true}) + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) + + await t.test( + 'should roundtrip synthesized emphasis-in-emphasis', + async function () { + /** @type {Root} */ + const tree = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'emphasis', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + } + ] + } + ] + } + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) + + await t.test( + 'should roundtrip a three-deep emphasis chain', + async function () { + const tree = from('_*_a_*_') + removePosition(tree, {force: true}) + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) + + await t.test('should roundtrip strong wrapping emphasis', async function () { + /** @type {Root} */ + const tree = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'strong', + children: [ + {type: 'emphasis', children: [{type: 'text', value: 'a'}]} + ] + } + ] + } + ] + } + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + }) + + await t.test( + 'should roundtrip a three-deep chain that follows sibling content', + async function () { + /** @type {Root} */ + const tree = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + {type: 'text', value: 'x'}, + { + type: 'emphasis', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'emphasis', + children: [{type: 'text', value: 'a'}] + } + ] + } + ] + } + ] + } + ] + } + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) + + await t.test( + 'should leave `emphasis > strong` adjacent-attention shapes untouched', + async function () { + // GH-12 edge case: intermediate emphasis whose sibling is a text run + // still fuses through the spec's attention algorithm. Verify this fix + // does not disturb a shape where the serializer and parser already + // agree: `*a***a***a*` round-trips as `[emphasis, strong, emphasis]`. + const tree = from('*a***a***a*') + removePosition(tree, {force: true}) + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) + + await t.test( + 'should leave `***a*a*-*` fusion shape untouched', + async function () { + // GH-12 edge case: `emphasis > [emphasis > [emphasis, text], text]`. + // CommonMark's rule 17 pairs the leading `***` as fused em+strong + // openers and recovers the three-deep nesting. The helper must not + // intervene here, even though the outer emphasis has a first-child + // emphasis, because the fusion is what makes the roundtrip work. + const tree = from('***a*a*-*') + removePosition(tree, {force: true}) + const out = from(to(tree)) + removePosition(out, {force: true}) + assert.deepEqual(out, tree) + } + ) }) test('heading', async function (t) {