@chamoji/core 0.0.0-stage → 0.1.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,14 @@
1
+ import type { ChamojiConfig, Changelog } 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
+ export declare function buildChangelog(options: BuildChangelogOptions): Promise<Changelog>;
@@ -0,0 +1,64 @@
1
+ import semver from 'semver';
2
+ import { assertGitRepo, assertRevision, getRemoteWebUrl, listReleaseTags, readCommits, } from './git.js';
3
+ import { groupCommits } from './group.js';
4
+ import { parseCommit } from './parse.js';
5
+ import { bumpFor, nextVersion } from './version.js';
6
+ /** The tag a `--release` version will get, following the latest tag's `v` style. */
7
+ function futureTag(release, latestTag) {
8
+ if (release.startsWith('v')) {
9
+ return release;
10
+ }
11
+ return latestTag === null || latestTag.startsWith('v')
12
+ ? `v${release}`
13
+ : release;
14
+ }
15
+ export async function buildChangelog(options) {
16
+ const { cwd, config, to = 'HEAD', from, release } = options;
17
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
18
+ if (release !== undefined && semver.valid(release) === null) {
19
+ throw new Error(`Invalid release version: ${release}`);
20
+ }
21
+ await assertGitRepo(cwd);
22
+ await assertRevision(cwd, to);
23
+ const tags = await listReleaseTags(cwd, to);
24
+ if (from && !tags.some(tag => tag.name === from)) {
25
+ throw new Error(`Unknown release tag for --from: ${from}`);
26
+ }
27
+ const makeRelease = (fields, raw, isUnreleased) => {
28
+ const commits = raw.map(parseCommit);
29
+ const bump = bumpFor(commits);
30
+ return {
31
+ ...fields,
32
+ bump,
33
+ nextVersion: isUnreleased ? nextVersion(fields.previousTag, bump) : null,
34
+ groups: groupCommits(commits, config.groups),
35
+ };
36
+ };
37
+ const releases = [];
38
+ const latest = tags[0]?.name ?? null;
39
+ const unreleased = await readCommits(cwd, latest ? `${latest}..${to}` : to);
40
+ if (unreleased.length > 0) {
41
+ releases.push(makeRelease({
42
+ version: release ?? 'Unreleased',
43
+ tag: release ? futureTag(release, latest) : null,
44
+ previousTag: latest,
45
+ date: release ? today : null,
46
+ }, unreleased, true));
47
+ }
48
+ for (const [index, tag] of tags.entries()) {
49
+ if (tag.name === from) {
50
+ break;
51
+ }
52
+ const previousTag = tags[index + 1]?.name ?? null;
53
+ const raw = await readCommits(cwd, previousTag ? `${previousTag}..${tag.name}` : tag.name);
54
+ releases.push(makeRelease({
55
+ version: tag.name,
56
+ tag: tag.name,
57
+ previousTag,
58
+ date: tag.date.slice(0, 10) || null,
59
+ }, raw, false));
60
+ }
61
+ const repositoryUrl = config.repositoryUrl ?? (await getRemoteWebUrl(cwd));
62
+ return { ...(repositoryUrl ? { repositoryUrl } : {}), releases };
63
+ }
64
+ //# 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,179 @@
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
+ 'heavy_plus_sign',
20
+ 'loud_sound',
21
+ 'globe_with_meridians',
22
+ 'camera_flash',
23
+ 'seedling',
24
+ 'test_tube',
25
+ 'necktie',
26
+ 'triangular_flag_on_post',
27
+ 'egg',
28
+ 'money_with_wings',
29
+ 'thread',
30
+ 'safety_vest',
31
+ 'airplane',
32
+ 'stethoscope',
33
+ ],
34
+ },
35
+ {
36
+ group: 'changed',
37
+ label: 'Changed',
38
+ emojis: [
39
+ 'art',
40
+ 'zap',
41
+ 'lipstick',
42
+ 'rotating_light',
43
+ 'arrow_down',
44
+ 'arrow_up',
45
+ 'pushpin',
46
+ 'recycle',
47
+ 'wrench',
48
+ 'rewind',
49
+ 'alien',
50
+ 'truck',
51
+ 'bento',
52
+ 'wheelchair',
53
+ 'speech_balloon',
54
+ 'card_file_box',
55
+ 'children_crossing',
56
+ 'building_construction',
57
+ 'iphone',
58
+ 'hammer',
59
+ 'package',
60
+ 'label',
61
+ 'mag',
62
+ 'dizzy',
63
+ 'goal_net',
64
+ 'bricks',
65
+ 'technologist',
66
+ 'page_facing_up',
67
+ 'poop',
68
+ ],
69
+ },
70
+ { group: 'breaking_changes', label: 'Breaking changes', emojis: ['boom'] },
71
+ { group: 'deprecated', label: 'Deprecated', emojis: ['wastebasket'] },
72
+ {
73
+ group: 'removed',
74
+ label: 'Removed',
75
+ emojis: ['fire', 'heavy_minus_sign', 'mute', 'coffin'],
76
+ },
77
+ {
78
+ group: 'fixed',
79
+ label: 'Fixed',
80
+ emojis: [
81
+ 'bug',
82
+ 'ambulance',
83
+ 'green_heart',
84
+ 'pencil2',
85
+ 'adhesive_bandage',
86
+ 'apple',
87
+ 'penguin',
88
+ 'checkered_flag',
89
+ 'robot',
90
+ 'green_apple',
91
+ ],
92
+ },
93
+ {
94
+ group: 'security',
95
+ label: 'Security',
96
+ emojis: ['lock', 'closed_lock_with_key', 'passport_control'],
97
+ },
98
+ {
99
+ group: 'useless',
100
+ label: 'Useless',
101
+ emojis: ['bookmark', 'twisted_rightwards_arrows'],
102
+ hidden: true,
103
+ },
104
+ {
105
+ group: 'misc',
106
+ label: 'Miscellaneous',
107
+ emojis: [
108
+ 'memo',
109
+ 'bulb',
110
+ 'rocket',
111
+ 'construction',
112
+ 'beers',
113
+ 'busts_in_silhouette',
114
+ 'clown_face',
115
+ 'see_no_evil',
116
+ 'alembic',
117
+ 'monocle_face',
118
+ ],
119
+ },
120
+ ];
121
+ /**
122
+ * Loads `explicitPath`, or `chamoji.config.json` in `cwd` when present.
123
+ * A JSON array is treated as a legacy group config.
124
+ */
125
+ export async function loadConfig(cwd, explicitPath) {
126
+ const file = path.resolve(cwd, explicitPath ?? CONFIG_FILE);
127
+ let text;
128
+ try {
129
+ text = await readFile(file, 'utf8');
130
+ }
131
+ catch (error) {
132
+ if (error.code !== 'ENOENT') {
133
+ throw error;
134
+ }
135
+ if (explicitPath) {
136
+ throw new Error(`Config file not found: ${file}`);
137
+ }
138
+ return { groups: DEFAULT_GROUP_CONFIG };
139
+ }
140
+ let data;
141
+ try {
142
+ data = JSON.parse(text);
143
+ }
144
+ catch (error) {
145
+ throw new Error(`Invalid config ${file}: ${error.message}`);
146
+ }
147
+ if (Array.isArray(data)) {
148
+ return { groups: validateGroups(data, file) };
149
+ }
150
+ if (!data || typeof data !== 'object') {
151
+ throw new Error(`Invalid config ${file}: expected an object or an array`);
152
+ }
153
+ const { groups, repositoryUrl } = data;
154
+ if (repositoryUrl !== undefined && typeof repositoryUrl !== 'string') {
155
+ throw new Error(`Invalid config ${file}: repositoryUrl must be a string`);
156
+ }
157
+ return {
158
+ groups: groups === undefined
159
+ ? DEFAULT_GROUP_CONFIG
160
+ : validateGroups(groups, file),
161
+ ...(repositoryUrl ? { repositoryUrl } : {}),
162
+ };
163
+ }
164
+ function validateGroups(value, file) {
165
+ const valid = Array.isArray(value) &&
166
+ value.every(item => item &&
167
+ typeof item.group === 'string' &&
168
+ typeof item.label === 'string' &&
169
+ Array.isArray(item.emojis) &&
170
+ item.emojis.every((code) => typeof code === 'string'));
171
+ if (!valid) {
172
+ throw new Error(`Invalid config ${file}: groups must be [{group, label, emojis: string[]}]`);
173
+ }
174
+ return value.map(item => ({
175
+ ...item,
176
+ emojis: item.emojis.map(code => code.replaceAll(':', '')),
177
+ }));
178
+ }
179
+ //# 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,32 @@
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
+ commits: buckets.get(item.group),
30
+ }));
31
+ }
32
+ //# 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,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,85 @@
1
+ import { findGitmoji, normalizeEmoji } from './gitmojis.js';
2
+ /** Conventional Commits types we recognize, and the gitmoji each implies. */
3
+ const TYPE_TO_GITMOJI = {
4
+ feat: 'sparkles',
5
+ fix: 'bug',
6
+ docs: 'memo',
7
+ style: 'art',
8
+ refactor: 'recycle',
9
+ perf: 'zap',
10
+ test: 'white_check_mark',
11
+ build: 'package',
12
+ ci: 'construction_worker',
13
+ chore: 'wrench',
14
+ revert: 'rewind',
15
+ };
16
+ const CONVENTIONAL_PREFIX = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*/;
17
+ const GITMOJI_CLI_SCOPE = /^\(([^)]+)\):\s*/;
18
+ const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
19
+ const GIT_REVERT = /^Revert "(.+)"$/;
20
+ export function parseCommit(raw) {
21
+ const revert = GIT_REVERT.exec(raw.subject.trim());
22
+ if (revert) {
23
+ // `git revert` quotes the original subject; classify by the revert, not
24
+ // by the reverted commit's gitmoji.
25
+ const original = parseCommit({ ...raw, subject: revert[1], body: '' });
26
+ return {
27
+ ...original,
28
+ body: raw.body,
29
+ gitmoji: 'rewind',
30
+ type: null,
31
+ description: `Revert "${original.description}"`,
32
+ breaking: BREAKING_FOOTER.test(raw.body),
33
+ };
34
+ }
35
+ let text = normalizeEmoji(raw.subject).trim();
36
+ let type = null;
37
+ let scope = null;
38
+ let bang = false;
39
+ const prefix = CONVENTIONAL_PREFIX.exec(text);
40
+ // Known types always count; any other word counts only with a `!`, so
41
+ // prose like "Note: …" is left alone.
42
+ if (prefix && (prefix[1].toLowerCase() in TYPE_TO_GITMOJI || prefix[3])) {
43
+ type = prefix[1].toLowerCase();
44
+ scope = prefix[2] ?? null;
45
+ bang = Boolean(prefix[3]);
46
+ text = text.slice(prefix[0].length);
47
+ }
48
+ let gitmoji = null;
49
+ const found = findGitmoji(text);
50
+ if (found) {
51
+ gitmoji = found.code;
52
+ text = (text.slice(0, found.index) + text.slice(found.index + found.length))
53
+ .replaceAll(/\s{2,}/g, ' ')
54
+ .trim();
55
+ }
56
+ const cliScope = scope ? null : GITMOJI_CLI_SCOPE.exec(text);
57
+ if (cliScope) {
58
+ scope = cliScope[1];
59
+ text = text.slice(cliScope[0].length);
60
+ }
61
+ if (!gitmoji && type) {
62
+ gitmoji = TYPE_TO_GITMOJI[type] ?? null;
63
+ }
64
+ const { trailers: rawTrailers, ...rest } = raw;
65
+ return {
66
+ ...rest,
67
+ gitmoji,
68
+ type,
69
+ scope,
70
+ description: text.trim(),
71
+ breaking: gitmoji === 'boom' || bang || BREAKING_FOOTER.test(raw.body),
72
+ trailers: parseTrailers(rawTrailers),
73
+ };
74
+ }
75
+ function parseTrailers(text) {
76
+ const trailers = [];
77
+ for (const line of text.split('\n')) {
78
+ const match = /^([\w-]+):\s*(.*)$/.exec(line.trim());
79
+ if (match) {
80
+ trailers.push({ key: match[1], value: match[2] });
81
+ }
82
+ }
83
+ return trailers;
84
+ }
85
+ //# 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,60 @@
1
+ import { commitLink, emojiFor, issueLink, releaseLink, safeRepositoryUrl, splitIssues, } 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
+ for (const commit of group.commits) {
38
+ out.push(`<li>${renderCommit(commit, url)}</li>`);
39
+ }
40
+ out.push('</ul>');
41
+ }
42
+ out.push('</section>');
43
+ }
44
+ out.push('</body>', '</html>', '');
45
+ return out.join('\n');
46
+ }
47
+ function renderCommit(commit, url) {
48
+ const emoji = emojiFor(commit);
49
+ const scope = commit.scope
50
+ ? `<strong>${escapeHtml(commit.scope)}:</strong> `
51
+ : '';
52
+ const description = splitIssues(commit.description)
53
+ .map(part => part.issue && url
54
+ ? anchor(issueLink(part.issue, url), escapeHtml(part.text))
55
+ : escapeHtml(part.text))
56
+ .join('');
57
+ const hash = `<code>${escapeHtml(commit.shortHash)}</code>`;
58
+ return `${emoji ? `${escapeHtml(emoji)} ` : ''}${scope}${description} (${url ? anchor(commitLink(commit, url), hash) : hash})`;
59
+ }
60
+ //# 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,55 @@
1
+ import { commitLink, emojiFor, issueLink, releaseLink, safeRepositoryUrl, splitIssues, } 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
+ for (const commit of group.commits) {
31
+ lines.push(`- ${renderCommit(commit, url)}`);
32
+ }
33
+ lines.push('');
34
+ }
35
+ }
36
+ return lines.join('\n').replace(/\n+$/, '\n');
37
+ }
38
+ function renderCommit(commit, url) {
39
+ const emoji = emojiFor(commit);
40
+ const scope = commit.scope ? `**${escapeMarkdown(commit.scope)}:** ` : '';
41
+ const description = splitCodeSpans(commit.description)
42
+ .map(span => span.code
43
+ ? span.text
44
+ : splitIssues(span.text)
45
+ .map(part => part.issue && url
46
+ ? `[${part.text}](${issueLink(part.issue, url)})`
47
+ : escapeMarkdown(part.text))
48
+ .join(''))
49
+ .join('');
50
+ const hash = url
51
+ ? `[${commit.shortHash}](${commitLink(commit, url)})`
52
+ : commit.shortHash;
53
+ return `${emoji ? `${emoji} ` : ''}${scope}${description} (${hash})`;
54
+ }
55
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,17 @@
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
+ /** The commit's gitmoji as a character, `:code:` for retired ones, or ''. */
9
+ export declare function emojiFor(commit: ParsedCommit): string;
10
+ /**
11
+ * Splits text into plain parts and `#123` issue references, so renderers can
12
+ * escape the plain parts and link the references.
13
+ */
14
+ export declare function splitIssues(text: string): Array<{
15
+ text: string;
16
+ issue?: string;
17
+ }>;
@@ -0,0 +1,39 @@
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
+ /** The commit's gitmoji as a character, `:code:` for retired ones, or ''. */
23
+ export function emojiFor(commit) {
24
+ if (!commit.gitmoji) {
25
+ return '';
26
+ }
27
+ return getGitmoji(commit.gitmoji)?.emoji ?? `:${commit.gitmoji}:`;
28
+ }
29
+ /**
30
+ * Splits text into plain parts and `#123` issue references, so renderers can
31
+ * escape the plain parts and link the references.
32
+ */
33
+ export function splitIssues(text) {
34
+ return text
35
+ .split(/(#\d+)\b/)
36
+ .filter(part => part.length > 0)
37
+ .map(part => /^#\d+$/.test(part) ? { text: part, issue: part.slice(1) } : { text: part });
38
+ }
39
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1,66 @@
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
+ export type ParsedCommit = Omit<RawCommit, 'trailers'> & {
20
+ /** Gitmoji code without colons, e.g. `sparkles`. */
21
+ gitmoji: string | null;
22
+ /** Conventional Commits type, e.g. `feat`. */
23
+ type: string | null;
24
+ scope: string | null;
25
+ /** Subject with the type, scope and gitmoji removed. */
26
+ description: string;
27
+ breaking: boolean;
28
+ trailers: Trailer[];
29
+ };
30
+ export type GroupConfigItem = {
31
+ group: string;
32
+ label: string;
33
+ /** Gitmoji codes without colons. */
34
+ emojis: string[];
35
+ /** Hidden groups are matched but left out of the output. */
36
+ hidden?: boolean;
37
+ };
38
+ export type GroupConfig = GroupConfigItem[];
39
+ export type ChamojiConfig = {
40
+ groups: GroupConfig;
41
+ repositoryUrl?: string;
42
+ };
43
+ export type Group = {
44
+ group: string;
45
+ label: string;
46
+ commits: ParsedCommit[];
47
+ };
48
+ export type Release = {
49
+ /** Tag name, the `--release` version, or `Unreleased`. */
50
+ version: string;
51
+ /**
52
+ * The release's tag. For a `--release` section, the tag it will get
53
+ * (e.g. `v1.2.0`); null for an unnamed Unreleased section.
54
+ */
55
+ tag: string | null;
56
+ previousTag: string | null;
57
+ /** `YYYY-MM-DD`, or null for an undated Unreleased section. */
58
+ date: string | null;
59
+ bump: SemverBump | null;
60
+ nextVersion: string | null;
61
+ groups: Group[];
62
+ };
63
+ export type Changelog = {
64
+ repositoryUrl?: string;
65
+ releases: Release[];
66
+ };
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.1.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
  }