@jxsuite/compiler 1.4.0 → 1.5.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/dist/cli.js +155 -156
- package/dist/compiler.js +105 -107
- package/package.json +6 -6
- package/src/shared.ts +236 -8
- package/src/site/schema-command.ts +37 -77
- package/src/site/site-build.ts +53 -14
- package/src/site/validate-command.ts +133 -37
- package/src/targets/compile-client.ts +63 -67
- package/src/targets/compile-element.ts +246 -31
- package/src/targets/compile-static.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@apidevtools/json-schema-ref-parser": "^15.5.0",
|
|
49
|
-
"@jxsuite/create": "^1.2.
|
|
50
|
-
"@jxsuite/runtime": "^1.3.
|
|
51
|
-
"@jxsuite/schema": "^1.
|
|
49
|
+
"@jxsuite/create": "^1.2.2",
|
|
50
|
+
"@jxsuite/runtime": "^1.3.2",
|
|
51
|
+
"@jxsuite/schema": "^1.5.0",
|
|
52
52
|
"esbuild": "^0.28.1",
|
|
53
53
|
"sharp": "^0.35.3"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@happy-dom/global-registrator": "^20.11.1",
|
|
57
|
-
"@jxsuite/connector": "^0.4.
|
|
58
|
-
"@jxsuite/parser": "^1.4.
|
|
57
|
+
"@jxsuite/connector": "^0.4.2",
|
|
58
|
+
"@jxsuite/parser": "^1.4.1",
|
|
59
59
|
"@types/mdast": "^4.0.4"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/src/shared.ts
CHANGED
|
@@ -77,8 +77,133 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
|
|
|
77
77
|
}
|
|
78
78
|
return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Emit the import-clause binding for one Function-def `$src` entry.
|
|
83
|
+
*
|
|
84
|
+
* `$export` names the export inside the module and defaults to the state key (spec.md §5.3 4d).
|
|
85
|
+
* When the two differ the clause has to alias, because the local name the rest of the generated
|
|
86
|
+
* module calls is the state key — importing the bare key instead requests an export the module does
|
|
87
|
+
* not have, and the browser rejects the whole module at link time.
|
|
88
|
+
*
|
|
89
|
+
* Aliasing rather than renaming call sites is what makes the awkward cases work: two keys may alias
|
|
90
|
+
* the same export, a key may collide with another entry's export name, and `$export: "default"` is
|
|
91
|
+
* only reachable as `default as key`.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} key - State key, and the local binding name
|
|
94
|
+
* @param {unknown} def - The Function definition
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function srcImportBinding(key: string, def: unknown) {
|
|
98
|
+
const declared = (def as { $export?: unknown } | null | undefined)?.$export;
|
|
99
|
+
const exportName = typeof declared === "string" && declared !== "" ? declared : key;
|
|
100
|
+
return exportName === key ? key : `${exportName} as ${key}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
|
|
105
|
+
*
|
|
106
|
+
* Shared by the client and element targets so both honour `manual`, template URLs and
|
|
107
|
+
* `method`/`headers`/`body` the same way. They diverged once: the element target emitted no fetch
|
|
108
|
+
* at all, leaving every Request entry permanently `null` with nothing thrown.
|
|
109
|
+
*
|
|
110
|
+
* A template `url` is read inside the effect, so the fetch re-runs whenever the interpolated state
|
|
111
|
+
* changes; an URL that still interpolates to `undefined` is skipped rather than fetched.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} key - State key receiving the response
|
|
114
|
+
* @param {JxPrototypeDef} def - The Request definition
|
|
115
|
+
* @param {{ statePrefix?: string; indent?: string; collect?: string }} [opts] - `statePrefix` is
|
|
116
|
+
* the expression the emitted code assigns through (`state` for the client module scope,
|
|
117
|
+
* `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`, when
|
|
118
|
+
* given, is an array expression the effect runner is pushed onto so the caller can `stop()` it on
|
|
119
|
+
* teardown.
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function emitRequestFetch(
|
|
123
|
+
key: string,
|
|
124
|
+
def: JxPrototypeDef,
|
|
125
|
+
opts: { statePrefix?: string; indent?: string; collect?: string } = {},
|
|
126
|
+
) {
|
|
127
|
+
const { statePrefix = "state", indent = "", collect } = opts;
|
|
128
|
+
const { url } = def;
|
|
129
|
+
const isTemplateUrl = isTemplateString(url);
|
|
130
|
+
const method = def.method ?? "GET";
|
|
131
|
+
|
|
132
|
+
if (def.manual) {
|
|
133
|
+
return `${indent}// ${key}: manual Request — fetch triggered by user action`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lines: string[] = [
|
|
137
|
+
`// ${key}: auto-fetch from ${isTemplateUrl ? "(dynamic URL)" : url}`,
|
|
138
|
+
collect ? `${collect}.push(effect(() => {` : "effect(() => {",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
if (isTemplateUrl) {
|
|
142
|
+
lines.push(
|
|
143
|
+
` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
|
|
144
|
+
' if (!url || url === "undefined" || url.includes("undefined")) return;',
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
lines.push(` const url = ${JSON.stringify(url)};`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fetchOpts: string[] = [];
|
|
151
|
+
if (method !== "GET") {
|
|
152
|
+
fetchOpts.push(`method: ${JSON.stringify(method)}`);
|
|
153
|
+
}
|
|
154
|
+
if (def.headers) {
|
|
155
|
+
fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
|
|
156
|
+
}
|
|
157
|
+
if (def.body) {
|
|
158
|
+
const bodyStr =
|
|
159
|
+
typeof def.body === "object"
|
|
160
|
+
? JSON.stringify(JSON.stringify(def.body))
|
|
161
|
+
: JSON.stringify(def.body);
|
|
162
|
+
fetchOpts.push(`body: ${bodyStr}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const optsStr = fetchOpts.length > 0 ? `, { ${fetchOpts.join(", ")} }` : "";
|
|
166
|
+
lines.push(
|
|
167
|
+
` fetch(url${optsStr})`,
|
|
168
|
+
" .then(r => r.ok ? r.json() : Promise.reject(r.statusText))",
|
|
169
|
+
` .then(d => { ${statePrefix}.${key} = d; })`,
|
|
170
|
+
` .catch(e => { ${statePrefix}.${key} = { error: String(e) }; });`,
|
|
171
|
+
collect ? "}));" : "});",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return lines.map((line) => `${indent}${line}`).join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rebase the bare `state.` reads in a template string onto `statePrefix`.
|
|
179
|
+
*
|
|
180
|
+
* Only the `${…}` interpolation holes are rewritten. A blanket replace would corrupt the literal
|
|
181
|
+
* text around them — `/api/state.json?q=${state.q}` would request `/api/this.state.json` — and the
|
|
182
|
+
* `\b` guard keeps an identifier that merely ends in `state` (`substate.z`) intact. Reads already
|
|
183
|
+
* carrying the prefix are left alone.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} str
|
|
186
|
+
* @param {string} statePrefix
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
function withStatePrefix(str: string, statePrefix: string) {
|
|
190
|
+
if (statePrefix === "state") {
|
|
191
|
+
return str;
|
|
192
|
+
}
|
|
193
|
+
return str.replaceAll(
|
|
194
|
+
/\$\{([^}]*)\}/g,
|
|
195
|
+
(_match: string, expr: string) =>
|
|
196
|
+
`\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
80
199
|
export type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
81
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
|
|
203
|
+
* Non-enumerable so it stays out of `Object.entries(scope)` and out of template evaluation.
|
|
204
|
+
*/
|
|
205
|
+
const RUNTIME_ONLY_KEYS = Symbol("jx.runtimeOnlyStateKeys");
|
|
206
|
+
|
|
82
207
|
// CDN defaults
|
|
83
208
|
export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
|
|
84
209
|
export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
|
|
@@ -275,6 +400,8 @@ export interface CompileContext {
|
|
|
275
400
|
scope: Record<string, unknown>;
|
|
276
401
|
scopeDefs: Record<string, unknown>;
|
|
277
402
|
media: Record<string, string>;
|
|
403
|
+
/** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
|
|
404
|
+
preformatted?: boolean;
|
|
278
405
|
}
|
|
279
406
|
|
|
280
407
|
export function createCompileContext(
|
|
@@ -299,6 +426,18 @@ export function buildInitialScope(
|
|
|
299
426
|
parentScope: Record<string, unknown> | null = null,
|
|
300
427
|
) {
|
|
301
428
|
const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
|
|
429
|
+
// Keys whose real value only exists after hydration, so prerender must not interpolate them —
|
|
430
|
+
// See `readsRuntimeOnlyState`. Seeded from the parent so nested scopes inherit the marks.
|
|
431
|
+
const inherited = (parentScope as Record<PropertyKey, unknown> | null)?.[RUNTIME_ONLY_KEYS];
|
|
432
|
+
const runtimeOnly = new Set<string>(inherited instanceof Set ? (inherited as Set<string>) : []);
|
|
433
|
+
// Attached up front, and mutated in place by the passes below, so the template pass at the end can
|
|
434
|
+
// Consult the marks the earlier passes added.
|
|
435
|
+
Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
|
|
436
|
+
configurable: true,
|
|
437
|
+
enumerable: false,
|
|
438
|
+
value: runtimeOnly,
|
|
439
|
+
writable: true,
|
|
440
|
+
});
|
|
302
441
|
|
|
303
442
|
for (const [key, def] of Object.entries(defs)) {
|
|
304
443
|
if (typeof def !== "object" || def === null || Array.isArray(def)) {
|
|
@@ -315,9 +454,13 @@ export function buildInitialScope(
|
|
|
315
454
|
}
|
|
316
455
|
}
|
|
317
456
|
|
|
457
|
+
// Template-string entries are deferred to a third pass: whether one is resolvable depends on the
|
|
458
|
+
// Runtime-only marks the loop below adds, and key order must not decide the answer.
|
|
459
|
+
const templateDefs: [string, string][] = [];
|
|
460
|
+
|
|
318
461
|
for (const [key, def] of Object.entries(defs)) {
|
|
319
462
|
if (typeof def === "string" && isTemplateString(def)) {
|
|
320
|
-
|
|
463
|
+
templateDefs.push([key, def]);
|
|
321
464
|
continue;
|
|
322
465
|
}
|
|
323
466
|
if (!def || typeof def !== "object") {
|
|
@@ -359,18 +502,27 @@ export function buildInitialScope(
|
|
|
359
502
|
void runStatements(body, s ?? scope, event ?? null);
|
|
360
503
|
});
|
|
361
504
|
} else if (typeof def.body === "string") {
|
|
362
|
-
const
|
|
505
|
+
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
363
506
|
const { body } = def;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
507
|
+
// Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
|
|
508
|
+
// Named `state` receives the scope, anything else receives the event. `state` is prepended
|
|
509
|
+
// When undeclared so a body may always reference it. Unconditionally prepending it instead —
|
|
510
|
+
// The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
|
|
511
|
+
// Form that examples/components/task-manager.json uses, leaving `state` undefined.
|
|
512
|
+
const params = declared.includes("state") ? declared : ["state", ...declared];
|
|
513
|
+
const fn = new Function(...params, body) as (...args: unknown[]) => unknown;
|
|
514
|
+
const invoke = (state: Record<string, unknown>, event?: unknown) =>
|
|
515
|
+
fn(...params.map((name) => (name === "state" ? state : event)));
|
|
367
516
|
if (bodyReturnsValue(body)) {
|
|
368
|
-
defineLazyScopeValue(scope, key, () =>
|
|
517
|
+
defineLazyScopeValue(scope, key, () => invoke(scope));
|
|
369
518
|
} else {
|
|
370
|
-
setOwnScopeValue(scope, key,
|
|
519
|
+
setOwnScopeValue(scope, key, invoke);
|
|
371
520
|
}
|
|
372
521
|
} else {
|
|
522
|
+
// Bodyless `$src` Function — the real implementation is only loaded in the browser, so this
|
|
523
|
+
// Is a placeholder standing in for its callable shape, never its value.
|
|
373
524
|
setOwnScopeValue(scope, key, () => {});
|
|
525
|
+
runtimeOnly.add(key);
|
|
374
526
|
}
|
|
375
527
|
continue;
|
|
376
528
|
}
|
|
@@ -379,9 +531,24 @@ export function buildInitialScope(
|
|
|
379
531
|
(def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
|
|
380
532
|
) {
|
|
381
533
|
setOwnScopeValue(scope, key, cloneValue(def.default ?? null));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (isPrototypeDef(def) && def.$prototype === "Request") {
|
|
537
|
+
// A client-timing fetch has no build-time value at all — not even a placeholder.
|
|
538
|
+
runtimeOnly.add(key);
|
|
382
539
|
}
|
|
383
540
|
}
|
|
384
541
|
|
|
542
|
+
for (const [key, template] of templateDefs) {
|
|
543
|
+
// A state entry that interpolates a runtime-only key cannot resolve until hydration either, so
|
|
544
|
+
// It is runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`)
|
|
545
|
+
// Into every template that reads *this* entry, one step removed from the original.
|
|
546
|
+
if (readsRuntimeOnlyState(template, scope)) {
|
|
547
|
+
runtimeOnly.add(key);
|
|
548
|
+
}
|
|
549
|
+
defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
|
|
550
|
+
}
|
|
551
|
+
|
|
385
552
|
return scope;
|
|
386
553
|
}
|
|
387
554
|
|
|
@@ -465,12 +632,51 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
|
|
|
465
632
|
return scope[refValue] ?? null;
|
|
466
633
|
}
|
|
467
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Detect whether a template string interpolates a state value that only exists after hydration.
|
|
637
|
+
*
|
|
638
|
+
* Evaluating one of those at build time is worse than leaving it alone. The build-time scope holds
|
|
639
|
+
* a `() => {}` placeholder for a bodyless `$src` Function and nothing at all for a `Request`, so
|
|
640
|
+
* the template resolves to text like `() => {}`, `undefined` or `""` — and that text _replaces_ the
|
|
641
|
+
* template in the emitted HTML, so the client-side binding is destroyed rather than merely wrong.
|
|
642
|
+
* Returning "unresolvable" instead leaves the template in place for the client to populate.
|
|
643
|
+
*
|
|
644
|
+
* A read that _calls_ the value is left alone: invoking a build-time callable is exactly how named
|
|
645
|
+
* formulas (spec.md §19.4c) are meant to be used during prerender.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} str
|
|
648
|
+
* @param {Record<string, unknown>} scope
|
|
649
|
+
* @returns {boolean}
|
|
650
|
+
*/
|
|
651
|
+
function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
|
|
652
|
+
if (!scope) {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
|
|
656
|
+
for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
|
|
657
|
+
if (match[2]) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const key = match[1] as string;
|
|
661
|
+
if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
if (typeof scope[key] === "function") {
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
|
|
468
671
|
/**
|
|
469
672
|
* @param {string} str
|
|
470
673
|
* @param {Record<string, unknown>} scope
|
|
471
674
|
* @returns {unknown}
|
|
472
675
|
*/
|
|
473
676
|
export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
|
|
677
|
+
if (readsRuntimeOnlyState(str, scope)) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
474
680
|
try {
|
|
475
681
|
const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
|
|
476
682
|
if (singleExprMatch) {
|
|
@@ -613,6 +819,26 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
613
819
|
return out;
|
|
614
820
|
}
|
|
615
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Tags rendered with `white-space: pre` by every UA, where inter-element whitespace is content
|
|
824
|
+
* rather than formatting. Emitters indent nested children for readability; inside these tags — and
|
|
825
|
+
* anywhere below them, since `white-space` inherits — that indentation would render as literal
|
|
826
|
+
* blank lines and leading spaces, so children must be concatenated with no separator.
|
|
827
|
+
*/
|
|
828
|
+
export const PREFORMATTED_TAGS = new Set(["pre", "textarea", "listing", "plaintext", "xmp"]);
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* The separator to place between an element's compiled children: none inside a preformatted
|
|
832
|
+
* subtree, a newline plus `indent` elsewhere.
|
|
833
|
+
*
|
|
834
|
+
* @param {boolean | undefined} preformatted
|
|
835
|
+
* @param {string} [indent]
|
|
836
|
+
* @returns {string}
|
|
837
|
+
*/
|
|
838
|
+
export function childSeparator(preformatted: boolean | undefined, indent = " "): string {
|
|
839
|
+
return preformatted ? "" : `\n${indent}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
616
842
|
/**
|
|
617
843
|
* Build the inner HTML (textContent or children) for a node.
|
|
618
844
|
*
|
|
@@ -622,6 +848,7 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
622
848
|
* scope: Record<string, unknown>;
|
|
623
849
|
* scopeDefs: Record<string, unknown>;
|
|
624
850
|
* media: Record<string, string>;
|
|
851
|
+
* preformatted?: boolean;
|
|
625
852
|
* }} context
|
|
626
853
|
* @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
|
|
627
854
|
* @returns {string}
|
|
@@ -633,6 +860,7 @@ export function buildInner(
|
|
|
633
860
|
scope: Record<string, unknown> | null;
|
|
634
861
|
scopeDefs: Record<string, unknown>;
|
|
635
862
|
media: Record<string, string>;
|
|
863
|
+
preformatted?: boolean;
|
|
636
864
|
},
|
|
637
865
|
childCompiler: (def: unknown, raw: unknown, context: unknown) => string,
|
|
638
866
|
) {
|
|
@@ -652,7 +880,7 @@ export function buildInner(
|
|
|
652
880
|
const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
|
|
653
881
|
return childCompiler(c, childRaw, context);
|
|
654
882
|
})
|
|
655
|
-
.join(
|
|
883
|
+
.join(childSeparator(context.preformatted));
|
|
656
884
|
}
|
|
657
885
|
return "";
|
|
658
886
|
}
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Composes the per-project entry documents (project.schema.json and document.schema.json) from the
|
|
5
5
|
* core schemas and each enabled extension's shipped fragments, then writes them into the project
|
|
6
|
-
* root as committed, SELF-CONTAINED
|
|
7
|
-
* {@link bundleSchema}
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* root as committed, SELF-CONTAINED, SINGLE-RESOURCE schemas: every fragment ref is resolved
|
|
7
|
+
* through {@link bundleSchema} (embedding the core + fragment resources under `$defs`) and then
|
|
8
|
+
* collapsed by {@link flattenSchema}, which drops the embedded `$id`s and rewrites every ref to a
|
|
9
|
+
* root-relative JSON Pointer. Both passes are needed for one resolution behavior across all
|
|
10
|
+
* consumers: relative `./node_modules/...` refs only resolve through Node module resolution, which
|
|
11
|
+
* editors do not perform (hoisted workspaces leave projects without their own node_modules), while
|
|
12
|
+
* `$id`-scoped refs inside a compound document resolve only in a fully compliant validator — VS
|
|
13
|
+
* Code's JSON language service and Monaco resolve `#/...` against the document root and fetch
|
|
14
|
+
* anything with a URI before the `#` over the network. Root pointers are what ajv, the language
|
|
15
|
+
* service, and Monaco all agree on.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -16,8 +20,8 @@ import { createRequire } from "node:module";
|
|
|
16
20
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
17
21
|
import {
|
|
18
22
|
bundleSchema,
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
composeProjectSchemas,
|
|
24
|
+
flattenSchema,
|
|
21
25
|
} from "@jxsuite/schema/project-schemas";
|
|
22
26
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
23
27
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
@@ -37,44 +41,22 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
37
41
|
const { config } = loadProjectConfig(projectRoot);
|
|
38
42
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const ext of registry.extensions) {
|
|
45
|
-
const projectFragment = ext.schemas.project;
|
|
46
|
-
if (projectFragment) {
|
|
47
|
-
fragments.push(fragmentRef(projectRoot, ext, projectFragment));
|
|
48
|
-
}
|
|
49
|
-
const documentFragment = ext.schemas.document;
|
|
50
|
-
if (documentFragment) {
|
|
51
|
-
pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
|
|
52
|
-
}
|
|
53
|
-
// The `schemas.fields` manifest convention: a fragment whose $defs members are extension
|
|
54
|
-
// Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
|
|
55
|
-
const fieldsFragment = ext.schemas.fields;
|
|
56
|
-
if (fieldsFragment) {
|
|
57
|
-
fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
|
|
58
|
-
fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Bundle before committing: the emitted relative refs are an intermediate form only.
|
|
44
|
+
/* Composition itself is host-agnostic (@jxsuite/schema `composeProjectSchemas`) — the filesystem
|
|
45
|
+
appears only in what this host contributes: project-relative refs for the fragments the
|
|
46
|
+
registry resolved, and a loader restricted to the project root. The cloud session composes the
|
|
47
|
+
same two documents from bundled artifacts through the same function. */
|
|
63
48
|
const root = resolve(projectRoot);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
loadJson,
|
|
76
|
-
root,
|
|
77
|
-
);
|
|
49
|
+
const { document: documentSchema, project: projectSchema } = await composeProjectSchemas({
|
|
50
|
+
baseDir: root,
|
|
51
|
+
coreDocumentRef: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
|
|
52
|
+
coreProjectRef: coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER),
|
|
53
|
+
extensions: registry.extensions.map((ext) => ({
|
|
54
|
+
document: ext.schemas.document && fragmentRef(projectRoot, ext, ext.schemas.document),
|
|
55
|
+
fields: ext.schemas.fields && fragmentRef(projectRoot, ext, ext.schemas.fields),
|
|
56
|
+
project: ext.schemas.project && fragmentRef(projectRoot, ext, ext.schemas.project),
|
|
57
|
+
})),
|
|
58
|
+
loadJson: restrictedSchemaLoader(root),
|
|
59
|
+
});
|
|
78
60
|
|
|
79
61
|
const projectSchemaPath = resolve(projectRoot, "project.schema.json");
|
|
80
62
|
const documentSchemaPath = resolve(projectRoot, "document.schema.json");
|
|
@@ -115,16 +97,16 @@ export async function readBundledProjectSchemas(projectRoot: string) {
|
|
|
115
97
|
}
|
|
116
98
|
|
|
117
99
|
const loadJson = restrictedSchemaLoader(root);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
);
|
|
100
|
+
const projectFile = JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<
|
|
101
|
+
string,
|
|
102
|
+
unknown
|
|
103
|
+
>;
|
|
104
|
+
const documentFile = JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<
|
|
105
|
+
string,
|
|
106
|
+
unknown
|
|
107
|
+
>;
|
|
108
|
+
const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
|
|
109
|
+
const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
|
|
128
110
|
return { document, project };
|
|
129
111
|
}
|
|
130
112
|
|
|
@@ -231,25 +213,3 @@ function projectRelativeRef(projectRoot: string, absPath: string, conventional:
|
|
|
231
213
|
}
|
|
232
214
|
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
233
215
|
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
|
|
237
|
-
* by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
|
|
238
|
-
* URIs, never file locations). Serves both the document paths union and the fields-extras union.
|
|
239
|
-
*
|
|
240
|
-
* @param {string} fragmentPath - Resolved absolute fragment path
|
|
241
|
-
* @returns {string[]}
|
|
242
|
-
*/
|
|
243
|
-
function fragmentDefsRefs(fragmentPath: string): string[] {
|
|
244
|
-
let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
|
|
245
|
-
try {
|
|
246
|
-
fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
|
|
247
|
-
} catch {
|
|
248
|
-
return [];
|
|
249
|
-
}
|
|
250
|
-
const { $id } = fragment;
|
|
251
|
-
if (typeof $id !== "string" || !fragment.$defs) {
|
|
252
|
-
return [];
|
|
253
|
-
}
|
|
254
|
-
return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
|
|
255
|
-
}
|
package/src/site/site-build.ts
CHANGED
|
@@ -256,20 +256,44 @@ export async function buildSite(
|
|
|
256
256
|
const componentOutDir = resolve(outDir, "components");
|
|
257
257
|
mkdirSync(componentOutDir, { recursive: true });
|
|
258
258
|
|
|
259
|
+
// Emitted modules are named after the component's `tagName` — the name the page's loader
|
|
260
|
+
// `<script>` and the CSS sidecar both use. So a component whose source basename differs from
|
|
261
|
+
// Its tag needs its `$elements` imports renamed to match, and those get resolved before the
|
|
262
|
+
// Dependency itself is compiled. Hence: read every component's tag up front.
|
|
263
|
+
const componentTagByPath = new Map<string, string>();
|
|
264
|
+
for (const file of componentFiles) {
|
|
265
|
+
const componentPath = resolve(componentsDir, file);
|
|
266
|
+
try {
|
|
267
|
+
const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
|
|
268
|
+
if (depTag) {
|
|
269
|
+
componentTagByPath.set(componentPath, depTag);
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Left to the compile pass below, which reports the parse failure with its file context.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
259
276
|
for (const file of componentFiles) {
|
|
260
277
|
try {
|
|
261
278
|
const componentPath = resolve(componentsDir, file);
|
|
262
279
|
const result = await compileElement(componentPath, {
|
|
263
280
|
...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
|
|
264
281
|
formats: formatRegistry,
|
|
282
|
+
resolveElementPath: (refPath: string, currentDir: string | null) => {
|
|
283
|
+
const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
|
|
284
|
+
const depTag = componentTagByPath.get(depPath);
|
|
285
|
+
return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
|
|
286
|
+
},
|
|
265
287
|
rewriteSrc: rewriteSidecarSrc,
|
|
266
288
|
});
|
|
267
289
|
for (const f of result.files) {
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
290
|
+
// Name the output after the tag. `injectComponentScripts` emits
|
|
291
|
+
// `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
|
|
292
|
+
// A source basename here left the loader pointing at a file that was never written.
|
|
293
|
+
// Basename is the fallback, and handles both / and \ — a forward-slash-only split left
|
|
294
|
+
// The full drive path on Windows, so resolve() then wrote the sidecar back into the
|
|
295
|
+
// Source tree instead of dist.
|
|
296
|
+
const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
|
|
273
297
|
writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
|
|
274
298
|
if (f.tagName) {
|
|
275
299
|
compiledComponentTags.push(f.tagName);
|
|
@@ -366,6 +390,7 @@ export async function buildSite(
|
|
|
366
390
|
compiledComponentTags,
|
|
367
391
|
componentCSS,
|
|
368
392
|
staticTags,
|
|
393
|
+
result.files.map((f: { content: string }) => f.content).join("\n"),
|
|
369
394
|
);
|
|
370
395
|
}
|
|
371
396
|
|
|
@@ -1151,9 +1176,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1151
1176
|
}
|
|
1152
1177
|
const rawChildren = node.children;
|
|
1153
1178
|
// Legacy whole-children repeater: expand the items into the node's static children.
|
|
1179
|
+
// Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
|
|
1180
|
+
// `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
|
|
1181
|
+
// Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
|
|
1182
|
+
// Definition the client needed. There is no prerendered output to preserve in that case, so the
|
|
1183
|
+
// Repeater is left in place for the client to bind (a page containing one is never zero-JS).
|
|
1154
1184
|
if (isMappedArray(rawChildren)) {
|
|
1155
1185
|
const expanded = expandMappedArrayStatic(rawChildren, scope);
|
|
1156
|
-
if (expanded) {
|
|
1186
|
+
if (expanded && expanded.length > 0) {
|
|
1157
1187
|
node.children = expanded;
|
|
1158
1188
|
return;
|
|
1159
1189
|
}
|
|
@@ -1183,10 +1213,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1183
1213
|
continue;
|
|
1184
1214
|
}
|
|
1185
1215
|
}
|
|
1186
|
-
// Array pseudo-element among siblings: expand its items in place
|
|
1216
|
+
// Array pseudo-element among siblings: expand its items in place, on the same
|
|
1217
|
+
// Produced-nothing rule as the whole-children branch above.
|
|
1187
1218
|
if (isMappedArray(child)) {
|
|
1188
1219
|
const expanded = expandMappedArrayStatic(child, scope);
|
|
1189
|
-
if (expanded) {
|
|
1220
|
+
if (expanded && expanded.length > 0) {
|
|
1190
1221
|
node.children.splice(i, 1, ...expanded);
|
|
1191
1222
|
i += expanded.length;
|
|
1192
1223
|
continue;
|
|
@@ -1351,6 +1382,7 @@ function expandComponents(
|
|
|
1351
1382
|
* @param {string[]} allComponentTags - All compiled component tag names
|
|
1352
1383
|
* @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
|
|
1353
1384
|
* @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
|
|
1385
|
+
* @param {string} [islandSource] - Concatenated island modules emitted for this page
|
|
1354
1386
|
* @returns {string}
|
|
1355
1387
|
*/
|
|
1356
1388
|
function injectComponentScripts(
|
|
@@ -1358,11 +1390,15 @@ function injectComponentScripts(
|
|
|
1358
1390
|
allComponentTags: string[],
|
|
1359
1391
|
cssMap = new Map<string, string>(),
|
|
1360
1392
|
staticTags = new Set<string>(),
|
|
1393
|
+
islandSource = "",
|
|
1361
1394
|
) {
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1395
|
+
// A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
|
|
1396
|
+
// From inside an island's client template — in which case the tag exists solely in the island's
|
|
1397
|
+
// Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
|
|
1398
|
+
// Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
|
|
1399
|
+
const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
|
|
1400
|
+
const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
|
|
1401
|
+
const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
|
|
1366
1402
|
if (usedTags.length === 0) {
|
|
1367
1403
|
return html;
|
|
1368
1404
|
}
|
|
@@ -1377,8 +1413,11 @@ function injectComponentScripts(
|
|
|
1377
1413
|
result = result.replace("</head>", ` ${cssLinks}\n</head>`);
|
|
1378
1414
|
}
|
|
1379
1415
|
|
|
1380
|
-
// Only inject JS for components that have non-static instances
|
|
1381
|
-
|
|
1416
|
+
// Only inject JS for components that have non-static instances. An island-rendered instance is
|
|
1417
|
+
// Never prerendered, so it needs its module even when the component itself is fully static —
|
|
1418
|
+
// That module is what fills the element in client-side. This holds when the tag ALSO appears in
|
|
1419
|
+
// The static HTML: those instances are prerendered, but the ones the island creates are not.
|
|
1420
|
+
const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
|
|
1382
1421
|
if (jsTags.length === 0) {
|
|
1383
1422
|
return result;
|
|
1384
1423
|
}
|