From 795692ed74194de9c279b6944ccf9408334c0a3f Mon Sep 17 00:00:00 2001 From: evermake Date: Fri, 27 Mar 2026 15:04:54 +0500 Subject: [PATCH 1/4] rename parse & render modules --- src/index.ts | 4 ++-- src/{parse.ts => parse-entities.ts} | 0 src/{render.ts => render-html.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{parse.ts => parse-entities.ts} (100%) rename src/{render.ts => render-html.ts} (100%) diff --git a/src/index.ts b/src/index.ts index c8f32ea..55a5ba8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './jsx.ts' export { Line } from './Line.ts' -export * from './parse.ts' -export * from './render.ts' +export * from './parse-entities.ts' +export * from './render-html.ts' export * from './types.ts' diff --git a/src/parse.ts b/src/parse-entities.ts similarity index 100% rename from src/parse.ts rename to src/parse-entities.ts diff --git a/src/render.ts b/src/render-html.ts similarity index 100% rename from src/render.ts rename to src/render-html.ts From b26682a2764cddc06dc7a2a89cc37425a546aa60 Mon Sep 17 00:00:00 2001 From: evermake Date: Sat, 28 Mar 2026 00:17:37 +0500 Subject: [PATCH 2/4] rename tests, fix import --- test/{parse.test.tsx => parse-entities.test.tsx} | 0 test/{render.test.ts => render-html.test.ts} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/{parse.test.tsx => parse-entities.test.tsx} (100%) rename test/{render.test.ts => render-html.test.ts} (99%) diff --git a/test/parse.test.tsx b/test/parse-entities.test.tsx similarity index 100% rename from test/parse.test.tsx rename to test/parse-entities.test.tsx diff --git a/test/render.test.ts b/test/render-html.test.ts similarity index 99% rename from test/render.test.ts rename to test/render-html.test.ts index f30addd..1e9ace7 100644 --- a/test/render.test.ts +++ b/test/render-html.test.ts @@ -1,6 +1,6 @@ import type { TextEntity, TgxElement } from '../src/types.ts' import { describe, expect, it } from 'vitest' -import { renderHtml } from '../src/render.ts' +import { renderHtml } from '../src/index.ts' function plain(value: string | number | boolean | null | undefined): TgxElement { return { type: 'plain', value } From f37c351fac03c023e9e2a71013d74ba8adbadc38 Mon Sep 17 00:00:00 2001 From: evermake Date: Sat, 28 Mar 2026 01:17:48 +0500 Subject: [PATCH 3/4] implement --- eslint.config.js | 5 + package.json | 3 + pnpm-lock.yaml | 11 ++ src/index.ts | 2 + src/parse-tfm.ts | 157 +++++++++++++++++++++++ src/render-tfm.ts | 81 ++++++++++++ test/parse-tfm-cases/blockquote.md | 1 + test/parse-tfm-cases/blockquote.txt | 1 + test/parse-tfm-cases/code-block.md | 3 + test/parse-tfm-cases/code-block.txt | 1 + test/parse-tfm-cases/comprehensive.md | 27 ++++ test/parse-tfm-cases/comprehensive.txt | 25 ++++ test/parse-tfm-cases/custom-emoji.md | 1 + test/parse-tfm-cases/custom-emoji.txt | 1 + test/parse-tfm-cases/heading.md | 1 + test/parse-tfm-cases/heading.txt | 1 + test/parse-tfm-cases/horizontal-rule.md | 1 + test/parse-tfm-cases/horizontal-rule.txt | 1 + test/parse-tfm-cases/inline-styles.md | 1 + test/parse-tfm-cases/inline-styles.txt | 1 + test/parse-tfm-cases/links.md | 1 + test/parse-tfm-cases/links.txt | 1 + test/parse-tfm-cases/list-contentful.md | 3 + test/parse-tfm-cases/list-contentful.txt | 3 + test/parse-tfm-cases/list-ordered.md | 3 + test/parse-tfm-cases/list-ordered.txt | 3 + test/parse-tfm-cases/list-unordered.md | 3 + test/parse-tfm-cases/list-unordered.txt | 3 + test/parse-tfm-cases/spoiler.md | 1 + test/parse-tfm-cases/spoiler.txt | 1 + test/parse-tfm-cases/task-lists.md | 2 + test/parse-tfm-cases/task-lists.txt | 2 + test/parse-tfm-cases/time-no-format.md | 1 + test/parse-tfm-cases/time-no-format.txt | 1 + test/parse-tfm-cases/time.md | 1 + test/parse-tfm-cases/time.txt | 1 + test/parse-tfm-cases/underline.md | 1 + test/parse-tfm-cases/underline.txt | 1 + test/parse-tfm.test.tsx | 62 +++++++++ test/render-tfm-cases/blockquote.md | 7 + test/render-tfm-cases/blockquote.tsx | 7 + test/render-tfm-cases/code-block.md | 7 + test/render-tfm-cases/code-block.tsx | 7 + test/render-tfm-cases/escaping.md | 1 + test/render-tfm-cases/escaping.tsx | 1 + test/render-tfm-cases/inline-styles.md | 1 + test/render-tfm-cases/inline-styles.tsx | 5 + test/render-tfm-cases/links.md | 1 + test/render-tfm-cases/links.tsx | 5 + test/render-tfm-cases/nesting.md | 1 + test/render-tfm-cases/nesting.tsx | 5 + test/render-tfm-cases/time.md | 1 + test/render-tfm-cases/time.tsx | 7 + test/render-tfm.test.tsx | 29 +++++ 54 files changed, 504 insertions(+) create mode 100644 src/parse-tfm.ts create mode 100644 src/render-tfm.ts create mode 100644 test/parse-tfm-cases/blockquote.md create mode 100644 test/parse-tfm-cases/blockquote.txt create mode 100644 test/parse-tfm-cases/code-block.md create mode 100644 test/parse-tfm-cases/code-block.txt create mode 100644 test/parse-tfm-cases/comprehensive.md create mode 100644 test/parse-tfm-cases/comprehensive.txt create mode 100644 test/parse-tfm-cases/custom-emoji.md create mode 100644 test/parse-tfm-cases/custom-emoji.txt create mode 100644 test/parse-tfm-cases/heading.md create mode 100644 test/parse-tfm-cases/heading.txt create mode 100644 test/parse-tfm-cases/horizontal-rule.md create mode 100644 test/parse-tfm-cases/horizontal-rule.txt create mode 100644 test/parse-tfm-cases/inline-styles.md create mode 100644 test/parse-tfm-cases/inline-styles.txt create mode 100644 test/parse-tfm-cases/links.md create mode 100644 test/parse-tfm-cases/links.txt create mode 100644 test/parse-tfm-cases/list-contentful.md create mode 100644 test/parse-tfm-cases/list-contentful.txt create mode 100644 test/parse-tfm-cases/list-ordered.md create mode 100644 test/parse-tfm-cases/list-ordered.txt create mode 100644 test/parse-tfm-cases/list-unordered.md create mode 100644 test/parse-tfm-cases/list-unordered.txt create mode 100644 test/parse-tfm-cases/spoiler.md create mode 100644 test/parse-tfm-cases/spoiler.txt create mode 100644 test/parse-tfm-cases/task-lists.md create mode 100644 test/parse-tfm-cases/task-lists.txt create mode 100644 test/parse-tfm-cases/time-no-format.md create mode 100644 test/parse-tfm-cases/time-no-format.txt create mode 100644 test/parse-tfm-cases/time.md create mode 100644 test/parse-tfm-cases/time.txt create mode 100644 test/parse-tfm-cases/underline.md create mode 100644 test/parse-tfm-cases/underline.txt create mode 100644 test/parse-tfm.test.tsx create mode 100644 test/render-tfm-cases/blockquote.md create mode 100644 test/render-tfm-cases/blockquote.tsx create mode 100644 test/render-tfm-cases/code-block.md create mode 100644 test/render-tfm-cases/code-block.tsx create mode 100644 test/render-tfm-cases/escaping.md create mode 100644 test/render-tfm-cases/escaping.tsx create mode 100644 test/render-tfm-cases/inline-styles.md create mode 100644 test/render-tfm-cases/inline-styles.tsx create mode 100644 test/render-tfm-cases/links.md create mode 100644 test/render-tfm-cases/links.tsx create mode 100644 test/render-tfm-cases/nesting.md create mode 100644 test/render-tfm-cases/nesting.tsx create mode 100644 test/render-tfm-cases/time.md create mode 100644 test/render-tfm-cases/time.tsx create mode 100644 test/render-tfm.test.tsx diff --git a/eslint.config.js b/eslint.config.js index e089a0a..964eaeb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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/**', + ], }) diff --git a/package.json b/package.json index 6569166..182f954 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a30600a..681c99b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + marked: + specifier: ^17.0.5 + version: 17.0.5 devDependencies: '@antfu/eslint-config': specifier: 7.4.3 @@ -1396,6 +1400,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.5: + resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -3334,6 +3343,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.5: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 diff --git a/src/index.ts b/src/index.ts index 55a5ba8..9eb234d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export * from './jsx.ts' export { Line } from './Line.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' diff --git a/src/parse-tfm.ts b/src/parse-tfm.ts new file mode 100644 index 0000000..1e50fc3 --- /dev/null +++ b/src/parse-tfm.ts @@ -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): Array { + const result: Array = [] + 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 = `` + 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)) + 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 = [] + // 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 +} | 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 = {} + 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, + tokens: Array, +): 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}>.`) +} diff --git a/src/render-tfm.ts b/src/render-tfm.ts new file mode 100644 index 0000000..2c5e2ee --- /dev/null +++ b/src/render-tfm.ts @@ -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 `${renderTfm(el.subelements)}` + case 'strikethrough': return `~~${renderTfm(el.subelements)}~~` + case 'spoiler': return `${renderTfm(el.subelements)}` + 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 + ? `` + : `` + ) + 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 + ? `
\n${renderTfm(el.subelements)}\n
` + : 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, '\\$&') +} diff --git a/test/parse-tfm-cases/blockquote.md b/test/parse-tfm-cases/blockquote.md new file mode 100644 index 0000000..4c9922f --- /dev/null +++ b/test/parse-tfm-cases/blockquote.md @@ -0,0 +1 @@ +> quoted diff --git a/test/parse-tfm-cases/blockquote.txt b/test/parse-tfm-cases/blockquote.txt new file mode 100644 index 0000000..2a7d675 --- /dev/null +++ b/test/parse-tfm-cases/blockquote.txt @@ -0,0 +1 @@ +
quoted
diff --git a/test/parse-tfm-cases/code-block.md b/test/parse-tfm-cases/code-block.md new file mode 100644 index 0000000..6f01964 --- /dev/null +++ b/test/parse-tfm-cases/code-block.md @@ -0,0 +1,3 @@ +```js +const x = 1 +``` diff --git a/test/parse-tfm-cases/code-block.txt b/test/parse-tfm-cases/code-block.txt new file mode 100644 index 0000000..8e2ef43 --- /dev/null +++ b/test/parse-tfm-cases/code-block.txt @@ -0,0 +1 @@ +
const x = 1
diff --git a/test/parse-tfm-cases/comprehensive.md b/test/parse-tfm-cases/comprehensive.md new file mode 100644 index 0000000..6f0b616 --- /dev/null +++ b/test/parse-tfm-cases/comprehensive.md @@ -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. diff --git a/test/parse-tfm-cases/comprehensive.txt b/test/parse-tfm-cases/comprehensive.txt new file mode 100644 index 0000000..f7d578b --- /dev/null +++ b/test/parse-tfm-cases/comprehensive.txt @@ -0,0 +1,25 @@ +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 and Anthropic APIs + +Migration Checklist + +☑ Update environment variables +☑ Run database migrations +☐ Test webhook endpoints +☐ Deploy to production + +————————— + +Code Example + +
const client = new GromClient({ apiKey: process.env.API_KEY })
+await client.connect()
+ +
Note: Make sure to handle connection errors gracefully in production environments.
diff --git a/test/parse-tfm-cases/custom-emoji.md b/test/parse-tfm-cases/custom-emoji.md new file mode 100644 index 0000000..39c0a97 --- /dev/null +++ b/test/parse-tfm-cases/custom-emoji.md @@ -0,0 +1 @@ +Reactions welcome: ![❤️](5368324170671202286) diff --git a/test/parse-tfm-cases/custom-emoji.txt b/test/parse-tfm-cases/custom-emoji.txt new file mode 100644 index 0000000..c6fe9eb --- /dev/null +++ b/test/parse-tfm-cases/custom-emoji.txt @@ -0,0 +1 @@ +Reactions welcome: ❤️ diff --git a/test/parse-tfm-cases/heading.md b/test/parse-tfm-cases/heading.md new file mode 100644 index 0000000..fec5601 --- /dev/null +++ b/test/parse-tfm-cases/heading.md @@ -0,0 +1 @@ +# Hello diff --git a/test/parse-tfm-cases/heading.txt b/test/parse-tfm-cases/heading.txt new file mode 100644 index 0000000..9db1c64 --- /dev/null +++ b/test/parse-tfm-cases/heading.txt @@ -0,0 +1 @@ +Hello diff --git a/test/parse-tfm-cases/horizontal-rule.md b/test/parse-tfm-cases/horizontal-rule.md new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/test/parse-tfm-cases/horizontal-rule.md @@ -0,0 +1 @@ +--- diff --git a/test/parse-tfm-cases/horizontal-rule.txt b/test/parse-tfm-cases/horizontal-rule.txt new file mode 100644 index 0000000..35c1d7e --- /dev/null +++ b/test/parse-tfm-cases/horizontal-rule.txt @@ -0,0 +1 @@ +————————— diff --git a/test/parse-tfm-cases/inline-styles.md b/test/parse-tfm-cases/inline-styles.md new file mode 100644 index 0000000..30d0a4f --- /dev/null +++ b/test/parse-tfm-cases/inline-styles.md @@ -0,0 +1 @@ +**bold** _italic_ *italic too* ~~strike~~ `code` diff --git a/test/parse-tfm-cases/inline-styles.txt b/test/parse-tfm-cases/inline-styles.txt new file mode 100644 index 0000000..98449eb --- /dev/null +++ b/test/parse-tfm-cases/inline-styles.txt @@ -0,0 +1 @@ +bold italic italic too strike code diff --git a/test/parse-tfm-cases/links.md b/test/parse-tfm-cases/links.md new file mode 100644 index 0000000..1a28e82 --- /dev/null +++ b/test/parse-tfm-cases/links.md @@ -0,0 +1 @@ +[site](https://example.com) diff --git a/test/parse-tfm-cases/links.txt b/test/parse-tfm-cases/links.txt new file mode 100644 index 0000000..1cf8e7b --- /dev/null +++ b/test/parse-tfm-cases/links.txt @@ -0,0 +1 @@ +site diff --git a/test/parse-tfm-cases/list-contentful.md b/test/parse-tfm-cases/list-contentful.md new file mode 100644 index 0000000..fe59a80 --- /dev/null +++ b/test/parse-tfm-cases/list-contentful.md @@ -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) diff --git a/test/parse-tfm-cases/list-contentful.txt b/test/parse-tfm-cases/list-contentful.txt new file mode 100644 index 0000000..a7e7e74 --- /dev/null +++ b/test/parse-tfm-cases/list-contentful.txt @@ -0,0 +1,3 @@ +• Quantum computing leverages superposition and entanglement to solve complex problems exponentially faster than classical computers +• The development of CRISPR-Cas9 technology has revolutionized genetic engineering, enabling precise DNA 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 diff --git a/test/parse-tfm-cases/list-ordered.md b/test/parse-tfm-cases/list-ordered.md new file mode 100644 index 0000000..0bdb799 --- /dev/null +++ b/test/parse-tfm-cases/list-ordered.md @@ -0,0 +1,3 @@ +1. one +2. two +3. три diff --git a/test/parse-tfm-cases/list-ordered.txt b/test/parse-tfm-cases/list-ordered.txt new file mode 100644 index 0000000..0bdb799 --- /dev/null +++ b/test/parse-tfm-cases/list-ordered.txt @@ -0,0 +1,3 @@ +1. one +2. two +3. три diff --git a/test/parse-tfm-cases/list-unordered.md b/test/parse-tfm-cases/list-unordered.md new file mode 100644 index 0000000..d758d32 --- /dev/null +++ b/test/parse-tfm-cases/list-unordered.md @@ -0,0 +1,3 @@ +- one +- two +- три diff --git a/test/parse-tfm-cases/list-unordered.txt b/test/parse-tfm-cases/list-unordered.txt new file mode 100644 index 0000000..49863a1 --- /dev/null +++ b/test/parse-tfm-cases/list-unordered.txt @@ -0,0 +1,3 @@ +• one +• two +• три diff --git a/test/parse-tfm-cases/spoiler.md b/test/parse-tfm-cases/spoiler.md new file mode 100644 index 0000000..b985902 --- /dev/null +++ b/test/parse-tfm-cases/spoiler.md @@ -0,0 +1 @@ +Your password is: unkonwn. diff --git a/test/parse-tfm-cases/spoiler.txt b/test/parse-tfm-cases/spoiler.txt new file mode 100644 index 0000000..8a918e3 --- /dev/null +++ b/test/parse-tfm-cases/spoiler.txt @@ -0,0 +1 @@ +Your password is: unkonwn. diff --git a/test/parse-tfm-cases/task-lists.md b/test/parse-tfm-cases/task-lists.md new file mode 100644 index 0000000..36bd598 --- /dev/null +++ b/test/parse-tfm-cases/task-lists.md @@ -0,0 +1,2 @@ +- [x] done +- [ ] todo diff --git a/test/parse-tfm-cases/task-lists.txt b/test/parse-tfm-cases/task-lists.txt new file mode 100644 index 0000000..7654c26 --- /dev/null +++ b/test/parse-tfm-cases/task-lists.txt @@ -0,0 +1,2 @@ +☑ done +☐ todo diff --git a/test/parse-tfm-cases/time-no-format.md b/test/parse-tfm-cases/time-no-format.md new file mode 100644 index 0000000..78081e6 --- /dev/null +++ b/test/parse-tfm-cases/time-no-format.md @@ -0,0 +1 @@ +Event at . diff --git a/test/parse-tfm-cases/time-no-format.txt b/test/parse-tfm-cases/time-no-format.txt new file mode 100644 index 0000000..8c828e7 --- /dev/null +++ b/test/parse-tfm-cases/time-no-format.txt @@ -0,0 +1 @@ +Event at Wed, Mar 27. diff --git a/test/parse-tfm-cases/time.md b/test/parse-tfm-cases/time.md new file mode 100644 index 0000000..cdccd90 --- /dev/null +++ b/test/parse-tfm-cases/time.md @@ -0,0 +1 @@ +Next sync: diff --git a/test/parse-tfm-cases/time.txt b/test/parse-tfm-cases/time.txt new file mode 100644 index 0000000..16027c6 --- /dev/null +++ b/test/parse-tfm-cases/time.txt @@ -0,0 +1 @@ +Next sync: Wed, Mar 27 diff --git a/test/parse-tfm-cases/underline.md b/test/parse-tfm-cases/underline.md new file mode 100644 index 0000000..5652625 --- /dev/null +++ b/test/parse-tfm-cases/underline.md @@ -0,0 +1 @@ +This is some sentence to showcase underline formatting inside Markdown. diff --git a/test/parse-tfm-cases/underline.txt b/test/parse-tfm-cases/underline.txt new file mode 100644 index 0000000..5652625 --- /dev/null +++ b/test/parse-tfm-cases/underline.txt @@ -0,0 +1 @@ +This is some sentence to showcase underline formatting inside Markdown. diff --git a/test/parse-tfm.test.tsx b/test/parse-tfm.test.tsx new file mode 100644 index 0000000..2422ec9 --- /dev/null +++ b/test/parse-tfm.test.tsx @@ -0,0 +1,62 @@ +import type { TgxElement } from '../src/index.ts' +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { parseTfm, renderHtml } from '../src/index.ts' + +describe('parseTfm', async () => { + const BASIC_CASES: Array<{ + name: string + tfm: string + expected: TgxElement + }> = [ + { + name: 'plain text', + tfm: 'Hello, freedom!', + expected: <>Hello, freedom!, + }, + { + name: 'bold', + tfm: 'this is **bold** text', + expected: <>this is bold text, + }, + { + name: 'italic', + tfm: 'this is _italic_ text', + expected: <>this is italic text, + }, + { + name: 'strikethrough', + tfm: 'this is ~~strikethrough~~ text', + expected: <>this is strikethrough text, + }, + ] + + it.each(BASIC_CASES)('should parse $name', ({ tfm, expected }) => { + const result = parseTfm(tfm) + expect(result).toStrictEqual(expected) + }) + + const HTML_CASES_DIR = path.resolve(__dirname, 'parse-tfm-cases') + const HTML_CASE_NAMES = await fs + .readdir(HTML_CASES_DIR) + .then((files) => + files + .filter((f) => f.endsWith('.md')) + .map((f) => f.slice(0, -3)) + .sort(), + ) + const HTML_CASES = await Promise.all( + HTML_CASE_NAMES.map(async (name) => ({ + name, + tfm: await fs.readFile(path.join(HTML_CASES_DIR, `${name}.md`), 'utf8'), + txt: await fs.readFile(path.join(HTML_CASES_DIR, `${name}.txt`), 'utf8'), + })), + ) + + it.each(HTML_CASES)('should parse and render HTML correctly for $name', ({ tfm, txt }) => { + const tgx = parseTfm(tfm.trim()) + const result = renderHtml(tgx) + expect(result.trim()).toBe(txt.trim()) + }) +}) diff --git a/test/render-tfm-cases/blockquote.md b/test/render-tfm-cases/blockquote.md new file mode 100644 index 0000000..be6ea93 --- /dev/null +++ b/test/render-tfm-cases/blockquote.md @@ -0,0 +1,7 @@ +> regular quote +> on multiple +> lines + +
+expandable quote +
diff --git a/test/render-tfm-cases/blockquote.tsx b/test/render-tfm-cases/blockquote.tsx new file mode 100644 index 0000000..e11d4b2 --- /dev/null +++ b/test/render-tfm-cases/blockquote.tsx @@ -0,0 +1,7 @@ +export default ( + <> +
{'regular quote\non multiple\nlines'}
+ {'\n\n'} +
expandable quote
+ +) diff --git a/test/render-tfm-cases/code-block.md b/test/render-tfm-cases/code-block.md new file mode 100644 index 0000000..816c5df --- /dev/null +++ b/test/render-tfm-cases/code-block.md @@ -0,0 +1,7 @@ +```typescript +const x: number = 1 +``` + +``` +no language here +``` diff --git a/test/render-tfm-cases/code-block.tsx b/test/render-tfm-cases/code-block.tsx new file mode 100644 index 0000000..1d710db --- /dev/null +++ b/test/render-tfm-cases/code-block.tsx @@ -0,0 +1,7 @@ +export default ( + <> + const x: number = 1 + {'\n\n'} + no language here + +) diff --git a/test/render-tfm-cases/escaping.md b/test/render-tfm-cases/escaping.md new file mode 100644 index 0000000..fd4b3dc --- /dev/null +++ b/test/render-tfm-cases/escaping.md @@ -0,0 +1 @@ +Price: \$10 \| \*emphasis\* \| \[link text\] \| \~strikethrough\~ diff --git a/test/render-tfm-cases/escaping.tsx b/test/render-tfm-cases/escaping.tsx new file mode 100644 index 0000000..40cecf4 --- /dev/null +++ b/test/render-tfm-cases/escaping.tsx @@ -0,0 +1 @@ +export default <>Price: $10 | *emphasis* | [link text] | ~strikethrough~ diff --git a/test/render-tfm-cases/inline-styles.md b/test/render-tfm-cases/inline-styles.md new file mode 100644 index 0000000..8429216 --- /dev/null +++ b/test/render-tfm-cases/inline-styles.md @@ -0,0 +1 @@ +**bold** _italic_ ~~strike~~ underline hidden `inline code` diff --git a/test/render-tfm-cases/inline-styles.tsx b/test/render-tfm-cases/inline-styles.tsx new file mode 100644 index 0000000..57d5479 --- /dev/null +++ b/test/render-tfm-cases/inline-styles.tsx @@ -0,0 +1,5 @@ +export default ( + <> + bold{' '}italic{' '}strike{' '}underline{' '}hidden{' '}inline code + +) diff --git a/test/render-tfm-cases/links.md b/test/render-tfm-cases/links.md new file mode 100644 index 0000000..cf0a460 --- /dev/null +++ b/test/render-tfm-cases/links.md @@ -0,0 +1 @@ +[click here](https://example.com) and ![❤️](5368324170671202286) diff --git a/test/render-tfm-cases/links.tsx b/test/render-tfm-cases/links.tsx new file mode 100644 index 0000000..188882e --- /dev/null +++ b/test/render-tfm-cases/links.tsx @@ -0,0 +1,5 @@ +export default ( + <> + click here{' '}and{' '} + +) diff --git a/test/render-tfm-cases/nesting.md b/test/render-tfm-cases/nesting.md new file mode 100644 index 0000000..6b31407 --- /dev/null +++ b/test/render-tfm-cases/nesting.md @@ -0,0 +1 @@ +**_bold italic_** ~~strike underline~~ diff --git a/test/render-tfm-cases/nesting.tsx b/test/render-tfm-cases/nesting.tsx new file mode 100644 index 0000000..ab8c0d8 --- /dev/null +++ b/test/render-tfm-cases/nesting.tsx @@ -0,0 +1,5 @@ +export default ( + <> + bold italic{' '}strike underline + +) diff --git a/test/render-tfm-cases/time.md b/test/render-tfm-cases/time.md new file mode 100644 index 0000000..27a43da --- /dev/null +++ b/test/render-tfm-cases/time.md @@ -0,0 +1 @@ + diff --git a/test/render-tfm-cases/time.tsx b/test/render-tfm-cases/time.tsx new file mode 100644 index 0000000..fc29e79 --- /dev/null +++ b/test/render-tfm-cases/time.tsx @@ -0,0 +1,7 @@ +export default ( + <> + + {' '} + + +) diff --git a/test/render-tfm.test.tsx b/test/render-tfm.test.tsx new file mode 100644 index 0000000..b60b4f6 --- /dev/null +++ b/test/render-tfm.test.tsx @@ -0,0 +1,29 @@ +import type { TgxElement } from '../src/index.ts' +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { renderTfm } from '../src/index.ts' + +describe('renderTfm', async () => { + const CASES_DIR = path.resolve(__dirname, 'render-tfm-cases') + const CASE_NAMES = await fs + .readdir(CASES_DIR) + .then((files) => + files + .filter((f) => f.endsWith('.md')) + .map((f) => f.slice(0, -3)) + .sort(), + ) + const CASES = await Promise.all( + CASE_NAMES.map(async (name) => ({ + name, + tgx: ((await import(`./render-tfm-cases/${name}.tsx`)) as { default: TgxElement }).default, + md: await fs.readFile(path.join(CASES_DIR, `${name}.md`), 'utf8'), + })), + ) + + it.each(CASES)('should render $name', ({ tgx, md }) => { + const result = renderTfm(tgx) + expect(result.trim()).toBe(md.trim()) + }) +}) From df4e15fa10bb21f70e165b846f5102d56cff351e Mon Sep 17 00:00:00 2001 From: evermake Date: Sat, 28 Mar 2026 01:20:01 +0500 Subject: [PATCH 4/4] update pnpm-lock.yaml --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 681c99b..65fca08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: dependencies: marked: - specifier: ^17.0.5 + specifier: 17.0.5 version: 17.0.5 devDependencies: '@antfu/eslint-config':