@chamoji/core 0.0.0-stage → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chamoji contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @chamoji/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The engine behind [`@chamoji/cli`](https://www.npmjs.com/package/@chamoji/cli). It reads git history, parses gitmoji and Conventional Commits messages, groups commits into changelog sections, suggests the next semver version, and renders Markdown, HTML or JSON. It has no UI and makes no network calls. Needs Node.js 22+ and git.
4
+
5
+ ```js
6
+ import {buildChangelog, loadConfig, render} from '@chamoji/core';
7
+
8
+ const cwd = process.cwd();
9
+ const changelog = await buildChangelog({cwd, config: await loadConfig(cwd)});
10
+ console.log(render(changelog, 'markdown'));
11
+ ```
12
+
13
+ Lower-level pieces are exported too: `parseCommit`, `findGitmoji`, `groupCommits`, `bumpFor`, `nextVersion`, `readCommits`, `listReleaseTags`, `renderMarkdown`, `renderHtml` and `renderJson`.
14
+
15
+ MIT licensed.
@@ -0,0 +1,24 @@
1
+ import type { ChamojiConfig, Changelog, CommitClassifier } from './types.js';
2
+ export type BuildChangelogOptions = {
3
+ cwd: string;
4
+ config: ChamojiConfig;
5
+ /** Ref treated as the head of history. Defaults to `HEAD`. */
6
+ to?: string;
7
+ /** Stop before the release with this tag (it and older ones are left out). */
8
+ from?: string;
9
+ /** Version to give the Unreleased section, dated `today`. */
10
+ release?: string;
11
+ /** `YYYY-MM-DD`; defaults to the current UTC date. */
12
+ today?: string;
13
+ /**
14
+ * Optional AI step for commits no gitmoji, type or wording rule could
15
+ * sort. It never sees or overrides the others.
16
+ */
17
+ classify?: CommitClassifier;
18
+ /**
19
+ * Which commits `classify` sees: every release (default), or only the
20
+ * unreleased section, which is all `next-version` needs.
21
+ */
22
+ classifyReleases?: 'all' | 'unreleased';
23
+ };
24
+ export declare function buildChangelog(options: BuildChangelogOptions): Promise<Changelog>;
@@ -0,0 +1,97 @@
1
+ import semver from 'semver';
2
+ import { assertGitRepo, assertRevision, getRemoteWebUrl, listReleaseTags, readCommits, } from './git.js';
3
+ import { getGitmoji } from './gitmojis.js';
4
+ import { groupCommits } from './group.js';
5
+ import { parseCommit } from './parse.js';
6
+ import { bumpFor, nextVersion } from './version.js';
7
+ /** The tag a `--release` version will get, following the latest tag's `v` style. */
8
+ function futureTag(release, latestTag) {
9
+ if (release.startsWith('v')) {
10
+ return release;
11
+ }
12
+ return latestTag === null || latestTag.startsWith('v')
13
+ ? `v${release}`
14
+ : release;
15
+ }
16
+ export async function buildChangelog(options) {
17
+ const { cwd, config, to = 'HEAD', from, release } = options;
18
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
19
+ if (release !== undefined && semver.valid(release) === null) {
20
+ throw new Error(`Invalid release version: ${release}`);
21
+ }
22
+ await assertGitRepo(cwd);
23
+ await assertRevision(cwd, to);
24
+ const tags = await listReleaseTags(cwd, to);
25
+ if (from && !tags.some(tag => tag.name === from)) {
26
+ throw new Error(`Unknown release tag for --from: ${from}`);
27
+ }
28
+ const pending = [];
29
+ const latest = tags[0]?.name ?? null;
30
+ const unreleased = await readCommits(cwd, latest ? `${latest}..${to}` : to);
31
+ if (unreleased.length > 0) {
32
+ pending.push({
33
+ fields: {
34
+ version: release ?? 'Unreleased',
35
+ tag: release ? futureTag(release, latest) : null,
36
+ previousTag: latest,
37
+ date: release ? today : null,
38
+ },
39
+ commits: unreleased.map(parseCommit),
40
+ isUnreleased: true,
41
+ });
42
+ }
43
+ for (const [index, tag] of tags.entries()) {
44
+ if (tag.name === from) {
45
+ break;
46
+ }
47
+ const previousTag = tags[index + 1]?.name ?? null;
48
+ const raw = await readCommits(cwd, previousTag ? `${previousTag}..${tag.name}` : tag.name);
49
+ pending.push({
50
+ fields: {
51
+ version: tag.name,
52
+ tag: tag.name,
53
+ previousTag,
54
+ date: tag.date.slice(0, 10) || null,
55
+ },
56
+ commits: raw.map(parseCommit),
57
+ isUnreleased: false,
58
+ });
59
+ }
60
+ if (options.classify) {
61
+ const scope = options.classifyReleases === 'unreleased'
62
+ ? pending.filter(item => item.isUnreleased)
63
+ : pending;
64
+ await applyClassifier(options.classify, scope.flatMap(item => item.commits));
65
+ }
66
+ const releases = pending.map(({ fields, commits, isUnreleased }) => {
67
+ const bump = bumpFor(commits);
68
+ return {
69
+ ...fields,
70
+ bump,
71
+ nextVersion: isUnreleased ? nextVersion(fields.previousTag, bump) : null,
72
+ groups: groupCommits(commits, config.groups),
73
+ };
74
+ });
75
+ const repositoryUrl = config.repositoryUrl ?? (await getRemoteWebUrl(cwd));
76
+ return { ...(repositoryUrl ? { repositoryUrl } : {}), releases };
77
+ }
78
+ /**
79
+ * Runs the classifier on unsorted commits and applies its picks in place.
80
+ * Unknown codes are ignored, and so is `boom`: only authors declare breaking
81
+ * changes.
82
+ */
83
+ async function applyClassifier(classify, commits) {
84
+ const unsorted = commits.filter(commit => commit.gitmojiSource === null);
85
+ if (unsorted.length === 0) {
86
+ return;
87
+ }
88
+ const picks = await classify(unsorted);
89
+ for (const commit of unsorted) {
90
+ const code = picks.get(commit.hash);
91
+ if (code && code !== 'boom' && getGitmoji(code)) {
92
+ commit.gitmoji = code;
93
+ commit.gitmojiSource = 'ai';
94
+ }
95
+ }
96
+ }
97
+ //# sourceMappingURL=changelog.js.map
@@ -0,0 +1,13 @@
1
+ import type { ChamojiConfig, GroupConfig } from './types.js';
2
+ export declare const CONFIG_FILE = "chamoji.config.json";
3
+ /**
4
+ * Keep a Changelog style sections. Every official gitmoji appears exactly once
5
+ * (enforced by a test). The retired `apple`, `penguin`, `checkered_flag`,
6
+ * `robot` and `green_apple` codes stay under Fixed for older histories.
7
+ */
8
+ export declare const DEFAULT_GROUP_CONFIG: GroupConfig;
9
+ /**
10
+ * Loads `explicitPath`, or `chamoji.config.json` in `cwd` when present.
11
+ * A JSON array is treated as a legacy group config.
12
+ */
13
+ export declare function loadConfig(cwd: string, explicitPath?: string): Promise<ChamojiConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,187 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const CONFIG_FILE = 'chamoji.config.json';
4
+ /**
5
+ * Keep a Changelog style sections. Every official gitmoji appears exactly once
6
+ * (enforced by a test). The retired `apple`, `penguin`, `checkered_flag`,
7
+ * `robot` and `green_apple` codes stay under Fixed for older histories.
8
+ */
9
+ export const DEFAULT_GROUP_CONFIG = [
10
+ {
11
+ group: 'added',
12
+ label: 'Added',
13
+ emojis: [
14
+ 'sparkles',
15
+ 'tada',
16
+ 'white_check_mark',
17
+ 'construction_worker',
18
+ 'chart_with_upwards_trend',
19
+ 'loud_sound',
20
+ 'globe_with_meridians',
21
+ 'camera_flash',
22
+ 'seedling',
23
+ 'test_tube',
24
+ 'necktie',
25
+ 'triangular_flag_on_post',
26
+ 'egg',
27
+ 'money_with_wings',
28
+ 'thread',
29
+ 'safety_vest',
30
+ 'airplane',
31
+ 'stethoscope',
32
+ ],
33
+ },
34
+ {
35
+ group: 'changed',
36
+ label: 'Changed',
37
+ emojis: [
38
+ 'art',
39
+ 'zap',
40
+ 'lipstick',
41
+ 'rotating_light',
42
+ 'recycle',
43
+ 'wrench',
44
+ 'rewind',
45
+ 'alien',
46
+ 'truck',
47
+ 'bento',
48
+ 'wheelchair',
49
+ 'speech_balloon',
50
+ 'card_file_box',
51
+ 'children_crossing',
52
+ 'building_construction',
53
+ 'iphone',
54
+ 'hammer',
55
+ 'package',
56
+ 'label',
57
+ 'mag',
58
+ 'dizzy',
59
+ 'goal_net',
60
+ 'bricks',
61
+ 'technologist',
62
+ 'page_facing_up',
63
+ 'poop',
64
+ ],
65
+ },
66
+ { group: 'breaking_changes', label: 'Breaking changes', emojis: ['boom'] },
67
+ { group: 'deprecated', label: 'Deprecated', emojis: ['wastebasket'] },
68
+ {
69
+ group: 'removed',
70
+ label: 'Removed',
71
+ emojis: ['fire', 'mute', 'coffin'],
72
+ },
73
+ {
74
+ group: 'fixed',
75
+ label: 'Fixed',
76
+ emojis: [
77
+ 'bug',
78
+ 'ambulance',
79
+ 'green_heart',
80
+ 'pencil2',
81
+ 'adhesive_bandage',
82
+ 'apple',
83
+ 'penguin',
84
+ 'checkered_flag',
85
+ 'robot',
86
+ 'green_apple',
87
+ ],
88
+ },
89
+ {
90
+ group: 'security',
91
+ label: 'Security',
92
+ emojis: ['lock', 'closed_lock_with_key', 'passport_control'],
93
+ },
94
+ {
95
+ group: 'dependencies',
96
+ label: 'Dependencies',
97
+ emojis: [
98
+ 'arrow_up',
99
+ 'arrow_down',
100
+ 'pushpin',
101
+ 'heavy_plus_sign',
102
+ 'heavy_minus_sign',
103
+ ],
104
+ collapse: true,
105
+ },
106
+ {
107
+ group: 'useless',
108
+ label: 'Useless',
109
+ emojis: ['bookmark', 'twisted_rightwards_arrows'],
110
+ hidden: true,
111
+ },
112
+ {
113
+ group: 'misc',
114
+ label: 'Miscellaneous',
115
+ emojis: [
116
+ 'memo',
117
+ 'bulb',
118
+ 'rocket',
119
+ 'construction',
120
+ 'beers',
121
+ 'busts_in_silhouette',
122
+ 'clown_face',
123
+ 'see_no_evil',
124
+ 'alembic',
125
+ 'monocle_face',
126
+ ],
127
+ },
128
+ ];
129
+ /**
130
+ * Loads `explicitPath`, or `chamoji.config.json` in `cwd` when present.
131
+ * A JSON array is treated as a legacy group config.
132
+ */
133
+ export async function loadConfig(cwd, explicitPath) {
134
+ const file = path.resolve(cwd, explicitPath ?? CONFIG_FILE);
135
+ let text;
136
+ try {
137
+ text = await readFile(file, 'utf8');
138
+ }
139
+ catch (error) {
140
+ if (error.code !== 'ENOENT') {
141
+ throw error;
142
+ }
143
+ if (explicitPath) {
144
+ throw new Error(`Config file not found: ${file}`);
145
+ }
146
+ return { groups: DEFAULT_GROUP_CONFIG };
147
+ }
148
+ let data;
149
+ try {
150
+ data = JSON.parse(text);
151
+ }
152
+ catch (error) {
153
+ throw new Error(`Invalid config ${file}: ${error.message}`);
154
+ }
155
+ if (Array.isArray(data)) {
156
+ return { groups: validateGroups(data, file) };
157
+ }
158
+ if (!data || typeof data !== 'object') {
159
+ throw new Error(`Invalid config ${file}: expected an object or an array`);
160
+ }
161
+ const { groups, repositoryUrl } = data;
162
+ if (repositoryUrl !== undefined && typeof repositoryUrl !== 'string') {
163
+ throw new Error(`Invalid config ${file}: repositoryUrl must be a string`);
164
+ }
165
+ return {
166
+ groups: groups === undefined
167
+ ? DEFAULT_GROUP_CONFIG
168
+ : validateGroups(groups, file),
169
+ ...(repositoryUrl ? { repositoryUrl } : {}),
170
+ };
171
+ }
172
+ function validateGroups(value, file) {
173
+ const valid = Array.isArray(value) &&
174
+ value.every(item => item &&
175
+ typeof item.group === 'string' &&
176
+ typeof item.label === 'string' &&
177
+ Array.isArray(item.emojis) &&
178
+ item.emojis.every((code) => typeof code === 'string'));
179
+ if (!valid) {
180
+ throw new Error(`Invalid config ${file}: groups must be [{group, label, emojis: string[]}]`);
181
+ }
182
+ return value.map(item => ({
183
+ ...item,
184
+ emojis: item.emojis.map(code => code.replaceAll(':', '')),
185
+ }));
186
+ }
187
+ //# sourceMappingURL=config.js.map
package/dist/git.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { RawCommit } from './types.js';
2
+ export type ReleaseTag = {
3
+ name: string;
4
+ date: string;
5
+ };
6
+ /** Runs git without a shell and returns stdout. */
7
+ export declare function runGit(args: string[], cwd: string): Promise<string>;
8
+ export declare function assertGitRepo(cwd: string): Promise<void>;
9
+ export declare function assertRevision(cwd: string, ref: string): Promise<void>;
10
+ /** Non-merge commits in `range` (anything `git log` accepts), newest first. */
11
+ export declare function readCommits(cwd: string, range: string): Promise<RawCommit[]>;
12
+ /**
13
+ * Semver tags reachable from `to`, newest first. `date` is the tagged
14
+ * commit's committer date, so annotated and lightweight tags agree.
15
+ */
16
+ export declare function listReleaseTags(cwd: string, to: string): Promise<ReleaseTag[]>;
17
+ /** `origin` as an https web URL, or undefined when there is none. */
18
+ export declare function getRemoteWebUrl(cwd: string): Promise<string | undefined>;
19
+ /** Converts an ssh, scp-style or http(s) git remote to a browsable URL. */
20
+ export declare function toWebUrl(remote: string): string | undefined;
package/dist/git.js ADDED
@@ -0,0 +1,133 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import semver from 'semver';
4
+ const execFileAsync = promisify(execFile);
5
+ const FIELD = '\u001F';
6
+ const RECORD = '\u001E';
7
+ /** Runs git without a shell and returns stdout. */
8
+ export async function runGit(args, cwd) {
9
+ const { stdout } = await execFileAsync('git', args, {
10
+ cwd,
11
+ maxBuffer: 64 * 1024 * 1024,
12
+ });
13
+ return stdout;
14
+ }
15
+ export async function assertGitRepo(cwd) {
16
+ try {
17
+ await runGit(['rev-parse', '--is-inside-work-tree'], cwd);
18
+ }
19
+ catch (error) {
20
+ const { code, stderr } = error;
21
+ if (code === 'ENOENT') {
22
+ throw new Error('git is not installed or not on PATH');
23
+ }
24
+ if (stderr?.includes('not a git repository')) {
25
+ throw new Error(`Not a git repository: ${cwd}`);
26
+ }
27
+ throw new Error(stderr?.trim() || error.message);
28
+ }
29
+ }
30
+ export async function assertRevision(cwd, ref) {
31
+ try {
32
+ await runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
33
+ }
34
+ catch {
35
+ throw new Error(ref === 'HEAD'
36
+ ? 'Repository has no commits yet'
37
+ : `Unknown revision: ${ref}`);
38
+ }
39
+ }
40
+ /** Non-merge commits in `range` (anything `git log` accepts), newest first. */
41
+ export async function readCommits(cwd, range) {
42
+ const format = [
43
+ '%H',
44
+ '%h',
45
+ '%an',
46
+ '%ae',
47
+ '%aI',
48
+ '%s',
49
+ '%b',
50
+ '%(trailers:only,unfold)',
51
+ ].join('%x1f');
52
+ const stdout = await runGit([
53
+ 'log',
54
+ '--no-merges',
55
+ '--no-show-signature',
56
+ `--format=${format}%x1e`,
57
+ '--end-of-options',
58
+ range,
59
+ '--',
60
+ ], cwd);
61
+ return stdout
62
+ .split(RECORD)
63
+ .map(record => record.replace(/^\n/, ''))
64
+ .filter(record => record.length > 0)
65
+ .map(record => {
66
+ const [hash, shortHash, authorName, authorEmail, date, subject, body, trailers,] = record.split(FIELD);
67
+ return {
68
+ hash: hash,
69
+ shortHash: shortHash,
70
+ authorName: authorName,
71
+ authorEmail: authorEmail,
72
+ date: date,
73
+ subject: subject,
74
+ body: body,
75
+ trailers: trailers ?? '',
76
+ };
77
+ });
78
+ }
79
+ /**
80
+ * Semver tags reachable from `to`, newest first. `date` is the tagged
81
+ * commit's committer date, so annotated and lightweight tags agree.
82
+ */
83
+ export async function listReleaseTags(cwd, to) {
84
+ const stdout = await runGit([
85
+ 'tag',
86
+ `--merged=${to}`,
87
+ '--format=%(refname:strip=2)%1f%(committerdate:iso-strict)%1f%(*committerdate:iso-strict)',
88
+ ], cwd);
89
+ return stdout
90
+ .split('\n')
91
+ .filter(Boolean)
92
+ .map(line => {
93
+ const [name, direct, dereferenced] = line.split(FIELD);
94
+ return { name: name, date: dereferenced || direct || '' };
95
+ })
96
+ .filter(tag => semver.valid(tag.name) !== null)
97
+ .sort((a, b) => semver.rcompare(a.name, b.name));
98
+ }
99
+ /** `origin` as an https web URL, or undefined when there is none. */
100
+ export async function getRemoteWebUrl(cwd) {
101
+ try {
102
+ return toWebUrl((await runGit(['remote', 'get-url', 'origin'], cwd)).trim());
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
108
+ /** Converts an ssh, scp-style or http(s) git remote to a browsable URL. */
109
+ export function toWebUrl(remote) {
110
+ const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(remote);
111
+ if (scp && !/^[a-z][a-z\d+.-]*:\/\//i.test(remote)) {
112
+ return `https://${scp[1]}/${stripGitSuffix(scp[2])}`;
113
+ }
114
+ let url;
115
+ try {
116
+ url = new URL(remote);
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ if (!['https:', 'http:', 'ssh:', 'git:'].includes(url.protocol)) {
122
+ return undefined;
123
+ }
124
+ const protocol = url.protocol === 'http:' ? 'http' : 'https';
125
+ const host = url.protocol === 'https:' || url.protocol === 'http:'
126
+ ? url.host
127
+ : url.hostname;
128
+ return `${protocol}://${host}/${stripGitSuffix(url.pathname.replace(/^\/+/, ''))}`;
129
+ }
130
+ function stripGitSuffix(repoPath) {
131
+ return repoPath.replace(/\/+$/, '').replace(/\.git$/, '');
132
+ }
133
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,22 @@
1
+ import type { SemverBump } from './types.js';
2
+ export type GitmojiInfo = {
3
+ /** Code without colons, e.g. `white_check_mark`. */
4
+ code: string;
5
+ emoji: string;
6
+ description: string;
7
+ semver: SemverBump | null;
8
+ };
9
+ export type GitmojiMatch = {
10
+ code: string;
11
+ index: number;
12
+ length: number;
13
+ };
14
+ /** Removes U+FE0F so `⚡` and `⚡️` compare equal. */
15
+ export declare function normalizeEmoji(text: string): string;
16
+ export declare const GITMOJIS: readonly GitmojiInfo[];
17
+ export declare function getGitmoji(code: string): GitmojiInfo | undefined;
18
+ /**
19
+ * Finds the earliest gitmoji in already-normalized `text`, either as a known
20
+ * `:code:` or as its unicode character.
21
+ */
22
+ export declare function findGitmoji(text: string): GitmojiMatch | null;
@@ -0,0 +1,47 @@
1
+ import { gitmojis } from 'gitmojis';
2
+ /** Removes U+FE0F so `⚡` and `⚡️` compare equal. */
3
+ export function normalizeEmoji(text) {
4
+ return text.replaceAll('️', '');
5
+ }
6
+ export const GITMOJIS = gitmojis.map(gitmoji => ({
7
+ code: gitmoji.code.slice(1, -1),
8
+ emoji: gitmoji.emoji,
9
+ description: gitmoji.description,
10
+ semver: gitmoji.semver,
11
+ }));
12
+ const byCode = new Map(GITMOJIS.map(gitmoji => [gitmoji.code, gitmoji]));
13
+ const byEmoji = GITMOJIS.map(gitmoji => ({
14
+ code: gitmoji.code,
15
+ emoji: normalizeEmoji(gitmoji.emoji),
16
+ }));
17
+ export function getGitmoji(code) {
18
+ return byCode.get(code);
19
+ }
20
+ /**
21
+ * Finds the earliest gitmoji in already-normalized `text`, either as a known
22
+ * `:code:` or as its unicode character.
23
+ */
24
+ export function findGitmoji(text) {
25
+ let best = null;
26
+ const consider = (match) => {
27
+ if (!best ||
28
+ match.index < best.index ||
29
+ (match.index === best.index && match.length > best.length)) {
30
+ best = match;
31
+ }
32
+ };
33
+ for (const match of text.matchAll(/:([a-z\d_+-]+):/g)) {
34
+ if (byCode.has(match[1])) {
35
+ consider({ code: match[1], index: match.index, length: match[0].length });
36
+ break;
37
+ }
38
+ }
39
+ for (const { code, emoji } of byEmoji) {
40
+ const index = text.indexOf(emoji);
41
+ if (index !== -1) {
42
+ consider({ code, index, length: emoji.length });
43
+ }
44
+ }
45
+ return best;
46
+ }
47
+ //# sourceMappingURL=gitmojis.js.map
@@ -0,0 +1,7 @@
1
+ import type { Group, GroupConfig, ParsedCommit } from './types.js';
2
+ /**
3
+ * Sorts commits into the configured sections. Breaking commits go to
4
+ * Breaking changes whatever their gitmoji; unmatched ones go to misc.
5
+ * Returns groups in config order, without empty or hidden ones.
6
+ */
7
+ export declare function groupCommits(commits: ParsedCommit[], config: GroupConfig): Group[];
package/dist/group.js ADDED
@@ -0,0 +1,33 @@
1
+ const BREAKING_GROUP = 'breaking_changes';
2
+ const FALLBACK_GROUP = 'misc';
3
+ /**
4
+ * Sorts commits into the configured sections. Breaking commits go to
5
+ * Breaking changes whatever their gitmoji; unmatched ones go to misc.
6
+ * Returns groups in config order, without empty or hidden ones.
7
+ */
8
+ export function groupCommits(commits, config) {
9
+ const buckets = new Map(config.map(item => [item.group, []]));
10
+ const groupByCode = new Map();
11
+ for (const item of config) {
12
+ for (const code of item.emojis) {
13
+ if (!groupByCode.has(code)) {
14
+ groupByCode.set(code, item.group);
15
+ }
16
+ }
17
+ }
18
+ for (const commit of commits) {
19
+ const target = commit.breaking && buckets.has(BREAKING_GROUP)
20
+ ? BREAKING_GROUP
21
+ : (commit.gitmoji && groupByCode.get(commit.gitmoji)) || FALLBACK_GROUP;
22
+ buckets.get(target)?.push(commit);
23
+ }
24
+ return config
25
+ .filter(item => !item.hidden && buckets.get(item.group).length > 0)
26
+ .map(item => ({
27
+ group: item.group,
28
+ label: item.label,
29
+ ...(item.collapse ? { collapse: true } : {}),
30
+ commits: buckets.get(item.group),
31
+ }));
32
+ }
33
+ //# sourceMappingURL=group.js.map
@@ -0,0 +1,9 @@
1
+ export { buildChangelog, type BuildChangelogOptions } from './changelog.js';
2
+ export { CONFIG_FILE, DEFAULT_GROUP_CONFIG, loadConfig } from './config.js';
3
+ export { getRemoteWebUrl, listReleaseTags, readCommits, runGit, toWebUrl, type ReleaseTag, } from './git.js';
4
+ export { GITMOJIS, findGitmoji, getGitmoji, normalizeEmoji, type GitmojiInfo, type GitmojiMatch, } from './gitmojis.js';
5
+ export { groupCommits } from './group.js';
6
+ export { parseCommit } from './parse.js';
7
+ export { escapeHtml, render, renderHtml, renderJson, renderMarkdown, type OutputFormat, } from './render/index.js';
8
+ export type * from './types.js';
9
+ export { bumpFor, nextVersion } from './version.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { buildChangelog } from './changelog.js';
2
+ export { CONFIG_FILE, DEFAULT_GROUP_CONFIG, loadConfig } from './config.js';
3
+ export { getRemoteWebUrl, listReleaseTags, readCommits, runGit, toWebUrl, } from './git.js';
4
+ export { GITMOJIS, findGitmoji, getGitmoji, normalizeEmoji, } from './gitmojis.js';
5
+ export { groupCommits } from './group.js';
6
+ export { parseCommit } from './parse.js';
7
+ export { escapeHtml, render, renderHtml, renderJson, renderMarkdown, } from './render/index.js';
8
+ export { bumpFor, nextVersion } from './version.js';
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Guesses a gitmoji for a plain commit subject ("Add x", "Fix y") so repos
3
+ * that use neither gitmoji nor Conventional Commits still get sections.
4
+ * Rules are checked in order; the first match wins. Verbs are matched as
5
+ * whole word forms ("fix", "fixes", "fixed", "fixing"), so "Fixture" or
6
+ * "Dropdown" don't count, and topic words only count near the start.
7
+ */
8
+ export declare function inferGitmoji(subject: string): string | null;
package/dist/infer.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Guesses a gitmoji for a plain commit subject ("Add x", "Fix y") so repos
3
+ * that use neither gitmoji nor Conventional Commits still get sections.
4
+ * Rules are checked in order; the first match wins. Verbs are matched as
5
+ * whole word forms ("fix", "fixes", "fixed", "fixing"), so "Fixture" or
6
+ * "Dropdown" don't count, and topic words only count near the start.
7
+ */
8
+ /** A version-like token: `1.2.3`, `v4`, `^7.0`. */
9
+ const VERSION = String.raw `[~^]?v?\d[\w.-]*`;
10
+ const RULES = [
11
+ // Version-only and release commits (hidden by default).
12
+ [/^v?\d+\.\d+\.\d+\S*$/, 'bookmark'],
13
+ [
14
+ /^(?:release|releasing|prepare release(?: for)?|bump version(?: to)?)\s+v?\d|^(?:release|bump version)$/i,
15
+ 'bookmark',
16
+ ],
17
+ [/^(?:initial commit|init)\b/i, 'tada'],
18
+ [/^\[breaking\]/i, 'boom'],
19
+ [/^(?:require|drop support for)\s+node(?:\.js)?\s*\d+/i, 'boom'],
20
+ // Dependency updates: Dependabot, Renovate and hand-written ones.
21
+ [/^pin\s+(?:dev-?)?dependenc(?:y|ies)\b/i, 'pushpin'],
22
+ [
23
+ /^(?:bump|update|upgrade)\s+(?:all\s+)?(?:non-major\s+|minor\s+|patch\s+)?(?:dev[- ]?)?(?:dependencies|dependency|deps)\b/i,
24
+ 'arrow_up',
25
+ ],
26
+ [/^lock\s?file maintenance\b/i, 'arrow_up'],
27
+ [/^bump\s+the\s+\S+\s+group\b/i, 'arrow_up'],
28
+ [/^update\s+\S+\s+(?:action|monorepo|docker tag|digest)\b/i, 'arrow_up'],
29
+ [
30
+ new RegExp(String.raw `^(?:bump|update|upgrade|pin)\s+\S+\s+from\s+${VERSION}\s+to\s+${VERSION}`, 'i'),
31
+ 'arrow_up',
32
+ ],
33
+ [/^bump\s+`?[@\w./-]+`?\s*$/i, 'arrow_up'],
34
+ [
35
+ new RegExp(String.raw `^upgrad(?:e|es|ed|ing)\b.*\s${VERSION}`, 'i'),
36
+ 'arrow_up',
37
+ ],
38
+ // Topics, only when they are what the subject is about.
39
+ [/^(?:\w+\s+){0,2}(?:the\s+)?(?:readme|docs?|documentation)\b/i, 'memo'],
40
+ [/^document(?:s|ed|ing)?\b/i, 'memo'],
41
+ [/^(?:fix(?:es|ed)?\s+(?:an?\s+|the\s+)?typos?|typos?)\b/i, 'pencil2'],
42
+ [/^fix(?:es|ed|ing)?\s+(?:the\s+)?ci\b/i, 'green_heart'],
43
+ [
44
+ /^(?:add|adds|added|improve|update|fix)\s+(?:more\s+)?tests?\b|^tests?\b/i,
45
+ 'white_check_mark',
46
+ ],
47
+ [
48
+ /^fix(?:es|ed|ing)?\b.*\b(?:security|vulnerabilit(?:y|ies)|cve-\d+)/i,
49
+ 'lock',
50
+ ],
51
+ [/^(?:security|cve-\d+)\b/i, 'lock'],
52
+ [
53
+ /^(?:improve|boost|increase)\s+(?:\w+\s+)?(?:performance|perf|speed)\b|^(?:optimi[sz]e[sd]?|speed\s+up)\b|^make\b.*\bfaster\b/i,
54
+ 'zap',
55
+ ],
56
+ [/^(?:minor\s+)?(?:code\s+style|minor\s+code|style)\s+tweaks?\b/i, 'art'],
57
+ [/^add(?:s|ed)?\s+missing\b/i, 'bug'],
58
+ // The leading verb.
59
+ [
60
+ /^(?:add(?:s|ed|ing)?|implement(?:s|ed|ing)?|introduc(?:e|es|ed|ing)|creat(?:e|es|ed|ing)|support(?:s|ed|ing)?|export(?:s|ed|ing)?|allow(?:s|ed|ing)?|enabl(?:e|es|ed|ing))\b/i,
61
+ 'sparkles',
62
+ ],
63
+ [
64
+ /^(?:fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|correct(?:s|ed|ing)?|repair(?:s|ed|ing)?|hotfix(?:es|ed)?)\b/i,
65
+ 'bug',
66
+ ],
67
+ [
68
+ /^(?:remov(?:e|es|ed|ing)|delet(?:e|es|ed|ing)|drop(?:s|ped|ping)?)\b/i,
69
+ 'fire',
70
+ ],
71
+ [/^deprecat(?:e|es|ed|ing)\b/i, 'wastebasket'],
72
+ [/^(?:renam(?:e|es|ed|ing)|mov(?:e|es|ed|ing))\b/i, 'truck'],
73
+ [
74
+ /^(?:refactor(?:s|ed|ing)?|clean(?:s|ed|ing)?|cleanup|simplif(?:y|ies|ied|ying)|restructur(?:e|es|ed|ing)|reorganiz(?:e|es|ed|ing))\b/i,
75
+ 'recycle',
76
+ ],
77
+ [/^downgrad(?:e|es|ed|ing)\b/i, 'arrow_down'],
78
+ [
79
+ /^(?:updat(?:e|es|ed|ing)|upgrad(?:e|es|ed|ing)|improv(?:e|es|ed|ing)|chang(?:e|es|ed|ing)|tweak(?:s|ed|ing)?|meta|use|replac(?:e|es|ed|ing)|rewrit(?:e|es|ing|ten)|migrat(?:e|es|ed|ing)|make|ensure|switch(?:es|ed|ing)?)\b/i,
80
+ 'wrench',
81
+ ],
82
+ ];
83
+ /** Prefixes that say nothing about the change, like "Close #14 PR: ". */
84
+ const NOISE_PREFIX = /^(?:close[sd]?\s+#\d+\s+pr:\s*)/i;
85
+ export function inferGitmoji(subject) {
86
+ const text = subject.trim().replace(NOISE_PREFIX, '');
87
+ for (const [pattern, code] of RULES) {
88
+ if (pattern.test(text)) {
89
+ return code;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ //# sourceMappingURL=infer.js.map
@@ -0,0 +1,2 @@
1
+ import type { ParsedCommit, RawCommit } from './types.js';
2
+ export declare function parseCommit(raw: RawCommit): ParsedCommit;
package/dist/parse.js ADDED
@@ -0,0 +1,104 @@
1
+ import { findGitmoji, normalizeEmoji } from './gitmojis.js';
2
+ import { inferGitmoji } from './infer.js';
3
+ /** Conventional Commits types we recognize, and the gitmoji each implies. */
4
+ const TYPE_TO_GITMOJI = {
5
+ feat: 'sparkles',
6
+ fix: 'bug',
7
+ docs: 'memo',
8
+ style: 'art',
9
+ refactor: 'recycle',
10
+ perf: 'zap',
11
+ test: 'white_check_mark',
12
+ build: 'package',
13
+ ci: 'construction_worker',
14
+ chore: 'wrench',
15
+ revert: 'rewind',
16
+ release: 'bookmark',
17
+ wip: 'construction',
18
+ deps: 'arrow_up',
19
+ security: 'lock',
20
+ };
21
+ /** Conventional scopes that mean "dependency update" under any type. */
22
+ const DEPENDENCY_SCOPES = new Set(['deps', 'deps-dev', 'dependencies']);
23
+ const CONVENTIONAL_PREFIX = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*/;
24
+ const GITMOJI_CLI_SCOPE = /^\(([^)]+)\):\s*/;
25
+ const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
26
+ const GIT_REVERT = /^Revert "(.+)"$/;
27
+ export function parseCommit(raw) {
28
+ const revert = GIT_REVERT.exec(raw.subject.trim());
29
+ if (revert) {
30
+ // `git revert` quotes the original subject; classify by the revert, not
31
+ // by the reverted commit's gitmoji.
32
+ const original = parseCommit({ ...raw, subject: revert[1], body: '' });
33
+ return {
34
+ ...original,
35
+ body: raw.body,
36
+ gitmoji: 'rewind',
37
+ gitmojiSource: 'rule',
38
+ type: null,
39
+ description: `Revert "${original.description}"`,
40
+ breaking: BREAKING_FOOTER.test(raw.body),
41
+ };
42
+ }
43
+ let text = normalizeEmoji(raw.subject).trim();
44
+ let type = null;
45
+ let scope = null;
46
+ let bang = false;
47
+ const prefix = CONVENTIONAL_PREFIX.exec(text);
48
+ // Known types always count; any other word counts only with a `!`, so
49
+ // prose like "Note: …" is left alone.
50
+ if (prefix && (prefix[1].toLowerCase() in TYPE_TO_GITMOJI || prefix[3])) {
51
+ type = prefix[1].toLowerCase();
52
+ scope = prefix[2] ?? null;
53
+ bang = Boolean(prefix[3]);
54
+ text = text.slice(prefix[0].length);
55
+ }
56
+ let gitmoji = null;
57
+ let gitmojiSource = null;
58
+ const found = findGitmoji(text);
59
+ if (found) {
60
+ gitmoji = found.code;
61
+ gitmojiSource = 'gitmoji';
62
+ text = (text.slice(0, found.index) + text.slice(found.index + found.length))
63
+ .replaceAll(/\s{2,}/g, ' ')
64
+ .trim();
65
+ }
66
+ const cliScope = scope ? null : GITMOJI_CLI_SCOPE.exec(text);
67
+ if (cliScope) {
68
+ scope = cliScope[1];
69
+ text = text.slice(cliScope[0].length);
70
+ }
71
+ if (!gitmoji && type) {
72
+ gitmoji =
73
+ scope && DEPENDENCY_SCOPES.has(scope.toLowerCase())
74
+ ? 'arrow_up'
75
+ : (TYPE_TO_GITMOJI[type] ?? null);
76
+ gitmojiSource = gitmoji ? 'type' : null;
77
+ }
78
+ if (!gitmoji) {
79
+ gitmoji = inferGitmoji(text);
80
+ gitmojiSource = gitmoji ? 'rule' : null;
81
+ }
82
+ const { trailers: rawTrailers, ...rest } = raw;
83
+ return {
84
+ ...rest,
85
+ gitmoji,
86
+ gitmojiSource,
87
+ type,
88
+ scope,
89
+ description: text.trim(),
90
+ breaking: gitmoji === 'boom' || bang || BREAKING_FOOTER.test(raw.body),
91
+ trailers: parseTrailers(rawTrailers),
92
+ };
93
+ }
94
+ function parseTrailers(text) {
95
+ const trailers = [];
96
+ for (const line of text.split('\n')) {
97
+ const match = /^([\w-]+):\s*(.*)$/.exec(line.trim());
98
+ if (match) {
99
+ trailers.push({ key: match[1], value: match[2] });
100
+ }
101
+ }
102
+ return trailers;
103
+ }
104
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1,3 @@
1
+ import type { Changelog } from '../types.js';
2
+ export declare const escapeHtml: (text: string) => string;
3
+ export declare function renderHtml(changelog: Changelog): string;
@@ -0,0 +1,65 @@
1
+ import { commitLink, DEPENDENCY_EMOJI, emojiFor, issueLink, releaseLink, safeRepositoryUrl, splitIssues, summarizeDependencies, } from './shared.js';
2
+ const ESCAPES = {
3
+ '&': '&amp;',
4
+ '<': '&lt;',
5
+ '>': '&gt;',
6
+ '"': '&quot;',
7
+ "'": '&#x27;',
8
+ };
9
+ export const escapeHtml = (text) => text.replaceAll(/[&<>"']/g, char => ESCAPES[char]);
10
+ const anchor = (href, content) => `<a href="${escapeHtml(href)}">${content}</a>`;
11
+ const STYLE = `body{font-family:system-ui,sans-serif;line-height:1.6;max-width:48rem;margin:2rem auto;padding:0 1rem}
12
+ h2{margin-top:2rem;border-bottom:1px solid #ddd}time{color:#666;font-size:.9em;font-weight:normal}
13
+ code{font-size:.85em}`;
14
+ export function renderHtml(changelog) {
15
+ const url = safeRepositoryUrl(changelog);
16
+ const out = [
17
+ '<!doctype html>',
18
+ '<html lang="en">',
19
+ '<head>',
20
+ '<meta charset="utf-8">',
21
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
22
+ '<title>Changelog</title>',
23
+ `<style>${STYLE}</style>`,
24
+ '</head>',
25
+ '<body>',
26
+ '<h1>Changelog</h1>',
27
+ ];
28
+ for (const release of changelog.releases) {
29
+ const link = releaseLink(release, url);
30
+ const version = escapeHtml(release.version);
31
+ const date = release.date
32
+ ? ` <time datetime="${escapeHtml(release.date)}">${escapeHtml(release.date)}</time>`
33
+ : '';
34
+ out.push(`<section id="${version}">`, `<h2>${link ? anchor(link, version) : version}${date}</h2>`);
35
+ for (const group of release.groups) {
36
+ out.push(`<h3>${escapeHtml(group.label)}</h3>`, '<ul>');
37
+ if (group.collapse) {
38
+ out.push(`<li>${DEPENDENCY_EMOJI} ${escapeHtml(summarizeDependencies(group.commits))}</li>`);
39
+ }
40
+ else {
41
+ for (const commit of group.commits) {
42
+ out.push(`<li>${renderCommit(commit, url)}</li>`);
43
+ }
44
+ }
45
+ out.push('</ul>');
46
+ }
47
+ out.push('</section>');
48
+ }
49
+ out.push('</body>', '</html>', '');
50
+ return out.join('\n');
51
+ }
52
+ function renderCommit(commit, url) {
53
+ const emoji = emojiFor(commit);
54
+ const scope = commit.scope
55
+ ? `<strong>${escapeHtml(commit.scope)}:</strong> `
56
+ : '';
57
+ const description = splitIssues(commit.description)
58
+ .map(part => part.issue && url
59
+ ? anchor(issueLink(part.issue, url), escapeHtml(part.text))
60
+ : escapeHtml(part.text))
61
+ .join('');
62
+ const hash = `<code>${escapeHtml(commit.shortHash)}</code>`;
63
+ return `${emoji ? `${escapeHtml(emoji)} ` : ''}${scope}${description} (${url ? anchor(commitLink(commit, url), hash) : hash})`;
64
+ }
65
+ //# sourceMappingURL=html.js.map
@@ -0,0 +1,6 @@
1
+ import type { Changelog } from '../types.js';
2
+ export type OutputFormat = 'markdown' | 'json' | 'html';
3
+ export declare const renderJson: (changelog: Changelog) => string;
4
+ export declare function render(changelog: Changelog, format: OutputFormat): string;
5
+ export { escapeHtml, renderHtml } from './html.js';
6
+ export { renderMarkdown } from './markdown.js';
@@ -0,0 +1,19 @@
1
+ import { renderHtml } from './html.js';
2
+ import { renderMarkdown } from './markdown.js';
3
+ export const renderJson = (changelog) => `${JSON.stringify(changelog, null, 2)}\n`;
4
+ export function render(changelog, format) {
5
+ switch (format) {
6
+ case 'markdown': {
7
+ return renderMarkdown(changelog);
8
+ }
9
+ case 'html': {
10
+ return renderHtml(changelog);
11
+ }
12
+ case 'json': {
13
+ return renderJson(changelog);
14
+ }
15
+ }
16
+ }
17
+ export { escapeHtml, renderHtml } from './html.js';
18
+ export { renderMarkdown } from './markdown.js';
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,2 @@
1
+ import type { Changelog } from '../types.js';
2
+ export declare function renderMarkdown(changelog: Changelog): string;
@@ -0,0 +1,60 @@
1
+ import { commitLink, DEPENDENCY_EMOJI, emojiFor, issueLink, releaseLink, safeRepositoryUrl, splitIssues, summarizeDependencies, } from './shared.js';
2
+ /**
3
+ * Makes commit text inert outside code spans: no raw HTML, and no links or
4
+ * images, since squash-merged PR titles can come from anyone. Backticks are
5
+ * escaped too: any left here are unpaired, and an unpaired run could open a
6
+ * code span a Markdown parser sees differently from `splitCodeSpans`.
7
+ */
8
+ const escapeMarkdown = (text) => text
9
+ .replaceAll(/[\\[\]`]/g, char => `\\${char}`)
10
+ .replaceAll('<', '&lt;')
11
+ .replaceAll('>', '&gt;');
12
+ /**
13
+ * Splits text into single-backtick `code spans` and the parts between. A span
14
+ * holds no backticks, so emitting it as-is is safe once the surrounding text
15
+ * has its stray backticks escaped.
16
+ */
17
+ const splitCodeSpans = (text) => text
18
+ .split(/(`[^`\n]+`)/)
19
+ .filter(part => part.length > 0)
20
+ .map(part => ({ text: part, code: /^`[^`\n]+`$/.test(part) }));
21
+ export function renderMarkdown(changelog) {
22
+ const url = safeRepositoryUrl(changelog);
23
+ const lines = ['# Changelog', ''];
24
+ for (const release of changelog.releases) {
25
+ const link = releaseLink(release, url);
26
+ const title = link ? `[${release.version}](${link})` : release.version;
27
+ lines.push(`## ${title}${release.date ? ` - ${release.date}` : ''}`, '');
28
+ for (const group of release.groups) {
29
+ lines.push(`### ${group.label}`, '');
30
+ if (group.collapse) {
31
+ lines.push(`- ${DEPENDENCY_EMOJI} ${escapeMarkdown(summarizeDependencies(group.commits))}`);
32
+ }
33
+ else {
34
+ for (const commit of group.commits) {
35
+ lines.push(`- ${renderCommit(commit, url)}`);
36
+ }
37
+ }
38
+ lines.push('');
39
+ }
40
+ }
41
+ return lines.join('\n').replace(/\n+$/, '\n');
42
+ }
43
+ function renderCommit(commit, url) {
44
+ const emoji = emojiFor(commit);
45
+ const scope = commit.scope ? `**${escapeMarkdown(commit.scope)}:** ` : '';
46
+ const description = splitCodeSpans(commit.description)
47
+ .map(span => span.code
48
+ ? span.text
49
+ : splitIssues(span.text)
50
+ .map(part => part.issue && url
51
+ ? `[${part.text}](${issueLink(part.issue, url)})`
52
+ : escapeMarkdown(part.text))
53
+ .join(''))
54
+ .join('');
55
+ const hash = url
56
+ ? `[${commit.shortHash}](${commitLink(commit, url)})`
57
+ : commit.shortHash;
58
+ return `${emoji ? `${emoji} ` : ''}${scope}${description} (${hash})`;
59
+ }
60
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,29 @@
1
+ import type { Changelog, ParsedCommit, Release } from '../types.js';
2
+ /** The repository URL if it is plain http(s), without a trailing slash. */
3
+ export declare function safeRepositoryUrl(changelog: Changelog): string | undefined;
4
+ /** Where a release heading links to: a compare view, when one exists. */
5
+ export declare function releaseLink(release: Release, url: string | undefined): string | undefined;
6
+ export declare function commitLink(commit: ParsedCommit, url: string): string;
7
+ export declare function issueLink(issue: string, url: string): string;
8
+ /**
9
+ * The commit's gitmoji as a character, `:code:` for retired ones, or ''.
10
+ * Gitmojis that chamoji guessed (from wording or AI) are not shown, so plain
11
+ * histories aren't decorated with emojis their authors never wrote.
12
+ */
13
+ export declare function emojiFor(commit: ParsedCommit): string;
14
+ /**
15
+ * One-line summary of a collapsed group of dependency updates, e.g.
16
+ * `9 dependency updates: jest, vite, … and 2 more`. Names come from
17
+ * Dependabot/Renovate-style subjects; a Dependabot group PR counts as the
18
+ * number of updates it bundles.
19
+ */
20
+ export declare function summarizeDependencies(commits: ParsedCommit[]): string;
21
+ export declare const DEPENDENCY_EMOJI: string;
22
+ /**
23
+ * Splits text into plain parts and `#123` issue references, so renderers can
24
+ * escape the plain parts and link the references.
25
+ */
26
+ export declare function splitIssues(text: string): Array<{
27
+ text: string;
28
+ issue?: string;
29
+ }>;
@@ -0,0 +1,79 @@
1
+ import { getGitmoji } from '../gitmojis.js';
2
+ /** The repository URL if it is plain http(s), without a trailing slash. */
3
+ export function safeRepositoryUrl(changelog) {
4
+ const url = changelog.repositoryUrl;
5
+ return url && /^https?:\/\/[^\s"'<>]+$/.test(url)
6
+ ? url.replace(/\/+$/, '')
7
+ : undefined;
8
+ }
9
+ /** Where a release heading links to: a compare view, when one exists. */
10
+ export function releaseLink(release, url) {
11
+ if (!url || !release.previousTag) {
12
+ return undefined;
13
+ }
14
+ return `${url}/compare/${release.previousTag}...${release.tag ?? 'HEAD'}`;
15
+ }
16
+ export function commitLink(commit, url) {
17
+ return `${url}/commit/${commit.hash}`;
18
+ }
19
+ export function issueLink(issue, url) {
20
+ return `${url}/issues/${issue}`;
21
+ }
22
+ /**
23
+ * The commit's gitmoji as a character, `:code:` for retired ones, or ''.
24
+ * Gitmojis that chamoji guessed (from wording or AI) are not shown, so plain
25
+ * histories aren't decorated with emojis their authors never wrote.
26
+ */
27
+ export function emojiFor(commit) {
28
+ if (!commit.gitmoji ||
29
+ (commit.gitmojiSource !== 'gitmoji' && commit.gitmojiSource !== 'type')) {
30
+ return '';
31
+ }
32
+ return getGitmoji(commit.gitmoji)?.emoji ?? `:${commit.gitmoji}:`;
33
+ }
34
+ const DEPENDENCY_NAME = /^(?:bump|update|upgrade|downgrade|pin)\s+(?:the\s+)?(?:(?:dev[- ]?)?dependency\s+)?([@\w./-]+)/i;
35
+ const NOT_A_NAME = /^(?:dependencies|deps|dev|all|minor|patch|major)$/i;
36
+ /** Dependabot grouped updates: "Bump the npm group … with 5 updates". */
37
+ const DEPENDABOT_GROUP = /^bump\s+the\s+\S+\s+group\b.*?\bwith\s+(\d+)\s+updates?\b/i;
38
+ const SHOWN_NAMES = 5;
39
+ /**
40
+ * One-line summary of a collapsed group of dependency updates, e.g.
41
+ * `9 dependency updates: jest, vite, … and 2 more`. Names come from
42
+ * Dependabot/Renovate-style subjects; a Dependabot group PR counts as the
43
+ * number of updates it bundles.
44
+ */
45
+ export function summarizeDependencies(commits) {
46
+ const names = new Set();
47
+ let updates = 0;
48
+ for (const commit of commits) {
49
+ const group = DEPENDABOT_GROUP.exec(commit.description);
50
+ if (group) {
51
+ updates += Number(group[1]);
52
+ continue;
53
+ }
54
+ updates++;
55
+ const name = DEPENDENCY_NAME.exec(commit.description)?.[1];
56
+ if (name && !NOT_A_NAME.test(name)) {
57
+ names.add(name);
58
+ }
59
+ }
60
+ const count = `${updates} dependency update${updates === 1 ? '' : 's'}`;
61
+ if (names.size === 0) {
62
+ return count;
63
+ }
64
+ const shown = [...names].slice(0, SHOWN_NAMES);
65
+ const more = names.size - shown.length;
66
+ return `${count}: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
67
+ }
68
+ export const DEPENDENCY_EMOJI = getGitmoji('arrow_up')?.emoji ?? '⬆️';
69
+ /**
70
+ * Splits text into plain parts and `#123` issue references, so renderers can
71
+ * escape the plain parts and link the references.
72
+ */
73
+ export function splitIssues(text) {
74
+ return text
75
+ .split(/(#\d+)\b/)
76
+ .filter(part => part.length > 0)
77
+ .map(part => /^#\d+$/.test(part) ? { text: part, issue: part.slice(1) } : { text: part });
78
+ }
79
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1,80 @@
1
+ export type SemverBump = 'major' | 'minor' | 'patch';
2
+ /** One commit as read from `git log`, before any interpretation. */
3
+ export type RawCommit = {
4
+ hash: string;
5
+ shortHash: string;
6
+ authorName: string;
7
+ authorEmail: string;
8
+ /** ISO 8601 author date. */
9
+ date: string;
10
+ subject: string;
11
+ body: string;
12
+ /** Raw `%(trailers:only,unfold)` output: one `Key: value` per line. */
13
+ trailers: string;
14
+ };
15
+ export type Trailer = {
16
+ key: string;
17
+ value: string;
18
+ };
19
+ /**
20
+ * Where a commit's gitmoji came from: written in the message, implied by a
21
+ * Conventional type, guessed from the wording, or picked by an AI classifier.
22
+ */
23
+ export type GitmojiSource = 'gitmoji' | 'type' | 'rule' | 'ai' | null;
24
+ export type ParsedCommit = Omit<RawCommit, 'trailers'> & {
25
+ /** Gitmoji code without colons, e.g. `sparkles`. */
26
+ gitmoji: string | null;
27
+ gitmojiSource: GitmojiSource;
28
+ /** Conventional Commits type, e.g. `feat`. */
29
+ type: string | null;
30
+ scope: string | null;
31
+ /** Subject with the type, scope and gitmoji removed. */
32
+ description: string;
33
+ breaking: boolean;
34
+ trailers: Trailer[];
35
+ };
36
+ export type GroupConfigItem = {
37
+ group: string;
38
+ label: string;
39
+ /** Gitmoji codes without colons. */
40
+ emojis: string[];
41
+ /** Hidden groups are matched but left out of the output. */
42
+ hidden?: boolean;
43
+ /** Render the group as one summary line (used for dependency updates). */
44
+ collapse?: boolean;
45
+ };
46
+ export type GroupConfig = GroupConfigItem[];
47
+ export type ChamojiConfig = {
48
+ groups: GroupConfig;
49
+ repositoryUrl?: string;
50
+ };
51
+ export type Group = {
52
+ group: string;
53
+ label: string;
54
+ collapse?: boolean;
55
+ commits: ParsedCommit[];
56
+ };
57
+ export type Release = {
58
+ /** Tag name, the `--release` version, or `Unreleased`. */
59
+ version: string;
60
+ /**
61
+ * The release's tag. For a `--release` section, the tag it will get
62
+ * (e.g. `v1.2.0`); null for an unnamed Unreleased section.
63
+ */
64
+ tag: string | null;
65
+ previousTag: string | null;
66
+ /** `YYYY-MM-DD`, or null for an undated Unreleased section. */
67
+ date: string | null;
68
+ bump: SemverBump | null;
69
+ nextVersion: string | null;
70
+ groups: Group[];
71
+ };
72
+ export type Changelog = {
73
+ repositoryUrl?: string;
74
+ releases: Release[];
75
+ };
76
+ /**
77
+ * Picks gitmoji codes for commits chamoji could not sort. Returns a map from
78
+ * commit hash to code, for the commits it is confident about.
79
+ */
80
+ export type CommitClassifier = (commits: ParsedCommit[]) => Promise<Map<string, string>>;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,5 @@
1
+ import type { ParsedCommit, SemverBump } from './types.js';
2
+ /** The release bump implied by `commits`, from gitmoji semver data. */
3
+ export declare function bumpFor(commits: ParsedCommit[]): SemverBump | null;
4
+ /** `current` bumped by `bump`, without a `v` prefix. */
5
+ export declare function nextVersion(current: string | null, bump: SemverBump | null): string | null;
@@ -0,0 +1,22 @@
1
+ import semver from 'semver';
2
+ import { getGitmoji } from './gitmojis.js';
3
+ const RANK = { patch: 1, minor: 2, major: 3 };
4
+ /** The release bump implied by `commits`, from gitmoji semver data. */
5
+ export function bumpFor(commits) {
6
+ let bump = null;
7
+ for (const commit of commits) {
8
+ if (commit.breaking) {
9
+ return 'major';
10
+ }
11
+ const level = commit.gitmoji ? getGitmoji(commit.gitmoji)?.semver : null;
12
+ if (level && (!bump || RANK[level] > RANK[bump])) {
13
+ bump = level;
14
+ }
15
+ }
16
+ return bump;
17
+ }
18
+ /** `current` bumped by `bump`, without a `v` prefix. */
19
+ export function nextVersion(current, bump) {
20
+ return bump ? semver.inc(current ?? '0.0.0', bump) : null;
21
+ }
22
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@chamoji/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Changelog engine for gitmoji and Conventional Commits",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "!dist/**/*.map"
19
+ ],
20
+ "dependencies": {
21
+ "gitmojis": "^3.15.0",
22
+ "semver": "^7.7.3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/semver": "^7.7.1"
26
+ },
27
+ "keywords": [
28
+ "changelog",
29
+ "gitmoji",
30
+ "conventional-commits",
31
+ "semver",
32
+ "git"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p ."
39
+ }
6
40
  }