Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/lib/github/webhooks/handlers/release-created.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 } },
Expand Down
1 change: 1 addition & 0 deletions src/lib/telegram/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./CommandParser.ts";
export * from "./createCommand.ts";
export * from "./render-markdown.ts";
export * as zs from "./schemas.ts";
112 changes: 112 additions & 0 deletions src/lib/telegram/render-markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <b>bold</b> text.";

assert.equal(renderMarkdown(markdown), expected);
});

it("should render links", () => {
const markdown = "This is a [link](https://example.com).";
const expected = 'This is a <a href="https://example.com">link</a>.';

assert.equal(renderMarkdown(markdown), expected);
});

it("should render headings", () => {
const markdown = "# Heading 1\n## Heading 2\n### Heading 3";
const expected = "<b>Heading 1</b>\n<b>Heading 2</b>\n<b>Heading 3</b>";

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 = `
<b><a href="https://github.com/fullstacksjs/eslint-config/compare/v13.11.2...v13.12.0">13.12.0</a> (2025-12-16)</b>

<b>Bug Fixes</b>

• <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>)
• <b>stylistic:</b> fix padding-line-between-statements to be more general (<a href="https://github.com/fullstacksjs/eslint-config/commit/956e745027bfc6025554e138d2b9084e3749178b">956e745</a>)
• <b>stylistic:</b> fix rule name padding-line-between-statements (<a href="https://github.com/fullstacksjs/eslint-config/commit/d509bec4c915c6df6a486f54b2a4c486fa40702c">d509bec</a>)

<b>Features</b>

• <b>stylistic:</b> add new rules (<a href="https://github.com/fullstacksjs/eslint-config/commit/228371129db44b128ef5e733b9f477983f4f4509">2283711</a>)
• <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>)
`.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);
});
52 changes: 52 additions & 0 deletions src/lib/telegram/render-markdown.ts
Original file line number Diff line number Diff line change
@@ -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, "<b>$1</b>").replace(linkPattern, '<a href="$2">$1</a>');
}

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(`<b>${renderInline(content.trim())}</b>`);
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();
}