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: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ export default antfu({
rules: {
'ts/no-empty-object-type': 'off',
'ts/no-namespace': 'off',
'unicorn/prefer-type-error': 'off',
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'style/arrow-parens': ['error', 'always'],
'curly': ['error', 'all'],
},
ignores: [
'test/parse-tfm-cases/**',
'test/render-tfm-cases/**',
],
})
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
"deps": "taze --interactive --write --include-locked",
"release": "bumpp"
},
"dependencies": {
"marked": "17.0.5"
},
"devDependencies": {
"@antfu/eslint-config": "7.4.3",
"@types/node": "22.19.11",
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './jsx.ts'
export { Line } from './Line.ts'
export * from './parse.ts'
export * from './render.ts'
export * from './parse-entities.ts'
export * from './parse-tfm.ts'
export * from './render-html.ts'
export * from './render-tfm.ts'
export * from './types.ts'
File renamed without changes.
157 changes: 157 additions & 0 deletions src/parse-tfm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { MarkedToken, Token } from 'marked'
import type { IntrinsicElements, TgxElement, TgxNode } from './types.ts'
import { lexer } from 'marked'
import { createElement, Fragment } from './jsx.ts'

/**
* Parses [Telegram-flavored Markdown](https://github.com/grom-dev/tfm) to
* {@link TgxElement}.
*/
export function parseTfm(tfm: string): TgxElement {
const tokens = lexer(tfm, {
async: false,
// GFM is required for strikethrough, task lists, etc.
gfm: true,
pedantic: false,
silent: false,
})
return Fragment({ children: renderTokens(tokens) })
}

const BLOCK_TOKEN_TYPES = new Set(['heading', 'paragraph', 'list', 'code', 'blockquote', 'hr'])

function renderTokens(tokens: Array<Token>): Array<TgxNode> {
const result: Array<TgxNode> = []
let seenBlock = false
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]!
if (token.type === 'html') {
const open = parseHtmlOpenTag(token.text)
if (!open) {
continue
}
const closeTag = `</${open.tag}>`
let j
for (j = i + 1; j < tokens.length; j++) {
const t = tokens[j]!
if (t.type === 'html' && t.text === closeTag) {
break
}
}
if (j < tokens.length) {
const innerTokens = tokens.slice(i + 1, j)
result.push(renderHtml(open.tag, open.attrs, innerTokens as Array<MarkedToken>))
i = j
}
continue
}
if (BLOCK_TOKEN_TYPES.has(token.type)) {
if (seenBlock) {
result.push('\n\n')
} else {
seenBlock = true
}
}
result.push(renderToken(token as MarkedToken))
}
return result
}

function renderToken(token: MarkedToken): TgxNode {
switch (token.type) {
case 'heading':
case 'strong':
return createElement('b', {}, renderTokens(token.tokens))
case 'em':
return createElement('i', {}, renderTokens(token.tokens))
case 'del':
return createElement('s', {}, renderTokens(token.tokens))
case 'code':
return createElement('codeblock', { lang: token.lang }, token.text)
case 'codespan':
return createElement('code', {}, token.text)
case 'link':
return createElement('a', { href: token.href }, renderTokens(token.tokens))
case 'text':
case 'paragraph':
case 'list_item':
return token.tokens ? renderTokens(token.tokens) : token.text
case 'br':
return '\n'
case 'hr':
return '—————————'
case 'escape':
return token.text
case 'blockquote':
return createElement('blockquote', {}, renderTokens(token.tokens))
case 'image':
return createElement('emoji', { alt: token.text, id: token.href }, null)
case 'list': {
const nodes: Array<TgxNode> = []
// TODO: handle loose lists (token.loose)
token.items.forEach((item, i) => {
nodes.push(
item.task
? '' // task items are handled by the checkbox token
: (token.ordered ? `${i + 1}. ` : '• '),
)
nodes.push(renderTokens(item.tokens))
if (i < token.items.length - 1) {
nodes.push('\n')
}
return nodes
})
return nodes
}
case 'checkbox':
return token.checked ? '☑ ' : '☐ '
case 'space':
return ''
}
throw new Error(`Unexpected token of type "${token.type}".`)
}

