@jarenjs/studio 0.75.0 → 0.83.2
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 +27 -2
- package/dist/types/assemble.d.ts +5 -8
- package/dist/types/author.d.ts +27 -0
- package/dist/types/component/index.d.ts +46 -29
- package/dist/types/component/view.d.ts +45 -28
- package/dist/types/errors.d.ts +1 -0
- package/dist/types/export.d.ts +5 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/resolve.d.ts +27 -0
- package/dist/types/skeletons.d.ts +6 -0
- package/dist/types/validate.d.ts +1 -1
- package/docs/PROJECT-FORMAT.md +99 -24
- package/package.json +18 -9
- package/schemas/jaren-project.draft-07.schema.json +80 -12
- package/schemas/jaren-project.schema.json +77 -13
- package/src/assemble.js +34 -14
- package/src/author.js +55 -0
- package/src/component/view.js +14 -8
- package/src/component/viewmodel.js +13 -8
- package/src/errors.js +1 -0
- package/src/export.js +66 -0
- package/src/index.js +2 -0
- package/src/project.js +1 -1
- package/src/resolve.js +111 -0
- package/src/skeletons.js +33 -0
- package/src/validate.js +26 -7
- package/styles/studio.css +14 -0
|
@@ -4,37 +4,105 @@
|
|
|
4
4
|
"title": "jaren-project (draft-07)",
|
|
5
5
|
"description": "The draft-07 twin of jaren-project, for tools pinned to draft-07. Mechanically equivalent to the 2020-12 canonical artifact.",
|
|
6
6
|
"type": "object",
|
|
7
|
-
"required": [
|
|
7
|
+
"required": [
|
|
8
|
+
"project",
|
|
9
|
+
"files"
|
|
10
|
+
],
|
|
8
11
|
"additionalProperties": false,
|
|
9
12
|
"properties": {
|
|
10
|
-
"project": {
|
|
13
|
+
"project": {
|
|
14
|
+
"enum": [
|
|
15
|
+
"0.1"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
11
18
|
"files": {
|
|
12
19
|
"type": "array",
|
|
13
|
-
"items": {
|
|
20
|
+
"items": {
|
|
21
|
+
"$ref": "#/definitions/file"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"active": {
|
|
25
|
+
"type": "string"
|
|
14
26
|
},
|
|
15
|
-
"
|
|
16
|
-
|
|
27
|
+
"layout": {
|
|
28
|
+
"$ref": "#/definitions/layout"
|
|
29
|
+
}
|
|
17
30
|
},
|
|
18
31
|
"definitions": {
|
|
19
32
|
"file": {
|
|
20
33
|
"type": "object",
|
|
21
|
-
"required": [
|
|
34
|
+
"required": [
|
|
35
|
+
"name",
|
|
36
|
+
"kind",
|
|
37
|
+
"text"
|
|
38
|
+
],
|
|
22
39
|
"additionalProperties": false,
|
|
23
40
|
"properties": {
|
|
24
|
-
"name": {
|
|
41
|
+
"name": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"minLength": 1
|
|
44
|
+
},
|
|
25
45
|
"kind": {
|
|
26
|
-
"enum": [
|
|
46
|
+
"enum": [
|
|
47
|
+
"app",
|
|
48
|
+
"jslt",
|
|
49
|
+
"query",
|
|
50
|
+
"state",
|
|
51
|
+
"data",
|
|
52
|
+
"schema",
|
|
53
|
+
"fsm",
|
|
54
|
+
"dag",
|
|
55
|
+
"model",
|
|
56
|
+
"contract"
|
|
57
|
+
]
|
|
27
58
|
},
|
|
28
|
-
"text": {
|
|
59
|
+
"text": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
},
|
|
62
|
+
"imports": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"description": "Top-level artifact member to project file name; replaces an absent member with the entire source JSON value.",
|
|
65
|
+
"additionalProperties": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"minLength": 1
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"input": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"minLength": 1,
|
|
73
|
+
"description": "Explicit data/state input file."
|
|
74
|
+
},
|
|
75
|
+
"model": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"minLength": 1,
|
|
78
|
+
"description": "Model file whose worker store executes this query."
|
|
79
|
+
},
|
|
80
|
+
"collection": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"minLength": 1,
|
|
83
|
+
"description": "Collection in the referenced model; required when ambiguous."
|
|
84
|
+
}
|
|
29
85
|
}
|
|
30
86
|
},
|
|
31
87
|
"layout": {
|
|
32
88
|
"type": "object",
|
|
33
89
|
"additionalProperties": false,
|
|
34
90
|
"properties": {
|
|
35
|
-
"mode": {
|
|
36
|
-
|
|
37
|
-
|
|
91
|
+
"mode": {
|
|
92
|
+
"enum": [
|
|
93
|
+
"classic",
|
|
94
|
+
"right",
|
|
95
|
+
"top"
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"ratio": {
|
|
99
|
+
"type": "number",
|
|
100
|
+
"minimum": 0,
|
|
101
|
+
"maximum": 1
|
|
102
|
+
},
|
|
103
|
+
"autorun": {
|
|
104
|
+
"type": "boolean"
|
|
105
|
+
}
|
|
38
106
|
}
|
|
39
107
|
}
|
|
40
108
|
}
|
|
@@ -2,45 +2,109 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://jarenjs.dev/schemas/jaren-project/0.1",
|
|
4
4
|
"title": "jaren-project",
|
|
5
|
-
"description": "A jaren studio project: a thin envelope over a list of typed files. The envelope is schema-gated; each file's `text` is validated against ITS OWN kind grammar at its boundary, never composed into one mega-schema
|
|
5
|
+
"description": "A jaren studio project: a thin envelope over a list of typed files. The envelope is schema-gated; each file's `text` is validated against ITS OWN kind grammar at its boundary, never composed into one mega-schema \u2014 so a data file may use host-registered operators the closed grammars forbid.",
|
|
6
6
|
"type": "object",
|
|
7
|
-
"required": [
|
|
7
|
+
"required": [
|
|
8
|
+
"project",
|
|
9
|
+
"files"
|
|
10
|
+
],
|
|
8
11
|
"additionalProperties": false,
|
|
9
12
|
"properties": {
|
|
10
|
-
"project": {
|
|
13
|
+
"project": {
|
|
14
|
+
"const": "0.1"
|
|
15
|
+
},
|
|
11
16
|
"files": {
|
|
12
17
|
"type": "array",
|
|
13
18
|
"description": "The project's files, each a named unit of one concern.",
|
|
14
|
-
"items": {
|
|
19
|
+
"items": {
|
|
20
|
+
"$ref": "#/$defs/file"
|
|
21
|
+
}
|
|
15
22
|
},
|
|
16
23
|
"active": {
|
|
17
24
|
"type": "string",
|
|
18
25
|
"description": "The name of the file the editor shows (falls back to the first file)."
|
|
19
26
|
},
|
|
20
|
-
"layout": {
|
|
27
|
+
"layout": {
|
|
28
|
+
"$ref": "#/$defs/layout"
|
|
29
|
+
}
|
|
21
30
|
},
|
|
22
31
|
"$defs": {
|
|
23
32
|
"file": {
|
|
24
33
|
"type": "object",
|
|
25
|
-
"required": [
|
|
34
|
+
"required": [
|
|
35
|
+
"name",
|
|
36
|
+
"kind",
|
|
37
|
+
"text"
|
|
38
|
+
],
|
|
26
39
|
"additionalProperties": false,
|
|
27
40
|
"properties": {
|
|
28
|
-
"name": {
|
|
41
|
+
"name": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"minLength": 1
|
|
44
|
+
},
|
|
29
45
|
"kind": {
|
|
30
46
|
"description": "The file's grammar/role; its `text` is validated against this kind, not the envelope.",
|
|
31
|
-
"enum": [
|
|
47
|
+
"enum": [
|
|
48
|
+
"app",
|
|
49
|
+
"jslt",
|
|
50
|
+
"query",
|
|
51
|
+
"state",
|
|
52
|
+
"data",
|
|
53
|
+
"schema",
|
|
54
|
+
"fsm",
|
|
55
|
+
"dag",
|
|
56
|
+
"model",
|
|
57
|
+
"contract"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"text": {
|
|
61
|
+
"type": "string"
|
|
32
62
|
},
|
|
33
|
-
"
|
|
63
|
+
"imports": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"description": "Top-level artifact member to project file name; replaces an absent member with the entire source JSON value.",
|
|
66
|
+
"additionalProperties": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"minLength": 1
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"input": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"minLength": 1,
|
|
74
|
+
"description": "Explicit data/state input file."
|
|
75
|
+
},
|
|
76
|
+
"model": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"description": "Model file whose worker store executes this query."
|
|
80
|
+
},
|
|
81
|
+
"collection": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"minLength": 1,
|
|
84
|
+
"description": "Collection in the referenced model; required when ambiguous."
|
|
85
|
+
}
|
|
34
86
|
}
|
|
35
87
|
},
|
|
36
88
|
"layout": {
|
|
37
89
|
"type": "object",
|
|
38
|
-
"description": "The IDE arrangement
|
|
90
|
+
"description": "The IDE arrangement \u2014 frozen so it survives a share link and the eject.",
|
|
39
91
|
"additionalProperties": false,
|
|
40
92
|
"properties": {
|
|
41
|
-
"mode": {
|
|
42
|
-
|
|
43
|
-
|
|
93
|
+
"mode": {
|
|
94
|
+
"enum": [
|
|
95
|
+
"classic",
|
|
96
|
+
"right",
|
|
97
|
+
"top"
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
"ratio": {
|
|
101
|
+
"type": "number",
|
|
102
|
+
"minimum": 0,
|
|
103
|
+
"maximum": 1
|
|
104
|
+
},
|
|
105
|
+
"autorun": {
|
|
106
|
+
"type": "boolean"
|
|
107
|
+
}
|
|
44
108
|
}
|
|
45
109
|
}
|
|
46
110
|
}
|
package/src/assemble.js
CHANGED
|
@@ -4,17 +4,14 @@
|
|
|
4
4
|
* needs to decide reboot-vs-hot-update.
|
|
5
5
|
*
|
|
6
6
|
* `assembleArtifacts` composes the project's files into the runnable set.
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
* composing separate `state` + `view` + `actions` files into ONE
|
|
11
|
-
* `jaren-app` document (the true HTML/CSS/JS split) — is the model's
|
|
12
|
-
* headline enhancement and layers on top without changing this contract.
|
|
7
|
+
* A runnable file can import named members from other project files;
|
|
8
|
+
* `sourceFiles` records the complete dependency chain. `state`/`data`
|
|
9
|
+
* files are inputs, not standalone artifacts.
|
|
13
10
|
*
|
|
14
11
|
* `classifyChange` is the load-bearing UX datum: a `state`-only edit must
|
|
15
12
|
* HOT-DISPATCH into a running app (no reboot, the user keeps scroll and
|
|
16
13
|
* inputs), while a `view`/`actions` change must reboot. It compares a
|
|
17
|
-
* STRUCTURAL key (
|
|
14
|
+
* STRUCTURAL key (all app members except `state`) via the suite's own
|
|
18
15
|
* `semanticKey` — the collision-free identity, not the memo-grade
|
|
19
16
|
* `contentKey` fingerprint: a classification decides whether a running
|
|
20
17
|
* app reboots, so a fingerprint collision would read a changed document
|
|
@@ -23,6 +20,7 @@
|
|
|
23
20
|
|
|
24
21
|
import { semanticKey, setObjectMember } from '@jarenjs/core/object';
|
|
25
22
|
import { validateFile } from './validate.js';
|
|
23
|
+
import { resolveProjectFile, projectFileContext } from './resolve.js';
|
|
26
24
|
|
|
27
25
|
/** Kinds that are runnable artifacts on their own (vs. `state`/`data`
|
|
28
26
|
* inputs). */
|
|
@@ -35,8 +33,12 @@ const ROLE = Object.freeze({
|
|
|
35
33
|
contract: 'contract',
|
|
36
34
|
});
|
|
37
35
|
|
|
36
|
+
// Keep assembled validation inputs stable across chrome/buffer renders.
|
|
37
|
+
// A new immutable file list invalidates its dependencies as one snapshot.
|
|
38
|
+
const assembledFiles = new WeakMap();
|
|
39
|
+
|
|
38
40
|
/**
|
|
39
|
-
* Compose the project's files into runnable artifacts
|
|
41
|
+
* Compose the project's files into runnable artifacts, resolving imports.
|
|
40
42
|
* @param {any} project - a normalized project (from `parseProject`)
|
|
41
43
|
* @returns {{ artifacts: Array<{ name: string, kind: string, role: string,
|
|
42
44
|
* doc: any, sourceFiles: string[] }>, errors: Array<{ file: string, message: string }> }}
|
|
@@ -46,14 +48,14 @@ export function assembleArtifacts(project) {
|
|
|
46
48
|
const errors = [];
|
|
47
49
|
for (const file of project.files) {
|
|
48
50
|
if (!RUNNABLE.has(file.kind)) continue;
|
|
49
|
-
let
|
|
50
|
-
try {
|
|
51
|
+
let resolved;
|
|
52
|
+
try { resolved = resolveProjectFile(project, file.name); }
|
|
51
53
|
catch (err) {
|
|
52
|
-
errors.push({ file: file.name, message:
|
|
54
|
+
errors.push({ file: file.name, message: String(/** @type {any} */ (err)?.message ?? err) });
|
|
53
55
|
continue;
|
|
54
56
|
}
|
|
55
57
|
artifacts.push({
|
|
56
|
-
name: file.name, kind: file.kind, role: ROLE[file.kind],
|
|
58
|
+
name: file.name, kind: file.kind, role: ROLE[file.kind], ...resolved,
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
return { artifacts, errors };
|
|
@@ -63,7 +65,7 @@ export function assembleArtifacts(project) {
|
|
|
63
65
|
* (its `state` excluded); everything else in full. */
|
|
64
66
|
function structuralKey(artifact) {
|
|
65
67
|
return artifact.kind === 'app'
|
|
66
|
-
? semanticKey({
|
|
68
|
+
? semanticKey({ ...artifact.doc, state: null })
|
|
67
69
|
: semanticKey(artifact.doc);
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -115,7 +117,25 @@ export function describe(project, options = {}) {
|
|
|
115
117
|
active: project.active,
|
|
116
118
|
layout: project.layout,
|
|
117
119
|
files: project.files.map((file) => {
|
|
118
|
-
|
|
120
|
+
let v;
|
|
121
|
+
try {
|
|
122
|
+
projectFileContext(project, file);
|
|
123
|
+
let effective = file;
|
|
124
|
+
if (file.imports) {
|
|
125
|
+
let byFile = assembledFiles.get(project.files);
|
|
126
|
+
if (!byFile) { byFile = new Map(); assembledFiles.set(project.files, byFile); }
|
|
127
|
+
effective = byFile.get(file);
|
|
128
|
+
if (!effective) {
|
|
129
|
+
effective = { ...file, text: JSON.stringify(resolveProjectFile(project, file.name).doc) };
|
|
130
|
+
byFile.set(file, effective);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
v = validateFile(effective, options);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
v = { valid: false, total: 1, errors: [{ code: error.code ?? null,
|
|
137
|
+
message: error.message, docPath: error.docPath ?? '' }] };
|
|
138
|
+
}
|
|
119
139
|
return {
|
|
120
140
|
name: file.name,
|
|
121
141
|
kind: file.kind,
|
package/src/author.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Constrain one file's generation, then apply the full grammar/compiler.
|
|
3
|
+
* This helper returns a candidate; the host owns conflict-safe publication. */
|
|
4
|
+
import { createStructuredOutput } from '@jarenjs/ai';
|
|
5
|
+
import { validateFile } from './validate.js';
|
|
6
|
+
import { resolveProjectFile } from './resolve.js';
|
|
7
|
+
import app from '@jarenjs/app/schemas/jaren-app.authoring.schema.json' with { type: 'json' };
|
|
8
|
+
import query from '@jarenjs/json/schemas/jaren-query.authoring.schema.json' with { type: 'json' };
|
|
9
|
+
import jslt from '@jarenjs/json/schemas/jaren-jslt.authoring.schema.json' with { type: 'json' };
|
|
10
|
+
import fsm from '@jarenjs/flow/schemas/jaren-fsm.authoring.schema.json' with { type: 'json' };
|
|
11
|
+
import dag from '@jarenjs/flow/schemas/jaren-dag.authoring.schema.json' with { type: 'json' };
|
|
12
|
+
import model from '@jarenjs/db/schemas/jaren-model.authoring.schema.json' with { type: 'json' };
|
|
13
|
+
|
|
14
|
+
export const AUTHORABLE_KINDS = Object.freeze(['app', 'query', 'jslt', 'fsm', 'dag', 'model', 'schema', 'state', 'data', 'contract']);
|
|
15
|
+
const PROFILES = { app, query, jslt, fsm, dag, model,
|
|
16
|
+
schema: { anyOf: [{ type: 'object' }, { type: 'boolean' }] },
|
|
17
|
+
state: {}, data: {}, contract: { type: 'object' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** @param {{ client: any, operators?: any, maxRepairs?: number }} options */
|
|
21
|
+
export function createStudioFileAuthor(options) {
|
|
22
|
+
return {
|
|
23
|
+
/** @param {{ project: any, name: string, kind?: string, prompt: string }} request
|
|
24
|
+
* @param {{ signal?: AbortSignal }} [hooks] */
|
|
25
|
+
async author(request, hooks = {}) {
|
|
26
|
+
const original = request.project.files.find((f) => f.name === request.name);
|
|
27
|
+
const kind = request.kind ?? original?.kind;
|
|
28
|
+
if (!AUTHORABLE_KINDS.includes(kind)) throw new TypeError('choose an authorable file kind');
|
|
29
|
+
// Imported destinations are authored as fragments, never expanded into
|
|
30
|
+
// duplicated members. Their assembled artifact is the acceptance gate.
|
|
31
|
+
const profile = original?.imports ? { type: 'object' } : PROFILES[kind];
|
|
32
|
+
const check = (doc) => {
|
|
33
|
+
try {
|
|
34
|
+
const file = { ...original, name: request.name, kind, text: JSON.stringify(doc) };
|
|
35
|
+
const project = { files: [...request.project.files.filter((f) => f.name !== file.name), file] };
|
|
36
|
+
const assembled = resolveProjectFile(project, file.name).doc;
|
|
37
|
+
const verdict = validateFile({ ...file, text: JSON.stringify(assembled) }, { operators: options.operators });
|
|
38
|
+
if (!verdict.valid) return verdict;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) { return { valid: false, errors: [{ code: error.code ?? null,
|
|
42
|
+
docPath: error.docPath ?? '', message: error.reason ?? error.message }] }; }
|
|
43
|
+
};
|
|
44
|
+
const generator = createStructuredOutput({ client: options.client, schema: profile,
|
|
45
|
+
name: `studio_${kind}`, strict: false, validator: check, maxRepairs: options.maxRepairs ?? 2 });
|
|
46
|
+
const result = await generator.generate([
|
|
47
|
+
{ role: 'system', content: `Author exactly one ${kind} file named ${request.name}. Return its JSON value only. Do not return a project envelope. Files available: ${JSON.stringify(request.project.files.map((f) => ({ name: f.name, kind: f.kind })))}.${original?.imports ? ` Imported members are supplied by ${JSON.stringify(original.imports)}; omit these members from your output.` : ''}` },
|
|
48
|
+
...(original ? [{ role: 'user', content: `Current file:\n${original.text}` }] : []),
|
|
49
|
+
{ role: 'user', content: request.prompt },
|
|
50
|
+
], hooks);
|
|
51
|
+
if (!('value' in result)) return result;
|
|
52
|
+
return { ...result, file: { ...original, name: request.name, kind, text: JSON.stringify(result.value, null, 2) } };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/component/view.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* kind badge rides `data-badge`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { ADDABLE_KINDS } from '../skeletons.js';
|
|
17
18
|
import { editorTextarea } from './editor.js';
|
|
18
19
|
|
|
19
20
|
/** The one mode this view uses. */
|
|
@@ -85,13 +86,7 @@ const shell = {
|
|
|
85
86
|
['nav', { class: 'js-rail', 'aria-label': 'files' },
|
|
86
87
|
['select', { class: 'js-addfile', 'aria-label': 'add a file', value: '', on: { change: 'project/add-file' } },
|
|
87
88
|
['option', { value: '' }, '+ add file…'],
|
|
88
|
-
['option', { value:
|
|
89
|
-
['option', { value: 'jslt' }, 'jslt'],
|
|
90
|
-
['option', { value: 'query' }, 'query'],
|
|
91
|
-
['option', { value: 'state' }, 'state'],
|
|
92
|
-
['option', { value: 'data' }, 'data'],
|
|
93
|
-
['option', { value: 'schema' }, 'schema'],
|
|
94
|
-
['option', { value: 'contract' }, 'contract'],
|
|
89
|
+
...ADDABLE_KINDS.map((kind) => ['option', { value: kind }, kind]),
|
|
95
90
|
],
|
|
96
91
|
[{ $apply: '$.rail[*]' }]],
|
|
97
92
|
// ——— editor ———
|
|
@@ -124,6 +119,17 @@ const shell = {
|
|
|
124
119
|
}, 'Take theirs'],
|
|
125
120
|
],
|
|
126
121
|
''] },
|
|
122
|
+
{ $if: ['$.routing', ['div', { class: 'js-routing' },
|
|
123
|
+
{ $if: ['$.routing.hasInput', ['label', {}, 'Input ', ['select', { 'aria-label': 'Input file', value: '$.routing.input', on: { change: { action: 'project/route-input' } } },
|
|
124
|
+
['option', { value: '' }, 'Default data file'],
|
|
125
|
+
[{ $for: { name: '$.routing.inputs[*]' }, $return: ['option', { value: '$name' }, '$name'] }]]], ''] },
|
|
126
|
+
{ $if: ['$.routing.canModel', ['label', {}, 'Model ',
|
|
127
|
+
['select', { 'aria-label': 'Model file', value: '$.routing.model', on: { change: 'project/route-model' } },
|
|
128
|
+
['option', { value: '' }, 'In-memory input'],
|
|
129
|
+
[{ $for: { name: '$.routing.models[*]' }, $return: ['option', { value: '$name' }, '$name'] }]]], ''] },
|
|
130
|
+
{ $if: ['$.routing.hasCollection', ['label', {}, 'Collection ', ['input', { 'aria-label': 'Collection', defaultValue: '$.routing.collection',
|
|
131
|
+
key: '$.active', placeholder: 'Only collection', on: { change: 'project/route-collection' } }]], ''] },
|
|
132
|
+
], ''] },
|
|
127
133
|
editorTextarea({
|
|
128
134
|
value: '$.editorValue',
|
|
129
135
|
action: 'project/file-text',
|
|
@@ -147,7 +153,7 @@ const shell = {
|
|
|
147
153
|
['div', { class: 'js-stage-head' }, 'Stage'],
|
|
148
154
|
{ $if: ['$.stageError', ['div', { class: 'js-stage-error js-stage-fail', role: 'status' }, '$.stageError'], ''] },
|
|
149
155
|
{ $if: [{ $eq: ['$.stage.kind', 'app'] },
|
|
150
|
-
['div', { class: 'js-stage-mount' }, ['jaren-widget', { name: 'studio-stage', props: '$.stage.mount' }]],
|
|
156
|
+
['div', { class: 'js-stage-mount' }, ['jaren-widget', { name: { $default: ['$.stage.widget', 'studio-stage'] }, props: '$.stage.mount' }]],
|
|
151
157
|
{ $if: [{ $eq: ['$.stage.kind', 'result'] },
|
|
152
158
|
{ $if: ['$.stage.ran',
|
|
153
159
|
['div', { class: 'js-stage-result' }, [{ $apply: ['$.stage.nodes[*]', 'ui'] }]],
|
|
@@ -21,13 +21,14 @@ const MOBILE_PANES = ['files', 'editor', 'stage'];
|
|
|
21
21
|
/** The stage the active file drives. */
|
|
22
22
|
function deriveStage(project, activeMeta, results, revision, committed) {
|
|
23
23
|
if (activeMeta === null) return { kind: 'empty', note: 'Add a file to begin.' };
|
|
24
|
-
if (
|
|
24
|
+
if (committed?.store) return { kind: 'app', widget: 'studio-data', mount: committed };
|
|
25
|
+
if (committed?.kind === 'app' || ['app', 'fsm', 'dag'].includes(activeMeta.kind)) {
|
|
25
26
|
// A host that runs the app live commits the LAST-GOOD assembled
|
|
26
27
|
// document (with its own reboot revision). That wins over the current
|
|
27
28
|
// text: a parse error in the editor must never blank the stage — the
|
|
28
29
|
// last good frame stays until the next VALID commit replaces it.
|
|
29
30
|
if (committed !== null && committed.doc) {
|
|
30
|
-
return { kind: 'app',
|
|
31
|
+
return { kind: 'app', widget: (committed.kind ?? activeMeta.kind) === 'app' ? 'studio-stage' : 'studio-flow', mount: committed };
|
|
31
32
|
}
|
|
32
33
|
const artifact = assembleArtifacts(project).artifacts.find((a) => a.name === activeMeta.name);
|
|
33
34
|
if (artifact === undefined || !activeMeta.valid) {
|
|
@@ -35,7 +36,7 @@ function deriveStage(project, activeMeta, results, revision, committed) {
|
|
|
35
36
|
}
|
|
36
37
|
// the reference-stable mount is memoized by the host at wiring time;
|
|
37
38
|
// here it is the data: the assembled document + the reboot revision
|
|
38
|
-
return { kind: 'app', mount: { doc: artifact.doc, revision } };
|
|
39
|
+
return { kind: 'app', widget: activeMeta.kind === 'app' ? 'studio-stage' : 'studio-flow', mount: { name: activeMeta.name, kind: activeMeta.kind, doc: artifact.doc, revision } };
|
|
39
40
|
}
|
|
40
41
|
if (activeMeta.kind === 'jslt' || activeMeta.kind === 'query' || activeMeta.kind === 'schema'
|
|
41
42
|
|| activeMeta.kind === 'contract') {
|
|
@@ -49,10 +50,7 @@ function deriveStage(project, activeMeta, results, revision, committed) {
|
|
|
49
50
|
if (activeMeta.kind === 'state' || activeMeta.kind === 'data') {
|
|
50
51
|
return { kind: 'inert', note: 'An input — edit it as text; it feeds the app, a query or a validation.' };
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
// runner here yet — the flow canvas and the worker-backed store still
|
|
54
|
-
// live on their own website surfaces (both are tracked in ROADMAP.md)
|
|
55
|
-
return { kind: 'inert', note: `The ${activeMeta.role} editor is not built yet; edit it as text meanwhile.` };
|
|
53
|
+
return { kind: 'inert', note: `Press Run to open the ${activeMeta.role}.` };
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
/**
|
|
@@ -100,7 +98,7 @@ export function projectViewModel(state, options = {}) {
|
|
|
100
98
|
// used only while it is the active file — a reference-stable document
|
|
101
99
|
// the stage widget reboots (revision change) or hot-updates (same
|
|
102
100
|
// revision, new state) against
|
|
103
|
-
const committed = (slice.mount && slice.mount.name === activeName) ? slice.mount : null;
|
|
101
|
+
const committed = (slice.mount && (slice.mount.name === activeName || slice.mount.sourceFiles?.includes(activeName))) ? slice.mount : null;
|
|
104
102
|
|
|
105
103
|
// The editor's value is the typing BUFFER reconciled against the file's
|
|
106
104
|
// committed text, never the committed text alone: the buffer is what the
|
|
@@ -134,6 +132,13 @@ export function projectViewModel(state, options = {}) {
|
|
|
134
132
|
? slice.renameDraft.text
|
|
135
133
|
: activeName,
|
|
136
134
|
activeKind: activeMeta?.kind ?? null,
|
|
135
|
+
routing: activeFile && ['query', 'jslt', 'schema', 'fsm', 'dag', 'model'].includes(activeFile.kind) ? {
|
|
136
|
+
input: activeFile.input ?? '', model: activeFile.model ?? '', collection: activeFile.collection ?? '',
|
|
137
|
+
canModel: activeFile.kind === 'query',
|
|
138
|
+
hasInput: !activeFile.model, hasCollection: activeFile.kind === 'model' || !!activeFile.model,
|
|
139
|
+
inputs: project.files.filter((f) => ['data', 'state'].includes(f.kind)).map((f) => f.name),
|
|
140
|
+
models: project.files.filter((f) => f.kind === 'model').map((f) => f.name),
|
|
141
|
+
} : null,
|
|
137
142
|
activeValid: activeMeta?.valid ?? true,
|
|
138
143
|
activeErrors: activeMeta?.errors ?? [],
|
|
139
144
|
editorValue,
|
package/src/errors.js
CHANGED
|
@@ -17,6 +17,7 @@ import { CodedError } from '@jarenjs/core/errors';
|
|
|
17
17
|
export const STUDIO_CODES = Object.freeze({
|
|
18
18
|
JS0001: 'the project document is invalid',
|
|
19
19
|
JS0002: 'a file name is duplicated in the project',
|
|
20
|
+
JS0003: 'a project file reference cannot be resolved',
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
/** A studio-envelope error. */
|
package/src/export.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** A deterministic ZIP export. Dependencies are supplied as local bundled
|
|
3
|
+
* bytes, never silently replaced with mutable CDN URLs. */
|
|
4
|
+
import { parseProject } from './project.js';
|
|
5
|
+
|
|
6
|
+
const utf8 = new TextEncoder();
|
|
7
|
+
const crc32 = (bytes) => {
|
|
8
|
+
let crc = 0xffffffff;
|
|
9
|
+
for (const byte of bytes) {
|
|
10
|
+
crc ^= byte;
|
|
11
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
12
|
+
}
|
|
13
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** ZIP method 0 (stored), UTF-8 names, fixed DOS epoch, no ZIP64.
|
|
17
|
+
* @param {Record<string, string | Uint8Array>} files @returns {Uint8Array} */
|
|
18
|
+
export function createProjectZip(files) {
|
|
19
|
+
const entries = Object.entries(files).map(([name, content]) => {
|
|
20
|
+
if (!name || name.startsWith('/') || name.includes('\\') || [...name].some((char) => char.charCodeAt(0) < 32)
|
|
21
|
+
|| name.split('/').some((part) => part === '..' || part === '.' || part === ''))
|
|
22
|
+
throw new TypeError(`unsafe archive path '${name}'`);
|
|
23
|
+
const path = utf8.encode(name);
|
|
24
|
+
if (path.length > 65535) throw new RangeError('archive path is too long');
|
|
25
|
+
return { path, bytes: typeof content === 'string' ? utf8.encode(content) : content, offset: 0 };
|
|
26
|
+
});
|
|
27
|
+
const size = entries.reduce((n, e) => n + 76 + e.path.length * 2 + e.bytes.length, 22);
|
|
28
|
+
if (entries.length > 65535 || size > 0xffffffff) throw new RangeError('ZIP64 is not supported');
|
|
29
|
+
const out = new Uint8Array(size);
|
|
30
|
+
const data = new DataView(out.buffer);
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
const u16 = (value) => { data.setUint16(cursor, value, true); cursor += 2; };
|
|
33
|
+
const u32 = (value) => { data.setUint32(cursor, value, true); cursor += 4; };
|
|
34
|
+
const bytes = (value) => { out.set(value, cursor); cursor += value.length; };
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
entry.offset = cursor;
|
|
37
|
+
u32(0x04034b50); u16(20); u16(0x800); u16(0); u16(0); u16(33);
|
|
38
|
+
u32(crc32(entry.bytes)); u32(entry.bytes.length); u32(entry.bytes.length);
|
|
39
|
+
u16(entry.path.length); u16(0); bytes(entry.path); bytes(entry.bytes);
|
|
40
|
+
}
|
|
41
|
+
const directory = cursor;
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
u32(0x02014b50); u16(20); u16(20); u16(0x800); u16(0); u16(0); u16(33);
|
|
44
|
+
u32(crc32(entry.bytes)); u32(entry.bytes.length); u32(entry.bytes.length);
|
|
45
|
+
u16(entry.path.length); u16(0); u16(0); u16(0); u16(0); u32(0); u32(entry.offset); bytes(entry.path);
|
|
46
|
+
}
|
|
47
|
+
const directorySize = cursor - directory;
|
|
48
|
+
u32(0x06054b50); u16(0); u16(0); u16(entries.length); u16(entries.length);
|
|
49
|
+
u32(directorySize); u32(directory); u16(0);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {any} project @param {Record<string, string | Uint8Array>} assets */
|
|
54
|
+
export function exportProject(project, assets) {
|
|
55
|
+
const normalized = parseProject({ project: project.project, files: project.files,
|
|
56
|
+
...(project.active ? { active: project.active } : {}), layout: project.layout });
|
|
57
|
+
if (!Object.hasOwn(assets, 'runtime.js')) throw new TypeError('a bundled runtime.js is required');
|
|
58
|
+
const files = {
|
|
59
|
+
'index.html': '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Jaren project</title><link rel="stylesheet" href="runtime/runtime.css"><body><main id="app"></main><script type="module" src="runtime/runtime.js"></script></body></html>',
|
|
60
|
+
'project.json': JSON.stringify(normalized, null, 2),
|
|
61
|
+
'README.md': '# Jaren offline project\n\nUnzip, then serve this directory locally:\n\n python3 -m http.server 8080 --bind 127.0.0.1\n\nOpen http://127.0.0.1:8080. No package installation or internet connection is needed.\n\nDependency policy: the runtime, worker, SQLite WASM and CSS are bundled from the exporting build, with versions in runtime/versions.json. There are no CDN dependencies. Application-authored external links/assets still require their own hosts. The host grants no application effects or subscriptions; flow effects are logged. Unknown DAG tasks are refused.\n\nproject.json preserves every file, reference, and layout. files/ contains verbatim source text in project order; numeric names preserve arbitrary original names safely in an archive. The runner selects any project file. Model stores are private in-memory SQLite and last until reload; database rows are runtime state and are not included in this source export.\n',
|
|
62
|
+
};
|
|
63
|
+
normalized.files.forEach((file, i) => { files[`files/${String(i + 1).padStart(4, '0')}.json`] = file.text; });
|
|
64
|
+
for (const [name, bytes] of Object.entries(assets)) files[`runtime/${name}`] = bytes;
|
|
65
|
+
return createProjectZip(files);
|
|
66
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,3 +18,5 @@ export { KINDS, LAYOUT_DEFAULT, parseProject, fileOf } from './project.js';
|
|
|
18
18
|
export { validateFile } from './validate.js';
|
|
19
19
|
export { assembleArtifacts, classifyChange, describe } from './assemble.js';
|
|
20
20
|
export { STUDIO_CODES, StudioError } from './errors.js';
|
|
21
|
+
export { resolveProjectFile, projectFileContext, renameProjectFile, writeProjectArtifact } from './resolve.js';
|
|
22
|
+
export { ADDABLE_KINDS, fileSkeleton } from './skeletons.js';
|
package/src/project.js
CHANGED
|
@@ -65,7 +65,7 @@ export function parseProject(input) {
|
|
|
65
65
|
: (doc.files[0]?.name ?? null);
|
|
66
66
|
return Object.freeze({
|
|
67
67
|
project: doc.project,
|
|
68
|
-
files: Object.freeze(doc.files.map((f) => Object.freeze({ ...f }))),
|
|
68
|
+
files: Object.freeze(doc.files.map((f) => Object.freeze({ ...f, ...(f.imports ? { imports: Object.freeze({ ...f.imports }) } : {}) }))),
|
|
69
69
|
active,
|
|
70
70
|
layout: Object.freeze({ ...LAYOUT_DEFAULT, ...doc.layout }),
|
|
71
71
|
});
|