@jarenjs/studio 0.83.2 → 0.84.3
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,308 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Complete project-stage behavior over injected execution and preview services. */
|
|
3
|
+
import { resolveProjectFile, projectFileContext } from '../index.js';
|
|
4
|
+
import { contentKey, semanticKey } from '@jarenjs/core/object';
|
|
5
|
+
import { createSplitterWidget } from '@jarenjs/app';
|
|
6
|
+
import { createStudioComponent } from './index.js';
|
|
7
|
+
|
|
8
|
+
import { compileFsm, compileDag } from '@jarenjs/flow';
|
|
9
|
+
import { compileContract } from '@jarenjs/contract';
|
|
10
|
+
import { toOpenApi } from '@jarenjs/contract/project';
|
|
11
|
+
|
|
12
|
+
import { errorMessage, cards, error, code } from './shared/nodes.js';
|
|
13
|
+
import { Float64 } from '@jarenjs/core/math';
|
|
14
|
+
const formatMsUnscaled = value => `${Float64.roundToPrecision(value, 3)} ms`;
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** @param {{ operators?: any, loadDocument: Function, runQuery: Function, runJslt: Function, runValidation: Function }} options */
|
|
18
|
+
export function createProjectHost(options) {
|
|
19
|
+
const { operators: operatorRegistry, loadDocument: loadStudioDocument, runQuery, runJslt, runValidation } = options;
|
|
20
|
+
/** The component, with the site's math/finance/stats packs mounted. */
|
|
21
|
+
const projectComponent = createStudioComponent({ operators: operatorRegistry });
|
|
22
|
+
|
|
23
|
+
/** A normalized project view of the live slice (for the engine calls). */
|
|
24
|
+
function projectOf(slice) {
|
|
25
|
+
return {
|
|
26
|
+
project: slice.project ?? '0.1',
|
|
27
|
+
files: slice.files ?? [],
|
|
28
|
+
active: slice.active ?? (slice.files?.[0]?.name ?? null),
|
|
29
|
+
layout: slice.layout,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The live slice as a persistable `jaren-project` document — what a save
|
|
35
|
+
* or a share token carries (the IDE-only fields — mount, results, dirty —
|
|
36
|
+
* stay behind).
|
|
37
|
+
* @param {any} slice - the `state.project` slice
|
|
38
|
+
*/
|
|
39
|
+
function projectSnapshot(slice) {
|
|
40
|
+
return { ...projectOf(slice), name: slice.name ?? 'Untitled project' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The project's designated `app` file — the one the Studio contracts
|
|
45
|
+
* (external document operations, the app download) operate on: the
|
|
46
|
+
* active file when it is an app, else the first app file, else null.
|
|
47
|
+
* @param {any} slice - the `state.project` slice
|
|
48
|
+
* @returns {{ name: string, kind: string, text: string } | null}
|
|
49
|
+
*/
|
|
50
|
+
function projectAppFile(slice) {
|
|
51
|
+
const files = slice.files ?? [];
|
|
52
|
+
const active = files.find((f) => f.name === slice.active);
|
|
53
|
+
if (active !== undefined && active.kind === 'app') return active;
|
|
54
|
+
return files.find((f) => f.kind === 'app') ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The edit loop's pure step: fold the live slice into the last-good
|
|
59
|
+
* `{ mount, revision }` for the active `app` file. A valid structural
|
|
60
|
+
* change bumps the revision (the widget reboots); a valid state-only
|
|
61
|
+
* change keeps the revision but swaps the document (the widget
|
|
62
|
+
* hot-updates); an invalid edit — or a non-app active file — keeps the
|
|
63
|
+
* previous mount untouched (the last good frame stays on the stage).
|
|
64
|
+
* @param {any} slice - the `state.project` slice
|
|
65
|
+
* @returns {{ mount: any, revision: number }}
|
|
66
|
+
*/
|
|
67
|
+
function commitProject(slice) {
|
|
68
|
+
const mount = slice.mount ?? null;
|
|
69
|
+
const revision = slice.revision ?? 0;
|
|
70
|
+
const project = projectOf(slice);
|
|
71
|
+
let active = project.active;
|
|
72
|
+
let activeFile = project.files.find((f) => f.name === active);
|
|
73
|
+
if (!activeFile) return { mount, revision };
|
|
74
|
+
// Editing an imported fragment keeps its owning app visible and live.
|
|
75
|
+
if (!['app', 'fsm', 'dag', 'model'].includes(activeFile.kind) && !activeFile.model) {
|
|
76
|
+
const owners = project.files.filter((f) => f.kind === 'app' && f.imports
|
|
77
|
+
&& Object.values(f.imports).includes(active));
|
|
78
|
+
if (owners.length !== 1) return { mount, revision };
|
|
79
|
+
activeFile = owners[0]; active = activeFile.name;
|
|
80
|
+
}
|
|
81
|
+
let resolved;
|
|
82
|
+
try { resolved = resolveProjectFile(project, active); }
|
|
83
|
+
catch { return { mount, revision }; }
|
|
84
|
+
const assembledFile = { ...activeFile, text: JSON.stringify(resolved.doc) };
|
|
85
|
+
const verdicts = projectComponent.describe(project).files;
|
|
86
|
+
if (!verdicts.find((f) => f.name === active)?.valid) return { mount, revision };
|
|
87
|
+
let input = null;
|
|
88
|
+
let context;
|
|
89
|
+
try {
|
|
90
|
+
context = projectFileContext(project, activeFile);
|
|
91
|
+
if (context.model && !verdicts.find((f) => f.name === context.model.name)?.valid) return { mount, revision };
|
|
92
|
+
const usesInput = ['fsm', 'dag'].includes(activeFile.kind)
|
|
93
|
+
|| (activeFile.kind === 'model' && activeFile.input !== undefined);
|
|
94
|
+
if (context.input && usesInput)
|
|
95
|
+
input = JSON.parse(context.input.text);
|
|
96
|
+
}
|
|
97
|
+
catch { return { mount, revision }; }
|
|
98
|
+
const isStore = activeFile.kind === 'model' || activeFile.model !== undefined;
|
|
99
|
+
const doc = resolved.doc;
|
|
100
|
+
const prevProject = mount && mount.name === active
|
|
101
|
+
? { files: [{ name: mount.name, kind: mount.kind ?? 'app', text: JSON.stringify(mount.doc) }] }
|
|
102
|
+
: { files: [] };
|
|
103
|
+
const nextProject = { files: [{ name: active, kind: activeFile.kind, text: assembledFile.text }] };
|
|
104
|
+
const policy = projectComponent.hostPolicy(prevProject, nextProject)[active] ?? 'reboot';
|
|
105
|
+
if (policy === 'skip' && mount && mount.name === active
|
|
106
|
+
&& (!isStore || mount.files === project.files) && semanticKey(mount.input ?? null) === semanticKey(input))
|
|
107
|
+
return { mount, revision };
|
|
108
|
+
const nextRevision = policy === 'reboot' ? revision + 1 : revision;
|
|
109
|
+
return { mount: { name: active, kind: activeFile.kind, doc, input,
|
|
110
|
+
sourceFiles: resolved.sourceFiles, ...(isStore ? { files: project.files, store: true } : {}),
|
|
111
|
+
revision: nextRevision }, revision: nextRevision };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run a transform file (`jslt` / `query`): pair it with the project's
|
|
116
|
+
* input (the first `data` file, else a `state` file) and hand it to the
|
|
117
|
+
* shared transform runners — so registered operators ($npv, $sqrt, …)
|
|
118
|
+
* work here exactly as in play. Returns `{ nodes }` (render nodes for
|
|
119
|
+
* the `ui` mode), or null for a kind that is not a runnable transform.
|
|
120
|
+
* The runners catch their own errors into error nodes, so this never
|
|
121
|
+
* throws.
|
|
122
|
+
* @param {any} slice - the `state.project` slice
|
|
123
|
+
* @param {string} name - the file to run
|
|
124
|
+
* @param {{ model?: (project: any, name: string) => Promise<any> }} [options]
|
|
125
|
+
* @returns {{ nodes: any[] } | Promise<{ nodes: any[] }> | null}
|
|
126
|
+
*/
|
|
127
|
+
function runProjectFile(slice, name, options = {}) {
|
|
128
|
+
const project = projectOf(slice);
|
|
129
|
+
const file = project.files.find((f) => f.name === name);
|
|
130
|
+
if (file === undefined) return null;
|
|
131
|
+
if (file.kind === 'model' || file.model !== undefined) {
|
|
132
|
+
if (!options.model) return null;
|
|
133
|
+
return options.model(project, name).then((result) => ({ nodes: [
|
|
134
|
+
code('Result', JSON.stringify(result.result, null, 2)), code('Query plan', JSON.stringify(result.plan, null, 2)),
|
|
135
|
+
] }), (err) => ({ nodes: [error(err, 'Model run')] }));
|
|
136
|
+
}
|
|
137
|
+
let input;
|
|
138
|
+
try { ({ input } = projectFileContext(project, file)); }
|
|
139
|
+
catch (err) { return { nodes: [error(err, 'Project reference')] }; }
|
|
140
|
+
const dataText = input ? input.text : 'null';
|
|
141
|
+
if (file.kind === 'fsm' || file.kind === 'dag') {
|
|
142
|
+
try {
|
|
143
|
+
const doc = resolveProjectFile(project, name).doc;
|
|
144
|
+
const data = JSON.parse(dataText);
|
|
145
|
+
if (file.kind === 'dag') return compileDag(doc).run(data).then(
|
|
146
|
+
(output) => ({ nodes: [code('DAG output', JSON.stringify(output, null, 2) ?? '(empty sequence)')] }),
|
|
147
|
+
(err) => ({ nodes: [error(err, 'DAG run')] }));
|
|
148
|
+
const machine = compileFsm(doc);
|
|
149
|
+
let state = machine.initial;
|
|
150
|
+
const trace = [];
|
|
151
|
+
for (const event of data?.events ?? []) {
|
|
152
|
+
const result = machine.step(state, typeof event === 'string' ? event : event.event,
|
|
153
|
+
{ context: data?.context ?? null, payload: typeof event === 'string' ? null : event.payload });
|
|
154
|
+
trace.push(result); state = result.state;
|
|
155
|
+
}
|
|
156
|
+
return { nodes: [code('Machine run', JSON.stringify({ state, events: state === null ? [] : machine.events(state), trace }, null, 2))] };
|
|
157
|
+
}
|
|
158
|
+
catch (err) { return { nodes: [error(err, 'Flow run')] }; }
|
|
159
|
+
}
|
|
160
|
+
if (file.kind === 'query') return { nodes: runQuery({ query: file.text, data: dataText, externals: '' }) };
|
|
161
|
+
if (file.kind === 'jslt') return { nodes: runJslt({ stylesheet: file.text, data: dataText }) };
|
|
162
|
+
if (file.kind === 'schema') return { nodes: validateNodes(file.text, dataText) };
|
|
163
|
+
if (file.kind === 'contract') return { nodes: contractNodes(file.text) };
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A `contract` file's stage: compile it and show the projections a
|
|
169
|
+
* consumer reads — `describe()` (resolved bindings + policy) and the
|
|
170
|
+
* OpenAPI 3.1 document. A compile refusal renders as the coded error
|
|
171
|
+
* node with its `JC00xx` and docPath (the same diagnosis the file rail
|
|
172
|
+
* shows), never a throw.
|
|
173
|
+
* @param {string} text - the contract file's text
|
|
174
|
+
*/
|
|
175
|
+
function contractNodes(text) {
|
|
176
|
+
let doc;
|
|
177
|
+
try { doc = JSON.parse(text); }
|
|
178
|
+
catch (err) { return [error({ message: errorMessage(err) }, 'Invalid JSON')]; }
|
|
179
|
+
let contract;
|
|
180
|
+
try { contract = compileContract(doc); }
|
|
181
|
+
catch (err) { return [error(/** @type {any} */ (err), 'Contract refusal')]; }
|
|
182
|
+
const description = contract.describe();
|
|
183
|
+
// lenient: a keyword the OpenAPI dialect cannot carry is dropped and
|
|
184
|
+
// COUNTED (the card says so) instead of refusing the whole pane — the
|
|
185
|
+
// strict mode is the CLI's job, the stage's job is to show the file
|
|
186
|
+
const openapi = toOpenApi(contract, {
|
|
187
|
+
lenient: true,
|
|
188
|
+
info: {
|
|
189
|
+
title: typeof doc.id === 'string' ? doc.id : 'contract',
|
|
190
|
+
version: typeof doc.version === 'string' ? doc.version : '0',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
return [
|
|
194
|
+
cards([
|
|
195
|
+
{ title: 'Operations', value: String(description.operations.length) },
|
|
196
|
+
...(openapi.dropped.length > 0 ? [{ title: 'OpenAPI dropped', value: String(openapi.dropped.length) }] : []),
|
|
197
|
+
]),
|
|
198
|
+
code('describe()', JSON.stringify(description, null, 2)),
|
|
199
|
+
code('OpenAPI 3.1', JSON.stringify(openapi.document, null, 2)),
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Validate a data file against a schema file and render the report on
|
|
204
|
+
* the stage: a valid/invalid summary plus each error as its own coded
|
|
205
|
+
* node. */
|
|
206
|
+
function validateNodes(schemaText, dataText) {
|
|
207
|
+
let data;
|
|
208
|
+
try { data = JSON.parse(dataText); }
|
|
209
|
+
catch (err) { return [error({ message: `data: ${errorMessage(err)}` }, 'Invalid JSON')]; }
|
|
210
|
+
const r = runValidation(schemaText, data);
|
|
211
|
+
if (r.schemaError !== null) return [error({ message: r.schemaError }, 'Schema error')];
|
|
212
|
+
const nodes = [cards([
|
|
213
|
+
{ title: r.valid ? 'Valid' : 'Invalid', value: r.valid ? '✓' : `${r.errors.length} error${r.errors.length === 1 ? '' : 's'}` },
|
|
214
|
+
{ title: 'Draft', value: r.draft },
|
|
215
|
+
{ title: 'Compile', value: formatMsUnscaled(r.compileMs) },
|
|
216
|
+
{ title: 'Validate', value: formatMsUnscaled(r.validateMs) },
|
|
217
|
+
])];
|
|
218
|
+
for (const e of r.errors) {
|
|
219
|
+
nodes.push(error({
|
|
220
|
+
message: e.message,
|
|
221
|
+
dataPath: e.instancePath === '' ? '(root)' : e.instancePath,
|
|
222
|
+
code: e.keyword !== '' ? e.keyword : undefined,
|
|
223
|
+
}, 'Validation error'));
|
|
224
|
+
}
|
|
225
|
+
return nodes;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The live stage host widget (`studio-stage`): boot the active app file's
|
|
230
|
+
* assembled document as an isolated nested app, then apply the
|
|
231
|
+
* reboot-vs-hot-update policy on every commit.
|
|
232
|
+
* @param {{ schedule?: (flush: () => void) => void }} [env]
|
|
233
|
+
*/
|
|
234
|
+
function createProjectStageWidget(env = {}) {
|
|
235
|
+
const stateKeyOf = (doc) => (doc && typeof doc === 'object' ? contentKey(doc.state ?? null) : null);
|
|
236
|
+
// the document minus its state — the same datum classifyChange keys on.
|
|
237
|
+
// The widget re-derives it defensively: two commits can race in at the
|
|
238
|
+
// SAME revision with different documents (a project/open interleaving
|
|
239
|
+
// with a route-arrival commit), and hot-dispatching one document's
|
|
240
|
+
// state into another document's running view renders garbage.
|
|
241
|
+
const shapeKeyOf = (doc) => (doc && typeof doc === 'object' ? contentKey({ ...doc, state: null }) : null);
|
|
242
|
+
|
|
243
|
+
const boot = (handle, mount) => {
|
|
244
|
+
handle.emit({ action: 'project/stage-error', with: null });
|
|
245
|
+
const result = loadStudioDocument(mount.doc, {
|
|
246
|
+
node: handle.host,
|
|
247
|
+
document: handle.host.ownerDocument,
|
|
248
|
+
schedule: env.schedule,
|
|
249
|
+
onError: (err) => handle.emit({ action: 'project/stage-error', with: errorMessage(err) }),
|
|
250
|
+
});
|
|
251
|
+
handle.app = result.ok ? result.app : null;
|
|
252
|
+
handle.revision = mount.revision;
|
|
253
|
+
handle.stateKey = stateKeyOf(mount.doc);
|
|
254
|
+
handle.shapeKey = shapeKeyOf(mount.doc);
|
|
255
|
+
if (!result.ok) handle.emit({ action: 'project/stage-error', with: result.message });
|
|
256
|
+
};
|
|
257
|
+
const destroy = (handle) => {
|
|
258
|
+
try { handle.app?.destroy(); }
|
|
259
|
+
catch (err) { handle.emit({ action: 'project/stage-error', with: errorMessage(err) }); }
|
|
260
|
+
handle.app = null;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
mount(host, props, emit) {
|
|
265
|
+
const handle = { host, emit, app: null, revision: null, stateKey: null, shapeKey: null };
|
|
266
|
+
boot(handle, props);
|
|
267
|
+
return handle;
|
|
268
|
+
},
|
|
269
|
+
update(handle, props, prevProps) {
|
|
270
|
+
if (props === prevProps) return;
|
|
271
|
+
// a structural change bumped the revision — or a raced-in commit
|
|
272
|
+
// swapped the document's shape at the same revision → destroy + reboot
|
|
273
|
+
if (props.revision !== handle.revision || shapeKeyOf(props.doc) !== handle.shapeKey) {
|
|
274
|
+
destroy(handle);
|
|
275
|
+
boot(handle, props);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// same revision, same shape, new document → a state-only edit →
|
|
279
|
+
// hot-dispatch it into the RUNNING app (no reboot: scroll, focus and
|
|
280
|
+
// uncontrolled inputs survive the diff re-render)
|
|
281
|
+
const key = stateKeyOf(props.doc);
|
|
282
|
+
if (handle.app !== null && key !== handle.stateKey) {
|
|
283
|
+
handle.app.setState(props.doc.state);
|
|
284
|
+
handle.stateKey = key;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
unmount(handle) {
|
|
288
|
+
destroy(handle);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The Studio's drag splitter (`studio-splitter`): the shared
|
|
295
|
+
* `createSplitterWidget` bound to the studio grid — a handle over the
|
|
296
|
+
* editor|stage boundary that drives `--js-ratio` live during a drag and
|
|
297
|
+
* commits `project/layout-ratio` on pointer-UP (keyboard-resizable as an
|
|
298
|
+
* ARIA separator). The reusable widget lives in `@jarenjs/app`; play binds
|
|
299
|
+
* the same factory to its own grid.
|
|
300
|
+
*/
|
|
301
|
+
function createProjectSplitterWidget() {
|
|
302
|
+
return createSplitterWidget({
|
|
303
|
+
grid: '.jstudio', rail: '.js-rail', cssVar: '--js-ratio', action: 'project/layout-ratio',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { projectComponent, projectSnapshot, projectAppFile, commitProject, runProjectFile, createProjectStageWidget, createProjectSplitterWidget };
|
|
308
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The shared host-widget lifecycle (the third studio forced the
|
|
4
|
+
* extraction): mount boots a nested app into the host node, update
|
|
5
|
+
* reboots ONLY when the document or its revision changed, unmount
|
|
6
|
+
* destroys. The `mount`/`update`/`unmount` trio was byte-identical
|
|
7
|
+
* between the Studio and Flow boundaries; the `destroy` halves
|
|
8
|
+
* differed DELIBERATELY (Studio reports teardown failures, Flow
|
|
9
|
+
* swallows them), so the factory takes both halves as parameters and
|
|
10
|
+
* reconciles nothing by averaging — one factory, three callers, each
|
|
11
|
+
* keeping its own policy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ boot: (handle: any, props: any) => void,
|
|
16
|
+
* destroy: (handle: any) => void }} lifecycle
|
|
17
|
+
* @returns {{ mount: Function, update: Function, unmount: Function }}
|
|
18
|
+
*/
|
|
19
|
+
export function createHostWidget({ boot, destroy }) {
|
|
20
|
+
return {
|
|
21
|
+
mount(host, props, emit) {
|
|
22
|
+
const handle = { host, emit, app: null };
|
|
23
|
+
boot(handle, props);
|
|
24
|
+
return handle;
|
|
25
|
+
},
|
|
26
|
+
update(handle, props, prevProps) {
|
|
27
|
+
if (props.doc === prevProps.doc && props.revision === prevProps.revision) return;
|
|
28
|
+
destroy(handle);
|
|
29
|
+
boot(handle, props);
|
|
30
|
+
},
|
|
31
|
+
unmount(handle) {
|
|
32
|
+
destroy(handle);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Single-entry memoization on argument identity. The viewModel wraps
|
|
4
|
+
* its derivations with this so an unchanged input slice returns the
|
|
5
|
+
* PREVIOUS node by reference — which makes the JSLT memo (and through
|
|
6
|
+
* it the renderer's === fast path) fire for the whole subtree.
|
|
7
|
+
* Only successful calls populate the memo; a throwing call keeps the
|
|
8
|
+
* previous successful entry available.
|
|
9
|
+
* @template {(...args: any[]) => any} F
|
|
10
|
+
* @param {F} fn
|
|
11
|
+
* @returns {F}
|
|
12
|
+
*/
|
|
13
|
+
export function memo1(fn) {
|
|
14
|
+
/** @type {any[] | null} */
|
|
15
|
+
let lastArgs = null;
|
|
16
|
+
/** @type {any} */
|
|
17
|
+
let lastResult;
|
|
18
|
+
return /** @type {F} */ ((...args) => {
|
|
19
|
+
if (lastArgs !== null && lastArgs.length === args.length
|
|
20
|
+
&& lastArgs.every((value, i) => value === args[i])) {
|
|
21
|
+
return lastResult;
|
|
22
|
+
}
|
|
23
|
+
const result = fn(...args);
|
|
24
|
+
lastArgs = args;
|
|
25
|
+
lastResult = result;
|
|
26
|
+
return lastResult;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Builders for the kind-tagged render-node vocabulary. Boundaries emit
|
|
4
|
+
* these plain-JSON nodes; the 'ui' view mode renders each kind with one
|
|
5
|
+
* generic rule. This is the site's component library — as data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A paragraph of prose. */
|
|
9
|
+
export const p = (text) => ({ kind: 'p', text });
|
|
10
|
+
|
|
11
|
+
/** A row of stat cards. */
|
|
12
|
+
export const cards = (items) => ({
|
|
13
|
+
kind: 'cards',
|
|
14
|
+
items: items.map((i) => ({ kind: 'card', ...i, note: i.note ?? null })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** A titled table; rows are `{ cells: string[], strong?: boolean }`. */
|
|
18
|
+
export const table = (title, head, rows, note) => ({
|
|
19
|
+
kind: 'table', title, head, note: note ?? null,
|
|
20
|
+
rows: rows.map((r) => ({ kind: 'row', strong: false, ...r })),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** An informational block with an optional link button. */
|
|
24
|
+
export const callout = (title, text, href, link) =>
|
|
25
|
+
({ kind: 'callout', title, text, href: href ?? null, link: link ?? null });
|
|
26
|
+
|
|
27
|
+
/** A code block; `badge` renders next to the title. */
|
|
28
|
+
export const code = (title, text, badge) =>
|
|
29
|
+
({ kind: 'code', title: title ?? null, text, badge: badge ?? null });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One error → one display string. Coded errors compose their own
|
|
33
|
+
* message (`code: reason at path` per the @jarenjs/core contract), so
|
|
34
|
+
* this never prefixes anything — prefixing again is how the same suite
|
|
35
|
+
* used to show one error two different ways on two pages.
|
|
36
|
+
* @param {unknown} err
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export const errorMessage = (err) =>
|
|
40
|
+
(typeof (/** @type {any} */ (err))?.message === 'string'
|
|
41
|
+
? /** @type {any} */ (err).message
|
|
42
|
+
: String(err));
|
|
43
|
+
|
|
44
|
+
/** An error block in the engine-error shape (code/docPath/dataPath/position). */
|
|
45
|
+
export function error(err, fallbackTitle) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
// A coded error (it carries `reason`) already renders its code and
|
|
48
|
+
// its location inside `message` — the detail line repeats only what
|
|
49
|
+
// the message does NOT contain.
|
|
50
|
+
const composed = typeof err.reason === 'string';
|
|
51
|
+
if (!composed) {
|
|
52
|
+
if (err.code) parts.push(`code: ${err.code}`);
|
|
53
|
+
if (err.docPath !== undefined && err.docPath !== null) {
|
|
54
|
+
parts.push(`docPath: ${err.docPath === '' ? '"" (document root)' : err.docPath}`);
|
|
55
|
+
}
|
|
56
|
+
if (err.dataPath !== undefined && err.dataPath !== null) {
|
|
57
|
+
parts.push(`dataPath: ${err.dataPath === '' ? '"" (document root)' : err.dataPath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (typeof err.position === 'number') parts.push(`position: ${err.position}`);
|
|
61
|
+
if (typeof err.line === 'number') parts.push(`line: ${err.line}`);
|
|
62
|
+
if (typeof err.column === 'number') parts.push(`column: ${err.column}`);
|
|
63
|
+
if (err.hint) parts.push(`hint: ${err.hint}`);
|
|
64
|
+
return {
|
|
65
|
+
kind: 'error',
|
|
66
|
+
title: err.code ?? fallbackTitle ?? err.name ?? 'Error',
|
|
67
|
+
message: errorMessage(err),
|
|
68
|
+
detail: parts.length > 0 ? parts.join(' · ') : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A collapsible section of further nodes. */
|
|
73
|
+
export const details = (summary, items) => ({ kind: 'details', summary, items });
|
|
74
|
+
|
|
75
|
+
/** An SVG chart card: `vnode` is @jarenjs/charts' projection (charts
|
|
76
|
+
* carry their own internal title; `title` adds a card heading only when
|
|
77
|
+
* the SVG has none). */
|
|
78
|
+
export const chart = (title, vnode, note) =>
|
|
79
|
+
({ kind: 'chart', title: title ?? null, vnode, note: note ?? null });
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A rendered Markdown article: `vnode` is the @jarenjs/md component's
|
|
83
|
+
* projection, spliced in verbatim exactly as a chart's SVG is. The
|
|
84
|
+
* site's own sections are kind-nodes; a section a PACKAGE owns arrives
|
|
85
|
+
* as markdown, and this is the one node that carries it.
|
|
86
|
+
*/
|
|
87
|
+
export const article = (vnode) => ({ kind: 'article', vnode });
|
|
88
|
+
|
|
89
|
+
/** A live search input bound to the given action. */
|
|
90
|
+
export const search = (action, value, placeholder) =>
|
|
91
|
+
({ kind: 'search', action, value, placeholder });
|
|
92
|
+
|
|
93
|
+
/** A "show more" button bound to the given action. */
|
|
94
|
+
export const more = (action, label) => ({ kind: 'more', action, label });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The validator options every surface uses when it compiles a schema
|
|
4
|
+
* the component ships — the engine grammars, the chart definition, the
|
|
5
|
+
* app meta-schema.
|
|
6
|
+
*
|
|
7
|
+
* `unknownFormats: 'error'` is the point of the file. `@jarenjs/validate`
|
|
8
|
+
* defaults to `'ignore'`, because the specification requires that an
|
|
9
|
+
* unknown `format` be treated as an annotation rather than an assertion
|
|
10
|
+
* failure, and a library has to be able to compile a stranger's schema.
|
|
11
|
+
* The component schemas are not a stranger's: a `format` in one of them names a
|
|
12
|
+
* check we intend to happen, so a name with no registered compiler is a
|
|
13
|
+
* missing `addFormats` call, and the alternative to failing is a keyword
|
|
14
|
+
* that silently validates everything. That is how the published grammars
|
|
15
|
+
* came to declare `format: "json-path"` for a long time without anything
|
|
16
|
+
* ever checking one.
|
|
17
|
+
*
|
|
18
|
+
* The line is deliberate and worth keeping: a schema the USER supplied —
|
|
19
|
+
* the validator playground's input, a project file in the studio — keeps
|
|
20
|
+
* the library default, because refusing to compile a stranger's valid
|
|
21
|
+
* schema over a format we happen not to implement would be our problem
|
|
22
|
+
* presented as theirs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Options for compiling a schema this project ships. */
|
|
26
|
+
export const OUR_SCHEMA_OPTIONS = Object.freeze({
|
|
27
|
+
skipErrors: false,
|
|
28
|
+
collectErrors: true,
|
|
29
|
+
unknownFormats: /** @type {'error'} */ ('error'),
|
|
30
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Shared editor controls and accessible phone pane switching. */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The editor textarea; live buffers publish input before unrelated renders.
|
|
6
|
+
* @param {{ value: string, action: string, rows?: number,
|
|
7
|
+
* readonly?: boolean, live?: boolean }} options
|
|
8
|
+
*/
|
|
9
|
+
export function editorTextarea(options) {
|
|
10
|
+
return ['textarea', {
|
|
11
|
+
class: 'editor',
|
|
12
|
+
rows: options.rows ?? 22,
|
|
13
|
+
spellcheck: 'false',
|
|
14
|
+
value: options.value,
|
|
15
|
+
...(options.readonly === true ? { readonly: '' } : {}),
|
|
16
|
+
on: { change: options.action, ...(options.live ? { input: options.action } : {}) },
|
|
17
|
+
}];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The error line.
|
|
22
|
+
* @param {any} content - a query expression or literal text
|
|
23
|
+
*/
|
|
24
|
+
export function errorLine(content) {
|
|
25
|
+
return ['p', { class: 'error-line' }, content];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The phone pane switcher — a segmented bar that shows ONE pane at a
|
|
30
|
+
* time below the breakpoint, the pattern `@jarenjs/play` established.
|
|
31
|
+
*
|
|
32
|
+
* The protocol is three parts, and every surface implements all three:
|
|
33
|
+
* the pane container carries `data-pane` (the live pane id), this bar
|
|
34
|
+
* carries the studio's own `-panebar` class plus the shared `seg`
|
|
35
|
+
* control classes, and the studio's stylesheet hides the unselected
|
|
36
|
+
* panes inside its `@media (max-width: 1024px)` block. Switching is a
|
|
37
|
+
* SINGLE attribute write on the container — the panes stay mounted, so
|
|
38
|
+
* a hidden editor keeps its caret, its scroll and its undo stack, and
|
|
39
|
+
* nothing re-renders but the bar's two changed buttons.
|
|
40
|
+
*
|
|
41
|
+
* The segments are toggle buttons in a group, not a `tablist`: a pane
|
|
42
|
+
* is a grid area, not a `tabpanel`, so `aria-pressed` states the truth
|
|
43
|
+
* that `aria-selected` would overclaim.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ pane: string, action: string, class: string,
|
|
46
|
+
* panes: [string, string][] }} options - `pane` is the query
|
|
47
|
+
* expression holding the live pane id, `panes` the `[id, label]`
|
|
48
|
+
* pairs in bar order
|
|
49
|
+
*/
|
|
50
|
+
export function paneSwitcher(options) {
|
|
51
|
+
return ['div', { class: `${options.class} seg`, role: 'group', 'aria-label': 'pane' },
|
|
52
|
+
...options.panes.map(([id, label]) => ['button', {
|
|
53
|
+
type: 'button',
|
|
54
|
+
class: { $if: [{ $eq: [options.pane, id] }, 'seg-btn active', 'seg-btn'] },
|
|
55
|
+
'aria-pressed': { $if: [{ $eq: [options.pane, id] }, 'true', 'false'] },
|
|
56
|
+
on: { click: { action: options.action, with: id } },
|
|
57
|
+
}, label]),
|
|
58
|
+
];
|
|
59
|
+
}
|