@yaebal/rich 0.0.1

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/lib/blocks.d.ts +115 -0
  4. package/lib/blocks.d.ts.map +1 -0
  5. package/lib/blocks.js +149 -0
  6. package/lib/blocks.js.map +1 -0
  7. package/lib/draft.d.ts +44 -0
  8. package/lib/draft.d.ts.map +1 -0
  9. package/lib/draft.js +85 -0
  10. package/lib/draft.js.map +1 -0
  11. package/lib/escape.d.ts +5 -0
  12. package/lib/escape.d.ts.map +1 -0
  13. package/lib/escape.js +9 -0
  14. package/lib/escape.js.map +1 -0
  15. package/lib/guards.d.ts +106 -0
  16. package/lib/guards.d.ts.map +1 -0
  17. package/lib/guards.js +55 -0
  18. package/lib/guards.js.map +1 -0
  19. package/lib/index.d.ts +33 -0
  20. package/lib/index.d.ts.map +1 -0
  21. package/lib/index.js +32 -0
  22. package/lib/index.js.map +1 -0
  23. package/lib/index.test.d.ts +2 -0
  24. package/lib/index.test.d.ts.map +1 -0
  25. package/lib/index.test.js +130 -0
  26. package/lib/index.test.js.map +1 -0
  27. package/lib/inline.d.ts +79 -0
  28. package/lib/inline.d.ts.map +1 -0
  29. package/lib/inline.js +120 -0
  30. package/lib/inline.js.map +1 -0
  31. package/lib/message.d.ts +26 -0
  32. package/lib/message.d.ts.map +1 -0
  33. package/lib/message.js +31 -0
  34. package/lib/message.js.map +1 -0
  35. package/lib/plaintext.d.ts +16 -0
  36. package/lib/plaintext.d.ts.map +1 -0
  37. package/lib/plaintext.js +104 -0
  38. package/lib/plaintext.js.map +1 -0
  39. package/lib/send.d.ts +27 -0
  40. package/lib/send.d.ts.map +1 -0
  41. package/lib/send.js +52 -0
  42. package/lib/send.js.map +1 -0
  43. package/package.json +51 -0
  44. package/src/blocks.ts +250 -0
  45. package/src/draft.ts +116 -0
  46. package/src/escape.ts +9 -0
  47. package/src/guards.ts +169 -0
  48. package/src/index.test.ts +201 -0
  49. package/src/index.ts +199 -0
  50. package/src/inline.ts +152 -0
  51. package/src/message.ts +45 -0
  52. package/src/plaintext.ts +143 -0
  53. package/src/send.ts +81 -0
