changelog-machine 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +133 -75
  3. package/bin/cli.js +5 -1
  4. package/lib/changelog/contributors.d.ts +2 -0
  5. package/lib/changelog/contributors.js +31 -0
  6. package/lib/changelog/links.d.ts +31 -0
  7. package/lib/changelog/links.js +83 -0
  8. package/lib/changelog/render.d.ts +2 -0
  9. package/lib/changelog/render.js +69 -0
  10. package/lib/changelog/text.d.ts +5 -0
  11. package/lib/changelog/text.js +23 -0
  12. package/lib/changelog/update.d.ts +7 -0
  13. package/lib/changelog/update.js +119 -0
  14. package/lib/cli/files.d.ts +5 -0
  15. package/lib/cli/files.js +35 -0
  16. package/lib/cli/index.d.ts +1 -1
  17. package/lib/cli/index.js +44 -39
  18. package/lib/cli/options.d.ts +13 -0
  19. package/lib/cli/options.js +48 -0
  20. package/lib/cli/plans.d.ts +7 -0
  21. package/lib/cli/plans.js +67 -0
  22. package/lib/cli/progress.d.ts +2 -0
  23. package/lib/cli/progress.js +37 -0
  24. package/lib/commits/parse.d.ts +3 -0
  25. package/lib/commits/parse.js +87 -0
  26. package/lib/contributors/authors.d.ts +6 -0
  27. package/lib/contributors/authors.js +20 -0
  28. package/lib/contributors/privacy.d.ts +2 -0
  29. package/lib/contributors/privacy.js +9 -0
  30. package/lib/contributors/profile.d.ts +5 -0
  31. package/lib/contributors/profile.js +51 -0
  32. package/lib/contributors/repository.d.ts +7 -0
  33. package/lib/contributors/repository.js +36 -0
  34. package/lib/contributors/resolve.d.ts +10 -0
  35. package/lib/contributors/resolve.js +86 -0
  36. package/lib/git/command.d.ts +5 -0
  37. package/lib/git/command.js +39 -0
  38. package/lib/git/commits.d.ts +2 -0
  39. package/lib/git/commits.js +27 -0
  40. package/lib/git/history.d.ts +3 -0
  41. package/lib/git/history.js +41 -0
  42. package/lib/git/refs.d.ts +12 -0
  43. package/lib/git/refs.js +101 -0
  44. package/lib/git/remote.d.ts +1 -0
  45. package/lib/git/remote.js +59 -0
  46. package/lib/git/tags.d.ts +1 -0
  47. package/lib/git/tags.js +27 -0
  48. package/lib/index.d.ts +8 -1
  49. package/lib/index.js +6 -13
  50. package/lib/types.d.ts +31 -0
  51. package/lib/types.js +1 -0
  52. package/lib/version/json.d.ts +5 -0
  53. package/lib/version/json.js +35 -0
  54. package/lib/version/lockfiles.d.ts +3 -0
  55. package/lib/version/lockfiles.js +44 -0
  56. package/lib/version/plan.d.ts +11 -0
  57. package/lib/version/plan.js +51 -0
  58. package/package.json +34 -42
  59. package/lib/cli/commands/printHelp.d.ts +0 -2
  60. package/lib/cli/commands/printHelp.js +0 -15
  61. package/lib/cli/commands/printMarkdown.d.ts +0 -3
  62. package/lib/cli/commands/printMarkdown.js +0 -26
  63. package/lib/cli/commands/printVersion.d.ts +0 -2
  64. package/lib/cli/commands/printVersion.js +0 -12
  65. package/lib/cli/config/options.d.ts +0 -3
  66. package/lib/cli/config/options.js +0 -13
  67. package/lib/cli/config/readConfigFile.d.ts +0 -3
  68. package/lib/cli/config/readConfigFile.js +0 -25
  69. package/lib/core/async.d.ts +0 -9
  70. package/lib/core/async.js +0 -32
  71. package/lib/core/default.d.ts +0 -6
  72. package/lib/core/default.js +0 -9
  73. package/lib/core/git.d.ts +0 -8
  74. package/lib/core/git.js +0 -88
  75. package/lib/core/markdown.d.ts +0 -8
  76. package/lib/core/markdown.js +0 -85
  77. package/lib/core/test-helpers.d.ts +0 -6
  78. package/lib/core/test-helpers.js +0 -14
  79. package/lib/models/commit.d.ts +0 -6
  80. package/lib/models/commit.js +0 -2
  81. package/lib/models/config.d.ts +0 -30
  82. package/lib/models/config.js +0 -2
  83. package/lib/models/index.d.ts +0 -3
  84. package/lib/models/index.js +0 -15
  85. package/lib/models/tagGroup.d.ts +0 -7
  86. package/lib/models/tagGroup.js +0 -2
