@jarenjs/play 0.34.0 → 0.43.1

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 CHANGED
@@ -3,8 +3,8 @@
3
3
  **A JSON-engine playground — understand an engine before you compose it.**
4
4
 
5
5
  Pick an engine (JSONPath, JSON Pointer, JSON Patch, `$query`, JSLT,
6
- markdown, mermaid, …), feed it a source input and one or more datasets from
7
- a curated example library, and watch it run. Where `@jarenjs/studio` is for
6
+ markdown, mermaid, a `$contract` document, …), feed it a source input and
7
+ one or more datasets from a curated example library, and watch it run. Where `@jarenjs/studio` is for
8
8
  building an application out of many files, `@jarenjs/play` is for
9
9
  learning one engine standalone — a reference bench you experiment on first.
10
10
 
@@ -23,7 +23,13 @@ Play is a **student tool**: it opens calm — one clean result per run — and
23
23
  drills deeper on demand. An engine's rich explainers (match cards, the
24
24
  compiled program, a geometry-free AST, a canonical round-trip) are `deep`
25
25
  result panels behind a quiet **"Explain ▸"** depth toggle: revealed beside
26
- the answer on desktop, as a full-pane swap with a ← back on a phone.
26
+ the answer on desktop, as a full-pane swap with a ← back on a phone. And
27
+ when a run fails, the error says **where**: the editor it is about is
28
+ marked invalid and its label carries the compiler's own location — the
29
+ rule of the stylesheet (`at /rules/0/match`), the token of the selector
30
+ (`at position 7`), the line of the document (`at line 2, column 5`) — so
31
+ the learner is pointed at the text to fix, not just told it is wrong
32
+ ([PLAY-FORMAT §2](./docs/PLAY-FORMAT.md#2-the-result)).
27
33
 
28
34
  ## The model
29
35
 
@@ -72,7 +72,7 @@ export declare function createPlayComponent(options?: {
72
72
  engines: Readonly<Record<string, import("../index.js").EngineDescriptor>>;
73
73
  examples: readonly import("../index.js").PlayExample[];
74
74
  engineIds: typeof engineIds;
75
- runExample: (engineId: any, source: any, data: any, perCall?: {}) => import("../index.js").PlayResult;
75
+ runExample: (engineId: any, source: any, data: any, perCall?: {}) => import("../index.js").PlayResult | Promise<import("../index.js").PlayResult>;
76
76
  operators: {
77
77
  toOptions: () => any;
78
78
  } | undefined;
@@ -77,17 +77,58 @@ export type Panel = {
77
77
  note?: string;
78
78
  }>;
79
79
  };
80
+ export type PlayError = {
81
+ /**
82
+ * - the compiler's message, verbatim (a coded
83
+ * error composes `code: reason at path` itself)
84
+ */
85
+ message: string;
86
+ /**
87
+ * - the stable diagnosis code, when there is one
88
+ */
89
+ code?: string;
90
+ /**
91
+ * - the pane KEY (source or data) the error is
92
+ * about: the source pane for a compile or run failure, the data pane
93
+ * whose JSON did not parse
94
+ */
95
+ pane?: string;
96
+ /**
97
+ * - JSON Pointer into that pane's DOCUMENT (a
98
+ * coded error's `docPath`: the failing patch operation, the query
99
+ * construct, the stylesheet rule)
100
+ */
101
+ path?: string;
102
+ /**
103
+ * - JSON Pointer into the DATA the document
104
+ * was applied to, when a runtime error blames both (a patch operation
105
+ * AND the target location it failed at)
106
+ */
107
+ dataPath?: string;
108
+ /**
109
+ * - 0-based offset into that pane's TEXT
110
+ * (the syntax family: a JSONPath selector, a JSON Pointer, an XQuery)
111
+ */
112
+ position?: number;
113
+ /**
114
+ * - 1-based line (the JOSL / CSV family)
115
+ */
116
+ line?: number;
117
+ /**
118
+ * - 1-based column (with `line`)
119
+ */
120
+ column?: number;
121
+ };
80
122
  export type PlayResult = {
81
123
  ok: boolean;
82
124
  timing: {
83
125
  compileMs: number;
84
126
  runMs: number;
85
127
  } | null;
86
- error: {
87
- message: string;
88
- code?: string;
89
- path?: string;
90
- } | null;
128
+ /**
129
+ * - null on an ok run
130
+ */
131
+ error: PlayError | null;
91
132
  /**
92
133
  * - the result screens (`[]` on error); a single
93
134
  * `code` panel for most engines, several for the richer ones
@@ -113,7 +154,13 @@ export type EngineDescriptor = {
113
154
  * - live mode selects (may be absent)
114
155
  */
115
156
  optionPanes?: OptionPane[];
116
- run: (source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult;
157
+ /**
158
+ * most engines answer synchronously; an engine whose run resolves real
159
+ * promises (the contract engine's dispatch panel) answers a thenable
160
+ * Result, which `runExample` passes through with the same never-throw
161
+ * contract (a rejection settles into an error Result)
162
+ */
163
+ run: (source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>;
117
164
  };
118
165
  export type RunOptions = {
119
166
  /**
@@ -206,11 +253,37 @@ export type PlayExample = {
206
253
  * @property {Array<{ title: string, value: string, note?: string }>} [items]
207
254
  * `cards`: a row of stat cards (matches, compile/run timings, …)
208
255
  */
256
+ /**
257
+ * The error half of a Result: what the compiler said, plus WHERE — in the
258
+ * format's own fields, so the view can point at the pane and the location
259
+ * rather than only quoting the message. Every location field is present
260
+ * exactly when the compiler stated it (a field is never fabricated), and
261
+ * `pane` names the editor the location points into; a location into a
262
+ * document the learner never typed (XQuery's generated query document) has
263
+ * no pane. See PLAY-FORMAT §2.
264
+ * @typedef {Object} PlayError
265
+ * @property {string} message - the compiler's message, verbatim (a coded
266
+ * error composes `code: reason at path` itself)
267
+ * @property {string} [code] - the stable diagnosis code, when there is one
268
+ * @property {string} [pane] - the pane KEY (source or data) the error is
269
+ * about: the source pane for a compile or run failure, the data pane
270
+ * whose JSON did not parse
271
+ * @property {string} [path] - JSON Pointer into that pane's DOCUMENT (a
272
+ * coded error's `docPath`: the failing patch operation, the query
273
+ * construct, the stylesheet rule)
274
+ * @property {string} [dataPath] - JSON Pointer into the DATA the document
275
+ * was applied to, when a runtime error blames both (a patch operation
276
+ * AND the target location it failed at)
277
+ * @property {number} [position] - 0-based offset into that pane's TEXT
278
+ * (the syntax family: a JSONPath selector, a JSON Pointer, an XQuery)
279
+ * @property {number} [line] - 1-based line (the JOSL / CSV family)
280
+ * @property {number} [column] - 1-based column (with `line`)
281
+ */
209
282
  /**
210
283
  * @typedef {Object} PlayResult
211
284
  * @property {boolean} ok
212
285
  * @property {{ compileMs: number, runMs: number } | null} timing
213
- * @property {{ message: string, code?: string, path?: string } | null} error
286
+ * @property {PlayError | null} error - null on an ok run
214
287
  * @property {Panel[]} panels - the result screens (`[]` on error); a single
215
288
  * `code` panel for most engines, several for the richer ones
216
289
  */
@@ -222,7 +295,11 @@ export type PlayExample = {
222
295
  * @property {EnginePane[]} sourcePanes - the engine INPUT pane(s)
223
296
  * @property {EnginePane[]} dataPanes - the JSON it runs against (may be [])
224
297
  * @property {OptionPane[]} [optionPanes] - live mode selects (may be absent)
225
- * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult} run
298
+ * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>} run
299
+ * most engines answer synchronously; an engine whose run resolves real
300
+ * promises (the contract engine's dispatch panel) answers a thenable
301
+ * Result, which `runExample` passes through with the same never-throw
302
+ * contract (a rejection settles into an error Result)
226
303
  */
227
304
  /**
228
305
  * @typedef {Object} RunOptions
@@ -261,12 +338,15 @@ export declare function engineIds(): string[];
261
338
  export declare const EXAMPLES: readonly PlayExample[];
262
339
  /**
263
340
  * Run one engine over a source + data. An unknown engine (or a throwing
264
- * runner) yields an error Result — this never throws.
341
+ * runner) yields an error Result — this never throws. A synchronous
342
+ * engine answers a Result; an async engine (the contract dispatch)
343
+ * answers a Promise of one that never rejects — a host that must know
344
+ * which awaits `Promise.resolve(runExample(…))`.
265
345
  * @param {string} engineId
266
346
  * @param {Record<string, string>} source
267
347
  * @param {Record<string, string>} data
268
348
  * @param {RunOptions} [options] - operators (query/jslt), the option-pane
269
349
  * config, and host renderers (markdown/mermaid/charts)
270
- * @returns {PlayResult}
350
+ * @returns {PlayResult | Promise<PlayResult>}
271
351
  */
272
- export declare function runExample(engineId: string, source: Record<string, string>, data: Record<string, string>, options?: RunOptions): PlayResult;
352
+ export declare function runExample(engineId: string, source: Record<string, string>, data: Record<string, string>, options?: RunOptions): PlayResult | Promise<PlayResult>;
@@ -13,7 +13,7 @@ EngineDescriptor = {
13
13
  lead?: string, // one line describing the engine
14
14
  sourcePanes: EnginePane[], // the engine INPUT (usually one)
15
15
  dataPanes: EnginePane[], // the JSON it runs against (may be empty)
16
- run: (source, data) => PlayResult,
16
+ run: (source, data) => PlayResult | Promise<PlayResult>,
17
17
  }
18
18
  EnginePane = { key: string, label: string, control?: 'code' | 'text' }
19
19
  ```
@@ -27,6 +27,13 @@ EnginePane = { key: string, label: string, control?: 'code' | 'text' }
27
27
  - `run(source, data)` is PURE and NEVER throws: it wraps the real shipped
28
28
  compiler and returns a `PlayResult`. `source` / `data` are maps keyed
29
29
  by the pane `key`s, holding the raw text.
30
+ - Most engines answer synchronously. An engine whose run resolves real
31
+ promises (the contract engine's dispatch panel invokes a local client)
32
+ answers a **thenable** `PlayResult`; `runExample` passes it through
33
+ under the same never-throw contract — a rejection settles into an
34
+ error Result, so `await Promise.resolve(runExample(…))` is total for
35
+ every engine. A host that renders live must drop a settled result an
36
+ even newer run superseded (the website keys runs by a sequence).
30
37
 
31
38
  ## §2 The result
32
39
 
@@ -38,10 +45,25 @@ PlayResult = {
38
45
  // and the stage omits that clause rather than printing `0 ms` — which
39
46
  // read as "it was free". Only measured phases are ever shown.
40
47
  timing: { compileMs: number | null, runMs: number | null } | null,
41
- error: { message: string, code?, path? } | null,
48
+ error: PlayError | null,
42
49
  panels: Panel[], // the result SCREENS ([] on error)
43
50
  }
44
51
 
52
+ PlayError = {
53
+ message: string, // what the compiler said, verbatim
54
+ code?: string, // its stable diagnosis code, when it has one
55
+ // WHERE — the location half, in the format's own fields. A field is
56
+ // present exactly when the compiler stated it; nothing is fabricated.
57
+ pane?: string, // the pane KEY the error is about: the source pane
58
+ // for a compile or run failure, the data pane
59
+ // whose JSON did not parse
60
+ path?: string, // JSON Pointer into that pane's DOCUMENT
61
+ dataPath?: string, // JSON Pointer into the DATA the document ran over
62
+ position?: number, // 0-based offset into that pane's TEXT
63
+ line?: number, // 1-based line …
64
+ column?: number, // … and column
65
+ }
66
+
45
67
  Panel = {
46
68
  id: string, // unique in the result (the tab key)
47
69
  label?: string, // the tab label (defaults to id)
@@ -56,6 +78,36 @@ Panel = {
56
78
  }
57
79
  ```
58
80
 
81
+ An error says **where**, because for a learner the location is the lesson:
82
+ which token of the selector, which operation of the patch, which rule of
83
+ the stylesheet. The compilers already carry it, in three shapes, and the
84
+ error copies whichever its compiler stated: the **coded family** (patch,
85
+ `$query`, JSLT, JTLT — `@jarenjs/core`'s `CodedError`) states `path`, a
86
+ JSON Pointer into the document being compiled or run (`/rules/0/match`,
87
+ `/$idiv`, `/0/path`), and a patch *runtime* error adds `dataPath`, the
88
+ target location its operation failed at — two facts, both kept; the
89
+ **syntax family** (JSONPath, JSON Pointer, XQuery) states `position`, a
90
+ 0-based offset into the source text; the **line/column family** (JOSL,
91
+ CSV) states 1-based `line` and `column`. `pane` names the editor the
92
+ location points into, and it is the whole location for a pane whose JSON
93
+ does not parse: the host's `JSON.parse` states its offset only inside
94
+ engine-specific message text, never as a field, so the pane is claimed and
95
+ nothing finer. Two honest gaps: a missing host seam (`PLAY_NO_RENDERER`,
96
+ `PLAY_NO_VALIDATOR`) is nobody's pane, and XQuery compiles a *generated*
97
+ query document, so a location after its parse phase is a pointer into a
98
+ document the learner never typed — stated, with no pane. `message` stays
99
+ verbatim (a coded error composes `code: reason at path` itself), so a
100
+ host with no structured reader still gets everything.
101
+
102
+ The component lands it in two places: the editor the error is about is
103
+ marked `aria-invalid` (the state the renderer owns; the ring is styled off
104
+ it) and its label carries the phrase — `at /rules/0/match`, `at position
105
+ 7`, `at line 2, column 5` — while the stage's error line shows the code
106
+ chip, the message with its own code prefix dropped rather than read twice,
107
+ and the pane's label. A data pane a runtime error merely failed *at* is
108
+ not marked invalid (the op was wrong, not the target) but shows the
109
+ `dataPath` phrase, because that is where the reader looks next.
110
+
59
111
  Every ok run yields **at least one** panel — most engines a single `code`
60
112
  panel. The `simple` panels are the calm default: the component shows one
61
113
  inline, or — for more than one — a tab strip above the active panel body.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/play",
3
3
  "private": false,
4
- "version": "0.34.0",
4
+ "version": "0.43.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -56,9 +56,10 @@
56
56
  "prepack": "npm run build:types"
57
57
  },
58
58
  "dependencies": {
59
- "@jarenjs/core": "^0.34.0",
60
- "@jarenjs/josl": "^0.34.0",
61
- "@jarenjs/json": "^0.34.0",
62
- "@jarenjs/validate": "^0.34.0"
59
+ "@jarenjs/contract": "^0.43.1",
60
+ "@jarenjs/core": "^0.43.1",
61
+ "@jarenjs/josl": "^0.43.1",
62
+ "@jarenjs/json": "^0.43.1",
63
+ "@jarenjs/validate": "^0.43.1"
63
64
  }
64
65
  }
@@ -172,7 +172,15 @@ const shell = {
172
172
  ], ''] },
173
173
  ], ''] },
