@yaebal/rich 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +104 -30
  2. package/lib/blocks.d.ts +93 -32
  3. package/lib/blocks.d.ts.map +1 -1
  4. package/lib/blocks.js +227 -71
  5. package/lib/blocks.js.map +1 -1
  6. package/lib/blocks.test.d.ts +2 -0
  7. package/lib/blocks.test.d.ts.map +1 -0
  8. package/lib/blocks.test.js +142 -0
  9. package/lib/blocks.test.js.map +1 -0
  10. package/lib/document.d.ts +28 -0
  11. package/lib/document.d.ts.map +1 -0
  12. package/lib/document.js +46 -0
  13. package/lib/document.js.map +1 -0
  14. package/lib/draft.d.ts +46 -9
  15. package/lib/draft.d.ts.map +1 -1
  16. package/lib/draft.js +115 -19
  17. package/lib/draft.js.map +1 -1
  18. package/lib/draft.test.d.ts +2 -0
  19. package/lib/draft.test.d.ts.map +1 -0
  20. package/lib/draft.test.js +122 -0
  21. package/lib/draft.test.js.map +1 -0
  22. package/lib/escape.d.ts +8 -0
  23. package/lib/escape.d.ts.map +1 -1
  24. package/lib/escape.js +17 -0
  25. package/lib/escape.js.map +1 -1
  26. package/lib/escape.test.d.ts +2 -0
  27. package/lib/escape.test.d.ts.map +1 -0
  28. package/lib/escape.test.js +28 -0
  29. package/lib/escape.test.js.map +1 -0
  30. package/lib/guards.test.d.ts +2 -0
  31. package/lib/guards.test.d.ts.map +1 -0
  32. package/lib/guards.test.js +85 -0
  33. package/lib/guards.test.js.map +1 -0
  34. package/lib/index.d.ts +18 -9
  35. package/lib/index.d.ts.map +1 -1
  36. package/lib/index.js +17 -8
  37. package/lib/index.js.map +1 -1
  38. package/lib/index.test.js +73 -103
  39. package/lib/index.test.js.map +1 -1
  40. package/lib/inline.d.ts +48 -59
  41. package/lib/inline.d.ts.map +1 -1
  42. package/lib/inline.js +87 -82
  43. package/lib/inline.js.map +1 -1
  44. package/lib/inline.test.d.ts +2 -0
  45. package/lib/inline.test.d.ts.map +1 -0
  46. package/lib/inline.test.js +84 -0
  47. package/lib/inline.test.js.map +1 -0
  48. package/lib/node.d.ts +25 -0
  49. package/lib/node.d.ts.map +1 -0
  50. package/lib/node.js +21 -0
  51. package/lib/node.js.map +1 -0
  52. package/lib/plaintext.test.d.ts +2 -0
  53. package/lib/plaintext.test.d.ts.map +1 -0
  54. package/lib/plaintext.test.js +100 -0
  55. package/lib/plaintext.test.js.map +1 -0
  56. package/lib/render.d.ts +17 -0
  57. package/lib/render.d.ts.map +1 -0
  58. package/lib/render.js +30 -0
  59. package/lib/render.js.map +1 -0
  60. package/lib/send.d.ts +6 -3
  61. package/lib/send.d.ts.map +1 -1
  62. package/lib/send.js +12 -3
  63. package/lib/send.js.map +1 -1
  64. package/lib/template.d.ts +46 -0
  65. package/lib/template.d.ts.map +1 -0
  66. package/lib/template.js +82 -0
  67. package/lib/template.js.map +1 -0
  68. package/lib/template.test.d.ts +2 -0
  69. package/lib/template.test.d.ts.map +1 -0
  70. package/lib/template.test.js +78 -0
  71. package/lib/template.test.js.map +1 -0
  72. package/package.json +6 -5
  73. package/src/blocks.test.ts +233 -0
  74. package/src/blocks.ts +304 -86
  75. package/src/document.ts +51 -0
  76. package/src/draft.test.ts +167 -0
  77. package/src/draft.ts +148 -21
  78. package/src/escape.test.ts +38 -0
  79. package/src/escape.ts +20 -0
  80. package/src/guards.test.ts +138 -0
  81. package/src/index.test.ts +79 -140
  82. package/src/index.ts +26 -11
  83. package/src/inline.test.ts +125 -0
  84. package/src/inline.ts +131 -97
  85. package/src/node.ts +43 -0
  86. package/src/plaintext.test.ts +141 -0
  87. package/src/render.ts +54 -0
  88. package/src/send.ts +17 -10
  89. package/src/template.test.ts +100 -0
  90. package/src/template.ts +115 -0
  91. package/lib/message.d.ts +0 -26
  92. package/lib/message.d.ts.map +0 -1
  93. package/lib/message.js +0 -31
  94. package/lib/message.js.map +0 -1
  95. package/src/message.ts +0 -45