package/LICENSE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  The MIT License (MIT)
2
- Copyright (c) 2022 Philipp Kief
2
+ Copyright (c) 2026 Philipp Kief
3
3
 
4
4
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
5
 
package/README.md CHANGED
@@ -1,108 +1,166 @@
1
- <h1 align="center">
2
- <br>
3
- <img src="https://github.com/PKief/changelog-machine/raw/main/logo.png" alt="logo" width="200">
4
- <br><br>
5
- Changelog Machine
6
- <br>
7
- <br>
8
- </h1>
1
+ # Changelog Machine
9
2
 
10
- <h4 align="center">Generate a changelog based on Git commit messages and tags.</h4>
3
+ Conventional changelogs and version bumps, with **zero production dependencies**.
4
+ Requires **Node 24+** and **Git**.
11
5
 
12
- ## Getting started
6
+ ## Quick start
13
7
 
14
- The tool can be executed with this command:
8
+ Run inside your repository:
15
9
 
16
- ```
17
- npx changelog-machine
10
+ ```sh
11
+ npx changelog-machine # Create or update CHANGELOG.md
12
+ npx changelog-machine --dry-run # Preview changes
13
+ npx changelog-machine --full # Rebuild from scratch
14
+ npx changelog-machine --bump --dry-run # Preview the next version
15
+ npx changelog-machine --bump # Update package metadata and release changelog
18
16
  ```
19
17
 
20
- It will generate a file called CHANGELOG.md in the root directory of the project.
18
+ - **Default:** add newer releases, refresh `Unreleased`, preserve older sections.
19
+ - **Missing file:** generate the entire history.
20
+ - **`--full`:** replace all content, including manual edits.
21
+ - No automatic Git fetches, commits, tags, pushes, or publishing.
21
22
 
22
- ## Configuration
23
+ ## Options
23
24
 
24
- The generation of the changelog can be configured via a config file.
25
+ | Option | Purpose |
26
+ | --- | --- |
27
+ | `--full` | Rebuild the entire changelog |
28
+ | `--bump` | Infer and apply a version bump |
29
+ | `--dry-run` | Preview without writing |
30
+ | `--output <path>` | Set the changelog destination |
31
+ | `--repo-url <url>` | Override repository links with an HTTPS URL |
32
+ | `--resolve-contributors` | Look up verified contributor profiles |
33
+ | `--include-non-conventional` | Include other commits under Other Changes |
34
+ | `--help`, `-h` | Show usage |
35
+ | `--version`, `-v` | Show the tool version |
25
36
 
26
- ```
27
- npx changelog-machine --config changelog.config.json
37
+ Long operations show progress in interactive terminals. Redirected output stays clean.
38
+
39
+ ## Changelog format
40
+
41
+ ```md
42
+ # Changelog
43
+
44
+ ## v2.0.0
45
+
46
+ [compare changes](https://github.com/example/project/compare/v1.0.0...v2.0.0)
47
+
48
+ ### Enhancements
49
+
50
+ - **icons:** Add new icons ([#42](https://github.com/example/project/pull/42))
51
+
52
+ ### Contributors
53
+
54
+ - Philipp Kief
28
55
  ```
29
56
 
30
- ### Command overview
57
+ - Releases: **newest first**. Commits within categories: **oldest first**.
58
+ - Messages start uppercase; scopes and Markdown are preserved.
59
+ - Separate sections for breaking changes, features, fixes, and other commit types.
60
+ - Release bookkeeping commits are excluded; merge commits are skipped.
61
+ - GitHub, GitLab, and Bitbucket links are supported.
62
+ - Contributor names honor `.mailmap`; recognizable emails are redacted.
31
63
 
32
- | COMMAND | ALIAS | DESCRIPTION |
33
- | ----------- | ----- | ------------------------------ |
34
- | `--config` | `-c` | Path to the configuration file |
35
- | `--version` | `-v` | Print version |
36
- | `--help` | `-h` | Print command instructions |
64
+ Use `--full` to apply formatting changes to older sections.
37
65
 