package/src/inline.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { User } from "@yaebal/core";
2
+ import { escapeAttr, escapeText } from "./escape.js";
3
+
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;
15
+
16
+ export function isRichNode(value: unknown): value is RichNode {
17
+ return (
18
+ typeof value === "object" && value !== null && typeof (value as RichNode).html === "string"
19
+ );
20
+ }
21
+
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));
26
+ }
27
+
28
+ function join(children: Insertable[]): string {
29
+ return children.map(toHtml).join("");
30
+ }
31
+
32
+ function wrap(tag: string) {
33
+ return (...children: Insertable[]): RichNode => ({ html: `<${tag}>${join(children)}</${tag}>` });
34
+ }
35
+
36
+ /**
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.
41
+ */
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 };
51
+ }
52
+
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");
67
+
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>` };
75
+ }
76
+
77
+ /**
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`.
80
+ * for `@username` mentions (`RichTextMention`), just write `@username` as plain
81
+ * text — the schema lists it as auto-detected (see `skipEntityDetection`).
82
+ */
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>` };
90
+ }
91
+
92
+ /** `RichTextAnchor` (inline form) — a named jump target, `<a name="…">`. */
93
+ export function anchor(name: string): RichNode {
94
+ return { html: `<a name="${escapeAttr(name)}"></a>` };
95
+ }
96
+
97
+ /**
98
+ * `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).
100
+ */
101
+ export function anchorLink(name: string, ...children: Insertable[]): RichNode {
102
+ return { html: `<a href="#${escapeAttr(name)}">${join(children)}</a>` };
103
+ }
104
+
105
+ // --- inline marks — best-effort tags ---
106
+ //
107
+ // telegram's schema names these types and their fields, but (as scraped) does not
108
+ // state an explicit "corresponding to the html tag …" for them the way it does for
109
+ // bold/italic/code/etc above. the guesses below follow telegram's own pattern of
110
+ // reusing standard html5 semantics (sub/sup/mark/time) or its `tg-*` custom-tag
111
+ // convention — verify against the live "rich message formatting options" docs
112
+ // before depending on the exact tag/attribute spelling in production.
113
+
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");
120
+
121
+ /**
122
+ * `RichTextMathematicalExpression` (inline), best-effort `<tg-math>` (the block
123
+ * form is confirmed as `<tg-math-block>` — see `mathBlock` in blocks.ts).
124
+ */
125
+ export function math(expression: string): RichNode {
126
+ return { html: `<tg-math>${escapeText(expression)}</tg-math>` };
127
+ }
128
+
129
+ /**
130
+ * `RichTextDateTime`, best-effort `<time>`. `format` is telegram's date-time
131
+ * entity format string; attribute name is a guess (`data-format`).
132
+ */
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
+ };
137
+ }
138
+
139
+ /**
140
+ * `RichTextReference`, a footnote definition. no tag is documented anywhere in
141
+ * the scraped schema — this is a from-scratch `tg-*`-style guess.
142
+ */
143
+ export function reference(name: string, ...children: Insertable[]): RichNode {
144
+ return { html: `<tg-reference name="${escapeAttr(name)}">${join(children)}</tg-reference>` };
145
+ }
146
+
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
+ };
152
+ }
package/src/message.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { InputRichMessage } from "@yaebal/types";
2
+ import type { Insertable } from "./inline.js";
3
+ import { toHtml } from "./inline.js";
4
+
5
+ export interface DocumentOptions {
6
+ /** `InputRichMessage.is_rtl` — show the message right-to-left. */
7
+ rtl?: boolean;
8
+ /**
9
+ * `InputRichMessage.skip_entity_detection` — turn off auto-detection of urls,
10
+ * emails, `@mentions`, `#hashtags`, `$cashtags`, `/bot_commands`, and phone
11
+ * numbers in plain text.
12
+ */
13
+ skipEntityDetection?: boolean;
14
+ }
15
+
16
+ /**
17
+ * assemble top-level blocks (from blocks.ts) into an `InputRichMessage.html`
18
+ * payload. pass the result straight to `sendRichMessage`/`sendRichMessageDraft`
19
+ * or a `RichMessageDraft`.
20
+ */
21
+ export function document(blocks: Insertable[], options: DocumentOptions = {}): InputRichMessage {
22
+ return {
23
+ html: blocks.map(toHtml).join(""),
24
+ ...(options.rtl !== undefined ? { is_rtl: options.rtl } : {}),
25
+ ...(options.skipEntityDetection !== undefined
26
+ ? { skip_entity_detection: options.skipEntityDetection }
27
+ : {}),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * a raw markdown payload — telegram parses `InputRichMessage.markdown` the same
33
+ * way as `html`, but the extended block syntax (tables, `tg-thinking`, …) is not
34
+ * documented in markdown form, so unlike `document()` this has no builder: pass a
35
+ * literal string.
36
+ */
37
+ export function markdown(source: string, options: DocumentOptions = {}): InputRichMessage {
38
+ return {
39
+ markdown: source,
40
+ ...(options.rtl !== undefined ? { is_rtl: options.rtl } : {}),
41
+ ...(options.skipEntityDetection !== undefined
42
+ ? { skip_entity_detection: options.skipEntityDetection }
43
+ : {}),
44
+ };
45
+ }
@@ -0,0 +1,143 @@
1
+ import type {
2
+ RichBlock,
3
+ RichBlockAnimation,
4
+ RichBlockAudio,
5
+ RichBlockBlockQuotation,
6
+ RichBlockCaption,
7
+ RichBlockCollage,
8
+ RichBlockDetails,
9
+ RichBlockFooter,
10
+ RichBlockList,
11
+ RichBlockMap,
12
+ RichBlockMathematicalExpression,
13
+ RichBlockParagraph,
14
+ RichBlockPhoto,
15
+ RichBlockPreformatted,
16
+ RichBlockPullQuotation,
17
+ RichBlockSectionHeading,
18
+ RichBlockSlideshow,
19
+ RichBlockTable,
20
+ RichBlockThinking,
21
+ RichBlockVideo,
22
+ RichBlockVoiceNote,
23
+ RichMessage,
24
+ RichText,
25
+ RichTextCustomEmoji,
26
+ RichTextMathematicalExpression,
27
+ } from "@yaebal/types";
28
+
29
+ /**
30
+ * the schema documents `RichText` fields as "either a String for plain text, an
31
+ * Array of RichText, or" one of the marked-up variants — but the generated
32
+ * `RichText` type only lists the object variants. widen locally so plain-string
33
+ * leaves (by far the common case) don't need a cast at every call site.
34
+ */
35
+ type RichTextLike = RichText | string | RichTextLike[] | undefined;
36
+
37
+ /**
38
+ * every generated `.type` field is plain `string`, not a literal — so unlike a
39
+ * normal discriminated union, `switch (x.type)` narrows the *string*, not `x`
40
+ * itself, and (worse) several `RichBlock`/`RichText` interfaces are structurally
41
+ * identical (e.g. `RichBlockParagraph`/`RichBlockFooter`/`RichBlockThinking` are
42
+ * all just `{ type; text }`), which makes typescript's guard-based negative
43
+ * narrowing collapse to `never` if you lean on it across a chain. so: switch on
44
+ * the string, cast once per case instead of narrowing.
45
+ */
46
+ function as<T>(value: unknown): T {
47
+ return value as T;
48
+ }
49
+
50
+ /** flatten a `RichText` field (bold/link/spoiler/…) down to its plain characters. */
51
+ export function richTextToPlainText(text: RichTextLike): string {
52
+ if (text === undefined) return "";
53
+ if (typeof text === "string") return text;
54
+ if (Array.isArray(text)) return text.map(richTextToPlainText).join("");
55
+
56
+ switch (text.type) {
57
+ case "custom_emoji":
58
+ return as<RichTextCustomEmoji>(text).alternative_text;
59
+ case "mathematical_expression":
60
+ return as<RichTextMathematicalExpression>(text).expression;
61
+ case "anchor":
62
+ return "";
63
+ // every remaining variant wraps a nested `text` field (bold, italic, url,
64
+ // mention, hashtag, date_time, reference, anchor_link, text_mention, …).
65
+ default:
66
+ return richTextToPlainText(as<{ text?: RichTextLike }>(text).text);
67
+ }
68
+ }
69
+
70
+ function captionToPlainText(caption: RichBlockCaption | undefined): string {
71
+ if (!caption) return "";
72
+
73
+ const credit = caption.credit ? ` (${richTextToPlainText(caption.credit)})` : "";
74
+ return `${richTextToPlainText(caption.text)}${credit}\n`;
75
+ }
76
+
77
+ /** flatten one `RichBlock` (and its nested blocks) down to plain text, one block per line. */
78
+ export function richBlockToPlainText(block: RichBlock): string {
79
+ switch (block.type) {
80
+ case "paragraph":
81
+ return `${richTextToPlainText(as<RichBlockParagraph>(block).text)}\n`;
82
+ case "heading":
83
+ return `${richTextToPlainText(as<RichBlockSectionHeading>(block).text)}\n`;
84
+ case "pre":
85
+ return `${richTextToPlainText(as<RichBlockPreformatted>(block).text)}\n`;
86
+ case "footer":
87
+ return `${richTextToPlainText(as<RichBlockFooter>(block).text)}\n`;
88
+ case "thinking":
89
+ return `${richTextToPlainText(as<RichBlockThinking>(block).text)}\n`;
90
+ case "divider":
91
+ return "---\n";
92
+ case "anchor":
93
+ return "";
94
+ case "mathematical_expression":
95
+ return `${as<RichBlockMathematicalExpression>(block).expression}\n`;
96
+ case "list": {
97
+ const { items } = as<RichBlockList>(block);
98
+ return `${items
99
+ .map((entry) => `- ${entry.blocks.map(richBlockToPlainText).join("").trim()}`)
100
+ .join("\n")}\n`;
101
+ }
102
+ case "blockquote": {
103
+ const { blocks, credit } = as<RichBlockBlockQuotation>(block);
104
+ return `${blocks.map(richBlockToPlainText).join("")}${credit ? ` — ${richTextToPlainText(credit)}` : ""}\n`;
105
+ }
106
+ case "pullquote": {
107
+ const { text, credit } = as<RichBlockPullQuotation>(block);
108
+ return `${richTextToPlainText(text)}${credit ? ` — ${richTextToPlainText(credit)}` : ""}\n`;
109
+ }
110
+ case "collage":
111
+ case "slideshow": {
112
+ const { blocks, caption } = as<RichBlockCollage | RichBlockSlideshow>(block);
113
+ return `${blocks.map(richBlockToPlainText).join("")}${captionToPlainText(caption)}`;
114
+ }
115
+ case "table": {
116
+ const { cells } = as<RichBlockTable>(block);
117
+ return `${cells.map((row) => row.map((c) => richTextToPlainText(c.text)).join(" | ")).join("\n")}\n`;
118
+ }
119
+ case "details": {
120
+ const { summary, blocks } = as<RichBlockDetails>(block);
121
+ return `${richTextToPlainText(summary)}\n${blocks.map(richBlockToPlainText).join("")}`;
122
+ }
123
+ case "map":
124
+ return captionToPlainText(as<RichBlockMap>(block).caption);
125
+ case "animation":
126
+ case "audio":
127
+ case "photo":
128
+ case "video":
129
+ case "voice_note": {
130
+ const { caption } = as<
131
+ RichBlockAnimation | RichBlockAudio | RichBlockPhoto | RichBlockVideo | RichBlockVoiceNote
132
+ >(block);
133
+ return captionToPlainText(caption);
134
+ }
135
+ default:
136
+ return "";
137
+ }
138
+ }
139
+
140
+ /** flatten a whole `RichMessage.blocks` tree to plain text — search indices, logs, notifications. */
141
+ export function richMessageToPlainText(message: RichMessage): string {
142
+ return message.blocks.map(richBlockToPlainText).join("").trim();
143
+ }
package/src/send.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { Api, Context, Message, Plugin } from "@yaebal/core";
2
+ import type { InputRichMessage } from "@yaebal/types";
3
+ import { RichMessageDraft, type RichMessageDraftOptions } from "./draft.js";
4
+
5
+ function toInput(input: InputRichMessage | string): InputRichMessage {
6
+ return typeof input === "string" ? { html: input } : input;
7
+ }
8
+
9
+ /**
10
+ * `sendRichMessage` — standalone, no plugin install required (the `@yaebal/fmt` /
11
+ * `@yaebal/keyboard` "pure helper" style). `extra` covers the rest of
12
+ * `SendRichMessageParams`: `reply_markup`, `reply_parameters`,
13
+ * `message_effect_id`, `disable_notification`, `protect_content`,
14
+ * `business_connection_id`, `message_thread_id`, `direct_messages_topic_id`,
15
+ * `allow_paid_broadcast`, `suggested_post_parameters`.
16
+ */
17
+ export function sendRichMessage(
18
+ api: Api,
19
+ chatId: number | string,
20
+ input: InputRichMessage | string,
21
+ extra: Record<string, unknown> = {},
22
+ ): Promise<Message> {
23
+ return api.call<Message>("sendRichMessage", {
24
+ chat_id: chatId,
25
+ rich_message: toInput(input),
26
+ ...extra,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * `sendRichMessageDraft` — a single ephemeral (30s-ttl) push. private chats
32
+ * only. most callers want `RichMessageDraft` (draft.ts) instead, which keeps the
33
+ * ttl alive across a stream and forces a final `sendRichMessage` commit.
34
+ */
35
+ export function sendRichMessageDraft(
36
+ api: Api,
37
+ chatId: number,
38
+ draftId: number,
39
+ input: InputRichMessage | string,
40
+ extra: Record<string, unknown> = {},
41
+ ): Promise<boolean> {
42
+ return api.call<boolean>("sendRichMessageDraft", {
43
+ chat_id: chatId,
44
+ draft_id: draftId,
45
+ rich_message: toInput(input),
46
+ ...extra,
47
+ });
48
+ }
49
+
50
+ export interface RichContext {
51
+ /** `ctx.send`-flavored `sendRichMessage`, bound to the current chat. */
52
+ sendRichMessage(
53
+ input: InputRichMessage | string,
54
+ extra?: Record<string, unknown>,
55
+ ): Promise<Message>;
56
+ /** open a `RichMessageDraft` streaming session bound to the current chat. */
57
+ richMessageDraft(draftId: number, options?: RichMessageDraftOptions): RichMessageDraft;
58
+ }
59
+
60
+ /** `bot.install(rich())` — adds `ctx.sendRichMessage` / `ctx.richMessageDraft`. */
61
+ export function rich(): Plugin<Context, RichContext> {
62
+ return (composer) =>
63
+ composer.decorate<RichContext>({
64
+ sendRichMessage(this: Context, input, extra = {}) {
65
+ const chatId = this.chat?.id;
66
+ if (chatId === undefined) {
67
+ return Promise.reject(new Error("sendRichMessage(): no chat in this update"));
68
+ }
69
+
70
+ return sendRichMessage(this.api, chatId, input, extra);
71
+ },
72
+ richMessageDraft(this: Context, draftId, options) {
73
+ const chatId = this.chat?.id;
74
+ if (chatId === undefined) {
75
+ throw new Error("richMessageDraft(): no chat in this update");
76
+ }
77
+
78
+ return new RichMessageDraft(this.api, chatId, draftId, options);
79
+ },
80
+ });
81
+ }