Files
OpenRCT2/scripts/format-changelog.js
2026-04-29 20:12:12 +02:00

310 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
// This is also the order expected for each version.
const ChangeTypes = ["Headline feature", "Feature", "Improved", "Change", "Removed", "Fix"];
// A list of change types that were previously used but aren't accepted anymore, will be replaced with the value in the map.
const ChangeTypeSubstitutions = {
"Improve": "Improved",
};
const HeaderSeparator = "------------------------------------------------------------------------";
function reportError(ctx, message) {
console.error(`Error on line ${ctx.lineIndex}: ${message}`);
process.exit(-1);
}
function reportLineError(line, message) {
console.error(`Error on line ${line.index + 1}: ${message}`);
process.exit(-1);
}
function consumeLine(ctx) {
if (ctx.lineIndex >= ctx.lines.length) {
reportError(ctx, "Unexpected end of file");
}
const line = ctx.lines[ctx.lineIndex];
ctx.lineIndex++;
return line;
}
function peekLine(ctx) {
if (ctx.lineIndex >= ctx.lines.length) {
return null;
}
return ctx.lines[ctx.lineIndex];
}
function readVersionHeader(ctx) {
// Extract the version number.
const versionLine = consumeLine(ctx);
const versionMatch = versionLine.text.match(/^((\d+\.\d+\.\d+(\.\d+)?(-\w+)?) \((\d{4}-\d{2}-\d{2}|in development)\))$/);
if (versionMatch == null) {
reportLineError(versionLine, "Expected a version number");
}
// Check the version separator
const versionSeparator = consumeLine(ctx);
if (versionSeparator.text != HeaderSeparator) {
reportLineError(versionSeparator, "Expected version separator");
}
// Check for optional headline, this means the there is text not starting with - and an empty line before
// the first entry.
var headline = peekLine(ctx);
if (headline != null && headline.text != "" && !headline.text.startsWith("-")) {
consumeLine(ctx);
// Check for empty line
const emptyLine = peekLine(ctx);
if (emptyLine == null || emptyLine.text != "") {
reportError(ctx, "Expected empty line after headline");
}
consumeLine(ctx);
headline = headline.text;
} else {
headline = "";
}
return {
version: versionMatch[2],
date: versionMatch[5],
headline: headline,
};
}
function isValidReference(ref) {
const regex = /^\w*#\d+( \(partial\))?$/;
return regex.test(ref);
}
function readVersionEntry(ctx, rawEntry, versionInfo) {
// Check for the '- ' prefix
var text = rawEntry.text;
if (!text.startsWith("- ")) {
reportLineError(rawEntry, "Expected entry prefix '- '");
}
text = text.substr(2);
// Extract the change type
const changeTypeIdx = text.indexOf(":");
if (changeTypeIdx == -1) {
reportLineError(rawEntry, "Expected change type");
}
var changeType = text.substring(0, changeTypeIdx).trim();
text = text.substring(changeTypeIdx + 1).trim();
// Check if changeType is valid
if (!ChangeTypes.includes(changeType)) {
// Check for substitution
if (changeType in ChangeTypeSubstitutions) {
changeType = ChangeTypeSubstitutions[changeType];
} else {
reportLineError(rawEntry, `Invalid change type '${changeType}', types can be ${ChangeTypes.join(", ")}`);
}
}
// Extract the references.
var refs = [];
if (text.startsWith("[")) {
const refsEndIdx = text.indexOf("]");
if (refsEndIdx == -1) {
reportLineError(rawEntry, "Open reference bracket '[' without closing bracket ']'");
}
refs = text.substring(1, refsEndIdx).split(",").map(ref => ref.trim());
text = text.substring(refsEndIdx + 1).trim();
} else {
// Text must start with '[', allow having this missing in older versions.
if (versionInfo.date == "in development") {
reportLineError(rawEntry, "Expected reference [#REF]");
}
}
// Check if reference is correct, can be '#123' or 'project#123'
for (const ref of refs) {
if(!isValidReference(ref)) {
reportLineError(rawEntry, `Invalid reference '${ref}', must be '#123' or 'project#123'`);
}
}
const textWithoutCode = text.replaceAll(/`.*`/g, '');
if (textWithoutCode.includes('"') || textWithoutCode.includes('\'')) {
reportLineError(rawEntry, `Use of typewriter quotes (' or "), please use typographical quotes ( and “ ”)`);
}
const result = {
changeType: changeType,
refs: refs,
description: text.trim(),
};
return result;
}
function readVersionEntries(ctx, versionInfo) {
var entries = [];
while (ctx.lineIndex < ctx.lines.length) {
const rawEntry = consumeLine(ctx);
// Empty line means end of version block.
if (rawEntry.text == "") {
break;
}
const entry = readVersionEntry(ctx, rawEntry, versionInfo);
entries.push(entry);
}
return entries;
}
function readVersionBlock(ctx) {
const versionInfo = readVersionHeader(ctx);
const entries = readVersionEntries(ctx, versionInfo);
return {
version: versionInfo.version,
date: versionInfo.date,
headline: versionInfo.headline,
entries: entries
};
}
function parseChangelog(changelog) {
// Split the changelog into lines but also keep the line numbers.
const lines = changelog.split('\n').map((text, index) => ({ text, index }));
// Remove duplicate empty lines.
for (var i = 0; i < lines.length; i++) {
if (lines[i].text == "" && (i == 0 || lines[i - 1].text == "")) {
lines.splice(i, 1);
i--;
}
}
var ctx = {
lines,
lineIndex: 0,
versions: []
};
while (ctx.lineIndex < ctx.lines.length) {
const versionBlock = readVersionBlock(ctx);
ctx.versions.push(versionBlock);
}
return ctx.versions;
}
function referenceSort(a, b) {
// Extract the number from the reference
const numA = parseInt(a.replace(/\D/g, ''), 10);
const numB = parseInt(b.replace(/\D/g, ''), 10);
// Check if both are direct references or subproject references
const isDirectA = a.startsWith('#');
const isDirectB = b.startsWith('#');
if (isDirectA && isDirectB) {
// If both are direct references, sort by the number
return numA - numB;
} else if (!isDirectA && !isDirectB) {
// If both are subproject references, sort by the name and then the number
const nameA = a.split('#')[0];
const nameB = b.split('#')[0];
if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
return numA - numB;
} else {
// If one is a direct reference and the other is a subproject reference,
// the direct reference should come first
return isDirectA ? -1 : 1;
}
}
function reorderGroups(a, b) {
const changeTypeDiff = ChangeTypes.indexOf(a.changeType) - ChangeTypes.indexOf(b.changeType);
if (changeTypeDiff != 0) {
return changeTypeDiff;
}
// If changeType is identical, sort based on reference
const hasMainRefA = a.refs.length > 0 && a.refs[0].startsWith("#");
const hasMainRefB = b.refs.length > 0 && b.refs[0].startsWith("#");
// Sort by main reference number.
if (hasMainRefA && hasMainRefB) {
const numA = parseInt(a.refs[0].replace(/\D/g, ''), 10);
const numB = parseInt(b.refs[0].replace(/\D/g, ''), 10);
return numA - numB;
} else if (hasMainRefA && !hasMainRefB) {
return -1;
} else if (!hasMainRefA && hasMainRefB) {
return 1;
} else {
// Sort by reference name.
const nameA = a.refs.length > 0 ? a.refs[0].split('#')[0] : "";
const nameB = b.refs.length > 0 ? b.refs[0].split('#')[0] : "";
if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
return 0;
}
}
function cleanupVersionBlock(version) {
// Sort the references first, numbers come before sub project refs.
version.entries.forEach(entry => entry.refs.sort((a, b) => referenceSort(a, b)));
// Sort the entries by change type.
version.entries.sort((a, b) => reorderGroups(a, b));
// Ensure entries end with a dot.
version.entries.forEach(entry => {
if (!entry.description.endsWith(".")) {
entry.description += ".";
}
});
}
function cleanupVersions(versions) {
for (const version of versions) {
cleanupVersionBlock(version);
}
}
function printChangelog(versions) {
for (const version of versions) {
console.log(`${version.version} (${version.date})`);
console.log(HeaderSeparator);
if (version.headline != "") {
console.log(version.headline);
console.log();
}
for (const entry of version.entries) {
var line = `- ${entry.changeType}: `;
if (entry.refs.length > 0) {
line += `[${entry.refs.join(", ")}] `;
}
line += `${entry.description}`;
console.log(line);
}
console.log();
}
}
function checkLineEndings(changelog) {
if (changelog.indexOf("\r\n") != -1) {
console.error("Changelog contains Windows line endings, please convert to Unix line endings.");
process.exit(-1);
}
}
const filePath = process.argv[2];
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('Error reading the file:', err);
process.exit(-1);
}
checkLineEndings(data);
var versions = parseChangelog(data);
cleanupVersions(versions);
printChangelog(versions);
});