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();
+}