Skip to content

Commit f93dfff

Browse files
committed
Fix impossible atx headings to use setext headings
This changes several cases where ATX headings—which are formed on a single line—could not be generated safely because of a descendant which included a line break that could not be escaped. Headings with a rank of 1 or 2, which include such a line break, are now generates in setext style—even without an explicit `setext: true`. Closes GH-26.
1 parent 18bc860 commit f93dfff

File tree

4 files changed

+95
-20
lines changed

4 files changed

+95
-20
lines changed

lib/handle/heading.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,11 @@ import {containerPhrasing} from '../util/container-phrasing.js'
1313
*/
1414
export function heading(node, _, context) {
1515
const rank = Math.max(Math.min(6, node.depth || 1), 1)
16-
/** @type {Exit} */
17-
let exit
18-
/** @type {Exit} */
19-
let subexit
20-
/** @type {string} */
21-
let value
2216

2317
if (formatHeadingAsSetext(node, context)) {
24-
exit = context.enter('headingSetext')
25-
subexit = context.enter('phrasing')
26-
value = containerPhrasing(node, context, {before: '\n', after: '\n'})
18+
const exit = context.enter('headingSetext')
19+
const subexit = context.enter('phrasing')
20+
const value = containerPhrasing(node, context, {before: '\n', after: '\n'})
2721
subexit()
2822
exit()
2923

@@ -41,10 +35,12 @@ export function heading(node, _, context) {
4135
}
4236

4337
const sequence = '#'.repeat(rank)
44-
exit = context.enter('headingAtx')
45-
subexit = context.enter('phrasing')
46-
value = containerPhrasing(node, context, {before: '# ', after: '\n'})
38+
const exit = context.enter('headingAtx')
39+
const subexit = context.enter('phrasing')
40+
let value = containerPhrasing(node, context, {before: '# ', after: '\n'})
41+
4742
value = value ? sequence + ' ' + value : sequence
43+
4844
if (context.options.closeAtx) {
4945
value += ' ' + sequence
5046
}

lib/util/format-heading-as-setext.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* @typedef {import('../types.js').Context} Context
44
*/
55

6+
import {visit, EXIT} from 'unist-util-visit'
67
import {toString} from 'mdast-util-to-string'
78

89
/**
@@ -11,7 +12,24 @@ import {toString} from 'mdast-util-to-string'
1112
* @returns {boolean}
1213
*/
1314
export function formatHeadingAsSetext(node, context) {
15+
let literalWithBreak = false
16+
17+
// Look for literals with a line break.
18+
// Note that this also
19+
visit(node, (node) => {
20+
console.log('n:', node.type)
21+
if (
22+
('value' in node && /\r?\n|\r/.test(node.value)) ||
23+
node.type === 'break'
24+
) {
25+
literalWithBreak = true
26+
return EXIT
27+
}
28+
})
29+
1430
return Boolean(
15-
context.options.setext && (!node.depth || node.depth < 3) && toString(node)
31+
(!node.depth || node.depth < 3) &&
32+
toString(node) &&
33+
(context.options.setext || literalWithBreak)
1634
)
1735
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"longest-streak": "^3.0.0",
4444
"mdast-util-to-string": "^3.0.0",
4545
"parse-entities": "^3.0.0",
46+
"unist-util-visit": "^4.0.0",
4647
"zwitch": "^2.0.0"
4748
},
4849
"devDependencies": {

test/index.js

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -385,12 +385,12 @@ test('blockquote', (t) => {
385385
children: [
386386
{
387387
type: 'heading',
388-
depth: 1,
388+
depth: 3,
389389
children: [{type: 'text', value: 'a\nb'}]
390390
}
391391
]
392392
}),
393-
'> # a&#xA;b\n',
393+
'> ### a&#xA;b\n',
394394
'should support a heading (atx) in a block quote'
395395
)
396396

@@ -597,15 +597,31 @@ test('break', (t) => {
597597
t.equal(to({type: 'break'}), '\\\n', 'should support a break')
598598

599599
t.equal(
600-
to(from('a \nb\n=\n')),
601-
'# a b\n',
600+
to({
601+
type: 'heading',
602+
depth: 3,
603+
children: [
604+
{type: 'text', value: 'a'},
605+
{type: 'break'},
606+
{type: 'text', value: 'b'}
607+
]
608+
}),
609+
'### a b\n',
602610
'should serialize breaks in heading (atx) as a space'
603611
)
604612

605613
t.equal(
606-
to(from('a \\\nb\n=\n')),
607-
'# a b\n',
608-
'should serialize breaks in heading (atx) as nothing when preceded by a space'
614+
to({
615+
type: 'heading',
616+
depth: 3,
617+
children: [
618+
{type: 'text', value: 'a '},
619+
{type: 'break'},
620+
{type: 'text', value: 'b'}
621+
]
622+
}),
623+
'### a b\n',
624+
'should serialize breaks in heading (atx) as a space'
609625
)
610626

611627
t.equal(
@@ -1080,6 +1096,50 @@ test('heading', (t) => {
10801096
'should serialize an empty heading w/ rank 2 as atx when `setext: true`'
10811097
)
10821098

1099+
t.equal(
1100+
to({
1101+
type: 'heading',
1102+
depth: 1,
1103+
children: [{type: 'inlineCode', value: '\n'}]
1104+
}),
1105+
'`\n`\n=\n',
1106+
'should serialize an heading w/ rank 1 and code w/ a line ending as setext'
1107+
)
1108+
1109+
t.equal(
1110+
to({
1111+
type: 'heading',
1112+
depth: 1,
1113+
children: [{type: 'html', value: '<a\n/>'}]
1114+
}),
1115+
'<a\n/>\n==\n',
1116+
'should serialize an heading w/ rank 1 and html w/ a line ending as setext'
1117+
)
1118+
1119+
t.equal(
1120+
to({
1121+
type: 'heading',
1122+
depth: 1,
1123+
children: [{type: 'text', value: 'a\nb'}]
1124+
}),
1125+
'a\nb\n=\n',
1126+
'should serialize an heading w/ rank 1 and text w/ a line ending as setext'
1127+
)
1128+
1129+
t.equal(
1130+
to({
1131+
type: 'heading',
1132+
depth: 1,
1133+
children: [
1134+
{type: 'text', value: 'a'},
1135+
{type: 'break'},
1136+
{type: 'text', value: 'b'}
1137+
]
1138+
}),
1139+
'a\\\nb\n=\n',
1140+
'should serialize an heading w/ rank 1 and a break as setext'
1141+
)
1142+
10831143
t.equal(
10841144
// @ts-expect-error: `children` missing.
10851145
to({type: 'heading'}, {closeAtx: true}),

0 commit comments

Comments
 (0)