switchroom 0.17.10 → 0.18.6
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Typed intermediate representation (IR) for the Telegram HTML render engine.
|
|
2
|
+
//
|
|
3
|
+
// This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
|
|
4
|
+
// mdast tree into this shape; a later increment's renderer walks it and emits
|
|
5
|
+
// Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
|
|
6
|
+
// is no renderer yet.
|
|
7
|
+
//
|
|
8
|
+
// Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
|
|
9
|
+
// from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
|
|
10
|
+
// code-unit indices into the original markdown string, so
|
|
11
|
+
// `source.slice(node.start, node.end)` round-trips to the node's source text.
|
|
12
|
+
//
|
|
13
|
+
// Telegram HTML tag mapping (for the next increment — NOT implemented here):
|
|
14
|
+
//
|
|
15
|
+
// Inline
|
|
16
|
+
// plain -> (raw text, HTML-escaped)
|
|
17
|
+
// bold -> <b>…</b> (markdown `**…**`)
|
|
18
|
+
// italic -> <i>…</i> (markdown `*…*`)
|
|
19
|
+
// underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
|
|
20
|
+
// strike -> <s>…</s> (markdown `~~…~~`)
|
|
21
|
+
// spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
|
|
22
|
+
// highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
|
|
23
|
+
// code -> <code>…</code>
|
|
24
|
+
// link -> <a href="…">…</a>
|
|
25
|
+
//
|
|
26
|
+
// Block
|
|
27
|
+
// paragraph -> children joined; blocks separated by "\n\n"
|
|
28
|
+
// heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
|
|
29
|
+
// blockquote -> <blockquote>…</blockquote>
|
|
30
|
+
// (expandable === true -> <blockquote expandable>)
|
|
31
|
+
// code-block -> <pre><code class="language-…">…</code></pre>
|
|
32
|
+
// list -> rendered line-per-item with "•"/"1." bullets
|
|
33
|
+
// (Telegram HTML has no <ul>/<ol>)
|
|
34
|
+
// thematic-break -> a horizontal-rule text line (e.g. "───")
|
|
35
|
+
// table -> monospaced <pre> table (Telegram HTML has no <table>)
|
|
36
|
+
|
|
37
|
+
export interface Pos {
|
|
38
|
+
/** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
|
|
39
|
+
start: number;
|
|
40
|
+
/** UTF-16 code-unit offset just past the node's last char (mdast position.end.offset). */
|
|
41
|
+
end: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Inline nodes
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface PlainNode extends Pos {
|
|
49
|
+
type: "plain";
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BoldNode extends Pos {
|
|
54
|
+
type: "bold";
|
|
55
|
+
children: Inline[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ItalicNode extends Pos {
|
|
59
|
+
type: "italic";
|
|
60
|
+
children: Inline[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
|
|
64
|
+
* double-underscore run is UNDERLINE — distinct from `**…**` bold, even though
|
|
65
|
+
* GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
|
|
66
|
+
* disambiguates the two by looking at the run's source delimiter. */
|
|
67
|
+
export interface UnderlineNode extends Pos {
|
|
68
|
+
type: "underline";
|
|
69
|
+
children: Inline[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StrikeNode extends Pos {
|
|
73
|
+
type: "strike";
|
|
74
|
+
children: Inline[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Telegram spoiler (<tg-spoiler>…</tg-spoiler>), markdown `||…||`. GFM has no
|
|
78
|
+
* spoiler syntax, so `parse.ts` recognises the `||…||` delimiter in a
|
|
79
|
+
* post-parse pass over `plain` text. */
|
|
80
|
+
export interface SpoilerNode extends Pos {
|
|
81
|
+
type: "spoiler";
|
|
82
|
+
children: Inline[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Telegram highlight / marked text (<mark>…</mark>), markdown `==…==` (Bot API
|
|
86
|
+
* 10.1). Like spoiler, recognised by `parse.ts` in a post-parse pass over
|
|
87
|
+
* `plain` text (GFM has no highlight syntax). */
|
|
88
|
+
export interface HighlightNode extends Pos {
|
|
89
|
+
type: "highlight";
|
|
90
|
+
children: Inline[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CodeNode extends Pos {
|
|
94
|
+
type: "code";
|
|
95
|
+
text: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LinkNode extends Pos {
|
|
99
|
+
type: "link";
|
|
100
|
+
href: string;
|
|
101
|
+
children: Inline[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type Inline =
|
|
105
|
+
| PlainNode
|
|
106
|
+
| BoldNode
|
|
107
|
+
| ItalicNode
|
|
108
|
+
| UnderlineNode
|
|
109
|
+
| StrikeNode
|
|
110
|
+
| SpoilerNode
|
|
111
|
+
| HighlightNode
|
|
112
|
+
| CodeNode
|
|
113
|
+
| LinkNode;
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Block nodes
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export interface ParagraphNode extends Pos {
|
|
120
|
+
type: "paragraph";
|
|
121
|
+
children: Inline[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface HeadingNode extends Pos {
|
|
125
|
+
type: "heading";
|
|
126
|
+
/** 1..6 */
|
|
127
|
+
level: number;
|
|
128
|
+
children: Inline[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface BlockquoteNode extends Pos {
|
|
132
|
+
type: "blockquote";
|
|
133
|
+
children: Block[];
|
|
134
|
+
/** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
|
|
135
|
+
expandable: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CodeBlockNode extends Pos {
|
|
139
|
+
type: "code-block";
|
|
140
|
+
text: string;
|
|
141
|
+
language: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ListNode extends Pos {
|
|
145
|
+
type: "list";
|
|
146
|
+
ordered: boolean;
|
|
147
|
+
// NOTE: the spec names this ordinal `start`, but every node already carries
|
|
148
|
+
// `start`/`end` UTF-16 offsets (load-bearing for round-trip slicing). To
|
|
149
|
+
// avoid the collision the ordered-list ordinal is `startNumber` here; its
|
|
150
|
+
// semantics match the spec's `list.start` exactly (mdast `list.start`).
|
|
151
|
+
/** First number of an ordered list (mdast `start`); null for unordered. */
|
|
152
|
+
startNumber: number | null;
|
|
153
|
+
/** Loose vs tight (mdast `list.spread`). A LOOSE list separates its items
|
|
154
|
+
* with a blank line in the source; a TIGHT list keeps them on adjacent
|
|
155
|
+
* lines. The renderer preserves this: loose lists join items with a blank
|
|
156
|
+
* line, tight lists (including nested sub-lists) stay on single newlines so
|
|
157
|
+
* no spurious blank line is injected between a tight item and its sub-list. */
|
|
158
|
+
spread: boolean;
|
|
159
|
+
items: ListItem[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ThematicBreakNode extends Pos {
|
|
163
|
+
type: "thematic-break";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TableNode extends Pos {
|
|
167
|
+
type: "table";
|
|
168
|
+
header: TableRow;
|
|
169
|
+
rows: TableRow[];
|
|
170
|
+
/** Per-column alignment, parallel to the cells. */
|
|
171
|
+
align: ("left" | "center" | "right" | null)[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type Block =
|
|
175
|
+
| ParagraphNode
|
|
176
|
+
| HeadingNode
|
|
177
|
+
| BlockquoteNode
|
|
178
|
+
| CodeBlockNode
|
|
179
|
+
| ListNode
|
|
180
|
+
| ThematicBreakNode
|
|
181
|
+
| TableNode;
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Composite / container shapes
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface ListItem extends Pos {
|
|
188
|
+
children: Block[];
|
|
189
|
+
/** GFM task-list state: true (checked), false (unchecked), null (not a task item). */
|
|
190
|
+
checked: boolean | null;
|
|
191
|
+
/** Loose vs tight at the ITEM level (mdast `listItem.spread`): whether this
|
|
192
|
+
* item's own block children are separated by a blank line in the source.
|
|
193
|
+
* A tight item (spread=false) — e.g. a paragraph followed by a nested
|
|
194
|
+
* sub-list — keeps its children on single newlines, so no blank line is
|
|
195
|
+
* injected before the sub-list. */
|
|
196
|
+
spread: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface TableRow extends Pos {
|
|
200
|
+
cells: TableCell[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface TableCell extends Pos {
|
|
204
|
+
children: Inline[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface Document {
|
|
208
|
+
blocks: Block[];
|
|
209
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Markdown -> typed IR parser for the Telegram HTML render engine.
|
|
2
|
+
//
|
|
3
|
+
// Uses `mdast-util-from-markdown` (micromark under the hood) with the GFM
|
|
4
|
+
// syntax + mdast extensions to obtain an mdast tree, then folds that tree into
|
|
5
|
+
// the IR defined in `ir.ts`. This is Increment 1: parser + IR only. There is
|
|
6
|
+
// no renderer and no chunker yet, and nothing here is wired into the live send
|
|
7
|
+
// path.
|
|
8
|
+
//
|
|
9
|
+
// Design rules honored here:
|
|
10
|
+
// - Every emitted node copies UTF-16 source offsets straight off mdast
|
|
11
|
+
// `position.start.offset` / `position.end.offset`, so
|
|
12
|
+
// `source.slice(node.start, node.end)` round-trips to the source text.
|
|
13
|
+
// - Never lose text. Any mdast node type outside the supported palette
|
|
14
|
+
// degrades to a `plain` inline (or a paragraph wrapping one) carrying the
|
|
15
|
+
// raw source slice, rather than being dropped.
|
|
16
|
+
//
|
|
17
|
+
// Underline vs bold (`__…__` vs `**…**`):
|
|
18
|
+
// Telegram's Bot API 10.1 rich markdown reads a `__…__` double-underscore run
|
|
19
|
+
// as UNDERLINE and a `**…**` run as BOLD. GFM/micromark folds BOTH into one
|
|
20
|
+
// `strong` mdast node with no record of which delimiter was used. This module
|
|
21
|
+
// disambiguates by reading the run's source delimiter off its UTF-16 offsets
|
|
22
|
+
// (`source.slice(start, start+2)`): `__` → `underline`, anything else →
|
|
23
|
+
// `bold`. A single `_…_` / `*…*` run stays `italic` (emphasis) either way.
|
|
24
|
+
//
|
|
25
|
+
// Spoiler + highlight handling (`||…||`, `==…==`):
|
|
26
|
+
// The IR carries `spoiler` and `highlight` nodes but GFM/micromark has no
|
|
27
|
+
// syntax for either — both delimiters fold into ordinary `plain` text. Rather
|
|
28
|
+
// than teach micromark two custom inline extensions, this module recognises
|
|
29
|
+
// them in a POST-PARSE pass over the folded `plain` nodes (`expandPlainNode`),
|
|
30
|
+
// splitting a `||secret||` / `==marked==` run into the matching
|
|
31
|
+
// `SpoilerNode` / `HighlightNode`. Recognition is per-`plain`-node: the
|
|
32
|
+
// delimited content must be a single contiguous plain-text run on one line
|
|
33
|
+
// (formatting INSIDE a spoiler — `||**bold**||` — is left as literal
|
|
34
|
+
// delimiters, since `**bold**` is already its own mdast node). A delimiter
|
|
35
|
+
// that does not form a closed, non-empty, single-line pair stays literal
|
|
36
|
+
// plain text and is re-escaped on render. This faithfully mirrors Telegram's
|
|
37
|
+
// own reading of the delimiters.
|
|
38
|
+
//
|
|
39
|
+
// Blockquote expandable handling:
|
|
40
|
+
// The IR carries `expandable: boolean` for Telegram's expandable blockquote
|
|
41
|
+
// (Bot API 10.1). GFM has no expandable marker; the switchroom render path
|
|
42
|
+
// emits `**> ` on the FIRST line of an expandable quote (`render.ts` /
|
|
43
|
+
// `reference/telegram-formatting-guide.md`). micromark does NOT understand
|
|
44
|
+
// `**> ` as a blockquote — the leading `**` makes the line a paragraph with
|
|
45
|
+
// an unclosed strong-emphasis run — so this module pre-transforms each
|
|
46
|
+
// `**>` marker into a plain ` >` marker of IDENTICAL length (`**` → two
|
|
47
|
+
// spaces) before handing the text to mdast. Length preservation keeps every
|
|
48
|
+
// UTF-16 source offset (and therefore the never-lose-text round-trip
|
|
49
|
+
// invariant against the ORIGINAL source) exactly intact — only the two
|
|
50
|
+
// marker characters differ, and they are never part of quoted content. The
|
|
51
|
+
// set of line-start offsets that carried the marker is threaded into
|
|
52
|
+
// `foldBlock` so the matching blockquote nodes get `expandable: true`.
|
|
53
|
+
|
|
54
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
55
|
+
import { gfm } from "micromark-extension-gfm";
|
|
56
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
57
|
+
import type {
|
|
58
|
+
Node as MdastNode,
|
|
59
|
+
Parent as MdastParent,
|
|
60
|
+
RootContent,
|
|
61
|
+
PhrasingContent,
|
|
62
|
+
AlignType,
|
|
63
|
+
} from "mdast";
|
|
64
|
+
|
|
65
|
+
import type {
|
|
66
|
+
Block,
|
|
67
|
+
Document,
|
|
68
|
+
Inline,
|
|
69
|
+
ListItem,
|
|
70
|
+
PlainNode,
|
|
71
|
+
Pos,
|
|
72
|
+
TableCell,
|
|
73
|
+
TableRow,
|
|
74
|
+
} from "./ir.js";
|
|
75
|
+
|
|
76
|
+
/** Copy UTF-16 offsets off an mdast node. Falls back to 0-length when a
|
|
77
|
+
* synthesized node lacks a position (from-markdown always sets one, but the
|
|
78
|
+
* mdast types make `position` optional). */
|
|
79
|
+
function pos(node: MdastNode): Pos {
|
|
80
|
+
const p = node.position;
|
|
81
|
+
return {
|
|
82
|
+
start: p?.start?.offset ?? 0,
|
|
83
|
+
end: p?.end?.offset ?? 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Raw source text a node spans — used for the never-lose-text fallback. */
|
|
88
|
+
function slice(source: string, node: MdastNode): string {
|
|
89
|
+
const { start, end } = pos(node);
|
|
90
|
+
return source.slice(start, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The Bot API 10.1 expandable-blockquote marker: `**>` at the very start of
|
|
94
|
+
* a line (column 0). This is exactly what the render path emits
|
|
95
|
+
* (`render.ts` writes `**> ` on the first line of an expandable quote; see
|
|
96
|
+
* `reference/telegram-formatting-guide.md`). Matching only at column 0 keeps
|
|
97
|
+
* the length-preserving rewrite (`**` → two spaces) inside CommonMark's
|
|
98
|
+
* 3-space blockquote-indent budget — allowing leading indent here would push
|
|
99
|
+
* the rewritten ` >` past 3 spaces and turn it into an indented code block. */
|
|
100
|
+
const EXPANDABLE_MARKER_RE = /^\*\*>/;
|
|
101
|
+
|
|
102
|
+
/** Pre-transform expandable-blockquote markers so mdast can parse them as
|
|
103
|
+
* ordinary blockquotes, WITHOUT shifting any source offset. Each line that
|
|
104
|
+
* opens with `**>` has its two `*` characters replaced by two spaces
|
|
105
|
+
* (`**>` → ` >`), which micromark reads as a normal (optionally
|
|
106
|
+
* 1–3-space-indented) blockquote line. Returns the rewritten text plus the
|
|
107
|
+
* set of line-start offsets that carried the marker — `foldBlock` uses that
|
|
108
|
+
* set to flip `expandable: true` on the produced blockquote nodes. */
|
|
109
|
+
function markExpandableQuotes(markdown: string): {
|
|
110
|
+
text: string;
|
|
111
|
+
expandableLineStarts: Set<number>;
|
|
112
|
+
} {
|
|
113
|
+
const expandableLineStarts = new Set<number>();
|
|
114
|
+
let out = "";
|
|
115
|
+
let offset = 0;
|
|
116
|
+
// Split keeping the trailing newline on each line so offsets are exact.
|
|
117
|
+
for (const line of markdown.split(/(?<=\n)/)) {
|
|
118
|
+
if (EXPANDABLE_MARKER_RE.test(line)) {
|
|
119
|
+
expandableLineStarts.add(offset);
|
|
120
|
+
// Replace the leading two `*` chars with two spaces; the `>` and
|
|
121
|
+
// everything after it are untouched (`**> x` -> ` > x`), a valid
|
|
122
|
+
// 2-space-indented blockquote that mdast parses normally.
|
|
123
|
+
out += " " + line.slice(2);
|
|
124
|
+
} else {
|
|
125
|
+
out += line;
|
|
126
|
+
}
|
|
127
|
+
offset += line.length;
|
|
128
|
+
}
|
|
129
|
+
return { text: out, expandableLineStarts };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Offset of the start of the line containing `offset` in `source`. */
|
|
133
|
+
function lineStart(source: string, offset: number): number {
|
|
134
|
+
let i = offset;
|
|
135
|
+
while (i > 0 && source[i - 1] !== "\n") i--;
|
|
136
|
+
return i;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** mdast `AlignType` (null | 'left' | 'right' | 'center') passes through
|
|
140
|
+
* unchanged; the IR uses the same union. */
|
|
141
|
+
function foldAlign(a: AlignType | undefined): "left" | "center" | "right" | null {
|
|
142
|
+
return a ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function foldInline(node: PhrasingContent, source: string): Inline {
|
|
146
|
+
switch (node.type) {
|
|
147
|
+
case "text":
|
|
148
|
+
return { type: "plain", text: node.value, ...pos(node) };
|
|
149
|
+
case "strong": {
|
|
150
|
+
// GFM folds both `**…**` (bold) and `__…__` (underline) into one `strong`
|
|
151
|
+
// node. Recover the intended construct from the source delimiter.
|
|
152
|
+
const p = pos(node);
|
|
153
|
+
const underline = source.slice(p.start, p.start + 2) === "__";
|
|
154
|
+
return {
|
|
155
|
+
type: underline ? "underline" : "bold",
|
|
156
|
+
children: foldInlineChildren(node, source),
|
|
157
|
+
...p,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
case "emphasis":
|
|
161
|
+
return { type: "italic", children: foldInlineChildren(node, source), ...pos(node) };
|
|
162
|
+
case "delete":
|
|
163
|
+
return { type: "strike", children: foldInlineChildren(node, source), ...pos(node) };
|
|
164
|
+
case "inlineCode":
|
|
165
|
+
return { type: "code", text: node.value, ...pos(node) };
|
|
166
|
+
case "link":
|
|
167
|
+
return {
|
|
168
|
+
type: "link",
|
|
169
|
+
href: node.url,
|
|
170
|
+
children: foldInlineChildren(node, source),
|
|
171
|
+
...pos(node),
|
|
172
|
+
};
|
|
173
|
+
// Not in the palette (break, image, html, footnoteReference, …): keep the
|
|
174
|
+
// raw source text so no content is lost.
|
|
175
|
+
default:
|
|
176
|
+
return { type: "plain", text: slice(source, node), ...pos(node) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Post-parse inline-marker recognition (spoiler `||…||`, highlight `==…==`)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** The post-parse inline delimiters GFM/micromark doesn't natively model. Each
|
|
185
|
+
* matches a CLOSED, NON-EMPTY, single-line delimited run inside `plain` text.
|
|
186
|
+
* `delim` is the delimiter length (both are 2 chars). */
|
|
187
|
+
const INLINE_MARKER_SPECS: ReadonlyArray<{
|
|
188
|
+
type: "spoiler" | "highlight";
|
|
189
|
+
re: RegExp;
|
|
190
|
+
delim: number;
|
|
191
|
+
}> = [
|
|
192
|
+
{ type: "spoiler", re: /\|\|([^|\n]+)\|\|/, delim: 2 },
|
|
193
|
+
{ type: "highlight", re: /==([^=\n]+)==/, delim: 2 },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
function mkPlain(text: string, start: number, end: number): PlainNode {
|
|
197
|
+
return { type: "plain", text, start, end };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Split a single `plain` node's text into `plain` / `spoiler` / `highlight`
|
|
201
|
+
* nodes by recognising `||…||` and `==…==` runs. Offsets are exact when the
|
|
202
|
+
* node's source slice equals its decoded text (the common no-escape case);
|
|
203
|
+
* otherwise every produced piece inherits the original node's span (still a
|
|
204
|
+
* valid in-bounds offset, just not byte-slice-exact). Recurses so a marker
|
|
205
|
+
* nested in plain text after another marker is also recognised. */
|
|
206
|
+
function expandPlainNode(node: PlainNode, source: string): Inline[] {
|
|
207
|
+
const text = node.text;
|
|
208
|
+
if (text.length === 0) return [node];
|
|
209
|
+
const exact = source.slice(node.start, node.end) === text;
|
|
210
|
+
const abs = (k: number): number => (exact ? node.start + k : node.start);
|
|
211
|
+
const absEnd = (k: number): number => (exact ? node.start + k : node.end);
|
|
212
|
+
|
|
213
|
+
// Earliest marker match across all delimiter kinds.
|
|
214
|
+
let best:
|
|
215
|
+
| { idx: number; full: number; inner: string; type: "spoiler" | "highlight"; delim: number }
|
|
216
|
+
| null = null;
|
|
217
|
+
for (const spec of INLINE_MARKER_SPECS) {
|
|
218
|
+
const m = spec.re.exec(text);
|
|
219
|
+
if (m != null && m[1].length > 0 && (best === null || m.index < best.idx)) {
|
|
220
|
+
best = { idx: m.index, full: m[0].length, inner: m[1], type: spec.type, delim: spec.delim };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (best === null) return [node];
|
|
224
|
+
|
|
225
|
+
const out: Inline[] = [];
|
|
226
|
+
if (best.idx > 0) out.push(mkPlain(text.slice(0, best.idx), abs(0), abs(best.idx)));
|
|
227
|
+
|
|
228
|
+
const innerStart = abs(best.idx + best.delim);
|
|
229
|
+
const innerEnd = absEnd(best.idx + best.delim + best.inner.length);
|
|
230
|
+
const innerPlain = mkPlain(best.inner, innerStart, innerEnd);
|
|
231
|
+
out.push({
|
|
232
|
+
type: best.type,
|
|
233
|
+
children: expandPlainNode(innerPlain, source),
|
|
234
|
+
start: abs(best.idx),
|
|
235
|
+
end: absEnd(best.idx + best.full),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const rest = text.slice(best.idx + best.full);
|
|
239
|
+
if (rest.length > 0) {
|
|
240
|
+
out.push(...expandPlainNode(mkPlain(rest, abs(best.idx + best.full), node.end), source));
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Fold a run of mdast phrasing children into IR inlines, then run the
|
|
246
|
+
* post-parse spoiler/highlight recognition over the produced `plain` nodes. */
|
|
247
|
+
function buildInlines(children: ReadonlyArray<MdastNode>, source: string): Inline[] {
|
|
248
|
+
return children
|
|
249
|
+
.map((c) => foldInline(c as PhrasingContent, source))
|
|
250
|
+
.flatMap((n) => (n.type === "plain" ? expandPlainNode(n, source) : [n]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function foldInlineChildren(node: MdastParent, source: string): Inline[] {
|
|
254
|
+
return buildInlines(node.children, source);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function foldTableRow(
|
|
258
|
+
row: Extract<RootContent, { type: "tableRow" }>,
|
|
259
|
+
source: string,
|
|
260
|
+
): TableRow {
|
|
261
|
+
const cells: TableCell[] = row.children.map((cell) => ({
|
|
262
|
+
children: buildInlines(cell.children, source),
|
|
263
|
+
...pos(cell),
|
|
264
|
+
}));
|
|
265
|
+
return { cells, ...pos(row) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function foldBlock(
|
|
269
|
+
node: RootContent,
|
|
270
|
+
source: string,
|
|
271
|
+
expandableLineStarts: Set<number>,
|
|
272
|
+
): Block {
|
|
273
|
+
switch (node.type) {
|
|
274
|
+
case "paragraph":
|
|
275
|
+
return { type: "paragraph", children: foldInlineChildren(node, source), ...pos(node) };
|
|
276
|
+
case "heading":
|
|
277
|
+
return {
|
|
278
|
+
type: "heading",
|
|
279
|
+
level: node.depth,
|
|
280
|
+
children: foldInlineChildren(node, source),
|
|
281
|
+
...pos(node),
|
|
282
|
+
};
|
|
283
|
+
case "blockquote": {
|
|
284
|
+
const p = pos(node);
|
|
285
|
+
// A blockquote is expandable when its FIRST line carried the `**>`
|
|
286
|
+
// marker in the original source (recorded by markExpandableQuotes).
|
|
287
|
+
const expandable = expandableLineStarts.has(lineStart(source, p.start));
|
|
288
|
+
return {
|
|
289
|
+
type: "blockquote",
|
|
290
|
+
children: node.children.map((c) => foldBlock(c, source, expandableLineStarts)),
|
|
291
|
+
expandable,
|
|
292
|
+
...p,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
case "code":
|
|
296
|
+
return {
|
|
297
|
+
type: "code-block",
|
|
298
|
+
text: node.value,
|
|
299
|
+
language: node.lang ?? null,
|
|
300
|
+
...pos(node),
|
|
301
|
+
};
|
|
302
|
+
case "list": {
|
|
303
|
+
const items: ListItem[] = node.children.map((li) => ({
|
|
304
|
+
children: li.children.map((c) => foldBlock(c, source, expandableLineStarts)),
|
|
305
|
+
checked: li.checked ?? null,
|
|
306
|
+
// mdast `listItem.spread`: whether this item's block children are
|
|
307
|
+
// separated by a blank line. Tight (false) keeps a paragraph and its
|
|
308
|
+
// nested sub-list adjacent so no blank line is injected on render.
|
|
309
|
+
spread: li.spread ?? false,
|
|
310
|
+
...pos(li),
|
|
311
|
+
}));
|
|
312
|
+
return {
|
|
313
|
+
type: "list",
|
|
314
|
+
ordered: node.ordered ?? false,
|
|
315
|
+
startNumber: node.ordered ? node.start ?? 1 : null,
|
|
316
|
+
// mdast `list.spread`: loose (true) vs tight (false) at the list level.
|
|
317
|
+
spread: node.spread ?? false,
|
|
318
|
+
items,
|
|
319
|
+
...pos(node),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
case "thematicBreak":
|
|
323
|
+
return { type: "thematic-break", ...pos(node) };
|
|
324
|
+
case "table": {
|
|
325
|
+
const rows = node.children;
|
|
326
|
+
const [headerRow, ...bodyRows] = rows;
|
|
327
|
+
return {
|
|
328
|
+
type: "table",
|
|
329
|
+
header: foldTableRow(headerRow, source),
|
|
330
|
+
rows: bodyRows.map((r) => foldTableRow(r, source)),
|
|
331
|
+
align: (node.align ?? []).map(foldAlign),
|
|
332
|
+
...pos(node),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Not in the palette (html, definition, footnoteDefinition, …): degrade to
|
|
336
|
+
// a paragraph carrying the raw source slice so no content is dropped.
|
|
337
|
+
default:
|
|
338
|
+
return {
|
|
339
|
+
type: "paragraph",
|
|
340
|
+
children: [{ type: "plain", text: slice(source, node), ...pos(node) }],
|
|
341
|
+
...pos(node),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Parse a markdown string into the typed IR Document. */
|
|
347
|
+
export function parse(markdown: string): Document {
|
|
348
|
+
// Rewrite expandable-blockquote markers (`**>` → ` >`) so micromark parses
|
|
349
|
+
// them as ordinary blockquotes. The rewrite is length-preserving, so mdast's
|
|
350
|
+
// offsets are valid against BOTH the rewritten and the original text — we
|
|
351
|
+
// parse the rewritten text but fold against the ORIGINAL `markdown`, keeping
|
|
352
|
+
// every source slice byte-identical to the caller's input.
|
|
353
|
+
const { text: rewritten, expandableLineStarts } = markExpandableQuotes(markdown);
|
|
354
|
+
const tree = fromMarkdown(rewritten, {
|
|
355
|
+
extensions: [gfm()],
|
|
356
|
+
mdastExtensions: [gfmFromMarkdown()],
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
blocks: tree.children.map((child) =>
|
|
360
|
+
foldBlock(child, markdown, expandableLineStarts),
|
|
361
|
+
),
|
|
362
|
+
};
|
|
363
|
+
}
|