@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.
- package/README.md +104 -30
- 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 +6 -5
- 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/blocks.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { escapeAttr,
|
|
2
|
-
import { type
|
|
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
|
-
|
|
5
|
-
|
|
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>${
|
|
19
|
-
return `<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
|
|
25
|
-
export function paragraph(...
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
export function footer(...
|
|
42
|
-
return
|
|
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
|
|
80
|
+
/** `RichBlockDivider` — `<hr/>` / `---`. */
|
|
46
81
|
export function divider(): RichNode {
|
|
47
|
-
return
|
|
82
|
+
return makeNode("block", (d) => (d === "markdown" ? "---" : "<hr/>"));
|
|
48
83
|
}
|
|
49
84
|
|
|
50
|
-
/** `RichBlockMathematicalExpression
|
|
85
|
+
/** `RichBlockMathematicalExpression` — confirmed `<tg-math-block>` / `$$…$$` (raw LaTeX, unescaped in markdown). */
|
|
51
86
|
export function mathBlock(expression: string): RichNode {
|
|
52
|
-
return
|
|
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
|
|
94
|
+
/** `RichBlockAnchor` — `<a name="…">` at block level, in both dialects. */
|
|
56
95
|
export function anchorBlock(name: string): RichNode {
|
|
57
|
-
return
|
|
96
|
+
return makeNode("block", () => `<a name="${escapeAttr(name)}"></a>`);
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
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
|
|
147
|
+
/** `RichBlockCollage` — confirmed custom tag `<tg-collage>` (raw in markdown too). */
|
|
73
148
|
export function collage(blocks: Insertable[], caption: Caption = {}): RichNode {
|
|
74
|
-
return
|
|
149
|
+
return mediaGroup("tg-collage", blocks, caption);
|
|
75
150
|
}
|
|
76
151
|
|
|
77
|
-
/** `RichBlockSlideshow
|
|
152
|
+
/** `RichBlockSlideshow` — confirmed custom tag `<tg-slideshow>` (raw in markdown too). */
|
|
78
153
|
export function slideshow(blocks: Insertable[], caption: Caption = {}): RichNode {
|
|
79
|
-
return
|
|
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 = {}):
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
156
|
-
export
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
? ""
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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>`
|
|
189
|
-
* best-effort guess (lat/long/zoom/width/height
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
398
|
+
return makeNode("block", (d) => {
|
|
399
|
+
if (d === "markdown") {
|
|
400
|
+
// best-effort ``; `credit`/`spoiler` have no markdown form and are dropped.
|
|
401
|
+
const title =
|
|
402
|
+
options.caption === undefined
|
|
403
|
+
? ""
|
|
404
|
+
: ` "${render(options.caption, d).replace(/"/g, """)}"`;
|
|
212
405
|
|
|
213
|
-
|
|
406
|
+
return `}${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(...
|
|
249
|
-
return
|
|
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
|
}
|
package/src/document.ts
ADDED
|
@@ -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
|
+
}
|