@khanglvm/relay 0.2.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,719 @@
1
+ // blocks.js — window.RelayBlocks: renders normalized "blocks" (markdown,
2
+ // table, code, chart, mermaid, html) into a container. Zero runtime deps;
3
+ // vanilla DOM. Chart.js / Mermaid are vendored and lazy-loaded on demand.
4
+ // XSS-safe: all source text is HTML-escaped before any markdown transform.
5
+ (() => {
6
+ 'use strict';
7
+
8
+ // ---------- tiny DOM helper (mirrors app.js idiom) ----------
9
+ function el(tag, attrs = {}, ...children) {
10
+ const n = document.createElement(tag);
11
+ for (const [k, v] of Object.entries(attrs)) {
12
+ if (v === undefined || v === null) continue;
13
+ if (k === 'class') n.className = v;
14
+ else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
15
+ else n.setAttribute(k, v);
16
+ }
17
+ for (const c of children.flat()) {
18
+ if (c !== null && c !== undefined) n.append(c.nodeType ? c : document.createTextNode(c));
19
+ }
20
+ return n;
21
+ }
22
+
23
+ function esc(s) {
24
+ return String(s)
25
+ .replace(/&/g, '&')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;');
30
+ }
31
+
32
+ // ---------- chart palette (from contract) ----------
33
+ const PALETTE_LIGHT = ['#c2674b', '#4d8a66', '#5a7ca8', '#b9913f', '#8a6da3', '#57534e'];
34
+ const PALETTE_DARK = ['#d98e67', '#6fbf92', '#7c9fcc', '#d4b061', '#ad8fc2', '#a29c93'];
35
+
36
+ function cssVar(name) {
37
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
38
+ }
39
+
40
+ // ---------- markdown mini renderer (NO library) ----------
41
+ // Escape FIRST, then apply transforms on the already-escaped text. Because
42
+ // <, >, & are gone, our generated tags are the only real tags in the output.
43
+ function mdInline(escaped) {
44
+ let s = escaped;
45
+ // inline code first so its contents aren't treated as bold/italic/links
46
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
47
+ // links [text](url) — url is already escaped; guard javascript: schemes
48
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
49
+ const safe = /^(https?:|mailto:|\/|#|\.)/i.test(url) ? url : '#';
50
+ return `<a href="${safe}" target="_blank" rel="noopener">${text}</a>`;
51
+ });
52
+ // bold then italic (bold uses ** so must run before single *)
53
+ s = s.replace(/\*\*([^*]+)\*\*/g, (_m, c) => `<strong>${c}</strong>`);
54
+ s = s.replace(/\*([^*]+)\*/g, (_m, c) => `<em>${c}</em>`);
55
+ return s;
56
+ }
57
+
58
+ function renderMarkdown(md) {
59
+ const root = el('div', { class: 'md' });
60
+ const lines = String(md).replace(/\r\n?/g, '\n').split('\n');
61
+ let html = '';
62
+ let i = 0;
63
+
64
+ // list-stack rendering supports one nesting level (indent >= 2 spaces)
65
+ function consumeList() {
66
+ const blocks = []; // {ordered, items:[{html, sub:[...]}]}
67
+ let cur = null;
68
+ while (i < lines.length) {
69
+ const line = lines[i];
70
+ const m = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
71
+ if (!m) break;
72
+ const indent = m[1].length;
73
+ const ordered = /\d+\./.test(m[2]);
74
+ const content = mdInline(esc(m[3]));
75
+ if (indent >= 2 && cur && cur.items.length) {
76
+ // nested under the previous top-level item
77
+ const parent = cur.items[cur.items.length - 1];
78
+ if (!parent.sub) parent.sub = { ordered, items: [] };
79
+ parent.sub.items.push(content);
80
+ } else {
81
+ if (!cur || cur.ordered !== ordered) {
82
+ cur = { ordered, items: [] };
83
+ blocks.push(cur);
84
+ }
85
+ cur.items.push({ html: content, sub: null });
86
+ }
87
+ i++;
88
+ }
89
+ let out = '';
90
+ for (const b of blocks) {
91
+ const tag = b.ordered ? 'ol' : 'ul';
92
+ out += `<${tag}>`;
93
+ for (const it of b.items) {
94
+ out += `<li>${it.html}`;
95
+ if (it.sub) {
96
+ const st = it.sub.ordered ? 'ol' : 'ul';
97
+ out += `<${st}>` + it.sub.items.map((x) => `<li>${x}</li>`).join('') + `</${st}>`;
98
+ }
99
+ out += '</li>';
100
+ }
101
+ out += `</${tag}>`;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ while (i < lines.length) {
107
+ const line = lines[i];
108
+
109
+ // fenced code block ```lang
110
+ const fence = line.match(/^```\s*([\w+-]*)\s*$/);
111
+ if (fence) {
112
+ i++;
113
+ const buf = [];
114
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
115
+ if (i < lines.length) i++; // closing fence
116
+ html += `<pre class="md-pre"><code>${esc(buf.join('\n'))}</code></pre>`;
117
+ continue;
118
+ }
119
+
120
+ // horizontal rule
121
+ if (/^\s*---+\s*$/.test(line)) { html += '<hr>'; i++; continue; }
122
+
123
+ // headings
124
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
125
+ if (h) {
126
+ const lvl = h[1].length;
127
+ html += `<h${lvl}>${mdInline(esc(h[2]))}</h${lvl}>`;
128
+ i++;
129
+ continue;
130
+ }
131
+
132
+ // blockquote (collapse consecutive > lines)
133
+ if (/^\s*>\s?/.test(line)) {
134
+ const buf = [];
135
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
136
+ buf.push(lines[i].replace(/^\s*>\s?/, ''));
137
+ i++;
138
+ }
139
+ html += `<blockquote>${mdInline(esc(buf.join(' ')))}</blockquote>`;
140
+ continue;
141
+ }
142
+
143
+ // lists
144
+ if (/^(\s*)([-*]|\d+\.)\s+/.test(line)) { html += consumeList(); continue; }
145
+
146
+ // blank line
147
+ if (/^\s*$/.test(line)) { i++; continue; }
148
+
149
+ // paragraph — accumulate until blank / block boundary
150
+ const buf = [];
151
+ while (
152
+ i < lines.length &&
153
+ !/^\s*$/.test(lines[i]) &&
154
+ !/^```/.test(lines[i]) &&
155
+ !/^\s*---+\s*$/.test(lines[i]) &&
156
+ !/^#{1,3}\s+/.test(lines[i]) &&
157
+ !/^\s*>\s?/.test(lines[i]) &&
158
+ !/^(\s*)([-*]|\d+\.)\s+/.test(lines[i])
159
+ ) {
160
+ buf.push(lines[i]);
161
+ i++;
162
+ }
163
+ html += `<p>${mdInline(esc(buf.join('\n'))).replace(/\n/g, '<br>')}</p>`;
164
+ }
165
+
166
+ root.innerHTML = html;
167
+ return root;
168
+ }
169
+
170
+ // ---------- code tinter (lightweight regex highlighter) ----------
171
+ const KEYWORDS = {
172
+ js: 'await async break case catch class const continue default delete do else export extends false finally for from function if import in instanceof let new null of return super switch this throw true try typeof undefined var void while yield',
173
+ ts: 'await async break case catch class const continue default delete do else enum export extends false finally for from function if implements import in instanceof interface let new null of private protected public readonly return super switch this throw true try type typeof undefined var void while yield',
174
+ json: 'true false null',
175
+ py: 'and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield',
176
+ sh: 'if then else elif fi for while do done case esac function in return export local echo cd',
177
+ css: '',
178
+ html: '',
179
+ };
180
+
181
+ function tintCode(code, lang) {
182
+ const langKey = (lang || '').toLowerCase();
183
+ const alias = { javascript: 'js', typescript: 'ts', shell: 'sh', bash: 'sh', python: 'py' };
184
+ const key = alias[langKey] || langKey;
185
+ if (!Object.prototype.hasOwnProperty.call(KEYWORDS, key)) {
186
+ return esc(code); // unknown lang -> plain
187
+ }
188
+ // Single-pass tokenizer over the RAW source: one alternation regex, each
189
+ // match escaped + wrapped as it is emitted. No placeholders — placeholder
190
+ // text gets re-tokenized by later passes and corrupts the output.
191
+ const kws = (KEYWORDS[key] || '').split(' ').filter(Boolean);
192
+ const kwAlt = kws.length ? kws.join('|') : 'A\\bB'; // alternation that never matches
193
+ let rx;
194
+ let classes;
195
+ if (key === 'css') {
196
+ rx = new RegExp(
197
+ /(\/\*[\s\S]*?\*\/)/.source +
198
+ '|' + /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.source +
199
+ '|' + /([A-Za-z-]+(?=\s*:))/.source +
200
+ '|' + /(\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b)/.source,
201
+ 'g'
202
+ );
203
+ classes = ['com', 'str', 'kw', 'num'];
204
+ } else if (key === 'html') {
205
+ rx = new RegExp(
206
+ /(<!--[\s\S]*?-->)/.source +
207
+ '|' + /(<\/?[A-Za-z][\w-]*|\/?>)/.source +
208
+ '|' + /("[^"]*")/.source,
209
+ 'g'
210
+ );
211
+ classes = ['com', 'kw', 'str'];
212
+ } else {
213
+ const comment = key === 'py' || key === 'sh'
214
+ ? /(#[^\n]*)/.source
215
+ : /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/.source;
216
+ const str = key === 'py' || key === 'sh'
217
+ ? /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.source
218
+ : /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/.source;
219
+ rx = new RegExp(
220
+ comment +
221
+ '|' + str +
222
+ '|' + /(\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b)/.source +
223
+ '|(\\b(?:' + kwAlt + ')\\b)',
224
+ 'g'
225
+ );
226
+ classes = ['com', 'str', 'num', 'kw'];
227
+ }
228
+ let out = '';
229
+ let last = 0;
230
+ let m;
231
+ while ((m = rx.exec(code))) {
232
+ out += esc(code.slice(last, m.index));
233
+ let cls = null;
234
+ for (let g = 1; g < m.length; g++) {
235
+ if (m[g] !== undefined) {
236
+ cls = classes[g - 1];
237
+ break;
238
+ }
239
+ }
240
+ out += cls ? '<span class="tok-' + cls + '">' + esc(m[0]) + '</span>' : esc(m[0]);
241
+ last = m.index + m[0].length;
242
+ if (m[0].length === 0) rx.lastIndex++;
243
+ }
244
+ out += esc(code.slice(last));
245
+ return out;
246
+ }
247
+
248
+ function renderCode(block) {
249
+ const code = el('code');
250
+ code.innerHTML = tintCode(block.code || '', block.lang);
251
+ return el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
252
+ }
253
+
254
+ // ---------- table ----------
255
+ function normalizeColumns(columns) {
256
+ return (columns || []).map((c, idx) => {
257
+ if (c && typeof c === 'object') {
258
+ return { key: c.key !== undefined ? c.key : idx, label: c.label !== undefined ? c.label : String(c.key), align: c.align || null };
259
+ }
260
+ return { key: idx, label: String(c), align: null };
261
+ });
262
+ }
263
+
264
+ function cellValue(row, col) {
265
+ if (Array.isArray(row)) return row[col.key];
266
+ return row[col.key];
267
+ }
268
+
269
+ function numericAware(a, b) {
270
+ const na = parseFloat(a);
271
+ const nb = parseFloat(b);
272
+ const aNum = a !== '' && a !== null && a !== undefined && !Number.isNaN(na) && String(na) === String(a).trim();
273
+ const bNum = b !== '' && b !== null && b !== undefined && !Number.isNaN(nb) && String(nb) === String(b).trim();
274
+ if (aNum && bNum) return na - nb;
275
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
276
+ }
277
+
278
+ function renderTable(block, ctx, blockId) {
279
+ const cols = normalizeColumns(block.columns);
280
+ const rows = block.rows || [];
281
+ const sortable = block.sortable === true;
282
+ // sortState: column index in cols (-1 none) + dir
283
+ let sortCol = -1;
284
+ let sortDir = 0; // 0 none, 1 asc, -1 desc
285
+
286
+ const table = el('table', { class: 'blk-table' });
287
+ const thead = el('thead');
288
+ const trHead = el('tr');
289
+ const headCells = [];
290
+ cols.forEach((col, ci) => {
291
+ const th = el('th', { class: sortable ? 'sortable' : null });
292
+ if (col.align) th.style.textAlign = col.align;
293
+ const labelSpan = el('span', {}, col.label);
294
+ const arrow = el('span', { class: 'sort-arrow' }, '');
295
+ th.append(labelSpan, arrow);
296
+ if (sortable) {
297
+ th.addEventListener('click', () => {
298
+ if (sortCol === ci) sortDir = sortDir === 1 ? -1 : sortDir === -1 ? 0 : 1;
299
+ else { sortCol = ci; sortDir = 1; }
300
+ rebuild();
301
+ });
302
+ }
303
+ headCells.push({ th, arrow });
304
+ trHead.append(th);
305
+ });
306
+ thead.append(trHead);
307
+ const tbody = el('tbody');
308
+ table.append(thead, tbody);
309
+
310
+ function rebuild() {
311
+ // keep original row indices stable across sorts
312
+ let order = rows.map((_r, idx) => idx);
313
+ if (sortable && sortCol >= 0 && sortDir !== 0) {
314
+ const col = cols[sortCol];
315
+ order.sort((ia, ib) => {
316
+ const r = numericAware(cellValue(rows[ia], col), cellValue(rows[ib], col));
317
+ return sortDir === 1 ? r : -r;
318
+ });
319
+ }
320
+ headCells.forEach((hc, ci) => {
321
+ hc.arrow.textContent = sortable && ci === sortCol && sortDir !== 0 ? (sortDir === 1 ? ' ↑' : ' ↓') : '';
322
+ });
323
+ tbody.replaceChildren();
324
+ for (const origIdx of order) {
325
+ const row = rows[origIdx];
326
+ const tr = el('tr');
327
+ for (const col of cols) {
328
+ const raw = cellValue(row, col);
329
+ const td = el('td', {}, raw === undefined || raw === null ? '' : String(raw));
330
+ if (col.align) td.style.textAlign = col.align;
331
+ ctx.annotate?.register(td, {
332
+ blockId,
333
+ questionId: ctx.questionId,
334
+ target: { kind: 'table-cell', row: origIdx, col: col.key, value: String(raw === undefined || raw === null ? '' : raw) },
335
+ });
336
+ tr.append(td);
337
+ }
338
+ tbody.append(tr);
339
+ }
340
+ }
341
+ rebuild();
342
+ return table;
343
+ }
344
+
345
+ // ---------- lazy vendor loaders (cached promises) ----------
346
+ let chartPromise = null;
347
+ function loadChart() {
348
+ if (window.Chart) return Promise.resolve(window.Chart);
349
+ if (chartPromise) return chartPromise;
350
+ chartPromise = new Promise((resolve, reject) => {
351
+ const s = document.createElement('script');
352
+ s.src = '/vendor/chart.umd.js';
353
+ s.onload = () => (window.Chart ? resolve(window.Chart) : reject(new Error('Chart.js failed to load')));
354
+ s.onerror = () => reject(new Error('Chart.js failed to load'));
355
+ document.head.appendChild(s);
356
+ });
357
+ return chartPromise;
358
+ }
359
+
360
+ let mermaidPromise = null;
361
+ function loadMermaid() {
362
+ if (window.mermaid) return Promise.resolve(window.mermaid);
363
+ if (mermaidPromise) return mermaidPromise;
364
+ mermaidPromise = new Promise((resolve, reject) => {
365
+ const s = document.createElement('script');
366
+ s.src = '/vendor/mermaid.min.js';
367
+ s.onload = () => (window.mermaid ? resolve(window.mermaid) : reject(new Error('Mermaid failed to load')));
368
+ s.onerror = () => reject(new Error('Mermaid failed to load'));
369
+ document.head.appendChild(s);
370
+ });
371
+ return mermaidPromise;
372
+ }
373
+
374
+ // ---------- chart ----------
375
+ function clampHeight(h, def) {
376
+ const n = Number(h);
377
+ if (!Number.isFinite(n)) return def;
378
+ return Math.max(100, Math.min(2400, n));
379
+ }
380
+
381
+ function palette() {
382
+ return (document.documentElement.dataset.theme === 'dark' ||
383
+ (!document.documentElement.dataset.theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches))
384
+ ? PALETTE_DARK
385
+ : PALETTE_LIGHT;
386
+ }
387
+
388
+ function themeDefaults(ctx) {
389
+ const grid = cssVar('--border') || '#ece9e4';
390
+ const tick = cssVar('--muted') || '#8a8580';
391
+ const fontFamily = cssVar('--sans') || 'sans-serif';
392
+ return { grid, tick, fontFamily };
393
+ }
394
+
395
+ // Apply theme defaults only where unset (deep, conservative).
396
+ function applyChartTheme(config, ctx) {
397
+ const { grid, tick, fontFamily } = themeDefaults(ctx);
398
+ config.options = config.options || {};
399
+ const o = config.options;
400
+ if (o.responsive === undefined) o.responsive = true;
401
+ if (o.maintainAspectRatio === undefined) o.maintainAspectRatio = false;
402
+ if (o.animation === undefined) o.animation = false;
403
+ o.plugins = o.plugins || {};
404
+ o.plugins.legend = o.plugins.legend || {};
405
+ o.plugins.legend.labels = o.plugins.legend.labels || {};
406
+ if (o.plugins.legend.labels.color === undefined) o.plugins.legend.labels.color = tick;
407
+ if (o.plugins.legend.labels.font === undefined) o.plugins.legend.labels.font = { family: fontFamily, size: 12 };
408
+ if (o.plugins.title && o.plugins.title.color === undefined) o.plugins.title.color = tick;
409
+
410
+ const styleAxis = (ax) => {
411
+ if (!ax || typeof ax !== 'object') return;
412
+ ax.grid = ax.grid || {};
413
+ if (ax.grid.color === undefined) ax.grid.color = grid;
414
+ ax.ticks = ax.ticks || {};
415
+ if (ax.ticks.color === undefined) ax.ticks.color = tick;
416
+ if (ax.ticks.font === undefined) ax.ticks.font = { family: fontFamily, size: 12 };
417
+ };
418
+ o.scales = o.scales || {};
419
+ // Chart.js v3+ uses named scales (x/y/r). Style any present; for simplified
420
+ // configs we pre-create x/y below.
421
+ for (const key of Object.keys(o.scales)) styleAxis(o.scales[key]);
422
+ return config;
423
+ }
424
+
425
+ function simplifiedToConfig(block, ctx) {
426
+ const pal = palette();
427
+ const kind = block.kind || 'bar';
428
+ const series = block.series || [];
429
+ const datasets = series.map((s, i) => {
430
+ const color = s.color || pal[i % pal.length];
431
+ const ds = {
432
+ label: s.label !== undefined ? s.label : `Series ${i + 1}`,
433
+ data: s.data || [],
434
+ };
435
+ if (kind === 'line') {
436
+ ds.borderColor = color;
437
+ ds.backgroundColor = color;
438
+ ds.tension = 0.25;
439
+ ds.pointRadius = 3;
440
+ } else if (kind === 'pie' || kind === 'doughnut') {
441
+ ds.backgroundColor = (s.data || []).map((_d, di) => s.color || pal[di % pal.length]);
442
+ ds.borderColor = cssVar('--card') || '#fff';
443
+ ds.borderWidth = 2;
444
+ } else if (kind === 'radar') {
445
+ ds.borderColor = color;
446
+ ds.backgroundColor = color + '33';
447
+ ds.pointBackgroundColor = color;
448
+ } else {
449
+ ds.backgroundColor = color;
450
+ ds.borderColor = color;
451
+ }
452
+ return ds;
453
+ });
454
+ const config = {
455
+ type: kind,
456
+ data: { labels: block.labels || [], datasets },
457
+ options: {},
458
+ };
459
+ if (block.title) {
460
+ config.options.plugins = { title: { display: true, text: block.title } };
461
+ }
462
+ // pre-create x/y scales for cartesian kinds so theme styling applies
463
+ if (kind === 'bar' || kind === 'line' || kind === 'scatter') {
464
+ config.options.scales = { x: {}, y: {} };
465
+ }
466
+ return config;
467
+ }
468
+
469
+ function chartAnnotationCount(ctx, blockId) {
470
+ if (!ctx.annotate) return 0;
471
+ return ctx.annotate.list().filter(
472
+ (a) => a.blockId === blockId && a.target && a.target.kind === 'chart-element'
473
+ ).length;
474
+ }
475
+
476
+ function renderChart(block, ctx, blockId) {
477
+ const height = clampHeight(block.height, 320);
478
+ const wrap = el('div', { class: 'blk-chart' });
479
+ wrap.style.height = height + 'px';
480
+ const canvas = el('canvas');
481
+ wrap.append(canvas);
482
+
483
+ const badge = el('span', { class: 'blk-chart-badge' }, '');
484
+ function syncBadge() {
485
+ const n = chartAnnotationCount(ctx, blockId);
486
+ if (n > 0) { badge.textContent = String(n); badge.style.display = ''; }
487
+ else badge.style.display = 'none';
488
+ }
489
+ syncBadge();
490
+ wrap.append(badge);
491
+
492
+ loadChart().then((Chart) => {
493
+ let config = block.config
494
+ ? JSON.parse(JSON.stringify(block.config))
495
+ : simplifiedToConfig(block, ctx);
496
+ config = applyChartTheme(config, ctx);
497
+ let chart;
498
+ try {
499
+ chart = new Chart(canvas.getContext('2d'), config);
500
+ chartRegistry.push({ chart });
501
+ } catch (err) {
502
+ wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
503
+ return;
504
+ }
505
+ // hover -> pointer cursor on a hit
506
+ canvas.addEventListener('mousemove', (e) => {
507
+ const hits = chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
508
+ canvas.style.cursor = hits.length ? 'pointer' : 'default';
509
+ });
510
+ canvas.addEventListener('click', (e) => {
511
+ if (!ctx.annotate) return;
512
+ const hits = chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
513
+ if (!hits.length) return;
514
+ const { datasetIndex, index } = hits[0];
515
+ const ds = config.data.datasets[datasetIndex] || {};
516
+ const label = config.data.labels ? config.data.labels[index] : undefined;
517
+ const value = Array.isArray(ds.data) ? ds.data[index] : undefined;
518
+ ctx.annotate.openExternal(
519
+ {
520
+ blockId,
521
+ questionId: ctx.questionId,
522
+ target: { kind: 'chart-element', datasetIndex, index, label: label !== undefined ? String(label) : '', value },
523
+ },
524
+ wrap
525
+ );
526
+ });
527
+ }).catch((err) => {
528
+ wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
529
+ });
530
+
531
+ // expose a refresh hook so the list can update the badge after changes
532
+ wrap._relaySyncBadge = syncBadge;
533
+ return wrap;
534
+ }
535
+
536
+ // ---------- mermaid ----------
537
+ let mermaidSeq = 0;
538
+ // Registries so a live theme toggle can re-render diagrams and restyle
539
+ // charts without a page reload.
540
+ const mermaidRegistry = [];
541
+ const chartRegistry = [];
542
+
543
+ function renderMermaid(block, ctx, blockId) {
544
+ const container = el('div', { class: 'blk-mermaid' });
545
+ const entry = { container, block, ctx, blockId };
546
+ mermaidRegistry.push(entry);
547
+ drawMermaid(entry);
548
+ return container;
549
+ }
550
+
551
+ function drawMermaid(entry) {
552
+ const { container, block, ctx, blockId } = entry;
553
+ loadMermaid().then((mermaid) => {
554
+ try {
555
+ mermaid.initialize({
556
+ startOnLoad: false,
557
+ theme: ctx.theme() === 'dark' ? 'dark' : 'neutral',
558
+ securityLevel: 'strict',
559
+ });
560
+ } catch {
561
+ // ignore re-init issues
562
+ }
563
+ const id = 'rly-mmd-' + (++mermaidSeq);
564
+ const code = block.code || '';
565
+ const onSvg = (svg) => {
566
+ container.innerHTML = svg;
567
+ const svgEl = container.querySelector('svg');
568
+ if (svgEl) {
569
+ svgEl.removeAttribute('height');
570
+ svgEl.removeAttribute('width');
571
+ const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
572
+ if (vb && vb.width > 0) {
573
+ // never upscale past the diagram's natural size; still shrink on
574
+ // narrow screens (a 112px-wide graph must not stretch to 820px)
575
+ svgEl.style.width = '100%';
576
+ svgEl.style.maxWidth = Math.ceil(vb.width) + 'px';
577
+ svgEl.style.height = 'auto';
578
+ } else {
579
+ svgEl.style.maxWidth = '100%';
580
+ }
581
+ }
582
+ if (!ctx.annotate) return;
583
+ const nodes = container.querySelectorAll('.node, .edgeLabel');
584
+ nodes.forEach((g) => {
585
+ ctx.annotate.register(g, {
586
+ blockId,
587
+ questionId: ctx.questionId,
588
+ target: {
589
+ kind: 'mermaid-node',
590
+ nodeId: g.id || '',
591
+ text: (g.textContent || '').trim().slice(0, 120),
592
+ },
593
+ });
594
+ });
595
+ };
596
+ try {
597
+ const ret = mermaid.render(id, code);
598
+ if (ret && typeof ret.then === 'function') {
599
+ ret.then((r) => onSvg(r.svg)).catch((err) => showMermaidErr(container, err));
600
+ } else if (ret && ret.svg) {
601
+ onSvg(ret.svg);
602
+ } else if (typeof ret === 'string') {
603
+ onSvg(ret);
604
+ } else {
605
+ // legacy callback signature: render(id, code, cb)
606
+ mermaid.render(id, code, (svg) => onSvg(svg));
607
+ }
608
+ } catch (err) {
609
+ showMermaidErr(container, err);
610
+ }
611
+ }).catch((err) => showMermaidErr(container, err));
612
+ }
613
+
614
+ // Live theme toggle: re-render mermaid diagrams with the new mermaid theme
615
+ // and restyle existing charts' grid/tick/legend colors in place.
616
+ function onThemeChange() {
617
+ for (const entry of mermaidRegistry) {
618
+ try {
619
+ drawMermaid(entry);
620
+ } catch {
621
+ // keep the previous svg on failure
622
+ }
623
+ }
624
+ const { grid, tick } = themeDefaults();
625
+ for (const { chart } of chartRegistry) {
626
+ try {
627
+ const o = chart.options || {};
628
+ if (o.plugins && o.plugins.legend && o.plugins.legend.labels) o.plugins.legend.labels.color = tick;
629
+ if (o.plugins && o.plugins.title) o.plugins.title.color = tick;
630
+ if (o.scales) {
631
+ for (const k of Object.keys(o.scales)) {
632
+ const ax = o.scales[k];
633
+ if (!ax) continue;
634
+ if (ax.grid) ax.grid.color = grid;
635
+ if (ax.ticks) ax.ticks.color = tick;
636
+ }
637
+ }
638
+ chart.update('none');
639
+ } catch {
640
+ // chart may already be destroyed
641
+ }
642
+ }
643
+ }
644
+
645
+ function showMermaidErr(container, err) {
646
+ container.replaceChildren(
647
+ el('div', { class: 'blk-error' }, 'Diagram error: ' + (err && err.message ? err.message : String(err)))
648
+ );
649
+ }
650
+
651
+ // ---------- html (sandboxed iframe) ----------
652
+ function renderHtml(block, ctx, blockId) {
653
+ const height = clampHeight(block.height, 360);
654
+ return el('iframe', {
655
+ class: 'viz',
656
+ 'data-block-id': blockId,
657
+ 'data-question-id': ctx.questionId || '',
658
+ src: ctx.htmlSrc(blockId),
659
+ height: String(height),
660
+ sandbox: 'allow-scripts allow-forms allow-popups allow-modals',
661
+ loading: 'lazy',
662
+ });
663
+ }
664
+
665
+ // ---------- dispatch ----------
666
+ function renderBlock(block, ctx) {
667
+ const blockId = block.id;
668
+ const wrapper = el('div', { class: 'blk blk-' + block.type, 'data-block-id': blockId });
669
+ let inner = null;
670
+ switch (block.type) {
671
+ case 'markdown': {
672
+ inner = renderMarkdown(block.md || '');
673
+ wrapper.append(inner);
674
+ ctx.annotate?.enableTextSelection(inner, { blockId, questionId: ctx.questionId });
675
+ break;
676
+ }
677
+ case 'table':
678
+ inner = renderTable(block, ctx, blockId);
679
+ wrapper.append(inner);
680
+ break;
681
+ case 'code':
682
+ inner = renderCode(block);
683
+ wrapper.append(inner);
684
+ break;
685
+ case 'chart':
686
+ inner = renderChart(block, ctx, blockId);
687
+ wrapper.append(inner);
688
+ break;
689
+ case 'mermaid':
690
+ inner = renderMermaid(block, ctx, blockId);
691
+ wrapper.append(inner);
692
+ break;
693
+ case 'html':
694
+ inner = renderHtml(block, ctx, blockId);
695
+ wrapper.append(inner);
696
+ break;
697
+ default:
698
+ wrapper.append(el('div', { class: 'blk-error' }, 'Unknown block type: ' + esc(String(block.type))));
699
+ }
700
+ return wrapper;
701
+ }
702
+
703
+ // ---------- public API ----------
704
+ async function render(container, blocks, ctx) {
705
+ const list = Array.isArray(blocks) ? blocks : [];
706
+ const safeCtx = {
707
+ theme: ctx && ctx.theme ? ctx.theme : () => 'light',
708
+ htmlSrc: ctx && ctx.htmlSrc ? ctx.htmlSrc : (id) => '/html/b/' + id,
709
+ questionId: ctx && ctx.questionId !== undefined ? ctx.questionId : null,
710
+ annotate: ctx && ctx.annotate ? ctx.annotate : null,
711
+ };
712
+ for (const block of list) {
713
+ if (!block || !block.type) continue;
714
+ container.append(renderBlock(block, safeCtx));
715
+ }
716
+ }
717
+
718
+ window.RelayBlocks = { render, onThemeChange };
719
+ })();