Skip to content

Commit 14e4356

Browse files
committed
feat: add markdown renderer
1 parent e246d9d commit 14e4356

File tree

4 files changed

+168
-2
lines changed

4 files changed

+168
-2
lines changed

src/lib/github/webhooks/handlers/release-created.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { HandlerFunction } from "@octokit/webhooks/types";
22

33
import { bot } from "#bot";
4+
import { renderMarkdown } from "#telegram";
45

56
import { escapeHtml } from "../../../escape-html.ts";
67
import { botText, getRepoHashtag } from "./_utils.ts";
@@ -11,14 +12,14 @@ export const releaseCreatedCallback: HandlerFunction<"release.created", unknown>
1112
const repoHashtag = getRepoHashtag(repo.name);
1213

1314
if (release.body) {
14-
const releaseNotesPreview = release.body.length > 2000 ? `${release.body.slice(0, 2000)}\n...` : release.body;
15+
const notes = renderMarkdown(release.body);
1516

1617
await bot.announce(
1718
botText("e_release_created_with_notes", {
1819
repoName: escapeHtml(repo.name),
1920
releaseTag: escapeHtml(release.tag_name),
2021
releaseUrl: escapeHtml(release.html_url),
21-
notes: releaseNotesPreview,
22+
notes,
2223
repoHashtag,
2324
}),
2425
{ link_preview_options: { prefer_small_media: true, url: release.html_url } },

src/lib/telegram/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./CommandParser.ts";
22
export * from "./createCommand.ts";
3+
export * from "./render-markdown.ts";
34
export * as zs from "./schemas.ts";
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from "node:assert";
2+
import { it } from "node:test";
3+
4+
import { renderMarkdown } from "./render-markdown.ts";
5+
6+
it("should render raw text", () => {
7+
const markdown = "This is a simple text without any markdown.";
8+
9+
assert.equal(renderMarkdown(markdown), markdown);
10+
});
11+
12+
it("should trim leading and trailing whitespace", () => {
13+
const markdown = "\n\n \n\nThis is a text with leading and trailing spaces.\n \n ";
14+
const expected = "This is a text with leading and trailing spaces.";
15+
16+
assert.equal(renderMarkdown(markdown), expected);
17+
});
18+
19+
it("should render bold text", () => {
20+
const markdown = "This is **bold** text.";
21+
const expected = "This is <b>bold</b> text.";
22+
23+
assert.equal(renderMarkdown(markdown), expected);
24+
});
25+
26+
it("should render links", () => {
27+
const markdown = "This is a [link](https://example.com).";
28+
const expected = 'This is a <a href="https://example.com">link</a>.';
29+
30+
assert.equal(renderMarkdown(markdown), expected);
31+
});
32+
33+
it("should render headings", () => {
34+
const markdown = "# Heading 1\n## Heading 2\n### Heading 3";
35+
const expected = "<b>Heading 1</b>\n<b>Heading 2</b>\n<b>Heading 3</b>";
36+
37+
assert.equal(renderMarkdown(markdown), expected);
38+
});
39+
40+
it("should render bullet points", () => {
41+
const markdown = "- Item 1\n- Item 2\n- Item 3";
42+
const expected = "• Item 1\n• Item 2\n• Item 3";
43+
44+
assert.equal(renderMarkdown(markdown), expected);
45+
});
46+
47+
it("should merge multiple empty lines", () => {
48+
const markdown = "Line 1\n\n\n\nLine 2";
49+
const expected = "Line 1\n\nLine 2";
50+
51+
assert.equal(renderMarkdown(markdown), expected);
52+
});
53+
54+
it("should render markdown to HTML", () => {
55+
const markdown = `
56+
# [13.12.0](https://github.com/fullstacksjs/eslint-config/compare/v13.11.2...v13.12.0) (2025-12-16)
57+
58+
59+
### Bug Fixes
60+
61+
* **stylistic:** fix error in rule lines-between-class-members, add new rule ([411160c](https://github.com/fullstacksjs/eslint-config/commit/411160c7f900e6417f32c671d00e8e949ca3e445))
62+
* **stylistic:** fix padding-line-between-statements to be more general ([956e745](https://github.com/fullstacksjs/eslint-config/commit/956e745027bfc6025554e138d2b9084e3749178b))
63+
* **stylistic:** fix rule name padding-line-between-statements ([d509bec](https://github.com/fullstacksjs/eslint-config/commit/d509bec4c915c6df6a486f54b2a4c486fa40702c))
64+
65+
66+
### Features
67+
68+
* **stylistic:** add new rules ([2283711](https://github.com/fullstacksjs/eslint-config/commit/228371129db44b128ef5e733b9f477983f4f4509))
69+
* **stylistic:** fix padding-line-between-statements and add new rule lines-between-class-members ([5c573f8](https://github.com/fullstacksjs/eslint-config/commit/5c573f89d522b9aeb4a11e654ccb224f93076094))
70+
`;
71+
72+
const expected = `
73+
<b><a href="https://github.com/fullstacksjs/eslint-config/compare/v13.11.2...v13.12.0">13.12.0</a> (2025-12-16)</b>
74+
75+
<b>Bug Fixes</b>
76+
77+
• <b>stylistic:</b> fix error in rule lines-between-class-members, add new rule (<a href="https://github.com/fullstacksjs/eslint-config/commit/411160c7f900e6417f32c671d00e8e949ca3e445">411160c</a>)
78+
• <b>stylistic:</b> fix padding-line-between-statements to be more general (<a href="https://github.com/fullstacksjs/eslint-config/commit/956e745027bfc6025554e138d2b9084e3749178b">956e745</a>)
79+
• <b>stylistic:</b> fix rule name padding-line-between-statements (<a href="https://github.com/fullstacksjs/eslint-config/commit/d509bec4c915c6df6a486f54b2a4c486fa40702c">d509bec</a>)
80+
81+
<b>Features</b>
82+
83+
• <b>stylistic:</b> add new rules (<a href="https://github.com/fullstacksjs/eslint-config/commit/228371129db44b128ef5e733b9f477983f4f4509">2283711</a>)
84+
• <b>stylistic:</b> fix padding-line-between-statements and add new rule lines-between-class-members (<a href="https://github.com/fullstacksjs/eslint-config/commit/5c573f89d522b9aeb4a11e654ccb224f93076094">5c573f8</a>)
85+
`.trim();
86+
87+
assert.equal(renderMarkdown(markdown), expected);
88+
});
89+
90+
it("should truncate output exceeding max characters", () => {
91+
const longText = "1\n3\n5\n6\n8\n";
92+
const rendered = renderMarkdown(longText, 5);
93+
const expected = "1\n3\n5\n...";
94+
95+
assert.equal(rendered, expected);
96+
});
97+
98+
it("should keep the line when truncating output exceeding max characters", () => {
99+
const longText = "First line\nSecond line\nThird line\nFourth line\nFifth line\n";
100+
const rendered = renderMarkdown(longText, 15);
101+
const expected = "First line\nSecond line\n...";
102+
103+
assert.equal(rendered, expected);
104+
});
105+
106+
it("should not add extra newline when truncating at the end of a line", () => {
107+
const longText = ["Line one", "Line two", "Line three"].join("\n");
108+
const rendered = renderMarkdown(longText, longText.indexOf("Line two") + 1);
109+
const expected = "Line one\nLine two\n...";
110+
111+
assert.equal(rendered, expected);
112+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { crlfToLf, isNullOrEmptyString } from "@fullstacksjs/toolbox";
2+
3+
const boldPattern = /\*\*(.+?)\*\*/g;
4+
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
5+
6+
function renderInline(content: string): string {
7+
return content.replace(boldPattern, "<b>$1</b>").replace(linkPattern, '<a href="$2">$1</a>');
8+
}
9+
10+
export function renderMarkdown(markdown: string, maxChars: number = Infinity): string {
11+
const lines = crlfToLf(markdown)
12+
.split("\n")
13+
.map((l) => l.trim());
14+
15+
const rendered: string[] = [];
16+
let lastWasEmpty = true;
17+
18+
for (const line of lines) {
19+
if (isNullOrEmptyString(line)) {
20+
if (!lastWasEmpty) rendered.push("");
21+
lastWasEmpty = true;
22+
continue;
23+
} else {
24+
lastWasEmpty = false;
25+
}
26+
27+
const headingMatch = line.match(/^#{1,6}\s+(\S.*)$/);
28+
if (headingMatch) {
29+
const content = headingMatch[1];
30+
rendered.push(`<b>${renderInline(content.trim())}</b>`);
31+
continue;
32+
}
33+
34+
const listMatch = line.match(/^\s*[*-]\s+(\S.*)$/);
35+
if (listMatch) {
36+
rendered.push(`• ${renderInline(listMatch[1].trim())}`);
37+
continue;
38+
}
39+
40+
rendered.push(renderInline(line));
41+
}
42+
43+
let html = "";
44+
for (const line of rendered) {
45+
if (html.length > maxChars) {
46+
return `${html}...`;
47+
}
48+
html += `${line}\n`;
49+
}
50+
51+
return html.trim();
52+
}

0 commit comments

Comments
 (0)