UNPKG

changelog-splitter

Version:

✂️ conventional commit changelog markdown splitter 约定式提交更新日志 markdown 切割器

589 lines (576 loc) 21.6 kB
'use strict'; 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