38
- ### Configuration file
66
+ ## Incremental updates
39
67
 
40
- The configuration file can be configured like this:
68
+ The newest documented tag is the update boundary. Only newer commit ranges are
69
+ loaded; older sections stay untouched. Unchanged files are not rewritten.
41
70
 
42
- ```json
43
- {
44
- "title": "Changelog",
45
- "description": "Some description text",
46
- "repositoryUrl": "https://github.com/PKief/vscode-material-icon-theme",
47
- "blacklistPattern": "Release|^\\d+\\.\\d+\\.\\d+$",
48
- "filePath": "CHANGELOG.md"
49
- }
71
+ - Supports `1.2.3` / `v1.2.3` tags, including prereleases and build metadata.
72
+ - Uses Git ancestry, not tag dates; avoids quadratic tag comparisons.
73
+ - Requires `# Changelog` and `## <tag>` / `## Unreleased` headings.
74
+ - Invalid formatting, ambiguous tags, or missing boundaries fail safely.
75
+ - Does not backfill gaps between older documented releases.
76
+ - Historical emails remain untouched; `--full` redacts generated history.
77
+
78
+ Shallow clone? Run `git fetch --unshallow --tags` first.
79
+
80
+ ## Contributor profiles
81
+
82
+ ```sh
83
+ changelog-machine --resolve-contributors
50
84
  ```
51
85
 
52
- More detailed description of the config:
86
+ Adds verified profile links next to Git author names. Network access is **opt-in**.
53
87
 
54
- | CONFIG | DESCRIPTION | DEFAULT |
55
- | ------------------ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
56
- | `title` | Title of the changelog | "Changelog" |
57
- | `description` | Description under the title | "All notable changes to this project will be documented in this file. Dates are displayed in UTC." |
58
- | `filePath` | Path of the output file | "CHANGELOG.md" |
59
- | `repositoryUrl` | Links to the repository for further information | |
60
- | `blacklistPattern` | Regex to remove commit messages | |
88
+ | Provider | Resolution | Optional token |
89
+ | --- | --- | --- |
90
+ | GitHub | Associated commit author | `GH_TOKEN` or `GITHUB_TOKEN` |
91
+ | Bitbucket | Associated user | `BITBUCKET_TOKEN` (bearer token) |
92
+ | GitLab | Git author name only | — |
61
93
 
62
- ## Example output
94
+ Unresolved users fall back to Git author names, never emails. Email-only names
95
+ become `Unknown contributor`. No username guessing or email searches.
63
96
 
64
- The following commit summary is printed into the CHANGELOG.md file and can look like this:
97
+ Lookups are grouped by mailmapped author identity and cached within a run. One
98
+ successful request covers all that author's commits; unresolved authors get at
99
+ most two attempts. Requests use five-second timeouts and three concurrent workers.
100
+ Rate limits stop queued requests with one warning; enriched results may vary.
65
101
 
66
- #### [v4.12.1](https://github.com/PKief/vscode-material-icon-theme/compare/v4.12.1...HEAD)
102
+ ## Version bumps
67
103
 
68
- > February 5, 2022
104
+ The highest-impact commit since the latest release wins:
69
105
 
