@adriangalilea/utils 1.2.0 → 1.3.0
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 +12 -1
- package/dist/tg-html/index.d.ts +37 -0
- package/dist/tg-html/index.d.ts.map +1 -0
- package/dist/tg-html/index.js +254 -0
- package/dist/tg-html/index.js.map +1 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -359,6 +359,17 @@ await ctx.say.edit({ en: 'Done', es: 'Listo' }) // → ctx.editText (callback
|
|
|
359
359
|
await ctx.say.answer({ en: 'OK', es: 'OK' }) // → ctx.answer (callback only)
|
|
360
360
|
```
|
|
361
361
|
|
|
362
|
+
### Telegram HTML (`tg-html`)
|
|
363
|
+
|
|
364
|
+
Transform arbitrary (LLM-emitted) HTML into Telegram-compatible `parse_mode=HTML` with opinionated, consistent spacing. Telegram's HTML subset has no headings, lists, or block layout — send it `<h1>`/`<ul>` and the API rejects the whole message. `transform()` accepts the HTML a model naturally writes and renders the structure typographically: `h1` → bold+underline + blank line, `h2…h6` → bold + blank line, `ul/li` → `• ` bullets, blocks get blank lines, blockquotes keep their internal line breaks, allowed tags pass through with attributes filtered, unknown tags drop their markup (content kept), and stray `<`/`>` escape instead of vanishing. Zero dependencies, no DOM, Worker-safe. Successor of the standalone `tghtml` package (jsr, archived).
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { transform } from '@adriangalilea/utils/tg-html'
|
|
368
|
+
|
|
369
|
+
transform('<h1>Title</h1><ul><li>Point one</li><li>Point two</li></ul>')
|
|
370
|
+
// '<b><u>Title</u></b>\n\n• Point one\n• Point two'
|
|
371
|
+
```
|
|
372
|
+
|
|
362
373
|
### Telegram bot plugins (GramIO)
|
|
363
374
|
|
|
364
375
|
Plugins for personal Telegram bots built on [GramIO](https://gramio.dev). Each plugin lives at its own subpath; peer deps (`gramio`, `@gramio/storage`, `@gramio/session`, `@gramio/format`, `marked`) are **all optional** — install only what you import.
|
|
@@ -434,7 +445,7 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
|
|
|
434
445
|
| `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
|
|
435
446
|
| `@adriangalilea/utils/bot/llm` | The full LLM-chatbot pipeline in one module. **Input:** `streamChat(response)` parses OpenAI-compatible SSE (OpenAI, vllm, mlx-lm, llama.cpp, Together, Groq, …) into a typed `AsyncGenerator<{type: 'content' \| 'reasoning', text}>`. **Output:** `ctx.startStream()` (low-level: debounced markdown to Telegram, 4000-char split, exposes `wasPartial` after `.end()`). `ctx.startChatStream(response)` (high-level: consumes the stream, renders reasoning as a Telegram `expandable_blockquote` entity + content as streamed markdown — both go through `markdownToFormattable` with graceful degradation — returns `{ content, reasoning }`). **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🗑 Delete this thread" for `botMenu` — wipes the LLM history AND calls `deleteForumTopic` so the Telegram thread + all its messages disappear from the chat; falls back to Redis-only clear when no `threadId` is present). |
|
|
436
447
|
| `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `label` / `header` / `style` / `visible` resolvers may be **async** (read your db at render time — never cache render strings in the session); `parseMode: 'HTML'` renders the header formatted (you own escaping); `personalData.onForget(ctx, userId)` wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
|
|
437
|
-
| `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, onFulfilled }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)`
|
|
448
|
+
| `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, onFulfilled }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)` / `onRefunded(...)` register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). **Stars-only by design** — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in `src/bot/payments/CLAUDE.md`. |
|
|
438
449
|
| `@adriangalilea/utils/bot/create` | `createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, session, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker, `app.session(ctx)` / the `handlers` callback's `session(ctx)` is the TYPED accessor for your `S` fields. Owns storage+session wiring; resolves storage per environment (D1 binding → `bot/storage-d1`; `BOT_PERSIST` path → sqlite, `redis://` → redis, lazily-imported optional peers; else announced-ephemeral memory) — or pass `storage: (env) => Storage` when the choice is env-dependent (e.g. a D1 binding not named `DB`). Boot NARRATES the composition (`session: memory …`, `features: language(en,es) · menu(/settings)`) so every implicit decision is visible where you're looking. Runnable demo: `pnpm demo:bot`. |
|
|
439
450
|
| `@adriangalilea/utils/bot/worker` | `botWorkerFetch(resolve)` — the Cloudflare Worker cap: secret-checked webhook (ack fast, work + storage flush ride `ctx.waitUntil`, errors DM admins throttled), `POST /setup` (webhook registration with handler-derived `allowed_updates` + a commit-narrating 🚀 deploy DM from the request body), `POST /deploy-started` (the 🛳 "what is shipping" DM — curl it from your deploy script on the still-live version, body `{sha, author, message, etaSeconds?}`), operator-authed `/pause` `/resume` `/webhook-status` (+ `statusExtra`), and a `routes` escape hatch tried before the built-ins. Structural bot type — no workers-types dependency. |
|
|
440
451
|
| `@adriangalilea/utils/bot/storage-d1` | `d1Storage({ db, table? })` — `@gramio/storage` adapter over a D1 `session` table (schema in the module doc). `flush()` matters: the session plugin writes un-awaited and workerd freezes the isolate the instant `fetch()` returns — hand `flush` to `bot/worker` (automatic via `createBot`) so writes survive. |
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tg-html` — transform arbitrary (LLM-emitted) HTML into Telegram-compatible
|
|
3
|
+
* `parse_mode=HTML` with opinionated, consistent spacing.
|
|
4
|
+
*
|
|
5
|
+
* Telegram's HTML subset has no headings, lists, or block layout — send it
|
|
6
|
+
* `<h1>`/`<ul>` and the API rejects the whole message. This module accepts the
|
|
7
|
+
* HTML a model naturally writes and renders the structure typographically:
|
|
8
|
+
*
|
|
9
|
+
* h1 → <b><u>…</u></b> + blank line
|
|
10
|
+
* h2…h6 → <b>…</b> + blank line
|
|
11
|
+
* p/div/section/article/… → content + blank line
|
|
12
|
+
* ul/ol/li → `• ` bullets, blank line after the list
|
|
13
|
+
* br → newline
|
|
14
|
+
* blockquote → kept (newlines inside preserved), newline after
|
|
15
|
+
* b/i/u/s/code/pre/a/… → kept, attributes filtered to Telegram's allow-list
|
|
16
|
+
* spoiler / span.tg-spoiler → <tg-spoiler>
|
|
17
|
+
* unknown letter-tags → dropped, content kept (hallucinated markup)
|
|
18
|
+
* stray < > → escaped, never eaten
|
|
19
|
+
*
|
|
20
|
+
* Zero dependencies, no DOM: a single-pass tokenizer over a small recursive
|
|
21
|
+
* renderer, safe everywhere (Workers included). Successor of the standalone
|
|
22
|
+
* `tghtml` package (jsr:@adriangalilea/tghtml, now archived), with its lossy
|
|
23
|
+
* edge cases fixed: `x<5 and y>10` escapes instead of vanishing, and unknown
|
|
24
|
+
* tags drop their markup instead of leaking `small…/small` fragments.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* import { transform } from '@adriangalilea/utils/tg-html'
|
|
28
|
+
*
|
|
29
|
+
* transform('<h1>Title</h1><ul><li>Point</li></ul>')
|
|
30
|
+
* // '<b><u>Title</u></b>\n\n• Point'
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Transform HTML into Telegram-compatible `parse_mode=HTML` with consistent
|
|
34
|
+
* spacing. Never throws; pathological input degrades to escaped plain text.
|
|
35
|
+
*/
|
|
36
|
+
export declare function transform(html: string): string;
|
|
37
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tg-html/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AA4PH;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAa9C"}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tg-html` — transform arbitrary (LLM-emitted) HTML into Telegram-compatible
|
|
3
|
+
* `parse_mode=HTML` with opinionated, consistent spacing.
|
|
4
|
+
*
|
|
5
|
+
* Telegram's HTML subset has no headings, lists, or block layout — send it
|
|
6
|
+
* `<h1>`/`<ul>` and the API rejects the whole message. This module accepts the
|
|
7
|
+
* HTML a model naturally writes and renders the structure typographically:
|
|
8
|
+
*
|
|
9
|
+
* h1 → <b><u>…</u></b> + blank line
|
|
10
|
+
* h2…h6 → <b>…</b> + blank line
|
|
11
|
+
* p/div/section/article/… → content + blank line
|
|
12
|
+
* ul/ol/li → `• ` bullets, blank line after the list
|
|
13
|
+
* br → newline
|
|
14
|
+
* blockquote → kept (newlines inside preserved), newline after
|
|
15
|
+
* b/i/u/s/code/pre/a/… → kept, attributes filtered to Telegram's allow-list
|
|
16
|
+
* spoiler / span.tg-spoiler → <tg-spoiler>
|
|
17
|
+
* unknown letter-tags → dropped, content kept (hallucinated markup)
|
|
18
|
+
* stray < > → escaped, never eaten
|
|
19
|
+
*
|
|
20
|
+
* Zero dependencies, no DOM: a single-pass tokenizer over a small recursive
|
|
21
|
+
* renderer, safe everywhere (Workers included). Successor of the standalone
|
|
22
|
+
* `tghtml` package (jsr:@adriangalilea/tghtml, now archived), with its lossy
|
|
23
|
+
* edge cases fixed: `x<5 and y>10` escapes instead of vanishing, and unknown
|
|
24
|
+
* tags drop their markup instead of leaking `small…/small` fragments.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* import { transform } from '@adriangalilea/utils/tg-html'
|
|
28
|
+
*
|
|
29
|
+
* transform('<h1>Title</h1><ul><li>Point</li></ul>')
|
|
30
|
+
* // '<b><u>Title</u></b>\n\n• Point'
|
|
31
|
+
*/
|
|
32
|
+
// Tags Telegram's parse_mode=HTML accepts verbatim.
|
|
33
|
+
const TELEGRAM_TAGS = new Set([
|
|
34
|
+
"b",
|
|
35
|
+
"strong",
|
|
36
|
+
"i",
|
|
37
|
+
"em",
|
|
38
|
+
"u",
|
|
39
|
+
"ins",
|
|
40
|
+
"s",
|
|
41
|
+
"strike",
|
|
42
|
+
"del",
|
|
43
|
+
"span",
|
|
44
|
+
"tg-spoiler",
|
|
45
|
+
"a",
|
|
46
|
+
"code",
|
|
47
|
+
"pre",
|
|
48
|
+
"blockquote",
|
|
49
|
+
"tg-emoji",
|
|
50
|
+
]);
|
|
51
|
+
// Attributes kept per tag; everything else is dropped.
|
|
52
|
+
const TELEGRAM_ATTRS = {
|
|
53
|
+
a: ["href"],
|
|
54
|
+
span: ["class"],
|
|
55
|
+
blockquote: ["expandable"],
|
|
56
|
+
code: ["class"],
|
|
57
|
+
"tg-emoji": ["emoji-id"],
|
|
58
|
+
};
|
|
59
|
+
// Structural tags this module renders typographically (they can't reach Telegram).
|
|
60
|
+
const STRUCTURAL = new Set([
|
|
61
|
+
"h1",
|
|
62
|
+
"h2",
|
|
63
|
+
"h3",
|
|
64
|
+
"h4",
|
|
65
|
+
"h5",
|
|
66
|
+
"h6",
|
|
67
|
+
"p",
|
|
68
|
+
"div",
|
|
69
|
+
"section",
|
|
70
|
+
"article",
|
|
71
|
+
"header",
|
|
72
|
+
"footer",
|
|
73
|
+
"ul",
|
|
74
|
+
"ol",
|
|
75
|
+
"li",
|
|
76
|
+
"br",
|
|
77
|
+
"spoiler",
|
|
78
|
+
// Document wrappers a model sometimes emits around its answer; rendered transparent.
|
|
79
|
+
"html",
|
|
80
|
+
"head",
|
|
81
|
+
"body",
|
|
82
|
+
]);
|
|
83
|
+
// Void tags: never expect a closing counterpart.
|
|
84
|
+
const VOID_TAGS = new Set(["br", "meta", "hr", "img", "input"]);
|
|
85
|
+
// A tag token must start with a letter (like real HTML): `<1mb>` or `<5` is text, not markup.
|
|
86
|
+
const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
|
|
87
|
+
const ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
88
|
+
function parseAttrs(raw) {
|
|
89
|
+
const attrs = {};
|
|
90
|
+
for (const m of raw.matchAll(ATTR_RE)) {
|
|
91
|
+
attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
92
|
+
}
|
|
93
|
+
return attrs;
|
|
94
|
+
}
|
|
95
|
+
/** Tokenize + tree-build in one pass. Mismatched closers close intervening elements (recovery). */
|
|
96
|
+
function parse(html) {
|
|
97
|
+
const root = {
|
|
98
|
+
kind: "element",
|
|
99
|
+
tag: "#root",
|
|
100
|
+
attrs: {},
|
|
101
|
+
children: [],
|
|
102
|
+
};
|
|
103
|
+
const stack = [root];
|
|
104
|
+
let last = 0;
|
|
105
|
+
const pushText = (text) => {
|
|
106
|
+
if (text)
|
|
107
|
+
stack[stack.length - 1].children.push({ kind: "text", text });
|
|
108
|
+
};
|
|
109
|
+
for (const m of html.matchAll(TAG_RE)) {
|
|
110
|
+
pushText(html.slice(last, m.index));
|
|
111
|
+
last = (m.index ?? 0) + m[0].length;
|
|
112
|
+
const closing = m[1] === "/";
|
|
113
|
+
const tag = m[2].toLowerCase();
|
|
114
|
+
const selfClosing = m[4] === "/" || VOID_TAGS.has(tag);
|
|
115
|
+
const known = TELEGRAM_TAGS.has(tag) || STRUCTURAL.has(tag);
|
|
116
|
+
if (!known)
|
|
117
|
+
continue; // hallucinated markup: drop the tag, keep surrounding content
|
|
118
|
+
if (closing) {
|
|
119
|
+
// Close up to the matching open element, if any (recovery for mismatches).
|
|
120
|
+
for (let at = stack.length - 1; at > 0; at--) {
|
|
121
|
+
if (stack[at].tag === tag) {
|
|
122
|
+
stack.length = at;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const el = {
|
|
129
|
+
kind: "element",
|
|
130
|
+
tag,
|
|
131
|
+
attrs: parseAttrs(m[3]),
|
|
132
|
+
children: [],
|
|
133
|
+
};
|
|
134
|
+
stack[stack.length - 1].children.push(el);
|
|
135
|
+
if (!selfClosing)
|
|
136
|
+
stack.push(el);
|
|
137
|
+
}
|
|
138
|
+
pushText(html.slice(last));
|
|
139
|
+
return root.children;
|
|
140
|
+
}
|
|
141
|
+
// ─── rendering ──────────────────────────────────────────────────────
|
|
142
|
+
function escapeText(s) {
|
|
143
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
144
|
+
}
|
|
145
|
+
function escapeAttr(s) {
|
|
146
|
+
return escapeText(s).replace(/"/g, """);
|
|
147
|
+
}
|
|
148
|
+
function renderText(text, mode) {
|
|
149
|
+
const escaped = escapeText(text);
|
|
150
|
+
if (mode === "verbatim")
|
|
151
|
+
return escaped;
|
|
152
|
+
if (mode === "quote") {
|
|
153
|
+
return escaped
|
|
154
|
+
.replace(/[ \t]+/g, " ")
|
|
155
|
+
.replace(/\n[ \t]+/g, "\n")
|
|
156
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
157
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
158
|
+
}
|
|
159
|
+
return escaped.replace(/\s+/g, " ");
|
|
160
|
+
}
|
|
161
|
+
function renderChildren(el, mode) {
|
|
162
|
+
let out = "";
|
|
163
|
+
for (const child of el.children) {
|
|
164
|
+
out +=
|
|
165
|
+
child.kind === "text"
|
|
166
|
+
? renderText(child.text, mode)
|
|
167
|
+
: renderElement(child, mode);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
function keptAttrs(el) {
|
|
172
|
+
let out = "";
|
|
173
|
+
for (const name of TELEGRAM_ATTRS[el.tag] ?? []) {
|
|
174
|
+
if (!(name in el.attrs))
|
|
175
|
+
continue;
|
|
176
|
+
let value = el.attrs[name];
|
|
177
|
+
// Scheme-less links get https; only http(s)/tg pass through untouched.
|
|
178
|
+
if (el.tag === "a" &&
|
|
179
|
+
name === "href" &&
|
|
180
|
+
value &&
|
|
181
|
+
!/^(https?|tg):/i.test(value)) {
|
|
182
|
+
value = `https://${value}`;
|
|
183
|
+
}
|
|
184
|
+
out += ` ${name}="${escapeAttr(value)}"`;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function renderElement(el, mode) {
|
|
189
|
+
const tag = el.tag;
|
|
190
|
+
if (tag === "br")
|
|
191
|
+
return "\n";
|
|
192
|
+
if (tag === "h1")
|
|
193
|
+
return `<b><u>${renderChildren(el, "collapse").trim()}</u></b>\n\n`;
|
|
194
|
+
if (/^h[2-6]$/.test(tag))
|
|
195
|
+
return `<b>${renderChildren(el, "collapse").trim()}</b>\n\n`;
|
|
196
|
+
if (tag === "ul" || tag === "ol") {
|
|
197
|
+
const items = el.children
|
|
198
|
+
.filter((c) => c.kind === "element" && c.tag === "li")
|
|
199
|
+
.map((li) => `• ${renderChildren(li, "collapse").trim()}`);
|
|
200
|
+
return `${items.join("\n")}\n\n`;
|
|
201
|
+
}
|
|
202
|
+
if (tag === "li")
|
|
203
|
+
return `• ${renderChildren(el, "collapse").trim()}\n`;
|
|
204
|
+
if ([
|
|
205
|
+
"p",
|
|
206
|
+
"div",
|
|
207
|
+
"section",
|
|
208
|
+
"article",
|
|
209
|
+
"header",
|
|
210
|
+
"footer",
|
|
211
|
+
"html",
|
|
212
|
+
"head",
|
|
213
|
+
"body",
|
|
214
|
+
].includes(tag)) {
|
|
215
|
+
return `${renderChildren(el, mode).trim()}\n\n`;
|
|
216
|
+
}
|
|
217
|
+
if (tag === "blockquote") {
|
|
218
|
+
const expandable = "expandable" in el.attrs ? ' expandable=""' : "";
|
|
219
|
+
return `<blockquote${expandable}>${renderChildren(el, "quote").trim()}</blockquote>\n`;
|
|
220
|
+
}
|
|
221
|
+
if (tag === "spoiler")
|
|
222
|
+
return `<tg-spoiler>${renderChildren(el, "collapse").trim()}</tg-spoiler>`;
|
|
223
|
+
if (tag === "span") {
|
|
224
|
+
// Only the spoiler span means anything to Telegram; other spans are transparent.
|
|
225
|
+
if (el.attrs.class !== "tg-spoiler")
|
|
226
|
+
return renderChildren(el, mode);
|
|
227
|
+
return `<span class="tg-spoiler">${renderChildren(el, "collapse").trim()}</span>`;
|
|
228
|
+
}
|
|
229
|
+
if (TELEGRAM_TAGS.has(tag)) {
|
|
230
|
+
// Quote mode applies to a blockquote's DIRECT text only: an inline tag inside it
|
|
231
|
+
// collapses its own whitespace like anywhere else.
|
|
232
|
+
const inner = tag === "pre" || tag === "code" ? "verbatim" : "collapse";
|
|
233
|
+
return `<${tag}${keptAttrs(el)}>${renderChildren(el, inner).trim()}</${tag}>`;
|
|
234
|
+
}
|
|
235
|
+
return renderChildren(el, mode);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Transform HTML into Telegram-compatible `parse_mode=HTML` with consistent
|
|
239
|
+
* spacing. Never throws; pathological input degrades to escaped plain text.
|
|
240
|
+
*/
|
|
241
|
+
export function transform(html) {
|
|
242
|
+
if (!html.trim())
|
|
243
|
+
return "";
|
|
244
|
+
return parse(html)
|
|
245
|
+
.map((n) => n.kind === "text"
|
|
246
|
+
? renderText(n.text, "collapse")
|
|
247
|
+
: renderElement(n, "collapse"))
|
|
248
|
+
.join("")
|
|
249
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
250
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
251
|
+
.replace(/\n +/g, "\n")
|
|
252
|
+
.trim();
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/tg-html/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adriangalilea/utils",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./dist/say/index.d.ts",
|
|
41
41
|
"default": "./dist/say/index.js"
|
|
42
42
|
},
|
|
43
|
+
"./tg-html": {
|
|
44
|
+
"types": "./dist/tg-html/index.d.ts",
|
|
45
|
+
"default": "./dist/tg-html/index.js"
|
|
46
|
+
},
|
|
43
47
|
"./bot": {
|
|
44
48
|
"types": "./dist/bot/index.d.ts",
|
|
45
49
|
"default": "./dist/bot/index.js"
|
|
@@ -137,7 +141,8 @@
|
|
|
137
141
|
"ci": "biome ci",
|
|
138
142
|
"prepublishOnly": "pnpm run build",
|
|
139
143
|
"test:worker-safe": "pnpm run build && tsx tests/worker-safe-test.ts",
|
|
140
|
-
"demo:bot": "tsx tests/create-demo.ts"
|
|
144
|
+
"demo:bot": "tsx tests/create-demo.ts",
|
|
145
|
+
"test:tg-html": "tsx tests/tg-html-test.ts"
|
|
141
146
|
},
|
|
142
147
|
"keywords": [
|
|
143
148
|
"typescript",
|