@jxsuite/schema 5.6.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,7 @@ Two subpaths let any host edit and write a Jx document the way Studio does, with
48
48
 
49
49
  - **`@jxsuite/schema/doc-ops`** is the document-op vocabulary. A `JxDocOp` (`set-key`, `insert-child`, `remove-child`, `set-child`, `move-child`) is a value-carrying mutation addressed by `JxPath`. `applyDocOpToDoc(doc, op)` applies one to a plain tree; `inverseOf(doc, op)` computes the op that undoes it, against the document as it stands before the op, in the coordinates of the document after it; `applyDocOpsWithInverse(doc, ops)` applies a sequence and answers each forward/inverse pair. Studio's history, the canvas iframe's shadow document and the collab bridge all replay through this one implementation, and `@jxsuite/collab/ops` re-exports it. Undoing an insert or move into a node that had no `children` leaves `children: []` behind, as Studio's history always has.
50
50
  - **`@jxsuite/schema/json-layout`** is the layout-preserving JSON serializer behind every save. `parseJsonDocument(text)` answers the document and the facts about its text the formatter keeps; `serializeJson(document, layout)` writes it back with those honoured, so a one-value edit is a one-line diff.
51
+ - **`@jxsuite/schema/dynamic-tag`** reads the structured subset of template expressions an editor can show as fields: `parseDynamicTag(expr)` takes the text inside one `${…}` and answers a tag (a path, optionally through `state.<formula>(…)`, optionally negated, optionally with a `??` or `||` fallback literal) or `null` for anything else; `printDynamicTag(tag)` writes it back, byte for byte for canonical input; `dynamicTagLabel(tag)` names it; `templateSegments(template)` splits a whole template string into literal text, tags and custom expressions, each tag with its verbatim source.
51
52
 
52
53
  ## Component schema coverage
53
54
 
package/package.json CHANGED
@@ -31,9 +31,13 @@
31
31
  "./parse": "./src/parse.ts",
32
32
  "./jxn": "./src/jxn/index.ts",
33
33
  "./virtual-types": "./src/jxn/virtual-types.ts",
34
+ "./virtual-document": "./src/jxn/virtual-document.ts",
35
+ "./jxn-completion": "./src/jxn/complete.ts",
34
36
  "./project-file": "./src/project-file.ts",
35
37
  "./project-file/node": "./src/project-file-node.ts",
36
38
  "./doc-ops": "./src/doc-ops.ts",
39
+ "./document-scripts": "./src/document-scripts.ts",
40
+ "./dynamic-tag": "./src/dynamic-tag.ts",
37
41
  "./json-layout": "./src/json-layout.ts",
38
42
  "./defs": "./defs/index.ts",
39
43
  "./schema.json": "./schema.json",
@@ -68,7 +72,7 @@
68
72
  "upgrade": "bun update --latest && bun install"
69
73
  },
70
74
  "type": "module",
71
- "version": "5.6.0",
75
+ "version": "6.0.0",
72
76
  "devDependencies": {
73
77
  "json-schema-to-ts": "^3.1.1"
74
78
  }
package/schema.json CHANGED
@@ -28509,6 +28509,21 @@
28509
28509
  }
28510
28510
  ]
28511
28511
  },
