Skip to content

Commit 121fc17

Browse files
author
Chad Murphy
committed
fix: flip attention marker to avoid nested-emphasis fusion
Problem: when an emphasis or strong node contains another emphasis or strong node at its first or last child position, both handlers emit the same configured marker (`*` by default). The adjacent opening or closing markers fuse (`*` + `*` = `**`) and re-tokenize as a different construct. For example, `emphasis > emphasis > text"a"` serialized to `**a**`, which re-parses as `strong`. Goal: pick a marker for each attention node that does not fuse with its neighbors, mirroring how list.js already flips bullets via checkBulletOther when collisions would form a thematic break. Changes: - add lib/util/emphasis-strong-marker.js to pick the marker per node using two tests: adjacency via info.before/info.after, and a boundary-chain check that forces the outer marker to `_` when three or more same-type emphasis are nested along the first or last child path and the primary marker is `*` (CommonMark's flanking rules only allow three-deep round-trip when the outer is `_`) - switch lib/handle/emphasis.js and lib/handle/strong.js to use the new utility in place of checkEmphasis / checkStrong - add tests covering emphasis-in-emphasis, strong-in-emphasis, emphasis-in-strong, three-deep emphasis, last-child boundary detection, and round-trip equivalence for the reproducer Notes: considered an escape-based fix (`*\\*a\\**`) and a character-reference approach; both are noisier than flipping and do not match the shape users write. A four-deep same-type chain is not addressed: no pure flip resolves it because CommonMark's pairing algorithm fuses delimiters regardless of outer choice. Closes #12
1 parent ee3b345 commit 121fc17

4 files changed

Lines changed: 200 additions & 4 deletions

File tree

lib/handle/emphasis.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @import {Emphasis, Parents} from 'mdast'
44
*/
55

6-
import {checkEmphasis} from '../util/check-emphasis.js'
6+
import {emphasisStrongMarker} from '../util/emphasis-strong-marker.js'
77
import {encodeCharacterReference} from '../util/encode-character-reference.js'
88
import {encodeInfo} from '../util/encode-info.js'
99

@@ -17,7 +17,7 @@ emphasis.peek = emphasisPeek
1717
* @returns {string}
1818
*/
1919
export function emphasis(node, _, state, info) {
20-
const marker = checkEmphasis(state)
20+
const marker = emphasisStrongMarker(node, state, info)
2121
const exit = state.enter('emphasis')
2222
const tracker = state.createTracker(info)
2323
const before = tracker.move(marker)

lib/handle/strong.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @import {Parents, Strong} from 'mdast'
44
*/
55

6-
import {checkStrong} from '../util/check-strong.js'
6+
import {emphasisStrongMarker} from '../util/emphasis-strong-marker.js'
77
import {encodeCharacterReference} from '../util/encode-character-reference.js'
88
import {encodeInfo} from '../util/encode-info.js'
99

@@ -17,7 +17,7 @@ strong.peek = strongPeek
1717
* @returns {string}
1818
*/
1919
export function strong(node, _, state, info) {
20-
const marker = checkStrong(state)
20+
const marker = emphasisStrongMarker(node, state, info)
2121
const exit = state.enter('strong')
2222
const tracker = state.createTracker(info)
2323
const before = tracker.move(marker + marker)

lib/util/emphasis-strong-marker.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @import {Emphasis, Strong} from 'mdast'
3+
* @import {Info, State} from 'mdast-util-to-markdown'
4+
*/
5+
6+
import {checkEmphasis} from './check-emphasis.js'
7+
import {checkStrong} from './check-strong.js'
8+
9+
/**
10+
* Pick the marker for an emphasis or strong node, flipping to the opposite
11+
* marker when the configured marker would fuse with a surrounding attention
12+
* delimiter and re-parse as a different construct.
13+
*
14+
* Two collisions are detected:
15+
*
16+
* 1. Adjacency: when this node sits at the first or last position of an
17+
* outer attention, its opening or closing marker butts up against the
18+
* outer marker; using the same character would merge `*` + `*` into `**`
19+
* which re-tokenizes as `strong` rather than nested `emphasis`.
20+
* 2. Three-deep same-type chains with `*` as the primary marker: the spec's
21+
* flanking rules are asymmetric between `*` and `_`, and three-deep
22+
* nesting only round-trips when the outermost marker is `_`.
23+
*
24+
* @param {Emphasis | Strong} node
25+
* @param {State} state
26+
* @param {Info} info
27+
* @returns {'*' | '_'}
28+
*/
29+
export function emphasisStrongMarker(node, state, info) {
30+
const primary =
31+
node.type === 'emphasis' ? checkEmphasis(state) : checkStrong(state)
32+
const other = primary === '*' ? '_' : '*'
33+
const before = info.before.charAt(info.before.length - 1)
34+
const after = info.after.charAt(0)
35+
36+
if (before === primary || after === primary) return other
37+
if (primary === '*' && boundaryChainDepth(node, node.type) >= 2) return other
38+
39+
return primary
40+
}
41+
42+
/**
43+
* Count how many same-type attention nodes form a chain along the
44+
* first-child or last-child boundary below `node`.
45+
*
46+
* @param {Emphasis | Strong} node
47+
* @param {'emphasis' | 'strong'} type
48+
* @returns {number}
49+
*/
50+
function boundaryChainDepth(node, type) {
51+
const children = node.children
52+
if (!children || children.length === 0) return 0
53+
54+
const first = children[0]
55+
const last = children[children.length - 1]
56+
let depth = 0
57+
58+
if (first.type === type) {
59+
depth = Math.max(
60+
depth,
61+
1 + boundaryChainDepth(/** @type {Emphasis | Strong} */ (first), type)
62+
)
63+
}
64+
65+
if (last && last !== first && last.type === type) {
66+
depth = Math.max(
67+
depth,
68+
1 + boundaryChainDepth(/** @type {Emphasis | Strong} */ (last), type)
69+
)
70+
}
71+
72+
return depth
73+
}

test/index.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,129 @@ test('emphasis', async function (t) {
12621262
)
12631263
}
12641264
)
1265+
1266+
await t.test(
1267+
'should flip the inner marker when emphasis wraps emphasis',
1268+
async function () {
1269+
assert.equal(
1270+
to({
1271+
type: 'emphasis',
1272+
children: [
1273+
{
1274+
type: 'emphasis',
1275+
children: [{type: 'text', value: 'a'}]
1276+
}
1277+
]
1278+
}),
1279+
'*_a_*\n'
1280+
)
1281+
}
1282+
)
1283+
1284+
await t.test(
1285+
'should flip the inner marker when strong wraps emphasis',
1286+
async function () {
1287+
assert.equal(
1288+
to({
1289+
type: 'strong',
1290+
children: [
1291+
{
1292+
type: 'emphasis',
1293+
children: [{type: 'text', value: 'a'}]
1294+
}
1295+
]
1296+
}),
1297+
'**_a_**\n'
1298+
)
1299+
}
1300+
)
1301+
1302+
await t.test(
1303+
'should flip the inner marker when emphasis wraps strong',
1304+
async function () {
1305+
assert.equal(
1306+
to({
1307+
type: 'emphasis',
1308+
children: [
1309+
{
1310+
type: 'strong',
1311+
children: [{type: 'text', value: 'a'}]
1312+
}
1313+
]
1314+
}),
1315+
'*__a__*\n'
1316+
)
1317+
}
1318+
)
1319+
1320+
await t.test(
1321+
'should start with `_` when three-deep same-type emphasis is nested',
1322+
async function () {
1323+
assert.equal(
1324+
to({
1325+
type: 'emphasis',
1326+
children: [
1327+
{
1328+
type: 'emphasis',
1329+
children: [
1330+
{
1331+
type: 'emphasis',
1332+
children: [{type: 'text', value: 'a'}]
1333+
}
1334+
]
1335+
}
1336+
]
1337+
}),
1338+
'_*_a_*_\n'
1339+
)
1340+
}
1341+
)
1342+
1343+
await t.test(
1344+
'should roundtrip emphasis wrapping emphasis',
1345+
async function () {
1346+
const tree = from('*_a_*')
1347+
removePosition(tree, {force: true})
1348+
const out = from(to(tree))
1349+
removePosition(out, {force: true})
1350+
assert.deepEqual(out, tree)
1351+
}
1352+
)
1353+
1354+
await t.test(
1355+
'should roundtrip three-deep nested emphasis',
1356+
async function () {
1357+
const tree = from('_*_a_*_')
1358+
removePosition(tree, {force: true})
1359+
const out = from(to(tree))
1360+
removePosition(out, {force: true})
1361+
assert.deepEqual(out, tree)
1362+
}
1363+
)
1364+
1365+
await t.test(
1366+
'should consider the last child when picking the marker',
1367+
async function () {
1368+
assert.equal(
1369+
to({
1370+
type: 'emphasis',
1371+
children: [
1372+
{type: 'text', value: 'x '},
1373+
{
1374+
type: 'emphasis',
1375+
children: [
1376+
{
1377+
type: 'emphasis',
1378+
children: [{type: 'text', value: 'a'}]
1379+
}
1380+
]
1381+
}
1382+
]
1383+
}),
1384+
'_x *_a_*_\n'
1385+
)
1386+
}
1387+
)
12651388
})
12661389

12671390
test('heading', async function (t) {

0 commit comments

Comments
 (0)