| 1 | // π€ Cards β a small, bounded, declarative document a bot can attach to a chat |
| 2 | // message ({ ...payload, card }), rendered natively per platform (this module |
| 3 | // on the web; CardView in LChatCore). Think Slack Block Kit, deliberately NOT |
| 4 | // a mini-app format: a card is a document with links, and interactivity beyond |
| 5 | // links rides the existing payload `actions` whitelist, not the card. |
| 6 | // |
| 7 | // The contract lives in test/event-vectors/cards.json β normalizeCard here and |
| 8 | // the Swift parser must agree on every vector, so the two renderers can't |
| 9 | // drift. Evolution is additive within a schema major; anything unknown or |
| 10 | // malformed FAILS CLOSED: normalizeCard returns null and the client shows the |
| 11 | // message's plain text instead (which is why `text` is a mandatory fallback on |
| 12 | // any card-carrying message). |
| 13 | // |
| 14 | // Pure module: normalizeCard/β¦ have no DOM or network so Node can test them; |
| 15 | // renderCard builds DOM with textContent only (never innerHTML β cards are |
| 16 | // bot-authored content). |
| 17 | |
| 18 | export const CARD_SCHEMA = 1; |
| 19 | |
| 20 | // Bounds β part of the schema contract (the Swift parser enforces the same). |
| 21 | const MAX_BLOCKS = 32; // top-level blocks, and blocks within one section |
| 22 | const MAX_LIST_ITEMS = 32; |
| 23 | const MAX_KV_ROWS = 16; |
| 24 | const MAX_CHIPS = 4; |
| 25 | const MAX_TEXT = 4000; // any body text |
| 26 | const MAX_LABEL = 200; // headings, titles, sources, chip labels |
| 27 | const MAX_URL = 2048; |
| 28 | |
| 29 | function str(v, max) { |
| 30 | return typeof v === "string" && v.length > 0 && v.length <= max ? v : null; |
| 31 | } |
| 32 | |
| 33 | function optStr(v, max) { |
| 34 | if (v === undefined || v === null) return ""; |
| 35 | return typeof v === "string" && v.length <= max ? v : null; |
| 36 | } |
| 37 | |
| 38 | function httpsURL(v) { |
| 39 | const s = str(v, MAX_URL); |
| 40 | if (!s || !/^https?:\/\//i.test(s)) return null; |
| 41 | return s; |
| 42 | } |
| 43 | |
| 44 | // normalizeCard validates a decrypted card into its canonical shape, or |
| 45 | // returns null β the fail-closed signal to render the plain-text fallback. |
| 46 | // Nothing about a card is trusted: it came from another member (a bot). |
| 47 | export function normalizeCard(card) { |
| 48 | if (!card || typeof card !== "object" || Array.isArray(card)) return null; |
| 49 | if (card.schema !== CARD_SCHEMA) return null; // unknown major β fallback |
| 50 | const blocks = normalizeBlocks(card.blocks, true); |
| 51 | if (!blocks || !blocks.length) return null; |
| 52 | return { schema: CARD_SCHEMA, blocks }; |
| 53 | } |
| 54 | |
| 55 | function normalizeBlocks(list, allowSection) { |
| 56 | if (!Array.isArray(list) || list.length === 0 || list.length > MAX_BLOCKS) return null; |
| 57 | const out = []; |
| 58 | for (const b of list) { |
| 59 | const n = normalizeBlock(b, allowSection); |
| 60 | if (!n) return null; // one bad block fails the whole card |
| 61 | out.push(n); |
| 62 | } |
| 63 | return out; |
| 64 | } |
| 65 | |
| 66 | function normalizeBlock(b, allowSection) { |
| 67 | if (!b || typeof b !== "object" || Array.isArray(b)) return null; |
| 68 | switch (b.b) { |
| 69 | case "heading": { |
| 70 | const text = str(b.text, MAX_LABEL); |
| 71 | const sub = optStr(b.sub, MAX_LABEL); |
| 72 | if (!text || sub === null) return null; |
| 73 | return sub ? { b: "heading", text, sub } : { b: "heading", text }; |
| 74 | } |
| 75 | case "text": { |
| 76 | const text = str(b.text, MAX_TEXT); |
| 77 | return text ? { b: "text", text } : null; |
| 78 | } |
| 79 | case "item": { |
| 80 | const text = str(b.text, MAX_TEXT); |
| 81 | const source = optStr(b.source, MAX_LABEL); |
| 82 | if (!text || source === null) return null; |
| 83 | const out = { b: "item", text }; |
| 84 | if (source) out.source = source; |
| 85 | if (b.url !== undefined) { |
| 86 | const url = httpsURL(b.url); |
| 87 | if (!url) return null; |
| 88 | out.url = url; |
| 89 | } |
| 90 | return out; |
| 91 | } |
| 92 | case "list": { |
| 93 | if (!Array.isArray(b.items) || !b.items.length || b.items.length > MAX_LIST_ITEMS) return null; |
| 94 | const items = []; |
| 95 | for (const it of b.items) { |
| 96 | const s = str(it, MAX_TEXT); |
| 97 | if (!s) return null; |
| 98 | items.push(s); |
| 99 | } |
| 100 | return { b: "list", items }; |
| 101 | } |
| 102 | case "kv": { |
| 103 | if (!Array.isArray(b.rows) || !b.rows.length || b.rows.length > MAX_KV_ROWS) return null; |
| 104 | const rows = []; |
| 105 | for (const r of b.rows) { |
| 106 | if (!Array.isArray(r) || r.length !== 2) return null; |
| 107 | const k = str(r[0], MAX_LABEL); |
| 108 | const v = str(r[1], MAX_TEXT); |
| 109 | if (!k || !v) return null; |
| 110 | rows.push([k, v]); |
| 111 | } |
| 112 | return { b: "kv", rows }; |
| 113 | } |
| 114 | case "divider": |
| 115 | return { b: "divider" }; |
| 116 | case "section": { |
| 117 | if (!allowSection) return null; // one level of nesting, no more |
| 118 | const title = optStr(b.title, MAX_LABEL); |
| 119 | if (title === null) return null; |
| 120 | const blocks = normalizeBlocks(b.blocks, false); |
| 121 | if (!blocks) return null; |
| 122 | const out = { b: "section", blocks }; |
| 123 | if (title) out.title = title; |
| 124 | return out; |
| 125 | } |
| 126 | case "chips": { |
| 127 | if (!Array.isArray(b.chips) || !b.chips.length || b.chips.length > MAX_CHIPS) return null; |
| 128 | const chips = []; |
| 129 | for (const c of b.chips) { |
| 130 | if (!c || typeof c !== "object") return null; |
| 131 | const label = str(c.label, MAX_LABEL); |
| 132 | const url = httpsURL(c.url); |
| 133 | if (!label || !url) return null; // v1 chips are links only |
| 134 | chips.push({ label, url }); |
| 135 | } |
| 136 | return { b: "chips", chips }; |
| 137 | } |
| 138 | default: |
| 139 | return null; // unknown block type β the whole card fails closed |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // renderCard builds the card's DOM, or returns null when the card doesn't |
| 144 | // normalize (caller falls back to the message text, which it renders anyway). |
| 145 | // All content lands via textContent; links open in a new tab like message |
| 146 | // links do. |
| 147 | export function renderCard(card) { |
| 148 | const n = normalizeCard(card); |
| 149 | if (!n) return null; |
| 150 | const root = document.createElement("div"); |
| 151 | root.className = "card"; |
| 152 | for (const b of n.blocks) root.appendChild(renderBlock(b)); |
| 153 | return root; |
| 154 | } |
| 155 | |
| 156 | function renderBlock(b) { |
| 157 | switch (b.b) { |
| 158 | case "heading": { |
| 159 | const el = document.createElement("div"); |
| 160 | el.className = "card-heading"; |
| 161 | const h = document.createElement("div"); |
| 162 | h.className = "card-heading-text"; |
| 163 | h.textContent = b.text; |
| 164 | el.appendChild(h); |
| 165 | if (b.sub) { |
| 166 | const s = document.createElement("div"); |
| 167 | s.className = "card-heading-sub"; |
| 168 | s.textContent = b.sub; |
| 169 | el.appendChild(s); |
| 170 | } |
| 171 | return el; |
| 172 | } |
| 173 | case "text": { |
| 174 | const el = document.createElement("div"); |
| 175 | el.className = "card-text"; |
| 176 | el.textContent = b.text; |
| 177 | return el; |
| 178 | } |
| 179 | case "item": { |
| 180 | const el = document.createElement("div"); |
| 181 | el.className = "card-item"; |
| 182 | const t = document.createElement(b.url ? "a" : "span"); |
| 183 | t.className = "card-item-text"; |
| 184 | t.textContent = b.text; |
| 185 | if (b.url) { |
| 186 | t.href = b.url; |
| 187 | t.target = "_blank"; |
| 188 | t.rel = "noopener noreferrer"; |
| 189 | } |
| 190 | el.appendChild(t); |
| 191 | if (b.source) { |
| 192 | const s = document.createElement("span"); |
| 193 | s.className = "card-item-source"; |
| 194 | s.textContent = b.source; |
| 195 | el.appendChild(s); |
| 196 | } |
| 197 | return el; |
| 198 | } |
| 199 | case "list": { |
| 200 | const el = document.createElement("ul"); |
| 201 | el.className = "card-list"; |
| 202 | for (const it of b.items) { |
| 203 | const li = document.createElement("li"); |
| 204 | li.textContent = it; |
| 205 | el.appendChild(li); |
| 206 | } |
| 207 | return el; |
| 208 | } |
| 209 | case "kv": { |
| 210 | const el = document.createElement("table"); |
| 211 | el.className = "card-kv"; |
| 212 | for (const [k, v] of b.rows) { |
| 213 | const tr = document.createElement("tr"); |
| 214 | const th = document.createElement("td"); |
| 215 | th.className = "card-kv-key"; |
| 216 | th.textContent = k; |
| 217 | const td = document.createElement("td"); |
| 218 | td.textContent = v; |
| 219 | tr.append(th, td); |
| 220 | el.appendChild(tr); |
| 221 | } |
| 222 | return el; |
| 223 | } |
| 224 | case "divider": { |
| 225 | const el = document.createElement("hr"); |
| 226 | el.className = "card-divider"; |
| 227 | return el; |
| 228 | } |
| 229 | case "section": { |
| 230 | const el = document.createElement("div"); |
| 231 | el.className = "card-section"; |
| 232 | if (b.title) { |
| 233 | const t = document.createElement("div"); |
| 234 | t.className = "card-section-title"; |
| 235 | t.textContent = b.title; |
| 236 | el.appendChild(t); |
| 237 | } |
| 238 | for (const inner of b.blocks) el.appendChild(renderBlock(inner)); |
| 239 | return el; |
| 240 | } |
| 241 | case "chips": { |
| 242 | const el = document.createElement("div"); |
| 243 | el.className = "card-chips"; |
| 244 | for (const c of b.chips) { |
| 245 | const a = document.createElement("a"); |
| 246 | a.className = "card-chip"; |
| 247 | a.textContent = c.label; |
| 248 | a.href = c.url; |
| 249 | a.target = "_blank"; |
| 250 | a.rel = "noopener noreferrer"; |
| 251 | el.appendChild(a); |
| 252 | } |
| 253 | return el; |
| 254 | } |
| 255 | } |
| 256 | // Unreachable after normalize, but never let a render throw take the feed down. |
| 257 | return document.createElement("span"); |
| 258 | } |
| 259 | |
| 260 | // CARD_CSS is injected by the chat app alongside its own styles β themed via |
| 261 | // the standard variables so cards follow light/dark and the chosen theme. |
| 262 | export const CARD_CSS = ` |
| 263 | .card { |
| 264 | margin-top: 6px; padding: 12px 14px; max-width: 480px; |
| 265 | background: var(--panel); border: 1px solid var(--border); |
| 266 | border-radius: var(--radius); display: flex; flex-direction: column; gap: 8px; |
| 267 | } |
| 268 | .card-heading-text { font-weight: 700; font-size: 1.05em; text-wrap: balance; } |
| 269 | .card-heading-sub { color: var(--muted); font-size: 0.85em; margin-top: 2px; } |
| 270 | .card-text { overflow-wrap: anywhere; white-space: pre-wrap; } |
| 271 | .card-item { display: flex; align-items: baseline; gap: 8px; } |
| 272 | .card-item-text { flex: 1 1 auto; overflow-wrap: anywhere; } |
| 273 | a.card-item-text { color: var(--link); text-decoration: none; } |
| 274 | a.card-item-text:hover { text-decoration: underline; } |
| 275 | .card-item-source { |
| 276 | flex: 0 0 auto; color: var(--muted); font-size: 0.78em; |
| 277 | text-transform: uppercase; letter-spacing: 0.04em; |
| 278 | } |
| 279 | .card-list { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 4px; } |
| 280 | .card-kv { border-collapse: collapse; font-variant-numeric: tabular-nums; } |
| 281 | .card-kv td { padding: 2px 0; } |
| 282 | .card-kv-key { color: var(--muted); padding-right: 14px; white-space: nowrap; } |
| 283 | .card-divider { border: none; border-top: 1px solid var(--border); margin: 2px 0; } |
| 284 | .card-section { display: flex; flex-direction: column; gap: 6px; } |
| 285 | .card-section-title { |
| 286 | font-weight: 600; font-size: 0.8em; color: var(--muted); |
| 287 | text-transform: uppercase; letter-spacing: 0.05em; |
| 288 | } |
| 289 | .card-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; } |
| 290 | .card-chip { |
| 291 | padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; |
| 292 | background: var(--bubble-them); color: var(--ink); text-decoration: none; |
| 293 | font-size: 0.9em; cursor: var(--ui-cursor, pointer); |
| 294 | } |
| 295 | .card-chip:hover { border-color: var(--accent); } |
| 296 | `; |
| 297 | |