@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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/rly.js +9 -0
- package/docs/AGENT.md +317 -0
- package/package.json +46 -0
- package/skills/relay/SKILL.md +156 -0
- package/skills/relay/examples/blocks-showcase.json +118 -0
- package/skills/relay/examples/feature-feedback.json +27 -0
- package/skills/relay/examples/prototype-review.json +25 -0
- package/src/cli.js +629 -0
- package/src/open.js +15 -0
- package/src/server.js +371 -0
- package/src/spec.js +435 -0
- package/src/store.js +147 -0
- package/src/ui/annotate.css +151 -0
- package/src/ui/annotate.js +440 -0
- package/src/ui/app.js +605 -0
- package/src/ui/blocks.css +170 -0
- package/src/ui/blocks.js +719 -0
- package/src/ui/index.html +21 -0
- package/src/ui/kit.js +413 -0
- package/src/ui/style.css +231 -0
- package/src/util.js +19 -0
- package/vendor/VERSIONS.json +5 -0
- package/vendor/chart.umd.js +14 -0
- package/vendor/mermaid.min.js +3405 -0
package/src/ui/blocks.js
ADDED
|
@@ -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, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''');
|
|
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
|
+
})();
|