174
174
  ],
175
- ['p', { class: 'error-line' }, ['strong', {}, '$.result.error.code'], ' ', '$.result.error.message']] },
175
+ // the error line: the code chip (only when there is one), the message
176
+ // (its own code prefix dropped), and the editor the error is about —
177
+ // the pane itself is flagged and carries the location, see
178
+ // sourcePane / dataPane
179
+ ['p', { class: 'error-line' },
180
+ { $if: ['$.result.error.code', [['strong', {}, '$.result.error.code'], ' '], ''] },
181
+ '$.result.error.text',
182
+ { $if: ['$.result.error.paneLabel',
183
+ ['span', { class: 'jplay-error-pane' }, ' — ', '$.result.error.paneLabel'], ''] }]] },
176
184
  ['p', { class: 'muted jplay-hint' }, 'Pick an example, or edit the source or data — it runs live.']] },
177
185
  ],
178
186
  ],
@@ -197,15 +205,24 @@ const railExample = {
197
205
  }, '$.label'],
198
206
  };
199
207
 
208
+ /** A pane label: the name, plus the last error's location in this pane
209
+ * (`at /rules/3`, `at position 7`, `at line 2, column 5`) when it has one. */
210
+ const paneLabel = ['span', { class: 'jplay-pane-label muted' }, '$.label',
211
+ { $if: ['$.where', ['span', { class: 'jplay-pane-where' }, ' · ', '$.where'], ''] }];
212
+
213
+ /** The editor the last error is about says so — `aria-invalid` is the
214
+ * state the renderer owns (the ring is styled off it), absent otherwise. */
215
+ const invalid = { $if: ['$.invalid', 'true'] };
216
+
200
217
  /** One source editor (a `text` control renders an input, else a textarea). */
