Skip to content

Commit 3d8baa4

Browse files
chore: setup commitizen with custom adapter
1 parent 0d2e3ed commit 3d8baa4

File tree

11 files changed

+808
-21
lines changed

11 files changed

+808
-21
lines changed

.czrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"path": "./cz-adapter"
3+
}

.eslintrc.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
],
2424
"parserOptions": {
2525
"ecmaVersion": 10,
26-
"project": ["./tsconfig.json", "./tests/tsconfig.json"],
26+
"project": ["./tsconfig.json", "./tests/tsconfig.json", "./cz-adapter/tsconfig.json"],
2727
"sourceType": "module"
2828
},
29-
"ignorePatterns": ["build/", "coverage/", "lib/"],
29+
"ignorePatterns": ["build/", "coverage/", "lib/", "**/*.js"],
3030
"rules": {
3131
"@typescript-eslint/no-unnecessary-condition": "off",
3232
"import/no-relative-parent-imports": "error",

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"files.exclude": {
33
".nyc_output": true,
44
"coverage": true,
5+
"cz-adapter/index.js": true,
56
},
67
"files.trimTrailingWhitespace": true,
78
"search.exclude": {

cz-adapter/.eslintrc.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"rules": {
3+
"functional/no-let": ["error", {
4+
"ignorePattern": "^mutable.+"
5+
}],
6+
"functional/no-return-void": "off",
7+
"import/no-extraneous-dependencies": [
8+
"error",
9+
{
10+
"devDependencies": true,
11+
"peerDependencies": true
12+
}
13+
],
14+
"node/no-unsupported-features/node-builtins": "off",
15+
"unicorn/prefer-module": "off"
16+
}
17+
}

cz-adapter/engine.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import chalk from "chalk";
2+
import wrap from "word-wrap";
3+
4+
import { rules } from "~/rules";
5+
6+
import type { Options } from "./options";
7+
8+
type Answers = {
9+
readonly type: string;
10+
readonly scope?: string;
11+
readonly scopeRules?: string;
12+
readonly subject: string;
13+
readonly body?: string;
14+
readonly isBreaking: boolean;
15+
readonly isIssueAffected: boolean;
16+
readonly issues?: string;
17+
};
18+
19+
type CZ = any;
20+
21+
/**
22+
* The engine.
23+
*/
24+
export default (
25+
options: Options
26+
): { prompter: (cz: CZ, commit: (msg: string) => unknown) => void } => {
27+
return {
28+
prompter: (cz, commit) =>
29+
promptUser(cz, options).then(doCommit(commit, options)),
30+
};
31+
};
32+
33+
/**
34+
* Prompt the user.
35+
*/
36+
function promptUser(cz: CZ, options: Options) {
37+
const {
38+
types,
39+
defaultType,
40+
defaultScope,
41+
defaultSubject,
42+
defaultBody,
43+
defaultIssues,
44+
} = options;
45+
46+
const typesLength =
47+
Object.keys(types).reduce(
48+
(longest, current) =>
49+
longest >= current.length ? longest : current.length,
50+
0
51+
) + 1;
52+
const typesChoices = Object.entries(types).map(([key, type]) => ({
53+
name: `${`${key}:`.padEnd(typesLength)} ${type.description}`,
54+
value: key,
55+
}));
56+
57+
const scopeRulesType = new Set<string>([
58+
"feat",
59+
"fix",
60+
"perf",
61+
"refactor",
62+
"test",
63+
]);
64+
65+
return cz.prompt([
66+
{
67+
type: "list",
68+
name: "type",
69+
message: "Select the type of change that you're committing:",
70+
choices: typesChoices,
71+
default: defaultType,
72+
},
73+
{
74+
type: "input",
75+
name: "scope",
76+
message: "What is the scope of this change: (press enter to skip)",
77+
default: defaultScope,
78+
when: (answers: Answers) => {
79+
return !scopeRulesType.has(answers.type);
80+
},
81+
filter: filterScope(options),
82+
},
83+
{
84+
type: "list",
85+
name: "scopeRules",
86+
message: "Which rule does this change apply to:",
87+
choices: getRulesChoices(),
88+
default: defaultScope,
89+
when: (answers: Answers) => {
90+
return scopeRulesType.has(answers.type);
91+
},
92+
filter: filterScope(options),
93+
},
94+
{
95+
type: "confirm",
96+
name: "isBreaking",
97+
message: "Are there any breaking changes?",
98+
default: false,
99+
},
100+
{
101+
type: "input",
102+
name: "subject",
103+
message(answers: Answers) {
104+
return `Write a short, imperative tense description of the change (max ${maxSummaryLength(
105+
options,
106+
answers
107+
)} chars):\n`;
108+
},
109+
default: defaultSubject,
110+
validate: (subject: string, answers: Answers) => {
111+
const filteredSubject = filterSubject(options)(subject);
112+
return filteredSubject.length === 0
113+
? "subject is required"
114+
: filteredSubject.length <= maxSummaryLength(options, answers)
115+
? true
116+
: `Subject length must be less than or equal to ${maxSummaryLength(
117+
options,
118+
answers
119+
)} characters. Current length is ${
120+
filteredSubject.length
121+
} characters.`;
122+
},
123+
transformer: (subject: string, answers: Answers) => {
124+
const filteredSubject = filterSubject(options)(subject);
125+
const color =
126+
filteredSubject.length <= maxSummaryLength(options, answers)
127+
? chalk.green
128+
: chalk.red;
129+
return color(`(${filteredSubject.length}) ${subject}`);
130+
},
131+
filter: filterSubject(options),
132+
},
133+
{
134+
type: "input",
135+
name: "body",
136+
message:
137+
"Provide a longer description of the change: (press enter to skip)\n",
138+
default: defaultBody,
139+
},
140+
{
141+
type: "confirm",
142+
name: "isIssueAffected",
143+
message: "Does this change affect any open issues?",
144+
default: Boolean(defaultIssues),
145+
},
146+
{
147+
type: "input",
148+
name: "issues",
149+
message: 'Add issue references (e.g. "fix #123", "re #123".):\n',
150+
when: (answers: Answers) => {
151+
return answers.isIssueAffected;
152+
},
153+
default: defaultIssues,
154+
},
155+
]);
156+
}
157+
158+
/**
159+
* Create the commit.
160+
*/
161+
function doCommit(
162+
commit: (msg: string) => unknown,
163+
options: Options
164+
): (answers: Answers) => unknown {
165+
const wrapOptions = {
166+
trim: true,
167+
cut: false,
168+
newline: "\n",
169+
indent: "",
170+
width: options.maxLineWidth,
171+
};
172+
173+
return (answers: Answers) => {
174+
const breakingMarker = answers.isBreaking ? "!" : "";
175+
176+
// Parentheses are only needed when a scope is present.
177+
const scopeValue = answers.scope ?? answers.scopeRules ?? "";
178+
const scope = scopeValue.length > 0 ? `(${scopeValue})` : "";
179+
// Hard limit is applied by the validate.
180+
const head = `${answers.type + breakingMarker + scope}: ${answers.subject}`;
181+
182+
const bodyValue = (answers.body ?? "").trim();
183+
const bodyValueWithBreaking =
184+
answers.isBreaking && bodyValue.length > 0
185+
? `BREAKING CHANGE: ${bodyValue.replace(/^breaking change: /iu, "")}`
186+
: bodyValue;
187+
188+
const body =
189+
bodyValueWithBreaking.length > 0
190+
? wrap(bodyValueWithBreaking, wrapOptions)
191+
: false;
192+
193+
const issues =
194+
(answers.issues?.length ?? 0) > 0
195+
? wrap(answers.issues!, wrapOptions)
196+
: false;
197+
198+
// Assemble the commmit message.
199+
const message = arrayFilterFalsy([head, body, issues]).join("\n\n");
200+
201+
// Print the commit message.
202+
const hr = `${"-".repeat(options.maxLineWidth)}\n`;
203+
console.info(`\ncommit message:\n${hr}${message}\n${hr}`);
204+
205+
// Do the commit.
206+
return commit(message);
207+
};
208+
}
209+
210+
/**
211+
* Filter out falsy values from the given array.
212+
*/
213+
function arrayFilterFalsy<T>(array: ReadonlyArray<T>) {
214+
return array.filter(Boolean);
215+
}
216+
217+
/**
218+
* The all the rules as prompt choices.
219+
*/
220+
function getRulesChoices() {
221+
return [
222+
{
223+
name: "-- none/multiple --",
224+
value: undefined,
225+
},
226+
...Object.keys(rules).map((name) => ({ name, value: name })),
227+
];
228+
}
229+
230+
/**
231+
* How long is the header?
232+
*/
233+
function headerLength(answers: Answers) {
234+
const scopeLength = answers.scope?.length ?? answers.scopeRules?.length ?? 0;
235+
236+
return (
237+
answers.type.length +
238+
2 +
239+
(scopeLength > 0 ? scopeLength + 2 : 0) +
240+
(answers.isBreaking ? 1 : 0)
241+
);
242+
}
243+
244+
/**
245+
* What's the max length the summary can be.
246+
*/
247+
function maxSummaryLength(options: Options, answers: Answers) {
248+
return options.maxHeaderWidth - headerLength(answers);
249+
}
250+
251+
/**
252+
* Get a function to auto-process the scope.
253+
*/
254+
function filterScope(options: Options) {
255+
return (value: string) => {
256+
return options.disableScopeLowerCase
257+
? value.trim()
258+
: value.trim().toLowerCase();
259+
};
260+
}
261+
262+
/**
263+
* Get a function to auto-process the subject.
264+
*/
265+
function filterSubject(options: Options) {
266+
return (subject: string) => {
267+
let mutableSubject = subject.trim();
268+
if (
269+
!options.disableSubjectLowerCase &&
270+
mutableSubject.charAt(0).toLowerCase() !== mutableSubject.charAt(0)
271+
) {
272+
mutableSubject =
273+
mutableSubject.charAt(0).toLowerCase() +
274+
mutableSubject.slice(1, mutableSubject.length);
275+
}
276+
// eslint-disable-next-line functional/no-loop-statement
277+
while (mutableSubject.endsWith(".")) {
278+
mutableSubject = mutableSubject.slice(0, -1);
279+
}
280+
return mutableSubject;
281+
};
282+
}

cz-adapter/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require("ts-node").register({
2+
compilerOptions: {
3+
module: "CommonJS",
4+
},
5+
});
6+
require("tsconfig-paths").register();
7+
8+
module.exports = require("./index.ts");

cz-adapter/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import engine from "./engine";
2+
import options from "./options";
3+
4+
module.exports = engine(options);

0 commit comments

Comments
 (0)