@jarenjs/studio 0.75.0 → 0.83.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.
@@ -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": ["project", "files"],
7
+ "required": [
8
+ "project",
9
+ "files"
10
+ ],
8
11
  "additionalProperties": false,
9
12
  "properties": {
10
- "project": { "enum": ["0.1"] },
13
+ "project": {
14
+ "enum": [
15
+ "0.1"
16
+ ]
17
+ },
11
18
  "files": {
12
19
  "type": "array",
13
- "items": { "$ref": "#/definitions/file" }
20
+ "items": {
21
+ "$ref": "#/definitions/file"
22
+ }
23
+ },
24
+ "active": {
25
+ "type": "string"
14
26
  },
15
- "active": { "type": "string" },
16
- "layout": { "$ref": "#/definitions/layout" }
27
+ "layout": {
28
+ "$ref": "#/definitions/layout"
29
+ }
17
30
  },
18
31
  "definitions": {
19
32
  "file": {
20
33
  "type": "object",
21
- "required": ["name", "kind", "text"],
34
+ "required": [
35
+ "name",
36
+ "kind",
37
+ "text"
38
+ ],
22
39
  "additionalProperties": false,
23
40
  "properties": {
24
- "name": { "type": "string", "minLength": 1 },
41
+ "name": {
42
+ "type": "string",
43
+ "minLength": 1
44
+ },
25
45
  "kind": {
26
- "enum": ["app", "jslt", "query", "state", "data", "schema", "fsm", "dag", "model", "contract"]
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": { "type": "string" }
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": { "enum": ["classic", "right", "top"] },
36
- "ratio": { "type": "number", "minimum": 0, "maximum": 1 },
37
- "autorun": { "type": "boolean" }
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 so a data file may use host-registered operators the closed grammars forbid.",
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": ["project", "files"],
7
+ "required": [
8
+ "project",
9
+ "files"
10
+ ],
8
11
  "additionalProperties": false,
9
12
  "properties": {
10
- "project": { "const": "0.1" },
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": { "$ref": "#/$defs/file" }
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": { "$ref": "#/$defs/layout" }
27
+ "layout": {
28
+ "$ref": "#/$defs/layout"
29
+ }
21
30
  },
22
31
  "$defs": {
23
32
  "file": {
24
33
  "type": "object",
25
- "required": ["name", "kind", "text"],
34
+ "required": [
35
+ "name",
36
+ "kind",
37
+ "text"
38
+ ],
26
39
  "additionalProperties": false,
27
40
  "properties": {
28
- "name": { "type": "string", "minLength": 1 },
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": ["app", "jslt", "query", "state", "data", "schema", "fsm", "dag", "model", "contract"]
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
- "text": { "type": "string" }
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 frozen so it survives a share link and the eject.",
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": { "enum": ["classic", "right", "top"] },
42
- "ratio": { "type": "number", "minimum": 0, "maximum": 1 },
43
- "autorun": { "type": "boolean" }
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
- * This order ships the WHOLE-DOCUMENT contract: a runnable file (`app`,
8
- * `fsm`, `dag`, `model`, `jslt`, `query`, `schema`) is its own artifact;
9
- * `state`/`data` files are inputs, not artifacts. Fragment assembly —
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 (an app's `view`, `actions` and `subs`) via the suite's own
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 (whole-document).
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 doc;
50
- try { doc = JSON.parse(file.text); }
51
+ let resolved;
52
+ try { resolved = resolveProjectFile(project, file.name); }
51
53
  catch (err) {
52
- errors.push({ file: file.name, message: `not valid JSON: ${String(/** @type {any} */ (err)?.message ?? err)}` });
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], doc, sourceFiles: [file.name],
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({ view: artifact.doc.view, actions: artifact.doc.actions, subs: artifact.doc.subs })
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
- const v = validateFile(file, options);
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
+ }
@@ -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: 'app' }, 'app'],
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 (activeMeta.kind === 'app') {
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', mount: { doc: committed.doc, revision: committed.revision } };
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
- // fsm / dag / model validate and assemble, but have no editor and no
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
  });