eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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
18export const CARD_SCHEMA = 1;
19
20// Bounds β€” part of the schema contract (the Swift parser enforces the same).
21const MAX_BLOCKS = 32; // top-level blocks, and blocks within one section
22const MAX_LIST_ITEMS = 32;
23const MAX_KV_ROWS = 16;
24const MAX_CHIPS = 4;
25const MAX_TEXT = 4000; // any body text
26const MAX_LABEL = 200; // headings, titles, sources, chip labels
27const MAX_URL = 2048;
28
29function str(v, max) {
30 return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
31}
32
33function optStr(v, max) {
34 if (v === undefined || v === null) return "";
35 return typeof v === "string" && v.length <= max ? v : null;
36}
37
38function 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).
47export 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
55function 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
66function 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.
147export 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
156function 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.
262export 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; }
273a.card-item-text { color: var(--link); text-decoration: none; }
274a.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