const HTML_OPEN_TAG_RE = /^<([a-z][a-z\d]*)(\s[^>]*)?>$/i
const HTML_ATTR_RE = /([a-z][a-z\d-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi

function parseHtmlOpenTag(s: string): {
tag: string
attrs: Record<string, string | undefined>
} | null {
const m = s.trim().match(HTML_OPEN_TAG_RE)
if (!m) {
return null
}
const tag = m[1]!.toLowerCase()
const attrsStr = m[2] ?? ''
const attrs: Record<string, string | undefined> = {}
for (const match of attrsStr.matchAll(HTML_ATTR_RE)) {
attrs[match[1]!] = match[2] ?? match[3] ?? match[4]
}
return { tag, attrs }
}

function renderHtml(
tag: string,
attrs: Record<string, string | undefined>,
tokens: Array<MarkedToken>,
): TgxNode {
switch (tag) {
case 'u':
return createElement('u', {}, renderTokens(tokens))
case 'spoiler':
return createElement('spoiler', {}, renderTokens(tokens))
case 'blockquote':
return createElement('blockquote', { expandable: 'expandable' in attrs }, renderTokens(tokens))
case 'time':
return createElement(
'time',
{
unix: Number.parseInt(attrs.unix ?? ''),
format: attrs.format as IntrinsicElements['time']['format'],
},
renderTokens(tokens),
)
}
throw new Error(`Unsupported HTML tag <${tag}>.`)
}
File renamed without changes.
81 changes: 81 additions & 0 deletions src/render-tfm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { TgxElement, TgxElementPlain, TgxElementText } from './types.ts'

const SPECIAL_CHARS_RE = /[\\*~`$[\]<>{}|^]/g

/**
* Converts {@link TgxElement} to a string formatted as
* [Telegram-Flavored Markdown](https://github.com/grom-dev/tfm).
*/
export function renderTfm(tgx: TgxElement | TgxElement[]): string {
return (Array.isArray(tgx) ? tgx : [tgx])
.map((el) => {
switch (el.type) {
case 'text': return renderTextElement(el)
case 'plain': return renderPlainElement(el)
case 'fragment': return renderTfm(el.subelements)
}
throw new Error(`Unknown element: ${el satisfies never}.`)
})
.join('')
}

function renderTextElement(el: TgxElementText): string {
switch (el.entity.type) {
case 'bold': return `**${renderTfm(el.subelements)}**`
case 'italic': return `_${renderTfm(el.subelements)}_`
case 'underline': return `<u>${renderTfm(el.subelements)}</u>`
case 'strikethrough': return `~~${renderTfm(el.subelements)}~~`
case 'spoiler': return `<spoiler>${renderTfm(el.subelements)}</spoiler>`
case 'link': return `[${renderTfm(el.subelements)}](${el.entity.url})`
case 'custom-emoji': return `![${el.entity.alt}](${el.entity.id})`
case 'date-time': return (
el.entity.format
? `<time unix="${el.entity.unix}" format="${el.entity.format}">${renderTfm(el.subelements)}</time>`
: `<time unix="${el.entity.unix}">${renderTfm(el.subelements)}</time>`
)
case 'code': return `\`${renderLiteral(el.subelements)}\``
case 'codeblock': return (
el.entity.language
? `\`\`\`${el.entity.language}\n${renderLiteral(el.subelements)}\n\`\`\``
: `\`\`\`\n${renderLiteral(el.subelements)}\n\`\`\``
)
case 'blockquote': return (
el.entity.expandable
? `<blockquote expandable>\n${renderTfm(el.subelements)}\n</blockquote>`
: renderTfm(el.subelements).split('\n').map((line) => `> ${line}`).join('\n')
)
}
}

/**
* Renders subelements as literal text (no TFM escaping), for use inside
* code spans and code blocks where content is always literal.
*/
function renderLiteral(subelements: TgxElement[]): string {
return subelements
.map((el) => {
switch (el.type) {
case 'plain': {
if (el.value == null || typeof el.value === 'boolean') {
return ''
}
return String(el.value)
}
case 'fragment': return renderLiteral(el.subelements)
case 'text': return renderLiteral(el.subelements)
}
throw new Error(`Unknown element: ${el satisfies never}.`)
})
.join('')
}

function renderPlainElement({ value }: TgxElementPlain): string {
if (value == null || typeof value === 'boolean') {
return ''
}
return escape(String(value))
}

function escape(text: string): string {
return text.replace(SPECIAL_CHARS_RE, '\\$&')
}
File renamed without changes.
1 change: 1 addition & 0 deletions test/parse-tfm-cases/blockquote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
> quoted
1 change: 1 addition & 0 deletions test/parse-tfm-cases/blockquote.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<blockquote>quoted</blockquote>
3 changes: 3 additions & 0 deletions test/parse-tfm-cases/code-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```js
const x = 1
```
1 change: 1 addition & 0 deletions test/parse-tfm-cases/code-block.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<pre><code class="language-js">const x = 1</code></pre>
27 changes: 27 additions & 0 deletions test/parse-tfm-cases/comprehensive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Release Notes v2.4.0

We're excited to announce **Grom 2.4.0** with _major performance improvements_ and new features!

## What's New

- **Real-time sync** now uses `WebSocket` connections instead of polling
- Added support for ~~legacy API~~ modern `REST` endpoints
- Integration with [OpenAI](https://openai.com) and [Anthropic](https://anthropic.com) APIs

## Migration Checklist

- [x] Update environment variables
- [x] Run database migrations
- [ ] Test webhook endpoints
- [ ] Deploy to production

---

## Code Example

```typescript
const client = new GromClient({ apiKey: process.env.API_KEY })
await client.connect()
```

> **Note:** Make sure to handle connection errors gracefully in production environments.
25 changes: 25 additions & 0 deletions test/parse-tfm-cases/comprehensive.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<b>Release Notes v2.4.0</b>

We're excited to announce <b>Grom 2.4.0</b> with <i>major performance improvements</i> and new features!

<b>What's New</b>

• <b>Real-time sync</b> now uses <code>WebSocket</code> connections instead of polling
• Added support for <s>legacy API</s> modern <code>REST</code> endpoints
• Integration with <a href="https://openai.com">OpenAI</a> and <a href="https://anthropic.com">Anthropic</a> APIs

<b>Migration Checklist</b>

☑ Update environment variables
☑ Run database migrations
☐ Test webhook endpoints
☐ Deploy to production

—————————

<b>Code Example</b>

<pre><code class="language-typescript">const client = new GromClient({ apiKey: process.env.API_KEY })
await client.connect()</code></pre>

<blockquote><b>Note:</b> Make sure to handle connection errors gracefully in production environments.</blockquote>
1 change: 1 addition & 0 deletions test/parse-tfm-cases/custom-emoji.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reactions welcome: ![❤️](5368324170671202286)
1 change: 1 addition & 0 deletions test/parse-tfm-cases/custom-emoji.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reactions welcome: <tg-emoji emoji-id="5368324170671202286">❤️</tg-emoji>
1 change: 1 addition & 0 deletions test/parse-tfm-cases/heading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Hello
1 change: 1 addition & 0 deletions test/parse-tfm-cases/heading.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<b>Hello</b>
1 change: 1 addition & 0 deletions test/parse-tfm-cases/horizontal-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
---
1 change: 1 addition & 0 deletions test/parse-tfm-cases/horizontal-rule.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
—————————
1 change: 1 addition & 0 deletions test/parse-tfm-cases/inline-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**bold** _italic_ *italic too* ~~strike~~ `code`
1 change: 1 addition & 0 deletions test/parse-tfm-cases/inline-styles.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<b>bold</b> <i>italic</i> <i>italic too</i> <s>strike</s> <code>code</code>
1 change: 1 addition & 0 deletions test/parse-tfm-cases/links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[site](https://example.com)
1 change: 1 addition & 0 deletions test/parse-tfm-cases/links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a href="https://example.com">site</a>
3 changes: 3 additions & 0 deletions test/parse-tfm-cases/list-contentful.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **Quantum computing** leverages _superposition_ and `entanglement` to solve complex problems exponentially faster than [classical computers](https://en.wikipedia.org/wiki/Classical_computer)
- The development of **CRISPR-Cas9** technology has revolutionized `genetic engineering`, enabling precise [DNA editing](https://www.genome.gov/about-genomics/policy-issues/Genome-Editing) with unprecedented accuracy
- **Renewable energy sources** like `solar` and `wind` power are becoming increasingly cost-effective, driving the global transition away from [fossil fuels](https://www.iea.org/topics/fossil-fuels)
3 changes: 3 additions & 0 deletions test/parse-tfm-cases/list-contentful.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• <b>Quantum computing</b> leverages <i>superposition</i> and <code>entanglement</code> to solve complex problems exponentially faster than <a href="https://en.wikipedia.org/wiki/Classical_computer">classical computers</a>
• The development of <b>CRISPR-Cas9</b> technology has revolutionized <code>genetic engineering</code>, enabling precise <a href="https://www.genome.gov/about-genomics/policy-issues/Genome-Editing">DNA editing</a> with unprecedented accuracy
• <b>Renewable energy sources</b> like <code>solar</code> and <code>wind</code> power are becoming increasingly cost-effective, driving the global transition away from <a href="https://www.iea.org/topics/fossil-fuels">fossil fuels</a>
Loading
Loading