@jarenjs/play 0.34.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/src/engines.js ADDED
@@ -0,0 +1,516 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The playground's engine descriptors — each wraps a real shipped
4
+ * `@jarenjs` compiler into a pure `run(source, data, options) → PlayResult`.
5
+ * `sourcePanes` are the engine input(s); `dataPanes` are the JSON it runs
6
+ * against (empty for source-only engines, which land in a later order).
7
+ * Registered operators reach the `query`/`jslt` engines through
8
+ * `options.operators` (a `.toOptions()` registry) — the host opt-in.
9
+ */
10
+
11
+ import {
12
+ compileJSONPath, compileJSONPointer, compileRelativeJSONPointer, JSONPOINTER_NOTHING,
13
+ compileJSONPatch, applyMergePatch, createJSONPatch, createMergePatch, compileJsonQuery,
14
+ } from '@jarenjs/json';
15
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
16
+ import { compileJtltStylesheet } from '@jarenjs/json/jtlt';
17
+ import { parseXQuery } from '@jarenjs/json/xquery';
18
+ import { parseJosl, stringifyJosl, stringifyJsonx } from '@jarenjs/josl';
19
+ import { parseCsvDocument, stringifyCsv, sniffCsvDialect } from '@jarenjs/josl/csv';
20
+ import { createTypeTestCompiler } from '@jarenjs/validate/query';
21
+ import { formatMs } from './format.js';
22
+
23
+ const compileTypeTest = createTypeTestCompiler();
24
+
25
+ const now = () => performance.now();
26
+ const fmt = (v) => (v === undefined ? '(no result)' : JSON.stringify(v, null, 2));
27
+ const msg = (err) => String(/** @type {any} */ (err)?.message ?? err);
28
+ const code = (err) => /** @type {any} */ (err)?.code;
29
+
30
+ /** Parse a pane's JSON text; `{ value }` or `{ error }`. */
31
+ function parseJson(text, label) {
32
+ try { return { value: JSON.parse((text ?? 'null') === '' ? 'null' : text) }; }
33
+ catch (err) { return { error: `${label}: ${msg(err)}` }; }
34
+ }
35
+
36
+ /** A single-`code`-panel Result — the shape most engines return. */
37
+ /** @returns {import('./index.js').PlayResult} */
38
+ const ok = (text, compileMs, runMs, deep) => okPanels([{ id: 'out', label: 'Output', kind: 'code', text }, ...(deep ?? [])], compileMs, runMs);
39
+
40
+ /**
41
+ * A multi-panel Result — the engine hands over its own screen list.
42
+ * Either half of the timing may be `null`, and that is load-bearing: an
43
+ * engine with no compile step (patch merge/diff) and a phase the host
44
+ * measured for us or not at all are all honestly "no number", never a
45
+ * fabricated `0` the stage would print as "ran 0 ms".
46
+ * @returns {import('./index.js').PlayResult}
47
+ */
48
+ const okPanels = (panels, compileMs, runMs) => ({ ok: true, timing: { compileMs, runMs }, error: null, panels });
49
+
50
+ /** A `deep` cards panel — the "how it ran" stat row of a drill-down. */
51
+ /** @returns {import('./index.js').Panel} */
52
+ const deepCards = (id, label, items) => ({ id, label, kind: 'cards', depth: 'deep', items });
53
+
54
+ /** A `deep` code panel — a compiled artifact / round-trip drill-down. */
55
+ /** @returns {import('./index.js').Panel} */
56
+ const deepCode = (id, label, text) => ({ id, label, kind: 'code', depth: 'deep', text });
57
+
58
+ /** A table cell → a display string. Primitives (incl. BigInt, from typed CSV)
59
+ * stringify directly; objects/arrays become compact JSON. Never throws. */
60
+ const cell = (v) => {
61
+ if (v == null) return '';
62
+ if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return String(v); } }
63
+ return String(v);
64
+ };
65
+
66
+ /**
67
+ * A VISUAL engine (markdown/mermaid/charts): the descriptor + examples live
68
+ * here (canonical), but the vnode rendering is delegated to a host-injected
69
+ * `options.renderers[id]` — the hybrid seam that keeps this package free of the
70
+ * @jarenjs/md, /mermaid and /charts dependencies. No renderer → an honest
71
+ * Result, never a throw.
72
+ * @returns {import('./index.js').EngineDescriptor}
73
+ */
74
+ function visual(id, label, lead, opts = {}) {
75
+ return {
76
+ id, label, lead,
77
+ sourcePanes: [{ key: 'source', label: opts.sourceLabel ?? 'Source', control: 'code' }],
78
+ dataPanes: [],
79
+ ...(opts.optionPanes ? { optionPanes: opts.optionPanes } : {}),
80
+ run(source, data, options) {
81
+ const render = options?.renderers?.[id];
82
+ if (typeof render !== 'function') {
83
+ return fail(`the ${label} engine renders in the host — inject options.renderers.${id}`, 'PLAY_NO_RENDERER');
84
+ }
85
+ const t0 = now();
86
+ let view;
87
+ try { view = render(source.source ?? '', options?.config); }
88
+ catch (err) { return fail(msg(err), code(err)); }
89
+ const t1 = now();
90
+ return okPanels(renderedPanels(view), ...renderTiming(view, t1 - t0));
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Split a host render's cost into `[compileMs, runMs]`. A renderer that
97
+ * reports its own phases (it compiles the source, then builds the vnode)
98
+ * is believed; one that does not leaves us holding a single wall-clock
99
+ * number for both, which we attribute to the RUN and leave the compile
100
+ * `null` — claiming a compile figure we never measured is the bug this
101
+ * replaces.
102
+ */
103
+ function renderTiming(view, totalMs) {
104
+ const compileMs = typeof view?.compileMs === 'number' ? view.compileMs : null;
105
+ const runMs = typeof view?.runMs === 'number' ? view.runMs : null;
106
+ if (compileMs === null && runMs === null) return [null, totalMs];
107
+ return [compileMs, runMs];
108
+ }
109
+
110
+ /** A host-rendered view (a bare vnode, or `{ vnode, deep }`) → the panel
111
+ * list: the preview plus any host-derived deep panels (AST, canonical
112
+ * round-trip), stamped `deep` so they ride behind the depth toggle. */
113
+ function renderedPanels(view) {
114
+ const rich = view !== null && typeof view === 'object' && !Array.isArray(view) && 'vnode' in view;
115
+ const vnode = rich ? view.vnode : view;
116
+ const deep = rich && Array.isArray(view.deep)
117
+ ? view.deep.map((p) => ({ ...p, depth: 'deep' }))
118
+ : [];
119
+ return [{ id: 'preview', label: 'Preview', kind: 'view', vnode }, ...deep];
120
+ }
121
+ /** @returns {import('./index.js').PlayResult} */
122
+ const fail = (message, c, path) => ({ ok: false, timing: null, error: { message, code: c, path }, panels: [] });
123
+
124
+ /** The compile options the query/jslt engines run with (registry-aware). */
125
+ function compileOptions(options) {
126
+ const registry = options?.operators;
127
+ const extra = registry && typeof registry.toOptions === 'function' ? registry.toOptions() : {};
128
+ return { compileTypeTest, ...extra };
129
+ }
130
+
131
+ /** @type {import('./index.js').EngineDescriptor[]} */
132
+ export const ENGINE_LIST = [
133
+ {
134
+ id: 'path', label: 'JSONPath', lead: 'RFC 9535 — select nodes from a document.',
135
+ sourcePanes: [{ key: 'selector', label: 'Selector', control: 'text' }],
136
+ dataPanes: [{ key: 'data', label: 'Data' }],
137
+ run(source, data) {
138
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
139
+ let compiled; const t0 = now();
140
+ try { compiled = compileJSONPath(source.selector ?? ''); }
141
+ catch (err) { return fail(msg(err), code(err)); }
142
+ const t1 = now();
143
+ let nodes;
144
+ try { nodes = compiled.nodes(d.value); }
145
+ catch (err) { return fail(msg(err), code(err)); }
146
+ const t2 = now();
147
+ return ok(fmt(nodes.map((n) => n.value)), t1 - t0, t2 - t1, [
148
+ deepCards('how', 'How it matched', [
149
+ { title: 'Matches', value: String(nodes.length) },
150
+ { title: 'Compile', value: formatMs(t1 - t0) },
151
+ { title: 'Run', value: formatMs(t2 - t1) },
152
+ ]),
153
+ deepCode('paths', 'The normalized paths', fmt(nodes.map((n) => n.path))),
154
+ ]);
155
+ },
156
+ },
157
+ {
158
+ id: 'pointer', label: 'JSON Pointer', lead: 'RFC 6901 absolute and relative pointers — address exactly one value.',
159
+ sourcePanes: [
160
+ { key: 'pointer', label: 'Pointer', control: 'text' },
161
+ // a RELATIVE pointer (it starts with a digit: "1/price", "0#") walks
162
+ // from this location; an absolute pointer ignores it
163
+ { key: 'location', label: 'From location (relative pointers)', control: 'text' },
164
+ ],
165
+ dataPanes: [{ key: 'data', label: 'Data' }],
166
+ run(source, data) {
167
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
168
+ const pointer = source.pointer ?? '';
169
+ const relative = /^\d/.test(pointer);
170
+ let getter; const t0 = now();
171
+ try { getter = relative ? compileRelativeJSONPointer(pointer) : compileJSONPointer(pointer); }
172
+ catch (err) { return fail(msg(err), code(err)); }
173
+ const t1 = now();
174
+ let value;
175
+ try { value = relative ? getter(d.value, source.location ?? '') : getter(d.value); }
176
+ catch (err) { return fail(msg(err), code(err)); }
177
+ const t2 = now();
178
+ if (value === JSONPOINTER_NOTHING) return ok('(nothing — the pointer addresses no value)', t1 - t0, t2 - t1);
179
+ return ok(fmt(value), t1 - t0, t2 - t1);
180
+ },
181
+ },
182
+ {
183
+ id: 'patch', label: 'JSON Patch', lead: 'RFC 6902 and RFC 7396, copy-on-write and atomic — plus structural diff.',
184
+ sourcePanes: [{ key: 'patch', label: 'Patch', control: 'code' }],
185
+ dataPanes: [{ key: 'data', label: 'Target document' }],
186
+ optionPanes: [{
187
+ key: 'mode', label: 'Mode', default: 'patch',
188
+ choices: [
189
+ { value: 'patch', label: 'RFC 6902 apply' },
190
+ { value: 'merge', label: 'RFC 7396 merge' },
191
+ { value: 'diff', label: 'diff (patch pane = the target)' },
192
+ ],
193
+ }],
194
+ run(source, data, options) {
195
+ const mode = options?.config?.mode ?? 'patch';
196
+ const target = parseJson(data.data, 'target'); if (target.error) return fail(target.error);
197
+ const patch = parseJson(source.patch, 'patch'); if (patch.error) return fail(patch.error);
198
+ if (mode === 'merge') {
199
+ // RFC 7396: null members delete; an unchanged document is the INPUT
200
+ let out; const t0 = now();
201
+ try { out = applyMergePatch(target.value, patch.value); }
202
+ catch (err) { return fail(msg(err), code(err)); }
203
+ const t1 = now();
204
+ return ok(fmt(out), null, t1 - t0, [
205
+ deepCards('how', 'How it merged', [
206
+ { title: 'Output', value: out === target.value ? '=== input' : 'a new document', note: out === target.value ? 'shared, copy-on-write' : undefined },
207
+ ]),
208
+ ]);
209
+ }
210
+ if (mode === 'diff') {
211
+ // the patch pane holds the TARGET document; both patch flavours of
212
+ // the structural diff are derived from data → target
213
+ let jsonPatch, mergePatch; const t0 = now();
214
+ try {
215
+ jsonPatch = createJSONPatch(target.value, patch.value);
216
+ mergePatch = createMergePatch(target.value, patch.value);
217
+ }
218
+ catch (err) { return fail(msg(err), code(err)); }
219
+ const t1 = now();
220
+ return okPanels([
221
+ { id: 'out', label: `JSON Patch (${jsonPatch.length} ops)`, kind: 'code', text: fmt(jsonPatch) },
222
+ deepCode('merge', 'The merge-patch flavour (RFC 7396)', fmt(mergePatch)),
223
+ ], null, t1 - t0);
224
+ }
225
+ let apply; const t0 = now();
226
+ try { apply = compileJSONPatch(patch.value, { changes: true }); }
227
+ catch (err) { return fail(msg(err), code(err)); }
228
+ const t1 = now();
229
+ let run;
230
+ try { run = apply(target.value); }
231
+ catch (err) { return fail(msg(err), code(err)); }
232
+ const t2 = now();
233
+ return ok(fmt(run.doc), t1 - t0, t2 - t1, [
234
+ deepCode('changes', 'What changed', fmt(run.changes)),
235
+ ]);
236
+ },
237
+ },
238
+ {
239
+ id: 'query', label: '$query', lead: 'Jaren JSON Query — filter, project and fold (registered operators included).',
240
+ sourcePanes: [{ key: 'query', label: 'Query', control: 'code' }, { key: 'externals', label: 'Externals', control: 'code' }],
241
+ dataPanes: [{ key: 'data', label: 'Data' }],
242
+ run(source, data, options) {
243
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
244
+ const q = parseJson(source.query, 'query'); if (q.error) return fail(q.error);
245
+ let externals = {};
246
+ if (source.externals !== undefined && String(source.externals).trim() !== '') {
247
+ const e = parseJson(source.externals, 'externals'); if (e.error) return fail(e.error); externals = e.value;
248
+ }
249
+ let fn; const t0 = now();
250
+ try { fn = compileJsonQuery(q.value, compileOptions(options)); }
251
+ catch (err) { return fail(msg(err), code(err)); }
252
+ const t1 = now();
253
+ let out;
254
+ try { out = fn(d.value, externals); }
255
+ catch (err) { return fail(msg(err), code(err)); }
256
+ const t2 = now();
257
+ const items = out === undefined ? 0 : Array.isArray(out) ? out.length : 1;
258
+ return ok(fmt(out), t1 - t0, t2 - t1, [
259
+ deepCards('how', 'How it ran', [
260
+ { title: 'Items', value: String(items) },
261
+ { title: 'Compile', value: formatMs(t1 - t0) },
262
+ { title: 'Run', value: formatMs(t2 - t1) },
263
+ ]),
264
+ ]);
265
+ },
266
+ },
267
+ {
268
+ id: 'jslt', label: 'JSLT', lead: 'JSON stylesheet transform (registered operators included).',
269
+ sourcePanes: [{ key: 'stylesheet', label: 'Stylesheet', control: 'code' }],
270
+ dataPanes: [{ key: 'data', label: 'Data' }],
271
+ run(source, data, options) {
272
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
273
+ const s = parseJson(source.stylesheet, 'stylesheet'); if (s.error) return fail(s.error);
274
+ let compiled; const t0 = now();
275
+ try { compiled = compileJsltStylesheet(s.value, compileOptions(options)); }
276
+ catch (err) { return fail(msg(err), code(err)); }
277
+ const t1 = now();
278
+ let out;
279
+ try { out = compiled(d.value); }
280
+ catch (err) { return fail(msg(err), code(err)); }
281
+ const t2 = now();
282
+ // the identity lesson: a rule that changes nothing hands the INPUT back
283
+ // (shared, copy-on-write) — worth teaching, so the deep card says which
284
+ return ok(fmt(out), t1 - t0, t2 - t1, [
285
+ deepCards('how', 'How it transformed', [
286
+ { title: 'Compile', value: formatMs(t1 - t0) },
287
+ { title: 'Transform', value: formatMs(t2 - t1) },
288
+ { title: 'Output', value: out === d.value ? '=== input' : 'a new document', note: out === d.value ? 'shared, copy-on-write' : undefined },
289
+ ]),
290
+ ]);
291
+ },
292
+ },
293
+ {
294
+ id: 'jtlt', label: 'JTLT', lead: 'JSLT\'s text front-end — render JSON as Markdown, XML or code.',
295
+ sourcePanes: [{ key: 'template', label: 'Template', control: 'code' }],
296
+ dataPanes: [{ key: 'data', label: 'Data' }],
297
+ run(source, data, options) {
298
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
299
+ const t = parseJson(source.template, 'template'); if (t.error) return fail(t.error);
300
+ let render; const t0 = now();
301
+ try { render = compileJtltStylesheet(t.value, compileOptions(options)); }
302
+ catch (err) { return fail(msg(err), code(err)); }
303
+ const t1 = now();
304
+ let out;
305
+ try { out = render(d.value); }
306
+ catch (err) { return fail(msg(err), code(err)); }
307
+ const t2 = now();
308
+ // JTLT emits TEXT (markdown / xml / source) — show it verbatim, not fmt'd
309
+ return ok(out === '' ? '(empty)' : out, t1 - t0, t2 - t1, [
310
+ // the machinery: JTLT desugars to a JSLT stylesheet — show it
311
+ deepCode('compiled', 'The compiled program', fmt(render.stylesheet)),
312
+ ]);
313
+ },
314
+ },
315
+ {
316
+ id: 'xquery', label: 'XQuery', lead: 'The XQuery 3.1 text subset — parsed to a query document and run over $doc.',
317
+ sourcePanes: [{ key: 'text', label: 'XQuery', control: 'code' }],
318
+ dataPanes: [{ key: 'data', label: 'Data' }],
319
+ run(source, data, options) {
320
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
321
+ let doc, fn; const t0 = now();
322
+ try {
323
+ doc = parseXQuery(source.text ?? '');
324
+ fn = compileJsonQuery(doc, compileOptions(options));
325
+ }
326
+ catch (err) { return fail(msg(err), code(err)); }
327
+ const t1 = now();
328
+ let out;
329
+ try {
330
+ const externals = fn.externals.includes('doc') ? { doc: d.value } : {};
331
+ out = fn(d.value, externals);
332
+ }
333
+ catch (err) { return fail(msg(err), code(err)); }
334
+ const t2 = now();
335
+ return ok(out === undefined ? '(empty sequence)' : fmt(out), t1 - t0, t2 - t1, [
336
+ // the machinery: the XQuery text parses to a runnable query DOCUMENT
337
+ deepCode('doc', 'The generated query document', fmt(doc)),
338
+ ]);
339
+ },
340
+ },
341
+ {
342
+ id: 'josl', label: 'JOSL', lead: 'The streaming TOML superset — JavaScript-obvious values, round-trips.',
343
+ sourcePanes: [{ key: 'text', label: 'Document', control: 'code' }],
344
+ dataPanes: [],
345
+ optionPanes: [{
346
+ key: 'mode', label: 'Dialect', default: 'josl',
347
+ choices: [{ value: 'josl', label: 'JOSL' }, { value: 'toml', label: 'TOML (strict 1.0)' }],
348
+ }],
349
+ run(source, data, options) {
350
+ const mode = options?.config?.mode ?? 'josl';
351
+ /** @type {any[]} */
352
+ const events = [];
353
+ let parsed; const t0 = now();
354
+ // parse (compile) and serialize (run) are the two visible phases; the
355
+ // parser streams document-order events as it reads — capture (capped)
356
+ // for the "how it streamed" drill-down
357
+ try { parsed = parseJosl(source.text ?? '', { mode, onEvent: (e) => { if (events.length < 200) events.push(e); } }); }
358
+ catch (err) { return fail(msg(err), code(err)); }
359
+ const t1 = now();
360
+ const out = stringifyJsonx(parsed, { indent: 2 });
361
+ const t2 = now();
362
+ return ok(out, t1 - t0, t2 - t1, [
363
+ {
364
+ id: 'events', label: `How it streamed (${events.length}${events.length === 200 ? ', capped' : ''})`,
365
+ kind: 'table', depth: 'deep', columns: ['Type', 'Path', 'Value'],
366
+ rows: events.map((e) => [e.type, `/${(e.path ?? []).join('/')}`, e.type === 'pair' ? stringifyJsonx(e.value) : '']),
367
+ },
368
+ deepCode('roundtrip', 'The canonical round-trip', stringifyJosl(parsed, { mode })),
369
+ ]);
370
+ },
371
+ },
372
+ {
373
+ id: 'csv', label: 'CSV', lead: 'RFC 4180 strict, or repair mode that reads damaged CSV and reports every fix.',
374
+ sourcePanes: [{ key: 'text', label: 'CSV', control: 'code' }],
375
+ dataPanes: [],
376
+ optionPanes: [
377
+ { key: 'repair', label: 'Mode', default: 'strict', choices: [{ value: 'strict', label: 'strict' }, { value: 'repair', label: 'repair' }] },
378
+ { key: 'headers', label: 'Header row', default: 'true', choices: [{ value: 'true', label: 'yes' }, { value: 'false', label: 'no' }, { value: 'auto', label: 'auto' }] },
379
+ { key: 'delimiter', label: 'Delimiter', default: 'auto', choices: [{ value: 'auto', label: 'auto' }, { value: ',', label: ',' }, { value: ';', label: ';' }, { value: 'tab', label: 'tab' }, { value: '|', label: '|' }] },
380
+ { key: 'typed', label: 'Typed values', default: 'off', choices: [{ value: 'off', label: 'off' }, { value: 'on', label: 'on' }] },
381
+ ],
382
+ run(source, data, options) {
383
+ const cfg = options?.config ?? {};
384
+ const opts = {
385
+ repair: cfg.repair === 'repair',
386
+ headers: cfg.headers === 'auto' ? 'auto' : cfg.headers !== 'false',
387
+ typed: cfg.typed === 'on',
388
+ delimiter: !cfg.delimiter || cfg.delimiter === 'auto' ? 'auto' : (cfg.delimiter === 'tab' ? '\t' : cfg.delimiter),
389
+ };
390
+ let doc; const t0 = now();
391
+ // strict mode THROWS on the first RFC 4180 violation (with a code);
392
+ // repair mode reads anyway and lists every fix — the lesson of the tab
393
+ try { doc = parseCsvDocument(source.text ?? '', opts); }
394
+ catch (err) { return fail(msg(err), code(err)); }
395
+ const t1 = now();
396
+ // one calm SCREEN (the summary note) plus the drill-down: the parsed
397
+ // records, the sniffed dialect, the repairs, and the CSV round-trip
398
+ // are `deep` — revealed only when the student asks how it was read
399
+ const fields = doc.fields; // string[] (headers) | null (positional)
400
+ const total = doc.rows.length;
401
+ const shown = doc.rows.slice(0, 50);
402
+ const width = shown.reduce((w, r) => Math.max(w, Array.isArray(r) ? r.length : fields ? fields.length : 0), 0);
403
+ const columns = fields ?? Array.from({ length: width }, (_, i) => String(i + 1));
404
+ const rows = shown.map((r) => columns.map((c, i) => cell(fields ? r[c] : r[i])));
405
+ const delim = doc.dialect.delimiter === '\t' ? 'tab' : doc.dialect.delimiter;
406
+ const plural = (n) => (n === 1 ? '' : 's');
407
+ const summary = [
408
+ `${delim}-delimited · ${doc.dialect.headers === false ? 'no header row' : 'header row'} · ${total} record${plural(total)}`
409
+ + (doc.repairs.length ? ` · ${doc.repairs.length} repair${plural(doc.repairs.length)}` : ' · clean')
410
+ + (total > shown.length ? ` · showing the first ${shown.length}` : ''),
411
+ ...doc.repairs.map((r) => `line ${r.line}: ${r.code} — ${r.message}`),
412
+ ].join('\n');
413
+ const roundtrip = stringifyCsv(doc.rows, { fields: fields ?? undefined, header: doc.dialect.headers !== false });
414
+ const sniff = sniffCsvDialect(source.text ?? '');
415
+ const t2 = now();
416
+ return okPanels([
417
+ { id: 'summary', label: 'Summary', kind: 'note', tone: doc.repairs.length ? 'warn' : 'ok', text: summary },
418
+ { id: 'rows', label: `Rows (${total})`, kind: 'table', depth: 'deep', columns, rows },
419
+ {
420
+ id: 'dialect', label: 'How it was read', kind: 'table', depth: 'deep',
421
+ columns: ['Property', 'Value'],
422
+ rows: [
423
+ ['delimiter', delim],
424
+ ['header row', String(doc.dialect.headers)],
425
+ ['sniffed', `${sniff.delimiter === '\t' ? 'tab' : sniff.delimiter} · ${sniff.width} columns · confidence ${sniff.confidence.toFixed(2)} · header ${sniff.headers}`],
426
+ ['records', String(total)],
427
+ ],
428
+ },
429
+ ...(doc.repairs.length ? [{
430
+ id: 'repairs', label: `The repairs (${doc.repairs.length})`, kind: /** @type {'table'} */ ('table'), depth: /** @type {'deep'} */ ('deep'),
431
+ columns: ['Code', 'Line', 'Col', 'What was read'],
432
+ rows: doc.repairs.map((r) => [r.code, String(r.line), String(r.column), r.message]),
433
+ }] : []),
434
+ { id: 'roundtrip', label: 'CSV round-trip', kind: 'code', depth: 'deep', text: roundtrip },
435
+ ], t1 - t0, t2 - t1);
436
+ },
437
+ },
438
+ {
439
+ id: 'validate', label: 'JSON Schema', lead: 'Compile a schema and validate data — every error, localized.',
440
+ sourcePanes: [{ key: 'schema', label: 'Schema', control: 'code' }],
441
+ dataPanes: [{ key: 'data', label: 'Data' }],
442
+ optionPanes: [{
443
+ key: 'locale', label: 'Messages', default: 'en',
444
+ // the @jarenjs/locales packs the host mounts; an unmounted code falls
445
+ // back to English in the host localizer, so the list is safe to declare
446
+ choices: [
447
+ { value: 'en', label: 'English' }, { value: 'nl', label: 'Nederlands' },
448
+ { value: 'fr', label: 'Français' }, { value: 'es', label: 'Español' },
449
+ { value: 'de', label: 'Deutsch' }, { value: 'pt', label: 'Português' },
450
+ { value: 'ja', label: '日本語' }, { value: 'ko', label: '한국어' },
451
+ { value: 'zh-tw', label: '繁體中文' }, { value: 'ru', label: 'Русский' },
452
+ { value: 'tr', label: 'Türkçe' }, { value: 'ar', label: 'العربية' },
453
+ ],
454
+ }],
455
+ run(source, data, options) {
456
+ // the validator + locale packs are heavy and already cached in the
457
+ // host — inject a `validate(schemaText, data, locale)` runner (the
458
+ // hybrid seam, like the visual engines' renderers). No runner → an
459
+ // honest Result, never a throw.
460
+ const validate = options?.validate;
461
+ if (typeof validate !== 'function') {
462
+ return fail('the JSON Schema engine validates in the host — inject options.validate', 'PLAY_NO_VALIDATOR');
463
+ }
464
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
465
+ const locale = options?.config?.locale ?? 'en';
466
+ let report;
467
+ try { report = validate(source.schema ?? '', d.value, locale); }
468
+ catch (err) { return fail(msg(err), code(err)); }
469
+ if (report.schemaError) return fail(report.schemaError, 'SCHEMA');
470
+ const errs = report.errors ?? [];
471
+ const summary = (report.valid ? '✓ valid' : `✗ ${errs.length} error${errs.length === 1 ? '' : 's'}`)
472
+ + ` · ${report.draft} · compiled ${formatMs(report.compileMs)} · validated ${formatMs(report.validateMs)}`;
473
+ const panels = [{ id: 'verdict', label: 'Verdict', kind: 'note', tone: report.valid ? 'ok' : 'warn', text: summary }];
474
+ if (errs.length) {
475
+ panels.push({
476
+ id: 'errors', label: `Errors (${errs.length})`, kind: 'table', depth: 'deep',
477
+ columns: ['path', 'message'],
478
+ rows: errs.map((e) => [e.instancePath === '' ? '(root)' : e.instancePath, e.message]),
479
+ });
480
+ }
481
+ return okPanels(panels, report.compileMs ?? null, report.validateMs ?? null);
482
+ },
483
+ },
484
+ {
485
+ // markdown × data: the mdx pass resolves `{$…}` interpolation and the
486
+ // `{#if}` / `{#each}` sections against the DATA pane, then renders —
487
+ // still a pure (source, data) → document engine, delegated to the
488
+ // host's mdx renderer (which owns @jarenjs/md/mdx + the query compiler)
489
+ id: 'mdx', label: 'MDX', lead: 'Markdown × data — {$…} interpolation, {#if} and {#each} sections, rendered live.',
490
+ sourcePanes: [{ key: 'source', label: 'Markdown', control: 'code' }],
491
+ dataPanes: [{ key: 'data', label: 'Data' }],
492
+ run(source, data, options) {
493
+ const render = options?.renderers?.mdx;
494
+ if (typeof render !== 'function') {
495
+ return fail('the MDX engine renders in the host — inject options.renderers.mdx', 'PLAY_NO_RENDERER');
496
+ }
497
+ const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
498
+ const t0 = now();
499
+ let view;
500
+ try { view = render(source.source ?? '', d.value); }
501
+ catch (err) { return fail(msg(err), code(err)); }
502
+ const t1 = now();
503
+ return okPanels(renderedPanels(view), ...renderTiming(view, t1 - t0));
504
+ },
505
+ },
506
+ // ——— the visual engines: descriptor + examples here, rendering delegated ———
507
+ visual('markdown', 'Markdown', 'CommonMark + GFM + frontmatter → a JSON AST, rendered live.', { sourceLabel: 'Markdown' }),
508
+ visual('mermaid', 'Mermaid', 'Diagrams-as-code → a geometry-free AST → pure-vnode SVG.', { sourceLabel: 'Mermaid' }),
509
+ visual('charts', 'Charts', 'A JSON / JSONX / JOSL chart definition → schema-validated → pure-vnode SVG.', {
510
+ sourceLabel: 'Chart definition',
511
+ optionPanes: [{
512
+ key: 'format', label: 'Format', default: 'json',
513
+ choices: [{ value: 'json', label: 'JSON' }, { value: 'jsonx', label: 'JSONX' }, { value: 'josl', label: 'JOSL' }],
514
+ }],
515
+ }),
516
+ ];