Skip to content

Commit 0f95f50

Browse files
committed
Add GitHub autolink support
1 parent febcd88 commit 0f95f50

3 files changed

Lines changed: 299 additions & 1 deletion

File tree

src/modules/html-templates.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ function releaseArticle({ repo, release }) {
5858
</hgroup>
5959
`;
6060

61-
const body = markdownParse(release.body);
61+
let body = markdownParse(release.body);
62+
63+
// Make sure to use links relevant to repo when linked with "owner/repo"
64+
body = body.replaceAll(
65+
"https://github.com/owner-to-be-replaced/repo-to-be-replaced/",
66+
`https://github.com/${repo}/`
67+
);
6268

6369
let footer = "";
6470
for (const [key, value] of Object.entries(release.reactions || {})) {

src/modules/markdown.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import DOMPurify from "dompurify";
33
import markedAlert from "marked-alert";
44
import { markedEmoji } from "marked-emoji";
55
import emojis from "./emojis.mjs";
6+
import githubAutolinkExtension from "./marked-github-autolink-extension.mjs";
67

78
// Configure marked for GitHub-flavored markdown
89
marked.setOptions({
@@ -20,6 +21,12 @@ marked.use(
2021
);
2122

2223
marked.use(markedAlert());
24+
marked.use(
25+
githubAutolinkExtension({
26+
// We don't know the repo here so we will replace it in another step
27+
repository: "owner-to-be-replaced/repo-to-be-replaced",
28+
})
29+
);
2330

2431
function markdownParse(text) {
2532
if (!text || typeof text !== "string") {
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// GitHub Autolinked References Extension for marked.js
2+
//
3+
// Based on the documentation from:
4+
// https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls
5+
//
6+
// URLs - creates links from standard URLs
7+
// http(s)://example.com
8+
// <a href="http(s)://example.com">http(s)://example.com</a>
9+
//
10+
// Issues and pull requests
11+
// Issue or pull request URL
12+
// https://github.com/jlord/sheetsee.js/issues/26
13+
// <a href="https://github.com/jlord/sheetsee.js/issues/26">#26</a>
14+
//
15+
// # and issue or pull request number
16+
// #26
17+
// <a href="https://github.com/jlord/sheetsee.js/issues/26">#26</a>
18+
//
19+
// GH- and issue or pull request number
20+
// GH-26
21+
// <a href="https://github.com/jlord/sheetsee.js/issues/26">GH-26</a>
22+
//
23+
// Username-or-org/Repository# and issue or pull request number
24+
// jlord/sheetsee.js#26
25+
// <a href="https://github.com/jlord/sheetsee.js/issues/26">jlord/sheetsee.js#26</a>
26+
//
27+
// Labels - This is skipped for now
28+
//
29+
// Commit SHAs - References to a commit's SHA hash are automatically converted into shortened links to the commit on GitHub.
30+
//
31+
// Commit URL
32+
// https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e
33+
// <a href="https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e">a5c3785</a>
34+
//
35+
// SHA
36+
// a5c3785ed8d6a35868bc169f07e40e889087fd2e
37+
// <a href="https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e">a5c3785</a>
38+
//
39+
// User@SHA
40+
// jlord@a5c3785ed8d6a35868bc169f07e40e889087fd2e
41+
// <a href="https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e">jlord@a5c3785</a>
42+
//
43+
// Username/Repository@SHA
44+
// jlord/sheetsee.js@a5c3785ed8d6a35868bc169f07e40e889087fd2e
45+
// <a href="https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e">jlord/sheetsee.js@a5c3785</a>
46+
//
47+
48+
function createGitHubAutolinkExtension({ repository }) {
49+
if (repository && !/^[\w.-]+\/[\w.-]+$/.test(repository)) {
50+
throw new Error('Repository must be in format "username/repository"');
51+
}
52+
53+
const githubAutolink = {
54+
name: "githubAutolink",
55+
level: "inline",
56+
start(src) {
57+
const patterns = [
58+
/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+/,
59+
/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/,
60+
/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/commit\/[a-f0-9]{40}/,
61+
/#\d+/,
62+
/GH-\d+/i,
63+
/[\w.-]+\/[\w.-]+#\d+/,
64+
/[a-f0-9]{7,40}(?=\s|$)/i,
65+
/[\w.-]+@[a-f0-9]{7,40}/i,
66+
/[\w.-]+\/[\w.-]+@[a-f0-9]{7,40}/i,
67+
/https?:\/\/[^\s<>"`]+/,
68+
];
69+
70+
for (const pattern of patterns) {
71+
const match = src.match(pattern);
72+
if (match) {
73+
return match.index;
74+
}
75+
}
76+
return -1;
77+
},
78+
tokenizer(src) {
79+
// Issue/PR URL: https://github.com/jlord/sheetsee.js/issues/26
80+
const issueUrlMatch = src.match(
81+
/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)/
82+
);
83+
if (issueUrlMatch) {
84+
return {
85+
type: "githubAutolink",
86+
raw: issueUrlMatch[0],
87+
linkType: "issue-url",
88+
owner: issueUrlMatch[1],
89+
repo: issueUrlMatch[2],
90+
number: issueUrlMatch[3],
91+
url: issueUrlMatch[0],
92+
};
93+
}
94+
95+
// PR URL: https://github.com/jlord/sheetsee.js/pull/26
96+
const prUrlMatch = src.match(
97+
/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
98+
);
99+
if (prUrlMatch) {
100+
return {
101+
type: "githubAutolink",
102+
raw: prUrlMatch[0],
103+
linkType: "pr-url",
104+
owner: prUrlMatch[1],
105+
repo: prUrlMatch[2],
106+
number: prUrlMatch[3],
107+
url: prUrlMatch[0],
108+
};
109+
}
110+
111+
// Commit URL: https://github.com/jlord/sheetsee.js/commit/a5c3785ed8d6a35868bc169f07e40e889087fd2e
112+
const commitUrlMatch = src.match(
113+
/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/commit\/([a-f0-9]{40})/
114+
);
115+
if (commitUrlMatch) {
116+
return {
117+
type: "githubAutolink",
118+
raw: commitUrlMatch[0],
119+
linkType: "commit-url",
120+
owner: commitUrlMatch[1],
121+
repo: commitUrlMatch[2],
122+
sha: commitUrlMatch[3],
123+
url: commitUrlMatch[0],
124+
};
125+
}
126+
127+
// Issue/PR reference: #26
128+
const issueMatch = src.match(/^#(\d+)/);
129+
if (issueMatch) {
130+
return {
131+
type: "githubAutolink",
132+
raw: issueMatch[0],
133+
linkType: "issue",
134+
number: issueMatch[1],
135+
repository,
136+
};
137+
}
138+
139+
// GH- reference: GH-26
140+
const ghMatch = src.match(/^GH-(\d+)/i);
141+
if (ghMatch) {
142+
return {
143+
type: "githubAutolink",
144+
raw: ghMatch[0],
145+
linkType: "gh-issue",
146+
number: ghMatch[1],
147+
repository,
148+
};
149+
}
150+
151+
// Cross-repository issue/PR reference: jlord/sheetsee.js#26
152+
const crossRepoIssueMatch = src.match(/^([\w.-]+)\/([\w.-]+)#(\d+)/);
153+
if (crossRepoIssueMatch) {
154+
return {
155+
type: "githubAutolink",
156+
raw: crossRepoIssueMatch[0],
157+
linkType: "cross-repo-issue",
158+
owner: crossRepoIssueMatch[1],
159+
repo: crossRepoIssueMatch[2],
160+
number: crossRepoIssueMatch[3],
161+
};
162+
}
163+
164+
// SHA reference: a5c3785ed8d6a35868bc169f07e40e889087fd2e
165+
const commitMatch = src.match(/^([a-f0-9]{7,40})(?=\s|$)/i);
166+
if (commitMatch) {
167+
return {
168+
type: "githubAutolink",
169+
raw: commitMatch[0],
170+
linkType: "commit",
171+
sha: commitMatch[1],
172+
repository,
173+
};
174+
}
175+
176+
// User@SHA reference: jlord@a5c3785ed8d6a35868bc169f07e40e889087fd2e
177+
const userCommitMatch = src.match(/^([\w.-]+)@([a-f0-9]{7,40})/i);
178+
if (userCommitMatch) {
179+
return {
180+
type: "githubAutolink",
181+
raw: userCommitMatch[0],
182+
linkType: "user-commit",
183+
user: userCommitMatch[1],
184+
sha: userCommitMatch[2],
185+
repository,
186+
};
187+
}
188+
189+
// Repository@SHA reference: jlord/sheetsee.js@a5c3785ed8d6a35868bc169f07e40e889087fd2e
190+
const repoCommitMatch = src.match(
191+
/^([\w.-]+)\/([\w.-]+)@([a-f0-9]{7,40})/i
192+
);
193+
if (repoCommitMatch) {
194+
return {
195+
type: "githubAutolink",
196+
raw: repoCommitMatch[0],
197+
linkType: "repo-commit",
198+
owner: repoCommitMatch[1],
199+
repo: repoCommitMatch[2],
200+
sha: repoCommitMatch[3],
201+
};
202+
}
203+
204+
// URL autolink: https://example.com
205+
const urlMatch = src.match(/^(https?:\/\/[^\s<>"`]+)/);
206+
if (urlMatch) {
207+
// Check if this URL is already part of a markdown link
208+
const beforeUrl = src.substring(0, src.indexOf(urlMatch[0]));
209+
if (beforeUrl.endsWith("](")) {
210+
return false; // Skip if it's already in a markdown link
211+
}
212+
213+
return {
214+
type: "githubAutolink",
215+
raw: urlMatch[0],
216+
linkType: "url",
217+
url: urlMatch[1],
218+
};
219+
}
220+
221+
return false;
222+
},
223+
renderer(token) {
224+
console.log(token);
225+
const { linkType } = token;
226+
227+
switch (linkType) {
228+
case "issue-url":
229+
return `<a href="${token.url}">#${token.number}</a>`;
230+
231+
case "pr-url":
232+
return `<a href="${token.url}">#${token.number}</a>`;
233+
234+
case "commit-url":
235+
return `<a href="${token.url}">${token.sha.substring(0, 7)}</a>`;
236+
237+
case "issue":
238+
case "gh-issue":
239+
if (token.repository) {
240+
const url = `https://github.com/${token.repository}/issues/${token.number}`;
241+
return `<a href="${url}">${token.raw}</a>`;
242+
}
243+
return token.raw; // No repository configured, return as plain text
244+
245+
case "cross-repo-issue":
246+
const issueUrl = `https://github.com/${token.owner}/${token.repo}/issues/${token.number}`;
247+
return `<a href="${issueUrl}">${token.raw}</a>`;
248+
249+
case "commit":
250+
if (token.repository) {
251+
const commitUrl = `https://github.com/${token.repository}/commit/${token.sha}`;
252+
return `<a href="${commitUrl}">${token.sha.substring(0, 7)}</a>`;
253+
}
254+
return token.raw; // No repository configured, return as plain text
255+
256+
case "user-commit":
257+
if (token.repository) {
258+
const userCommitUrl = `https://github.com/${token.repository}/commit/${token.sha}`;
259+
return `<a href="${userCommitUrl}">${
260+
token.user
261+
}@${token.sha.substring(0, 7)}</a>`;
262+
}
263+
return token.raw; // No repository configured, return as plain text
264+
265+
case "repo-commit":
266+
const repoCommitUrl = `https://github.com/${token.owner}/${token.repo}/commit/${token.sha}`;
267+
return `<a href="${repoCommitUrl}">${token.owner}/${
268+
token.repo
269+
}@${token.sha.substring(0, 7)}</a>`;
270+
271+
case "url":
272+
return `<a href="${token.url}">${token.url}</a>`;
273+
274+
default:
275+
return token.raw;
276+
}
277+
},
278+
};
279+
280+
return {
281+
extensions: [githubAutolink],
282+
};
283+
}
284+
285+
export default createGitHubAutolinkExtension;

0 commit comments

Comments
 (0)