28512
+ "$script": {
28513
+ "additionalProperties": false,
28514
+ "description": "This document's own modules, as TypeScript module text (spec.md §23.2). A state entry names an export of one with `$src: \"#client\"` or `$src: \"#server\"`. `client` runs in the browser; `server` runs only on the server and is never sent to one.",
28515
+ "properties": {
28516
+ "client": {
28517
+ "description": "The module the browser runs. A Function entry with `$src: \"#client\"` is one of its exports.",
28518
+ "type": "string"
28519
+ },
28520
+ "server": {
28521
+ "description": "The module the server runs. A `timing: \"server\"` entry with `$src: \"#server\"` is one of its exports, called with `(args, env)`.",
28522
+ "type": "string"
28523
+ }
28524
+ },
28525
+ "type": "object"
28526
+ },
28512
28527
  "$shadow": {
28513
28528
  "description": "Render this component into a shadow root instead of the light DOM (spec.md §16.6). Off unless set: a shadow root isolates the component's styles from the page and replaces the <slot> emulation with real slot distribution, so it changes how the component composes. Overrides `defaults.shadow` in both directions — `false` opts one component out of a project that opted in.",
28514
28529
  "oneOf": [
@@ -0,0 +1,304 @@
1
+ /**
2
+ * A document's own modules (spec.md §23.2): how a `$src` names them, and how that name is given the
3
+ * document it belongs to.
4
+ *
5
+ * A document carries its client and server modules as text in `$script`, and an entry names one
6
+ * with `$src: "#client"` or `$src: "#server"`. Those two strings say nothing about WHICH document,
7
+ * and everything downstream keys a module on the specifier it was spelled with: the interpreter's
8
+ * module cache, the build's bundle table, the site worker's one-route-per-export rule. A page and
9
+ * its layout each naming `#client` would be one module to all of them.
10
+ *
11
+ * So a document is **qualified** once, as it is read and before anything merges it with another:
12
+ * `#client` becomes `jx-script:<path>#client`, where `<path>` is the document's own address. After
13
+ * that a page's module and its layout's are different strings, and nothing downstream needs to know
14
+ * that document scripts exist.
15
+ *
16
+ * Pure string and tree work, with no node imports, so every host can use it.
17
+ *
18
+ * @docs framework/concepts/functions
19
+ */
20
+
21
+ import { isJsonObject, isString } from "./guards";
22
+ import type { JsonValue, JxDocument, JxDocumentValue, JxElement } from "../types";
23
+
24
+ export type { JxDocumentScripts } from "../types";
25
+
26
+ /** The two modules a document may carry: one the browser runs, one the server runs. */
27
+ export type ScriptSide = "client" | "server";
28
+
29
+ /** What a qualified script specifier starts with. */
30
+ export const SCRIPT_SPECIFIER_PREFIX = "jx-script:";
31
+
32
+ /** The authored `$src` of an entry naming its own document's client module. */
33
+ export const CLIENT_SCRIPT_REF = "#client";
34
+
35
+ /** The authored `$src` of an entry naming its own document's server module. */
36
+ export const SERVER_SCRIPT_REF = "#server";
37
+
38
+ /** A qualified specifier, read back: the document it names, and which of its modules. */
39
+ /** A URL path naming a TypeScript module. */
40
+ const TYPESCRIPT_PATH = /\.[cm]?ts$/iu;
41
+
42
+ /**
43
+ * True when a URL path names a TypeScript module: text a browser cannot import as written, which a
44
+ * page's module loader erases first.
45
+ *
46
+ * @param pathname A URL's path, without its query or fragment.
47
+ */
48
+ export function isTypeScriptPath(pathname: string): boolean {
49
+ return TYPESCRIPT_PATH.test(pathname);
50
+ }
51
+
52
+ /**
53
+ * A document as a browser may have it: without its server module. The module is called over the
54
+ * network and never imported by a page, so its text is nothing a reader should be sent.
55
+ *
56
+ * The document given is not changed, and one with no server module is returned as it is. `$script`
57
+ * itself stays, however little is left in it: it is what says the document has modules of its own,
58
+ * which is what has a host qualify the entries that name them.
59
+ *
60
+ * @param doc The document as read.
61
+ */
62
+ export function withoutServerScript<T extends JxDocument>(doc: T): T {
63
+ const script = doc.$script;
64
+ if (script?.server === undefined) {
65
+ return doc;
66
+ }
67
+ const { server: _server, ...kept } = script;
68
+ return { ...doc, $script: kept };
69
+ }
70
+
71
+ export interface ScriptSpecifier {
72
+ /** The document's address, as it was qualified: `/`-separated and without a leading `./`. */
73
+ path: string;
74
+ side: ScriptSide;
75
+ }
76
+
77
+ /** The side an authored `$src` names when it names its own document's module, else `null`. */
78
+ export function scriptRefSide(src: string): ScriptSide | null {
79
+ if (src === CLIENT_SCRIPT_REF) {
80
+ return "client";
81
+ }
82
+ if (src === SERVER_SCRIPT_REF) {
83
+ return "server";
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** A document address in the one spelling a specifier uses: `/` separators, no leading `./`. */
89
+ function normalizedPath(path: string): string {
90
+ return path.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
91
+ }
92
+
93
+ /**
94
+ * The specifier naming one of a document's modules: `jx-script:components/counter.jxn#client`.
95
+ *
96
+ * @param path The document's address, project-relative in a project.
97
+ * @param side Which module.
98
+ */
99
+ export function scriptSpecifier(path: string, side: ScriptSide): string {
100
+ return `${SCRIPT_SPECIFIER_PREFIX}${normalizedPath(path)}#${side}`;
101
+ }
102
+
103
+ /** True when a `$src` is a qualified script specifier. */
104
+ export function isScriptSpecifier(src: string): boolean {
105
+ return src.startsWith(SCRIPT_SPECIFIER_PREFIX);
106
+ }
107
+
108
+ /**
109
+ * Read a qualified specifier back into the document it names and the side. `null` for anything
110
+ * else, including a `jx-script:` specifier that names no document or neither side.
111
+ */
112
+ export function parseScriptSpecifier(src: string): ScriptSpecifier | null {
113
+ if (!isScriptSpecifier(src)) {
114
+ return null;
115
+ }
116
+ const rest = src.slice(SCRIPT_SPECIFIER_PREFIX.length);
117
+ const hash = rest.lastIndexOf("#");
118
+ if (hash <= 0) {
119
+ return null;
120
+ }
121
+ const side = rest.slice(hash + 1);
122
+ if (side !== "client" && side !== "server") {
123
+ return null;
124
+ }
125
+ return { path: rest.slice(0, hash), side };
126
+ }
127
+
128
+ /**
129
+ * One value of a document with every `$src` naming its own document's module qualified. A value
130
+ * with nothing to qualify is returned as it was given, so an untouched subtree is shared rather
131
+ * than copied.
132
+ */
133
+ function qualifiedValue(value: JxDocumentValue, path: string): JxDocumentValue {
134
+ if (Array.isArray(value)) {
135
+ const items = value.map((item) => qualifiedValue(item, path));
136
+ /* SAFETY: `items` holds what `value` held, each member either itself or its qualified copy, so
137
+ it is the same kind of array `value` was. */
138
+ return items.some((item, index) => item !== value[index]) ? (items as JsonValue[]) : value;
139
+ }
140
+ if (!isJsonObject(value)) {
141
+ return value;
142
+ }
143
+ let copy: Record<string, JxDocumentValue> | null = null;
144
+ for (const [key, member] of Object.entries(value)) {
145
+ let next: JxDocumentValue = member;
146
+ if (key === "$src" && isString(member)) {
147
+ const side = scriptRefSide(member);
148
+ next = side === null ? member : scriptSpecifier(path, side);
149
+ } else if (key !== "$script") {
150
+ next = qualifiedValue(member, path);
151
+ }
152
+ if (next !== member) {
153
+ copy ??= { ...value };
154
+ copy[key] = next;
155
+ }
156
+ }
157
+ /* SAFETY: `copy` is `value` with some members replaced by their qualified copies, which are the
158
+ same kinds of value, so it is the kind of object `value` was. */
159
+ return copy === null ? value : (copy as JxDocumentValue);
160
+ }
161
+
162
+ /**
163
+ * A document with every `$src: "#client"` and `$src: "#server"` in it naming THIS document:
164
+ * `jx-script:<path>#client`. Run once as a document is read, before a layout merge or a project
165
+ * state merge, because after either nothing records which file an entry was written in.
166
+ *
167
+ * The document given is not changed, and one with no such entry is returned as it is. `$script`
168
+ * itself is left alone: it is the text the specifier names. Qualifying twice changes nothing.
169
+ *
170
+ * @param doc The document as read.
171
+ * @param path The document's address: project-relative in a project, with either separator.
172
+ */
173
+ export function qualifyScripts<T extends JxDocument>(doc: T, path: string): T {
174
+ /* SAFETY: `qualifiedValue` returns its argument or a copy of it with `$src` strings replaced, so
175
+ a document goes in and the same kind of document comes out. */
176
+ return qualifiedValue(doc, path) as T;
177
+ }
178
+
179
+ // ─── Advice ─────────────────────────────────────────────────────────────────────
180
+
181
+ /** The two things worth saying about how a document names its modules. */
182
+ export type ScriptAdviceRule = "relative-src" | "lazy-shares-script";
183
+
184
+ /**
185
+ * One piece of advice about a `$src`. Advice, not a defect: the document is correct and runs as
186
+ * written everywhere, so nothing that reports one may fail on it.
187
+ */
188
+ export interface ScriptAdvice {
189
+ rule: ScriptAdviceRule;
190
+ /** The entry the `$src` is written on, in the segment vocabulary a document path uses. */
191
+ path: (string | number)[];
192
+ /** One sentence naming what was found, in the author's language. */
193
+ message: string;
194
+ /** What to do instead, and what happens when nothing is done. */
195
+ detail: string;
196
+ }
197
+
198
+ /** Matches a `$src` naming a file beside its document: `./counter.js`, `../lib/format.ts`. */
199
+ const RELATIVE_SRC = /^\.{1,2}\//u;
200
+
201
+ /** Matches a `$src` naming a JSON document, which is a class schema and never a script (§12.2). */
202
+ const JSON_SRC = /\.json$/iu;
203
+
204
+ /**
205
+ * True when a `$src` names a script file by a relative path: the one spelling a document's own
206
+ * script can stand in for. An `npm:` package, a kit behaviour (`jx-ui:/…`), an absolute URL and a
207
+ * `.class.json` schema have no other spelling, and `#client` is already the script.
208
+ *
209
+ * @param src An entry's `$src`.
210
+ */
211
+ export function isRelativeScriptSrc(src: string): boolean {
212
+ return RELATIVE_SRC.test(src) && !JSON_SRC.test(src);
213
+ }
214
+
215
+ /** True when a `$src` names its document's client module, as authored or once qualified. */
216
+ function namesClientScript(src: string): boolean {
217
+ return scriptRefSide(src) === "client" || parseScriptSpecifier(src)?.side === "client";
218
+ }
219
+
220
+ /** An entry carrying a `$src`, as the walk found it. */
221
+ interface SrcEntry {
222
+ path: (string | number)[];
223
+ src: string;
224
+ lazy: boolean;
225
+ }
226
+
227
+ /** Every object in a document that carries a string `$src`, in document order. */
228
+ function srcEntries(
229
+ value: JxDocumentValue,
230
+ path: (string | number)[],
231
+ found: SrcEntry[],
232
+ ): SrcEntry[] {
233
+ if (Array.isArray(value)) {
234
+ for (const [index, item] of value.entries()) {
235
+ srcEntries(item, [...path, index], found);
236
+ }
237
+ return found;
238
+ }
239
+ if (!isJsonObject(value)) {
240
+ return found;
241
+ }
242
+ if (isString(value.$src)) {
243
+ found.push({ lazy: value.$lazy === true, path, src: value.$src });
244
+ }
245
+ for (const [key, member] of Object.entries(value)) {
246
+ // `$script` is the modules' text, not entries that name one.
247
+ if (key !== "$script") {
248
+ srcEntries(member, [...path, key], found);
249
+ }
250
+ }
251
+ return found;
252
+ }
253
+
254
+ /** What an entry is called in a sentence: its key, or `this entry` for one in a list. */
255
+ function entryName(path: (string | number)[]): string {
256
+ const last = path.at(-1);
257
+ return isString(last) ? `"${last}"` : "this entry";
258
+ }
259
+
260
+ /**
261
+ * What there is to say about how a document names its modules (spec.md §23.2).
262
+ *
263
+ * - **`relative-src`**: an entry imports a file beside the document. A module only that document uses
264
+ * can be the document's own script, which makes the document one file. A `$lazy` entry is left
265
+ * alone: a module of its own is exactly what it needs.
266
+ * - **`lazy-shares-script`**: a `$lazy` entry names the client script that another entry loads up
267
+ * front. A document has one client script, so the deferral defers nothing.
268
+ *
269
+ * `jx validate` and Studio's Problems both report from here, so the two say the same thing about
270
+ * the same document.
271
+ *
272
+ * @param doc The document as written, or as qualified: both spellings of `#client` are read.
273
+ */
274
+ export function findScriptAdvice(doc: JxDocument | JxElement): ScriptAdvice[] {
275
+ const entries = srcEntries(doc, [], []);
276
+ const eagerClient = entries.some((entry) => !entry.lazy && namesClientScript(entry.src));
277
+ const advice: ScriptAdvice[] = [];
278
+ for (const entry of entries) {
279
+ const name = entryName(entry.path);
280
+ if (!entry.lazy && isRelativeScriptSrc(entry.src)) {
281
+ advice.push({
282
+ detail:
283
+ "A module only this document uses can be the document's own script: move the code " +
284
+ 'into the client script (the server script for a `timing: "server"` entry) and name ' +
285
+ 'it with `$src: "#client"` or `$src: "#server"`. A module several documents import ' +
286
+ "stays a file, and this one keeps working as it is.",
287
+ message: `${name} imports ${entry.src}, which the document could carry as its own script`,
288
+ path: entry.path,
289
+ rule: "relative-src",
290
+ });
291
+ } else if (entry.lazy && eagerClient && namesClientScript(entry.src)) {
292
+ advice.push({
293
+ detail:
294
+ "A document has one client script, and another entry imports it as the page loads, " +
295
+ "so `$lazy` defers nothing here. Move this export to a `$src` module of its own to " +
296
+ "load it on its first call.",
297
+ message: `${name} is $lazy, but the script it is in loads with the page`,
298
+ path: entry.path,
299
+ rule: "lazy-shares-script",
300
+ });
301
+ }
302
+ }
303
+ return advice;
304
+ }