forked from ungoldman/changelog-parser
-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
201 lines (166 loc) · 5.5 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
const EOL = require('os').EOL
const lineReader = require('line-reader')
const removeMarkdown = require('remove-markdown')
// patterns
const semver = /\[?v?([\w\d.-]+\.[\w\d.-]+[a-zA-Z0-9])\]?/
const date = /.*[ ]\(?(\d\d?\d?\d?[-/.]\d\d?[-/.]\d\d?\d?\d?)\)?.*/
const subhead = /^###/
const listitem = /^[*-]/
const defaultOptions = { removeMarkdown: true }
/**
* Changelog parser.
*
* @param {string|object} options - changelog file string or options object containing file string
* @param {string} [options.filePath] - path to changelog file
* @param {string} [options.text] - changelog text (filePath alternative)
* @param {boolean} [options.removeMarkdown=true] - changelog file string to parse
* @param {function} [callback] - optional callback
* @returns {Promise<object>} - parsed changelog object
*/
function parseChangelog (options, callback) {
if (typeof options === 'undefined') throw new Error('missing options argument')
if (typeof options === 'string') options = { filePath: options }
if (typeof options === 'object') {
const hasFilePath = typeof options.filePath !== 'undefined'
const hasText = typeof options.text !== 'undefined'
const invalidFilePath = typeof options.filePath !== 'string'
const invalidText = typeof options.text !== 'string'
if (!hasFilePath && !hasText) {
throw new Error('must provide filePath or text')
}
if (hasFilePath && invalidFilePath) {
throw new Error('invalid filePath, expected string')
}
if (hasText && invalidText) {
throw new Error('invalid text, expected string')
}
}
const opts = Object.assign({}, defaultOptions, options)
const changelog = parse(opts)
if (typeof callback === 'function') {
changelog
.then(function (log) { callback(null, log) })
.catch(function (err) { callback(err) })
}
// otherwise, invoke callback
return changelog
}
/**
* Internal parsing logic.
*
* @param {options} options - options object
* @param {string} [options.filePath] - path to changelog file
* @param {string} [options.text] - changelog text (filePath alternative)
* @param {boolean} [options.removeMarkdown] - remove markdown
* @returns {Promise<object>} - parsed changelog object
*/
function parse (options) {
const filePath = options.filePath
const text = options.text
const data = {
log: { versions: [] },
current: null
}
// allow `handleLine` to mutate log/current data as `this`.
const cb = handleLine.bind(data, options)
return new Promise(function (resolve, reject) {
function done () {
// push last version into log
if (data.current) {
pushCurrent(data)
}
// clean up description
data.log.description = clean(data.log.description)
if (data.log.description === '') delete data.log.description
resolve(data.log)
}
if (text) {
text.split(/\r\n?|\n/mg).forEach(cb)
done()
} else {
lineReader.eachLine(filePath, cb, EOL).then(done)
}
})
}
/**
* Handles each line and mutates data object (bound to `this`) as needed.
*
* @param {object} options - options object
* @param {boolean} options.removeMarkdown - whether or not to remove markdown
* @param {string} line - line from changelog file
*/
function handleLine (options, line) {
// skip line if it's a link label
if (line.match(/^\[[^[\]]*\] *?:/)) return
// set title if it's there
if (!this.log.title && line.match(/^# ?[^#]/)) {
this.log.title = line.substring(1).trim()
return
}
// new version found!
if (line.match(/^##? ?[^#]/)) {
if (this.current && this.current.title) pushCurrent(this)
this.current = versionFactory()
if (semver.exec(line)) this.current.version = semver.exec(line)[1]
this.current.title = line.substring(2).trim()
if (this.current.title && date.exec(this.current.title)) this.current.date = date.exec(this.current.title)[1]
return
}
// deal with body or description content
if (this.current) {
this.current.body += line + EOL
// handle case where current line is a 'subhead':
// - 'handleize' subhead.
// - add subhead to 'parsed' data if not already present.
if (subhead.exec(line)) {
const key = line.replace('###', '').trim()
if (!this.current.parsed[key]) {
this.current.parsed[key] = []
this.current._private.activeSubhead = key
}
}
// handle case where current line is a 'list item':
if (listitem.exec(line)) {
const log = options.removeMarkdown ? removeMarkdown(line) : line
// add line to 'catch all' array
this.current.parsed._.push(log)
// add line to 'active subhead' if applicable (eg. 'Added', 'Changed', etc.)
if (this.current._private.activeSubhead) {
this.current.parsed[this.current._private.activeSubhead].push(log)
}
}
} else {
this.log.description = (this.log.description || '') + line + EOL
}
}
function versionFactory () {
return {
version: null,
title: null,
date: null,
body: '',
parsed: {
_: []
},
_private: {
activeSubhead: null
}
}
}
function pushCurrent (data) {
// remove private properties
delete data.current._private
data.current.body = clean(data.current.body)
data.log.versions.push(data.current)
}
function clean (str) {
if (!str) return ''
// trim
str = str.trim()
// remove leading newlines
str = str.replace(new RegExp('[' + EOL + ']*'), '')
// remove trailing newlines
str = str.replace(new RegExp('[' + EOL + ']*$'), '')
return str
}
module.exports = parseChangelog