@jarenjs/studio 0.83.3 → 0.85.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 +21 -6
- package/contracts/data.contract.json +243 -0
- package/dist/types/component/data/actions.d.ts +234 -0
- package/dist/types/component/data/index.d.ts +9 -0
- package/dist/types/component/data/mount.d.ts +87 -0
- package/dist/types/component/data/project-widget.d.ts +27 -0
- package/dist/types/component/data/viewmodel.d.ts +85 -0
- package/dist/types/component/data/views.d.ts +148 -0
- package/dist/types/component/document.d.ts +82 -0
- package/dist/types/component/flow/actions.d.ts +708 -0
- package/dist/types/component/flow/index.d.ts +7 -0
- package/dist/types/component/flow/mount.d.ts +66 -0
- package/dist/types/component/flow/project-widget.d.ts +8 -0
- package/dist/types/component/flow/runtime.d.ts +57 -0
- package/dist/types/component/flow/views.d.ts +242 -0
- package/dist/types/component/host.d.ts +2 -2
- package/dist/types/component/index.d.ts +21 -15
- package/dist/types/component/mount.d.ts +51 -0
- package/dist/types/component/project-actions.d.ts +302 -0
- package/dist/types/component/project-controller.d.ts +81 -0
- package/dist/types/component/project-state.d.ts +1 -0
- package/dist/types/component/project.d.ts +327 -0
- package/dist/types/component/shared/host-widget.d.ts +24 -0
- package/dist/types/component/shared/memo.d.ts +12 -0
- package/dist/types/component/shared/nodes.d.ts +92 -0
- package/dist/types/component/shared/schema-options.d.ts +28 -0
- package/dist/types/component/shared/studio-kit.d.ts +78 -0
- package/dist/types/component/shared/ui.d.ts +136 -0
- package/dist/types/component/view.d.ts +6 -6
- package/dist/types/data/boot-stages.d.ts +97 -0
- package/dist/types/data/browser-worker.d.ts +22 -0
- package/dist/types/data/contract.d.ts +9 -0
- package/dist/types/data/editor.d.ts +81 -0
- package/dist/types/data/handlers.d.ts +130 -0
- package/dist/types/data/host.d.ts +8 -0
- package/dist/types/data/project-worker.d.ts +8 -0
- package/dist/types/data/runtime.d.ts +46 -0
- package/dist/types/data/state.d.ts +33 -0
- package/dist/types/data/storage.d.ts +26 -0
- package/dist/types/data/transport.d.ts +69 -0
- package/dist/types/flow-document.d.ts +19 -0
- package/dist/types/flow-editor.d.ts +88 -0
- package/docs/EDITORS.md +158 -0
- package/docs/PROJECT-FORMAT.md +1 -11
- package/package.json +32 -15
- package/src/component/data/actions.js +138 -0
- package/src/component/data/index.js +10 -0
- package/src/component/data/mount.js +63 -0
- package/src/component/data/project-widget.js +164 -0
- package/src/component/data/viewmodel.js +185 -0
- package/src/component/data/views.js +241 -0
- package/src/component/document.js +344 -0
- package/src/component/flow/actions.js +331 -0
- package/src/component/flow/index.js +8 -0
- package/src/component/flow/mount.js +53 -0
- package/src/component/flow/project-widget.js +44 -0
- package/src/component/flow/runtime.js +481 -0
- package/src/component/flow/views.js +196 -0
- package/src/component/host.js +2 -2
- package/src/component/index.js +19 -9
- package/src/component/mount.js +43 -0
- package/src/component/project-actions.js +189 -0
- package/src/component/project-controller.js +248 -0
- package/src/component/project-state.js +30 -0
- package/src/component/project.js +308 -0
- package/src/component/shared/host-widget.js +35 -0
- package/src/component/shared/memo.js +28 -0
- package/src/component/shared/nodes.js +94 -0
- package/src/component/shared/schema-options.js +30 -0
- package/src/component/shared/studio-kit.js +59 -0
- package/src/component/shared/ui.js +134 -0
- package/src/data/boot-stages.js +202 -0
- package/src/data/browser-worker.js +247 -0
- package/src/data/contract.js +7 -0
- package/src/data/editor.js +95 -0
- package/src/data/handlers.js +349 -0
- package/src/data/host.js +8 -0
- package/src/data/project-worker.js +26 -0
- package/src/data/runtime.js +462 -0
- package/src/data/state.js +46 -0
- package/src/data/storage.js +61 -0
- package/src/data/transport.js +215 -0
- package/src/flow-document.js +24 -0
- package/src/flow-editor.js +98 -0
- package/styles/data.css +53 -0
- package/styles/editor.css +150 -0
- package/styles/flow.css +99 -0
- package/styles/studio.css +1 -0
- package/dist/types/author.d.ts +0 -27
- package/src/author.js +0 -55
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The data studio — mode 'data', dispatched with `$.ui.data` (derived
|
|
4
|
+
* by boundaries/data.js — the boundary-exports convention). The THIRD
|
|
5
|
+
* studio, built on the extracted kit: both editors are the kit's
|
|
6
|
+
* `editorTextarea`, errors are its `errorLine`. Left: the model
|
|
7
|
+
* document and the store's honest status (topology, VFS, durability).
|
|
8
|
+
* Middle: the query document with results and `explain()` — the
|
|
9
|
+
* pushdown made visible. Right: a live query maintaining as rows are
|
|
10
|
+
* inserted, and the worked migration with its shadow verification.
|
|
11
|
+
* Fourth: the spatial round trip — CSV to a map through the stylesheet,
|
|
12
|
+
* the meta-schema, a throwaway store with derived spatial indexes, a
|
|
13
|
+
* linq `$within` and its `explain()`, in this tab.
|
|
14
|
+
*
|
|
15
|
+
* Below the breakpoint those four become one pane at a time behind the
|
|
16
|
+
* shared segmented switcher (Store · Query · Live · Round trip),
|
|
17
|
+
* defaulting to the query — the pane a reader of this page came for.
|
|
18
|
+
*/
|
|
19
|
+
import { editorTextarea, errorLine, paneSwitcher } from '../shared/studio-kit.js';
|
|
20
|
+
|
|
21
|
+
const ownerDisabled = { $or: [{ $ne: ['$.topology', 'owner'] }, { $ne: ['$.status', 'ready'] }] };
|
|
22
|
+
const ownerReason = { $if: [{ $ne: ['$.topology', 'owner'] },
|
|
23
|
+
['p', { class: 'muted data-owner-reason' }, 'Only the owning tab can recreate or migrate the store.']] };
|
|
24
|
+
|
|
25
|
+
const statusCard =
|
|
26
|
+
['div', { class: 'card pg-card data-status' },
|
|
27
|
+
['h3', {}, 'Store'],
|
|
28
|
+
['p', { class: 'data-badges' },
|
|
29
|
+
['span', { class: 'badge' }, 'topology: ', ['strong', { class: 'data-topology' }, '$.topology']],
|
|
30
|
+
['span', { class: 'badge' }, 'vfs: ', ['strong', { class: 'data-vfs' }, '$.vfs']],
|
|
31
|
+
['span', { class: 'badge' }, 'capture: ', ['strong', {}, '$.capture']],
|
|
32
|
+
['span', { class: 'badge' }, 'sqlite ', '$.version'],
|
|
33
|
+
['span', { class: 'badge' }, 'operators: ', ['strong', { class: 'data-operators' }, '$.operatorSummary']],
|
|
34
|
+
],
|
|
35
|
+
['p', { class: 'muted data-durability' }, '$.durability'],
|
|
36
|
+
{ $if: ['$.oracleAvailable', ['details', { class: 'details-card data-oracle' },
|
|
37
|
+
['summary', {}, 'Spatial agreement \u2014 the same plans as Node'],
|
|
38
|
+
['p', { class: 'muted' },
|
|
39
|
+
'This store runs the same spatial plans as Node. The committed spatial corpus \u2014 the entries the JavaScript engine recorded its answers for \u2014 runs here through SQLite compiled to wasm, one throwaway in-memory store per entry, with and without the derived spatial indexes, and every answer is compared with the recorded one. The same corpus holds the Node driver to the engine in the test suite; this tab is the third executor, and the test suite drives this button in Chromium, Firefox and WebKit. It proves execution, not durability: where OPFS is absent the store is in-memory, which is a property of the host, not of the suite.'],
|
|
40
|
+
['button', { class: 'btn small data-oracle-run', type: 'button', on: { click: 'data/oracle-run' } },
|
|
41
|
+
'Run the spatial corpus'],
|
|
42
|
+
{ $if: ['$.oracle',
|
|
43
|
+
['div', { class: 'data-oracle-report', 'data-status': '$.oracle.status' },
|
|
44
|
+
['p', { class: 'data-oracle-summary' }, '$.oracleSummary'],
|
|
45
|
+
{ $if: ['$.oracleDisagreed',
|
|
46
|
+
['pre', { class: 'code-block clamp data-oracle-disagreements' },
|
|
47
|
+
['code', {}, '$.oracleDisagreements']]] },
|
|
48
|
+
{ $if: ['$.oracleDone',
|
|
49
|
+
['details', { class: 'details-card' },
|
|
50
|
+
['summary', {}, 'Every answer, as JSON'],
|
|
51
|
+
['pre', { class: 'code-block clamp data-oracle-results' },
|
|
52
|
+
['code', {}, '$.oracleResultsJson']]]] },
|
|
53
|
+
]] },
|
|
54
|
+
]] },
|
|
55
|
+
['details', { class: 'details-card' },
|
|
56
|
+
['summary', {}, 'Registered operators (host opt-in)'],
|
|
57
|
+
['p', { class: 'muted' }, 'This studio mounts the math / finance / stats packs, so queries here may use $sqrt, $npv, $mean, $stddev, $percentile and more. The pushable-scalar subset (math) runs inside SQLite as deterministic UDFs; finance and stats fold a series in the query residual — explain() shows which.'],
|
|
58
|
+
['pre', { class: 'code-block clamp' }, ['code', {}, '$.operatorList']],
|
|
59
|
+
],
|
|
60
|
+
{ $if: ['$.refusal',
|
|
61
|
+
['p', { class: 'muted data-refusal' },
|
|
62
|
+
['code', {}, '$.refusal.code'], ' — ', '$.refusal.message']] },
|
|
63
|
+
['details', { class: 'details-card', open: true },
|
|
64
|
+
['summary', {}, 'Model (jaren-model JSON)'],
|
|
65
|
+
editorTextarea({ value: '$.modelText', action: 'data/model-text', rows: 14, live: true }),
|
|
66
|
+
['button', { class: 'btn small', type: 'button', disabled: ownerDisabled, on: { click: 'data/open' } },
|
|
67
|
+
'Recreate store from model'],
|
|
68
|
+
ownerReason,
|
|
69
|
+
['p', { class: 'muted' },
|
|
70
|
+
'Recreating unlinks the database the owning tab holds, so only that tab can do it: a client tab is refused, with the store\u2019s own coded message.'],
|
|
71
|
+
],
|
|
72
|
+
['details', { class: 'details-card', open: true },
|
|
73
|
+
['summary', {}, 'Documents'],
|
|
74
|
+
['p', { class: 'muted data-row-summary' }, '$.rowSummary'],
|
|
75
|
+
['ul', { class: 'data-rows' }, [{ $apply: '$.rowList[*]' }]],
|
|
76
|
+
],
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** One stored document: what it holds, and the delete the live pane
|
|
80
|
+
* shows arriving as an RFC 6902 remove. A row whose key pointer found
|
|
81
|
+
* nothing carries no control — there would be nothing to address. */
|
|
82
|
+
const documentRow = {
|
|
83
|
+
match: '$.ui.data.rowList[*]', mode: 'data',
|
|
84
|
+
body: ['li', { class: 'data-row' },
|
|
85
|
+
['code', {}, '$.text'],
|
|
86
|
+
{ $if: [{ $exists: '$.key' },
|
|
87
|
+
['button', {
|
|
88
|
+
type: 'button', class: 'btn small data-row-delete', title: 'Delete this document',
|
|
89
|
+
on: { click: { action: 'data/delete', with: '$.key' } },
|
|
90
|
+
}, '\u00d7']] },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const queryCard =
|
|
95
|
+
['div', { class: 'card pg-card data-query' },
|
|
96
|
+
['h3', {}, 'Query'],
|
|
97
|
+
editorTextarea({ value: '$.queryText', action: 'data/query-text', rows: 8, live: true }),
|
|
98
|
+
['p', {},
|
|
99
|
+
['button', {
|
|
100
|
+
class: 'btn small primary', type: 'button',
|
|
101
|
+
disabled: { $ne: ['$.status', 'ready'] }, on: { click: 'data/run' },
|
|
102
|
+
},
|
|
103
|
+
'Run + explain'],
|
|
104
|
+
// every keystroke is published, then Enter or blur commits: this
|
|
105
|
+
// page re-renders on each live-query event, and a controlled input
|
|
106
|
+
// whose buffer is not in state is reset by that render mid-typing
|
|
107
|
+
['input', {
|
|
108
|
+
class: 'data-insert-title', type: 'text',
|
|
109
|
+
value: '$.insertDraft', placeholder: '$.insertPlaceholder',
|
|
110
|
+
disabled: { $ne: ['$.status', 'ready'] },
|
|
111
|
+
on: { input: 'data/insert-draft', change: 'data/insert' },
|
|
112
|
+
}]],
|
|
113
|
+
{ $if: ['$.explain',
|
|
114
|
+
['details', { class: 'details-card', open: true },
|
|
115
|
+
['summary', {}, 'explain() — the pushdown, visible'],
|
|
116
|
+
['pre', { class: 'code-block data-explain-sql' }, ['code', {}, '$.explain.sql']],
|
|
117
|
+
['p', { class: 'muted' }, 'params: ', ['code', {}, '$.explain.params']],
|
|
118
|
+
['p', { class: 'muted' }, 'indexes: ', ['code', { class: 'data-explain-indexes' }, '$.explain.indexes']],
|
|
119
|
+
['p', { class: 'muted' }, 'residual: ', ['code', {}, '$.explain.residual']],
|
|
120
|
+
]] },
|
|
121
|
+
['details', { class: 'details-card', open: true },
|
|
122
|
+
['summary', {}, 'Results'],
|
|
123
|
+
['pre', { class: 'code-block clamp data-results' }, ['code', {}, '$.resultsJson']],
|
|
124
|
+
],
|
|
125
|
+
['details', { class: 'details-card' },
|
|
126
|
+
['summary', {}, 'Try a registered operator'],
|
|
127
|
+
['p', { class: 'muted' }, 'The sqrt operator is a pushable scalar — paste this, Run, and watch explain() show a jaren_p_ UDF in the SQL (SQLite does the filtering). A finance or stats operator, such as mean over a series, runs in the residual instead, and explain() names it.'],
|
|
128
|
+
['pre', { class: 'code-block' }, ['code', {}, '$.operatorSample']],
|
|
129
|
+
],
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const liveCard =
|
|
133
|
+
['div', { class: 'card pg-card data-live' },
|
|
134
|
+
['h3', {}, 'Live query'],
|
|
135
|
+
{ $if: ['$.liveNote', ['p', { class: 'muted data-live-note' }, '$.liveNote']] },
|
|
136
|
+
['p', { class: 'muted' },
|
|
137
|
+
'A subscribe operation over the contract stream binding: the snapshot, then RFC 6902 patches as writes commit — one diff format end to end.'],
|
|
138
|
+
['p', { class: 'data-live-count' }, '$.liveSummary'],
|
|
139
|
+
['p', { class: 'muted data-live-regs' }, 'live registrations on the store: ', ['strong', {}, '$.liveRegs']],
|
|
140
|
+
['pre', { class: 'code-block clamp data-live-rows' }, ['code', {}, '$.liveJson']],
|
|
141
|
+
['h3', {}, 'Migration'],
|
|
142
|
+
['p', { class: 'muted' },
|
|
143
|
+
'Plan a model change, verify it on a SHADOW database, apply it — in the browser.'],
|
|
144
|
+
{ $if: ['$.migrationAvailable', ['div', {},
|
|
145
|
+
['button', { class: 'btn small', type: 'button', disabled: ownerDisabled, on: { click: 'data/migrate' } },
|
|
146
|
+
'Add a title index (plan → shadow → apply)'],
|
|
147
|
+
ownerReason,
|
|
148
|
+
]] },
|
|
149
|
+
{ $if: ['$.migration',
|
|
150
|
+
['div', { class: 'data-migration' },
|
|
151
|
+
['p', {}, 'planned steps:'],
|
|
152
|
+
['pre', { class: 'code-block clamp data-migration-steps' },
|
|
153
|
+
['code', {}, '$.migrationSteps']],
|
|
154
|
+
['p', { class: 'data-migration-applied' }, '$.migrationSummary'],
|
|
155
|
+
]] },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The round trip, where a reader can watch it: every stage is a
|
|
160
|
+
* document the page prints, the store stage is explained beside its
|
|
161
|
+
* answer, and the answer is drawn. The map is what the `geojson`
|
|
162
|
+
* format's preview hint asks a host for; this host has a renderer.
|
|
163
|
+
*/
|
|
164
|
+
const tripCard =
|
|
165
|
+
['div', { class: 'card pg-card data-trip' },
|
|
166
|
+
['h3', {}, 'Round trip'],
|
|
167
|
+
['p', { class: 'muted' },
|
|
168
|
+
'CSV in, a map out, in this tab. The rows become a FeatureCollection through a JSLT stylesheet, the collection is judged by the GeoJSON meta-schema, stored in a throwaway collection with derived spatial indexes, queried with a $within written through @jarenjs/linq, and explain() shows the two-stage plan \u2014 the bounding box the index seeks and the exact refinement the engine keeps \u2014 before the result is drawn.'],
|
|
169
|
+
editorTextarea({ value: '$.tripCsv', action: 'data/trip-csv', rows: 7, live: true }),
|
|
170
|
+
['p', {},
|
|
171
|
+
['button', { class: 'btn small primary data-trip-run', type: 'button', on: { click: 'data/trip-run' } },
|
|
172
|
+
'Run the round trip']],
|
|
173
|
+
{ $if: ['$.trip',
|
|
174
|
+
['div', { class: 'data-trip-report', 'data-status': '$.tripStatus' },
|
|
175
|
+
['p', { class: 'data-trip-summary' }, '$.tripSummary'],
|
|
176
|
+
{ $if: ['$.tripDone',
|
|
177
|
+
['div', {},
|
|
178
|
+
['details', { class: 'details-card', open: true },
|
|
179
|
+
['summary', {}, 'explain() \u2014 the two-stage spatial plan'],
|
|
180
|
+
['pre', { class: 'code-block data-trip-sql' }, ['code', {}, '$.tripExplainSql']],
|
|
181
|
+
['p', { class: 'muted' }, 'pre-filters: ', ['code', { class: 'data-trip-prefilters' }, '$.tripPrefilters']],
|
|
182
|
+
['p', { class: 'muted' }, 'scan narrative: ', ['code', { class: 'data-trip-narrative' }, '$.tripNarrative']],
|
|
183
|
+
['p', { class: 'muted' }, 'indexes: ', ['code', { class: 'data-trip-indexes' }, '$.tripIndexes']],
|
|
184
|
+
['p', { class: 'muted' }, 'residual: ', ['code', { class: 'data-trip-residual' }, '$.tripResidual']],
|
|
185
|
+
],
|
|
186
|
+
{ $if: ['$.tripMap', ['div', { class: 'chart-card data-trip-map' }, '$.tripMap']] },
|
|
187
|
+
['details', { class: 'details-card' },
|
|
188
|
+
['summary', {}, 'The result \u2014 the features inside the region, as stored'],
|
|
189
|
+
['pre', { class: 'code-block clamp data-trip-results' }, ['code', {}, '$.tripResultsJson']]],
|
|
190
|
+
]] },
|
|
191
|
+
]] },
|
|
192
|
+
['details', { class: 'details-card' },
|
|
193
|
+
['summary', {}, 'The stylesheet \u2014 CSV rows to a FeatureCollection, no code'],
|
|
194
|
+
['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripStylesheet']]],
|
|
195
|
+
['details', { class: 'details-card' },
|
|
196
|
+
['summary', {}, 'The model \u2014 derived spatial indexes over the geometry'],
|
|
197
|
+
['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripModel']]],
|
|
198
|
+
['details', { class: 'details-card' },
|
|
199
|
+
['summary', {}, 'The query \u2014 written through @jarenjs/linq'],
|
|
200
|
+
['pre', { class: 'code-block' }, ['code', {}, '$.tripChain']],
|
|
201
|
+
['p', { class: 'muted' }, 'emits this document, with the region bound as an external:'],
|
|
202
|
+
['pre', { class: 'code-block data-trip-query' }, ['code', {}, '$.tripQuery']],
|
|
203
|
+
['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripRegion']]],
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
const dataPaneSwitcher = paneSwitcher({
|
|
207
|
+
class: 'data-panebar', pane: '$.mobilePane', action: 'data/pane',
|
|
208
|
+
panes: [['store', 'Store'], ['query', 'Query'], ['live', 'Live'], ['trip', 'Round trip']],
|
|
209
|
+
});
|
|
210
|
+
dataPaneSwitcher[dataPaneSwitcher.length - 1] = { $if: ['$.tripAvailable', dataPaneSwitcher.at(-1)] };
|
|
211
|
+
|
|
212
|
+
export const DATA_RULES = [
|
|
213
|
+
documentRow,
|
|
214
|
+
{
|
|
215
|
+
match: '$.ui.data', mode: 'data',
|
|
216
|
+
// `container` for the gutters the other studios have: without it this
|
|
217
|
+
// page ran edge-to-edge, into the notch, at every width
|
|
218
|
+
body: ['section', { class: 'jaren-data-editor' },
|
|
219
|
+
{ $if: [{ $eq: ['$.status', 'boot'] },
|
|
220
|
+
['p', { class: 'muted data-booting' }, 'Loading the SQLite wasm build…']] },
|
|
221
|
+
// the boot's terminal failure: the stage that failed is what a
|
|
222
|
+
// reader can report, and the retry starts the protocol over
|
|
223
|
+
{ $if: [{ $eq: ['$.status', 'error'] },
|
|
224
|
+
['div', { class: 'card pg-card data-boot-error', role: 'alert' },
|
|
225
|
+
['p', {},
|
|
226
|
+
['strong', {}, 'The store did not boot.'], ' Stage ',
|
|
227
|
+
['code', { class: 'data-boot-stage' }, '$.boot.stage'], ' failed: ',
|
|
228
|
+
['span', { class: 'data-boot-message' }, '$.boot.message']],
|
|
229
|
+
['button', { class: 'btn small data-boot-retry', type: 'button', on: { click: 'data/retry' } },
|
|
230
|
+
'Retry the boot']]] },
|
|
231
|
+
{ $if: ['$.plainError', errorLine('$.plainError')] },
|
|
232
|
+
['div', { class: 'data-grid', 'data-pane': '$.mobilePane' },
|
|
233
|
+
dataPaneSwitcher,
|
|
234
|
+
statusCard,
|
|
235
|
+
queryCard,
|
|
236
|
+
liveCard,
|
|
237
|
+
{ $if: ['$.tripAvailable', tripCard] },
|
|
238
|
+
],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
];
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Validate, audit and mount isolated app documents with explicit render services. */
|
|
3
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
4
|
+
import { jsonFormats } from '@jarenjs/formats';
|
|
5
|
+
import { OUR_SCHEMA_OPTIONS } from './shared/schema-options.js';
|
|
6
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query';
|
|
7
|
+
import { createApp, createFormView, formEventFields } from '@jarenjs/app';
|
|
8
|
+
import { createDomRenderer } from '@jarenjs/view';
|
|
9
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
10
|
+
import { buildFormModel, buildFormViewModel } from '@jarenjs/forms';
|
|
11
|
+
import { compileChart } from '@jarenjs/charts';
|
|
12
|
+
import { errorMessage } from './shared/nodes.js';
|
|
13
|
+
import appSchema from '@jarenjs/app/schemas/jaren-app.schema.json' with { type: 'json' };
|
|
14
|
+
import querySchema from '@jarenjs/json/schemas/jaren-query.schema.json' with { type: 'json' };
|
|
15
|
+
import jsltSchema from '@jarenjs/json/schemas/jaren-jslt.schema.json' with { type: 'json' };
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** @param {{ markdown: (source: string) => any, diagram: (source: string) => any, templates?: any[] }} options */
|
|
20
|
+
export function createStudioDocumentHost(options) {
|
|
21
|
+
const { markdown, diagram, templates: STUDIO_TEMPLATES = [] } = options;
|
|
22
|
+
/** Schema errors kept per report: enough to repair, bounded for state. */
|
|
23
|
+
const MAX_ERRORS = 20;
|
|
24
|
+
|
|
25
|
+
// the meta-schema composes the published query and JSLT grammars by
|
|
26
|
+
// reference — register those artifacts alongside it (APP-FORMAT §2)
|
|
27
|
+
const validateApp = new JarenValidator(OUR_SCHEMA_OPTIONS)
|
|
28
|
+
.addFormats(jsonFormats)
|
|
29
|
+
.addSchema(querySchema)
|
|
30
|
+
.addSchema(jsltSchema)
|
|
31
|
+
.compile(appSchema);
|
|
32
|
+
|
|
33
|
+
const message = errorMessage;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a candidate document against the jaren-app meta-schema.
|
|
37
|
+
* @param {any} doc
|
|
38
|
+
* @returns {{ valid: boolean, errors: Array<{ instancePath: string, keyword: string, message: string }>, total: number }}
|
|
39
|
+
*/
|
|
40
|
+
function validateAppDocument(doc) {
|
|
41
|
+
const outcome = validateApp(doc);
|
|
42
|
+
const valid = typeof outcome === 'object' && outcome !== null ? outcome.valid : outcome === true;
|
|
43
|
+
if (valid) return { valid: true, errors: [], total: 0 };
|
|
44
|
+
const raw = (typeof outcome === 'object' && outcome !== null ? outcome.errors : null) ?? [];
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
total: raw.length,
|
|
48
|
+
errors: raw.slice(0, MAX_ERRORS).map((e) => ({
|
|
49
|
+
instancePath: e.instancePath ?? '',
|
|
50
|
+
keyword: e.keyword ?? '',
|
|
51
|
+
message: e.message ?? 'invalid',
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//#region the document's render-capability widgets
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A pure props→vnode widget: its own mini renderer into the host, and
|
|
60
|
+
* every `on` binding inside the projection emits back into the hosting
|
|
61
|
+
* app's dispatch (VIEW-FORMAT §7) — the widget renders, the document's
|
|
62
|
+
* actions own the state transitions.
|
|
63
|
+
* @param {(props: any) => any} view
|
|
64
|
+
* @returns {{ mount: (host: HTMLElement, props: any, emit: (binding: any, event: Event) => void) => { render: ReturnType<typeof createDomRenderer> }, update: (handle: { render: ReturnType<typeof createDomRenderer> }, props: any) => void, unmount: (handle: { render: ReturnType<typeof createDomRenderer> }) => void }}
|
|
65
|
+
*/
|
|
66
|
+
function vnodeWidget(view) {
|
|
67
|
+
return {
|
|
68
|
+
mount(host, props, emit) {
|
|
69
|
+
const render = createDomRenderer(host, {
|
|
70
|
+
document: host.ownerDocument,
|
|
71
|
+
onEvent: (binding, event) => emit(binding, event),
|
|
72
|
+
});
|
|
73
|
+
render(view(props));
|
|
74
|
+
return { render };
|
|
75
|
+
},
|
|
76
|
+
update(handle, props) {
|
|
77
|
+
handle.render(view(props));
|
|
78
|
+
},
|
|
79
|
+
unmount(handle) {
|
|
80
|
+
handle.render.destroy?.();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// the standard forms stylesheet, compiled once: the form widget renders
|
|
86
|
+
// any `buildFormViewModel` tree; its bindings dispatch the standard
|
|
87
|
+
// form actions the document embeds (createFormActions output is JSON)
|
|
88
|
+
const formStylesheet = compileJsltStylesheet({
|
|
89
|
+
$jslt: '0.1',
|
|
90
|
+
rules: [
|
|
91
|
+
{ match: '$', body: { $apply: '$.form' } },
|
|
92
|
+
...createFormView(),
|
|
93
|
+
],
|
|
94
|
+
}, { memo: true });
|
|
95
|
+
|
|
96
|
+
/** Form models memoized per schema value identity (state is immutable). */
|
|
97
|
+
const formModels = new WeakMap();
|
|
98
|
+
|
|
99
|
+
function formVnode(props) {
|
|
100
|
+
try {
|
|
101
|
+
const schema = props?.schema;
|
|
102
|
+
if (schema === null || typeof schema !== 'object') {
|
|
103
|
+
return ['p', { class: 'error-line' }, 'form: props.schema must be a JSON Schema object'];
|
|
104
|
+
}
|
|
105
|
+
let model = formModels.get(schema);
|
|
106
|
+
if (model === undefined) {
|
|
107
|
+
model = buildFormModel(schema);
|
|
108
|
+
formModels.set(schema, model);
|
|
109
|
+
}
|
|
110
|
+
return formStylesheet({
|
|
111
|
+
form: buildFormViewModel(model, props.data ?? null, { validateFields: true }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return ['p', { class: 'error-line' }, `form: ${message(err)}`];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function chartVnode(props) {
|
|
120
|
+
try {
|
|
121
|
+
// inline definitions carry their own data (the charts engine's
|
|
122
|
+
// static path); theme 'host' keeps light/dark live (docs/DESIGN.md §7)
|
|
123
|
+
return ['div', { class: 'studio-chart' },
|
|
124
|
+
compileChart(props.config, props.config, { theme: 'host' }).toVnode()];
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return ['p', { class: 'error-line' }, `chart: ${message(err)}`];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const markdownVnode = (props) => markdown(String(props?.source ?? ''));
|
|
132
|
+
const mermaidVnode = (props) => diagram(String(props?.source ?? ''));
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The render capabilities a studio document may name (and nothing else).
|
|
136
|
+
* A composed host can register the same chart/mermaid/markdown/form widgets
|
|
137
|
+
* in its own view.
|
|
138
|
+
*/
|
|
139
|
+
const STUDIO_WIDGETS = {
|
|
140
|
+
form: vnodeWidget(formVnode),
|
|
141
|
+
chart: vnodeWidget(chartVnode),
|
|
142
|
+
markdown: vnodeWidget(markdownVnode),
|
|
143
|
+
mermaid: vnodeWidget(mermaidVnode),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
|
|
148
|
+
// $valid/$assert/$as and schema matches are compile-time capabilities
|
|
149
|
+
// of the document grammars (pure, no side channel) — granted, unlike
|
|
150
|
+
// effects and subs
|
|
151
|
+
const compileTypeTest = createTypeTestCompiler();
|
|
152
|
+
|
|
153
|
+
//#region the headless render audit
|
|
154
|
+
|
|
155
|
+
/** Render problems reported per audit: enough to repair, bounded. */
|
|
156
|
+
const MAX_RENDER_PROBLEMS = 8;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A tag `createElement` accepts: a letter, then letters, digits or
|
|
160
|
+
* hyphens. One-element arrays like `["hr"]` are valid void elements —
|
|
161
|
+
* the defect class is invalid tag NAMES, not array arity.
|
|
162
|
+
*/
|
|
163
|
+
const VALID_TAG = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Walk one rendered vnode with the renderer's own shape rules (text,
|
|
167
|
+
* skipped, `[tag, props?, ...children]`, non-string-head arrays splice
|
|
168
|
+
* as lists) and collect what a live mount would host: the widgets it
|
|
169
|
+
* names, and the shapes that render as junk even though the meta-schema
|
|
170
|
+
* accepted the document.
|
|
171
|
+
* @param {any} node
|
|
172
|
+
* @param {string} path - a JSON-pointer-ish trail into the rendered tree
|
|
173
|
+
* @param {{ widgets: string[], problems: string[] }} out
|
|
174
|
+
*/
|
|
175
|
+
function walkRenderedVnode(node, path, out) {
|
|
176
|
+
if (out.problems.length >= MAX_RENDER_PROBLEMS) return;
|
|
177
|
+
if (node == null || node === true || node === false) return; // skipped
|
|
178
|
+
if (typeof node === 'string' || typeof node === 'number') return; // text
|
|
179
|
+
if (!Array.isArray(node)) {
|
|
180
|
+
out.problems.push(`${path}: a bare object is not a vnode — it renders as nothing (expected ["tag", props, ...children] or text)`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (typeof node[0] !== 'string') {
|
|
184
|
+
// a list: each item renders in place
|
|
185
|
+
for (let i = 0; i < node.length; i++) walkRenderedVnode(node[i], `${path}/${i}`, out);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!VALID_TAG.test(node[0])) {
|
|
189
|
+
// this is a boot-stopper, not a cosmetic problem: the nested app
|
|
190
|
+
// dies on createElement and the whole stage ends empty
|
|
191
|
+
out.problems.push(`${path}: '${node[0]}' is not a valid element tag name — the app fails to boot on it (text belongs directly in the children, not wrapped in an array)`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (node[0] === 'jaren-widget') {
|
|
195
|
+
const props = (node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? node[1] : {};
|
|
196
|
+
const name = props.name;
|
|
197
|
+
out.widgets.push(String(name));
|
|
198
|
+
if (STUDIO_WIDGETS[name] === undefined) {
|
|
199
|
+
out.problems.push(`${path}: unknown widget '${String(name)}' — available: ${Object.keys(STUDIO_WIDGETS).join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
else if (name === 'form' && (props.props?.schema === null || typeof props.props?.schema !== 'object')) {
|
|
202
|
+
out.problems.push(`${path}: the form widget's props.schema did not resolve to a JSON Schema object — the form renders an error instead of fields`);
|
|
203
|
+
}
|
|
204
|
+
else if (name === 'chart' && (props.props?.config === null || typeof props.props?.config !== 'object')) {
|
|
205
|
+
out.problems.push(`${path}: the chart widget's props.config did not resolve to a chart definition object`);
|
|
206
|
+
}
|
|
207
|
+
if (name === 'form' && typeof props.props?.schema?.title === 'string' && props.props.schema.title !== '') {
|
|
208
|
+
out.formTitles.push(props.props.schema.title);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (node[0] === 'h1' || node[0] === 'h2' || node[0] === 'h3') {
|
|
213
|
+
const start = (node.length > 1 && node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? 2 : 1;
|
|
214
|
+
let text = '';
|
|
215
|
+
for (let i = start; i < node.length; i++) {
|
|
216
|
+
if (typeof node[i] === 'string') text += node[i];
|
|
217
|
+
}
|
|
218
|
+
if (text.trim() !== '') out.headings.push(text.trim());
|
|
219
|
+
}
|
|
220
|
+
const start = (node.length > 1 && node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? 2 : 1;
|
|
221
|
+
for (let i = start; i < node.length; i++) walkRenderedVnode(node[i], `${path}/${i}`, out);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The seed templates' rendered headings, each mapped to the form
|
|
226
|
+
* titles that seed legitimately pairs them with — the reference for
|
|
227
|
+
* the stale-heading note below. Built lazily from the templates
|
|
228
|
+
* themselves so it can never drift from the seed library.
|
|
229
|
+
* @type {Map<string, Set<string>> | null}
|
|
230
|
+
*/
|
|
231
|
+
let seedHeadings = null;
|
|
232
|
+
|
|
233
|
+
function getSeedHeadings() {
|
|
234
|
+
if (seedHeadings === null) {
|
|
235
|
+
seedHeadings = new Map();
|
|
236
|
+
for (const template of STUDIO_TEMPLATES) {
|
|
237
|
+
/** @type {{ widgets: string[], problems: string[], headings: string[], formTitles: string[] }} */
|
|
238
|
+
const out = { widgets: [], problems: [], headings: [], formTitles: [] };
|
|
239
|
+
try {
|
|
240
|
+
walkRenderedVnode(
|
|
241
|
+
compileJsltStylesheet(template.doc.view, { compileTypeTest, memo: false })(template.doc.state),
|
|
242
|
+
'', out);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
continue; // a seed that fails to render simply contributes nothing
|
|
246
|
+
}
|
|
247
|
+
for (const heading of out.headings) {
|
|
248
|
+
const titles = seedHeadings.get(heading) ?? new Set();
|
|
249
|
+
for (const title of out.formTitles) titles.add(title);
|
|
250
|
+
seedHeadings.set(heading, titles);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return seedHeadings;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Render the document's first frame headlessly — the same stylesheet
|
|
259
|
+
* compiler and state the live host boots with — and report the widgets
|
|
260
|
+
* it hosts plus any render problems the meta-schema cannot see. "It
|
|
261
|
+
* validates" is not "it renders": a valid document can still name an
|
|
262
|
+
* unknown widget, hand the form widget a non-object schema, or place a
|
|
263
|
+
* bare object where a vnode belongs.
|
|
264
|
+
*
|
|
265
|
+
* `notes` are soft semantic observations, not failures — today one
|
|
266
|
+
* rule: a SEED template's heading still on screen while the form's
|
|
267
|
+
* schema title has moved on (the repurposed-template leftover). The
|
|
268
|
+
* rule deliberately keys on the seed headings so a pristine template,
|
|
269
|
+
* or an authored heading of the user's own, never trips it.
|
|
270
|
+
* @param {any} doc
|
|
271
|
+
* @returns {{ widgets: string[], problems: string[], notes: string[] }}
|
|
272
|
+
*/
|
|
273
|
+
function auditDocumentRender(doc) {
|
|
274
|
+
/** @type {{ widgets: string[], problems: string[], headings: string[], formTitles: string[] }} */
|
|
275
|
+
const out = { widgets: [], problems: [], headings: [], formTitles: [] };
|
|
276
|
+
let vnode;
|
|
277
|
+
try {
|
|
278
|
+
vnode = compileJsltStylesheet(doc.view, { compileTypeTest, memo: false })(doc.state);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
out.problems.push(`the view failed to render its first frame: ${message(err)}`);
|
|
282
|
+
return { widgets: out.widgets, problems: out.problems, notes: [] };
|
|
283
|
+
}
|
|
284
|
+
walkRenderedVnode(vnode, '', out);
|
|
285
|
+
|
|
286
|
+
/** @type {string[]} */
|
|
287
|
+
const notes = [];
|
|
288
|
+
const markers = getSeedHeadings();
|
|
289
|
+
for (const heading of out.headings) {
|
|
290
|
+
const seedTitles = markers.get(heading);
|
|
291
|
+
if (seedTitles === undefined) continue;
|
|
292
|
+
const departed = out.formTitles.find((title) => !seedTitles.has(title)
|
|
293
|
+
&& !heading.toLowerCase().includes(title.toLowerCase()));
|
|
294
|
+
if (departed !== undefined) {
|
|
295
|
+
notes.push(`the heading "${heading}" is still the seed template's while the form is now titled "${departed}" — patch the heading to match the repurposed form`);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { widgets: out.widgets, problems: out.problems, notes };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate a document against the meta-schema and boot it as an
|
|
306
|
+
* isolated app: no effects, no subs, its own `onError` sink, the
|
|
307
|
+
* render-capability widgets, and the runtime's atomic-boot guarantee.
|
|
308
|
+
* @param {any} doc
|
|
309
|
+
* @param {{ node?: any, document?: any, schedule?: (flush: () => void) => void,
|
|
310
|
+
* onError?: (err: Error) => void }} env
|
|
311
|
+
* @returns {{ ok: true, app: any } | { ok: false, errors: any[], total: number, message: string }}
|
|
312
|
+
*/
|
|
313
|
+
function loadStudioDocument(doc, env = {}) {
|
|
314
|
+
const report = validateAppDocument(doc);
|
|
315
|
+
if (!report.valid) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
errors: report.errors,
|
|
319
|
+
total: report.total,
|
|
320
|
+
message: `the document does not validate against the jaren-app meta-schema (${report.total} error${report.total === 1 ? '' : 's'})`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const app = createApp(doc, {
|
|
325
|
+
node: env.node,
|
|
326
|
+
document: env.document,
|
|
327
|
+
schedule: env.schedule,
|
|
328
|
+
widgets: STUDIO_WIDGETS,
|
|
329
|
+
compileTypeTest,
|
|
330
|
+
// the form widget's typed selects and json editor decode here
|
|
331
|
+
eventFields: { ...formEventFields() },
|
|
332
|
+
onError: env.onError,
|
|
333
|
+
// deliberately absent: effects, subs — the isolation boundary
|
|
334
|
+
});
|
|
335
|
+
return { ok: true, app };
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
// JA0007: boot rolled back atomically, the container ends empty
|
|
339
|
+
return { ok: false, errors: [], total: 0, message: message(err) };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { validateAppDocument, STUDIO_WIDGETS, auditDocumentRender, loadStudioDocument };
|
|
344
|
+
}
|