201
218
  const sourcePane = {
202
219
  match: `${PLAY_BASE}.sourcePanes[*]`, mode: PLAY_MODE,
203
220
  body: ['label', { class: 'jplay-pane' },
204
- ['span', { class: 'jplay-pane-label muted' }, '$.label'],
221
+ paneLabel,
205
222
  { $if: [{ $eq: ['$.control', 'text'] },
206
- ['input', { type: 'text', class: 'editor line', spellcheck: 'false', value: '$.value',
223
+ ['input', { type: 'text', class: 'editor line', spellcheck: 'false', value: '$.value', 'aria-invalid': invalid,
207
224
  on: { input: { action: 'play/source', with: { key: '$.key' } } } }],
208
- ['textarea', { class: 'editor', rows: 6, spellcheck: 'false', value: '$.value',
225
+ ['textarea', { class: 'editor', rows: 6, spellcheck: 'false', value: '$.value', 'aria-invalid': invalid,
209
226
  on: { input: { action: 'play/source', with: { key: '$.key' } } } }]] },
210
227
  ],
211
228
  };
@@ -214,8 +231,8 @@ const sourcePane = {
214
231
  const dataPane = {
215
232
  match: `${PLAY_BASE}.dataPanes[*]`, mode: PLAY_MODE,
216
233
  body: ['label', { class: 'jplay-pane' },
217
- ['span', { class: 'jplay-pane-label muted' }, '$.label'],
218
- ['textarea', { class: 'editor', rows: 8, spellcheck: 'false', value: '$.value',
234
+ paneLabel,
235
+ ['textarea', { class: 'editor', rows: 8, spellcheck: 'false', value: '$.value', 'aria-invalid': invalid,
219
236
  on: { input: { action: 'play/data', with: { key: '$.key' } } } }],
220
237
  ],
221
238
  };
@@ -27,6 +27,59 @@ function formatTiming(timing) {
27
27
  return parts.length === 0 ? null : parts.join(' · ');
28
28
  }
29
29
 
30
+ /**
31
+ * The location an error states, as the phrase a pane label carries — one
32
+ * per family, in the wording the compilers' own messages use: a JSON
33
+ * Pointer (`at /rules/3`; the root pointer `''` reads "at the root"), a
34
+ * 0-based source offset (`at position 7`), or a 1-based line and column
35
+ * (`at line 2, column 5`). Empty when the error states no location.
36
+ */
37
+ function formatWhere(err) {
38
+ if (!err) return '';
39
+ const parts = [];
40
+ if (typeof err.path === 'string') parts.push(`at ${err.path === '' ? 'the root' : err.path}`);
41
+ if (typeof err.position === 'number') parts.push(`at position ${err.position}`);
42
+ if (typeof err.line === 'number') {
43
+ parts.push(`at line ${err.line}${typeof err.column === 'number' ? `, column ${err.column}` : ''}`);
44
+ }
45
+ return parts.join(', ');
46
+ }
47
+
48
+ /**
49
+ * The error's claim on one editor pane. The pane the error names is
50
+ * INVALID (its text is what failed — the compile, the run, or a JSON parse)
51
+ * and carries the location phrase; a data pane is not invalid when a
52
+ * runtime error merely failed AT a location in it (a patch op removing a
53
+ * path the target lacks blames the op, not the target), yet that location
54
+ * is the lesson, so the data pane carries it as `where` without the flag.
55
+ * @returns {{ invalid: boolean, where: string }}
56
+ */
57
+ function paneError(err, key, isData) {
58
+ if (!err) return { invalid: false, where: '' };
59
+ if (err.pane === key) return { invalid: true, where: formatWhere(err) };
60
+ if (isData && typeof err.dataPath === 'string') {
61
+ return { invalid: false, where: `at ${err.dataPath === '' ? 'the root' : err.dataPath}` };
62
+ }
63
+ return { invalid: false, where: '' };
64
+ }
65
+
66
+ /**
67
+ * The stage's error line. `code` is shown as its own chip, so a message
68
+ * that begins with that same code (the coded family composes
69
+ * `code: reason at path`) drops the prefix rather than reading it twice;
70
+ * `paneLabel` names the editor the error is about (the pane's LABEL, for
71
+ * a reader), empty when no pane owns it.
72
+ */
73
+ function deriveError(err, panes) {
74
+ if (!err) return null;
75
+ const code = err.code ?? '';
76
+ const message = err.message ?? '';
77
+ const prefix = `${code}: `;
78
+ const text = code !== '' && message.startsWith(prefix) ? message.slice(prefix.length) : message;
79
+ const pane = typeof err.pane === 'string' ? panes.find((p) => p.key === err.pane) : undefined;
80
+ return { code, message, text, paneLabel: pane?.label ?? '' };
81
+ }
82
+
30
83
  /**
31
84
  * Shape one panel for the view: kind flags for the `$if` dispatch plus the
32
85
  * per-kind content (a `table` becomes column/cell records the JSLT can walk).
@@ -62,7 +115,7 @@ function shapePanel(p) {
62
115
  * else the first — so a fresh result with different screens never strands
63
116
  * the view on a tab that is gone.
64
117
  */
65
- function deriveResult(r, wantedId, deepWanted, deepPick) {
118
+ function deriveResult(r, wantedId, deepWanted, deepPick, panes) {
66
119
  const all = Array.isArray(r.panels) ? r.panels : [];
67
120
  const panels = all.filter((p) => p.depth !== 'deep');
68
121
  const deep = all.filter((p) => p.depth === 'deep');
@@ -75,7 +128,7 @@ function deriveResult(r, wantedId, deepWanted, deepPick) {
75
128
  return {
76
129
  ran: true,
77
130
  ok: r.ok === true,
78
- error: r.error ? { code: r.error.code ?? '', message: r.error.message ?? '' } : null,
131
+ error: deriveError(r.error, panes),
79
132
  timing: formatTiming(r.timing),
80
133
  tabbed: panels.length > 1,
81
134
  tabs: panels.map((p) => ({ id: p.id, label: p.label ?? p.id, active: p.id === activeId })),
@@ -113,11 +166,16 @@ export function playViewModel(state) {
113
166
  examples: exs.map((ex) => ({ id: ex.id, label: ex.label, active: ex.id === s.exampleId })),
114
167
  }));
115
168
 
169
+ // the editors, each carrying the last error's claim on it (an invalid
170
+ // flag + a location phrase) so the pane the learner has to fix says so
171
+ const err = s.result?.error ?? null;
116
172
  const sourcePanes = (engine?.sourcePanes ?? []).map((p) => ({
117
173
  key: p.key, label: p.label, control: p.control ?? 'code', value: s.source?.[p.key] ?? '',
174
+ ...paneError(err, p.key, false),
118
175
  }));
119
176
  const dataPanes = (engine?.dataPanes ?? []).map((p) => ({
120
177
  key: p.key, label: p.label, value: s.data?.[p.key] ?? '',
178
+ ...paneError(err, p.key, true),
121
179
  }));
122
180
 
123
181
  // option panes: live mode selects (josl dialect, csv repair/headers/…);
@@ -133,7 +191,7 @@ export function playViewModel(state) {
133
191
  const datasets = (active?.datasets ?? []).map((ds, i) => ({ index: i, label: ds.label, active: i === datasetIndex }));
134
192
 
135
193
  const r = s.result ?? null;
136
- const result = r === null ? { ran: false } : deriveResult(r, s.panel, s.deep, s.deepPick);
194
+ const result = r === null ? { ran: false } : deriveResult(r, s.panel, s.deep, s.deepPick, [...sourcePanes, ...dataPanes]);
137
195
 
138
196
  // the IDE half: the saveable-session chrome
139
197
  const names = Array.isArray(s.names) ? s.names : [];
package/src/engines.js CHANGED
@@ -18,6 +18,9 @@ import { parseXQuery } from '@jarenjs/json/xquery';
18
18
  import { parseJosl, stringifyJosl, stringifyJsonx } from '@jarenjs/josl';
19
19
  import { parseCsvDocument, stringifyCsv, sniffCsvDialect } from '@jarenjs/josl/csv';
20
20
  import { createTypeTestCompiler } from '@jarenjs/validate/query';
21
+ import { compileContract } from '@jarenjs/contract';
22
+ import { openLocalClient } from '@jarenjs/contract/local';
23
+ import { toOpenApi, toTypeScript } from '@jarenjs/contract/project';
21
24
  import { formatMs } from './format.js';
22
25
 
23
26
  const compileTypeTest = createTypeTestCompiler();
@@ -27,10 +30,18 @@ const fmt = (v) => (v === undefined ? '(no result)' : JSON.stringify(v, null, 2)
27
30
  const msg = (err) => String(/** @type {any} */ (err)?.message ?? err);
28
31
  const code = (err) => /** @type {any} */ (err)?.code;
29
32
 
30
- /** Parse a pane's JSON text; `{ value }` or `{ error }`. */
31
- function parseJson(text, label) {
33
+ /**
34
+ * Parse a pane's JSON text; `{ value }` or `{ error, pane }`. `pane` is the
35
+ * pane KEY (what the result's error location names); `label` is the word
36
+ * the message uses for it, which differs where the pane's label does (the
37
+ * patch engine's `data` pane is its "target"). The host's `JSON.parse`
38
+ * states the offending offset only inside its engine-specific message
39
+ * text, never as a field — so a parse failure locates the PANE and
40
+ * nothing finer.
41
+ */
42
+ function parseJson(text, pane, label = pane) {
32
43
  try { return { value: JSON.parse((text ?? 'null') === '' ? 'null' : text) }; }
33
- catch (err) { return { error: `${label}: ${msg(err)}` }; }
44
+ catch (err) { return { error: `${label}: ${msg(err)}`, pane }; }
34
45
  }
35
46
 
36
47
  /** A single-`code`-panel Result — the shape most engines return. */
@@ -85,7 +96,7 @@ function visual(id, label, lead, opts = {}) {
85
96
  const t0 = now();
86
97
  let view;
87
98
  try { view = render(source.source ?? '', options?.config); }
88
- catch (err) { return fail(msg(err), code(err)); }
99
+ catch (err) { return fail(msg(err), code(err), locate(err, 'source')); }
89
100
  const t1 = now();
90
101
  return okPanels(renderedPanels(view), ...renderTiming(view, t1 - t0));
91
102
  },
@@ -118,8 +129,50 @@ function renderedPanels(view) {
118
129
  : [];
119
130
  return [{ id: 'preview', label: 'Preview', kind: 'view', vnode }, ...deep];
120
131
  }
121
- /** @returns {import('./index.js').PlayResult} */
122
- const fail = (message, c, path) => ({ ok: false, timing: null, error: { message, code: c, path }, panels: [] });
132
+ /**
133
+ * An error Result. `where` is the location half of the format's error
134
+ * `pane` plus whichever of `path`/`dataPath`/`position`/`line`/`column`
135
+ * the compiler stated (see {@link locate}); only stated fields land on
136
+ * the error, so a field's presence means the compiler said it.
137
+ * @returns {import('./index.js').PlayResult}
138
+ */
139
+ const fail = (message, c, where) => ({
140
+ ok: false, timing: null,
141
+ error: { message, ...(c === undefined ? {} : { code: c }), ...where },
142
+ panels: [],
143
+ });
144
+
145
+ /** The error Result of a pane whose JSON did not parse — locates the pane. */
146
+ const failParse = (d) => fail(d.error, undefined, { pane: d.pane });
147
+
148
+ /**
149
+ * The location an engine error carries, in the format's own fields — one
150
+ * adapter over the three shapes the compilers use. The coded family
151
+ * (`@jarenjs/core` `CodedError`: patch, query, JSLT, JTLT) states a
152
+ * `docPath`, a JSON Pointer into the document being compiled or run, and
153
+ * a patch runtime error adds the `dataPath` its operation failed at in the
154
+ * target; the syntax family (JSONPath, JSON Pointer, XQuery) states a
155
+ * 0-based `position` into the source text; the line/column family (JOSL,
156
+ * CSV) states 1-based `line`/`column`. Only what the error carries is
157
+ * copied. `pane` names the editor the phase was working on — the source
158
+ * pane for compile AND run (a runtime location points into the compiled
159
+ * document, which is that pane's text) — and is omitted where the
160
+ * location is into a document the learner never typed (XQuery compiles a
161
+ * GENERATED query document, and a location in it has no pane).
162
+ * @param {unknown} err
163
+ * @param {string} [pane] the pane key the location points into
164
+ */
165
+ function locate(err, pane) {
166
+ const e = /** @type {any} */ (err);
167
+ /** @type {{ pane?: string, path?: string, dataPath?: string, position?: number, line?: number, column?: number }} */
168
+ const where = pane === undefined ? {} : { pane };
169
+ if (typeof e?.docPath === 'string') where.path = e.docPath;
170
+ if (typeof e?.dataPath === 'string') where.dataPath = e.dataPath;
171
+ if (typeof e?.position === 'number') where.position = e.position;
172
+ if (typeof e?.line === 'number') where.line = e.line;
173
+ if (typeof e?.column === 'number') where.column = e.column;
174
+ return where;
175
+ }
123
176
 
124
177
  /** The compile options the query/jslt engines run with (registry-aware). */
125
178
  function compileOptions(options) {
@@ -135,14 +188,14 @@ export const ENGINE_LIST = [
135
188
  sourcePanes: [{ key: 'selector', label: 'Selector', control: 'text' }],
136
189
  dataPanes: [{ key: 'data', label: 'Data' }],
137
190
  run(source, data) {
138
- const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
191
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
139
192
  let compiled; const t0 = now();
140
193
  try { compiled = compileJSONPath(source.selector ?? ''); }
141
- catch (err) { return fail(msg(err), code(err)); }
194
+ catch (err) { return fail(msg(err), code(err), locate(err, 'selector')); }
142
195
  const t1 = now();
143
196
  let nodes;
144
197
  try { nodes = compiled.nodes(d.value); }
145
- catch (err) { return fail(msg(err), code(err)); }
198
+ catch (err) { return fail(msg(err), code(err), locate(err, 'selector')); }
146
199
  const t2 = now();
147
200
  return ok(fmt(nodes.map((n) => n.value)), t1 - t0, t2 - t1, [
148
201
  deepCards('how', 'How it matched', [
@@ -164,16 +217,16 @@ export const ENGINE_LIST = [
164
217
  ],
165
218
  dataPanes: [{ key: 'data', label: 'Data' }],
166
219
  run(source, data) {
167
- const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
220
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
168
221
  const pointer = source.pointer ?? '';
169
222
  const relative = /^\d/.test(pointer);
170
223
  let getter; const t0 = now();
171
224
  try { getter = relative ? compileRelativeJSONPointer(pointer) : compileJSONPointer(pointer); }
172
- catch (err) { return fail(msg(err), code(err)); }
225
+ catch (err) { return fail(msg(err), code(err), locate(err, 'pointer')); }
173
226
  const t1 = now();
174
227
  let value;
175
228
  try { value = relative ? getter(d.value, source.location ?? '') : getter(d.value); }
176
- catch (err) { return fail(msg(err), code(err)); }
229
+ catch (err) { return fail(msg(err), code(err), locate(err, 'pointer')); }
177
230
  const t2 = now();
178
231
  if (value === JSONPOINTER_NOTHING) return ok('(nothing — the pointer addresses no value)', t1 - t0, t2 - t1);
179
232
  return ok(fmt(value), t1 - t0, t2 - t1);
@@ -193,13 +246,13 @@ export const ENGINE_LIST = [
193
246
  }],
194
247
  run(source, data, options) {
195
248
  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);
249
+ const target = parseJson(data.data, 'data', 'target'); if (target.error) return failParse(target);
250
+ const patch = parseJson(source.patch, 'patch'); if (patch.error) return failParse(patch);
198
251
  if (mode === 'merge') {
199
252
  // RFC 7396: null members delete; an unchanged document is the INPUT
200
253
  let out; const t0 = now();
201
254
  try { out = applyMergePatch(target.value, patch.value); }
202
- catch (err) { return fail(msg(err), code(err)); }
255
+ catch (err) { return fail(msg(err), code(err), locate(err, 'patch')); }
203
256
  const t1 = now();
204
257
  return ok(fmt(out), null, t1 - t0, [
205
258
  deepCards('how', 'How it merged', [
@@ -215,7 +268,7 @@ export const ENGINE_LIST = [
215
268
  jsonPatch = createJSONPatch(target.value, patch.value);
216
269
  mergePatch = createMergePatch(target.value, patch.value);
217
270
  }
218
- catch (err) { return fail(msg(err), code(err)); }
271
+ catch (err) { return fail(msg(err), code(err), locate(err, 'patch')); }
219
272
  const t1 = now();
220
273
  return okPanels([
221
274
  { id: 'out', label: `JSON Patch (${jsonPatch.length} ops)`, kind: 'code', text: fmt(jsonPatch) },
@@ -224,11 +277,11 @@ export const ENGINE_LIST = [
224
277
  }
225
278
  let apply; const t0 = now();
226
279
  try { apply = compileJSONPatch(patch.value, { changes: true }); }
227
- catch (err) { return fail(msg(err), code(err)); }
280
+ catch (err) { return fail(msg(err), code(err), locate(err, 'patch')); }
228
281
  const t1 = now();
229
282
  let run;
230
283
  try { run = apply(target.value); }
231
- catch (err) { return fail(msg(err), code(err)); }
284
+ catch (err) { return fail(msg(err), code(err), locate(err, 'patch')); }
232
285
  const t2 = now();
233
286
  return ok(fmt(run.doc), t1 - t0, t2 - t1, [
234
287
  deepCode('changes', 'What changed', fmt(run.changes)),
@@ -240,19 +293,19 @@ export const ENGINE_LIST = [
240
293
  sourcePanes: [{ key: 'query', label: 'Query', control: 'code' }, { key: 'externals', label: 'Externals', control: 'code' }],
241
294
  dataPanes: [{ key: 'data', label: 'Data' }],
242
295
  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);
296
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
297
+ const q = parseJson(source.query, 'query'); if (q.error) return failParse(q);
245
298
  let externals = {};
246
299
  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;
300
+ const e = parseJson(source.externals, 'externals'); if (e.error) return failParse(e); externals = e.value;
248
301
  }
249
302
  let fn; const t0 = now();
250
303
  try { fn = compileJsonQuery(q.value, compileOptions(options)); }
251
- catch (err) { return fail(msg(err), code(err)); }
304
+ catch (err) { return fail(msg(err), code(err), locate(err, 'query')); }
252
305
  const t1 = now();
253
306
  let out;
254
307
  try { out = fn(d.value, externals); }
255
- catch (err) { return fail(msg(err), code(err)); }
308
+ catch (err) { return fail(msg(err), code(err), locate(err, 'query')); }
256
309
  const t2 = now();
257
310
  const items = out === undefined ? 0 : Array.isArray(out) ? out.length : 1;
258
311
  return ok(fmt(out), t1 - t0, t2 - t1, [
@@ -269,15 +322,15 @@ export const ENGINE_LIST = [
269
322
  sourcePanes: [{ key: 'stylesheet', label: 'Stylesheet', control: 'code' }],
270
323
  dataPanes: [{ key: 'data', label: 'Data' }],
271
324
  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);
325
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
326
+ const s = parseJson(source.stylesheet, 'stylesheet'); if (s.error) return failParse(s);
274
327
  let compiled; const t0 = now();
275
328
  try { compiled = compileJsltStylesheet(s.value, compileOptions(options)); }
276
- catch (err) { return fail(msg(err), code(err)); }
329
+ catch (err) { return fail(msg(err), code(err), locate(err, 'stylesheet')); }
277
330
  const t1 = now();
278
331
  let out;
279
332
  try { out = compiled(d.value); }
280
- catch (err) { return fail(msg(err), code(err)); }
333
+ catch (err) { return fail(msg(err), code(err), locate(err, 'stylesheet')); }
281
334
  const t2 = now();
282
335
  // the identity lesson: a rule that changes nothing hands the INPUT back
283
336
  // (shared, copy-on-write) — worth teaching, so the deep card says which
@@ -295,15 +348,15 @@ export const ENGINE_LIST = [
295
348
  sourcePanes: [{ key: 'template', label: 'Template', control: 'code' }],
296
349
  dataPanes: [{ key: 'data', label: 'Data' }],
297
350
  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);
351
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
352
+ const t = parseJson(source.template, 'template'); if (t.error) return failParse(t);
300
353
  let render; const t0 = now();
301
354
  try { render = compileJtltStylesheet(t.value, compileOptions(options)); }
302
- catch (err) { return fail(msg(err), code(err)); }
355
+ catch (err) { return fail(msg(err), code(err), locate(err, 'template')); }
303
356
  const t1 = now();
304
357
  let out;
305
358
  try { out = render(d.value); }
306
- catch (err) { return fail(msg(err), code(err)); }
359
+ catch (err) { return fail(msg(err), code(err), locate(err, 'template')); }
307
360
  const t2 = now();
308
361
  // JTLT emits TEXT (markdown / xml / source) — show it verbatim, not fmt'd
309
362
  return ok(out === '' ? '(empty)' : out, t1 - t0, t2 - t1, [
@@ -317,20 +370,22 @@ export const ENGINE_LIST = [
317
370
  sourcePanes: [{ key: 'text', label: 'XQuery', control: 'code' }],
318
371
  dataPanes: [{ key: 'data', label: 'Data' }],
319
372
  run(source, data, options) {
320
- const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
373
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
321
374
  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)); }
375
+ // two phases: the text parses (a syntax error locates a position in
376
+ // the XQuery pane), then the GENERATED query document compiles and
377
+ // runs a location in that document has no pane the learner typed
378
+ try { doc = parseXQuery(source.text ?? ''); }
379
+ catch (err) { return fail(msg(err), code(err), locate(err, 'text')); }
380
+ try { fn = compileJsonQuery(doc, compileOptions(options)); }
381
+ catch (err) { return fail(msg(err), code(err), locate(err)); }
327
382
  const t1 = now();
328
383
  let out;
329
384
  try {
330
385
  const externals = fn.externals.includes('doc') ? { doc: d.value } : {};
331
386
  out = fn(d.value, externals);
332
387
  }
333
- catch (err) { return fail(msg(err), code(err)); }
388
+ catch (err) { return fail(msg(err), code(err), locate(err)); }
334
389
  const t2 = now();
335
390
  return ok(out === undefined ? '(empty sequence)' : fmt(out), t1 - t0, t2 - t1, [
336
391
  // the machinery: the XQuery text parses to a runnable query DOCUMENT
@@ -355,7 +410,7 @@ export const ENGINE_LIST = [
355
410
  // parser streams document-order events as it reads — capture (capped)
356
411
  // for the "how it streamed" drill-down
357
412
  try { parsed = parseJosl(source.text ?? '', { mode, onEvent: (e) => { if (events.length < 200) events.push(e); } }); }
358
- catch (err) { return fail(msg(err), code(err)); }
413
+ catch (err) { return fail(msg(err), code(err), locate(err, 'text')); }
359
414
  const t1 = now();
360
415
  const out = stringifyJsonx(parsed, { indent: 2 });
361
416
  const t2 = now();
@@ -391,7 +446,7 @@ export const ENGINE_LIST = [
391
446
  // strict mode THROWS on the first RFC 4180 violation (with a code);
392
447
  // repair mode reads anyway and lists every fix — the lesson of the tab
393
448
  try { doc = parseCsvDocument(source.text ?? '', opts); }
394
- catch (err) { return fail(msg(err), code(err)); }
449
+ catch (err) { return fail(msg(err), code(err), locate(err, 'text')); }
395
450
  const t1 = now();
396
451
  // one calm SCREEN (the summary note) plus the drill-down: the parsed
397
452
  // records, the sniffed dialect, the repairs, and the CSV round-trip
@@ -461,12 +516,12 @@ export const ENGINE_LIST = [
461
516
  if (typeof validate !== 'function') {
462
517
  return fail('the JSON Schema engine validates in the host — inject options.validate', 'PLAY_NO_VALIDATOR');
463
518
  }
464
- const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
519
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
465
520
  const locale = options?.config?.locale ?? 'en';
466
521
  let report;
467
522
  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');
523
+ catch (err) { return fail(msg(err), code(err), locate(err)); }
524
+ if (report.schemaError) return fail(report.schemaError, 'SCHEMA', { pane: 'schema' });
470
525
  const errs = report.errors ?? [];
471
526
  const summary = (report.valid ? '✓ valid' : `✗ ${errs.length} error${errs.length === 1 ? '' : 's'}`)
472
527
  + ` · ${report.draft} · compiled ${formatMs(report.compileMs)} · validated ${formatMs(report.validateMs)}`;
@@ -494,15 +549,83 @@ export const ENGINE_LIST = [
494
549
  if (typeof render !== 'function') {
495
550
  return fail('the MDX engine renders in the host — inject options.renderers.mdx', 'PLAY_NO_RENDERER');
496
551
  }
497
- const d = parseJson(data.data, 'data'); if (d.error) return fail(d.error);
552
+ const d = parseJson(data.data, 'data'); if (d.error) return failParse(d);
498
553
  const t0 = now();
499
554
  let view;
500
555
  try { view = render(source.source ?? '', d.value); }
501
- catch (err) { return fail(msg(err), code(err)); }
556
+ catch (err) { return fail(msg(err), code(err), locate(err, 'source')); }
502
557
  const t1 = now();
503
558
  return okPanels(renderedPanels(view), ...renderTiming(view, t1 - t0));
504
559
  },
505
560
  },
561
+ {
562
+ // the one ASYNC engine: the dispatch panel resolves a real client
563
+ // outcome, so run() returns a Promise — runExample and the hosts
564
+ // pass a thenable Result through (PLAY-FORMAT §2)
565
+ id: 'contract', label: 'Contract', lead: 'A $contract document — operations two ends may exchange, compiled, projected and dispatched in-process.',
566
+ sourcePanes: [{ key: 'document', label: '$contract document', control: 'code' }],
567
+ dataPanes: [{ key: 'call', label: 'Dispatch — { "op", "input" }' }],
568
+ async run(source, data) {
569
+ const d = parseJson(source.document, 'document'); if (d.error) return failParse(d);
570
+ let contract; const t0 = now();
571
+ // a compile refusal is the engine's lesson: a stable JC00xx code
572
+ // with the JSON Pointer of the member at fault
573
+ try { contract = compileContract(d.value); }
574
+ catch (err) { return fail(msg(err), code(err), locate(err, 'document')); }
575
+ const t1 = now();
576
+ let openapi, types;
577
+ try {
578
+ openapi = toOpenApi(contract, {
579
+ info: {
580
+ title: typeof d.value?.id === 'string' ? d.value.id : 'contract',
581
+ version: typeof d.value?.version === 'string' ? d.value.version : '0',
582
+ },
583
+ });
584
+ types = toTypeScript(contract);
585
+ }
586
+ catch (err) { return fail(msg(err), code(err), locate(err, 'document')); }
587
+ // the dispatch pane: an op + input run against trivial echo
588
+ // handlers over the local binding — the whole pipeline with no
589
+ // network. Output validation is DECLARED off (a capability the
590
+ // binding states), so any echo crosses; input validation still
591
+ // refuses a bad input with its outcome, which is the lesson.
592
+ /** @type {import('./index.js').Panel} */
593
+ let dispatch = {
594
+ id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'info',
595
+ text: 'Type { "op": "<operation id>", "input": { … } } in the Dispatch pane to run an operation against echo handlers (validateOutput declared \'never\').',
596
+ };
597
+ if (String(data.call ?? '').trim() !== '') {
598
+ const call = parseJson(data.call, 'call'); if (call.error) return failParse(call);
599
+ const op = call.value !== null && typeof call.value === 'object' ? call.value.op : undefined;
600
+ if (typeof op !== 'string') {
601
+ dispatch = { id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'warn', text: 'the call must be an object naming an "op" (and optionally an "input")' };
602
+ }
603
+ else {
604
+ /** @type {Record<string, (input: any) => any>} */
605
+ const handlers = {};
606
+ for (const id of Object.keys(contract.operations)) handlers[id] = (input) => input;
607
+ const client = openLocalClient(contract, handlers, { validateOutput: 'never' });
608
+ try {
609
+ const outcome = await client.invoke(op, call.value.input ?? {});
610
+ dispatch = { id: 'dispatch', label: 'Dispatch (echo)', kind: 'code', text: fmt(outcome) };
611
+ }
612
+ catch (err) {
613
+ // a host mistake (JC1005: unknown, opaque or subscribe
614
+ // operation) is the panel's answer, not an engine failure
615
+ dispatch = { id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'warn', text: msg(err) };
616
+ }
617
+ finally { client.close(); }
618
+ }
619
+ }
620
+ const t2 = now();
621
+ return okPanels([
622
+ { id: 'describe', label: 'describe()', kind: 'code', text: fmt(contract.describe()) },
623
+ { id: 'openapi', label: 'OpenAPI', kind: 'code', text: fmt(openapi.document) },
624
+ { id: 'types', label: 'TypeScript', kind: 'code', text: types },
625
+ dispatch,
626
+ ], t1 - t0, t2 - t1);
627
+ },
628
+ },
506
629
  // ——— the visual engines: descriptor + examples here, rendering delegated ———
507
630
  visual('markdown', 'Markdown', 'CommonMark + GFM + frontmatter → a JSON AST, rendered live.', { sourceLabel: 'Markdown' }),
508
631
  visual('mermaid', 'Mermaid', 'Diagrams-as-code → a geometry-free AST → pure-vnode SVG.', { sourceLabel: 'Mermaid' }),
package/src/examples.js CHANGED
@@ -543,4 +543,46 @@ is a fixed point.
543
543
  name: { type: 'string', minLength: 2 }, email: { type: 'string', format: 'email' }, age: { type: 'integer', minimum: 13 },
544
544
  }, required: ['name', 'email'] }) },
545
545
  datasets: [{ label: 'invalid', data: { data: j({ name: 'A', email: 'not-an-email', age: 7 }) } }] },
546
+
547
+ // ——— Contract ($contract document → describe / OpenAPI / TypeScript / dispatch) ———
548
+ { id: 'contract-shop', label: 'A shop contract', engine: 'contract',
549
+ source: { document: j({ $contract: '0.1', id: 'shop', version: '1',
550
+ $defs: { Product: { type: 'object', required: ['id', 'name', 'price'], properties: {
551
+ id: { type: 'integer' }, name: { type: 'string', minLength: 1 }, price: { type: 'number', minimum: 0 } } } },
552
+ operations: {
553
+ 'catalog.load': { kind: 'read',
554
+ input: { type: 'object', properties: { since: { type: 'string', format: 'date-time' } } },
555
+ output: { type: 'array', items: { $ref: '#/$defs/Product' } },
556
+ http: { method: 'GET', path: '/api/catalog' },
557
+ doc: 'The whole catalog.' },
558
+ 'product.save': { kind: 'command',
559
+ input: { type: 'object', required: ['id', 'product'], properties: {
560
+ id: { type: 'integer' }, product: { $ref: '#/$defs/Product' } } },
561
+ output: { $ref: '#/$defs/Product' },
562
+ errors: { conflict: { status: 409 } },
563
+ policy: { idempotency: 'optional' },
564
+ http: { method: 'PUT', path: '/api/products/{id}' } },
565
+ } }) },
566
+ datasets: [
567
+ { label: 'a valid save', data: { call: j({ op: 'product.save', input: { id: 7, product: { id: 7, name: 'Duck', price: 9.99 } } }) } },
568
+ { label: 'an invalid input', data: { call: j({ op: 'product.save', input: { id: 'seven' } }) } },
569
+ { label: 'no dispatch', data: { call: '' } },
570
+ ] },
571
+ { id: 'contract-minimal', label: 'One operation, no http', engine: 'contract',
572
+ source: { document: j({ $contract: '0.1', id: 'echo', operations: {
573
+ 'echo.say': { kind: 'command',
574
+ input: { type: 'object', required: ['text'], properties: { text: { type: 'string' } } },
575
+ output: true,
576
+ doc: 'No http member: the binding defaults to POST /echo.say.' },
577
+ } }) },
578
+ datasets: [{ label: 'say something', data: { call: j({ op: 'echo.say', input: { text: 'hello' } }) } }] },
579
+ { id: 'contract-broken', label: 'A refusal, with its docPath', engine: 'contract',
580
+ source: { document: j({ $contract: '0.1', id: 'broken', operations: {
581
+ 'catalog.load': { kind: 'read',
582
+ input: { type: 'object', properties: { since: { type: 'string' } } },
583
+ // no output member: JC00xx at compile, never at request time —
584
+ // the error names /operations/catalog.load with its code
585
+ http: { method: 'GET', path: '/api/catalog' } },
586
+ } }) },
587
+ datasets: [{ label: 'nothing to dispatch', data: { call: '' } }] },
546
588
  ];
package/src/index.js CHANGED
@@ -54,11 +54,38 @@ import { EXAMPLE_LIST } from './examples.js';
54
54
  * `cards`: a row of stat cards (matches, compile/run timings, …)
55
55
  */
56
56
 
57
+ /**
58
+ * The error half of a Result: what the compiler said, plus WHERE — in the
59
+ * format's own fields, so the view can point at the pane and the location
60
+ * rather than only quoting the message. Every location field is present
61
+ * exactly when the compiler stated it (a field is never fabricated), and
62
+ * `pane` names the editor the location points into; a location into a
63
+ * document the learner never typed (XQuery's generated query document) has
64
+ * no pane. See PLAY-FORMAT §2.
65
+ * @typedef {Object} PlayError
66
+ * @property {string} message - the compiler's message, verbatim (a coded
67
+ * error composes `code: reason at path` itself)
68
+ * @property {string} [code] - the stable diagnosis code, when there is one
69
+ * @property {string} [pane] - the pane KEY (source or data) the error is
70
+ * about: the source pane for a compile or run failure, the data pane
71
+ * whose JSON did not parse
72
+ * @property {string} [path] - JSON Pointer into that pane's DOCUMENT (a
73
+ * coded error's `docPath`: the failing patch operation, the query
74
+ * construct, the stylesheet rule)
75
+ * @property {string} [dataPath] - JSON Pointer into the DATA the document
76
+ * was applied to, when a runtime error blames both (a patch operation
77
+ * AND the target location it failed at)
78
+ * @property {number} [position] - 0-based offset into that pane's TEXT
79
+ * (the syntax family: a JSONPath selector, a JSON Pointer, an XQuery)
80
+ * @property {number} [line] - 1-based line (the JOSL / CSV family)
81
+ * @property {number} [column] - 1-based column (with `line`)
82
+ */
83
+
57
84
  /**
58
85
  * @typedef {Object} PlayResult
59
86
  * @property {boolean} ok
60
87
  * @property {{ compileMs: number, runMs: number } | null} timing
61
- * @property {{ message: string, code?: string, path?: string } | null} error
88
+ * @property {PlayError | null} error - null on an ok run
62
89
  * @property {Panel[]} panels - the result screens (`[]` on error); a single
63
90
  * `code` panel for most engines, several for the richer ones
64
91
  */
@@ -71,7 +98,11 @@ import { EXAMPLE_LIST } from './examples.js';
71
98
  * @property {EnginePane[]} sourcePanes - the engine INPUT pane(s)
72
99
  * @property {EnginePane[]} dataPanes - the JSON it runs against (may be [])
73
100
  * @property {OptionPane[]} [optionPanes] - live mode selects (may be absent)
74
- * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult} run
101
+ * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>} run
102
+ * most engines answer synchronously; an engine whose run resolves real
103
+ * promises (the contract engine's dispatch panel) answers a thenable
104
+ * Result, which `runExample` passes through with the same never-throw
105
+ * contract (a rejection settles into an error Result)
75
106
  */
76
107
 
77
108
  /**
@@ -135,23 +166,31 @@ function withConfig(engine, options) {
135
166
 
136
167
  /**
137
168
  * Run one engine over a source + data. An unknown engine (or a throwing
138
- * runner) yields an error Result — this never throws.
169
+ * runner) yields an error Result — this never throws. A synchronous
170
+ * engine answers a Result; an async engine (the contract dispatch)
171
+ * answers a Promise of one that never rejects — a host that must know
172
+ * which awaits `Promise.resolve(runExample(…))`.
139
173
  * @param {string} engineId
140
174
  * @param {Record<string, string>} source
141
175
  * @param {Record<string, string>} data
142
176
  * @param {RunOptions} [options] - operators (query/jslt), the option-pane
143
177
  * config, and host renderers (markdown/mermaid/charts)
144
- * @returns {PlayResult}
178
+ * @returns {PlayResult | Promise<PlayResult>}
145
179
  */
146
180
  export function runExample(engineId, source, data, options = {}) {
147
181
  const engine = ENGINES[engineId];
148
182
  if (engine === undefined) {
149
183
  return { ok: false, timing: null, error: { message: `unknown engine: ${engineId}` }, panels: [] };
150
184
  }
185
+ const failed = (/** @type {unknown} */ err) =>
186
+ /** @type {PlayResult} */ ({ ok: false, timing: null, error: { message: String(/** @type {any} */ (err)?.message ?? err) }, panels: [] });
151
187
  try {
152
- return engine.run(source ?? {}, data ?? {}, withConfig(engine, options));
188
+ const result = engine.run(source ?? {}, data ?? {}, withConfig(engine, options));
189
+ return result !== null && typeof result === 'object' && typeof (/** @type {any} */ (result).then) === 'function'
190
+ ? /** @type {Promise<PlayResult>} */ (result).then((r) => r, failed)
191
+ : result;
153
192
  }
154
193
  catch (err) {
155
- return { ok: false, timing: null, error: { message: String(/** @type {any} */ (err)?.message ?? err) }, panels: [] };
194
+ return failed(err);
156
195
  }
157
196
  }
package/styles/play.css CHANGED
@@ -70,6 +70,12 @@
70
70
  .jplay-lead { font-size: 0.8rem; }
71
71
  .jplay-pane { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
72
72
  .jplay-pane-label { font-size: 0.7rem; font-family: var(--mono, monospace); }
73
+ /* the last error's claim on a pane: the location rides on the label, and the
74
+ editor it is about carries aria-invalid — the state the renderer owns —
75
+ which is what the ring is styled off (the --fail family, no new hue) */
76
+ .jplay-pane-where { color: var(--fail, #dc2626); }
77
+ .jplay .editor[aria-invalid='true'] { border-color: var(--fail, #dc2626); }
78
+ .jplay-error-pane { color: var(--muted, #64748b); }
73
79
  .jplay-datasets { align-self: flex-start; }
74
80
  /* the validate engine's JSON ↔ form toggle + the generated-form container */
75
81
  .jplay-dataview { align-self: flex-start; }