package/src/index.test.ts CHANGED
@@ -1,122 +1,64 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { type Api, Composer, Context } from "@yaebal/core";
4
- import type { RichBlock, RichMessage } from "@yaebal/types";
3
+ import { Composer, Context } from "@yaebal/core";
4
+ import { mockApi } from "@yaebal/test";
5
5
  import {
6
6
  bold,
7
- cell,
8
- details,
9
7
  document,
10
- heading,
11
- html,
12
- image,
13
- isPhoto,
14
- isTable,
15
- item,
16
- link,
17
- list,
8
+ md,
18
9
  paragraph,
19
10
  RichMessageDraft,
20
11
  rich,
21
- richMessageToPlainText,
22
12
  sendRichMessage,
23
13
  sendRichMessageDraft,
24
- table,
25
- thinking,
26
14
  } from "./index.js";
27
15
 
28
- function mockApi(): Api & { calls: [string, Record<string, unknown> | undefined][] } {
29
- const calls: [string, Record<string, unknown> | undefined][] = [];
16
+ // integration-level coverage for the parts that tie the package together: the raw
17
+ // send/draft functions, and the `rich()` plugin's ctx decoration. per-builder output
18
+ // (blocks.test.ts/inline.test.ts/markdown.test.ts), guards (guards.test.ts),
19
+ // plaintext flattening (plaintext.test.ts), and RichMessageDraft's rewrite/write/send
20
+ // state machine (draft.test.ts) all have their own dedicated test files.
30
21
 
31
- return {
32
- calls,
33
- call: async (method: string, params?: Record<string, unknown>) => {
34
- calls.push([method, params]);
35
- return {} as never;
36
- },
37
- } as unknown as Api & { calls: [string, Record<string, unknown> | undefined][] };
38
- }
39
-
40
- test("html template escapes interpolations and splices RichNode subs", () => {
41
- const node = html`<p>hi ${"<b>hax</b>"} — ${bold("safe")}</p>`;
42
-
43
- assert.equal(node.html, "<p>hi &lt;b&gt;hax&lt;/b&gt; — <b>safe</b></p>");
44
- });
45
-
46
- test("inline builders emit the documented tags", () => {
47
- assert.equal(bold("x").html, "<b>x</b>");
48
- assert.equal(link("https://yaeb.al", "docs").html, '<a href="https://yaeb.al">docs</a>');
49
- });
50
-
51
- test("block builders compose into a document", () => {
52
- const input = document([
53
- heading(1, "title"),
54
- paragraph("hello ", bold("world")),
55
- list([item(["a"]), item(["b"], { checkbox: true, checked: true })]),
56
- details("more", [paragraph("hidden")], { open: false }),
57
- ]);
58
-
59
- assert.equal(
60
- input.html,
61
- "<h1>title</h1>" +
62
- "<p>hello <b>world</b></p>" +
63
- '<ul><li>a</li><li><input type="checkbox" checked/> b</li></ul>' +
64
- "<details><summary>more</summary><p>hidden</p></details>",
65
- );
66
- });
67
-
68
- test("table() and cell() render alignment attributes telegram's schema documents", () => {
69
- const node = table([
70
- [cell("a", { header: true, align: "center" }), cell("b", { valign: "middle" })],
71
- ]);
72
-
73
- assert.equal(
74
- node.html,
75
- '<table><tr><th align="center">a</th><td valign="middle">b</td></tr></table>',
76
- );
77
- });
78
-
79
- test("image() wraps caption/credit in figure/figcaption/cite", () => {
80
- const node = image("https://example.com/x.jpg", { caption: "a cat", credit: "photographer" });
22
+ test("sendRichMessage posts chat_id + rich_message to the raw api", async () => {
23
+ const { api, calls } = mockApi();
81
24
 
82
- assert.equal(
83
- node.html,
84
- '<figure><img src="https://example.com/x.jpg"></img><figcaption>a cat<cite>photographer</cite></figcaption></figure>',
85
- );
86
- });
25
+ await sendRichMessage(api, 42, document([paragraph("hi")]), { reply_markup: { x: 1 } });
87
26
 
88
- test("thinking() is exposed for draft-only use", () => {
89
- assert.equal(thinking("…").html, "<tg-thinking>…</tg-thinking>");
27
+ assert.equal(calls[0]?.method, "sendRichMessage");
28
+ assert.deepEqual(calls[0]?.params, {
29
+ chat_id: 42,
30
+ rich_message: { html: "<p>hi</p>" },
31
+ reply_markup: { x: 1 },
32
+ });
90
33
  });
91
34
 
92
- test("sendRichMessage posts chat_id + rich_message to the raw api", async () => {
93
- const api = mockApi();
35
+ test("sendRichMessage unwraps a markdown RichDocument with its fluent flags", async () => {
36
+ const { api, calls } = mockApi();
94
37
 
95
- await sendRichMessage(api, 42, document([paragraph("hi")]), { reply_markup: { x: 1 } });
38
+ await sendRichMessage(api, 42, md`# hi ${bold("there")}`.rtl());
96
39
 
97
- assert.deepEqual(api.calls, [
98
- [
99
- "sendRichMessage",
100
- { chat_id: 42, rich_message: { html: "<p>hi</p>" }, reply_markup: { x: 1 } },
101
- ],
102
- ]);
40
+ assert.equal(calls[0]?.method, "sendRichMessage");
41
+ assert.deepEqual(calls[0]?.params, {
42
+ chat_id: 42,
43
+ rich_message: { markdown: "# hi **there**", is_rtl: true },
44
+ });
103
45
  });
104
46
 
105
47
  test("sendRichMessageDraft posts draft_id alongside chat_id + rich_message", async () => {
106
- const api = mockApi();
48
+ const { api, calls } = mockApi();
107
49
 
108
50
  await sendRichMessageDraft(api, 42, 7, "<tg-thinking>…</tg-thinking>");
109
51
 
110
- assert.deepEqual(api.calls, [
111
- [
112
- "sendRichMessageDraft",
113
- { chat_id: 42, draft_id: 7, rich_message: { html: "<tg-thinking>…</tg-thinking>" } },
114
- ],
115
- ]);
52
+ assert.equal(calls[0]?.method, "sendRichMessageDraft");
53
+ assert.deepEqual(calls[0]?.params, {
54
+ chat_id: 42,
55
+ draft_id: 7,
56
+ rich_message: { html: "<tg-thinking>…</tg-thinking>" },
57
+ });
116
58
  });
117
59
 
118
- test("rich() plugin decorates ctx.sendRichMessage bound to the current chat", async () => {
119
- const api = mockApi();
60
+ test("rich() plugin decorates ctx.sendRichMessage/ctx.richMessageDraft bound to the current chat", async () => {
61
+ const { api, calls } = mockApi();
120
62
  const composer = new Composer().install(rich());
121
63
 
122
64
  const ctx = new Context({
@@ -136,66 +78,63 @@ test("rich() plugin decorates ctx.sendRichMessage bound to the current chat", as
136
78
  };
137
79
 
138
80
  await decorated.sendRichMessage(document([paragraph("hi")]));
139
- assert.deepEqual(api.calls[0], [
140
- "sendRichMessage",
141
- { chat_id: 5, rich_message: { html: "<p>hi</p>" } },
142
- ]);
81
+ assert.equal(calls[0]?.method, "sendRichMessage");
82
+ assert.deepEqual(calls[0]?.params, { chat_id: 5, rich_message: { html: "<p>hi</p>" } });
143
83
 
144
84
  const draft = decorated.richMessageDraft(1);
145
85
  assert.ok(draft instanceof RichMessageDraft);
146
86
  });
147
87
 
148
- test("RichMessageDraft.push() re-arms a keep-alive timer and commit() stops it", async () => {
149
- const api = mockApi();
150
- const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
151
-
152
- await draft.push(thinking("…"));
153
- assert.equal(api.calls[0]?.[0], "sendRichMessageDraft");
154
-
155
- await draft.commit(document([paragraph("done")]));
156
- assert.equal(api.calls[1]?.[0], "sendRichMessage");
157
- assert.equal(draft.closed, true);
88
+ test("rich() plugin routes ctx.sendRichMessage through the business connection/topic", async () => {
89
+ const { api, calls } = mockApi();
90
+ const composer = new Composer().install(rich());
158
91
 
159
- await assert.rejects(() => draft.push("late"), /after commit\(\)\/cancel\(\)/);
160
- });
92
+ const ctx = new Context({
93
+ api,
94
+ update: {
95
+ update_id: 1,
96
+ business_message: {
97
+ message_id: 1,
98
+ date: 0,
99
+ chat: { id: 5, type: "private" },
100
+ business_connection_id: "bc1",
101
+ message_thread_id: 9,
102
+ },
103
+ } as never,
104
+ updateType: "business_message",
105
+ });
161
106
 
162
- test("RichMessageDraft.cancel() closes without persisting", async () => {
163
- const api = mockApi();
164
- const draft = new RichMessageDraft(api, 1, 2);
107
+ await composer.toMiddleware()(ctx as never, async () => {});
165
108
 
166
- await draft.push("draft text");
167
- draft.cancel();
109
+ const decorated = ctx as unknown as { sendRichMessage: (input: unknown) => Promise<unknown> };
110
+ await decorated.sendRichMessage(document([paragraph("hi")]));
168
111
 
169
- assert.equal(draft.closed, true);
170
- assert.equal(
171
- api.calls.some(([method]) => method === "sendRichMessage"),
172
- false,
173
- );
112
+ assert.equal(calls[0]?.method, "sendRichMessage");
113
+ assert.deepEqual(calls[0]?.params, {
114
+ chat_id: 5,
115
+ rich_message: { html: "<p>hi</p>" },
116
+ business_connection_id: "bc1",
117
+ message_thread_id: 9,
118
+ });
174
119
  });
175
120
 
176
- test("guards narrow RichBlock by .type despite the generated field being `string`", () => {
177
- const blocks = [
178
- { type: "table", cells: [[{ text: "x", align: "left", valign: "top" }]] },
179
- { type: "photo", photo: [] },
180
- ] as unknown as RichBlock[];
121
+ test("sendRichMessage()/richMessageDraft() reject when the update has no chat", async () => {
122
+ const { api } = mockApi();
123
+ const composer = new Composer().install(rich());
181
124
 
182
- assert.equal(isTable(blocks[0] as RichBlock), true);
183
- assert.equal(isPhoto(blocks[1] as RichBlock), true);
184
- assert.equal(isTable(blocks[1] as RichBlock), false);
185
- });
125
+ const ctx = new Context({
126
+ api,
127
+ update: { update_id: 1 } as never,
128
+ updateType: "message",
129
+ });
186
130
 
187
- test("richMessageToPlainText flattens a full block tree", () => {
188
- const message = {
189
- blocks: [
190
- { type: "heading", text: "title", size: 1 },
191
- { type: "paragraph", text: [{ type: "bold", text: "hi" }, " there"] },
192
- { type: "divider" },
193
- {
194
- type: "list",
195
- items: [{ label: "1", blocks: [{ type: "paragraph", text: "one" }] }],
196
- },
197
- ],
198
- } as unknown as RichMessage;
131
+ await composer.toMiddleware()(ctx as never, async () => {});
132
+
133
+ const decorated = ctx as unknown as {
134
+ sendRichMessage: (input: unknown) => Promise<unknown>;
135
+ richMessageDraft: (draftId: number) => RichMessageDraft;
136
+ };
199
137
 
200
- assert.equal(richMessageToPlainText(message), "title\nhi there\n---\n- one");
138
+ await assert.rejects(() => decorated.sendRichMessage("x"), /no chat in this update/);
139
+ assert.throws(() => decorated.richMessageDraft(1), /no chat in this update/);
201
140
  });
package/src/index.ts CHANGED
@@ -6,12 +6,17 @@
6
6
  * parses it server-side, and you get the same tree back on `message.rich_message`.
7
7
  * this package covers all three parts of that surface:
8
8
  *
9
- * - **write**: `document()` + block builders (blocks.ts) and inline mark builders
10
- * (inline.ts) assemble an `InputRichMessage` without hand-written angle brackets.
9
+ * - **write**: one dual-dialect builder set. every builder (`bold`, `paragraph`,
10
+ * `table`, …) returns a dialect-agnostic `RichNode`; the `html`/`md` template
11
+ * tags (or `document()`) pick the wire dialect once, at the edge, and emit a
12
+ * `RichDocument` — the `rich_message` envelope with fluent `.rtl()` /
13
+ * `.noEntityDetection()` flags. interpolated text is always dialect-escaped,
14
+ * so user input can never inject markup in either dialect.
11
15
  * - **stream**: `RichMessageDraft` (draft.ts) owns the fiddly part of
12
16
  * `sendRichMessageDraft` — the draft is ephemeral (telegram drops it 30s after
13
- * the last push) and must be closed with a real `sendRichMessage` or explicitly
14
- * cancelled.
17
+ * the last push) and must be closed with a real `sendRichMessage` (`send()`) or
18
+ * explicitly `cancel()`led. `rewrite()` replaces the whole draft, `write()`
19
+ * appends to it.
15
20
  * - **read**: type guards (guards.ts) and plain-text flattening (plaintext.ts)
16
21
  * cover every `RichBlock`/`RichText` variant telegram can hand back.
17
22
  *
@@ -95,9 +100,17 @@ export {
95
100
  details,
96
101
  divider,
97
102
  footer,
103
+ h1,
104
+ h2,
105
+ h3,
106
+ h4,
107
+ h5,
108
+ h6,
98
109
  heading,
99
110
  image,
100
111
  item,
112
+ join,
113
+ type ListItem,
101
114
  type ListItemOptions,
102
115
  type ListOptions,
103
116
  list,
@@ -109,13 +122,16 @@ export {
109
122
  preformatted,
110
123
  pullquote,
111
124
  slideshow,
125
+ type TableCell,
112
126
  type TableCellOptions,
113
127
  type TableOptions,
114
128
  table,
115
129
  thinking,
116
130
  video,
117
131
  } from "./blocks.js";
132
+ export { RichDocument } from "./document.js";
118
133
  export { RichMessageDraft, type RichMessageDraftOptions } from "./draft.js";
134
+ export { escapeMarkdown, escapeMarkdownUrl } from "./escape.js";
119
135
  export {
120
136
  isAnchorBlock,
121
137
  isAnchorLink,
@@ -137,6 +153,7 @@ export {
137
153
  isFooter,
138
154
  isHashtag,
139
155
  isHeading,
156
+ isItalic,
140
157
  isList,
141
158
  isMap,
142
159
  isMarked,
@@ -167,17 +184,14 @@ export {
167
184
  anchor,
168
185
  anchorLink,
169
186
  bold,
187
+ br,
170
188
  code,
171
189
  customEmoji,
172
190
  dateTime,
173
- html,
174
- type Insertable,
175
- isRichNode,
176
191
  italic,
177
192
  link,
178
193
  marked,
179
194
  math,
180
- type RichNode,
181
195
  reference,
182
196
  referenceLink,
183
197
  spoiler,
@@ -185,15 +199,16 @@ export {
185
199
  subscript,
186
200
  superscript,
187
201
  textMention,
188
- toHtml,
189
202
  underline,
190
203
  } from "./inline.js";
191
- export { type DocumentOptions, document, markdown } from "./message.js";
192
-
204
+ export { type Dialect, isRichNode, type Level, RichError, type RichNode } from "./node.js";
193
205
  export { richBlockToPlainText, richMessageToPlainText, richTextToPlainText } from "./plaintext.js";
206
+ export { escapeFor, type Insertable, render } from "./render.js";
194
207
  export {
195
208
  type RichContext,
209
+ type RichSource,
196
210
  rich,
197
211
  sendRichMessage,
198
212
  sendRichMessageDraft,
199
213
  } from "./send.js";
214
+ export { type DocumentOptions, document, html, md, type RichTemplate } from "./template.js";
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ anchor,
5
+ anchorLink,
6
+ bold,
7
+ br,
8
+ code,
9
+ customEmoji,
10
+ dateTime,
11
+ italic,
12
+ link,
13
+ marked,
14
+ math,
15
+ reference,
16
+ referenceLink,
17
+ spoiler,
18
+ strikethrough,
19
+ subscript,
20
+ superscript,
21
+ textMention,
22
+ underline,
23
+ } from "./inline.js";
24
+ import type { RichNode } from "./node.js";
25
+
26
+ // every inline builder is dual-dialect: one node, two renders.
27
+ function both(node: RichNode): [html: string, md: string] {
28
+ return [node.render("html"), node.render("markdown")];
29
+ }
30
+
31
+ test("wrapper marks render their token pair in each dialect", () => {
32
+ assert.deepEqual(both(bold("x")), ["<b>x</b>", "**x**"]);
33
+ assert.deepEqual(both(italic("x")), ["<i>x</i>", "*x*"]);
34
+ assert.deepEqual(both(strikethrough("x")), ["<s>x</s>", "~~x~~"]);
35
+ assert.deepEqual(both(spoiler("x")), ["<tg-spoiler>x</tg-spoiler>", "||x||"]);
36
+ assert.deepEqual(both(code("x")), ["<code>x</code>", "`x`"]);
37
+ assert.deepEqual(both(marked("x")), ["<mark>x</mark>", "==x=="]);
38
+ });
39
+
40
+ test("marks with no markdown token embed the raw html tag in both dialects", () => {
41
+ assert.deepEqual(both(underline("x")), ["<u>x</u>", "<u>x</u>"]);
42
+ assert.deepEqual(both(subscript("x")), ["<sub>x</sub>", "<sub>x</sub>"]);
43
+ assert.deepEqual(both(superscript("x")), ["<sup>x</sup>", "<sup>x</sup>"]);
44
+ });
45
+
46
+ test("string children are escaped per dialect, so user input cannot inject markup", () => {
47
+ assert.equal(bold("<i>&</i>").render("html"), "<b>&lt;i&gt;&amp;&lt;/i&gt;</b>");
48
+ assert.equal(bold("a*b_c").render("markdown"), "**a\\*b\\_c**");
49
+ // html-significant chars become numeric entities in markdown (a backslash would show).
50
+ assert.equal(bold("<&>").render("markdown"), "**&#60;&#38;&#62;**");
51
+ });
52
+
53
+ test("nested nodes render into the chosen dialect; siblings concatenate", () => {
54
+ const node = bold("a ", italic("b"), " c");
55
+
56
+ assert.equal(node.render("html"), "<b>a <i>b</i> c</b>");
57
+ assert.equal(node.render("markdown"), "**a *b* c**");
58
+ });
59
+
60
+ test("null/undefined/false children render as nothing — `cond && node` composes", () => {
61
+ const cond = false as boolean;
62
+
63
+ assert.equal(bold("a", null, undefined, cond && italic("b")).render("html"), "<b>a</b>");
64
+ });
65
+
66
+ test("array children are flattened and concatenated", () => {
67
+ assert.equal(bold(["a", ["b", italic("c")]]).render("html"), "<b>ab<i>c</i></b>");
68
+ });
69
+
70
+ test("number and bigint children are stringified then escaped", () => {
71
+ assert.equal(bold(42, "|", 10n).render("markdown"), "**42\\|10**");
72
+ });
73
+
74
+ test("br() is a hard newline in markdown, <br/> in html", () => {
75
+ assert.deepEqual(both(br()), ["<br/>", "\n"]);
76
+ });
77
+
78
+ test("link() escapes the url per dialect so a hostile url cannot break out", () => {
79
+ assert.equal(
80
+ link('https://x.dev/?a=1&b="2"', "t").render("html"),
81
+ '<a href="https://x.dev/?a=1&amp;b=&quot;2&quot;">t</a>',
82
+ );
83
+ assert.equal(link("https://x.dev/a) b", "t").render("markdown"), "[t](https://x.dev/a\\)%20b)");
84
+ });
85
+
86
+ test("textMention() links tg://user?id=… in both dialects", () => {
87
+ assert.deepEqual(both(textMention({ id: 7 }, "dave")), [
88
+ '<a href="tg://user?id=7">dave</a>',
89
+ "[dave](tg://user?id=7)",
90
+ ]);
91
+ });
92
+
93
+ test("anchor()/anchorLink() — jump target and #link", () => {
94
+ assert.deepEqual(both(anchor("top")), ['<a name="top"></a>', '<a name="top"></a>']);
95
+ assert.deepEqual(both(anchorLink("top", "up")), ['<a href="#top">up</a>', "[up](#top)"]);
96
+ });
97
+
98
+ test("customEmoji() — tg-emoji tag / tg://emoji image link", () => {
99
+ assert.deepEqual(both(customEmoji("5368324170671202286", "👍")), [
100
+ '<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>',
101
+ "![👍](tg://emoji?id=5368324170671202286)",
102
+ ]);
103
+ });
104
+
105
+ test("math() keeps LaTeX raw in markdown, html-escapes it in html", () => {
106
+ assert.deepEqual(both(math("a_1 < b")), ["<tg-math>a_1 &lt; b</tg-math>", "$a_1 < b$"]);
107
+ });
108
+
109
+ test("dateTime() — best-effort <time> / tg://time image link", () => {
110
+ assert.deepEqual(both(dateTime(1735689600, "R", "soon")), [
111
+ '<time datetime="1735689600" data-format="R">soon</time>',
112
+ "![soon](tg://time?unix=1735689600&format=R)",
113
+ ]);
114
+ });
115
+
116
+ test("reference()/referenceLink() — tg-reference tags / markdown footnote syntax", () => {
117
+ assert.deepEqual(both(reference("1", "the fine print")), [
118
+ '<tg-reference name="1">the fine print</tg-reference>',
119
+ "[^1]: the fine print",
120
+ ]);
121
+ assert.deepEqual(both(referenceLink("1", "see note")), [
122
+ '<tg-reference-link name="1">see note</tg-reference-link>',
123
+ "[^1]",
124
+ ]);
125
+ });