@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/inline.ts CHANGED
@@ -1,105 +1,115 @@
1
1
  import type { User } from "@yaebal/core";
2
- import { escapeAttr, escapeText } from "./escape.js";
2
+ import { escapeAttr, escapeMarkdownUrl } from "./escape.js";
3
+ import { type Dialect, makeNode, type RichNode } from "./node.js";
4
+ import { escapeFor, type Insertable, render } from "./render.js";
3
5
 
4
- /**
5
- * a fragment of the extended html telegram parses into a `RichMessage` (see
6
- * `InputRichMessage.html`). unlike `@yaebal/fmt`'s `FormatResult` (a flat
7
- * `{ text, entities }` pair), rich messages are a block tree parsed server-side —
8
- * there is nothing to build client-side except this html string.
9
- */
10
- export interface RichNode {
11
- readonly html: string;
12
- }
13
-
14
- export type Insertable = RichNode | string | number | bigint | boolean | null | undefined;
6
+ // every builder here returns a dialect-agnostic `RichNode` — the html/markdown
7
+ // choice happens once, at the `html`/`md`/`document()` boundary. children render
8
+ // lazily, so a node built from user input is safe in either dialect.
15
9
 
16
- export function isRichNode(value: unknown): value is RichNode {
17
- return (
18
- typeof value === "object" && value !== null && typeof (value as RichNode).html === "string"
19
- );
10
+ function children(items: Insertable[], dialect: Dialect): string {
11
+ return items.map((item) => render(item, dialect)).join("");
20
12
  }
21
13
 
22
- /** render one interpolation: a `RichNode` is spliced raw, anything else is escaped text. */
23
- export function toHtml(value: Insertable): string {
24
- if (isRichNode(value)) return value.html;
25
- return value == null ? "" : escapeText(String(value));
14
+ // a wrapper mark: same shape in both dialects, differing only in the surrounding tokens.
15
+ function wrap(md: (inner: string) => string, html: (inner: string) => string) {
16
+ return (...items: Insertable[]): RichNode =>
17
+ makeNode("inline", (d) => (d === "markdown" ? md : html)(children(items, d)));
26
18
  }
27
19
 
28
- function join(children: Insertable[]): string {
29
- return children.map(toHtml).join("");
30
- }
20
+ // --- inline marks — confirmed tags (same dialect as classic parse_mode html) ---
31
21
 
32
- function wrap(tag: string) {
33
- return (...children: Insertable[]): RichNode => ({ html: `<${tag}>${join(children)}</${tag}>` });
22
+ /** `RichTextBold` `<b>` / `**x**`. */
23
+ export const bold = wrap(
24
+ (x) => `**${x}**`,
25
+ (x) => `<b>${x}</b>`,
26
+ );
27
+ /** `RichTextItalic` — `<i>` / `*x*`. */
28
+ export const italic = wrap(
29
+ (x) => `*${x}*`,
30
+ (x) => `<i>${x}</i>`,
31
+ );
32
+ /** `RichTextUnderline` — `<u>`; no markdown token, the raw tag is embedded there too. */
33
+ export const underline = wrap(
34
+ (x) => `<u>${x}</u>`,
35
+ (x) => `<u>${x}</u>`,
36
+ );
37
+ /** `RichTextStrikethrough` — `<s>` / `~~x~~`. */
38
+ export const strikethrough = wrap(
39
+ (x) => `~~${x}~~`,
40
+ (x) => `<s>${x}</s>`,
41
+ );
42
+ /** `RichTextSpoiler` — `<tg-spoiler>` / `||x||`. */
43
+ export const spoiler = wrap(
44
+ (x) => `||${x}||`,
45
+ (x) => `<tg-spoiler>${x}</tg-spoiler>`,
46
+ );
47
+ /** `RichTextCode` — `<code>` / `` `x` ``. */
48
+ export const code = wrap(
49
+ (x) => `\`${x}\``,
50
+ (x) => `<code>${x}</code>`,
51
+ );
52
+
53
+ /** line break — hard newline in markdown, `<br/>` in html. */
54
+ export function br(): RichNode {
55
+ return makeNode("inline", (d) => (d === "markdown" ? "\n" : "<br/>"));
34
56
  }
35
57
 
36
58
  /**
37
- * an extended-html template: interpolations are auto-escaped (never re-parsed as
38
- * markup), and a nested `RichNode` (from these builders, or another `html`
39
- * template) is spliced in raw. mirrors `@yaebal/fmt`'s `html` tag, but for the
40
- * rich-message dialect instead of the classic entity one.
59
+ * `RichTextCustomEmoji` — `<tg-emoji emoji-id="…">`, the same custom tag classic
60
+ * `parse_mode: "HTML"` uses (telegram documents it as reused verbatim here);
61
+ * best-effort `![fallback](tg://emoji?id=…)` in markdown. `fallback` is the
62
+ * plain emoji shown where custom emoji can't render.
41
63
  */
42
- export function html(strings: TemplateStringsArray, ...subs: Insertable[]): RichNode {
43
- let out = "";
44
-
45
- for (let i = 0; i < strings.length; i++) {
46
- out += strings[i] ?? "";
47
- if (i < subs.length) out += toHtml(subs[i]);
48
- }
49
-
50
- return { html: out };
64
+ export function customEmoji(emojiId: string, fallback: string): RichNode {
65
+ return makeNode("inline", (d) =>
66
+ d === "markdown"
67
+ ? `![${escapeFor(fallback, d)}](${escapeMarkdownUrl(`tg://emoji?id=${emojiId}`)})`
68
+ : `<tg-emoji emoji-id="${escapeAttr(emojiId)}">${escapeFor(fallback, d)}</tg-emoji>`,
69
+ );
51
70
  }
52
71
 
53
- // --- inline marks confirmed tags (same dialect as classic parse_mode html) ---
54
-
55
- /** `RichTextBold`, `<b>`. */
56
- export const bold = wrap("b");
57
- /** `RichTextItalic`, `<i>`. */
58
- export const italic = wrap("i");
59
- /** `RichTextUnderline`, `<u>`. */
60
- export const underline = wrap("u");
61
- /** `RichTextStrikethrough`, `<s>`. */
62
- export const strikethrough = wrap("s");
63
- /** `RichTextSpoiler`, `<tg-spoiler>`. */
64
- export const spoiler = wrap("tg-spoiler");
65
- /** `RichTextCode`, `<code>`. */
66
- export const code = wrap("code");
72
+ // a markdown link destination: `[text](url)` is terminated by `)` or whitespace,
73
+ // so the url must be escaped to keep an attacker-controlled value inside the link.
74
+ function mdLink(text: string, url: string): string {
75
+ return `[${text}](${escapeMarkdownUrl(url)})`;
76
+ }
67
77
 
68
- /**
69
- * `RichTextCustomEmoji`, `<tg-emoji emoji-id="…">`. same custom tag classic
70
- * `parse_mode: "HTML"` uses for custom emoji — telegram documents it as reused
71
- * verbatim here. `fallback` is the plain emoji shown where custom emoji can't render.
72
- */
73
- export function customEmoji(emojiId: string, fallback: string): RichNode {
74
- return { html: `<tg-emoji emoji-id="${escapeAttr(emojiId)}">${escapeText(fallback)}</tg-emoji>` };
78
+ /** `RichTextUrl`, an explicit link. for a bare auto-linked url, just write it as plain text. */
79
+ export function link(url: string, ...items: Insertable[]): RichNode {
80
+ return makeNode("inline", (d) =>
81
+ d === "markdown"
82
+ ? mdLink(children(items, d), url)
83
+ : `<a href="${escapeAttr(url)}">${children(items, d)}</a>`,
84
+ );
75
85
  }
76
86
 
77
87
  /**
78
- * `RichTextTextMention`, a mention of a user who may have no `@username` — same
79
- * `tg://user?id=…` link telegram's classic html dialect uses for `text_mention`.
88
+ * `RichTextTextMention`, a mention of a user who may have no `@username` — the
89
+ * same `tg://user?id=…` link telegram's classic dialects use for `text_mention`.
80
90
  * for `@username` mentions (`RichTextMention`), just write `@username` as plain
81
- * text — the schema lists it as auto-detected (see `skipEntityDetection`).
91
+ * text — the schema lists it as auto-detected (see `noEntityDetection`).
82
92
  */
83
- export function textMention(user: Pick<User, "id">, ...children: Insertable[]): RichNode {
84
- return { html: `<a href="tg://user?id=${user.id}">${join(children)}</a>` };
85
- }
86
-
87
- /** `RichTextUrl`, an explicit link. for a bare auto-linked url, just write it as plain text. */
88
- export function link(url: string, ...children: Insertable[]): RichNode {
89
- return { html: `<a href="${escapeAttr(url)}">${join(children)}</a>` };
93
+ export function textMention(user: Pick<User, "id">, ...items: Insertable[]): RichNode {
94
+ return link(`tg://user?id=${user.id}`, ...items);
90
95
  }
91
96
 
92
- /** `RichTextAnchor` (inline form) — a named jump target, `<a name="…">`. */
97
+ /** `RichTextAnchor` (inline form) — a named jump target, `<a name="…">` in both dialects. */
93
98
  export function anchor(name: string): RichNode {
94
- return { html: `<a name="${escapeAttr(name)}"></a>` };
99
+ return makeNode("inline", () => `<a name="${escapeAttr(name)}"></a>`);
95
100
  }
96
101
 
97
102
  /**
98
103
  * `RichTextAnchorLink`, a link to an `anchor()` elsewhere in the message —
99
- * `<a href="#name">`. an empty `name` jumps back to the top (per the schema).
104
+ * `<a href="#name">` / `[text](#name)`. an empty `name` jumps back to the top
105
+ * (per the schema).
100
106
  */
101
- export function anchorLink(name: string, ...children: Insertable[]): RichNode {
102
- return { html: `<a href="#${escapeAttr(name)}">${join(children)}</a>` };
107
+ export function anchorLink(name: string, ...items: Insertable[]): RichNode {
108
+ return makeNode("inline", (d) =>
109
+ d === "markdown"
110
+ ? mdLink(children(items, d), `#${name}`)
111
+ : `<a href="#${escapeAttr(name)}">${children(items, d)}</a>`,
112
+ );
103
113
  }
104
114
 
105
115
  // --- inline marks — best-effort tags ---
@@ -111,42 +121,66 @@ export function anchorLink(name: string, ...children: Insertable[]): RichNode {
111
121
  // convention — verify against the live "rich message formatting options" docs
112
122
  // before depending on the exact tag/attribute spelling in production.
113
123
 
114
- /** `RichTextMarked`, best-effort `<mark>` (standard html5 highlight). */
115
- export const marked = wrap("mark");
116
- /** `RichTextSubscript`, best-effort `<sub>`. */
117
- export const subscript = wrap("sub");
118
- /** `RichTextSuperscript`, best-effort `<sup>`. */
119
- export const superscript = wrap("sup");
124
+ /** `RichTextMarked`, highlighted text — best-effort `<mark>` / `==x==`. */
125
+ export const marked = wrap(
126
+ (x) => `==${x}==`,
127
+ (x) => `<mark>${x}</mark>`,
128
+ );
129
+ /** `RichTextSubscript` best-effort `<sub>`; no markdown token, raw tag embedded there too. */
130
+ export const subscript = wrap(
131
+ (x) => `<sub>${x}</sub>`,
132
+ (x) => `<sub>${x}</sub>`,
133
+ );
134
+ /** `RichTextSuperscript` — best-effort `<sup>`; no markdown token, raw tag embedded there too. */
135
+ export const superscript = wrap(
136
+ (x) => `<sup>${x}</sup>`,
137
+ (x) => `<sup>${x}</sup>`,
138
+ );
120
139
 
121
140
  /**
122
- * `RichTextMathematicalExpression` (inline), best-effort `<tg-math>` (the block
123
- * form is confirmed as `<tg-math-block>` — see `mathBlock` in blocks.ts).
141
+ * `RichTextMathematicalExpression` (inline) best-effort `<tg-math>` / `$x$`
142
+ * (the block form is confirmed as `<tg-math-block>` — see `mathBlock` in
143
+ * blocks.ts). `expression` is raw LaTeX — not markdown-escaped.
124
144
  */
125
145
  export function math(expression: string): RichNode {
126
- return { html: `<tg-math>${escapeText(expression)}</tg-math>` };
146
+ return makeNode("inline", (d) =>
147
+ d === "markdown" ? `$${expression}$` : `<tg-math>${escapeFor(expression, "html")}</tg-math>`,
148
+ );
127
149
  }
128
150
 
129
151
  /**
130
- * `RichTextDateTime`, best-effort `<time>`. `format` is telegram's date-time
131
- * entity format string; attribute name is a guess (`data-format`).
152
+ * `RichTextDateTime`, auto-formatted date-time — best-effort `<time>` (attribute
153
+ * name `data-format` is a guess) / `![label](tg://time?unix=…&format=…)`.
154
+ * `format` is telegram's date-time entity format string.
132
155
  */
133
- export function dateTime(unixTime: number, format: string, ...children: Insertable[]): RichNode {
134
- return {
135
- html: `<time datetime="${unixTime}" data-format="${escapeAttr(format)}">${join(children)}</time>`,
136
- };
156
+ export function dateTime(unixTime: number, format: string, ...items: Insertable[]): RichNode {
157
+ const query = `tg://time?unix=${unixTime}${format ? `&format=${format}` : ""}`;
158
+
159
+ return makeNode("inline", (d) =>
160
+ d === "markdown"
161
+ ? `![${children(items, d)}](${escapeMarkdownUrl(query)})`
162
+ : `<time datetime="${unixTime}" data-format="${escapeAttr(format)}">${children(items, d)}</time>`,
163
+ );
137
164
  }
138
165
 
139
166
  /**
140
167
  * `RichTextReference`, a footnote definition. no tag is documented anywhere in
141
- * the scraped schema — this is a from-scratch `tg-*`-style guess.
168
+ * the scraped schema — `<tg-reference>` is a from-scratch `tg-*`-style guess,
169
+ * `[^name]: …` the standard markdown footnote line (place it at line start).
142
170
  */
143
- export function reference(name: string, ...children: Insertable[]): RichNode {
144
- return { html: `<tg-reference name="${escapeAttr(name)}">${join(children)}</tg-reference>` };
171
+ export function reference(name: string, ...items: Insertable[]): RichNode {
172
+ return makeNode("inline", (d) =>
173
+ d === "markdown"
174
+ ? `[^${name}]: ${children(items, d)}`
175
+ : `<tg-reference name="${escapeAttr(name)}">${children(items, d)}</tg-reference>`,
176
+ );
145
177
  }
146
178
 
147
- /** `RichTextReferenceLink`, a link to a `reference()`. same caveat as `reference`. */
148
- export function referenceLink(name: string, ...children: Insertable[]): RichNode {
149
- return {
150
- html: `<tg-reference-link name="${escapeAttr(name)}">${join(children)}</tg-reference-link>`,
151
- };
179
+ /** `RichTextReferenceLink`, a link to a `reference()` same caveat as `reference`. */
180
+ export function referenceLink(name: string, ...items: Insertable[]): RichNode {
181
+ return makeNode("inline", (d) =>
182
+ d === "markdown"
183
+ ? `[^${name}]`
184
+ : `<tg-reference-link name="${escapeAttr(name)}">${children(items, d)}</tg-reference-link>`,
185
+ );
152
186
  }
package/src/node.ts ADDED
@@ -0,0 +1,43 @@
1
+ /** the two wire dialects of `InputRichMessage` — `html` and `markdown`. */
2
+ export type Dialect = "html" | "markdown";
3
+
4
+ /** whether a node is an inline mark (bold, link, …) or a top-level block (paragraph, table, …). */
5
+ export type Level = "inline" | "block";
6
+
7
+ // `Symbol.for` so two copies of the package in one node_modules tree still
8
+ // recognise each other's nodes (a duck-typed `.html` check would false-positive
9
+ // on any object that happens to carry an `html: string` field).
10
+ const RICH_NODE = Symbol.for("yaebal.rich.node");
11
+
12
+ /**
13
+ * one builder result — a dialect-agnostic fragment of a rich message. every
14
+ * builder (`bold`, `paragraph`, `table`, …) returns one of these; nothing is
15
+ * serialized until the node lands in an `html`/`md` template or a `document()`,
16
+ * which picks the dialect and calls `render` once.
17
+ */
18
+ export interface RichNode {
19
+ readonly [RICH_NODE]: true;
20
+ readonly level: Level;
21
+ render(dialect: Dialect): string;
22
+ }
23
+
24
+ /** construct a `RichNode` from a level + per-dialect renderer. */
25
+ export function makeNode(level: Level, render: (dialect: Dialect) => string): RichNode {
26
+ return { [RICH_NODE]: true as const, level, render };
27
+ }
28
+
29
+ export function isRichNode(value: unknown): value is RichNode {
30
+ return (
31
+ typeof value === "object" &&
32
+ value !== null &&
33
+ (value as Record<symbol, unknown>)[RICH_NODE] === true
34
+ );
35
+ }
36
+
37
+ /** thrown on misuse — a dialect-mismatched interpolation, unsupported content. */
38
+ export class RichError extends Error {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = "RichError";
42
+ }
43
+ }
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { RichBlock, RichMessage } from "@yaebal/types";
4
+ import { richBlockToPlainText, richMessageToPlainText, richTextToPlainText } from "./plaintext.js";
5
+
6
+ test("richTextToPlainText flattens a plain string, nested marks, and arrays", () => {
7
+ assert.equal(richTextToPlainText("hi"), "hi");
8
+ assert.equal(richTextToPlainText({ type: "bold", text: "hi" } as never), "hi");
9
+ assert.equal(
10
+ richTextToPlainText([{ type: "bold", text: "a" }, " ", { type: "italic", text: "b" }] as never),
11
+ "a b",
12
+ );
13
+ });
14
+
15
+ test("richTextToPlainText special-cases custom_emoji/mathematical_expression/anchor", () => {
16
+ assert.equal(
17
+ richTextToPlainText({ type: "custom_emoji", alternative_text: "😀" } as never),
18
+ "😀",
19
+ );
20
+ assert.equal(
21
+ richTextToPlainText({ type: "mathematical_expression", expression: "a^2" } as never),
22
+ "a^2",
23
+ );
24
+ assert.equal(richTextToPlainText({ type: "anchor", name: "top" } as never), "");
25
+ });
26
+
27
+ test("richTextToPlainText returns empty string for undefined", () => {
28
+ assert.equal(richTextToPlainText(undefined), "");
29
+ });
30
+
31
+ test("richBlockToPlainText flattens simple text-bearing blocks with a trailing newline", () => {
32
+ assert.equal(richBlockToPlainText({ type: "paragraph", text: "hi" } as never), "hi\n");
33
+ assert.equal(
34
+ richBlockToPlainText({ type: "heading", text: "title", size: 1 } as never),
35
+ "title\n",
36
+ );
37
+ assert.equal(richBlockToPlainText({ type: "pre", text: "code" } as never), "code\n");
38
+ assert.equal(richBlockToPlainText({ type: "footer", text: "f" } as never), "f\n");
39
+ assert.equal(richBlockToPlainText({ type: "thinking", text: "…" } as never), "…\n");
40
+ assert.equal(richBlockToPlainText({ type: "divider" } as never), "---\n");
41
+ assert.equal(richBlockToPlainText({ type: "anchor", name: "top" } as never), "");
42
+ assert.equal(
43
+ richBlockToPlainText({ type: "mathematical_expression", expression: "a^2" } as never),
44
+ "a^2\n",
45
+ );
46
+ });
47
+
48
+ test("richBlockToPlainText flattens a list, one `- ` item per line", () => {
49
+ const block = {
50
+ type: "list",
51
+ items: [
52
+ { blocks: [{ type: "paragraph", text: "one" }] },
53
+ { blocks: [{ type: "paragraph", text: "two" }] },
54
+ ],
55
+ } as never;
56
+
57
+ assert.equal(richBlockToPlainText(block), "- one\n- two\n");
58
+ });
59
+
60
+ test("richBlockToPlainText flattens blockquote/pullquote with an optional ' — credit' suffix", () => {
61
+ assert.equal(
62
+ richBlockToPlainText({
63
+ type: "blockquote",
64
+ blocks: [{ type: "paragraph", text: "x" }],
65
+ } as never),
66
+ "x\n\n",
67
+ );
68
+ assert.equal(
69
+ richBlockToPlainText({
70
+ type: "blockquote",
71
+ blocks: [{ type: "paragraph", text: "x" }],
72
+ credit: "author",
73
+ } as never),
74
+ "x\n — author\n",
75
+ );
76
+ assert.equal(richBlockToPlainText({ type: "pullquote", text: "x" } as never), "x\n");
77
+ assert.equal(
78
+ richBlockToPlainText({ type: "pullquote", text: "x", credit: "author" } as never),
79
+ "x — author\n",
80
+ );
81
+ });
82
+
83
+ test("richBlockToPlainText flattens collage/slideshow blocks with a captioned trailer", () => {
84
+ const block = {
85
+ type: "collage",
86
+ blocks: [{ type: "photo", photo: [], caption: undefined }],
87
+ caption: { text: "a cat", credit: "photographer" },
88
+ } as never;
89
+
90
+ assert.equal(richBlockToPlainText(block), "a cat (photographer)\n");
91
+ });
92
+
93
+ test("richBlockToPlainText flattens a table, ` | `-joined cells, one row per line", () => {
94
+ const block = {
95
+ type: "table",
96
+ cells: [
97
+ [{ text: "a" }, { text: "b" }],
98
+ [{ text: "c" }, { text: "d" }],
99
+ ],
100
+ } as never;
101
+
102
+ assert.equal(richBlockToPlainText(block), "a | b\nc | d\n");
103
+ });
104
+
105
+ test("richBlockToPlainText flattens details as its summary followed by its blocks", () => {
106
+ const block = {
107
+ type: "details",
108
+ summary: "more",
109
+ blocks: [{ type: "paragraph", text: "hidden" }],
110
+ } as never;
111
+
112
+ assert.equal(richBlockToPlainText(block), "more\nhidden\n");
113
+ });
114
+
115
+ test("richBlockToPlainText flattens map/media blocks down to just their caption", () => {
116
+ assert.equal(richBlockToPlainText({ type: "map", caption: { text: "here" } } as never), "here\n");
117
+ assert.equal(richBlockToPlainText({ type: "photo", photo: [], caption: undefined } as never), "");
118
+ for (const type of ["animation", "audio", "photo", "video", "voice_note"] as const) {
119
+ assert.equal(richBlockToPlainText({ type, caption: { text: "cap" } } as never), "cap\n");
120
+ }
121
+ });
122
+
123
+ test("richBlockToPlainText falls back to empty string for an unrecognized block type", () => {
124
+ assert.equal(richBlockToPlainText({ type: "not-a-real-type" } as unknown as RichBlock), "");
125
+ });
126
+
127
+ test("richMessageToPlainText flattens a full block tree, joined and trimmed", () => {
128
+ const message = {
129
+ blocks: [
130
+ { type: "heading", text: "title", size: 1 },
131
+ { type: "paragraph", text: [{ type: "bold", text: "hi" }, " there"] },
132
+ { type: "divider" },
133
+ {
134
+ type: "list",
135
+ items: [{ label: "1", blocks: [{ type: "paragraph", text: "one" }] }],
136
+ },
137
+ ],
138
+ } as unknown as RichMessage;
139
+
140
+ assert.equal(richMessageToPlainText(message), "title\nhi there\n---\n- one");
141
+ });
package/src/render.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { RichDocument } from "./document.js";
2
+ import { escapeMarkdown, escapeText } from "./escape.js";
3
+ import { type Dialect, isRichNode, RichError, type RichNode } from "./node.js";
4
+
5
+ /**
6
+ * anything a builder or template accepts as content:
7
+ *
8
+ * - `string` / `number` / `bigint` — escaped for the target dialect, renders literally;
9
+ * - a `RichNode` (builder result) — rendered into the target dialect;
10
+ * - a `RichDocument` — inlined as-is (its dialect must match, else `RichError`);
11
+ * - `null` / `undefined` / `false` — empty string, so `cond && bold(…)` composes;
12
+ * - an array — each item rendered and concatenated.
13
+ */
14
+ export type Insertable =
15
+ | RichNode
16
+ | RichDocument
17
+ | string
18
+ | number
19
+ | bigint
20
+ | null
21
+ | undefined
22
+ | false
23
+ | readonly Insertable[];
24
+
25
+ /** escape plain text for a dialect so it can never be re-parsed as markup. */
26
+ export function escapeFor(text: string, dialect: Dialect): string {
27
+ return dialect === "html" ? escapeText(text) : escapeMarkdown(text);
28
+ }
29
+
30
+ /** resolve one piece of content into a dialect string (text escaped, nodes rendered). */
31
+ export function render(value: Insertable, dialect: Dialect): string {
32
+ if (value === null || value === undefined || value === false) return "";
33
+
34
+ if (typeof value === "string") return escapeFor(value, dialect);
35
+ if (typeof value === "number" || typeof value === "bigint") {
36
+ return escapeFor(String(value), dialect);
37
+ }
38
+
39
+ if (Array.isArray(value)) {
40
+ return value.map((item) => render(item as Insertable, dialect)).join("");
41
+ }
42
+
43
+ if (value instanceof RichDocument) {
44
+ if (value.dialect !== dialect) {
45
+ throw new RichError(`cannot inline a ${value.dialect} document into a ${dialect} template`);
46
+ }
47
+
48
+ return value.content;
49
+ }
50
+
51
+ if (isRichNode(value)) return value.render(dialect);
52
+
53
+ throw new RichError(`unsupported rich content: ${typeof value}`);
54
+ }
package/src/send.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { Api, Context, Message, Plugin } from "@yaebal/core";
2
2
  import type { InputRichMessage } from "@yaebal/types";
3
+ import { RichDocument } from "./document.js";
3
4
  import { RichMessageDraft, type RichMessageDraftOptions } from "./draft.js";
4
5
 
5
- function toInput(input: InputRichMessage | string): InputRichMessage {
6
- return typeof input === "string" ? { html: input } : input;
6
+ /** everything the send/draft surface accepts: a template/`document()` result, a raw payload, or an html string. */
7
+ export type RichSource = RichDocument | InputRichMessage | string;
8
+
9
+ function toInput(input: RichSource): InputRichMessage {
10
+ if (typeof input === "string") return { html: input };
11
+ if (input instanceof RichDocument) return input.toInputRichMessage();
12
+ return input;
7
13
  }
8
14
 
9
15
  /**
@@ -17,7 +23,7 @@ function toInput(input: InputRichMessage | string): InputRichMessage {
17
23
  export function sendRichMessage(
18
24
  api: Api,
19
25
  chatId: number | string,
20
- input: InputRichMessage | string,
26
+ input: RichSource,
21
27
  extra: Record<string, unknown> = {},
22
28
  ): Promise<Message> {
23
29
  return api.call<Message>("sendRichMessage", {
@@ -36,7 +42,7 @@ export function sendRichMessageDraft(
36
42
  api: Api,
37
43
  chatId: number,
38
44
  draftId: number,
39
- input: InputRichMessage | string,
45
+ input: RichSource,
40
46
  extra: Record<string, unknown> = {},
41
47
  ): Promise<boolean> {
42
48
  return api.call<boolean>("sendRichMessageDraft", {
@@ -49,10 +55,7 @@ export function sendRichMessageDraft(
49
55
 
50
56
  export interface RichContext {
51
57
  /** `ctx.send`-flavored `sendRichMessage`, bound to the current chat. */
52
- sendRichMessage(
53
- input: InputRichMessage | string,
54
- extra?: Record<string, unknown>,
55
- ): Promise<Message>;
58
+ sendRichMessage(input: RichSource, extra?: Record<string, unknown>): Promise<Message>;
56
59
  /** open a `RichMessageDraft` streaming session bound to the current chat. */
57
60
  richMessageDraft(draftId: number, options?: RichMessageDraftOptions): RichMessageDraft;
58
61
  }
@@ -67,7 +70,7 @@ export function rich(): Plugin<Context, RichContext> {
67
70
  return Promise.reject(new Error("sendRichMessage(): no chat in this update"));
68
71
  }
69
72
 
70
- return sendRichMessage(this.api, chatId, input, extra);
73
+ return sendRichMessage(this.api, chatId, input, { ...this.routing(), ...extra });
71
74
  },
72
75
  richMessageDraft(this: Context, draftId, options) {
73
76
  const chatId = this.chat?.id;
@@ -75,7 +78,11 @@ export function rich(): Plugin<Context, RichContext> {
75
78
  throw new Error("richMessageDraft(): no chat in this update");
76
79
  }
77
80
 
78
- return new RichMessageDraft(this.api, chatId, draftId, options);
81
+ return new RichMessageDraft(this.api, chatId, draftId, {
82
+ messageThreadId: this.messageThreadId,
83
+ businessConnectionId: this.businessConnectionId,
84
+ ...options,
85
+ });
79
86
  },
80
87
  });
81
88
  }