70
- - Update release commit message [`3167316`](https://github.com/PKief/vscode-material-icon-theme/commit/3167316)
71
- - Update release workflow [`7f2520d`](https://github.com/PKief/vscode-material-icon-theme/commit/7f2520d)
72
- - Adjust preview script for folder icons [`6a00111`](https://github.com/PKief/vscode-material-icon-theme/commit/6a00111)
73
- - Fix compare link [`fa45abf`](https://github.com/PKief/vscode-material-icon-theme/commit/fa45abf)
74
- - Filter out release commits [`e3468ca`](https://github.com/PKief/vscode-material-icon-theme/commit/e3468ca)
106
+ | Commit | Bump |
107
+ | --- | --- |
108
+ | `!`, `BREAKING CHANGE:`, or `BREAKING-CHANGE:` | Major, even on `0.x` |
109
+ | `feat:` | Minor |
110
+ | `fix:` or `perf:` | Patch |
111
+ | Other types | None |
75
112
 
76
- ## Programmatic use
113
+ - Updates `package.json`, an existing npm lockfile (versions 1–3), and the changelog with `## v<next-version>`. No Git tag is needed to prepare that section; the CLI does not create one.
114
+ - Leaves dependency versions unchanged.
115
+ - Requires the latest tag to match the package version; repeated bumps fail.
116
+ - Without tags, analyzes all commits against the current package version.
117
+ - Prerelease promotion, Yarn/pnpm lockfiles, and workspaces are unsupported.
118
+ - `--bump --full` rebuilds historical sections too. Without `--full`, older sections remain unchanged. `--output` also works with bumps; `--dry-run` previews without writing. No release-worthy commits means no file changes.
119
+ - After a successful bump, commit and create the matching version tag before another incremental update or bump.
77
120
 
78
- The tool can be imported as module into existing JavaScript or TypeScript code. Therefor it is necessary to install it via npm or yarn:
121
+ Files are staged first and replaced atomically per file—not as a multi-file transaction.
79
122
 
80
- NPM:
123
+ ## API
81
124
 
82
- ```
83
- npm install --save-dev changelog-machine
125
+ ```ts
126
+ import {
127
+ getReleases,
128
+ getRepositoryUrl,
129
+ renderMarkdown,
130
+ resolveContributors,
131
+ } from 'changelog-machine';
132
+
133
+ const releases = await getReleases();
134
+ const repositoryUrl = await getRepositoryUrl();
135
+ const markdown = renderMarkdown(releases, { repositoryUrl });
136
+
137
+ // Optional network enrichment
138
+ const contributors = await resolveContributors(
139
+ releases.flatMap(release => release.commits),
140
+ repositoryUrl,
141
+ );
142
+ const enriched = renderMarkdown(releases, { repositoryUrl, contributors });
84
143
  ```
85
144
 
86
- Yarn:
145
+ Also exports `parseCommit`, `recommendedBump`, `planVersion`, and TypeScript types.
146
+ Pass a boundary tag to `getReleases(cwd, tag)` for incremental history.
147
+ The API does not write files. Explicit GitLab profiles can be supplied to the renderer.
87
148
 
88
- ```
89
- yarn add --dev changelog-machine
90
- ```
149
+ **ESM-only.** Replaces the old `printMarkdown` API and JSON configuration; requires a major release.
91
150
 
92
- The module can be imported like this:
151
+ ## Development and publishing
93
152
 
94
- ```ts
95
- import { printMarkdown } from 'changelog-machine';
96
-
97
- // usage with default config
98
- printMarkdown();
99
-
100
- // usage with customizations
101
- printMarkdown({
102
- filePath: './changelog/CHANGES.md',
103
- description: 'List of changes',
104
- title: 'All releases',
105
- blacklistPattern: '^Release', // excludes all commits that start with "Release"
106
- repositoryUrl: 'https://github.com/PKief/vscode-material-icon-theme',
107
- });
153
+ ```sh
154
+ npm ci
155
+ npm run build
156
+ npm test
157
+ npm run lint
158
+ npm pack --dry-run
108
159
  ```
160
+
161
+ - Tests live beside source as `name.test.ts` and run directly with Node's native TypeScript support; no compiled test directory is needed.
162
+ - `npm test` type-checks first, then runs Node's test runner. Node strips types but does not type-check.
163
+ - Root TypeScript config covers source and tests; build config excludes tests. Source imports use `.js`, matching published output. A test-only Node loader resolves those imports to `.ts` while executing source directly.
164
+ - TypeScript 7 is development-only; npm ships compiled JavaScript and types.
165
+ - `package.json`'s `files` allowlist replaces `.npmignore`; `prepack` builds automatically.
166
+ - Use **Actions → Release → Run workflow** on the default branch, or `gh workflow run release.yml --ref main`. No version input is needed: conventional commits determine the bump. The workflow updates package metadata and changelog, commits and pushes a version tag, publishes to npm, and creates a GitHub Release. With no release-worthy commits, it makes no changes. Requires npm trusted publishing configured for this repository and `release.yml`, plus permission for the workflow token to push to the default branch; branch-protection rules may block that push. Tag pushes no longer trigger publishing separately. If npm publication fails after the release commit/tag was pushed, recover manually rather than expecting another click to repeat the release.
package/bin/cli.js CHANGED
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env node
2
- require('../lib/cli/index.js');
2
+ import { run } from '../lib/cli/index.js';
3
+ run().catch(error => {
4
+ console.error(`Error: ${error.message}`);
5
+ process.exitCode = 1;
6
+ });
@@ -0,0 +1,2 @@
1
+ import type { Options, ParsedCommit } from '../types.js';
2
+ export declare function contributorLines(commits: ParsedCommit[], options: Options): string[];
@@ -0,0 +1,31 @@
1
+ import { authorName } from '../contributors/privacy.js';
2
+ import { validateProfile } from '../contributors/profile.js';
3
+ import { plainText } from './text.js';
4
+ function profileForCommit(commit, options) {
5
+ const profile = options.contributors?.[commit.hash];
6
+ if (!profile || !options.repositoryUrl)
7
+ return undefined;
8
+ try {
9
+ const host = new URL(options.repositoryUrl).hostname;
10
+ if (!['github.com', 'bitbucket.org', 'gitlab.com'].includes(host))
11
+ return undefined;
12
+ return validateProfile(profile.username, profile.url, host);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ export function contributorLines(commits, options) {
19
+ const contributors = new Map();
20
+ for (const commit of commits) {
21
+ const name = authorName(commit.name);
22
+ const profile = profileForCommit(commit, options);
23
+ const key = profile?.url ?? name;
24
+ const label = profile
25
+ ? `${plainText(name)} ([@${plainText(profile.username)}](${profile.url}))`
26
+ : plainText(name);
27
+ if (!contributors.has(key))
28
+ contributors.set(key, `- ${label}`);
29
+ }
30
+ return [...contributors.values()].sort();
31
+ }
@@ -0,0 +1,31 @@
1
+ import type { ParsedCommit } from '../types.js';
2
+ declare const repositoryRoutes: {
3
+ 'github.com': {
4
+ compare: string;
5
+ commit: string;
6
+ issue: string;
7
+ pullRequest: string;
8
+ };
9
+ 'gitlab.com': {
10
+ compare: string;
11
+ commit: string;
12
+ issue: string;
13
+ pullRequest: string;
14
+ };
15
+ 'bitbucket.org': {
16
+ compare: string;
17
+ commit: string;
18
+ issue: string;
19
+ pullRequest: string;
20
+ };
21
+ };
22
+ type RepositoryHost = keyof typeof repositoryRoutes;
23
+ export type Repository = {
24
+ base: string;
25
+ host: RepositoryHost;
26
+ };
27
+ export declare function repositoryBase(value?: string): Repository | undefined;
28
+ export declare function compareUrl(repository: Repository, baseTag: string, targetTag: string): string;
29
+ export declare function linkIssueReferences(message: string, repository?: Repository): string;
30
+ export declare function commitReference(commit: ParsedCommit, repository?: Repository): string;
31
+ export {};
@@ -0,0 +1,83 @@
1
+ import { encodeCharacter, encodeSegment, plainText } from './text.js';
2
+ const trailingSlashesPattern = /\/+$/;
3
+ const gitSuffixPattern = /\.git$/;
4
+ const urlUnsafeCharactersPattern = /[\s()<>\[\]]/g;
5
+ const repositoryRoutes = {
6
+ 'github.com': {
7
+ compare: '/compare/',
8
+ commit: '/commit/',
9
+ issue: '/issues/',
10
+ pullRequest: '/pull/',
11
+ },
12
+ 'gitlab.com': {
13
+ compare: '/-/compare/',
14
+ commit: '/-/commit/',
15
+ issue: '/-/issues/',
16
+ pullRequest: '/-/merge_requests/',
17
+ },
18
+ 'bitbucket.org': {
19
+ compare: '/branches/compare/',
20
+ commit: '/commits/',
21
+ issue: '/issues/',
22
+ pullRequest: '/pull-requests/',
23
+ },
24
+ };
25
+ export function repositoryBase(value) {
26
+ if (!value)
27
+ return undefined;
28
+ try {
29
+ const url = new URL(value);
30
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
31
+ return undefined;
32
+ if (url.username || url.password)
33
+ return undefined;
34
+ const host = url.hostname;
35
+ if (host !== 'github.com' &&
36
+ host !== 'gitlab.com' &&
37
+ host !== 'bitbucket.org')
38
+ return undefined;
39
+ url.search = '';
40
+ url.hash = '';
41
+ const base = url
42
+ .toString()
43
+ .replace(trailingSlashesPattern, '')
44
+ .replace(gitSuffixPattern, '')
45
+ .replace(urlUnsafeCharactersPattern, encodeCharacter);
46
+ return { base, host };
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ export function compareUrl(repository, baseTag, targetTag) {
53
+ const base = encodeSegment(baseTag);
54
+ const target = encodeSegment(targetTag);
55
+ const comparison = repository.host === 'bitbucket.org'
56
+ ? `${target}..${base}`
57
+ : `${base}...${target}`;
58
+ return `${repository.base}${repositoryRoutes[repository.host].compare}${comparison}`;
59
+ }
60
+ const inlineCodePattern = /(`+)[\s\S]*?\1/;
61
+ const markdownLinkPattern = /!?\[[^\]\n]*\]\([^\n]*?\)/;
62
+ const plainUrlPattern = /https?:\/\/[^\s)]+/;
63
+ const issueNumberPattern = /(?<![\w/#\\])#([1-9]\d*)\b/;
64
+ const protectedMarkdownOrIssue = new RegExp([inlineCodePattern, markdownLinkPattern, plainUrlPattern, issueNumberPattern]
65
+ .map((pattern) => pattern.source)
66
+ .join('|'), 'g');
67
+ export function linkIssueReferences(message, repository) {
68
+ if (!repository)
69
+ return message;
70
+ const issueUrl = `${repository.base}${repositoryRoutes[repository.host].issue}`;
71
+ return message.replace(protectedMarkdownOrIssue, (match, _code, issue) => issue ? `[#${issue}](${issueUrl}${issue})` : match);
72
+ }
73
+ export function commitReference(commit, repository) {
74
+ const label = commit.pr
75
+ ? `#${commit.pr}`
76
+ : plainText(commit.hash.slice(0, 8));
77
+ if (!repository)
78
+ return `(${label})`;
79
+ const routes = repositoryRoutes[repository.host];
80
+ const path = commit.pr ? routes.pullRequest : routes.commit;
81
+ const identifier = encodeSegment(commit.pr ?? commit.hash);
82
+ return `([${label}](${repository.base}${path}${identifier}))`;
83
+ }
@@ -0,0 +1,2 @@
1
+ import type { Options, Release } from '../types.js';
2
+ export declare function renderMarkdown(releases: Release[], options?: Options): string;
@@ -0,0 +1,69 @@
1
+ import { parseCommit } from '../commits/parse.js';
2
+ import { redactEmails } from '../contributors/privacy.js';
3
+ import { contributorLines } from './contributors.js';
4
+ import { commitReference, compareUrl, linkIssueReferences, repositoryBase, } from './links.js';
5
+ import { capitalizeMessage, plainText, safeText } from './text.js';
6
+ const groups = [
7
+ ['breaking', 'Breaking Changes'],
8
+ ['feat', 'Enhancements'],
9
+ ['fix', 'Fixes'],
10
+ ['perf', 'Performance'],
11
+ ['docs', 'Documentation'],
12
+ ['refactor', 'Refactors'],
13
+ ['test', 'Tests'],
14
+ ['build', 'Build'],
15
+ ['ci', 'CI'],
16
+ ['chore', 'Chores'],
17
+ ['revert', 'Reverts'],
18
+ ['other', 'Other Changes'],
19
+ ];
20
+ const noteLineBreakPattern = /\r?\n/;
21
+ function entry(commit, repository) {
22
+ const scope = commit.scope ? `**${plainText(commit.scope)}:** ` : '';
23
+ const reference = commitReference(commit, repository);
24
+ const lines = [
25
+ `- ${scope}${linkIssueReferences(safeText(capitalizeMessage(commit.description)), repository)} ${reference}`,
26
+ ];
27
+ for (const note of commit.breakingNotes) {
28
+ const parts = linkIssueReferences(safeText(capitalizeMessage(note)), repository).split(noteLineBreakPattern);
29
+ lines.push(` - ${parts[0]}`, ...parts.slice(1).map((line) => ` ${line}`));
30
+ }
31
+ return lines;
32
+ }
33
+ export function renderMarkdown(releases, options = {}) {
34
+ const repository = repositoryBase(options.repositoryUrl);
35
+ const lines = ['# Changelog'];
36
+ for (const release of releases) {
37
+ lines.push('', `## ${plainText(release.tag)}`);
38
+ if (repository && release.previousTag) {
39
+ lines.push('', `[compare changes](${compareUrl(repository, release.previousTag, release.tag)})`);
40
+ }
41
+ const commits = release.commits
42
+ .map((commit) => parseCommit(commit, options.includeNonConventional))
43
+ .filter((commit) => commit !== undefined);
44
+ const grouped = new Map();
45
+ for (const commit of commits) {
46
+ const key = commit.breaking
47
+ ? 'breaking'
48
+ : groups.some(([type]) => type === commit.type)
49
+ ? commit.type
50
+ : 'other';
51
+ const list = grouped.get(key) ?? [];
52
+ list.push(commit);
53
+ grouped.set(key, list);
54
+ }
55
+ for (const [key, title] of groups) {
56
+ const entries = grouped.get(key);
57
+ if (!entries?.length)
58
+ continue;
59
+ lines.push('', `### ${title}`, '');
60
+ for (const commit of entries)
61
+ lines.push(...entry(commit, repository));
62
+ }
63
+ const contributors = contributorLines(commits, options);
64
+ if (contributors.length) {
65
+ lines.push('', '### Contributors', '', ...contributors);
66
+ }
67
+ }
68
+ return redactEmails(`${lines.join('\n')}\n`);
69
+ }
@@ -0,0 +1,5 @@
1
+ export declare function capitalizeMessage(value: string): string;
2
+ export declare function safeText(value: string): string;
3
+ export declare function plainText(value: string): string;
4
+ export declare function encodeCharacter(character: string): string;
5
+ export declare function encodeSegment(value: string): string;
@@ -0,0 +1,23 @@
1
+ const markdownPunctuationPattern = /[\\`*_{}\[\]()#!|]/g;
2
+ const textLineBreakPattern = /[\r\n]+/g;
3
+ const segmentUnsafeCharactersPattern = /[!'()*]/g;
4
+ export function capitalizeMessage(value) {
5
+ return value.replace(/\p{L}/u, (letter) => letter.toUpperCase());
6
+ }
7
+ export function safeText(value) {
8
+ return value
9
+ .replaceAll('&', '&amp;')
10
+ .replaceAll('<', '&lt;')
11
+ .replaceAll('>', '&gt;');
12
+ }
13
+ export function plainText(value) {
14
+ return safeText(value)
15
+ .replace(markdownPunctuationPattern, '\\$&')
16
+ .replace(textLineBreakPattern, ' ');
17
+ }
18
+ export function encodeCharacter(character) {
19
+ return `%${character.charCodeAt(0).toString(16).toUpperCase()}`;
20
+ }
21
+ export function encodeSegment(value) {
22
+ return encodeURIComponent(value).replace(segmentUnsafeCharactersPattern, encodeCharacter);
23
+ }
@@ -0,0 +1,7 @@
1
+ export interface ExistingChangelog {
2
+ boundaryTag?: string;
3
+ prefix: string;
4
+ history: string;
5
+ }
6
+ export declare function parseChangelog(content: string): ExistingChangelog;
7
+ export declare function mergeChangelog(existing: ExistingChangelog | undefined, generated: string): string;
@@ -0,0 +1,119 @@
1
+ import { isVersionTag } from '../git/tags.js';
2
+ function headings(content) {
3
+ const result = [];
4
+ let offset = 0;
5
+ let fence;
6
+ for (const line of content.match(/[^\n]*(?:\n|$)/g) ?? []) {
7
+ if (!line)
8
+ continue;
9
+ const text = line.replace(/\r?\n$/, '');
10
+ const marker = text.match(/^ {0,3}(`{3,}|~{3,})/);
11
+ if (fence) {
12
+ if (marker &&
13
+ marker[1][0] === fence.character &&
14
+ marker[1].length >= fence.length) {
15
+ fence = undefined;
16
+ }
17
+ }
18
+ else if (marker) {
19
+ fence = { character: marker[1][0], length: marker[1].length };
20
+ }
21
+ else if (/^##(?:\s|$)/.test(text)) {
22
+ const heading = text.match(/^##\s+(.+?)\s*#*\s*$/);
23
+ result.push({ title: heading?.[1].trim() ?? '', start: offset });
24
+ }
25
+ offset += line.length;
26
+ }
27
+ return result;
28
+ }
29
+ function formatError(detail) {
30
+ return new Error(`Cannot update changelog: ${detail}. Use --full to regenerate it.`);
31
+ }
32
+ function removeSection(content, sectionHeadings, index) {
33
+ const start = sectionHeadings[index].start;
34
+ const end = sectionHeadings[index + 1]?.start ?? content.length;
35
+ return content.slice(0, start) + content.slice(end);
36
+ }
37
+ function validateTitle(content) {
38
+ if (!/^# Changelog(?:\r?\n|$)/.test(content)) {
39
+ throw formatError('expected a # Changelog title');
40
+ }
41
+ }
42
+ function validateSections(sectionHeadings) {
43
+ let foundRelease = false;
44
+ let foundUnreleased = false;
45
+ const tags = new Set();
46
+ for (const heading of sectionHeadings) {
47
+ if (heading.title === 'Unreleased') {
48
+ if (foundUnreleased)
49
+ throw formatError('duplicate Unreleased section');
50
+ if (foundRelease)
51
+ throw formatError('Unreleased section must precede tagged releases');
52
+ foundUnreleased = true;
53
+ }
54
+ else if (!isVersionTag(heading.title)) {
55
+ throw formatError(`unrecognized release heading "${heading.title}"`);
56
+ }
57
+ else {
58
+ if (tags.has(heading.title))
59
+ throw formatError(`duplicate release tag "${heading.title}"`);
60
+ tags.add(heading.title);
61
+ foundRelease = true;
62
+ }
63
+ }
64
+ }
65
+ function removeUnreleased(content, sectionHeadings) {
66
+ const index = sectionHeadings.findIndex((heading) => heading.title === 'Unreleased');
67
+ return index < 0 ? content : removeSection(content, sectionHeadings, index);
68
+ }
69
+ export function parseChangelog(content) {
70
+ validateTitle(content);
71
+ const sectionHeadings = headings(content);
72
+ validateSections(sectionHeadings);
73
+ const firstReleaseIndex = sectionHeadings.findIndex((heading) => isVersionTag(heading.title));
74
+ const firstRelease = sectionHeadings[firstReleaseIndex];
75
+ const prefixEnd = firstRelease?.start ?? content.length;
76
+ const prefix = removeUnreleased(content.slice(0, prefixEnd), sectionHeadings.filter((heading) => heading.start < prefixEnd));
77
+ if (!firstRelease)
78
+ return { prefix, history: '' };
79
+ return {
80
+ boundaryTag: firstRelease.title,
81
+ prefix,
82
+ history: content.slice(firstRelease.start),
83
+ };
84
+ }
85
+ function joinAtBoundary(left, right) {
86
+ if (!left)
87
+ return right;
88
+ if (!right)
89
+ return left;
90
+ return `${left.replace(/\n+$/, '')}\n\n${right.replace(/^\n+/, '')}`;
91
+ }
92
+ function generatedSections(content, sectionHeadings) {
93
+ const firstSection = sectionHeadings[0]?.start;
94
+ if (firstSection === undefined)
95
+ return '';
96
+ return content.slice(firstSection);
97
+ }
98
+ export function mergeChangelog(existing, generated) {
99
+ validateTitle(generated);
100
+ const generatedHeadings = headings(generated);
101
+ validateSections(generatedHeadings);
102
+ const parsed = parseChangelog(generated);
103
+ const generatedPrefix = generated.slice(0, generatedHeadings[0]?.start ?? generated.length);
104
+ const prefix = existing?.prefix ?? generatedPrefix;
105
+ let freshSections = generatedSections(generated, generatedHeadings);
106
+ if (existing?.boundaryTag) {
107
+ const duplicate = generatedHeadings.findIndex((heading) => heading.title === existing.boundaryTag);
108
+ if (duplicate >= 0) {
109
+ const firstSection = generatedHeadings[0]?.start;
110
+ freshSections =
111
+ firstSection === undefined ||
112
+ generatedHeadings[duplicate].start <= firstSection
113
+ ? ''
114
+ : generated.slice(firstSection, generatedHeadings[duplicate].start);
115
+ }
116
+ }
117
+ const history = joinAtBoundary(freshSections, existing?.history ?? '');
118
+ return joinAtBoundary(prefix, history) || parsed.prefix;
119
+ }
@@ -0,0 +1,5 @@
1
+ export type FilePlan = {
2
+ path: string;
3
+ content: string;
4
+ };
5
+ export declare function apply(files: FilePlan[]): Promise<void>;