@slack/radar-mcp 1.6.0 → 1.8.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.
@@ -0,0 +1,210 @@
1
+ // App root. Owns: header (conn dot, profile label, reconnect, theme toggle, ▣ Screen toggle),
2
+ // modebar tabs (incl. the persistent Custom tab), the alert flash/banner, and the view router.
3
+ // Holds NO device data -- that all lives in store.ts; this composes the components and reacts
4
+ // to store events. The DB browser (source:db) renders its own self-contained container, which
5
+ // does NOT import the store (it is point-in-time, not a live feed). The screen overlay is a
6
+ // router sibling toggled by ▣ Screen: a fixed right-docked panel that floats over any view.
7
+ import { html } from "./html.js";
8
+ import { render } from "preact";
9
+ import { useState, useEffect, useCallback } from "preact/hooks";
10
+ import { useStoreValue } from "./hooks.js";
11
+ import { store, MODES, describeConstraints } from "./store.js";
12
+ import { Feed } from "./feed.js";
13
+ import { Cards } from "./widgets.js";
14
+ import { Inspector } from "./inspector.js";
15
+ import { Filters } from "./filters.js";
16
+ import { DbBrowser } from "./db.js";
17
+ import { ScreenOverlay } from "./screen.js";
18
+ function readTheme() {
19
+ try {
20
+ return document.documentElement.classList.contains("light") ? "light" : "dark";
21
+ }
22
+ catch {
23
+ return "dark";
24
+ }
25
+ }
26
+ function Header({ screenOpen, onScreenToggle }) {
27
+ const conn = useStoreValue(store, "conn", () => store.conn);
28
+ const profile = useStoreValue(store, "profile", () => store.profileLabel);
29
+ const [busy, setBusy] = useState(false);
30
+ const [theme, setTheme] = useState(readTheme);
31
+ const reconnect = useCallback(async () => {
32
+ setBusy(true);
33
+ await store.reconnect();
34
+ setBusy(false);
35
+ }, []);
36
+ const toggleTheme = useCallback(() => {
37
+ const next = readTheme() === "light" ? "dark" : "light";
38
+ document.documentElement.classList.toggle("light", next === "light");
39
+ try {
40
+ localStorage.setItem("radar-theme", next);
41
+ }
42
+ catch {
43
+ /* private mode: theme resets next load */
44
+ }
45
+ setTheme(next);
46
+ }, []);
47
+ const connected = conn.state === "connected";
48
+ return html `
49
+ <h1>
50
+ SLACK RADAR DASHBOARD
51
+ <span class=${"conn " + conn.state}>${connected ? "● live" : "● " + (conn.msg || "reconnecting…")}</span>
52
+ <span class="profilewrap" title="the device profile Radar is connected to (first app to launch wins the socket)">profile: <b>${profile}</b></span>
53
+ ${!connected ? html `<button class="reconnect" disabled=${busy} onClick=${reconnect}>${busy ? "⟳ enabling…" : "⟳ Reconnect Radar"}</button>` : null}
54
+ <button class="themebtn" onClick=${toggleTheme} title="Toggle light / dark theme">${theme === "light" ? "🌙 Dark" : "☀ Light"}</button>
55
+ <button class=${"screenbtn" + (screenOpen ? " on" : "")} onClick=${onScreenToggle} title="Mirror the live device screen">▣ Screen</button>
56
+ </h1>
57
+ `;
58
+ }
59
+ function ModeBar() {
60
+ const [, force] = useState(0);
61
+ useEffect(() => store.on("spec", () => force((n) => n + 1)), []);
62
+ const activeKey = store.activeModeKey();
63
+ const pick = (specToPush) => store.setSpec(specToPush);
64
+ return html `
65
+ <div class="modebar">
66
+ ${MODES.map((m) => html `<button class=${"modebtn" + (activeKey === m.key ? " active" : "")} onClick=${() => pick(m.spec)}>${m.label}</button>`)}
67
+ ${store.lastCustomSpec ? html `<button class=${"modebtn custom" + (activeKey === "__custom__" ? " active" : "")} title="pushed from a terminal Claude Code session; persists until refresh" onClick=${() => pick(store.lastCustomSpec)}>✎ ${store.lastCustomSpec.title || "Custom"}</button>` : null}
68
+ </div>
69
+ `;
70
+ }
71
+ let audioCtx = null;
72
+ function beep() {
73
+ try {
74
+ const Ctx = window.AudioContext || window.webkitAudioContext;
75
+ audioCtx = audioCtx || new Ctx();
76
+ const o = audioCtx.createOscillator();
77
+ const g = audioCtx.createGain();
78
+ o.type = "sine";
79
+ o.frequency.value = 880;
80
+ o.connect(g);
81
+ g.connect(audioCtx.destination);
82
+ g.gain.setValueAtTime(0.0001, audioCtx.currentTime);
83
+ g.gain.exponentialRampToValueAtTime(0.25, audioCtx.currentTime + 0.01);
84
+ g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.25);
85
+ o.start();
86
+ o.stop(audioCtx.currentTime + 0.26);
87
+ }
88
+ catch {
89
+ /* no audio */
90
+ }
91
+ }
92
+ function Alert() {
93
+ const [banner, setBanner] = useState(null);
94
+ const [flash, setFlash] = useState(false);
95
+ useEffect(() => {
96
+ let bTimer = null;
97
+ const off = store.on("alert", ({ spec, ev, note }) => {
98
+ if (spec.flash !== false) {
99
+ setFlash(true);
100
+ setTimeout(() => setFlash(false), 140);
101
+ }
102
+ if (spec.sound !== false)
103
+ beep();
104
+ const tail = note || ev.event_type || ev.event_name || (ev.url ? new URL(ev.url, "http://x").pathname : "") || "";
105
+ setBanner({ say: spec.say || "ALERT", tail: String(tail) });
106
+ if (bTimer)
107
+ clearTimeout(bTimer);
108
+ bTimer = setTimeout(() => setBanner(null), 2500);
109
+ });
110
+ return () => {
111
+ off();
112
+ if (bTimer)
113
+ clearTimeout(bTimer);
114
+ };
115
+ }, []);
116
+ return html `
117
+ <div id="flash" class=${flash ? "on" : ""}></div>
118
+ ${banner ? html `<div id="alertbanner" class="on"><span>${banner.say} — ${banner.tail}</span><span class="x" onClick=${() => setBanner(null)}>dismiss ✕</span></div>` : null}
119
+ `;
120
+ }
121
+ // The streaming view: cards on top, then feed + (selected) inspector side by side.
122
+ function StreamView({ spec }) {
123
+ const [selected, setSelected] = useState(null);
124
+ useEffect(() => setSelected(null), [spec.source]);
125
+ const tech = describeConstraints(spec);
126
+ return html `
127
+ <div class="sub">
128
+ <span class="summary">${spec.summary || spec.title}</span>
129
+ <span class="tech">${tech}</span>
130
+ </div>
131
+ <${Filters} spec=${spec} />
132
+ <${Cards} spec=${spec} />
133
+ <div class=${"stage" + (selected ? " split" : "")}>
134
+ <${Feed} spec=${spec} onInspect=${(ev) => setSelected(ev)} selectedId=${selected && selected.id} />
135
+ ${selected ? html `<${Inspector} ev=${selected} onClose=${() => setSelected(null)} />` : null}
136
+ </div>
137
+ `;
138
+ }
139
+ function Blank() {
140
+ return html `<div class="sub"><span class="summary">Pick a dashboard above.</span><span class="tech">Live, Network, RTM, Clogs, Logs — or ask a terminal Claude Code session to build a custom view.</span></div>`;
141
+ }
142
+ function App() {
143
+ const spec = useStoreValue(store, "spec", () => store.spec);
144
+ const [screenOpen, setScreenOpen] = useState(false);
145
+ // The screen overlay is a fixed right-docked panel; widen the page padding while it is open so
146
+ // it never covers the feed/widgets.
147
+ useEffect(() => {
148
+ document.body.classList.toggle("screenopen", screenOpen);
149
+ }, [screenOpen]);
150
+ // GLOBAL tab switching (← / → / h / l) -- at the app root so it works on EVERY tab. The
151
+ // per-row j/k nav stays inside the Feed. Ignored while typing in a control.
152
+ useEffect(() => {
153
+ const onKey = (e) => {
154
+ if (e.metaKey || e.ctrlKey || e.altKey)
155
+ return;
156
+ const t = e.target;
157
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable))
158
+ return;
159
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "h" && e.key !== "l")
160
+ return;
161
+ const dir = e.key === "ArrowRight" || e.key === "l" ? 1 : -1;
162
+ const keys = MODES.map((m) => m.key);
163
+ if (store.lastCustomSpec)
164
+ keys.push("__custom__");
165
+ let cur = keys.indexOf(store.activeModeKey() || "");
166
+ if (cur === -1)
167
+ cur = 0;
168
+ const next = Math.max(0, Math.min(keys.length - 1, cur + dir));
169
+ if (next === cur)
170
+ return;
171
+ const nextKey = keys[next];
172
+ const nextSpec = nextKey === "__custom__" ? store.lastCustomSpec : MODES.find((m) => m.key === nextKey)?.spec;
173
+ if (nextSpec)
174
+ store.setSpec(nextSpec);
175
+ e.preventDefault();
176
+ };
177
+ window.addEventListener("keydown", onKey);
178
+ return () => window.removeEventListener("keydown", onKey);
179
+ }, []);
180
+ let view;
181
+ if (!spec || spec.blank) {
182
+ view = html `<${Blank} />`;
183
+ }
184
+ else if (spec.source === "db") {
185
+ // The DB browser is a self-contained, point-in-time container: it does NOT import the store
186
+ // (no SSE engine / timers on a tab that has no live feed). key=db so it remounts cleanly
187
+ // when switching to the DB tab.
188
+ view = html `
189
+ <div class="sub">
190
+ <span class="summary">${spec.summary || "Device database browser"}</span>
191
+ <span class="tech">source=db (on-device SQLite snapshot, read-only)</span>
192
+ </div>
193
+ <${DbBrowser} spec=${spec} key="db" />
194
+ `;
195
+ }
196
+ else {
197
+ view = html `<${StreamView} spec=${spec} key=${spec.source} />`;
198
+ }
199
+ return html `
200
+ <${Alert} />
201
+ <${Header} screenOpen=${screenOpen} onScreenToggle=${() => setScreenOpen((o) => !o)} />
202
+ <${ModeBar} />
203
+ ${view}
204
+ <${ScreenOverlay} open=${screenOpen} onClose=${() => setScreenOpen(false)} />
205
+ `;
206
+ }
207
+ store.start();
208
+ const root = document.getElementById("app");
209
+ if (root)
210
+ render(html `<${App} />`, root);
@@ -0,0 +1,286 @@
1
+ // DB browser (source:"db") -- read-only on-device SQLite browser: database list -> tables ->
2
+ // query grid, with a profile picker on dual-profile devices. Ported from the PoC db.js
3
+ // (vanilla buildDbView/loadDbList/dbSelectDb/dbSelectTbl/dbRun). Server contract (unchanged):
4
+ // /dbs?user= -> { user, availableUsers: string[], names: string[] } | { error, names:[], availableUsers:[] }
5
+ // /dbtables?db=&user=&refresh=1 -> { tables: {name, rows}[], sizeBytes } | { error }
6
+ // /dbquery?db=&sql=&user= -> { rows: object[] } (200) | { error } (400)
7
+ //
8
+ // IMPORTANT (plan requirement): this module must NOT import the streaming store. The DB tab is
9
+ // point-in-time, not a live feed; pulling the store would start the SSE engine + timers on a
10
+ // tab that has no use for them. It carries its own keyboard nav since there is no Feed here.
11
+ import { html } from "./html.js";
12
+ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
13
+ import { useLatestRef, useGlobalKeydown } from "./hooks.js";
14
+ function profileLabel(u) {
15
+ return u === "" || u == null ? "device" : u === "0" ? "personal (user 0)" : "secondary (user " + u + ")";
16
+ }
17
+ export function DbBrowser({ spec }) {
18
+ const [user, setUser] = useState(null);
19
+ const [dbs, setDbs] = useState(null); // null = loading
20
+ const [db, setDb] = useState(null);
21
+ const [tables, setTables] = useState(null); // null = loading
22
+ const [sql, setSql] = useState("");
23
+ const [out, setOut] = useState({ msg: "pick a database, then a table" });
24
+ const [meta, setMeta] = useState("");
25
+ // keyboard nav: which pane has focus ("db" list or "tbl" list) + the highlighted index in
26
+ // each. j/k and Up/Down move the highlight within a pane, Enter drills in, Esc backs out of
27
+ // the tables pane, Tab toggles panes. Arrow keys are consumed here (preventDefault) so list
28
+ // navigation does not also scroll the page.
29
+ const [pane, setPane] = useState("db");
30
+ const [dbIdx, setDbIdx] = useState(0);
31
+ const [tblIdx, setTblIdx] = useState(0);
32
+ const userQS = useCallback((u) => (u != null ? "&user=" + encodeURIComponent(u) : ""), []);
33
+ const loadList = useCallback(async (u) => {
34
+ setDbs(null);
35
+ try {
36
+ const r = await (await fetch("/dbs?" + (u != null ? "user=" + encodeURIComponent(u) : ""))).json();
37
+ setDbs(r);
38
+ if (!r.error)
39
+ setUser(r.user ?? null);
40
+ }
41
+ catch (e) {
42
+ setDbs({ error: "network error loading databases" });
43
+ }
44
+ }, []);
45
+ // initial load
46
+ useEffect(() => {
47
+ loadList(null);
48
+ }, []);
49
+ // keep the keyboard highlight in range when the lists change
50
+ useEffect(() => {
51
+ setDbIdx((i) => Math.max(0, Math.min(((dbs && dbs.names) || []).length - 1, i)));
52
+ }, [dbs]);
53
+ useEffect(() => {
54
+ setTblIdx((i) => Math.max(0, Math.min(((tables && tables.tables) || []).length - 1, i)));
55
+ }, [tables]);
56
+ const pickDb = useCallback(async (name, refresh) => {
57
+ setDb(name);
58
+ setSql("");
59
+ setOut({ msg: "pick a table or write a query" });
60
+ setTables(null);
61
+ try {
62
+ const r = await (await fetch("/dbtables?db=" + encodeURIComponent(name) + userQS(user) + (refresh ? "&refresh=1" : ""))).json();
63
+ setTables(r);
64
+ if (!r.error)
65
+ setMeta(name + " — " + (((r.sizeBytes ?? 0) / 1024) | 0) + " KB snapshot, " + (r.tables ?? []).length + " tables · " + profileLabel(user));
66
+ }
67
+ catch (e) {
68
+ setTables({ error: "network error loading tables" });
69
+ }
70
+ }, [user, userQS]);
71
+ const RENDER_LIMIT = 10000;
72
+ const run = useCallback(async (q) => {
73
+ const theSql = q ?? sql;
74
+ if (!db || !theSql)
75
+ return;
76
+ setOut({ msg: "running…" });
77
+ try {
78
+ const r = await (await fetch("/dbquery?db=" + encodeURIComponent(db) + "&sql=" + encodeURIComponent(theSql) + userQS(user))).json();
79
+ if (r.error)
80
+ setOut({ error: r.error });
81
+ else if (!r.rows || !r.rows.length) {
82
+ setOut({ rows: [] });
83
+ setMeta(db + " — 0 rows");
84
+ }
85
+ else {
86
+ const totalRows = r.rows.length;
87
+ const displayRows = r.rows.slice(0, RENDER_LIMIT);
88
+ setOut({ rows: displayRows });
89
+ const suffix = totalRows > RENDER_LIMIT ? " (showing first " + RENDER_LIMIT + " of " + totalRows + ")" : "";
90
+ setMeta(db + " — " + totalRows + " rows" + suffix);
91
+ }
92
+ }
93
+ catch (e) {
94
+ setOut({ error: "network error running query" });
95
+ }
96
+ }, [db, sql, user, userQS]);
97
+ const pickTable = useCallback((t) => {
98
+ const q = 'SELECT * FROM "' + t + '" LIMIT 100';
99
+ setSql(q);
100
+ run(q);
101
+ }, [run]);
102
+ // Scroll the keyboard-highlighted item into view as the focus index moves, so paging a long
103
+ // list with j/k keeps the green-outlined row on screen. block:nearest avoids a jump when it
104
+ // is already visible.
105
+ const sideRef = useRef(null);
106
+ useEffect(() => {
107
+ const el = sideRef.current && sideRef.current.querySelector(".dbitem.kbd");
108
+ if (el && el.scrollIntoView)
109
+ el.scrollIntoView({ block: "nearest" });
110
+ }, [pane, dbIdx, tblIdx]);
111
+ // Keyboard navigation. The latest lists/indices/handlers live in a ref so the single window
112
+ // listener (attached once by useGlobalKeydown) never goes stale. Mirrors the PoC's
113
+ // stale-closure-ref pattern, now via the shared Foundation hooks.
114
+ const dbNames = (dbs && dbs.names) || [];
115
+ const tblNames = (tables && tables.tables) || [];
116
+ const st = useLatestRef({ dbNames, tblNames, pane, dbIdx, tblIdx, pickDb, pickTable });
117
+ useGlobalKeydown((e) => {
118
+ // never hijack the SQL box or the profile dropdown
119
+ const t = e.target;
120
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable))
121
+ return;
122
+ if (e.metaKey || e.ctrlKey || e.altKey)
123
+ return;
124
+ const s = st.current;
125
+ const list = s.pane === "tbl" ? s.tblNames : s.dbNames;
126
+ switch (e.key) {
127
+ case "ArrowDown":
128
+ case "j":
129
+ if (!list.length)
130
+ return;
131
+ if (s.pane === "tbl")
132
+ setTblIdx((i) => Math.min(s.tblNames.length - 1, i + 1));
133
+ else
134
+ setDbIdx((i) => Math.min(s.dbNames.length - 1, i + 1));
135
+ break;
136
+ case "ArrowUp":
137
+ case "k":
138
+ if (!list.length)
139
+ return;
140
+ if (s.pane === "tbl")
141
+ setTblIdx((i) => Math.max(0, i - 1));
142
+ else
143
+ setDbIdx((i) => Math.max(0, i - 1));
144
+ break;
145
+ case "Tab":
146
+ if (s.pane === "db" && s.tblNames.length)
147
+ setPane("tbl");
148
+ else
149
+ setPane("db");
150
+ break;
151
+ case "Enter":
152
+ if (s.pane === "db") {
153
+ const name = s.dbNames[s.dbIdx];
154
+ if (name) {
155
+ s.pickDb(name);
156
+ setPane("tbl");
157
+ setTblIdx(0);
158
+ }
159
+ }
160
+ else {
161
+ const tbl = s.tblNames[s.tblIdx];
162
+ if (tbl)
163
+ s.pickTable(tbl.name);
164
+ }
165
+ break;
166
+ case "Escape":
167
+ // back out of the tables pane to the db list; from the db pane Escape is a no-op
168
+ if (s.pane === "tbl")
169
+ setPane("db");
170
+ else
171
+ return;
172
+ break;
173
+ default:
174
+ return;
175
+ }
176
+ e.preventDefault();
177
+ });
178
+ return html `
179
+ <div class="dbview">
180
+ <div class="dbside" ref=${sideRef}>
181
+ <div class="dbsec">databases</div>
182
+ ${dbs == null
183
+ ? html `<div class="msg">loading…</div>`
184
+ : dbs.error
185
+ ? html `<div class="msg err">${dbs.error}</div>`
186
+ : !dbs.names || !dbs.names.length
187
+ ? html `<div class="msg">no databases (device unreachable, or debug build not installed)</div>`
188
+ : html `
189
+ ${(dbs.availableUsers || []).length > 1
190
+ ? html `<div class="dbprofile">
191
+ <span class="dbprofcap">profile</span>
192
+ <select
193
+ onChange=${(e) => {
194
+ const v = e.currentTarget.value;
195
+ setUser(v);
196
+ setDb(null);
197
+ setTables(null);
198
+ setOut({ msg: "pick a database" });
199
+ loadList(v);
200
+ }}
201
+ >
202
+ ${dbs.availableUsers.map((u) => html `<option value=${u} selected=${u === dbs.user}>${profileLabel(u)}</option>`)}
203
+ </select>
204
+ </div>`
205
+ : html `<div class="dbprofile">profile: <b>${profileLabel(dbs.user)}</b></div>`}
206
+ ${dbs.names.map((n, i) => html `<div
207
+ class=${"dbitem" + (n === db ? " sel" : "") + (pane === "db" && i === dbIdx ? " kbd" : "")}
208
+ onClick=${() => {
209
+ setPane("db");
210
+ setDbIdx(i);
211
+ pickDb(n);
212
+ }}
213
+ >
214
+ ${n}
215
+ </div>`)}
216
+ `}
217
+ <div class="dbsec">tables</div>
218
+ ${tables == null
219
+ ? db
220
+ ? html `<div class="msg">pulling + reading…</div>`
221
+ : html `<div class="msg">pick a database</div>`
222
+ : tables.error
223
+ ? html `<div class="msg err">${tables.error}</div>`
224
+ : html `${(tables.tables ?? []).map((t, i) => html `<div
225
+ class=${"dbitem" + (pane === "tbl" && i === tblIdx ? " kbd" : "")}
226
+ onClick=${() => {
227
+ setPane("tbl");
228
+ setTblIdx(i);
229
+ pickTable(t.name);
230
+ }}
231
+ >
232
+ ${t.name}<span class="rows">${String(t.rows)}</span>
233
+ </div>`)}`}
234
+ </div>
235
+ <div class="dbmain">
236
+ <div class="dbprivacy">
237
+ Privacy: opening a database copies the on-device Slack store (message text,
238
+ request/response bodies, user, channel, and team IDs) in plaintext to a temp file on
239
+ this machine. The copy is read-only and auto-deleted when this dashboard stops. Review
240
+ before sharing anything from here.
241
+ </div>
242
+ <div class="dbqbar">
243
+ <input
244
+ value=${sql}
245
+ placeholder="SELECT * FROM table LIMIT 100 (read-only)"
246
+ autocomplete="off"
247
+ onInput=${(e) => setSql(e.currentTarget.value)}
248
+ onKeyDown=${(e) => {
249
+ if (e.key === "Enter")
250
+ run();
251
+ }}
252
+ />
253
+ <button onClick=${() => run()}>Run</button>
254
+ ${db ? html `<button class="dbrefresh" onClick=${() => pickDb(db, true)}>↻ refresh</button>` : null}
255
+ </div>
256
+ <div class="dbmeta">${meta}</div>
257
+ <div class="dbout">
258
+ ${out.msg
259
+ ? html `<div class="msg">${out.msg}</div>`
260
+ : out.error
261
+ ? html `<div class="msg err">${out.error}</div>`
262
+ : !out.rows || !out.rows.length
263
+ ? html `<div class="msg">0 rows</div>`
264
+ : html `<${Grid} rows=${out.rows} />`}
265
+ </div>
266
+ </div>
267
+ </div>
268
+ `;
269
+ }
270
+ function Grid({ rows }) {
271
+ const cols = Object.keys(rows[0]);
272
+ return html `
273
+ <table>
274
+ <thead>
275
+ <tr>
276
+ ${cols.map((c) => html `<th>${c}</th>`)}
277
+ </tr>
278
+ </thead>
279
+ <tbody>
280
+ ${rows.map((row) => html `<tr>
281
+ ${cols.map((c) => html `<td title=${String(row[c] ?? "")}>${String(row[c] ?? "")}</td>`)}
282
+ </tr>`)}
283
+ </tbody>
284
+ </table>
285
+ `;
286
+ }
@@ -0,0 +1,13 @@
1
+ // One HTML escaper, shared by every dangerouslySetInnerHTML sink in the dashboard (the
2
+ // syntax-highlighted JSON, the graph SVG, the legend). Preact auto-escapes normal
3
+ // interpolation, so this is ONLY for the handful of innerHTML paths that build markup as a
4
+ // string. Escapes all five HTML-significant characters; missing any one reopens the DOM-XSS
5
+ // class the vanilla dashboard had. Do NOT inline a second copy in a component -- import this.
6
+ //
7
+ // SINK-CONTEXT ASSUMPTION: this escaper is safe for TEXT content and DOUBLE-QUOTED attribute
8
+ // values, which is what every current sink uses. It does NOT escape space, `=`, backtick, or
9
+ // `/`, so it is NOT safe in an UNQUOTED attribute or a template-literal context. Any new sink
10
+ // must keep interpolated attributes double-quoted (attr="${esc(x)}"), never attr=${esc(x)}.
11
+ export function esc(s) {
12
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
13
+ }