@release-notes/changelog-parser
Version:
Parse CHANGELOG.md, HISTORY.md and derivative markdown files into js objects.
218 lines (183 loc) • 6.54 kB
JavaScript
const Release = require('@release-notes/node/lib/models/Release');
const ReleaseNotes = require('@release-notes/node/lib/models/ReleaseNotes');
const MODIFICATION_TYPE_MAP = {
security: 'secured',
updated: 'changed',
improvements: 'improved',
'new features': 'added',
'bug fixes': 'fixed',
new: 'added',
'new!!!': 'added',
};
const LINK_REGEX = /^\[([^\]]+)]:\s*(.+)$/;
const RELEASE_HEADER_REGEX = /^\[?([\w\d.-]+\.[\w\d.-]+[a-zA-Z0-9]|Unreleased|Upcoming|Next)]?(?:\s*[^\w(]+\s*(?:\((.+)\)|(.+)))?$/;
const MODIFICATION_LIST_SPLIT_REGEX = /^-\s|^\*\s|\n+-\s|\n+\*\s/m;
function getParentContext(context, section) {
if (context.isRoot || context.level < section.level) {
return context;
}
// context h1 section h2 -> return h1
// context h2 section h1 -> root
// contect h1 section h3 -> return h1
// context h3 section h1 -> h
let ctx = context;
while (ctx.level && ctx.level >= section.level) {
ctx = ctx.parent;
}
return ctx;
}
function parseModificationType(modification) {
let modificationType = 'changed';
if (modification.match(/^add/i)) {
modificationType = 'added';
} else if (modification.match(/^fix/i)) {
modificationType = 'fixed';
} else if (modification.match(/^improve/i)) {
modificationType = 'improved';
}
return modificationType;
}
function buildTree(markdown) {
const lines = markdown.split('\n');
const tree = {
isRoot: true,
level: 0,
children: [],
content: '',
links: {},
};
let context = tree.children;
context.parent = tree;
context.root = tree;
context.content = '';
context.children = [];
lines.forEach((line) => {
const trimmedLine = line.trim();
if (trimmedLine[0] === '#') {
if (trimmedLine.startsWith('# ')) {
const section = {
title: trimmedLine.substr(2),
level: 1,
children: [],
content: '',
parent: tree,
};
tree.children.push(section);
context = section;
} else if (trimmedLine.startsWith('## ')) {
const section = {
title: trimmedLine.substr(3),
level: 2,
content: '',
children: [],
};
section.parent = getParentContext(context, section);
section.parent.children.push(section);
context = section;
} else if (trimmedLine.startsWith('### ')) {
const section = {
title: trimmedLine.substr(4),
level: 3,
content: '',
children: [],
};
section.parent = getParentContext(context, section);
section.parent.children.push(section);
context = section;
} else if (trimmedLine.startsWith('#### ')) {
const section = {
title: trimmedLine.substr(5),
level: 3,
content: '',
children: [],
};
section.parent = getParentContext(context, section);
section.parent.children.push(section);
context = section;
} else {
console.warn('Cannot handle title line. Treat it as normal content.', line);
context.content += `${trimmedLine}\n`;
}
} else if (trimmedLine[0] === '[') {
const link = trimmedLine.match(LINK_REGEX);
if (link) {
tree.links[link[1]] = link[2];
}
} else {
context.content += `${line}\n`;
}
});
return tree;
}
function parseModifications(nodes) {
const parsedModifications = [];
nodes.forEach((modificationNode) => {
let modificationType = (modificationNode.title || '').toLowerCase();
if (modificationType in MODIFICATION_TYPE_MAP) {
modificationType = MODIFICATION_TYPE_MAP[modificationType];
}
const isKnownModificationType = Release.MODIFICATION_TYPES.includes(modificationType);
const modifications = (modificationNode.content || '').split(MODIFICATION_LIST_SPLIT_REGEX);
modifications.forEach((modification) => {
const trimmedModification = modification.trim();
if (trimmedModification) {
if (isKnownModificationType) {
parsedModifications.push({ type: modificationType, modification: trimmedModification });
} else {
const tag = modificationType;
const parsedModificationType = parseModificationType(trimmedModification);
parsedModifications.push({ type: parsedModificationType, modification: {
title: trimmedModification,
tags: [tag],
}});
}
}
});
});
return parsedModifications;
}
function parseReleases(nodes) {
const releases = [];
nodes.forEach((releaseNode) => {
const parsedTitle = releaseNode.title && releaseNode.title.match(RELEASE_HEADER_REGEX);
if (parsedTitle) {
const parsedVersion = parsedTitle[1];
const parsedDate = parsedTitle[2] || parsedTitle[3] || null;
const releaseDate = new Date(parsedDate);
let releaseDescription = (releaseNode.content || '').trim();
let releaseTitle = '';
const releaseDescriptionLines = releaseDescription.split('\n');
if (releaseDescriptionLines[0].startsWith('**') && releaseDescriptionLines[0].endsWith('**')) {
releaseTitle = releaseDescriptionLines.shift().replace(/\*/g, '');
}
const release = new Release({
version: parsedVersion,
description: releaseDescriptionLines.join('\n').trim(),
title: releaseTitle,
});
if (releaseDate && !['Unreleased', 'Upcoming', 'Next'].includes(parsedVersion)) {
release.date = releaseDate === 'Invalid Date' ? (new Date(0)).toISOString() : releaseDate.toISOString();
}
const modifications = parseModifications(releaseNode.children || []);
modifications.forEach(modification => release.addModification(modification.type, modification.modification));
releases.push(release);
} else {
console.warn('Unable to parse version and date from release', releaseNode.title);
}
});
return releases;
}
function parse(markdown) {
if (/^# |^## /gm.test(markdown) === false && /^=+$|^-+$/gm.test(markdown)) {
markdown = markdown.replace(/^(.+)\s+(^=+$|^-+$)/mg, '## $1');
}
const tree = buildTree(markdown);
const releaseNotesNode = tree.children[0] || tree.children || {};
const releaseNotes = new ReleaseNotes({
title: releaseNotesNode.title,
description: (releaseNotesNode.content || '').trim(),
releases: parseReleases((releaseNotesNode.children || [])),
});
return releaseNotes;
}
module.exports = { parse };