@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/blocks.ts CHANGED
@@ -1,8 +1,16 @@
1
- import { escapeAttr, escapeText } from "./escape.js";
2
- import { type Insertable, type RichNode, toHtml } from "./inline.js";
1
+ import { escapeAttr, escapeMarkdownUrl } from "./escape.js";
2
+ import { type Dialect, isRichNode, makeNode, type RichNode } from "./node.js";
3
+ import { escapeFor, type Insertable, render } from "./render.js";
3
4
 
4
- function join(children: Insertable[]): string {
5
- return children.map(toHtml).join("");
5
+ // every builder here returns a dialect-agnostic block `RichNode`; the
6
+ // html/markdown choice happens at the `html`/`md`/`document()` boundary.
7
+ // where telegram's rich-markdown dialect has no native token for a block
8
+ // (footer, pull-quote, collage/slideshow, map, details, thinking), the raw html
9
+ // tag is embedded as-is — telegram's markdown parser accepts embedded html
10
+ // blocks as long as they are blank-line-separated from surrounding content.
11
+
12
+ function children(items: Insertable[], dialect: Dialect): string {
13
+ return items.map((item) => render(item, dialect)).join("");
6
14
  }
7
15
 
8
16
  /** a caption + optional credit for a media/collage/slideshow/map block (`RichBlockCaption`). */
@@ -12,85 +20,173 @@ export interface Caption {
12
20
  credit?: Insertable;
13
21
  }
14
22
 
15
- function figcaption({ caption, credit }: Caption): string {
23
+ function figcaption({ caption, credit }: Caption, dialect: Dialect): string {
16
24
  if (caption === undefined && credit === undefined) return "";
17
25
 
18
- const creditHtml = credit === undefined ? "" : `<cite>${toHtml(credit)}</cite>`;
19
- return `<figcaption>${toHtml(caption)}${creditHtml}</figcaption>`;
26
+ const creditHtml = credit === undefined ? "" : `<cite>${render(credit, dialect)}</cite>`;
27
+ return `<figcaption>${render(caption, dialect)}${creditHtml}</figcaption>`;
20
28
  }
21
29
 
22
30
  // --- confirmed tags ---
23
31
 
24
- /** `RichBlockParagraph`, `<p>`. */
25
- export function paragraph(...children: Insertable[]): RichNode {
26
- return { html: `<p>${join(children)}</p>` };
27
- }
28
-
29
- /** `RichBlockSectionHeading`, `<h1>`–`<h6>`. `level` is telegram's `size` (1 largest, 6 smallest). */
30
- export function heading(level: 1 | 2 | 3 | 4 | 5 | 6, ...children: Insertable[]): RichNode {
31
- return { html: `<h${level}>${join(children)}</h${level}>` };
32
- }
32
+ /** `RichBlockParagraph` `<p>` in html, bare text in markdown (blocks are blank-line-joined). */
33
+ export function paragraph(...items: Insertable[]): RichNode {
34
+ return makeNode("block", (d) =>
35
+ d === "markdown" ? children(items, d) : `<p>${children(items, d)}</p>`,
36
+ );
37
+ }
38
+
39
+ /** `RichBlockSectionHeading` `<h1>`–`<h6>` / `#`–`######`. `level` is telegram's `size` (1 largest). */
40
+ export function heading(level: 1 | 2 | 3 | 4 | 5 | 6, ...items: Insertable[]): RichNode {
41
+ return makeNode("block", (d) =>
42
+ d === "markdown"
43
+ ? `${"#".repeat(level)} ${children(items, d)}`
44
+ : `<h${level}>${children(items, d)}</h${level}>`,
45
+ );
46
+ }
47
+
48
+ /** `heading(1, …)`. */
49
+ export const h1 = (...items: Insertable[]): RichNode => heading(1, ...items);
50
+ /** `heading(2, …)`. */
51
+ export const h2 = (...items: Insertable[]): RichNode => heading(2, ...items);
52
+ /** `heading(3, …)`. */
53
+ export const h3 = (...items: Insertable[]): RichNode => heading(3, ...items);
54
+ /** `heading(4, …)`. */
55
+ export const h4 = (...items: Insertable[]): RichNode => heading(4, ...items);
56
+ /** `heading(5, …)`. */
57
+ export const h5 = (...items: Insertable[]): RichNode => heading(5, ...items);
58
+ /** `heading(6, …)`. */
59
+ export const h6 = (...items: Insertable[]): RichNode => heading(6, ...items);
33
60
 
34
- /** `RichBlockPreformatted`, nested `<pre><code>`, matching classic parse_mode html. */
61
+ /**
62
+ * `RichBlockPreformatted` — nested `<pre><code>` (matching classic parse_mode
63
+ * html) / a fenced code block. `text` is raw code: html-escaped in the html
64
+ * dialect, emitted verbatim inside the markdown fence.
65
+ */
35
66
  export function preformatted(text: string, language?: string): RichNode {
36
- const cls = language ? ` class="language-${escapeAttr(language)}"` : "";
37
- return { html: `<pre><code${cls}>${escapeText(text)}</code></pre>` };
67
+ return makeNode("block", (d) => {
68
+ if (d === "markdown") return `\`\`\`${language ?? ""}\n${text}\n\`\`\``;
69
+
70
+ const cls = language ? ` class="language-${escapeAttr(language)}"` : "";
71
+ return `<pre><code${cls}>${escapeFor(text, "html")}</code></pre>`;
72
+ });
38
73
  }
39
74
 
40
- /** `RichBlockFooter`, `<footer>`. */
41
- export function footer(...children: Insertable[]): RichNode {
42
- return { html: `<footer>${join(children)}</footer>` };
75
+ /** `RichBlockFooter` `<footer>`; no markdown token, raw tag embedded there too. */
76
+ export function footer(...items: Insertable[]): RichNode {
77
+ return makeNode("block", (d) => `<footer>${children(items, d)}</footer>`);
43
78
  }
44
79
 
45
- /** `RichBlockDivider`, `<hr/>`. */
80
+ /** `RichBlockDivider` `<hr/>` / `---`. */
46
81
  export function divider(): RichNode {
47
- return { html: "<hr/>" };
82
+ return makeNode("block", (d) => (d === "markdown" ? "---" : "<hr/>"));
48
83
  }
49
84
 
50
- /** `RichBlockMathematicalExpression`, confirmed `<tg-math-block>` (LaTeX). */
85
+ /** `RichBlockMathematicalExpression` confirmed `<tg-math-block>` / `$$…$$` (raw LaTeX, unescaped in markdown). */
51
86
  export function mathBlock(expression: string): RichNode {
52
- return { html: `<tg-math-block>${escapeText(expression)}</tg-math-block>` };
87
+ return makeNode("block", (d) =>
88
+ d === "markdown"
89
+ ? `$$${expression}$$`
90
+ : `<tg-math-block>${escapeFor(expression, "html")}</tg-math-block>`,
91
+ );
53
92
  }
54
93
 
55
- /** `RichBlockAnchor`, `<a name="…">` at block level. */
94
+ /** `RichBlockAnchor` `<a name="…">` at block level, in both dialects. */
56
95
  export function anchorBlock(name: string): RichNode {
57
- return { html: `<a name="${escapeAttr(name)}"></a>` };
96
+ return makeNode("block", () => `<a name="${escapeAttr(name)}"></a>`);
58
97
  }
59
98
 
60
- /** `RichBlockBlockQuotation`, `<blockquote>`, with an optional `<cite>` credit. */
61
- export function blockquote(children: Insertable[], credit?: Insertable): RichNode {
62
- const creditHtml = credit === undefined ? "" : `<cite>${toHtml(credit)}</cite>`;
63
- return { html: `<blockquote>${join(children)}${creditHtml}</blockquote>` };
99
+ /**
100
+ * `RichBlockBlockQuotation` — `<blockquote>` with an optional `<cite>` credit;
101
+ * `>`-prefixed lines in markdown (each array item becomes its own line, the
102
+ * credit a trailing `> — credit` line).
103
+ */
104
+ export function blockquote(items: Insertable[], credit?: Insertable): RichNode {
105
+ return makeNode("block", (d) => {
106
+ if (d === "markdown") {
107
+ const body = items
108
+ .map((item) =>
109
+ render(item, d)
110
+ .split("\n")
111
+ .map((line) => `>${line}`)
112
+ .join("\n"),
113
+ )
114
+ .join("\n");
115
+ const creditLine = credit === undefined ? "" : `\n> — ${render(credit, d)}`;
116
+
117
+ return `${body}${creditLine}`;
118
+ }
119
+
120
+ const creditHtml = credit === undefined ? "" : `<cite>${render(credit, d)}</cite>`;
121
+ return `<blockquote>${children(items, d)}${creditHtml}</blockquote>`;
122
+ });
123
+ }
124
+
125
+ /** `RichBlockPullQuotation` — loosely `<aside>` per the schema, with an optional `<cite>` credit. */
126
+ export function pullquote(text: Insertable, credit?: Insertable): RichNode {
127
+ return makeNode("block", (d) => {
128
+ const creditHtml = credit === undefined ? "" : `<cite>${render(credit, d)}</cite>`;
129
+ return `<aside>${render(text, d)}${creditHtml}</aside>`;
130
+ });
64
131
  }
65
132
 
66
- /** `RichBlockPullQuotation`, loosely `<aside>` per the schema, with an optional `<cite>` credit. */
67
- export function pullquote(text: Insertable, credit?: Insertable): RichNode {
68
- const creditHtml = credit === undefined ? "" : `<cite>${toHtml(credit)}</cite>`;
69
- return { html: `<aside>${toHtml(text)}${creditHtml}</aside>` };
133
+ // media inside an embedded html block must be blank-line-separated for
134
+ // telegram's markdown parser to pick it up.
135
+ function mediaGroup(tag: string, blocks: Insertable[], caption: Caption): RichNode {
136
+ return makeNode("block", (d) => {
137
+ const cap = figcaption(caption, d);
138
+
139
+ if (d === "markdown") {
140
+ return `<${tag}>\n\n${blocks.map((b) => render(b, d)).join("\n\n")}\n\n${cap}</${tag}>`;
141
+ }
142
+
143
+ return `<${tag}>${children(blocks, d)}${cap}</${tag}>`;
144
+ });
70
145
  }
71
146
 
72
- /** `RichBlockCollage`, confirmed custom tag `<tg-collage>`. */
147
+ /** `RichBlockCollage` confirmed custom tag `<tg-collage>` (raw in markdown too). */
73
148
  export function collage(blocks: Insertable[], caption: Caption = {}): RichNode {
74
- return { html: `<tg-collage>${join(blocks)}${figcaption(caption)}</tg-collage>` };
149
+ return mediaGroup("tg-collage", blocks, caption);
75
150
  }
76
151
 
77
- /** `RichBlockSlideshow`, confirmed custom tag `<tg-slideshow>`. */
152
+ /** `RichBlockSlideshow` confirmed custom tag `<tg-slideshow>` (raw in markdown too). */
78
153
  export function slideshow(blocks: Insertable[], caption: Caption = {}): RichNode {
79
- return { html: `<tg-slideshow>${join(blocks)}${figcaption(caption)}</tg-slideshow>` };
154
+ return mediaGroup("tg-slideshow", blocks, caption);
80
155
  }
81
156
 
157
+ // --- tables ---
158
+
159
+ const TABLE_CELL = Symbol.for("yaebal.rich.table-cell");
160
+
82
161
  export interface TableCellOptions {
162
+ /** `<th>` instead of `<td>`. html-only — gfm's header is structural (see `table`). */
83
163
  header?: boolean;
164
+ /** html-only — gfm has no cell spans. */
84
165
  colspan?: number;
166
+ /** html-only — gfm has no cell spans. */
85
167
  rowspan?: number;
86
- /** default `"left"`, matching `RichBlockTableCell.align`. */
168
+ /** default `"left"`, matching `RichBlockTableCell.align`. feeds gfm's column alignment row. */
87
169
  align?: "left" | "center" | "right";
88
- /** default `"top"`, matching `RichBlockTableCell.valign`. */
170
+ /** default `"top"`, matching `RichBlockTableCell.valign`. html-only. */
89
171
  valign?: "top" | "middle" | "bottom";
90
172
  }
91
173
 
174
+ /** one table cell — a `RichNode` carrying its options so `table()` can read the gfm alignment. */
175
+ export interface TableCell extends RichNode {
176
+ readonly [TABLE_CELL]: true;
177
+ readonly align?: "left" | "center" | "right";
178
+ }
179
+
180
+ function isTableCell(value: unknown): value is TableCell {
181
+ return (
182
+ typeof value === "object" &&
183
+ value !== null &&
184
+ (value as Record<symbol, unknown>)[TABLE_CELL] === true
185
+ );
186
+ }
187
+
92
188
  /** one `<td>`/`<th>` cell; `content` omitted ⇒ an invisible cell (per `RichBlockTableCell`). */
93
- export function cell(content?: Insertable, options: TableCellOptions = {}): RichNode {
189
+ export function cell(content?: Insertable, options: TableCellOptions = {}): TableCell {
94
190
  const tag = options.header ? "th" : "td";
95
191
  const attrs =
96
192
  (options.colspan ? ` colspan="${options.colspan}"` : "") +
@@ -100,30 +196,60 @@ export function cell(content?: Insertable, options: TableCellOptions = {}): Rich
100
196
  (options.align ? ` align="${options.align}"` : "") +
101
197
  (options.valign ? ` valign="${options.valign}"` : "");
102
198
 
103
- return {
104
- html:
105
- content === undefined
106
- ? `<${tag}${attrs}></${tag}>`
107
- : `<${tag}${attrs}>${toHtml(content)}</${tag}>`,
108
- };
199
+ const node = makeNode("inline", (d) => {
200
+ // markdown: bare cell text — `table()` owns the `|` framing and the alignment
201
+ // row; colspan/rowspan/valign/header have no gfm equivalent and are dropped.
202
+ if (d === "markdown") return content === undefined ? "" : render(content, d);
203
+
204
+ return `<${tag}${attrs}>${content === undefined ? "" : render(content, d)}</${tag}>`;
205
+ });
206
+
207
+ return Object.assign(node, { [TABLE_CELL]: true as const, align: options.align });
109
208
  }
110
209
 
111
210
  export interface TableOptions {
112
- /** best-effort: rendered as the `border` attribute. */
211
+ /** best-effort: rendered as the `border` attribute. html-only. */
113
212
  bordered?: boolean;
114
- /** best-effort: no standard html equivalent, rendered as `data-striped`. */
213
+ /** best-effort: no standard html equivalent, rendered as `data-striped`. html-only. */
115
214
  striped?: boolean;
215
+ /** `<caption>` in html; a leading caption line in markdown. */
116
216
  caption?: Insertable;
117
217
  }
118
218
 
119
- /** `RichBlockTable`, confirmed `<table>`. build rows with `cell()`. */
219
+ const ALIGN_MARKER: Record<"left" | "center" | "right", string> = {
220
+ left: ":--",
221
+ center: ":-:",
222
+ right: "--:",
223
+ };
224
+
225
+ /**
226
+ * `RichBlockTable`, confirmed `<table>` / a gfm table. build cells with `cell()`
227
+ * (a bare value is wrapped in a plain `cell()` automatically). gfm structurally
228
+ * requires a header row, so in markdown `rows[0]` always renders as the header
229
+ * line and `colspan`/`rowspan`/`valign`/`header`/`bordered`/`striped` are
230
+ * dropped; in html the header is per-cell opt-in (`cell(x, { header: true })`).
231
+ */
120
232
  export function table(rows: Insertable[][], options: TableOptions = {}): RichNode {
121
- const attrs = (options.bordered ? " border" : "") + (options.striped ? " data-striped" : "");
122
- const body = rows.map((row) => `<tr>${join(row)}</tr>`).join("");
123
- const captionHtml =
124
- options.caption === undefined ? "" : `<caption>${toHtml(options.caption)}</caption>`;
233
+ const cells = rows.map((row) => row.map((c) => (isTableCell(c) ? c : cell(c))));
234
+
235
+ return makeNode("block", (d) => {
236
+ if (d === "markdown") {
237
+ // cell content is markdown-escaped by render(), so `|` inside a cell is pipe-safe.
238
+ const line = (row: TableCell[]) => `| ${row.map((c) => c.render(d)).join(" | ")} |`;
239
+ const head = cells[0] ?? [];
240
+ const separator = `| ${head.map((c) => ALIGN_MARKER[c.align ?? "left"]).join(" | ")} |`;
241
+ const captionLine = options.caption === undefined ? "" : `${render(options.caption, d)}\n\n`;
125
242
 
126
- return { html: `<table${attrs}>${captionHtml}${body}</table>` };
243
+ return `${captionLine}${[line(head), separator, ...cells.slice(1).map(line)].join("\n")}`;
244
+ }
245
+
246
+ const attrs = (options.bordered ? " border" : "") + (options.striped ? " data-striped" : "");
247
+ const captionHtml =
248
+ options.caption === undefined ? "" : `<caption>${render(options.caption, d)}</caption>`;
249
+ const body = cells.map((row) => `<tr>${row.map((c) => c.render(d)).join("")}</tr>`).join("");
250
+
251
+ return `<table${attrs}>${captionHtml}${body}</table>`;
252
+ });
127
253
  }
128
254
 
129
255
  export interface DetailsOptions {
@@ -131,52 +257,114 @@ export interface DetailsOptions {
131
257
  open?: boolean;
132
258
  }
133
259
 
134
- /** `RichBlockDetails`, confirmed `<details>`/`<summary>`. */
260
+ /**
261
+ * `RichBlockDetails`, confirmed `<details>`/`<summary>` (an html-only tag, legal
262
+ * inside markdown too — there the body must be blank-line-separated or telegram
263
+ * renders it as literal text).
264
+ */
135
265
  export function details(
136
266
  summary: Insertable,
137
267
  blocks: Insertable[],
138
268
  options: DetailsOptions = {},
139
269
  ): RichNode {
140
- return {
141
- html: `<details${options.open ? " open" : ""}><summary>${toHtml(summary)}</summary>${join(blocks)}</details>`,
142
- };
270
+ return makeNode("block", (d) => {
271
+ const head = `<details${options.open ? " open" : ""}><summary>${render(summary, d)}</summary>`;
272
+
273
+ return d === "markdown"
274
+ ? `${head}\n\n${blocks.map((b) => render(b, d)).join("\n\n")}\n\n</details>`
275
+ : `${head}${children(blocks, d)}</details>`;
276
+ });
143
277
  }
144
278
 
279
+ // --- lists ---
280
+
281
+ const LIST_ITEM = Symbol.for("yaebal.rich.list-item");
282
+
145
283
  export interface ListItemOptions {
146
284
  /** an unchecked/checked checkbox prefix (`RichBlockListItem.has_checkbox`/`is_checked`). */
147
285
  checkbox?: boolean;
148
286
  checked?: boolean;
149
- /** ordered-list numeric override (`RichBlockListItem.value`) — standard `<li value>`. */
287
+ /** ordered-list numeric override (`RichBlockListItem.value`) — standard `<li value>`; restarts gfm numbering. */
150
288
  value?: number;
151
- /** ordered-list label style override (`RichBlockListItem.type`) — standard `<li type>`. */
289
+ /** ordered-list label style override (`RichBlockListItem.type`) — standard `<li type>`. html-only. */
152
290
  type?: "a" | "A" | "i" | "I";
153
291
  }
154
292
 
155
- /** one `<li>` for `list()`. */
156
- export function item(blocks: Insertable[], options: ListItemOptions = {}): RichNode {
157
- const attrs =
158
- (options.value !== undefined ? ` value="${options.value}"` : "") +
159
- (options.type ? ` type="${options.type}"` : "");
293
+ /** one list item — a `RichNode` carrying its options so `list()` can number/prefix it. */
294
+ export interface ListItem extends RichNode {
295
+ readonly [LIST_ITEM]: true;
296
+ readonly value?: number;
297
+ }
298
+
299
+ function isListItem(value: unknown): value is ListItem {
300
+ return (
301
+ typeof value === "object" &&
302
+ value !== null &&
303
+ (value as Record<symbol, unknown>)[LIST_ITEM] === true
304
+ );
305
+ }
306
+
307
+ /** one `<li>` for `list()` — only needed when a plain value isn't enough (checkbox, value, type). */
308
+ export function item(blocks: Insertable[], options: ListItemOptions = {}): ListItem {
309
+ const node = makeNode("inline", (d) => {
310
+ if (d === "markdown") {
311
+ // `- ` / `1. ` markers belong to list(); the item contributes the checkbox + content.
312
+ const checkbox = options.checkbox === undefined ? "" : `[${options.checked ? "x" : " "}] `;
313
+ return `${checkbox}${blocks.map((b) => render(b, d)).join("")}`;
314
+ }
160
315
 
161
- const checkboxHtml =
162
- options.checkbox === undefined
163
- ? ""
164
- : `<input type="checkbox"${options.checked ? " checked" : ""}/> `;
316
+ const attrs =
317
+ (options.value !== undefined ? ` value="${options.value}"` : "") +
318
+ (options.type ? ` type="${options.type}"` : "");
319
+ const checkboxHtml =
320
+ options.checkbox === undefined
321
+ ? ""
322
+ : `<input type="checkbox"${options.checked ? " checked" : ""}/> `;
165
323
 
166
- return { html: `<li${attrs}>${checkboxHtml}${join(blocks)}</li>` };
324
+ return `<li${attrs}>${checkboxHtml}${children(blocks, d)}</li>`;
325
+ });
326
+
327
+ return Object.assign(node, { [LIST_ITEM]: true as const, value: options.value });
167
328
  }
168
329
 
169
330
  export interface ListOptions {
170
331
  /** `<ol>` instead of `<ul>`. defaults to `false`. */
171
332
  ordered?: boolean;
333
+ /** first number of an ordered list — standard `<ol start>` / the first gfm marker. */
334
+ start?: number;
172
335
  }
173
336
 
174
- /** `RichBlockList`, `<ul>`/`<ol>` of `item()`s. */
175
- export function list(items: Insertable[], options: ListOptions = {}): RichNode {
176
- const tag = options.ordered ? "ol" : "ul";
177
- return { html: `<${tag}>${join(items)}</${tag}>` };
337
+ /**
338
+ * `RichBlockList` `<ul>`/`<ol>` / `- `/`1. ` lines. entries may be plain
339
+ * values (wrapped in a default `item()` automatically) or explicit `item()`s
340
+ * for checkboxes and numbering overrides.
341
+ */
342
+ export function list(entries: Insertable[], options: ListOptions = {}): RichNode {
343
+ const items = entries.map((entry) => (isListItem(entry) ? entry : item([entry])));
344
+
345
+ return makeNode("block", (d) => {
346
+ if (d === "markdown") {
347
+ let counter = options.start ?? 1;
348
+
349
+ return items
350
+ .map((it) => {
351
+ if (it.value !== undefined) counter = it.value;
352
+ const marker = options.ordered ? `${counter++}.` : "-";
353
+
354
+ return `${marker} ${it.render(d)}`;
355
+ })
356
+ .join("\n");
357
+ }
358
+
359
+ const tag = options.ordered ? "ol" : "ul";
360
+ const start = options.ordered && options.start !== undefined ? ` start="${options.start}"` : "";
361
+
362
+ return `<${tag}${start}>${items.map((it) => it.render(d)).join("")}</${tag}>`;
363
+ });
178
364
  }
179
365
 
366
+ // --- media ---
367
+
180
368
  export interface MapOptions {
181
369
  /** 13–20, per `RichBlockMap.zoom`. */
182
370
  zoom: number;
@@ -185,8 +373,9 @@ export interface MapOptions {
185
373
  }
186
374
 
187
375
  /**
188
- * `RichBlockMap`, confirmed custom tag `<tg-map>` attribute names/encoding are a
189
- * best-effort guess (lat/long/zoom/width/height are telegram's own field names).
376
+ * `RichBlockMap`, confirmed custom tag `<tg-map>` (raw in markdown too)
377
+ * attribute names/encoding are a best-effort guess (lat/long/zoom/width/height
378
+ * are telegram's own field names).
190
379
  */
191
380
  export function map(
192
381
  location: { latitude: number; longitude: number },
@@ -197,7 +386,7 @@ export function map(
197
386
  ` latitude="${location.latitude}" longitude="${location.longitude}"` +
198
387
  ` zoom="${options.zoom}" width="${options.width}" height="${options.height}"`;
199
388
 
200
- return { html: `<tg-map${attrs}>${figcaption(caption)}</tg-map>` };
389
+ return makeNode("block", (d) => `<tg-map${attrs}>${figcaption(caption, d)}</tg-map>`);
201
390
  }
202
391
 
203
392
  export interface MediaOptions extends Caption {
@@ -206,11 +395,23 @@ export interface MediaOptions extends Caption {
206
395
  }
207
396
 
208
397
  function figure(tag: string, src: string, options: MediaOptions): RichNode {
209
- const spoilerAttr = options.spoiler ? " data-media-spoiler" : "";
210
- const media = `<${tag} src="${escapeAttr(src)}"${spoilerAttr}></${tag}>`;
211
- const cap = figcaption(options);
398
+ return makeNode("block", (d) => {
399
+ if (d === "markdown") {
400
+ // best-effort `![](url "caption")`; `credit`/`spoiler` have no markdown form and are dropped.
401
+ const title =
402
+ options.caption === undefined
403
+ ? ""
404
+ : ` "${render(options.caption, d).replace(/"/g, "&#34;")}"`;
212
405
 
213
- return { html: cap ? `<figure>${media}${cap}</figure>` : media };
406
+ return `![](${escapeMarkdownUrl(src)}${title})`;
407
+ }
408
+
409
+ const spoilerAttr = options.spoiler ? " data-media-spoiler" : "";
410
+ const media = `<${tag} src="${escapeAttr(src)}"${spoilerAttr}></${tag}>`;
411
+ const cap = figcaption(options, d);
412
+
413
+ return cap ? `<figure>${media}${cap}</figure>` : media;
414
+ });
214
415
  }
215
416
 
216
417
  /**
@@ -245,6 +446,23 @@ export function audio(src: string, options: MediaOptions = {}): RichNode {
245
446
  * schema states it can be used in `sendRichMessageDraft` payloads but is never
246
447
  * received back on a real message. see `RichMessageDraft` in draft.ts.
247
448
  */
248
- export function thinking(...children: Insertable[]): RichNode {
249
- return { html: `<tg-thinking>${join(children)}</tg-thinking>` };
449
+ export function thinking(...items: Insertable[]): RichNode {
450
+ return makeNode("block", (d) => `<tg-thinking>${children(items, d)}</tg-thinking>`);
451
+ }
452
+
453
+ // --- composition ---
454
+
455
+ /**
456
+ * join content with a separator. if any entry is a block, the result is a block
457
+ * and entries are blank-line-joined (markdown) / concatenated (html) — the
458
+ * separator only applies to all-inline joins.
459
+ */
460
+ export function join(entries: Insertable[], separator: Insertable = ""): RichNode {
461
+ const block = entries.some((entry) => isRichNode(entry) && entry.level === "block");
462
+
463
+ return makeNode(block ? "block" : "inline", (d) => {
464
+ const sep = block ? (d === "markdown" ? "\n\n" : "") : render(separator, d);
465
+
466
+ return entries.map((entry) => render(entry, d)).join(sep);
467
+ });
250
468
  }
@@ -0,0 +1,51 @@
1
+ import type { InputRichMessage } from "@yaebal/types";
2
+ import type { Dialect } from "./node.js";
3
+
4
+ /**
5
+ * the emitted rich message — a rendered dialect string plus the
6
+ * `InputRichMessage` flags, settable fluently (`.rtl()`, `.noEntityDetection()`).
7
+ *
8
+ * `sendRichMessage`/`RichMessageDraft` accept it directly, and `toJSON()`
9
+ * delegates to `toInputRichMessage()`, so even a raw `api.call` payload holding
10
+ * a `RichDocument` serializes into the correct `rich_message` shape.
11
+ */
12
+ export class RichDocument {
13
+ #isRtl?: boolean;
14
+ #skipEntityDetection?: boolean;
15
+
16
+ constructor(
17
+ readonly dialect: Dialect,
18
+ readonly content: string,
19
+ ) {}
20
+
21
+ /** `InputRichMessage.is_rtl` — show the message right-to-left. */
22
+ rtl(value = true): this {
23
+ this.#isRtl = value;
24
+ return this;
25
+ }
26
+
27
+ /**
28
+ * `InputRichMessage.skip_entity_detection` — turn off auto-detection of urls,
29
+ * emails, `@mentions`, `#hashtags`, `$cashtags`, `/bot_commands`, and phone
30
+ * numbers in plain text.
31
+ */
32
+ noEntityDetection(value = true): this {
33
+ this.#skipEntityDetection = value;
34
+ return this;
35
+ }
36
+
37
+ /** the `rich_message` method argument: `{ [dialect]: content, is_rtl?, skip_entity_detection? }`. */
38
+ toInputRichMessage(): InputRichMessage {
39
+ return {
40
+ [this.dialect]: this.content,
41
+ ...(this.#isRtl !== undefined ? { is_rtl: this.#isRtl } : {}),
42
+ ...(this.#skipEntityDetection !== undefined
43
+ ? { skip_entity_detection: this.#skipEntityDetection }
44
+ : {}),
45
+ };
46
+ }
47
+
48
+ toJSON(): InputRichMessage {
49
+ return this.toInputRichMessage();
50
+ }
51
+ }