From 403a6d4b78520daf77b983171edb869173791ac4 Mon Sep 17 00:00:00 2001 From: Alireza Safaierad Date: Tue, 16 Dec 2025 21:26:14 +0100 Subject: [PATCH] feat: add markdown renderer --- .../webhooks/handlers/release-created.ts | 5 +- src/lib/telegram/index.ts | 1 + src/lib/telegram/render-markdown.spec.ts | 112 ++++++++++++++++++ src/lib/telegram/render-markdown.ts | 52 ++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/lib/telegram/render-markdown.spec.ts create mode 100644 src/lib/telegram/render-markdown.ts diff --git a/src/lib/github/webhooks/handlers/release-created.ts b/src/lib/github/webhooks/handlers/release-created.ts index 5b1bfe2..038c952 100644 --- a/src/lib/github/webhooks/handlers/release-created.ts +++ b/src/lib/github/webhooks/handlers/release-created.ts @@ -1,6 +1,7 @@ import type { HandlerFunction } from "@octokit/webhooks/types"; import { bot } from "#bot"; +import { renderMarkdown } from "#telegram"; import { escapeHtml } from "../../../escape-html.ts"; import { botText, getRepoHashtag } from "./_utils.ts"; @@ -11,14 +12,14 @@ export const releaseCreatedCallback: HandlerFunction<"release.created", unknown> const repoHashtag = getRepoHashtag(repo.name); if (release.body) { - const releaseNotesPreview = release.body.length > 2000 ? `${release.body.slice(0, 2000)}\n...` : release.body; + const notes = renderMarkdown(release.body); await bot.announce( botText("e_release_created_with_notes", { repoName: escapeHtml(repo.name), releaseTag: escapeHtml(release.tag_name), releaseUrl: escapeHtml(release.html_url), - notes: releaseNotesPreview, + notes, repoHashtag, }), { link_preview_options: { prefer_small_media: true, url: release.html_url } }, diff --git a/src/lib/telegram/index.ts b/src/lib/telegram/index.ts index 070ebeb..c534cb8 100644 --- a/src/lib/telegram/index.ts +++ b/src/lib/telegram/index.ts @@ -1,3 +1,4 @@ export * from "./CommandParser.ts"; export * from "./createCommand.ts"; +export * from "./render-markdown.ts"; export * as zs from "./schemas.ts"; diff --git a/src/lib/telegram/render-markdown.spec.ts b/src/lib/telegram/render-markdown.spec.ts new file mode 100644 index 0000000..44648b2 --- /dev/null +++ b/src/lib/telegram/render-markdown.spec.ts @@ -0,0 +1,112 @@ +import assert from "node:assert"; +import { it } from "node:test"; + +import { renderMarkdown } from "./render-markdown.ts"; + +it("should render raw text", () => { + const markdown = "This is a simple text without any markdown."; + + assert.equal(renderMarkdown(markdown), markdown); +}); + +it("should trim leading and trailing whitespace", () => { + const markdown = "\n\n \n\nThis is a text with leading and trailing spaces.\n \n "; + const expected = "This is a text with leading and trailing spaces."; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should render bold text", () => { + const markdown = "This is **bold** text."; + const expected = "This is bold text."; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should render links", () => { + const markdown = "This is a [link](https://example.com)."; + const expected = 'This is a link.'; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should render headings", () => { + const markdown = "# Heading 1\n## Heading 2\n### Heading 3"; + const expected = "Heading 1\nHeading 2\nHeading 3"; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should render bullet points", () => { + const markdown = "- Item 1\n- Item 2\n- Item 3"; + const expected = "• Item 1\n• Item 2\n• Item 3"; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should merge multiple empty lines", () => { + const markdown = "Line 1\n\n\n\nLine 2"; + const expected = "Line 1\n\nLine 2"; + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should render markdown to HTML", () => { + const markdown = ` +# [13.12.0](https://github.com/fullstacksjs/eslint-config/compare/v13.11.2...v13.12.0) (2025-12-16) + + +### Bug Fixes + +* **stylistic:** fix error in rule lines-between-class-members, add new rule ([411160c](https://github.com/fullstacksjs/eslint-config/commit/411160c7f900e6417f32c671d00e8e949ca3e445)) +* **stylistic:** fix padding-line-between-statements to be more general ([956e745](https://github.com/fullstacksjs/eslint-config/commit/956e745027bfc6025554e138d2b9084e3749178b)) +* **stylistic:** fix rule name padding-line-between-statements ([d509bec](https://github.com/fullstacksjs/eslint-config/commit/d509bec4c915c6df6a486f54b2a4c486fa40702c)) + + +### Features + +* **stylistic:** add new rules ([2283711](https://github.com/fullstacksjs/eslint-config/commit/228371129db44b128ef5e733b9f477983f4f4509)) +* **stylistic:** fix padding-line-between-statements and add new rule lines-between-class-members ([5c573f8](https://github.com/fullstacksjs/eslint-config/commit/5c573f89d522b9aeb4a11e654ccb224f93076094)) +`; + + const expected = ` +13.12.0 (2025-12-16) + +Bug Fixes + +• stylistic: fix error in rule lines-between-class-members, add new rule (411160c) +• stylistic: fix padding-line-between-statements to be more general (956e745) +• stylistic: fix rule name padding-line-between-statements (d509bec) + +Features + +• stylistic: add new rules (2283711) +• stylistic: fix padding-line-between-statements and add new rule lines-between-class-members (5c573f8) +`.trim(); + + assert.equal(renderMarkdown(markdown), expected); +}); + +it("should truncate output exceeding max characters", () => { + const longText = "1\n3\n5\n6\n8\n"; + const rendered = renderMarkdown(longText, 5); + const expected = "1\n3\n5\n..."; + + assert.equal(rendered, expected); +}); + +it("should keep the line when truncating output exceeding max characters", () => { + const longText = "First line\nSecond line\nThird line\nFourth line\nFifth line\n"; + const rendered = renderMarkdown(longText, 15); + const expected = "First line\nSecond line\n..."; + + assert.equal(rendered, expected); +}); + +it("should not add extra newline when truncating at the end of a line", () => { + const longText = ["Line one", "Line two", "Line three"].join("\n"); + const rendered = renderMarkdown(longText, longText.indexOf("Line two") + 1); + const expected = "Line one\nLine two\n..."; + + assert.equal(rendered, expected); +}); diff --git a/src/lib/telegram/render-markdown.ts b/src/lib/telegram/render-markdown.ts new file mode 100644 index 0000000..ff96bf8 --- /dev/null +++ b/src/lib/telegram/render-markdown.ts @@ -0,0 +1,52 @@ +import { crlfToLf, isNullOrEmptyString } from "@fullstacksjs/toolbox"; + +const boldPattern = /\*\*(.+?)\*\*/g; +const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g; + +function renderInline(content: string): string { + return content.replace(boldPattern, "$1").replace(linkPattern, '$1'); +} + +export function renderMarkdown(markdown: string, maxChars: number = Infinity): string { + const lines = crlfToLf(markdown) + .split("\n") + .map((l) => l.trim()); + + const rendered: string[] = []; + let lastWasEmpty = true; + + for (const line of lines) { + if (isNullOrEmptyString(line)) { + if (!lastWasEmpty) rendered.push(""); + lastWasEmpty = true; + continue; + } else { + lastWasEmpty = false; + } + + const headingMatch = line.match(/^#{1,6}\s+(\S.*)$/); + if (headingMatch) { + const content = headingMatch[1]; + rendered.push(`${renderInline(content.trim())}`); + continue; + } + + const listMatch = line.match(/^\s*[*-]\s+(\S.*)$/); + if (listMatch) { + rendered.push(`• ${renderInline(listMatch[1].trim())}`); + continue; + } + + rendered.push(renderInline(line)); + } + + let html = ""; + for (const line of rendered) { + if (html.length > maxChars) { + return `${html}...`; + } + html += `${line}\n`; + } + + return html.trim(); +}