Repository: refined-github/refined-github
Pull request #7813 (2024-09-21 11:13:43)

`conventional-commits` - Cleanup
- Follows https://github.com/refined-github/refined-github/pull/7796


### Addresses

- support titles that contain HTML
- `parseConventionalCommit` now rejects invalid types
- use CSS attributes for type matching
- avoid hardcoded padding values
- reduce label to type
- add test URLs

### Test URLs


- Repo commits: https://github.com/refined-github/sandbox/commits/conventional-commits/
- PR commits: https://github.com/refined-github/sandbox/pull/91/commits
- Real data: https://github.com/semantic-release/semantic-release/commits
- Repo without conventional commits: https://github.com/refined-github/refined-github/commits


### Demo


<table>
<tr>
	<td><img width="458" alt="light" src="https://github.com/user-attachments/assets/5e557bb4-aacf-4848-b11f-cb00e80bf039"> 
	<td><img width="458" alt="dark" src="https://github.com/user-attachments/assets/69692400-3f57-4186-b1a8-1e05c2f24333">
</table>






fregante: Screenshot updated

![Screenshot 4](https://github.com/user-attachments/assets/980a2d5e-13c2-4b1b-bb80-81dc94723000)

oSumAtrIX: One more idea that came up is to add a second label for the scope in a lighter tint (or just grey)
fregante: I reviewed the spec at https://www.conventionalcommits.org/en/v1.0.0/ and found that:

- scope should "a noun", so technically multiple words shouldn't even be recognized as valid
- this also means <code>feat(\`backticks\`): foo</code> isn't valid either, and it's fine if we don't support it
- the prefix can also include `!` to mark major changes


This to show there were arguments about out-of-spec stuff while also ignoring the actual spec.

The feature is finally ready to be shipped

Patch:
diff --git a/build/__snapshots__/features-meta.json b/build/__snapshots__/features-meta.json
index 26b895d2..90b9a4a3 100644
--- a/build/__snapshots__/features-meta.json
+++ b/build/__snapshots__/features-meta.json
@@ -151,7 +151,7 @@
 		"id": "conventional-commits",
 		"description": "Shows conventional commit types as labels before the commit message.",
 		"css": true,
-		"screenshot": "https://github.com/user-attachments/assets/a031ec07-c655-4f80-b43e-80ab8ece55ea"
+		"screenshot": "https://github.com/user-attachments/assets/980a2d5e-13c2-4b1b-bb80-81dc94723000"
 	},
 	{
 		"id": "conversation-activity-filter",
diff --git a/readme.md b/readme.md
index 6358edab..f145e763 100644
--- a/readme.md
+++ b/readme.md
@@ -288,7 +288,7 @@ Thanks for contributing! 🦋🙌
 - [](# "same-branch-author-commits") [Preserves current branch and path when viewing all commits by an author.](https://user-images.githubusercontent.com/44045911/148764372-ee443213-e61a-4227-9219-0ee54ed832e8.png)
 - [](# "easy-toggle-commit-messages") [Enables toggling commit messages by clicking on the commit box.](https://user-images.githubusercontent.com/1402241/152121837-ca13bf8a-9b7f-4517-8e8d-b58bb135523b.gif)
 - [](# "link-to-compare-diff") [Linkifies the "X files changed" text on compare pages to allow jumping to the diff.](https://user-images.githubusercontent.com/46634000/157072587-0335357a-18c7-44c4-ae6e-237080fb36b4.png)
-- [](# "conventional-commits") [Shows conventional commit types as labels before the commit message.](https://github.com/user-attachments/assets/a031ec07-c655-4f80-b43e-80ab8ece55ea)
+- [](# "conventional-commits") [Shows conventional commit types as labels before the commit message.](https://github.com/user-attachments/assets/980a2d5e-13c2-4b1b-bb80-81dc94723000)
 
 <!-- Refer to style guide above. Keep this message between sections. -->
 
diff --git a/source/features/conventional-commits.css b/source/features/conventional-commits.css
index 85f5b8d5..a5365b5d 100644
--- a/source/features/conventional-commits.css
+++ b/source/features/conventional-commits.css
@@ -1,40 +1,49 @@
-.rgh-commit-type-label-feat-colors {
-	--label-r: 14;
-	--label-g: 138;
-	--label-b: 22;
-	--label-h: 123;
-	--label-s: 81;
-	--label-l: 29;
+:root [rgh-conventional-commits] {
+	font-size: var(--body-font-size, 1px);
+	line-height: 1.5;
 }
 
-.rgh-commit-type-label-fix-colors {
-	--label-r: 182;
-	--label-g: 2;
-	--label-b: 5;
-	--label-h: 359;
-	--label-s: 97;
-	--label-l: 36;
+/* Color from `enhancement` label */
+[rgh-conventional-commits='feat'] {
+	--label-r: 162;
+	--label-g: 238;
+	--label-b: 239;
+	--label-h: 180;
+	--label-s: 70;
+	--label-l: 78;
 }
 
-.rgh-commit-type-label-chore-colors {
-	--label-r: 254;
-	--label-g: 242;
-	--label-b: 192;
-	--label-h: 48;
-	--label-s: 96;
-	--label-l: 87;
+/* Color from `bug` label */
+[rgh-conventional-commits='fix'] {
+	--label-r: 215;
+	--label-g: 58;
+	--label-b: 74;
+	--label-h: 353;
+	--label-s: 66;
+	--label-l: 53;
 }
 
-.rgh-commit-type-label-docs-colors {
-	--label-r: 249;
-	--label-g: 208;
-	--label-b: 196;
-	--label-h: 13;
-	--label-s: 81;
-	--label-l: 87;
+/* Color from `wontfix` label. Just white because it's the most common and meaningless type of commit */
+[rgh-conventional-commits='chore'] {
+	--label-r: 255;
+	--label-g: 255;
+	--label-b: 255;
+	--label-h: 0;
+	--label-s: 0;
+	--label-l: 100;
 }
 
-.rgh-commit-type-label-build-colors {
+/* Color from `documentation` label */
+[rgh-conventional-commits='docs'] {
+	--label-r: 0;
+	--label-g: 117;
+	--label-b: 202;
+	--label-h: 205;
+	--label-s: 100;
+	--label-l: 39;
+}
+
+[rgh-conventional-commits='build'] {
 	--label-r: 217;
 	--label-g: 63;
 	--label-b: 11;
@@ -43,7 +52,7 @@
 	--label-l: 44;
 }
 
-.rgh-commit-type-label-refactor-colors {
+[rgh-conventional-commits='refactor'] {
 	--label-r: 0;
 	--label-g: 107;
 	--label-b: 117;
@@ -52,7 +61,7 @@
 	--label-l: 22;
 }
 
-.rgh-commit-type-label-test-colors {
+[rgh-conventional-commits='test'] {
 	--label-r: 251;
 	--label-g: 202;
 	--label-b: 4;
@@ -61,7 +70,7 @@
 	--label-l: 50;
 }
 
-.rgh-commit-type-label-ci-colors {
+[rgh-conventional-commits='ci'] {
 	--label-r: 197;
 	--label-g: 222;
 	--label-b: 245;
@@ -70,7 +79,7 @@
 	--label-l: 86;
 }
 
-.rgh-commit-type-label-perf-colors {
+[rgh-conventional-commits='perf'] {
 	--label-r: 83;
 	--label-g: 25;
 	--label-b: 231;
@@ -78,8 +87,3 @@
 	--label-s: 81;
 	--label-l: 50;
 }
-
-.rgh-commit-type-label {
-	font-size: var(--body-font-size, 14px) !important;
-	padding: 2px 0.5rem !important;
-}
diff --git a/source/features/conventional-commits.tsx b/source/features/conventional-commits.tsx
index 1b91df07..382d406c 100644
--- a/source/features/conventional-commits.tsx
+++ b/source/features/conventional-commits.tsx
@@ -7,65 +7,43 @@ import observe from '../helpers/selector-observer.js';
 import {commitTitleInLists} from '../github-helpers/selectors.js';
 import {parseConventionalCommit, removeCommitAndScope} from '../helpers/conventional-commits.js';
 
-const types = new Map([
-	['feat', 'Feature'],
-	['fix', 'Fix'],
-	['chore', 'Chore'],
-	['docs', 'Docs'],
-	['build', 'Build'],
-	['refactor', 'Refactor'],
-	['test', 'Test'],
-	['ci', 'CI'],
-	['perf', 'Performance'],
-]);
-
-function createLabelElement(type: string, scope?: string): JSX.Element {
-	const label = types.get(type)!;
-
-	return (
-		<span className={`IssueLabel hx_IssueLabel rgh-commit-type-label rgh-commit-type-label-${type}-colors`}>
-			{scope ? `${label}: ${scope}` : label}
-		</span>
-	);
-}
-
 function renderLabelInCommitTitle(commitTitleElement: HTMLElement): void {
-	const match = parseConventionalCommit(commitTitleElement.textContent);
+	const textNode = commitTitleElement.firstChild!;
+	const commit = parseConventionalCommit(textNode.textContent);
 
-	const {type, scope} = match?.groups ?? {};
-	if (!type || !types.has(type)) {
+	if (!commit) {
 		return;
 	}
 
-	commitTitleElement.prepend(createLabelElement(type, scope));
-	removeCommitAndScope(commitTitleElement, match!);
-}
+	commitTitleElement.prepend(
+		<span className="IssueLabel hx_IssueLabel mr-2" rgh-conventional-commits={commit.rawType}>
+			{commit.type}
+		</span>,
+		// Keep scope outside because that's how they're rendered in release notes as well
+		commit.scope ? <span style={{opacity: 0.7}}>{commit.scope}</span> : '',
+	);
 
-function initRepoCommitList(signal: AbortSignal): void {
-	observe(`:is(${commitTitleInLists}) h4 > span`, renderLabelInCommitTitle, {signal});
+	removeCommitAndScope(textNode);
 }
 
-function initPrCommitList(signal: AbortSignal): void {
-	observe(`:is(${commitTitleInLists}) a`, renderLabelInCommitTitle, {signal});
+function init(signal: AbortSignal): void {
+	observe(`:is(${commitTitleInLists}) h4 > span > a:first-child`, renderLabelInCommitTitle, {signal});
 }
 
 void features.add(import.meta.url, {
 	include: [
-		pageDetect.isRepoCommitList,
-	],
-	init: initRepoCommitList,
-}, {
-	include: [
-		pageDetect.isPRCommitList,
+		pageDetect.isCommitList,
 	],
-	init: initPrCommitList,
+	init,
 });
 
 /*
 
 Test URLs:
 
-https://github.com/semantic-release/semantic-release/commits/master/
-https://github.com/ReVanced/revanced-patches/commits/main/
+- Repo commits: https://github.com/refined-github/sandbox/commits/conventional-commits/
+- PR commits: https://github.com/refined-github/sandbox/pull/91/commits
+- Real data: https://github.com/conventional-changelog/standard-version/commits
+- Repo without conventional commits: https://github.com/refined-github/refined-github/commits
 
 */
diff --git a/source/helpers/conventional-commits.ts b/source/helpers/conventional-commits.ts
index 490cd8a5..d2375563 100644
--- a/source/helpers/conventional-commits.ts
+++ b/source/helpers/conventional-commits.ts
@@ -1,26 +1,50 @@
-import zipTextNodes from 'zip-text-nodes';
+import {assertNodeContent} from './dom-utils.js';
 
 // Using https://www.conventionalcommits.org/ as a reference.
-const CONVENTIONAL_COMMIT_REGEX = /^(?<type>\w+)(?:\((?<scope>.+)\))?: /;
-
-export function parseConventionalCommit(commitTitle: string): RegExpExecArray | undefined {
-	return CONVENTIONAL_COMMIT_REGEX.exec(commitTitle) ?? undefined;
+const conventionalCommitRegex = /^(?<type>\w+)(?:\((?<scope>.+)\))?(?<major>!)?: /;
+
+const types = new Map([
+	['feat', 'Feature'],
+	['fix', 'Fix'],
+	['chore', 'Chore'],
+	['docs', 'Docs'],
+	['build', 'Build'],
+	['refactor', 'Refactor'],
+	['test', 'Test'],
+	['ci', 'CI'],
+	['perf', 'Performance'],
+]);
+
+export function parseConventionalCommit(commitTitle: string): {
+	rawType: string;
+	type: string;
+	scope?: string;
+	raw: string;
+} | undefined {
+	const match = conventionalCommitRegex.exec(commitTitle);
+	if (!match?.groups?.type) {
+		return;
+	}
+
+	const {type: rawType, scope, major} = match.groups;
+	const type = types.get(rawType);
+	if (!type) {
+		return;
+	}
+
+	return {
+		rawType,
+		type: major ? `${type}!` : type,
+		scope: scope ? `${scope}: ` : undefined,
+		raw: match[0],
+	};
 }
 
-export function removeCommitAndScope(
-	element: HTMLElement,
-	match: RegExpExecArray,
-): void {
-	const {type, scope} = match?.groups ?? {};
-
-	const semanticCommitTypeAndScope = scope ? `${type}(${scope}):` : `${type}:`;
-
-	const modified = element.textContent.replace(semanticCommitTypeAndScope, match => `
${match}
`);
-
-	const temporaryDiv = document.createElement('div');
-	temporaryDiv.innerHTML = modified;
-
-	zipTextNodes(element, temporaryDiv);
-
-	element.querySelector('s')!.remove();
+/**
+Remove the raw commit prefix from the single text node.
+Note: It does not support pre-formatted titles like: fix(#1 `x`)
+*/
+export function removeCommitAndScope(textNode: Text | ChildNode): void {
+	assertNodeContent(textNode, conventionalCommitRegex);
+	textNode.textContent = textNode.textContent.replace(conventionalCommitRegex, '');
 }
diff --git a/source/helpers/dom-utils.ts b/source/helpers/dom-utils.ts
index 19f11ff3..99c6f395 100644
--- a/source/helpers/dom-utils.ts
+++ b/source/helpers/dom-utils.ts
@@ -7,7 +7,7 @@ import type {Nullable} from 'vitest';
 // This will set the correct `origin` header without having to use XMLHttpRequest
 // https://stackoverflow.com/questions/47356375/firefox-fetch-api-how-to-omit-the-origin-header-in-the-request
 // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts#XHR_and_Fetch
-if (window.content?.fetch) {
+if (globalThis.window?.content?.fetch) {
 	setFetch(window.content.fetch);
 }
 


Test patch:
diff --git a/source/helpers/conventional-commits.test.ts b/source/helpers/conventional-commits.test.ts
index a2707ef4..50241d6a 100644
--- a/source/helpers/conventional-commits.test.ts
+++ b/source/helpers/conventional-commits.test.ts
@@ -1,16 +1,68 @@
-import {test, assert} from 'vitest';
+import {test, expect} from 'vitest';
 
 import {parseConventionalCommit} from './conventional-commits.js';
 
 test('parseConventionalCommit', () => {
-	assert.deepStrictEqual(parseConventionalCommit('fix: Commit message'), ['fix: ', 'fix', undefined]);
-	assert.deepStrictEqual(parseConventionalCommit('feat: Commit message'), ['feat: ', 'feat', undefined]);
-	assert.deepStrictEqual(parseConventionalCommit('feat(scope): Commit message'), ['feat(scope): ', 'feat', 'scope']);
-	assert.deepStrictEqual(parseConventionalCommit('feat(sco pe): Commit message'), ['feat(sco pe): ', 'feat', 'sco pe']);
-	assert.deepStrictEqual(parseConventionalCommit(('feat: Commit (message)')), ['feat: ', 'feat', undefined]);
+	expect(parseConventionalCommit('fix: Commit message')).toMatchInlineSnapshot(`
+		{
+		  "raw": "fix: ",
+		  "rawType": "fix",
+		  "scope": undefined,
+		  "type": "Fix",
+		}
+	`);
+	expect(parseConventionalCommit('feat: Commit message')).toMatchInlineSnapshot(`
+		{
+		  "raw": "feat: ",
+		  "rawType": "feat",
+		  "scope": undefined,
+		  "type": "Feature",
+		}
+	`);
+	expect(parseConventionalCommit('fix!: Breaking change')).toMatchInlineSnapshot(`
+		{
+		  "raw": "fix!: ",
+		  "rawType": "fix",
+		  "scope": undefined,
+		  "type": "Fix!",
+		}
+	`);
+	expect(parseConventionalCommit('feat(scope): Commit message')).toMatchInlineSnapshot(`
+		{
+		  "raw": "feat(scope): ",
+		  "rawType": "feat",
+		  "scope": "scope: ",
+		  "type": "Feature",
+		}
+	`);
+	expect(parseConventionalCommit('feat(scope)!: Breaking change')).toMatchInlineSnapshot(`
+		{
+		  "raw": "feat(scope)!: ",
+		  "rawType": "feat",
+		  "scope": "scope: ",
+		  "type": "Feature!",
+		}
+	`);
+	expect(parseConventionalCommit('feat(sco pe): Commit message')).toMatchInlineSnapshot(`
+		{
+		  "raw": "feat(sco pe): ",
+		  "rawType": "feat",
+		  "scope": "sco pe: ",
+		  "type": "Feature",
+		}
+	`);
+	expect(parseConventionalCommit(('feat: Commit (message)'))).toMatchInlineSnapshot(`
+		{
+		  "raw": "feat: ",
+		  "rawType": "feat",
+		  "scope": undefined,
+		  "type": "Feature",
+		}
+	`);
 
-	assert.isUndefined(parseConventionalCommit('feat:'));
-	assert.isUndefined(parseConventionalCommit('Commit message'));
-	assert.isUndefined(parseConventionalCommit('feat(): Commit message'));
-	assert.isUndefined(parseConventionalCommit('fe at(scope): Commit message) '));
+	expect(parseConventionalCommit('feat:')).toBeUndefined();
+	expect(parseConventionalCommit('idk(label): not recognized')).toBeUndefined();
+	expect(parseConventionalCommit('Commit message')).toBeUndefined();
+	expect(parseConventionalCommit('feat(): Commit message')).toBeUndefined();
+	expect(parseConventionalCommit('fe at(scope): Commit message) ')).toBeUndefined();
 });