├── .github └── workflows │ └── node.yml ├── .gitignore ├── changelog.md ├── changelog.mjs ├── cli.mjs ├── index.mjs ├── package-lock.json ├── package.json ├── parse.mjs ├── readme.md ├── test └── parse.mjs ├── tsconfig.json └── util.mjs /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | node-test: 14 | name: Node.js tests 15 | 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | 19 | strategy: 20 | matrix: 21 | node-version: [16.x, 18.x, 20.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v1 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | - run: npm ci 31 | - run: npm test 32 | 33 | types: 34 | name: Typescript check 35 | 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Use Node.js 41 | uses: actions/setup-node@v1 42 | with: 43 | node-version: 18.x 44 | - run: npm ci 45 | - run: npx tsc 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #npm 2 | /node_modules 3 | 4 | #vim 5 | .*.swp 6 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.2.1 (2025-05-26) 5 | ------------------ 6 | 7 | * New --force flag was being ignored. 8 | 9 | 10 | 1.2.0 (2025-05-12) 11 | ------------------ 12 | 13 | * Added `--force` flag, which ignores errors when calling changelog release. 14 | 15 | Note: v1.1.0 was skipped. A few years ago this version was accidentally 16 | published on NPM so we need to skip it. 17 | 18 | 19 | 1.0.1 (2023-06-30) 20 | ------------------ 21 | 22 | * Fix bug in parsing link references with more than 1 character 23 | 24 | 25 | 1.0.0 (2023-06-20) 26 | ------------------ 27 | 28 | First stable release! Just kidding, it was already stable. 29 | 30 | * Add support for [Markdown reference links][1]. References are a Markdown 31 | feature that lets you write links in paragraphs, but put the actual target 32 | near the end of the document similar to references in technical documents. 33 | This can declutter the reading experience for those reading the Markdown 34 | sources. The tool doesn't let you quickly add links via the CLI yet, but it 35 | will no longer mangle them when they appear. 36 | * Testing Node 20 37 | * Bugfix: Always insert an empty line between the 'preface' and bulletpoints 38 | sections of a version block. 39 | 40 | 41 | 0.7.2 (2023-02-17) 42 | ------------------ 43 | 44 | * Added a `--nowrap` option to `show`, which doesn't wrap long lines. This is 45 | useful for copy-pasting changelog into places where linebreaks are 46 | significant, such as the Github releases section. 47 | * Support multiple digits for the alpha/beta release string. 48 | * Also allows setting the changelog message as positional arguments. 49 | 50 | 51 | 0.7.1 (2023-02-14) 52 | ------------------ 53 | 54 | * Bug: forgot to commit the release 55 | 56 | 57 | 0.7.0 (2023-02-14) 58 | ------------------ 59 | 60 | * The "release" command now automatically commits and and creates a git tag, 61 | much like `npm version` 62 | 63 | 64 | 0.6.0 (2023-02-14) 65 | ------------------ 66 | 67 | * The release command now automatically calls "npm version" if a package.json 68 | was found in the project directory 69 | * Bug fix: the --major and --minor arguments were ignored when using "add" to 70 | create a new version log 71 | 72 | 73 | 0.5.0 (2023-02-12) 74 | ------------------ 75 | 76 | * Support changing the version to the next major/minor using the `--major` and 77 | `--minor` arguments. 78 | * The `add` command now uses the -m argument instead of a positional for the 79 | message. 80 | 81 | 82 | 0.4.1 (2023-02-12) 83 | ------------------ 84 | 85 | * Make sure that the binary is executable 86 | 87 | 88 | 0.4.0 (2023-02-12) 89 | ------------------ 90 | 91 | * Implemented the "format", "parse" and "release" commands. 92 | 93 | 94 | 0.3.0 (2023-02-12) 95 | ------------------ 96 | 97 | * Implemented the 'show' command. 98 | 99 | 100 | 0.2.0 (2023-02-12) 101 | ------------------ 102 | 103 | * Implemented the 'list' command. 104 | * Added testing framework based on node:test. 105 | 106 | 107 | 0.1.0 (2023-02-08) 108 | ------------------ 109 | 110 | * Implemented the 'help' and 'init' commands 111 | 112 | [1]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#link 113 | "Markdown cheatsheet: Links" 114 | -------------------------------------------------------------------------------- /changelog.mjs: -------------------------------------------------------------------------------- 1 | import { calculateNextVersion, wrap } from './util.mjs'; 2 | 3 | // @ts-check 4 | export class Changelog { 5 | 6 | title = 'Changelog'; 7 | 8 | /** 9 | * @type {VersionLog[]} 10 | */ 11 | versions = []; 12 | 13 | /** 14 | * @type {Link[]} 15 | */ 16 | links = []; 17 | 18 | toString() { 19 | 20 | return ( 21 | this.title + '\n' + 22 | ('='.repeat(this.title.length)) + '\n' + 23 | '\n' + 24 | this.versions.map(version => version.toString()).join('\n\n') + 25 | 26 | // Sorry about this line future me (or someone else) 27 | (this.links.length > 0 ? ('\n' + this.links.map( link => wrap(`[${link.name}]: ${link.href}${link.title?` "${link.title}"`:''}`, link.name.length+4)).join('\n') + '\n') : '') 28 | ); 29 | 30 | } 31 | 32 | /** 33 | * Adds a new Version log 34 | * 35 | * @param version {VersionLog} 36 | * @returns {VersionLog} 37 | */ 38 | add(version) { 39 | 40 | this.versions = [version, ...this.versions]; 41 | return version; 42 | 43 | } 44 | 45 | /** 46 | * Adds a new version to the log. Version string is automatically increased 47 | * from the previous one 48 | * 49 | * @param {'patch'|'minor'|'major'} changeType 50 | * @returns {VersionLog} 51 | */ 52 | newVersion(changeType = 'patch') { 53 | 54 | const lastVersion = this.versions[0].version; 55 | const newVersion = calculateNextVersion(lastVersion, changeType); 56 | const versionLog = new VersionLog(newVersion); 57 | 58 | return this.add(versionLog); 59 | 60 | } 61 | 62 | /** 63 | * Finds a VersionLog by its version string 64 | * 65 | * @param {string} version 66 | * @returns {VersionLog} 67 | */ 68 | get(version) { 69 | 70 | const log = this.versions.find( myLog => myLog.version === version); 71 | if (!log) { 72 | throw new Error(`Couldn't find version ${version} in the changelog`); 73 | } 74 | return log; 75 | 76 | } 77 | 78 | } 79 | 80 | export class VersionLog { 81 | 82 | /** 83 | * @type {string} 84 | */ 85 | version; 86 | 87 | /** 88 | * @type {string|null} 89 | */ 90 | date = null; 91 | 92 | /** 93 | * @type {string|null} 94 | */ 95 | preface = null; 96 | 97 | /** 98 | * @type {string|null} 99 | */ 100 | postface = null; 101 | 102 | /** 103 | * @type {LogItem[]} 104 | */ 105 | items = []; 106 | 107 | /** 108 | * @param {string} version 109 | */ 110 | constructor(version) { 111 | this.version = version; 112 | 113 | } 114 | 115 | /** 116 | * @param {string} message 117 | * @returns {LogItem} 118 | */ 119 | add(message) { 120 | const item = new LogItem(message); 121 | this.items.push(item); 122 | return item; 123 | } 124 | 125 | toString() { 126 | 127 | return this.output(); 128 | 129 | } 130 | 131 | /** 132 | * Renders the changelog as a string. 133 | * 134 | * @param {boolean} lineWrap 135 | * @returns {string} 136 | */ 137 | output(lineWrap = true) { 138 | 139 | const lineLength = lineWrap ? 79 : Infinity; 140 | const title = this.version + ' (' + (this.date ?? '????-??-??') + ')'; 141 | return ( 142 | title + '\n' + 143 | ('-'.repeat(title.length)) + '\n' + 144 | (this.preface ? '\n' + wrap(this.preface, 0, lineLength) + '\n' : '') + 145 | '\n' + 146 | this.items.map(version => version.output(lineWrap)).join('\n') + 147 | '\n' + 148 | (this.postface ? '\n' + wrap(this.postface, 0, lineLength) + '\n' : '') 149 | ); 150 | 151 | } 152 | 153 | } 154 | 155 | export class LogItem { 156 | 157 | /** 158 | * @type {string} 159 | */ 160 | message; 161 | 162 | /** 163 | * @param {string} message 164 | */ 165 | constructor(message) { 166 | this.message = message; 167 | } 168 | 169 | /** 170 | * Renders the changelog as a string. 171 | * 172 | * @param {boolean} lineWrap 173 | * @returns {string} 174 | */ 175 | output(lineWrap = true) { 176 | const lineLength = lineWrap ? 79 : Infinity; 177 | return wrap('* ' + this.message, 2, lineLength); 178 | } 179 | 180 | toString() { 181 | 182 | return wrap('* ' + this.message, 2); 183 | 184 | } 185 | 186 | } 187 | 188 | /** 189 | * @typedef Link {Object} 190 | * @property Link.href {string} 191 | * @property Link.name {string} 192 | * @property Link.title {string|null} 193 | */ 194 | -------------------------------------------------------------------------------- /cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @ts-check 3 | import { parseArgs } from 'node:util'; 4 | import * as fs from 'node:fs/promises'; 5 | import * as url from 'node:url'; 6 | import { readPackageVersion, exists, calculateNextVersion, isGit, isGitClean, runCommand } from './util.mjs'; 7 | import { Changelog, VersionLog, LogItem } from './changelog.mjs'; 8 | import { parseFile } from './parse.mjs'; 9 | 10 | const filename = 'changelog.md'; 11 | 12 | const pkg = JSON.parse( 13 | await fs.readFile( 14 | url.fileURLToPath(url.resolve(import.meta.url, './package.json')), 15 | 'utf-8', 16 | ) 17 | ); 18 | 19 | async function main() { 20 | 21 | const { positionals, values } = parseArgs({ 22 | options: { 23 | help: { 24 | type: 'boolean', 25 | short: 'h', 26 | default: false, 27 | description: 'This help screen', 28 | }, 29 | all: { 30 | type: 'boolean', 31 | default: false, 32 | description: 'Show all versions', 33 | }, 34 | message: { 35 | type: 'string', 36 | description: 'Changelog message', 37 | short: 'm' 38 | }, 39 | patch: { 40 | type: 'boolean', 41 | description: 'Indicates that the current change is a patch-level change.', 42 | }, 43 | minor: { 44 | type: 'boolean', 45 | description: 'Indicates that the current change is a minor change.', 46 | }, 47 | major: { 48 | type: 'boolean', 49 | description: 'Indicates that the current change is a major change.', 50 | }, 51 | nowrap: { 52 | type: 'boolean', 53 | description: 'Don\'t wrap "show" output' 54 | }, 55 | force: { 56 | type: 'boolean', 57 | description: 'Ignore some errors and try anyway.', 58 | }, 59 | }, 60 | allowPositionals: true, 61 | }); 62 | 63 | 64 | 65 | if (positionals.length < 1 || values.help) { 66 | help(); 67 | process.exit(1); 68 | } 69 | 70 | const command = positionals[0]; 71 | 72 | switch(command) { 73 | case 'help' : 74 | await help(); 75 | break; 76 | case 'init' : 77 | await init(); 78 | break; 79 | case 'add' : 80 | /** @type {'major' | 'minor' | 'patch'} */ 81 | let changeType = 'patch'; 82 | if (values.minor) { 83 | changeType = 'minor'; 84 | } 85 | if (values.major) { 86 | changeType = 'major'; 87 | } 88 | if (!values.message) { 89 | if (positionals.length>1) { 90 | // We also support setting a message after the command instead of -m 91 | values.message = positionals.slice(1).join(' '); 92 | } else { 93 | throw new Error('The "-m" or "--message" argument is required'); 94 | } 95 | } 96 | await add({ 97 | message: values.message, 98 | changeType, 99 | }); 100 | break; 101 | case 'release' : 102 | await release(values.force); 103 | break; 104 | case 'format' : 105 | await format(); 106 | break; 107 | case 'show' : 108 | await show({ 109 | all: !!values.all, 110 | version: positionals[1], 111 | noWrap: !!values.nowrap 112 | }); 113 | break; 114 | case 'list' : 115 | await list(); 116 | break; 117 | default: 118 | process.stderr.write(`Unknown command ${command}\n`); 119 | process.exit(1); 120 | break; 121 | } 122 | 123 | } 124 | 125 | try { 126 | await main(); 127 | } catch (err) { 128 | process.stderr.write('Error: ' + err.message + '\n'); 129 | process.exit(1); 130 | } 131 | 132 | function help() { 133 | console.log( 134 | `Changelog Tool v${pkg.version} 135 | 136 | Manipulate your changelog file 137 | 138 | Usage: 139 | 140 | changelog init - Create a new, empty changelog. 141 | changelog add -m [message] - Adds a new line to the changelog. 142 | changelog release - Marks the current changelog as released. 143 | changelog show - Show the last changelog. 144 | changelog show [version] - Show the changelog of a specific version. 145 | changelog list - List all versions in the changelog. 146 | changelog format - Reformats the changelog in the standard format. 147 | 148 | The logs this tool uses follows a specific markdown format. Currently it will 149 | only look for a file named 'changelog.md' in the current directory. 150 | 151 | To see an example of this format, you can either run 'changelog init' or 152 | check out the changelog shipped with this project: 153 | 154 | https://github.com/evert/changelog-tool 155 | `); 156 | 157 | } 158 | 159 | async function init() { 160 | 161 | if (await exists(filename)) { 162 | throw new Error(`A file named ${filename} already exists`); 163 | } 164 | 165 | const changelog = new Changelog(); 166 | const version = new VersionLog(await readPackageVersion()); 167 | version.add('New project!'); 168 | changelog.versions.push(version); 169 | 170 | await fs.writeFile(filename, changelog.toString()); 171 | console.log(`${filename} created`); 172 | 173 | } 174 | 175 | async function list() { 176 | 177 | const changelog = await parseChangelog(); 178 | 179 | for(const version of changelog.versions) { 180 | console.log(version.version); 181 | } 182 | 183 | } 184 | 185 | /** 186 | * @param {Object} showOptions 187 | * @param {boolean} showOptions.all Show all versions 188 | * @param {string?} showOptions.version show a specific version 189 | * @param {boolean?} showOptions.noWrap don't line-wrap output 190 | */ 191 | async function show({all, version, noWrap}) { 192 | 193 | const changelog = await parseChangelog(); 194 | 195 | let toRender; 196 | if (all) { 197 | toRender = changelog.versions; 198 | } else if (version) { 199 | toRender = [changelog.get(version)]; 200 | } else { 201 | toRender = [changelog.versions[0]]; 202 | } 203 | 204 | console.log( 205 | toRender 206 | .map( log => log.output(!noWrap)) 207 | .join('\n\n') 208 | ); 209 | 210 | } 211 | 212 | async function format() { 213 | const changelog = await parseChangelog(); 214 | await fs.writeFile(filename, changelog.toString()); 215 | console.log(`${changelog.versions.length} changelogs saved to ${filename}`); 216 | } 217 | 218 | /** 219 | * @param {Object} options 220 | * @param {string} options.message 221 | * @param {'patch'|'major'|'minor'} options.changeType 222 | */ 223 | async function add({message, changeType}) { 224 | const changelog = await parseChangelog(); 225 | 226 | let lastVersion = changelog.versions[0]; 227 | if (lastVersion.date) { 228 | lastVersion = changelog.newVersion(changeType); 229 | console.log('Creating new version: %s', lastVersion.version); 230 | } else { 231 | if (changeType === 'minor' || changeType === 'major') { 232 | const previousVersion = changelog.versions[1]; 233 | const updatedVersionStr = calculateNextVersion(previousVersion.version, changeType); 234 | if (updatedVersionStr !== lastVersion.version) { 235 | console.log('Updating unreleased version from %s to %s', lastVersion.version, updatedVersionStr); 236 | lastVersion.version = updatedVersionStr; 237 | } 238 | } 239 | } 240 | 241 | lastVersion.add(message); 242 | 243 | await fs.writeFile(filename, changelog.toString()); 244 | console.log(`${changelog.versions.length} changelogs saved to ${filename}`); 245 | } 246 | 247 | async function release(force = false) { 248 | const changelog = await parseChangelog(); 249 | 250 | let lastVersion = changelog.versions[0]; 251 | if (lastVersion.date) { 252 | throw new Error(`Previous version "${lastVersion.version}" already had a release date`); 253 | } 254 | lastVersion.date = new Date().toISOString().substr(0,10); 255 | console.log(`Releasing ${lastVersion.version}`); 256 | 257 | const useGit = await isGit(); 258 | 259 | if (useGit) { 260 | if (!isGitClean()) { 261 | if (force) { 262 | console.warn('Warning: Git working directory is not clean. Ignoring.'); 263 | } else { 264 | throw new Error('Current git working directory is not clean. Please commit your changes first'); 265 | } 266 | } 267 | } 268 | 269 | await fs.writeFile(filename, changelog.toString()); 270 | console.log(`${changelog.versions.length} changelogs saved to ${filename}`); 271 | 272 | if (await exists('package.json')) { 273 | runCommand( 274 | `npm version "${lastVersion.version}" --no-git-tag-version` 275 | ); 276 | } 277 | if (useGit) { 278 | runCommand(`git add --all`); 279 | runCommand(`git commit -m "Releasing ${lastVersion.version}"`); 280 | runCommand(`git tag v${lastVersion.version}`); 281 | } 282 | 283 | } 284 | 285 | /** 286 | * @returns {Promise} 287 | */ 288 | async function parseChangelog() { 289 | 290 | if (!await exists(filename)) { 291 | throw new Error(`${filename} not found in current directory`); 292 | } 293 | 294 | return await parseFile(filename); 295 | 296 | } 297 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | export { parse, parseFile } from './parse.mjs'; 2 | export { Changelog, VersionLog, LogItem } from './changelog.mjs'; 3 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "changelog-tool", 3 | "version": "1.2.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "changelog-tool", 9 | "version": "1.2.1", 10 | "license": "MIT", 11 | "bin": { 12 | "changelog": "cli.mjs" 13 | }, 14 | "devDependencies": { 15 | "@types/node": "^18.11.19", 16 | "typescript": "^5.8.3" 17 | } 18 | }, 19 | "node_modules/@types/node": { 20 | "version": "18.19.100", 21 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.100.tgz", 22 | "integrity": "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA==", 23 | "dev": true, 24 | "license": "MIT", 25 | "dependencies": { 26 | "undici-types": "~5.26.4" 27 | } 28 | }, 29 | "node_modules/typescript": { 30 | "version": "5.8.3", 31 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 32 | "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 33 | "dev": true, 34 | "license": "Apache-2.0", 35 | "bin": { 36 | "tsc": "bin/tsc", 37 | "tsserver": "bin/tsserver" 38 | }, 39 | "engines": { 40 | "node": ">=14.17" 41 | } 42 | }, 43 | "node_modules/undici-types": { 44 | "version": "5.26.5", 45 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 46 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 47 | "dev": true, 48 | "license": "MIT" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "changelog-tool", 3 | "version": "1.2.1", 4 | "description": "A CLI tool for manipulating changelogs", 5 | "type": "module", 6 | "main": "index.mjs", 7 | "homepage": "https://github.com/evert/changelog-tool", 8 | "author": "Evert Pot (https://evertpot.com/)", 9 | "license": "MIT", 10 | "scripts": { 11 | "test": "node --test", 12 | "watch": "tsc --watch" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:evert/changelog-tool.git" 17 | }, 18 | "keywords": [ 19 | "changelog", 20 | "markdown" 21 | ], 22 | "engine": { 23 | "node": ">16" 24 | }, 25 | "bin": { 26 | "changelog": "./cli.mjs" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^18.11.19", 30 | "typescript": "^5.8.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /parse.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Changelog, VersionLog } from "./changelog.mjs"; 3 | import { readFile } from 'node:fs/promises'; 4 | 5 | /** 6 | * @param {string} filename 7 | * @returns {Promise} 8 | */ 9 | export async function parseFile(filename) { 10 | 11 | return parse( 12 | await readFile(filename, 'utf-8') 13 | ); 14 | 15 | } 16 | 17 | const linkReferenceRe = /^\[([a-zA-Z0-9]+)\]:/; 18 | const versionRe = /^([0-9\.]{3,}(?:-(?:alpha|beta)\.[0-9]+)?) \(([0-9]{4}-[0-9]{2}-[0-9]{2}|\?\?\?\?-\?\?-\?\?)\)$/; 19 | 20 | 21 | /** 22 | * @param {string} changelogInput 23 | * @returns {Changelog} 24 | */ 25 | export function parse(changelogInput) { 26 | 27 | const lines = changelogInput.split('\n'); 28 | if (!lines[1].match(/^={1,}$/)) { 29 | throw new Error('Parse error: Line 1 and 2 of the changelog must be in the format "Changelog\\n=====". We did not find all equals signs on the second line.'); 30 | } 31 | const changelog = new Changelog(); 32 | changelog.title = lines[0]; 33 | 34 | let lastVersionLog = null; 35 | let lastBullet = null; 36 | 37 | for(let idx=2; idx { 7 | 8 | const input = `Time for a change 9 | ========= 10 | 11 | 0.2.0 (????-??-??) 12 | ------------------ 13 | 14 | * Implemented the 'list' command. 15 | * Added testing framework. 16 | 17 | 0.1.0 (2023-02-08) 18 | ------------------ 19 | 20 | * Implemented the 'help' and 'init' commands. 21 | * 22 | `; 23 | 24 | const result = await parse(input); 25 | 26 | assert.equal('Time for a change', result.title); 27 | assert.equal(2, result.versions.length); 28 | 29 | assert.equal(null, result.versions[0].date); 30 | assert.equal('0.2.0', result.versions[0].version); 31 | assert.equal('2023-02-08', result.versions[1].date); 32 | assert.equal('0.1.0', result.versions[1].version); 33 | 34 | }); 35 | 36 | test('Parsing changelog entries', async () => { 37 | 38 | const input = `Time for a change 39 | ========= 40 | 41 | 0.2.0 (????-??-??) 42 | ------------------ 43 | 44 | * Implemented the 'list' command. 45 | * Added testing framework. 46 | 47 | 0.1.0 (2023-02-08) 48 | ------------------ 49 | 50 | * Implemented the 'help' and 'init' commands. 51 | * 52 | `; 53 | 54 | const result = await parse(input); 55 | 56 | const latest = result.get('0.2.0'); 57 | assert.equal(2, latest.items.length); 58 | assert.equal('Implemented the \'list\' command.', latest.items[0].message); 59 | 60 | 61 | }); 62 | 63 | test('Preface and postface', async () => { 64 | 65 | const input = `Time for a change 66 | ========= 67 | 68 | 0.2.0 (????-??-??) 69 | ------------------ 70 | 71 | WOW another release. How good is that? 72 | Here's another line. 73 | 74 | * Implemented the 'list' command. 75 | * Added testing framework. 76 | 77 | Well, that's all folks. 78 | 79 | 0.1.0 (2023-02-08) 80 | ------------------ 81 | 82 | * Implemented the 'help' and 'init' commands. 83 | * 84 | `; 85 | 86 | const result = await parse(input); 87 | 88 | const latest = result.get('0.2.0'); 89 | 90 | assert.equal('WOW another release. How good is that? Here\'s another line.', latest.preface); 91 | assert.equal('Well, that\'s all folks.', latest.postface); 92 | 93 | 94 | }); 95 | 96 | test('Link references', async() => { 97 | 98 | 99 | const input = `Changesss 100 | ========= 101 | 102 | 0.2.0 (????-??-??) 103 | ------------------ 104 | 105 | WOW another release. How good is that? 106 | Here's another line. 107 | 108 | * Implemented the 'list' command. 109 | * Added testing framework. See [the blog post][1] for more information. 110 | 111 | 0.1.0 (2023-02-08) 112 | ------------------ 113 | 114 | * Implemented the ['help'][2] and 'init' commands. 115 | 116 | [1]: https://evertpot.com/ "My Blog" 117 | [2]: https://indieweb.social/@evert "My Mastodon account, but it's split 118 | over two lines" 119 | [blabla]: http://example 120 | `; 121 | 122 | const result = await parse(input); 123 | 124 | assert.deepEqual({ 125 | name: '1', 126 | href: 'https://evertpot.com/', 127 | title: 'My Blog', 128 | }, result.links[0]); 129 | assert.deepEqual({ 130 | name: '2', 131 | href: 'https://indieweb.social/@evert', 132 | title: 'My Mastodon account, but it\'s split over two lines', 133 | }, result.links[1]); 134 | assert.deepEqual({ 135 | name: 'blabla', 136 | href: 'http://example', 137 | title: null, 138 | }, result.links[2]); 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "esnext", 5 | "rootDir": "./", 6 | "allowJs": true, 7 | "checkJs": true, 8 | 9 | "moduleResolution": "node", 10 | 11 | "noEmit": true, 12 | "strict": true, 13 | "useUnknownInCatchVariables": false, 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /util.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { execSync } from 'node:child_process'; 3 | import * as fs from 'node:fs/promises'; 4 | import * as path from 'node:path'; 5 | 6 | /** 7 | * Checks if a file exists 8 | * 9 | * @param {string} filename 10 | * @returns {Promise} 11 | */ 12 | export async function exists(filename) { 13 | 14 | try { 15 | await fs.stat(filename) 16 | } catch (err) { 17 | if (err.code === 'ENOENT') return false; 18 | throw err; 19 | } 20 | return true; 21 | 22 | } 23 | 24 | /** 25 | * Returns the version property of the package.json file in the current 26 | * directory. 27 | * 28 | * @returns {Promise} 29 | */ 30 | export async function readPackageVersion() { 31 | 32 | if (!await exists('package.json')) { 33 | throw new Error('package.json does not exists in the current directory'); 34 | } 35 | 36 | const json = JSON.parse( 37 | await fs.readFile( 38 | 'package.json', 39 | 'utf-8' 40 | ) 41 | ); 42 | 43 | return json.version; 44 | 45 | } 46 | 47 | /** 48 | * Wraps a line over multiple lines. 49 | * 50 | * @param {string} input 51 | * @param {number} secondLineOffset 52 | * @param {number} lineLength 53 | */ 54 | export function wrap(input, secondLineOffset = 0, lineLength = 79) { 55 | 56 | const words = input.split(' '); 57 | const lines = []; 58 | for(const word of words) { 59 | 60 | if (!lines.length) { 61 | // First line 62 | lines.push(word); 63 | continue; 64 | } 65 | 66 | const maxLength = lines.length > 1 ? lineLength - secondLineOffset : lineLength; 67 | 68 | const potentialNewLine = [lines.at(-1),word].join(' '); 69 | if (potentialNewLine.length>maxLength) { 70 | lines.push(word); 71 | } else { 72 | lines[lines.length-1] = potentialNewLine; 73 | } 74 | 75 | } 76 | return lines.join('\n' + ' '.repeat(secondLineOffset)); 77 | 78 | } 79 | 80 | /** 81 | * @param {string} prevVersion 82 | * @param {'patch'|'minor'|'major'} changeType 83 | * @returns {string} 84 | */ 85 | export function calculateNextVersion(prevVersion, changeType = 'patch') { 86 | 87 | // This function only currently understands 1 format, but this may change 88 | // in the future. 89 | if (!prevVersion.match(/^[0-9]+\.[0-9]+\.[0-9]+$/)) { 90 | throw new Error(`Could not automatically determine the next ${changeType} version from ${prevVersion}. You might want to request a new feature to support this`); 91 | } 92 | 93 | const parts = prevVersion.split('.').map( part => +part); 94 | 95 | switch(changeType) { 96 | case 'major' : 97 | parts[0]++; 98 | parts[1]=0; 99 | parts[2]=0; 100 | break; 101 | case 'minor' : 102 | parts[1]++; 103 | parts[2]=0; 104 | break; 105 | case 'patch' : 106 | parts[2]++; 107 | break; 108 | } 109 | 110 | return parts.join('.'); 111 | 112 | } 113 | 114 | /** 115 | * Returns true if we're in a git-powered directory 116 | * 117 | * @returns {Promise} 118 | */ 119 | export async function isGit() { 120 | 121 | let currentPath = process.cwd(); 122 | while(currentPath!=='/') { 123 | if (await exists(path.join(currentPath,'.git'))) { 124 | return true; 125 | } 126 | currentPath = path.dirname(currentPath); 127 | } 128 | return false; 129 | 130 | } 131 | 132 | /** 133 | * @param {string} command 134 | * @returns {string} 135 | */ 136 | export function runCommand(command) { 137 | 138 | process.stderr.write(command + '\n'); 139 | return execSync(command).toString('utf-8'); 140 | 141 | } 142 | 143 | /** 144 | * Returns true if the current working directory is clean. 145 | * 146 | * @returns {boolean} 147 | */ 148 | export function isGitClean() { 149 | 150 | const result = execSync('git status --porcelain=v1').toString('utf-8'); 151 | return result.trim().length === 0; 152 | 153 | } 154 | --------------------------------------------------------------------------------