@yaebal/rich 0.0.1 → 0.0.3
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.
- package/README.md +109 -35
- package/lib/blocks.d.ts +93 -32
- package/lib/blocks.d.ts.map +1 -1
- package/lib/blocks.js +227 -71
- package/lib/blocks.js.map +1 -1
- package/lib/blocks.test.d.ts +2 -0
- package/lib/blocks.test.d.ts.map +1 -0
- package/lib/blocks.test.js +142 -0
- package/lib/blocks.test.js.map +1 -0
- package/lib/document.d.ts +28 -0
- package/lib/document.d.ts.map +1 -0
- package/lib/document.js +46 -0
- package/lib/document.js.map +1 -0
- package/lib/draft.d.ts +46 -9
- package/lib/draft.d.ts.map +1 -1
- package/lib/draft.js +115 -19
- package/lib/draft.js.map +1 -1
- package/lib/draft.test.d.ts +2 -0
- package/lib/draft.test.d.ts.map +1 -0
- package/lib/draft.test.js +122 -0
- package/lib/draft.test.js.map +1 -0
- package/lib/escape.d.ts +8 -0
- package/lib/escape.d.ts.map +1 -1
- package/lib/escape.js +17 -0
- package/lib/escape.js.map +1 -1
- package/lib/escape.test.d.ts +2 -0
- package/lib/escape.test.d.ts.map +1 -0
- package/lib/escape.test.js +28 -0
- package/lib/escape.test.js.map +1 -0
- package/lib/guards.test.d.ts +2 -0
- package/lib/guards.test.d.ts.map +1 -0
- package/lib/guards.test.js +85 -0
- package/lib/guards.test.js.map +1 -0
- package/lib/index.d.ts +18 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -8
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +73 -103
- package/lib/index.test.js.map +1 -1
- package/lib/inline.d.ts +48 -59
- package/lib/inline.d.ts.map +1 -1
- package/lib/inline.js +87 -82
- package/lib/inline.js.map +1 -1
- package/lib/inline.test.d.ts +2 -0
- package/lib/inline.test.d.ts.map +1 -0
- package/lib/inline.test.js +84 -0
- package/lib/inline.test.js.map +1 -0
- package/lib/node.d.ts +25 -0
- package/lib/node.d.ts.map +1 -0
- package/lib/node.js +21 -0
- package/lib/node.js.map +1 -0
- package/lib/plaintext.test.d.ts +2 -0
- package/lib/plaintext.test.d.ts.map +1 -0
- package/lib/plaintext.test.js +100 -0
- package/lib/plaintext.test.js.map +1 -0
- package/lib/render.d.ts +17 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +30 -0
- package/lib/render.js.map +1 -0
- package/lib/send.d.ts +6 -3
- package/lib/send.d.ts.map +1 -1
- package/lib/send.js +12 -3
- package/lib/send.js.map +1 -1
- package/lib/template.d.ts +46 -0
- package/lib/template.d.ts.map +1 -0
- package/lib/template.js +82 -0
- package/lib/template.js.map +1 -0
- package/lib/template.test.d.ts +2 -0
- package/lib/template.test.d.ts.map +1 -0
- package/lib/template.test.js +78 -0
- package/lib/template.test.js.map +1 -0
- package/package.json +5 -4
- package/src/blocks.test.ts +233 -0
- package/src/blocks.ts +304 -86
- package/src/document.ts +51 -0
- package/src/draft.test.ts +167 -0
- package/src/draft.ts +148 -21
- package/src/escape.test.ts +38 -0
- package/src/escape.ts +20 -0
- package/src/guards.test.ts +138 -0
- package/src/index.test.ts +79 -140
- package/src/index.ts +26 -11
- package/src/inline.test.ts +125 -0
- package/src/inline.ts +131 -97
- package/src/node.ts +43 -0
- package/src/plaintext.test.ts +141 -0
- package/src/render.ts +54 -0
- package/src/send.ts +17 -10
- package/src/template.test.ts +100 -0
- package/src/template.ts +115 -0
- package/lib/message.d.ts +0 -26
- package/lib/message.d.ts.map +0 -1
- package/lib/message.js +0 -31
- package/lib/message.js.map +0 -1
- 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,
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
return children.map(toHtml).join("");
|
|
30
|
-
}
|
|
20
|
+
// --- inline marks — confirmed tags (same dialect as classic parse_mode html) ---
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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 `` in markdown. `fallback` is the
|
|
62
|
+
* plain emoji shown where custom emoji can't render.
|
|
41
63
|
*/
|
|
42
|
-
export function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return { html: out };
|
|
64
|
+
export function customEmoji(emojiId: string, fallback: string): RichNode {
|
|
65
|
+
return makeNode("inline", (d) =>
|
|
66
|
+
d === "markdown"
|
|
67
|
+
? `})`
|
|
68
|
+
: `<tg-emoji emoji-id="${escapeAttr(emojiId)}">${escapeFor(fallback, d)}</tg-emoji>`,
|
|
69
|
+
);
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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` —
|
|
79
|
-
* `tg://user?id=…` link telegram's classic
|
|
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 `
|
|
91
|
+
* text — the schema lists it as auto-detected (see `noEntityDetection`).
|
|
82
92
|
*/
|
|
83
|
-
export function textMention(user: Pick<User, "id">, ...
|
|
84
|
-
return
|
|
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
|
|
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"
|
|
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, ...
|
|
102
|
-
return
|
|
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>`
|
|
115
|
-
export const marked = wrap(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)
|
|
123
|
-
* form is confirmed as `<tg-math-block>` — see `mathBlock` in
|
|
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
|
|
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
|
|
131
|
-
*
|
|
152
|
+
* `RichTextDateTime`, auto-formatted date-time — best-effort `<time>` (attribute
|
|
153
|
+
* name `data-format` is a guess) / ``.
|
|
154
|
+
* `format` is telegram's date-time entity format string.
|
|
132
155
|
*/
|
|
133
|
-
export function dateTime(unixTime: number, format: string, ...
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
? `})`
|
|
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 —
|
|
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, ...
|
|
144
|
-
return
|
|
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()
|
|
148
|
-
export function referenceLink(name: string, ...
|
|
149
|
-
return
|
|
150
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
}
|