Skip to content

Commit 5ad6785

Browse files
author
Benjamin E. Coe
authored
feat(conventional-changelog): handle BREAKING CHANGE in footer and body (#32)
1 parent 8f3f5c7 commit 5ad6785

4 files changed

Lines changed: 95 additions & 41 deletions

File tree

lib/utils.js

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
const visit = require('unist-util-visit')
2+
const visitWithAncestors = require('unist-util-visit-parents')
23
const NUMBER_REGEX = /^[0-9]+$/
34

45
// Converts conventional commit AST into conventional-changelog's
56
// output format, see: https://www.npmjs.com/package/conventional-commits-parser
67
function toConventionalChangelogFormat (ast) {
78
const cc = {
8-
body: null,
9+
body: '',
10+
subject: '',
11+
type: '',
12+
scope: null,
913
notes: [],
1014
references: [],
1115
mentions: [],
1216
merge: null,
13-
revert: null
17+
revert: null,
18+
header: '',
19+
footer: null
1420
}
15-
let breaking
16-
let body
17-
let summary
1821
// Separate the body and summary nodes, this simplifies the subsequent
1922
// tree walking logic:
23+
let body
24+
let summary
2025
visit(ast, ['body', 'summary'], (node) => {
2126
switch (node.type) {
2227
case 'body':
@@ -25,54 +30,77 @@ function toConventionalChangelogFormat (ast) {
2530
case 'summary':
2631
summary = node
2732
break
28-
default:
29-
break
3033
}
3134
})
3235

33-
visit(summary, () => true, (node) => {
36+
// <type>, "(", <scope>, ")", ["!"], ":", <whitespace>*, <text>
37+
visit(summary, (node) => {
3438
switch (node.type) {
3539
case 'type':
3640
cc.type = node.value
41+
cc.header += node.value
3742
break
3843
case 'scope':
3944
cc.scope = node.value
45+
cc.header += `(${node.value})`
46+
break
47+
case 'breaking-change':
48+
cc.header += '!'
4049
break
4150
case 'text':
4251
cc.subject = node.value
43-
break
44-
case 'breaking-change':
45-
breaking = {
46-
title: 'BREAKING CHANGE'
47-
// "text" node should be added with subject after walk.
48-
}
52+
cc.header += `: ${node.value}`
4953
break
5054
default:
5155
break
5256
}
5357
})
54-
// The header contains the recombined components of the summary:
55-
cc.header = `${cc.type}${cc.scope ? `(${cc.scope})` : ''}${breaking ? '!' : ''}: ${cc.subject}`
5658

59+
// [<any body-text except pre-footer>]
5760
if (body) {
58-
let text = ''
5961
visit(body, 'text', (node, _i, parent) => {
60-
if (parent.type !== 'body') return
61-
if (text !== '') text += '\n'
62-
text += node.value
62+
// TODO(@bcoe): once we have \n tokens in tree we can drop this:
63+
if (cc.body !== '') cc.body += '\n'
64+
cc.body += node.value
6365
})
64-
if (text !== '') cc.body = text
6566
}
6667

67-
// A breaking change note was found either in the body, the header, or
68-
// in one of the footers:
69-
// TODO(bcoe): if we refactor the grammar slightly, so that footer is a
70-
// direct parent of `breaking-change`, the logic for extracting a breaking
71-
// change would be easier.
72-
if (breaking) {
73-
if (!breaking.text) breaking.text = cc.subject
74-
cc.notes.push(breaking)
68+
// Extract BREAKING CHANGE notes, regardless of whether they fall in
69+
// summary, body, or footer:
70+
const breaking = {
71+
title: 'BREAKING CHANGE',
72+
text: '' // "text" will be populated if a BREAKING CHANGE token is parsed.
7573
}
74+
visitWithAncestors(ast, ['breaking-change'], (node, ancestors) => {
75+
let parent = ancestors.pop()
76+
let startCollecting = false
77+
switch (parent.type) {
78+
case 'summary':
79+
breaking.text = cc.subject
80+
break
81+
case 'body':
82+
breaking.text = ''
83+
// We treat text from the BREAKING CHANGE marker forward as
84+
// the breaking change notes:
85+
visit(parent, ['text', 'breaking-change'], (node) => {
86+
// TODO(@bcoe): once we have \n tokens in tree we can drop this:
87+
if (startCollecting && node.type === 'text') {
88+
if (breaking.text !== '') breaking.text += '\n'
89+
breaking.text += node.value
90+
} else if (node.type === 'breaking-change') {
91+
startCollecting = true
92+
}
93+
})
94+
break
95+
case 'token':
96+
parent = ancestors.pop()
97+
visit(parent, 'text', (node) => {
98+
breaking.text = node.value
99+
})
100+
break
101+
}
102+
})
103+
if (breaking.text !== '') cc.notes.push(breaking)
76104

77105
// Populates references array from footers:
78106
// references: [{
@@ -109,8 +137,6 @@ function toConventionalChangelogFormat (ast) {
109137
reference.issue = node.value
110138
}
111139
break
112-
default:
113-
break
114140
}
115141
})
116142
// TODO(@bcoe): how should references like "Refs: v8:8940" work.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"yargs": "^16.2.0"
4545
},
4646
"dependencies": {
47-
"unist-util-visit": "^2.0.3"
47+
"unist-util-visit": "^2.0.3",
48+
"unist-util-visit-parents": "^3.1.1"
4849
}
4950
}

scripts/inspect.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
#!/usr/bin/env node
22

3-
const { parser } = require('..')
3+
const { parser, toConventionalChangelogFormat } = require('..')
44
const inspect = require('unist-util-inspect')
55
const { hideBin } = require('yargs/helpers')
66
const yargs = require('yargs/yargs')(hideBin(process.argv))
77

88
yargs
9-
.command('$0 <message>', 'Output the parsed syntax tree', () => {}, (argv) => {
9+
.command('$0 <message>', 'output the parsed syntax tree', () => {}, (argv) => {
1010
console.log(inspect(parser(argv.message)))
1111
})
12+
.command('cc <message>', 'output conventional changelog format commit', () => {}, (argv) => {
13+
const cc = toConventionalChangelogFormat(parser(argv.message))
14+
console.log('-----')
15+
console.log(JSON.stringify(cc, null, 2))
16+
})
1217
.parse()

test/utils.js

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,6 @@ describe('utils', () => {
1717
const parsed = toConventionalChangelogFormat(parser('foo: bar\n\nthe body of commit\nsecond line'))
1818
assert.strictEqual(parsed.body, 'the body of commit\nsecond line')
1919
})
20-
it('extracts BREAKING CHANGE from header', () => {
21-
const parsed = toConventionalChangelogFormat(parser('foo!: hello world'))
22-
assert.strictEqual(parsed.notes.length, 1)
23-
const note = parsed.notes[0]
24-
assert.strictEqual(note.title, 'BREAKING CHANGE')
25-
assert.strictEqual(note.text, 'hello world')
26-
})
2720
it('populates references entry from footer', () => {
2821
const parsed = toConventionalChangelogFormat(parser('foo: summary\n\nRefs #34'))
2922
assert.strictEqual(parsed.references.length, 1)
@@ -40,5 +33,34 @@ describe('utils', () => {
4033
const parsed = toConventionalChangelogFormat(parser('foo: summary\n\nRefs #batman'))
4134
assert.strictEqual(parsed.references.length, 0)
4235
})
36+
it('extracts BREAKING CHANGE from header', () => {
37+
const parsed = toConventionalChangelogFormat(parser('foo!: hello world'))
38+
assert.strictEqual(parsed.notes.length, 1)
39+
const note = parsed.notes[0]
40+
assert.strictEqual(note.title, 'BREAKING CHANGE')
41+
assert.strictEqual(note.text, 'hello world')
42+
})
43+
it('extracts BREAKING CHANGE from body', () => {
44+
const parsed = toConventionalChangelogFormat(parser('foo!: hello world\n\nBREAKING CHANGE: this change is breaking\nsecond line'))
45+
assert.strictEqual(parsed.notes.length, 1)
46+
const note = parsed.notes[0]
47+
assert.strictEqual(note.title, 'BREAKING CHANGE')
48+
assert.strictEqual(note.text, 'this change is breaking\nsecond line')
49+
})
50+
it('only extracts text after BREAKING CHANGE token in body', () => {
51+
const parsed = toConventionalChangelogFormat(parser('foo!: hello world\n\nstart of body\nBREAKING CHANGE: this change is breaking\nsecond line'))
52+
assert.strictEqual(parsed.notes.length, 1)
53+
const note = parsed.notes[0]
54+
assert.strictEqual(note.title, 'BREAKING CHANGE')
55+
assert.strictEqual(note.text, 'this change is breaking\nsecond line')
56+
})
57+
it('extracts BREAKING CHANGE from footer', () => {
58+
const parsed = toConventionalChangelogFormat(parser('foo!: hello world\n\nthis is the body\n\nBREAKING CHANGE: this change is breaking'))
59+
assert.strictEqual(parsed.notes.length, 1)
60+
assert.strictEqual(parsed.body, 'this is the body')
61+
const note = parsed.notes[0]
62+
assert.strictEqual(note.title, 'BREAKING CHANGE')
63+
assert.strictEqual(note.text, 'this change is breaking')
64+
})
4365
})
4466
})

0 commit comments

Comments
 (0)