@jxsuite/schema 5.6.0 → 6.0.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 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
 
@@ -1,7 +1,15 @@
1
1
  export const imageConfigSchema = {
2
2
  description: "Image optimization configuration for the project.",
3
3
  properties: {
4
- formats: { items: { type: "string" }, type: "array" },
4
+ formats: {
5
+ description:
6
+ 'Output formats, each one of "avif", "jpeg", "png" or "webp" (default ["avif", "jpeg"]). ' +
7
+ "AVIF and JPEG are lossy; PNG and WebP are written losslessly, so listing WebP produces " +
8
+ "larger files than either. A source with transparency is written as PNG wherever JPEG is " +
9
+ "listed.",
10
+ items: { type: "string" },
11
+ type: "array",
12
+ },
5
13
  lazyLoad: { type: "boolean" },
6
14
  optimize: { type: "boolean" },
7
15
  picture: {
@@ -12,6 +20,10 @@ export const imageConfigSchema = {
12
20
  type: "boolean",
13
21
  },
14
22
  quality: {
23
+ description:
24
+ "Lossy quality per format, 1 to 100 (defaults: avif 65, jpeg 80). The build writes png " +
25
+ "and webp losslessly, so png is never read and webp is read only by the cloudflare " +
26
+ "service, as the quality of its transform URLs (default 80).",
15
27
  properties: {
16
28
  avif: { type: "number" },
17
29
  jpeg: { type: "number" },
@@ -30,7 +42,7 @@ export const imageConfigSchema = {
30
42
  },
31
43
  service: {
32
44
  description:
33
- 'How images are optimized: "build" runs Sharp at build time; "cloudflare" emits ' +
45
+ 'How images are optimized: "build" encodes variants at build time; "cloudflare" emits ' +
34
46
  "/cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires " +
35
47
  "the feature to be enabled on the serving zone).",
36
48
  enum: ["build", "cloudflare"],
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "dependencies": {
3
- "@bgotink/kdl": "0.4.1",
4
- "@webref/css": "^8.7.8",
3
+ "@bgotink/kdl": "0.4.2",
4
+ "@webref/css": "^8.7.9",
5
5
  "@webref/elements": "^2.9.2",
6
- "@webref/idl": "^3.86.0",
6
+ "@webref/idl": "^3.87.0",
7
7
  "ajv": "^8.20.0",
8
8
  "ajv-formats": "^3.0.1"
9
9
  },
@@ -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.2",
72
76
  "devDependencies": {
73
77
  "json-schema-to-ts": "^3.1.1"
74
78
  }
@@ -4,6 +4,7 @@
4
4
  "description": "Image optimization configuration for the project.",
5
5
  "properties": {
6
6
  "formats": {
7
+ "description": "Output formats, each one of \"avif\", \"jpeg\", \"png\" or \"webp\" (default [\"avif\", \"jpeg\"]). AVIF and JPEG are lossy; PNG and WebP are written losslessly, so listing WebP produces larger files than either. A source with transparency is written as PNG wherever JPEG is listed.",
7
8
  "items": {
8
9
  "type": "string"
9
10
  },
@@ -20,6 +21,7 @@
20
21
  "type": "boolean"
21
22
  },
22
23
  "quality": {
24
+ "description": "Lossy quality per format, 1 to 100 (defaults: avif 65, jpeg 80). The build writes png and webp losslessly, so png is never read and webp is read only by the cloudflare service, as the quality of its transform URLs (default 80).",
23
25
  "properties": {
24
26
  "avif": {
25
27
  "type": "number"
@@ -44,7 +46,7 @@
44
46
  "type": "array"
45
47
  },
46
48
  "service": {
47
- "description": "How images are optimized: \"build\" runs Sharp at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
49
+ "description": "How images are optimized: \"build\" encodes variants at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
48
50
  "enum": [
49
51
  "build",
50
52
  "cloudflare"
package/schema.json CHANGED
@@ -2789,6 +2789,20 @@
2789
2789
  }
2790
2790
  ]
2791
2791
  },
2792
+ "oncsrcchange": {
2793
+ "description": "Event handler for the \"csrcchange\" event.",
2794
+ "oneOf": [
2795
+ {
2796
+ "$ref": "#/$defs/RefObject"
2797
+ },
2798
+ {
2799
+ "$ref": "#/$defs/ExpressionEntry"
2800
+ },
2801
+ {
2802
+ "$ref": "#/$defs/FunctionDef"
2803
+ }
2804
+ ]
2805
+ },
2792
2806
  "oncuechange": {
2793
2807
  "description": "Event handler for the \"cuechange\" event.",
2794
2808
  "oneOf": [
@@ -3503,6 +3517,48 @@
3503
3517
  }
3504
3518
  ]
3505
3519
  },
3520
+ "onicecandidatepairadd": {
3521
+ "description": "Event handler for the \"icecandidatepairadd\" event.",
3522
+ "oneOf": [
3523
+ {
3524
+ "$ref": "#/$defs/RefObject"
3525
+ },
3526
+ {
3527
+ "$ref": "#/$defs/ExpressionEntry"
3528
+ },
3529
+ {
3530
+ "$ref": "#/$defs/FunctionDef"
3531
+ }
3532
+ ]
3533
+ },
3534
+ "onicecandidatepairnominate": {
3535
+ "description": "Event handler for the \"icecandidatepairnominate\" event.",
3536
+ "oneOf": [
3537
+ {
3538
+ "$ref": "#/$defs/RefObject"
3539
+ },
3540
+ {
3541
+ "$ref": "#/$defs/ExpressionEntry"
3542
+ },
3543
+ {
3544
+ "$ref": "#/$defs/FunctionDef"
3545
+ }
3546
+ ]
3547
+ },
3548
+ "onicecandidatepairremove": {
3549
+ "description": "Event handler for the \"icecandidatepairremove\" event.",
3550
+ "oneOf": [
3551
+ {
3552
+ "$ref": "#/$defs/RefObject"
3553
+ },
3554
+ {
3555
+ "$ref": "#/$defs/ExpressionEntry"
3556
+ },
3557
+ {
3558
+ "$ref": "#/$defs/FunctionDef"
3559
+ }
3560
+ ]
3561
+ },
3506
3562
  "oniceconnectionstatechange": {
3507
3563
  "description": "Event handler for the \"iceconnectionstatechange\" event.",
3508
3564
  "oneOf": [
@@ -5407,6 +5463,20 @@
5407
5463
  }
5408
5464
  ]
5409
5465
  },
5466
+ "onssrcchange": {
5467
+ "description": "Event handler for the \"ssrcchange\" event.",
5468
+ "oneOf": [
5469
+ {
5470
+ "$ref": "#/$defs/RefObject"
5471
+ },
5472
+ {
5473
+ "$ref": "#/$defs/ExpressionEntry"
5474
+ },
5475
+ {
5476
+ "$ref": "#/$defs/FunctionDef"
5477
+ }
5478
+ ]
5479
+ },
5410
5480
  "onstalled": {
5411
5481
  "description": "Event handler for the \"stalled\" event.",
5412
5482
  "oneOf": [
@@ -28258,6 +28328,7 @@
28258
28328
  "description": "Image optimization configuration for the project.",
28259
28329
  "properties": {
28260
28330
  "formats": {
28331
+ "description": "Output formats, each one of \"avif\", \"jpeg\", \"png\" or \"webp\" (default [\"avif\", \"jpeg\"]). AVIF and JPEG are lossy; PNG and WebP are written losslessly, so listing WebP produces larger files than either. A source with transparency is written as PNG wherever JPEG is listed.",
28261
28332
  "items": {
28262
28333
  "type": "string"
28263
28334
  },
@@ -28274,6 +28345,7 @@
28274
28345
  "type": "boolean"
28275
28346
  },
28276
28347
  "quality": {
28348
+ "description": "Lossy quality per format, 1 to 100 (defaults: avif 65, jpeg 80). The build writes png and webp losslessly, so png is never read and webp is read only by the cloudflare service, as the quality of its transform URLs (default 80).",
28277
28349
  "properties": {
28278
28350
  "avif": {
28279
28351
  "type": "number"
@@ -28298,7 +28370,7 @@
28298
28370
  "type": "array"
28299
28371
  },
28300
28372
  "service": {
28301
- "description": "How images are optimized: \"build\" runs Sharp at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
28373
+ "description": "How images are optimized: \"build\" encodes variants at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
28302
28374
  "enum": [
28303
28375
  "build",
28304
28376
  "cloudflare"
@@ -28509,6 +28581,21 @@
28509
28581
  }
28510
28582
  ]
28511
28583
  },
28584
+ "$script": {
28585
+ "additionalProperties": false,
28586
+ "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.",
28587
+ "properties": {
28588
+ "client": {
28589
+ "description": "The module the browser runs. A Function entry with `$src: \"#client\"` is one of its exports.",
28590
+ "type": "string"
28591
+ },
28592
+ "server": {
28593
+ "description": "The module the server runs. A `timing: \"server\"` entry with `$src: \"#server\"` is one of its exports, called with `(args, env)`.",
28594
+ "type": "string"
28595
+ }
28596
+ },
28597
+ "type": "object"
28598
+ },
28512
28599
  "$shadow": {
28513
28600
  "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
28601
  "oneOf": [
@@ -4,6 +4,7 @@
4
4
  "description": "Image optimization configuration for the project.",
5
5
  "properties": {
6
6
  "formats": {
7
+ "description": "Output formats, each one of \"avif\", \"jpeg\", \"png\" or \"webp\" (default [\"avif\", \"jpeg\"]). AVIF and JPEG are lossy; PNG and WebP are written losslessly, so listing WebP produces larger files than either. A source with transparency is written as PNG wherever JPEG is listed.",
7
8
  "items": {
8
9
  "type": "string"
9
10
  },
@@ -20,6 +21,7 @@
20
21
  "type": "boolean"
21
22
  },
22
23
  "quality": {
24
+ "description": "Lossy quality per format, 1 to 100 (defaults: avif 65, jpeg 80). The build writes png and webp losslessly, so png is never read and webp is read only by the cloudflare service, as the quality of its transform URLs (default 80).",
23
25
  "properties": {
24
26
  "avif": {
25
27
  "type": "number"
@@ -44,7 +46,7 @@
44
46
  "type": "array"
45
47
  },
46
48
  "service": {
47
- "description": "How images are optimized: \"build\" runs Sharp at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
49
+ "description": "How images are optimized: \"build\" encodes variants at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
48
50
  "enum": ["build", "cloudflare"],
49
51
  "type": "string"
50
52
  },
@@ -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
+ }