@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/README.md +55 -0
- package/dist/types/component/index.d.ts +80 -0
- package/dist/types/component/view.d.ts +49 -0
- package/dist/types/component/viewmodel.d.ts +7 -0
- package/dist/types/engines.d.ts +10 -0
- package/dist/types/examples.d.ts +9 -0
- package/dist/types/format.d.ts +15 -0
- package/dist/types/index.d.ts +272 -0
- package/docs/PLAY-FORMAT.md +156 -0
- package/package.json +64 -0
- package/src/component/index.js +53 -0
- package/src/component/view.js +318 -0
- package/src/component/viewmodel.js +181 -0
- package/src/engines.js +516 -0
- package/src/examples.js +546 -0
- package/src/format.js +20 -0
- package/src/index.js +157 -0
- package/styles/play.css +176 -0
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
|
+
];
|