changelog-splitter
Version:
✂️ conventional commit changelog markdown splitter 约定式提交更新日志 markdown 切割器
589 lines (576 loc) • 21.6 kB
JavaScript
;
var chalk = require('chalk');
var cliProgress = require('cli-progress');
var tryFlatten = require('try-flatten');
var fs = require('fs');
var path = require('path');
var os = require('os');
var readline = require('readline');
var crypto = require('crypto');
var MultiStream = require('multistream');
var fsp = require('fs/promises');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
var fsp__namespace = /*#__PURE__*/_interopNamespaceDefault(fsp);
// export interface Error
// export type ExtractFault =
function defineFault(name, definition) {
return class Fault extends Error {
name = name;
code;
constructor(code, message) {
super(definition[code]);
this.code = code;
if (message)
this.message = message;
// @ref https://stackoverflow.com/a/32749533
if (typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(this, this.constructor);
}
else {
this.stack = new Error(message).stack;
}
}
because(cause) {
this.cause = cause;
return this;
}
};
}
const configFaultDefinition = {
CHANGELOG_NOT_FOUND: '更新日志文件不存在',
PACKAGE_JSON_READ_ERROR: 'package.json 文件读取失败',
PACKAGE_JSON_PARSE_ERROR: 'package.json 文件解析失败',
};
const ConfigFault = defineFault('ConfigFault', configFaultDefinition);
const splitFaultDefinition = {
LINK_CHANGELOG_NOT_FOUND: '链接的更新日志文件不存在',
};
const SplitFault = defineFault('SplitFault', splitFaultDefinition);
const pkgName = "changelog-splitter";
const pkgVersion = "1.0.1";
const generatedSeparator = `<!-- Generated by ${pkgName}@v${pkgVersion}, please do not modify manually!! -->`;
const generatedSeparatorRE = new RegExp(`<!-- .*?${pkgName}@v.*? -->`);
/**
* 匹配版本号中的主版本号
* @param {string} version
* @returns {string}
*/
function matchMajor(version) {
return version.split('.')[0].replace(/\D/g, '');
}
/**
* 从 markdown 中匹配版本标题,例如
* "## [12.34.56](http://example.com) A new version (2022-11-22)"
* @param {string} line
* @returns {{date: string, major: string, name: string, link: string, title: string}}
*/
function matchVersion(line) {
// ## [version](link) title (date)
// ## [version](link) (date)
// ## version title (date)
// ## version (date)
const matches = line.match(/^#+\s+(\S+)(.*?)(?:\((.*?)\)|$)/);
if (!matches)
return;
const nameLink = matches[1].match(/\[(.*?)]\((.*?)\)/);
const name = nameLink ? nameLink[1] : matches[1];
const major = matchMajor(name);
if (!major)
return;
return {
name,
major,
link: nameLink ? nameLink[2] : '',
title: matches[2].trim(),
date: matches[3] || '',
};
}
/**
* 从 markdown 中匹配前版本链接,如:
* - [v1.x](changelogs/v1.x.md)
* @param {string} line
* @returns {{major: string, name: string, link: string}}
*/
function matchPrevious(line) {
// - [version](file)
const matches = line.match(/\[(.*?)]\((.*?)\)/);
if (!matches)
return;
const name = matches[1];
const major = matchMajor(name);
return {
name,
major,
link: matches[2],
};
}
/**
* 按行读取文件内容
* @param {string} filePath
* @param {(line: string) => any} lineProcessor
* @returns {Promise<void>}
*/
async function readFileLineByLine(filePath, lineProcessor) {
const inputStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: inputStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
await lineProcessor(line);
}
}
/**
* 统计文件的行数
* @param {string} filePath
* @returns {Promise<number>}
*/
async function countFileLines(filePath) {
let count = 0;
await readFileLineByLine(filePath, () => count++);
return count;
}
/**
* 流式复制文件,会覆盖目标文件
* @param {string} source
* @param {string} target
* @returns {Promise<unknown>}
*/
async function pipeFile(source, target) {
return new Promise((resolve, reject) => {
if (source === target)
return resolve();
const rs = fs.createReadStream(source);
const ws = fs.createWriteStream(target);
rs.pipe(ws).on('error', reject).on('close', resolve);
});
}
/**
* 合并多个文件
* @param {string[]} files
* @param {string} target
* @returns {Promise<void>}
*/
async function mergeFiles(files, target) {
return new Promise((resolve, reject) => {
const inputs = files.map((file) => fs.createReadStream(file));
const output = fs.createWriteStream(target);
new MultiStream(inputs).pipe(output).on('error', reject).on('close', resolve);
});
}
/**
* 创建临时目录【必存在】
* @returns {string}
*/
function createTempDirname() {
const d = path.join(os.tmpdir(), pkgName, pkgVersion, crypto__namespace.randomUUID() + '.d');
fs.mkdirSync(d, { recursive: true });
return d;
}
/**
* 创建临时文件【必存在】
* @param {string} data
* @returns {string}
*/
function createTempFile(data = '') {
const d = createTempDirname();
const f = path.join(d, crypto__namespace.randomUUID() + '.f');
fs.writeFileSync(f, data);
return f;
}
/**
* 根据主版本号生成实际字符串
* @param {string} templateName
* @param {string} major
* @returns {string}
*/
function generateNameByMajor(templateName, major) {
return templateName.replace(/\[major]/g, major);
}
/**
* 版本号排序
* @param {string[]} versionList
* @param desc
* @returns {string[]}
*/
function versionListSort(versionList, desc = false) {
return versionList.sort((a, b) => {
const av = a.replace(/^\D+/, '').split('.');
const bv = b.replace(/^\D+/, '').split('.');
const length = Math.max(av.length, bv.length);
for (let i = 0; i < length; i++) {
const a = parseInt(av[i] || '0', 10);
const b = parseInt(bv[i] || '0', 10);
if (a === b)
continue;
return desc ? b - a : a - b;
}
return 0;
});
}
var ConflictStrategy;
(function (ConflictStrategy) {
// 选择当前正在处理的文件
ConflictStrategy[ConflictStrategy["ProcessingFile"] = 0] = "ProcessingFile";
// 选择已经处理过的文件
ConflictStrategy[ConflictStrategy["ProcessedFile"] = 1] = "ProcessedFile";
})(ConflictStrategy || (ConflictStrategy = {}));
const defaults = {
cwd: process.cwd(),
currentChangelogFile: 'CHANGELOG.md',
currentVersionChangeFileName: 'CHANGELOG.md',
packageFile: 'package.json',
previousVersionChangelogFileName: 'changelogs/v[major].x-CHANGELOG.md',
previousVersionChangelogConflictStrategy: ConflictStrategy.ProcessingFile,
previousVersionChangelogTitle: '# v[major].x 更新日志',
previousVersionLinkTitle: '## 其他版本的更新日志',
};
/**
* 定义配置
* @param {UserConfig} config
* @returns {StrictUserConfig}
*/
function defineConfig(config) {
return Object.assign({}, defaults, config);
}
/**
* 创建运行期配置
* @param {StrictUserConfig} strictUserConfig
* @returns {RuntimeConfig}
*/
function createRuntimeConfig(strictUserConfig) {
const { cwd, packageFile, currentChangelogFile, currentVersionChangeFileName } = strictUserConfig;
const resolvePath = (...segments) => path.resolve(cwd, ...segments);
const currentChangelogFilePath = resolvePath(currentChangelogFile);
const currentVersionChangeFilePath = resolvePath(currentVersionChangeFileName);
const currentMajorChangelogFilePath = path.join(createTempDirname(), `CHANGELOG.md`);
if (!fs.existsSync(currentChangelogFilePath))
throw new ConfigFault('CHANGELOG_NOT_FOUND', `${currentChangelogFile} 文件不存在`);
const packageFilePath = resolvePath(packageFile);
let currentVersion = '';
let currentMajor = '';
if (fs.existsSync(packageFilePath)) {
const [jsonError, json] = tryFlatten.tryFlatten(() => fs.readFileSync(packageFilePath, 'utf8'));
if (jsonError)
throw new ConfigFault('PACKAGE_JSON_READ_ERROR').because(jsonError);
const [parseError, parseResult] = tryFlatten.tryFlatten(() => JSON.parse(json));
if (parseError)
throw new ConfigFault('PACKAGE_JSON_PARSE_ERROR').because(parseError);
const { version } = parseResult;
currentVersion = version;
currentMajor = matchMajor(version);
}
return Object.assign({}, strictUserConfig, {
resolvePath,
currentChangelogFilePath,
currentVersionChangeFilePath,
currentVersion,
currentMajor,
currentVersionChangeTempFilePath: currentMajorChangelogFilePath,
});
}
class Printer {
cwd;
counts = {
insertFiles: new Set(),
updateFiles: new Set(),
removeFiles: new Set(),
};
static insertSymbol = chalk.cyanBright('+');
static updateSymbol = chalk.yellowBright('~');
static removeSymbol = chalk.redBright('-');
constructor(cwd) {
this.cwd = cwd;
}
toRelative(to) {
return path.relative(this.cwd, to);
}
insertFile(file) {
if (this.counts.insertFiles.has(file))
return;
console.log(Printer.insertSymbol, this.toRelative(file));
this.counts.insertFiles.add(file);
}
updateFile(file) {
if (this.counts.updateFiles.has(file))
return;
console.log(Printer.updateSymbol, this.toRelative(file));
this.counts.updateFiles.add(file);
}
removeFile(file) {
if (this.counts.removeFiles.has(file))
return;
console.log(Printer.removeSymbol, this.toRelative(file));
this.counts.removeFiles.add(file);
}
}
var SplitProcessingStage;
(function (SplitProcessingStage) {
SplitProcessingStage[SplitProcessingStage["Parse"] = 0] = "Parse";
SplitProcessingStage[SplitProcessingStage["Refer"] = 1] = "Refer";
})(SplitProcessingStage || (SplitProcessingStage = {}));
function createSplitContext() {
return {
processedFileByMajor: {},
blankLengthByMajor: {},
deprecatedMajorFiles: {},
};
}
/**
* 分离当前更新日志
* @param {RuntimeConfig} runtimeConfig
* @param {OnProcessing} [onProcessing]
* @returns {Promise<SplitContext>}
*/
async function parseCurrentChangelog(runtimeConfig, onProcessing) {
const { previousVersionChangelogTitle, previousVersionChangelogFileName, previousVersionChangelogConflictStrategy, resolvePath, currentChangelogFilePath, currentMajor, currentVersionChangeTempFilePath, } = runtimeConfig;
const splitContext = createSplitContext();
const { processedFileByMajor, blankLengthByMajor, deprecatedMajorFiles } = splitContext;
const process = async (major, line) => {
const isCurrentMajor = major === currentMajor;
const filePath = isCurrentMajor
? currentVersionChangeTempFilePath
: resolvePath(generateNameByMajor(previousVersionChangelogFileName, major));
await fsp__namespace.mkdir(path__namespace.dirname(filePath), { recursive: true });
// 第一次处理,清空其本身内容
if (!processedFileByMajor[major]) {
await fsp__namespace.writeFile(filePath, '');
}
// 未处理过的大版本 && 不是当前版本 = 第一次处理旧版本更新日志
if (!processedFileByMajor[major] && !isCurrentMajor) {
await fsp__namespace.writeFile(filePath, `${generatedSeparator}\n\n`);
// 旧版本标题
if (previousVersionChangelogTitle) {
const titleText = generateNameByMajor(previousVersionChangelogTitle, major);
await fsp__namespace.appendFile(filePath, titleText + '\n\n');
}
}
const blankLength = blankLengthByMajor[major] || 0;
const isBlank = line.trim() === '';
if (blankLength < 3 || !isBlank) {
await fsp__namespace.appendFile(filePath, line + '\n');
}
blankLengthByMajor[major] = isBlank ? blankLength + 1 : 0;
processedFileByMajor[major] = filePath;
};
let processingMajor = '';
let processingPrevious = false;
// title
// version
// ...
// version
// previousLink
// ...
// previousLink
const count = await countFileLines(currentChangelogFilePath);
let lines = 0;
await readFileLineByLine(currentChangelogFilePath, async (line) => {
lines++;
onProcessing?.({
stage: SplitProcessingStage.Parse,
progress: lines / count,
});
const isPreviousBlock = generatedSeparatorRE.test(line);
// 前版本块
if (isPreviousBlock) {
processingPrevious = true;
return;
}
// 处理前版本引用链接,此时已经处理过更新日志中的所有版本了
if (processingPrevious) {
const previous = matchPrevious(line);
if (!previous)
return;
const { link, major } = previous;
// 旧文件
const processedFile = resolvePath(link);
// 新文件
const processingFile = processedFileByMajor[major] || resolvePath(generateNameByMajor(previousVersionChangelogFileName, major));
// 是否存在两份旧版本文件
const conflicting = processedFile !== processingFile;
if (!conflicting) {
processedFileByMajor[major] = processingFile;
return;
}
const existProcessingFile = fs__namespace.existsSync(processingFile);
const existProcessedFile = fs__namespace.existsSync(processedFile);
// 新旧文件同时存在:合并文件
const acceptedFile = previousVersionChangelogConflictStrategy === ConflictStrategy.ProcessedFile
? (processedFileByMajor[major] = processedFile)
: (processedFileByMajor[major] = processingFile);
if (existProcessingFile && existProcessedFile) {
const tempFile = createTempFile();
await mergeFiles([
// 旧文件在前
processedFile,
// 新文件在后
processingFile,
], tempFile);
await pipeFile(tempFile, acceptedFile);
if (processedFile !== acceptedFile)
deprecatedMajorFiles[major] = processedFile;
}
// 只有旧文件
else if (existProcessedFile) {
await pipeFile(processedFile, acceptedFile);
if (processedFile !== acceptedFile)
deprecatedMajorFiles[major] = processedFile;
}
else {
throw new SplitFault('LINK_CHANGELOG_NOT_FOUND', `链接的更新日志文件不存在 ${processedFile}`);
}
return;
}
const version = matchVersion(line);
// 版本开始
if (version) {
processingMajor = version.major;
await process(processingMajor, line);
}
// 版本区
else if (processingMajor) {
await process(processingMajor, line);
}
// 标题块
else {
await process(currentMajor, line);
}
});
return splitContext;
}
/**
* 引用之前的更新日志链接
* @param {RuntimeConfig} runtimeConfig
* @param {SplitContext} splitContext
* @param {OnProcessing} onProcessing
* @returns {Promise<void>}
*/
async function referPreviousChangelog(runtimeConfig, splitContext, onProcessing) {
const { currentVersionChangeTempFilePath, currentMajor, previousVersionLinkTitle, currentVersionChangeFilePath } = runtimeConfig;
const { processedFileByMajor, blankLengthByMajor } = splitContext;
const prevVersions = versionListSort(Object.keys(processedFileByMajor).filter((v) => v !== currentMajor), true);
// 需要链接其他版本
const count = prevVersions.length;
if (count > 0) {
// 通常是存在的,为了便于单元测试时不存在
if (!fs__namespace.existsSync(currentVersionChangeTempFilePath)) {
await fsp__namespace.mkdir(path__namespace.dirname(currentVersionChangeTempFilePath), { recursive: true });
}
if (!blankLengthByMajor[currentMajor]) {
await fsp__namespace.appendFile(currentVersionChangeTempFilePath, `\n\n`);
}
await fsp__namespace.appendFile(currentVersionChangeTempFilePath, `${generatedSeparator}\n\n`);
await fsp__namespace.appendFile(currentVersionChangeTempFilePath, `${previousVersionLinkTitle}\n`);
const currentDir = path__namespace.dirname(currentVersionChangeFilePath);
for (const v of prevVersions) {
const index = prevVersions.indexOf(v);
onProcessing?.({
stage: SplitProcessingStage.Refer,
progress: (index + 1) / count,
});
const filePath = processedFileByMajor[v];
const relativePath = path__namespace.relative(currentDir, filePath);
await fsp__namespace.appendFile(currentVersionChangeTempFilePath, `- [v${v}.x](${relativePath})\n`);
}
await fsp__namespace.appendFile(currentVersionChangeTempFilePath, '\n');
}
// 通常是存在的,为了便于单元测试时不存在
if (fs__namespace.existsSync(currentVersionChangeTempFilePath)) {
// 复制当前大版本的更新日志临时文件回原地
await pipeFile(currentVersionChangeTempFilePath, currentVersionChangeFilePath);
await fsp__namespace.rm(currentVersionChangeTempFilePath);
}
}
/**
* 切割更新日志
* @param {StrictUserConfig} config
* @param {StrictUserConfig} config
* @param {OnProcessing} [onProcessing]
* @returns {Promise<SplitResult>}
*/
async function splitChangelog(config, onProcessing) {
const runtimeConfig = createRuntimeConfig(config);
const splitContext = await parseCurrentChangelog(runtimeConfig, onProcessing);
await referPreviousChangelog(runtimeConfig, splitContext, onProcessing);
return { splitContext, runtimeConfig };
}
function printResult(splitResult) {
const { splitContext, runtimeConfig } = splitResult;
const { cwd, currentVersionChangeFilePath, currentChangelogFilePath, currentVersionChangeTempFilePath } = runtimeConfig;
const { processedFileByMajor, deprecatedMajorFiles } = splitContext;
const printer = new Printer(cwd);
console.log(`更新日志文件变化情况如下(删除标记“${Printer.removeSymbol}”的文件需要手动删除):`);
// current version changelog
if (currentChangelogFilePath === currentVersionChangeFilePath) {
printer.updateFile(currentVersionChangeFilePath);
}
else {
printer.removeFile(currentChangelogFilePath);
printer.insertFile(currentVersionChangeFilePath);
}
// previous version changelog
for (const [major, file] of Object.entries(processedFileByMajor)) {
if (file === currentVersionChangeTempFilePath)
continue;
printer.insertFile(file);
}
for (const [major, file] of Object.entries(deprecatedMajorFiles)) {
printer.removeFile(file);
}
}
async function run(userConfig) {
const strictUserConfig = defineConfig(userConfig);
const stageNames = {
[SplitProcessingStage.Parse]: '解析',
[SplitProcessingStage.Refer]: '引用',
};
const createBar = (stage) => {
const bar = new cliProgress.SingleBar({
format: `${stageNames[stage]} [{bar}] {percentage}%`,
});
bar.start(100, 0);
return bar;
};
const bars = {
[SplitProcessingStage.Parse]: null,
[SplitProcessingStage.Refer]: null,
};
console.log('更新日志文件切割进行中...');
console.log();
const [err, res] = await tryFlatten.tryFlatten(splitChangelog(strictUserConfig, (processing) => {
const bar = (bars[processing.stage] = bars[processing.stage] || createBar(processing.stage));
bar.update(processing.progress * 100);
if (processing.progress === 1)
bar.stop();
}));
console.log();
if (err) {
console.log(chalk.redBright('更新日志文件切割失败'));
console.log(chalk.redBright(err.message));
return;
}
printResult(res);
console.log();
console.log(chalk.greenBright('更新日志文件切割成功'));
}
exports.defineConfig = defineConfig;
exports.run = run;
//# sourceMappingURL=index.cjs.map