diff --git a/src/format.ts b/src/format.ts
new file mode 100644
index 0000000..4cb7b6d
--- /dev/null
+++ b/src/format.ts
@@ -0,0 +1,49 @@
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+function inlineToHtml(text: string): string {
+ let html = escapeHtml(text)
+ html = html.replace(/`([^`\n]+)`/g, '$1')
+ html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1')
+ return html
+}
+
+export function markdownToHtml(text: string): string {
+ const parts = text.split(/```[\w-]*/)
+ if (parts.length % 2 === 0) return inlineToHtml(text)
+ let html = ''
+ for (let i = 0; i < parts.length; i++) {
+ if (i % 2 === 1) {
+ const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
+ html += `
${escapeHtml(code)}`
+ } else {
+ html += inlineToHtml(parts[i]!)
+ }
+ }
+ return html
+}
+
+export function splitMessage(text: string, maxLength: number): string[] {
+ if (maxLength < 1) throw new Error('maxLength must be >= 1')
+ if (text.length <= maxLength) return [text]
+ const chunks: string[] = []
+ let rest = text
+ while (rest.length > maxLength) {
+ const window = rest.slice(0, maxLength)
+ const newline = window.lastIndexOf('\n')
+ const ideographic = window.lastIndexOf('。')
+ const sentence = window.lastIndexOf('. ')
+ const breakAt = Math.max(newline, ideographic, sentence)
+ const cut = breakAt > 0 ? (breakAt === sentence ? breakAt + 2 : breakAt + 1) : maxLength
+ chunks.push(rest.slice(0, cut))
+ rest = rest.slice(cut)
+ }
+ chunks.push(rest)
+ return chunks
+}
diff --git a/tests/format.test.ts b/tests/format.test.ts
new file mode 100644
index 0000000..1448ac5
--- /dev/null
+++ b/tests/format.test.ts
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { escapeHtml, markdownToHtml, splitMessage } from '../src/format.ts'
+
+test('escapeHtml escapes five special chars', () => {
+ assert.equal(escapeHtml(`&<>"'`), '&<>"'')
+})
+
+test('markdownToHtml converts fence bold and inline code', () => {
+ const html = markdownToHtml('hi **x** and `y`\n```\nz\n```')
+ assert.match(html, /x<\/b>/)
+ assert.match(html, /y<\/code>/)
+ assert.match(html, /z<\/pre>/)
+})
+
+test('markdownToHtml keeps unbalanced fence without pre', () => {
+ const html = markdownToHtml('start ``` not closed')
+ assert.equal(html.includes(''), false)
+ assert.match(html, /start/)
+})
+
+test('splitMessage prefers newline within window', () => {
+ const text = 'aaaa\nbbbb\ncccc'
+ assert.deepEqual(splitMessage(text, 6), ['aaaa\n', 'bbbb\n', 'cccc'])
+})
+
+test('splitMessage hard-cuts when no break', () => {
+ assert.deepEqual(splitMessage('abcdefghij', 4), ['abcd', 'efgh', 'ij'])
+})