@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/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/play",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.34.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./component": {
|
|
15
|
+
"types": "./dist/types/component/index.d.ts",
|
|
16
|
+
"default": "./src/component/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./styles/play.css": "./styles/play.css",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist/types/",
|
|
23
|
+
"src/",
|
|
24
|
+
"docs/",
|
|
25
|
+
"styles/"
|
|
26
|
+
],
|
|
27
|
+
"description": "The jaren engine playground: pick a JSON engine (JSONPath, JSON Pointer, JSON Patch, query, JSLT, markdown, mermaid, …), feed it an input and one or more datasets from a curated example library, and watch it run. Understand an engine standalone before composing it in the studio. The engine is headless; the component is the playground UI.",
|
|
28
|
+
"author": "joham",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
32
|
+
"directory": "components/play"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=24"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org/"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"jaren",
|
|
44
|
+
"json",
|
|
45
|
+
"playground",
|
|
46
|
+
"playground",
|
|
47
|
+
"examples",
|
|
48
|
+
"jsonpath",
|
|
49
|
+
"jslt",
|
|
50
|
+
"jsonquery",
|
|
51
|
+
"headless"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "npm run build:types",
|
|
55
|
+
"build:types": "tsc -p tsconfig.json",
|
|
56
|
+
"prepack": "npm run build:types"
|
|
57
|
+
},
|
|
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"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The playground COMPONENT — `createPlayComponent(options)`, the
|
|
4
|
+
* suite's factory convention (like `@jarenjs/calc` / `@jarenjs/studio`). It
|
|
5
|
+
* hands the host the JSLT view (`rules` + `mode` + `modes`), the pure
|
|
6
|
+
* derivation (`viewModel`), and the headless engine surface its run loop
|
|
7
|
+
* binds (`runExample`, the registry, the example library). The reducer
|
|
8
|
+
* `play/*` actions and the debounced live-run are wired at the host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ENGINES, EXAMPLES, engineIds, runExample } from '../index.js';
|
|
12
|
+
import { playViewModel } from './viewmodel.js';
|
|
13
|
+
import { playRules, playModes, PLAY_MODE, PLAY_BASE } from './view.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the playground component.
|
|
17
|
+
*
|
|
18
|
+
* The three host seams are configured ONCE here and become defaults for
|
|
19
|
+
* every `runExample` call the returned component makes; a per-call option
|
|
20
|
+
* of the same name still wins, so a caller can vary one run. Configuring
|
|
21
|
+
* them at the factory and having them silently ignored is the trap this
|
|
22
|
+
* shape exists to close — a host that registered its operator packs here
|
|
23
|
+
* would otherwise watch `$mean` and `$npv` go missing at run time.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ operators?: { toOptions: () => any },
|
|
26
|
+
* renderers?: Record<string, Function>, validate?: Function }} [options]
|
|
27
|
+
* `operators` reaches the query/jslt engines, `renderers` the visual
|
|
28
|
+
* engines (markdown/mermaid/charts/mdx), `validate` the JSON Schema one
|
|
29
|
+
*/
|
|
30
|
+
export function createPlayComponent(options = {}) {
|
|
31
|
+
const seams = {};
|
|
32
|
+
if (options.operators !== undefined) seams.operators = options.operators;
|
|
33
|
+
if (options.renderers !== undefined) seams.renderers = options.renderers;
|
|
34
|
+
if (options.validate !== undefined) seams.validate = options.validate;
|
|
35
|
+
return {
|
|
36
|
+
mode: PLAY_MODE,
|
|
37
|
+
rules: playRules,
|
|
38
|
+
modes: playModes,
|
|
39
|
+
viewModel: playViewModel,
|
|
40
|
+
// the engine surface the host's run loop binds
|
|
41
|
+
engines: ENGINES,
|
|
42
|
+
examples: EXAMPLES,
|
|
43
|
+
engineIds,
|
|
44
|
+
runExample: (engineId, source, data, perCall = {}) =>
|
|
45
|
+
runExample(engineId, source, data, { ...seams, ...perCall }),
|
|
46
|
+
operators: options.operators,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
playViewModel, playRules, playModes, PLAY_MODE, PLAY_BASE,
|
|
52
|
+
ENGINES, EXAMPLES, engineIds, runExample,
|
|
53
|
+
};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The playground as a JSLT view — the chrome is a document, rendered
|
|
4
|
+
* by the same engine as the rest of the suite. One `play` mode, rules
|
|
5
|
+
* matched by their ABSOLUTE slice path (`$.ui.play`), `$apply` and body
|
|
6
|
+
* references RELATIVE to the matched node. The host mounts the view model
|
|
7
|
+
* at `$.ui.play`. No imperative islands — the whole playground is data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The one mode this view uses. */
|
|
11
|
+
export const PLAY_MODE = 'play';
|
|
12
|
+
/** The slice the host mounts the view model at. */
|
|
13
|
+
export const PLAY_BASE = '$.ui.play';
|
|
14
|
+
/** The modes the host merges into the site stylesheet. */
|
|
15
|
+
export const playModes = Object.freeze({ [PLAY_MODE]: { unmatched: 'error' } });
|
|
16
|
+
|
|
17
|
+
/** One segment of the phone pane switcher — active when it is the live pane. */
|
|
18
|
+
const paneButton = (pane, label) => ['button', {
|
|
19
|
+
type: 'button',
|
|
20
|
+
class: { $if: [{ $eq: ['$.mobilePane', pane] }, 'seg-btn active', 'seg-btn'] },
|
|
21
|
+
'aria-pressed': { $if: [{ $eq: ['$.mobilePane', pane] }, 'true', 'false'] },
|
|
22
|
+
on: { click: { action: 'play/mobile-pane', with: pane } },
|
|
23
|
+
}, label];
|
|
24
|
+
|
|
25
|
+
/** The shell (matches the whole slice): bar / rail | editors | split | stage. */
|
|
26
|
+
const shell = {
|
|
27
|
+
match: PLAY_BASE, mode: PLAY_MODE,
|
|
28
|
+
body: ['div', { class: 'jplay', 'data-pane': '$.mobilePane' },
|
|
29
|
+
// ——— the IDE bar: engine title, session name, New/Save/Save As/Share,
|
|
30
|
+
// the Load dropdown, and the last share status (a play session is a
|
|
31
|
+
// saveable document) ———
|
|
32
|
+
['div', { class: 'jplay-bar' },
|
|
33
|
+
['strong', { class: 'jplay-title' }, '$.engine.label'],
|
|
34
|
+
// a NAME field, not a code editor: `.editor` paints the code-block
|
|
35
|
+
// background, which put a black hole in the middle of a light toolbar
|
|
36
|
+
['input', {
|
|
37
|
+
class: 'jplay-name', value: '$.name', spellcheck: 'false',
|
|
38
|
+
autocapitalize: 'off', autocomplete: 'off', placeholder: 'name this session…',
|
|
39
|
+
'aria-label': 'session name', on: { input: 'play/name' },
|
|
40
|
+
}],
|
|
41
|
+
['div', { class: 'jplay-actions' },
|
|
42
|
+
['button', { class: 'btn small', type: 'button', on: { click: 'play/new' } }, 'New'],
|
|
43
|
+
['button', {
|
|
44
|
+
class: 'btn small', type: 'button',
|
|
45
|
+
title: { $if: ['$.savedName',
|
|
46
|
+
{ $concat: ['Overwrite “', '$.savedName', '”'] },
|
|
47
|
+
'Save this session under the name above'] },
|
|
48
|
+
on: { click: 'play/save' },
|
|
49
|
+
}, 'Save'],
|
|
50
|
+
['button', {
|
|
51
|
+
class: 'btn small', type: 'button',
|
|
52
|
+
title: 'Save the name above as a SEPARATE session, keeping the original',
|
|
53
|
+
on: { click: 'play/save-as' },
|
|
54
|
+
}, 'Save As'],
|
|
55
|
+
// the way out of the browser, and back in: a session too large for
|
|
56
|
+
// a share link still has a file
|
|
57
|
+
['button', {
|
|
58
|
+
class: 'btn small', type: 'button',
|
|
59
|
+
title: 'Download this session as a file',
|
|
60
|
+
on: { click: 'play/download' },
|
|
61
|
+
}, 'Download'],
|
|
62
|
+
['button', {
|
|
63
|
+
class: 'btn small', type: 'button',
|
|
64
|
+
title: 'Open a downloaded session file',
|
|
65
|
+
on: { click: 'play/import' },
|
|
66
|
+
}, 'Import'],
|
|
67
|
+
['button', { class: 'btn small', type: 'button', on: { click: 'play/share' } }, 'Share'],
|
|
68
|
+
// Delete the current named session (only meaningful once named+saved)
|
|
69
|
+
{ $if: ['$.name',
|
|
70
|
+
['button', { class: 'btn small', type: 'button', title: 'delete this saved session',
|
|
71
|
+
on: { click: { action: 'play/delete-session', with: '$.name' } } }, 'Delete'],
|
|
72
|
+
''] },
|
|
73
|
+
// the Load dropdown appears once there is something saved
|
|
74
|
+
{ $if: ['$.hasSaved',
|
|
75
|
+
['select', { class: 'jplay-load editor line', 'aria-label': 'load a saved session', value: '', on: { change: 'play/open' } },
|
|
76
|
+
['option', { value: '' }, 'Load…'],
|
|
77
|
+
[{ $apply: '$.names[*]' }]],
|
|
78
|
+
''] },
|
|
79
|
+
],
|
|
80
|
+
// the title has been edited away from the bound record: say which
|
|
81
|
+
// button does what, rather than letting Save silently overwrite the
|
|
82
|
+
// session the user opened under a name they have already changed
|
|
83
|
+
{ $if: ['$.renamed',
|
|
84
|
+
['span', { class: 'jplay-bound muted', role: 'status' },
|
|
85
|
+
'Save overwrites “', ['text', '$.savedName'], '” · Save As keeps both'],
|
|
86
|
+
''] },
|
|
87
|
+
{ $if: ['$.shared', ['span', { class: 'jplay-shared muted', role: 'status' }, '$.shared'], ''] },
|
|
88
|
+
],
|
|
89
|
+
// ——— the phone pane switcher: below the breakpoint the rail | editors
|
|
90
|
+
// | result grid is ONE column, one pane at a time — a segmented bar
|
|
91
|
+
// instead of a tall stack (desktop hides this bar entirely). A pane
|
|
92
|
+
// is a grid area, not a `tabpanel`, so these are toggle buttons in a
|
|
93
|
+
// group with `aria-pressed`, not a `tablist` with `aria-selected`
|
|
94
|
+
// (the RESULT strip below is a real tablist and says so) ———
|
|
95
|
+
['div', { class: 'jplay-mobilebar seg', role: 'group', 'aria-label': 'pane' },
|
|
96
|
+
paneButton('examples', 'Examples'),
|
|
97
|
+
paneButton('editor', 'Editor'),
|
|
98
|
+
paneButton('result', 'Result'),
|
|
99
|
+
],
|
|
100
|
+
// ——— the example picker (a "file tree" grouped by engine) ———
|
|
101
|
+
['nav', { class: 'jplay-rail', 'aria-label': 'examples' }, [{ $apply: '$.rail[*]' }]],
|
|
102
|
+
// ——— the source + data editors ———
|
|
103
|
+
['div', { class: 'jplay-editors' },
|
|
104
|
+
['div', { class: 'jplay-head' },
|
|
105
|
+
['strong', { class: 'jplay-engine' }, '$.engine.label'],
|
|
106
|
+
['span', { class: 'muted jplay-lead' }, '$.engine.lead'],
|
|
107
|
+
],
|
|
108
|
+
// live mode selects (josl dialect, csv strict/repair) — empty for most
|
|
109
|
+
['div', { class: 'jplay-options' }, [{ $apply: '$.optionPanes[*]' }]],
|
|
110
|
+
[{ $apply: '$.sourcePanes[*]' }],
|
|
111
|
+
{ $if: ['$.hasSwitcher',
|
|
112
|
+
['div', { class: 'jplay-datasets seg', role: 'group', 'aria-label': 'dataset' }, [{ $apply: '$.datasets[*]' }]],
|
|
113
|
+
''] },
|
|
114
|
+
// the validate engine offers a JSON ↔ generated-form toggle on its data
|
|
115
|
+
{ $if: ['$.hasForm',
|
|
116
|
+
['div', { class: 'jplay-dataview seg', role: 'group', 'aria-label': 'data view' },
|
|
117
|
+
['button', { type: 'button', class: { $if: [{ $eq: ['$.dataView', 'json'] }, 'seg-btn active', 'seg-btn'] },
|
|
118
|
+
on: { click: { action: 'play/data-view', with: 'json' } } }, 'JSON'],
|
|
119
|
+
['button', { type: 'button', class: { $if: [{ $eq: ['$.dataView', 'form'] }, 'seg-btn active', 'seg-btn'] },
|
|
120
|
+
on: { click: { action: 'play/data-view', with: 'form' } } }, 'Form'],
|
|
121
|
+
], ''] },
|
|
122
|
+
// form mode → the host-supplied generated form (a two-way seam); else
|
|
123
|
+
// the JSON data editor(s)
|
|
124
|
+
{ $if: ['$.showForm',
|
|
125
|
+
['div', { class: 'jplay-form' }, { $apply: ['$.dataForm', PLAY_MODE] }],
|
|
126
|
+
[{ $apply: '$.dataPanes[*]' }]] },
|
|
127
|
+
],
|
|
128
|
+
// ——— the editors|result splitter (a pointer-capture widget; its host
|
|
129
|
+
// IS the grab bar — drives --jplay-ratio live, commits on pointer-up) ———
|
|
130
|
+
['jaren-widget', {
|
|
131
|
+
name: 'play-splitter', class: 'jplay-split',
|
|
132
|
+
role: 'separator', 'aria-orientation': 'vertical',
|
|
133
|
+
'aria-label': 'Resize the editors and result',
|
|
134
|
+
'aria-valuemin': '10', 'aria-valuemax': '90', 'aria-valuenow': '$.ratioPct',
|
|
135
|
+
tabindex: '0',
|
|
136
|
+
props: { ratio: '$.ratio' },
|
|
137
|
+
}],
|
|
138
|
+
// ——— the run stage ———
|
|
139
|
+
['div', { class: 'jplay-stage' },
|
|
140
|
+
['div', { class: 'jplay-stage-head muted' }, 'Result'],
|
|
141
|
+
{ $if: ['$.result.ran',
|
|
142
|
+
{ $if: ['$.result.ok',
|
|
143
|
+
['div', { class: { $if: ['$.result.deepOn', 'jplay-result deep-on', 'jplay-result'] } },
|
|
144
|
+
// the SIMPLE half — the calm answer (on a phone, the open
|
|
145
|
+
// drill-down swaps this half out entirely; see play.css)
|
|
146
|
+
['div', { class: 'jplay-simple' },
|
|
147
|
+
{ $if: ['$.result.timing', ['p', { class: 'muted jplay-timing' }, '$.result.timing'], ''] },
|
|
148
|
+
// >1 screen → a tab strip selecting the active one; 1 → no tabs
|
|
149
|
+
{ $if: ['$.result.tabbed',
|
|
150
|
+
['div', { class: 'jplay-tabs seg', role: 'tablist' }, [{ $apply: '$.result.tabs[*]' }]], ''] },
|
|
151
|
+
// the active panel body, rendered by its kind (a single object →
|
|
152
|
+
// the explicit [path, mode] apply form)
|
|
153
|
+
{ $apply: [`$.result.activePanel`, PLAY_MODE] },
|
|
154
|
+
],
|
|
155
|
+
// the DRILL-DOWN half — the engine's rich explainers, opt-in:
|
|
156
|
+
// a quiet affordance beneath the simple answer reveals the deep
|
|
157
|
+
// panels as an additional tab row (desktop) or a full-pane
|
|
158
|
+
// sub-view with a ← back (mobile)
|
|
159
|
+
{ $if: ['$.result.hasDeep',
|
|
160
|
+
['div', { class: 'jplay-deep' },
|
|
161
|
+
['button', {
|
|
162
|
+
type: 'button', class: 'jplay-deep-toggle',
|
|
163
|
+
'aria-expanded': { $if: ['$.result.deepOn', 'true', 'false'] },
|
|
164
|
+
on: { click: { action: 'play/deep', with: '$.result.deepNext' } },
|
|
165
|
+
}, '$.result.deepLabel'],
|
|
166
|
+
{ $if: ['$.result.deepOn',
|
|
167
|
+
['div', { class: 'jplay-deep-body' },
|
|
168
|
+
['button', { type: 'button', class: 'jplay-deep-back', on: { click: { action: 'play/deep', with: '$.result.deepNext' } } }, '← Back to the result'],
|
|
169
|
+
{ $if: ['$.result.deepTabbed',
|
|
170
|
+
['div', { class: 'jplay-deep-tabs seg', role: 'tablist' }, [{ $apply: '$.result.deepTabs[*]' }]], ''] },
|
|
171
|
+
{ $apply: [`$.result.deepPanel`, PLAY_MODE] },
|
|
172
|
+
], ''] },
|
|
173
|
+
], ''] },
|
|
174
|
+
],
|
|
175
|
+
['p', { class: 'error-line' }, ['strong', {}, '$.result.error.code'], ' ', '$.result.error.message']] },
|
|
176
|
+
['p', { class: 'muted jplay-hint' }, 'Pick an example, or edit the source or data — it runs live.']] },
|
|
177
|
+
],
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** One engine group in the rail. */
|
|
182
|
+
const railGroup = {
|
|
183
|
+
match: `${PLAY_BASE}.rail[*]`, mode: PLAY_MODE,
|
|
184
|
+
body: ['div', { class: { $if: ['$.active', 'jplay-group active', 'jplay-group'] } },
|
|
185
|
+
['div', { class: 'jplay-group-head muted' }, '$.label'],
|
|
186
|
+
[{ $apply: '$.examples[*]' }],
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** One example in a group — picking it loads its source + first dataset. */
|
|
191
|
+
const railExample = {
|
|
192
|
+
match: `${PLAY_BASE}.rail[*].examples[*]`, mode: PLAY_MODE,
|
|
193
|
+
body: ['button', {
|
|
194
|
+
type: 'button',
|
|
195
|
+
class: { $if: ['$.active', 'jplay-ex active', 'jplay-ex'] },
|
|
196
|
+
on: { click: { action: 'play/example', with: '$.id' } },
|
|
197
|
+
}, '$.label'],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** One source editor (a `text` control renders an input, else a textarea). */
|
|
201
|
+
const sourcePane = {
|
|
202
|
+
match: `${PLAY_BASE}.sourcePanes[*]`, mode: PLAY_MODE,
|
|
203
|
+
body: ['label', { class: 'jplay-pane' },
|
|
204
|
+
['span', { class: 'jplay-pane-label muted' }, '$.label'],
|
|
205
|
+
{ $if: [{ $eq: ['$.control', 'text'] },
|
|
206
|
+
['input', { type: 'text', class: 'editor line', spellcheck: 'false', value: '$.value',
|
|
207
|
+
on: { input: { action: 'play/source', with: { key: '$.key' } } } }],
|
|
208
|
+
['textarea', { class: 'editor', rows: 6, spellcheck: 'false', value: '$.value',
|
|
209
|
+
on: { input: { action: 'play/source', with: { key: '$.key' } } } }]] },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** One data editor. */
|
|
214
|
+
const dataPane = {
|
|
215
|
+
match: `${PLAY_BASE}.dataPanes[*]`, mode: PLAY_MODE,
|
|
216
|
+
body: ['label', { class: 'jplay-pane' },
|
|
217
|
+
['span', { class: 'jplay-pane-label muted' }, '$.label'],
|
|
218
|
+
['textarea', { class: 'editor', rows: 8, spellcheck: 'false', value: '$.value',
|
|
219
|
+
on: { input: { action: 'play/data', with: { key: '$.key' } } } }],
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** One option pane — a live mode select (its value lives in host `config`). */
|
|
224
|
+
const optionPane = {
|
|
225
|
+
match: `${PLAY_BASE}.optionPanes[*]`, mode: PLAY_MODE,
|
|
226
|
+
body: ['label', { class: 'jplay-option' },
|
|
227
|
+
['span', { class: 'jplay-pane-label muted' }, '$.label'],
|
|
228
|
+
['select', { class: 'editor line', on: { change: { action: 'play/option', with: { key: '$.key' } } } },
|
|
229
|
+
[{ $apply: '$.choices[*]' }]],
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** One choice inside an option select. */
|
|
234
|
+
const optionChoice = {
|
|
235
|
+
match: `${PLAY_BASE}.optionPanes[*].choices[*]`, mode: PLAY_MODE,
|
|
236
|
+
body: ['option', { value: '$.value', selected: '$.selected' }, '$.label'],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** One dataset segment in the switcher. */
|
|
240
|
+
const datasetOption = {
|
|
241
|
+
match: `${PLAY_BASE}.datasets[*]`, mode: PLAY_MODE,
|
|
242
|
+
body: ['button', {
|
|
243
|
+
type: 'button',
|
|
244
|
+
class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
|
|
245
|
+
on: { click: { action: 'play/dataset', with: '$.index' } },
|
|
246
|
+
}, '$.label'],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** One saved session in the Load dropdown. */
|
|
250
|
+
const savedOption = {
|
|
251
|
+
match: `${PLAY_BASE}.names[*]`, mode: PLAY_MODE,
|
|
252
|
+
body: ['option', { value: '$.name' }, '$.name'],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** One tab in the result strip (only shown when a result has >1 panel). */
|
|
256
|
+
const resultTab = {
|
|
257
|
+
match: `${PLAY_BASE}.result.tabs[*]`, mode: PLAY_MODE,
|
|
258
|
+
body: ['button', {
|
|
259
|
+
type: 'button', role: 'tab',
|
|
260
|
+
class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
|
|
261
|
+
'aria-selected': { $if: ['$.active', 'true', 'false'] },
|
|
262
|
+
on: { click: { action: 'play/panel', with: '$.id' } },
|
|
263
|
+
}, '$.label'],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** One tab in the drill-down's own row (revealed by the depth toggle). */
|
|
267
|
+
const deepTab = {
|
|
268
|
+
match: `${PLAY_BASE}.result.deepTabs[*]`, mode: PLAY_MODE,
|
|
269
|
+
body: ['button', {
|
|
270
|
+
type: 'button', role: 'tab',
|
|
271
|
+
class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
|
|
272
|
+
'aria-selected': { $if: ['$.active', 'true', 'false'] },
|
|
273
|
+
on: { click: { action: 'play/deep-pick', with: '$.id' } },
|
|
274
|
+
}, '$.label'],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** A result panel, rendered by its kind (code | view | table | note | cards) —
|
|
278
|
+
* one body, matched at both the simple and the deep panel slot. */
|
|
279
|
+
const panelBody = { $if: ['$.isView', ['div', { class: 'jplay-view' }, '$.vnode'],
|
|
280
|
+
{ $if: ['$.isTable',
|
|
281
|
+
['div', { class: 'jplay-table-wrap' },
|
|
282
|
+
['table', { class: 'jplay-table' },
|
|
283
|
+
['thead', {}, ['tr', {}, [{ $apply: '$.columns[*]' }]]],
|
|
284
|
+
['tbody', {}, [{ $apply: '$.rows[*]' }]]]],
|
|
285
|
+
{ $if: ['$.isNote', ['p', { class: '$.noteClass' }, '$.text'],
|
|
286
|
+
{ $if: ['$.isCards',
|
|
287
|
+
['div', { class: 'jplay-cards' }, [{ $apply: '$.items[*]' }]],
|
|
288
|
+
['pre', { class: 'code-block' }, ['code', {}, '$.text']]] }] }] }] };
|
|
289
|
+
|
|
290
|
+
const activePanel = { match: `${PLAY_BASE}.result.activePanel`, mode: PLAY_MODE, body: panelBody };
|
|
291
|
+
const deepPanel = { match: `${PLAY_BASE}.result.deepPanel`, mode: PLAY_MODE, body: panelBody };
|
|
292
|
+
|
|
293
|
+
/** One stat card in a `cards` panel. */
|
|
294
|
+
const cardBody = ['div', { class: 'jplay-card' },
|
|
295
|
+
['span', { class: 'jplay-card-title muted' }, '$.title'],
|
|
296
|
+
['strong', { class: 'jplay-card-value' }, '$.value'],
|
|
297
|
+
{ $if: ['$.note', ['span', { class: 'jplay-card-note muted' }, '$.note'], ''] },
|
|
298
|
+
];
|
|
299
|
+
const activeCard = { match: `${PLAY_BASE}.result.activePanel.items[*]`, mode: PLAY_MODE, body: cardBody };
|
|
300
|
+
const deepCard = { match: `${PLAY_BASE}.result.deepPanel.items[*]`, mode: PLAY_MODE, body: cardBody };
|
|
301
|
+
|
|
302
|
+
/** A table header cell / body row / body cell — again at both slots. */
|
|
303
|
+
const columnBody = ['th', {}, '$.label'];
|
|
304
|
+
const rowBody = ['tr', {}, [{ $apply: '$.cells[*]' }]];
|
|
305
|
+
const cellBody = ['td', {}, '$.text'];
|
|
306
|
+
const tableColumn = { match: `${PLAY_BASE}.result.activePanel.columns[*]`, mode: PLAY_MODE, body: columnBody };
|
|
307
|
+
const tableRow = { match: `${PLAY_BASE}.result.activePanel.rows[*]`, mode: PLAY_MODE, body: rowBody };
|
|
308
|
+
const tableCell = { match: `${PLAY_BASE}.result.activePanel.rows[*].cells[*]`, mode: PLAY_MODE, body: cellBody };
|
|
309
|
+
const deepTableColumn = { match: `${PLAY_BASE}.result.deepPanel.columns[*]`, mode: PLAY_MODE, body: columnBody };
|
|
310
|
+
const deepTableRow = { match: `${PLAY_BASE}.result.deepPanel.rows[*]`, mode: PLAY_MODE, body: rowBody };
|
|
311
|
+
const deepTableCell = { match: `${PLAY_BASE}.result.deepPanel.rows[*].cells[*]`, mode: PLAY_MODE, body: cellBody };
|
|
312
|
+
|
|
313
|
+
/** The playground's JSLT rules — spread into the site stylesheet. */
|
|
314
|
+
export const playRules = [
|
|
315
|
+
shell, railGroup, railExample, optionPane, optionChoice, sourcePane, dataPane, datasetOption,
|
|
316
|
+
savedOption, resultTab, deepTab, activePanel, deepPanel, activeCard, deepCard,
|
|
317
|
+
tableColumn, tableRow, tableCell, deepTableColumn, deepTableRow, deepTableCell,
|
|
318
|
+
];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `playViewModel(state)` — the playground's pure derivation. From
|
|
4
|
+
* the `state.play` slice it derives the example rail (the picker, grouped
|
|
5
|
+
* by engine — the "file-picker of sorts"), the active engine's source
|
|
6
|
+
* editors, the dataset switcher, the data editors, and the run result for
|
|
7
|
+
* the stage. Pure: nothing here is stored back in state.
|
|
8
|
+
*/
|
|
9
|
+
import { pickAllowed } from '@jarenjs/core/array';
|
|
10
|
+
import { ENGINES, EXAMPLES } from '../index.js';
|
|
11
|
+
import { formatMs } from '../format.js';
|
|
12
|
+
|
|
13
|
+
/** The phone panes, in switcher order. */
|
|
14
|
+
const MOBILE_PANES = ['examples', 'editor', 'result'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The timing line, built only from the phases that were actually measured.
|
|
18
|
+
* A `null` half means "no such phase" (patch merge has nothing to compile)
|
|
19
|
+
* or "the host did not report it" — either way it is omitted rather than
|
|
20
|
+
* printed as `0 ms`, which read as "rendering was free".
|
|
21
|
+
*/
|
|
22
|
+
function formatTiming(timing) {
|
|
23
|
+
if (!timing) return null;
|
|
24
|
+
const parts = [];
|
|
25
|
+
if (typeof timing.compileMs === 'number') parts.push(`compiled ${formatMs(timing.compileMs)}`);
|
|
26
|
+
if (typeof timing.runMs === 'number') parts.push(`ran ${formatMs(timing.runMs)}`);
|
|
27
|
+
return parts.length === 0 ? null : parts.join(' · ');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Shape one panel for the view: kind flags for the `$if` dispatch plus the
|
|
32
|
+
* per-kind content (a `table` becomes column/cell records the JSLT can walk).
|
|
33
|
+
*/
|
|
34
|
+
function shapePanel(p) {
|
|
35
|
+
const kind = p.kind;
|
|
36
|
+
const base = {
|
|
37
|
+
id: p.id, label: p.label ?? p.id, kind,
|
|
38
|
+
isCode: kind === 'code', isView: kind === 'view', isTable: kind === 'table',
|
|
39
|
+
isNote: kind === 'note', isCards: kind === 'cards',
|
|
40
|
+
};
|
|
41
|
+
if (kind === 'view') return { ...base, vnode: p.vnode ?? null };
|
|
42
|
+
if (kind === 'note') return { ...base, text: p.text ?? '', noteClass: `jplay-note ${p.tone ?? 'info'}` };
|
|
43
|
+
if (kind === 'table') return {
|
|
44
|
+
...base,
|
|
45
|
+
columns: (p.columns ?? []).map((label) => ({ label: String(label) })),
|
|
46
|
+
rows: (p.rows ?? []).map((cells) => ({ cells: (cells ?? []).map((text) => ({ text: String(text) })) })),
|
|
47
|
+
};
|
|
48
|
+
if (kind === 'cards') return {
|
|
49
|
+
...base,
|
|
50
|
+
items: (p.items ?? []).map((i) => ({ title: String(i.title), value: String(i.value), note: i.note ?? '' })),
|
|
51
|
+
};
|
|
52
|
+
return { ...base, text: p.text ?? '' }; // code
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Derive the run result for the stage. The `simple` panels are the calm
|
|
57
|
+
* default: they become a tab strip (when >1) plus the single ACTIVE panel
|
|
58
|
+
* body. The `deep` panels — the engine's rich explainers — stay hidden
|
|
59
|
+
* until the student toggles the drill-down (`state.play.deep`); then they
|
|
60
|
+
* form their OWN tab row (`deepPick` selects among several). Each active
|
|
61
|
+
* panel is the one the host asked for when it still exists in this result,
|
|
62
|
+
* else the first — so a fresh result with different screens never strands
|
|
63
|
+
* the view on a tab that is gone.
|
|
64
|
+
*/
|
|
65
|
+
function deriveResult(r, wantedId, deepWanted, deepPick) {
|
|
66
|
+
const all = Array.isArray(r.panels) ? r.panels : [];
|
|
67
|
+
const panels = all.filter((p) => p.depth !== 'deep');
|
|
68
|
+
const deep = all.filter((p) => p.depth === 'deep');
|
|
69
|
+
const activeId = panels.some((p) => p.id === wantedId) ? wantedId : (panels[0]?.id ?? null);
|
|
70
|
+
const active = panels.find((p) => p.id === activeId) ?? null;
|
|
71
|
+
const hasDeep = deep.length > 0;
|
|
72
|
+
const deepOn = hasDeep && deepWanted === true;
|
|
73
|
+
const deepId = deep.some((p) => p.id === deepPick) ? deepPick : (deep[0]?.id ?? null);
|
|
74
|
+
const activeDeep = deep.find((p) => p.id === deepId) ?? null;
|
|
75
|
+
return {
|
|
76
|
+
ran: true,
|
|
77
|
+
ok: r.ok === true,
|
|
78
|
+
error: r.error ? { code: r.error.code ?? '', message: r.error.message ?? '' } : null,
|
|
79
|
+
timing: formatTiming(r.timing),
|
|
80
|
+
tabbed: panels.length > 1,
|
|
81
|
+
tabs: panels.map((p) => ({ id: p.id, label: p.label ?? p.id, active: p.id === activeId })),
|
|
82
|
+
activePanel: active ? shapePanel(active) : null,
|
|
83
|
+
// the drill-deeper half: the affordance, its state, and the deep screens
|
|
84
|
+
hasDeep,
|
|
85
|
+
deepOn,
|
|
86
|
+
deepNext: !deepOn,
|
|
87
|
+
deepLabel: deepOn ? 'Explain ▾' : 'Explain ▸',
|
|
88
|
+
deepTabbed: deep.length > 1,
|
|
89
|
+
deepTabs: deep.map((p) => ({ id: p.id, label: p.label ?? p.id, active: p.id === deepId })),
|
|
90
|
+
deepPanel: deepOn && activeDeep ? shapePanel(activeDeep) : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {{ play?: any }} state
|
|
96
|
+
* @returns {any}
|
|
97
|
+
*/
|
|
98
|
+
export function playViewModel(state) {
|
|
99
|
+
const s = state.play ?? {};
|
|
100
|
+
const engineId = s.engine ?? EXAMPLES[0]?.engine ?? '';
|
|
101
|
+
const engine = ENGINES[engineId] ?? null;
|
|
102
|
+
|
|
103
|
+
// the rail: examples grouped by engine (a folder per engine)
|
|
104
|
+
const groups = new Map();
|
|
105
|
+
for (const ex of EXAMPLES) {
|
|
106
|
+
if (!groups.has(ex.engine)) groups.set(ex.engine, []);
|
|
107
|
+
groups.get(ex.engine).push(ex);
|
|
108
|
+
}
|
|
109
|
+
const rail = [...groups.entries()].map(([id, exs]) => ({
|
|
110
|
+
engine: id,
|
|
111
|
+
label: ENGINES[id]?.label ?? id,
|
|
112
|
+
active: id === engineId,
|
|
113
|
+
examples: exs.map((ex) => ({ id: ex.id, label: ex.label, active: ex.id === s.exampleId })),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const sourcePanes = (engine?.sourcePanes ?? []).map((p) => ({
|
|
117
|
+
key: p.key, label: p.label, control: p.control ?? 'code', value: s.source?.[p.key] ?? '',
|
|
118
|
+
}));
|
|
119
|
+
const dataPanes = (engine?.dataPanes ?? []).map((p) => ({
|
|
120
|
+
key: p.key, label: p.label, value: s.data?.[p.key] ?? '',
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
// option panes: live mode selects (josl dialect, csv repair/headers/…);
|
|
124
|
+
// the selected value is the host config override or the pane's default
|
|
125
|
+
const config = s.config ?? {};
|
|
126
|
+
const optionPanes = (engine?.optionPanes ?? []).map((p) => ({
|
|
127
|
+
key: p.key, label: p.label,
|
|
128
|
+
choices: p.choices.map((c) => ({ value: c.value, label: c.label, selected: (config[p.key] ?? p.default) === c.value })),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const active = EXAMPLES.find((e) => e.id === s.exampleId) ?? null;
|
|
132
|
+
const datasetIndex = s.datasetIndex ?? 0;
|
|
133
|
+
const datasets = (active?.datasets ?? []).map((ds, i) => ({ index: i, label: ds.label, active: i === datasetIndex }));
|
|
134
|
+
|
|
135
|
+
const r = s.result ?? null;
|
|
136
|
+
const result = r === null ? { ran: false } : deriveResult(r, s.panel, s.deep, s.deepPick);
|
|
137
|
+
|
|
138
|
+
// the IDE half: the saveable-session chrome
|
|
139
|
+
const names = Array.isArray(s.names) ? s.names : [];
|
|
140
|
+
const ratio = typeof s.ratio === 'number' ? s.ratio : 0.5;
|
|
141
|
+
|
|
142
|
+
// the generated-form half: the validate engine's data pane can
|
|
143
|
+
// swap the JSON textarea for a schema-generated form. The form tree itself
|
|
144
|
+
// (`dataForm`) is host-supplied (it needs @jarenjs/forms) — the package only
|
|
145
|
+
// decides WHEN to show it.
|
|
146
|
+
const hasForm = engineId === 'validate';
|
|
147
|
+
const dataView = s.dataView === 'form' ? 'form' : 'json';
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
engine: { id: engineId, label: engine?.label ?? engineId, lead: engine?.lead ?? '' },
|
|
151
|
+
active: active ? { id: active.id, label: active.label } : null,
|
|
152
|
+
rail,
|
|
153
|
+
optionPanes,
|
|
154
|
+
sourcePanes,
|
|
155
|
+
dataPanes,
|
|
156
|
+
datasets,
|
|
157
|
+
hasSwitcher: datasets.length >= 2,
|
|
158
|
+
result,
|
|
159
|
+
// IDE chrome: the session name, the saved list, the last share status,
|
|
160
|
+
// and the editor|result split (the shared splitter reads `ratio`)
|
|
161
|
+
name: s.name ?? '',
|
|
162
|
+
names: names.map((n) => ({ name: n, active: n === s.name })),
|
|
163
|
+
hasSaved: names.length > 0,
|
|
164
|
+
// the record this session is bound to, and whether the title has been
|
|
165
|
+
// edited away from it. Without this the two save buttons look
|
|
166
|
+
// identical: the hint is what tells the reader that Save lands on the
|
|
167
|
+
// record they opened and Save As lands on the name they just typed.
|
|
168
|
+
savedName: s.savedName ?? null,
|
|
169
|
+
renamed: s.savedName != null && (s.name ?? '') !== s.savedName,
|
|
170
|
+
shared: s.shared ?? null,
|
|
171
|
+
ratio,
|
|
172
|
+
ratioPct: String(Math.round(ratio * 100)),
|
|
173
|
+
// the form/JSON toggle (validate only); `showForm` gates the form seam
|
|
174
|
+
hasForm,
|
|
175
|
+
dataView,
|
|
176
|
+
showForm: hasForm && dataView === 'form',
|
|
177
|
+
// the phone layout: one pane at a time behind a segmented switcher
|
|
178
|
+
// (Examples · Editor · Result); desktop ignores it (CSS)
|
|
179
|
+
mobilePane: pickAllowed(s.mobilePane, MOBILE_PANES, 'editor'),
|
|
180
|
+
};
|
|
181
|
+
}
|