@jxsuite/compiler 1.4.0 → 2.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/dist/cli.js +564 -230
- package/dist/compiler.js +334 -133
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +461 -51
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +138 -87
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +956 -109
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +134 -38
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +144 -81
- package/src/targets/compile-element.ts +620 -58
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +12 -3
- package/src/types.ts +2 -0
package/src/shared.ts
CHANGED
|
@@ -18,21 +18,26 @@ import {
|
|
|
18
18
|
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
19
19
|
import { runStatements } from "@jxsuite/runtime/statements";
|
|
20
20
|
import {
|
|
21
|
+
bodyReturnsValue,
|
|
21
22
|
childrenContainArray,
|
|
22
23
|
hasStructuredBody,
|
|
23
24
|
isExpressionDef,
|
|
24
25
|
isFunctionDef,
|
|
25
26
|
isMappedArray,
|
|
26
|
-
bodyReturnsValue,
|
|
27
27
|
isNamedFormulaDef,
|
|
28
28
|
isPrototypeDef,
|
|
29
29
|
isRef,
|
|
30
30
|
isSchemaOnlyDef as isSchemaOnly,
|
|
31
31
|
isServerFnDef,
|
|
32
|
+
isTagExpression,
|
|
32
33
|
isTemplateString,
|
|
33
34
|
paramNames,
|
|
35
|
+
tagNameCandidates,
|
|
34
36
|
} from "@jxsuite/schema/guards";
|
|
37
|
+
import { styleScopePrefix } from "./shadow.ts";
|
|
38
|
+
import type { ShadowMode } from "./shadow.ts";
|
|
35
39
|
import type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
40
|
+
import { readPath } from "@jxsuite/runtime/pointer";
|
|
36
41
|
import type {
|
|
37
42
|
JsonValue,
|
|
38
43
|
JxElement,
|
|
@@ -54,7 +59,13 @@ export {
|
|
|
54
59
|
schemeSelectors,
|
|
55
60
|
toCSSText,
|
|
56
61
|
} from "@jxsuite/runtime";
|
|
57
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
compileExpression,
|
|
64
|
+
compileOperandSource,
|
|
65
|
+
evaluateExpression,
|
|
66
|
+
evaluateOperand,
|
|
67
|
+
isMutating,
|
|
68
|
+
} from "@jxsuite/runtime/expression";
|
|
58
69
|
export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
|
|
59
70
|
|
|
60
71
|
/**
|
|
@@ -77,8 +88,133 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
|
|
|
77
88
|
}
|
|
78
89
|
return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
|
|
79
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Emit the import-clause binding for one Function-def `$src` entry.
|
|
94
|
+
*
|
|
95
|
+
* `$export` names the export inside the module and defaults to the state key (spec.md §5.3 4d).
|
|
96
|
+
* When the two differ the clause has to alias, because the local name the rest of the generated
|
|
97
|
+
* module calls is the state key — importing the bare key instead requests an export the module does
|
|
98
|
+
* not have, and the browser rejects the whole module at link time.
|
|
99
|
+
*
|
|
100
|
+
* Aliasing rather than renaming call sites is what makes the awkward cases work: two keys may alias
|
|
101
|
+
* the same export, a key may collide with another entry's export name, and `$export: "default"` is
|
|
102
|
+
* only reachable as `default as key`.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} key - State key, and the local binding name
|
|
105
|
+
* @param {unknown} def - The Function definition
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function srcImportBinding(key: string, def: unknown) {
|
|
109
|
+
const declared = (def as { $export?: unknown } | null | undefined)?.$export;
|
|
110
|
+
const exportName = typeof declared === "string" && declared !== "" ? declared : key;
|
|
111
|
+
return exportName === key ? key : `${exportName} as ${key}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
|
|
116
|
+
*
|
|
117
|
+
* Shared by the client and element targets so both honour `manual`, template URLs and
|
|
118
|
+
* `method`/`headers`/`body` the same way. They diverged once: the element target emitted no fetch
|
|
119
|
+
* at all, leaving every Request entry permanently `null` with nothing thrown.
|
|
120
|
+
*
|
|
121
|
+
* A template `url` is read inside the effect, so the fetch re-runs whenever the interpolated state
|
|
122
|
+
* changes; an URL that still interpolates to `undefined` is skipped rather than fetched.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} key - State key receiving the response
|
|
125
|
+
* @param {JxPrototypeDef} def - The Request definition
|
|
126
|
+
* @param {{ statePrefix?: string; indent?: string; collect?: string }} [opts] - `statePrefix` is
|
|
127
|
+
* the expression the emitted code assigns through (`state` for the client module scope,
|
|
128
|
+
* `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`, when
|
|
129
|
+
* given, is an array expression the effect runner is pushed onto so the caller can `stop()` it on
|
|
130
|
+
* teardown.
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function emitRequestFetch(
|
|
134
|
+
key: string,
|
|
135
|
+
def: JxPrototypeDef,
|
|
136
|
+
opts: { statePrefix?: string; indent?: string; collect?: string } = {},
|
|
137
|
+
) {
|
|
138
|
+
const { statePrefix = "state", indent = "", collect } = opts;
|
|
139
|
+
const { url } = def;
|
|
140
|
+
const isTemplateUrl = isTemplateString(url);
|
|
141
|
+
const method = def.method ?? "GET";
|
|
142
|
+
|
|
143
|
+
if (def.manual) {
|
|
144
|
+
return `${indent}// ${key}: manual Request — fetch triggered by user action`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lines: string[] = [
|
|
148
|
+
`// ${key}: auto-fetch from ${isTemplateUrl ? "(dynamic URL)" : url}`,
|
|
149
|
+
collect ? `${collect}.push(effect(() => {` : "effect(() => {",
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
if (isTemplateUrl) {
|
|
153
|
+
lines.push(
|
|
154
|
+
` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
|
|
155
|
+
' if (!url || url === "undefined" || url.includes("undefined")) return;',
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
lines.push(` const url = ${JSON.stringify(url)};`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const fetchOpts: string[] = [];
|
|
162
|
+
if (method !== "GET") {
|
|
163
|
+
fetchOpts.push(`method: ${JSON.stringify(method)}`);
|
|
164
|
+
}
|
|
165
|
+
if (def.headers) {
|
|
166
|
+
fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
|
|
167
|
+
}
|
|
168
|
+
if (def.body) {
|
|
169
|
+
const bodyStr =
|
|
170
|
+
typeof def.body === "object"
|
|
171
|
+
? JSON.stringify(JSON.stringify(def.body))
|
|
172
|
+
: JSON.stringify(def.body);
|
|
173
|
+
fetchOpts.push(`body: ${bodyStr}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const optsStr = fetchOpts.length > 0 ? `, { ${fetchOpts.join(", ")} }` : "";
|
|
177
|
+
lines.push(
|
|
178
|
+
` fetch(url${optsStr})`,
|
|
179
|
+
" .then(r => r.ok ? r.json() : Promise.reject(r.statusText))",
|
|
180
|
+
` .then(d => { ${statePrefix}.${key} = d; })`,
|
|
181
|
+
` .catch(e => { ${statePrefix}.${key} = { error: String(e) }; });`,
|
|
182
|
+
collect ? "}));" : "});",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return lines.map((line) => `${indent}${line}`).join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Rebase the bare `state.` reads in a template string onto `statePrefix`.
|
|
190
|
+
*
|
|
191
|
+
* Only the `${…}` interpolation holes are rewritten. A blanket replace would corrupt the literal
|
|
192
|
+
* text around them — `/api/state.json?q=${state.q}` would request `/api/this.state.json` — and the
|
|
193
|
+
* `\b` guard keeps an identifier that merely ends in `state` (`substate.z`) intact. Reads already
|
|
194
|
+
* carrying the prefix are left alone.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} str
|
|
197
|
+
* @param {string} statePrefix
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function withStatePrefix(str: string, statePrefix: string) {
|
|
201
|
+
if (statePrefix === "state") {
|
|
202
|
+
return str;
|
|
203
|
+
}
|
|
204
|
+
return str.replaceAll(
|
|
205
|
+
/\$\{([^}]*)\}/g,
|
|
206
|
+
(_match: string, expr: string) =>
|
|
207
|
+
`\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
80
210
|
export type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
81
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
|
|
214
|
+
* Non-enumerable so it stays out of `Object.entries(scope)` and out of template evaluation.
|
|
215
|
+
*/
|
|
216
|
+
const RUNTIME_ONLY_KEYS = Symbol("jx.runtimeOnlyStateKeys");
|
|
217
|
+
|
|
82
218
|
// CDN defaults
|
|
83
219
|
export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
|
|
84
220
|
export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
|
|
@@ -275,6 +411,8 @@ export interface CompileContext {
|
|
|
275
411
|
scope: Record<string, unknown>;
|
|
276
412
|
scopeDefs: Record<string, unknown>;
|
|
277
413
|
media: Record<string, string>;
|
|
414
|
+
/** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
|
|
415
|
+
preformatted?: boolean;
|
|
278
416
|
}
|
|
279
417
|
|
|
280
418
|
export function createCompileContext(
|
|
@@ -289,6 +427,62 @@ export function createCompileContext(
|
|
|
289
427
|
return { media, scope, scopeDefs };
|
|
290
428
|
}
|
|
291
429
|
|
|
430
|
+
/**
|
|
431
|
+
* `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
|
|
432
|
+
* state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
|
|
433
|
+
*/
|
|
434
|
+
const STATE_ASSIGNMENT_RE =
|
|
435
|
+
/\bstate\.([A-Za-z_$][\w$]*)\s*(?:\+\+|--|(?:\*\*|\|\||&&|\?\?|<<|>>>|>>|[-+*/%&|^])?=(?!=))/g;
|
|
436
|
+
|
|
437
|
+
/** `state.list.push(…)` and friends mutate in place without ever assigning to the entry. */
|
|
438
|
+
const STATE_MUTATING_METHOD_RE =
|
|
439
|
+
/\bstate\.([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g;
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Collect every state key a handler writes to, across all the forms a body can take: a string body,
|
|
443
|
+
* a structured body (spec §20), and an `$expression` node with a mutating operator.
|
|
444
|
+
*
|
|
445
|
+
* Used to keep prerender from baking a binding over an entry that changes after hydration. A plain
|
|
446
|
+
* `{ "type": "string" }` entry holds a perfectly ordinary build-time value, so nothing else
|
|
447
|
+
* distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
|
|
448
|
+
* replaces the template in the emitted output and the element is dead for the life of the page.
|
|
449
|
+
*
|
|
450
|
+
* @param {unknown} node
|
|
451
|
+
* @param {Set<string>} into
|
|
452
|
+
*/
|
|
453
|
+
function collectAssignedStateKeys(node: unknown, into: Set<string>) {
|
|
454
|
+
if (typeof node === "string") {
|
|
455
|
+
for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
|
|
456
|
+
into.add(m[1] as string);
|
|
457
|
+
}
|
|
458
|
+
for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
|
|
459
|
+
into.add(m[1] as string);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!node || typeof node !== "object") {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(node)) {
|
|
467
|
+
for (const entry of node) {
|
|
468
|
+
collectAssignedStateKeys(entry, into);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const rec = node as Record<string, unknown>;
|
|
473
|
+
// A mutating expression names its destination as `target: { $ref: "#/state/x" }`.
|
|
474
|
+
const op = rec.operator;
|
|
475
|
+
if (typeof op === "string" && isMutating(op)) {
|
|
476
|
+
const target = rec.target as { $ref?: string } | undefined;
|
|
477
|
+
if (target && typeof target.$ref === "string" && target.$ref.startsWith("#/state/")) {
|
|
478
|
+
into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const value of Object.values(rec)) {
|
|
482
|
+
collectAssignedStateKeys(value, into);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
292
486
|
/**
|
|
293
487
|
* @param {Record<string, JxStateDefinition>} [defs]
|
|
294
488
|
* @param {Record<string, unknown> | null} [parentScope]
|
|
@@ -299,6 +493,38 @@ export function buildInitialScope(
|
|
|
299
493
|
parentScope: Record<string, unknown> | null = null,
|
|
300
494
|
) {
|
|
301
495
|
const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
|
|
496
|
+
// Keys whose real value only exists after hydration, so prerender must not interpolate them —
|
|
497
|
+
// See `readsRuntimeOnlyState`. Seeded from the parent so nested scopes inherit the marks.
|
|
498
|
+
const inherited = (parentScope as Record<PropertyKey, unknown> | null)?.[RUNTIME_ONLY_KEYS];
|
|
499
|
+
const runtimeOnly = new Set<string>(inherited instanceof Set ? (inherited as Set<string>) : []);
|
|
500
|
+
// Attached up front, and mutated in place by the passes below, so the template pass at the end can
|
|
501
|
+
// Consult the marks the earlier passes added.
|
|
502
|
+
Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
|
|
503
|
+
configurable: true,
|
|
504
|
+
enumerable: false,
|
|
505
|
+
value: runtimeOnly,
|
|
506
|
+
writable: true,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Pass 0: every entry some handler in this document writes to is mutable, so prerender must not
|
|
510
|
+
// Bake a binding over it. Narrow on purpose — an entry nothing ever writes stays bakeable, so
|
|
511
|
+
// Prerendered content is preserved for SEO. A `$src` handler's assignments live in a JS file this
|
|
512
|
+
// Builder cannot open, so a write that only happens there is still missed (issue #125).
|
|
513
|
+
const assigned = new Set<string>();
|
|
514
|
+
for (const def of Object.values(defs)) {
|
|
515
|
+
if (def && typeof def === "object" && (isFunctionDef(def) || isExpressionDef(def))) {
|
|
516
|
+
collectAssignedStateKeys(
|
|
517
|
+
(def as { body?: unknown; $expression?: unknown }).body ??
|
|
518
|
+
(def as { $expression?: unknown }).$expression,
|
|
519
|
+
assigned,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const key of assigned) {
|
|
524
|
+
if (key in defs) {
|
|
525
|
+
runtimeOnly.add(key);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
302
528
|
|
|
303
529
|
for (const [key, def] of Object.entries(defs)) {
|
|
304
530
|
if (typeof def !== "object" || def === null || Array.isArray(def)) {
|
|
@@ -315,9 +541,17 @@ export function buildInitialScope(
|
|
|
315
541
|
}
|
|
316
542
|
}
|
|
317
543
|
|
|
544
|
+
// Template-string entries are deferred to a third pass: whether one is resolvable depends on the
|
|
545
|
+
// Runtime-only marks the loop below adds, and key order must not decide the answer.
|
|
546
|
+
const templateDefs: [string, string][] = [];
|
|
547
|
+
// Computed (`bodyReturnsValue`) entries are deferred for the same reason. A computed is stored in
|
|
548
|
+
// The scope as its already-evaluated *result*, so a template reading it sees an ordinary string
|
|
549
|
+
// And prerender bakes it — destroying the binding rather than merely staling it (issue #124).
|
|
550
|
+
const computedBodies: [string, string][] = [];
|
|
551
|
+
|
|
318
552
|
for (const [key, def] of Object.entries(defs)) {
|
|
319
553
|
if (typeof def === "string" && isTemplateString(def)) {
|
|
320
|
-
|
|
554
|
+
templateDefs.push([key, def]);
|
|
321
555
|
continue;
|
|
322
556
|
}
|
|
323
557
|
if (!def || typeof def !== "object") {
|
|
@@ -359,18 +593,28 @@ export function buildInitialScope(
|
|
|
359
593
|
void runStatements(body, s ?? scope, event ?? null);
|
|
360
594
|
});
|
|
361
595
|
} else if (typeof def.body === "string") {
|
|
362
|
-
const
|
|
596
|
+
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
363
597
|
const { body } = def;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
598
|
+
// Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
|
|
599
|
+
// Named `state` receives the scope, anything else receives the event. `state` is prepended
|
|
600
|
+
// When undeclared so a body may always reference it. Unconditionally prepending it instead —
|
|
601
|
+
// The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
|
|
602
|
+
// Form that examples/components/task-manager.json uses, leaving `state` undefined.
|
|
603
|
+
const params = declared.includes("state") ? declared : ["state", ...declared];
|
|
604
|
+
const fn = new Function(...params, body) as (...args: unknown[]) => unknown;
|
|
605
|
+
const invoke = (state: Record<string, unknown>, event?: unknown) =>
|
|
606
|
+
fn(...params.map((name) => (name === "state" ? state : event)));
|
|
367
607
|
if (bodyReturnsValue(body)) {
|
|
368
|
-
defineLazyScopeValue(scope, key, () =>
|
|
608
|
+
defineLazyScopeValue(scope, key, () => invoke(scope));
|
|
609
|
+
computedBodies.push([key, body]);
|
|
369
610
|
} else {
|
|
370
|
-
setOwnScopeValue(scope, key,
|
|
611
|
+
setOwnScopeValue(scope, key, invoke);
|
|
371
612
|
}
|
|
372
613
|
} else {
|
|
614
|
+
// Bodyless `$src` Function — the real implementation is only loaded in the browser, so this
|
|
615
|
+
// Is a placeholder standing in for its callable shape, never its value.
|
|
373
616
|
setOwnScopeValue(scope, key, () => {});
|
|
617
|
+
runtimeOnly.add(key);
|
|
374
618
|
}
|
|
375
619
|
continue;
|
|
376
620
|
}
|
|
@@ -379,9 +623,34 @@ export function buildInitialScope(
|
|
|
379
623
|
(def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
|
|
380
624
|
) {
|
|
381
625
|
setOwnScopeValue(scope, key, cloneValue(def.default ?? null));
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (isPrototypeDef(def) && def.$prototype === "Request") {
|
|
629
|
+
// A client-timing fetch has no build-time value at all — not even a placeholder.
|
|
630
|
+
runtimeOnly.add(key);
|
|
382
631
|
}
|
|
383
632
|
}
|
|
384
633
|
|
|
634
|
+
// A state entry that reads a runtime-only key cannot resolve until hydration either, so it is
|
|
635
|
+
// Runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`) into
|
|
636
|
+
// Every template that reads *this* entry, one step removed from the original. Iterated to a
|
|
637
|
+
// Fixpoint because marking one entry can qualify another, in either direction, and declaration
|
|
638
|
+
// Order must not decide the answer.
|
|
639
|
+
let marked = true;
|
|
640
|
+
while (marked) {
|
|
641
|
+
marked = false;
|
|
642
|
+
for (const [key, source] of [...computedBodies, ...templateDefs]) {
|
|
643
|
+
if (!runtimeOnly.has(key) && readsRuntimeOnlyState(source, scope)) {
|
|
644
|
+
runtimeOnly.add(key);
|
|
645
|
+
marked = true;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const [key, template] of templateDefs) {
|
|
651
|
+
defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
|
|
652
|
+
}
|
|
653
|
+
|
|
385
654
|
return scope;
|
|
386
655
|
}
|
|
387
656
|
|
|
@@ -455,14 +724,59 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
|
|
|
455
724
|
return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
|
|
456
725
|
}
|
|
457
726
|
if (refValue.startsWith("#/state/")) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
727
|
+
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
728
|
+
// `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
|
|
729
|
+
return getPathValue(scope, refValue.slice("#/state/".length));
|
|
730
|
+
}
|
|
731
|
+
/*
|
|
732
|
+
* An unrecognized scheme is still a path, matching the runtime resolvers and the lowerer.
|
|
733
|
+
* Reading it as one key returned null for `a/b` while the emitted client module walked it.
|
|
734
|
+
*/
|
|
735
|
+
return getPathValue(scope, refValue) ?? null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Detect whether a template string interpolates a state value that only exists after hydration.
|
|
740
|
+
*
|
|
741
|
+
* Evaluating one of those at build time is worse than leaving it alone. The build-time scope holds
|
|
742
|
+
* a `() => {}` placeholder for a bodyless `$src` Function and nothing at all for a `Request`, so
|
|
743
|
+
* the template resolves to text like `() => {}`, `undefined` or `""` — and that text _replaces_ the
|
|
744
|
+
* template in the emitted HTML, so the client-side binding is destroyed rather than merely wrong.
|
|
745
|
+
* Returning "unresolvable" instead leaves the template in place for the client to populate.
|
|
746
|
+
*
|
|
747
|
+
* A read that _calls_ the value is left alone: invoking a build-time callable is exactly how named
|
|
748
|
+
* formulas (spec.md §19.4c) are meant to be used during prerender.
|
|
749
|
+
*
|
|
750
|
+
* @param {string} str
|
|
751
|
+
* @param {Record<string, unknown>} scope
|
|
752
|
+
* @returns {boolean}
|
|
753
|
+
*/
|
|
754
|
+
function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
|
|
755
|
+
if (!scope) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
|
|
759
|
+
for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
|
|
760
|
+
if (match[2]) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const key = match[1] as string;
|
|
764
|
+
if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
// Reading the key can run a computed's getter, which is free to throw on partial build-time
|
|
768
|
+
// Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
|
|
769
|
+
let value: unknown;
|
|
770
|
+
try {
|
|
771
|
+
value = scope[key];
|
|
772
|
+
} catch {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (typeof value === "function") {
|
|
776
|
+
return true;
|
|
462
777
|
}
|
|
463
|
-
return getPathValue(scope[sub.slice(0, slash)], sub.slice(slash + 1));
|
|
464
778
|
}
|
|
465
|
-
return
|
|
779
|
+
return false;
|
|
466
780
|
}
|
|
467
781
|
|
|
468
782
|
/**
|
|
@@ -471,20 +785,24 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
|
|
|
471
785
|
* @returns {unknown}
|
|
472
786
|
*/
|
|
473
787
|
export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
|
|
788
|
+
if (readsRuntimeOnlyState(str, scope)) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
/*
|
|
792
|
+
* `$site` and `$page` are bound as parameters, not just left on `state`.
|
|
793
|
+
*
|
|
794
|
+
* `injectContext` puts them on the document's state, so `${state.$site.name}` always worked — but
|
|
795
|
+
* every example in site-architecture.md §8.2 writes the bare `${$site.name}`, and that threw a
|
|
796
|
+
* ReferenceError the catch below turned into a silent null. A `$head` title referencing the site
|
|
797
|
+
* name reached the page as the literal template text.
|
|
798
|
+
*/
|
|
799
|
+
const args = ["state", "$map", "$site", "$page"] as const;
|
|
800
|
+
const values = [scope, scope?.$map, scope?.$site, scope?.$page];
|
|
474
801
|
try {
|
|
475
802
|
const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
$map: unknown,
|
|
480
|
-
) => unknown;
|
|
481
|
-
return fn(scope, scope?.$map);
|
|
482
|
-
}
|
|
483
|
-
const fn = new Function("state", "$map", `return \`${str}\``) as (
|
|
484
|
-
state: Record<string, unknown>,
|
|
485
|
-
$map: unknown,
|
|
486
|
-
) => unknown;
|
|
487
|
-
return fn(scope, scope?.$map);
|
|
803
|
+
const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
|
|
804
|
+
const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
|
|
805
|
+
return fn(...values);
|
|
488
806
|
} catch {
|
|
489
807
|
return null;
|
|
490
808
|
}
|
|
@@ -496,14 +814,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
|
|
|
496
814
|
* @returns {unknown}
|
|
497
815
|
*/
|
|
498
816
|
export function getPathValue(base: unknown, path: string) {
|
|
499
|
-
|
|
500
|
-
return base;
|
|
501
|
-
}
|
|
502
|
-
let acc: unknown = base;
|
|
503
|
-
for (const key of path.split("/")) {
|
|
504
|
-
acc = acc == null ? undefined : (acc as Record<string, unknown>)[key];
|
|
505
|
-
}
|
|
506
|
-
return acc;
|
|
817
|
+
return readPath(base, path);
|
|
507
818
|
}
|
|
508
819
|
|
|
509
820
|
/**
|
|
@@ -595,15 +906,6 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
595
906
|
}
|
|
596
907
|
}
|
|
597
908
|
|
|
598
|
-
if (def.tagName === "img") {
|
|
599
|
-
if (!def.attributes?.loading) {
|
|
600
|
-
out += ` loading="lazy"`;
|
|
601
|
-
}
|
|
602
|
-
if (!def.attributes?.decoding) {
|
|
603
|
-
out += ` decoding="async"`;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
909
|
if (def.$static) {
|
|
608
910
|
out += ` data-jx-static`;
|
|
609
911
|
} else if (def.$prerendered) {
|
|
@@ -613,6 +915,26 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
613
915
|
return out;
|
|
614
916
|
}
|
|
615
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Tags rendered with `white-space: pre` by every UA, where inter-element whitespace is content
|
|
920
|
+
* rather than formatting. Emitters indent nested children for readability; inside these tags — and
|
|
921
|
+
* anywhere below them, since `white-space` inherits — that indentation would render as literal
|
|
922
|
+
* blank lines and leading spaces, so children must be concatenated with no separator.
|
|
923
|
+
*/
|
|
924
|
+
export const PREFORMATTED_TAGS = new Set(["pre", "textarea", "listing", "plaintext", "xmp"]);
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The separator to place between an element's compiled children: none inside a preformatted
|
|
928
|
+
* subtree, a newline plus `indent` elsewhere.
|
|
929
|
+
*
|
|
930
|
+
* @param {boolean | undefined} preformatted
|
|
931
|
+
* @param {string} [indent]
|
|
932
|
+
* @returns {string}
|
|
933
|
+
*/
|
|
934
|
+
export function childSeparator(preformatted: boolean | undefined, indent = " "): string {
|
|
935
|
+
return preformatted ? "" : `\n${indent}`;
|
|
936
|
+
}
|
|
937
|
+
|
|
616
938
|
/**
|
|
617
939
|
* Build the inner HTML (textContent or children) for a node.
|
|
618
940
|
*
|
|
@@ -622,6 +944,7 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
622
944
|
* scope: Record<string, unknown>;
|
|
623
945
|
* scopeDefs: Record<string, unknown>;
|
|
624
946
|
* media: Record<string, string>;
|
|
947
|
+
* preformatted?: boolean;
|
|
625
948
|
* }} context
|
|
626
949
|
* @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
|
|
627
950
|
* @returns {string}
|
|
@@ -633,6 +956,7 @@ export function buildInner(
|
|
|
633
956
|
scope: Record<string, unknown> | null;
|
|
634
957
|
scopeDefs: Record<string, unknown>;
|
|
635
958
|
media: Record<string, string>;
|
|
959
|
+
preformatted?: boolean;
|
|
636
960
|
},
|
|
637
961
|
childCompiler: (def: unknown, raw: unknown, context: unknown) => string,
|
|
638
962
|
) {
|
|
@@ -652,7 +976,7 @@ export function buildInner(
|
|
|
652
976
|
const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
|
|
653
977
|
return childCompiler(c, childRaw, context);
|
|
654
978
|
})
|
|
655
|
-
.join(
|
|
979
|
+
.join(childSeparator(context.preformatted));
|
|
656
980
|
}
|
|
657
981
|
return "";
|
|
658
982
|
}
|
|
@@ -945,11 +1269,16 @@ export function collectStyles(
|
|
|
945
1269
|
counter.n += 1;
|
|
946
1270
|
}
|
|
947
1271
|
|
|
1272
|
+
/* A chosen tag needs a selector that matches every candidate, not the first one.
|
|
1273
|
+
`#id` and `.class` are unaffected — they identify the element regardless of what it turns out
|
|
1274
|
+
to be — but a bare tag selector has to become the union, or styling an `<a>|<div>` wrapper
|
|
1275
|
+
would style exactly one of the two branches and silently miss the other. */
|
|
1276
|
+
const tagSelector = tagNameCandidates(def.tagName).join(", ") || "*";
|
|
948
1277
|
const selector = def.id
|
|
949
1278
|
? `#${def.id}`
|
|
950
1279
|
: def.className
|
|
951
1280
|
? `.${def.className.split(" ")[0]}`
|
|
952
|
-
:
|
|
1281
|
+
: tagSelector;
|
|
953
1282
|
|
|
954
1283
|
if (def.style) {
|
|
955
1284
|
const baseDecls = [];
|
|
@@ -1150,6 +1479,40 @@ export const SELF_CLOSING = new Set<string>([
|
|
|
1150
1479
|
"source",
|
|
1151
1480
|
]);
|
|
1152
1481
|
|
|
1482
|
+
/**
|
|
1483
|
+
* A tag for the PRERENDER, resolved against the same scope the attributes are.
|
|
1484
|
+
*
|
|
1485
|
+
* The static renderer produces bytes, so it must commit to one element. Every candidate is a
|
|
1486
|
+
* literal `TagName`, so committing is safe — and it is resolved here rather than left as text
|
|
1487
|
+
* precisely because the old `${…}` form was left as text and then re-resolved against the page
|
|
1488
|
+
* scope, where a component's own state is undefined.
|
|
1489
|
+
*
|
|
1490
|
+
* @param {unknown} tagName
|
|
1491
|
+
* @param {Record<string, unknown>} scope
|
|
1492
|
+
* @returns {string}
|
|
1493
|
+
*/
|
|
1494
|
+
export function resolveStaticTagName(
|
|
1495
|
+
tagName: unknown,
|
|
1496
|
+
scope: Record<string, unknown> | null,
|
|
1497
|
+
): string {
|
|
1498
|
+
if (typeof tagName === "string") {
|
|
1499
|
+
return tagName;
|
|
1500
|
+
}
|
|
1501
|
+
const candidates = tagNameCandidates(tagName);
|
|
1502
|
+
if (candidates.length === 0) {
|
|
1503
|
+
return "div";
|
|
1504
|
+
}
|
|
1505
|
+
if (!isTagExpression(tagName)) {
|
|
1506
|
+
return candidates[0]!;
|
|
1507
|
+
}
|
|
1508
|
+
const expression = tagName.$expression;
|
|
1509
|
+
if (expression.operator === "?:") {
|
|
1510
|
+
return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
|
|
1511
|
+
}
|
|
1512
|
+
const key = resolveStaticValue(expression.target, scope);
|
|
1513
|
+
return expression.cases[String(key)] ?? expression.default;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1153
1516
|
/**
|
|
1154
1517
|
* Recursively render a Jx node tree to static HTML for pre-rendering.
|
|
1155
1518
|
*
|
|
@@ -1207,7 +1570,11 @@ export function renderStaticNode(
|
|
|
1207
1570
|
return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
|
|
1208
1571
|
}
|
|
1209
1572
|
|
|
1210
|
-
|
|
1573
|
+
/* Resolved against the SAME scope the attributes are resolved against, so the prerendered markup
|
|
1574
|
+
and the client's first render agree about what element this is. The old `${…}` form could not
|
|
1575
|
+
do this — it re-resolved the emitted string against the page scope, where a component's own
|
|
1576
|
+
state does not exist, so SSR silently emitted the fallback branch's tag. */
|
|
1577
|
+
const tag = resolveStaticTagName(node.tagName, scope);
|
|
1211
1578
|
|
|
1212
1579
|
// Replace <slot> with provided slot content
|
|
1213
1580
|
if (tag === "slot" && slotContent != null) {
|
|
@@ -1356,13 +1723,55 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
|
|
|
1356
1723
|
* @param {Record<string, string>} [mediaQueries] - Project media query definitions
|
|
1357
1724
|
* @returns {string} CSS text, or empty string if no styles
|
|
1358
1725
|
*/
|
|
1726
|
+
/** `::slotted()` and `::part()` select through a shadow boundary, not the host that owns it. */
|
|
1727
|
+
const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Turn one nested style key into a selector, given the component's scope prefix.
|
|
1731
|
+
*
|
|
1732
|
+
* Three shapes, and the interesting one is `:host`. A style object should mean the same thing in
|
|
1733
|
+
* both modes, so `:host` and `:host(.foo)` are **translated** rather than passed through: inside a
|
|
1734
|
+
* shadow root they stand alone, and in the light DOM they become the tag name and `<tag>.foo` —
|
|
1735
|
+
* which is what "the host, matching this" means when there is no shadow root. Moving a component
|
|
1736
|
+
* between modes therefore does not silently break its styles.
|
|
1737
|
+
*
|
|
1738
|
+
* @param {string} prop - The style-object key, e.g. `":hover"`, `"& .inner"`, `":host(.wide)"`
|
|
1739
|
+
* @param {string} scope - `":host"` in shadow mode, the tag name otherwise
|
|
1740
|
+
* @returns {string}
|
|
1741
|
+
*/
|
|
1742
|
+
function resolveSelector(prop: string, scope: string): string {
|
|
1743
|
+
if (prop.startsWith("&")) {
|
|
1744
|
+
return prop.replace("&", scope);
|
|
1745
|
+
}
|
|
1746
|
+
if (prop.startsWith(":host")) {
|
|
1747
|
+
const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
|
|
1748
|
+
if (scope === ":host") {
|
|
1749
|
+
return prop;
|
|
1750
|
+
}
|
|
1751
|
+
return inner === undefined ? scope : `${scope}${inner}`;
|
|
1752
|
+
}
|
|
1753
|
+
// `:host::slotted(x)` matches nothing — the pseudo-element attaches to a slot, not the host.
|
|
1754
|
+
if (scope === ":host" && SHADOW_STANDALONE.test(prop)) {
|
|
1755
|
+
return prop;
|
|
1756
|
+
}
|
|
1757
|
+
return `${scope}${prop}`;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1359
1760
|
export function buildComponentCSS(
|
|
1360
1761
|
tagName: string,
|
|
1361
1762
|
styleDef?: JxStyle | null | undefined,
|
|
1362
1763
|
doc: JxElement | null = null,
|
|
1363
1764
|
mediaQueries: Record<string, string> = {},
|
|
1765
|
+
shadow: ShadowMode | null = null,
|
|
1364
1766
|
) {
|
|
1365
1767
|
const rules: string[] = [];
|
|
1768
|
+
/*
|
|
1769
|
+
* The scope prefix. In light DOM it is the tag name, which is what keeps `sty-card .inner` from
|
|
1770
|
+
* reaching another component's `.inner`. Inside a shadow root the selector cannot see the host's
|
|
1771
|
+
* tag name at all, and `:host` is the standard's way to address it — so the same style object
|
|
1772
|
+
* produces different, correct CSS in each mode without the author restating it.
|
|
1773
|
+
*/
|
|
1774
|
+
const scope = styleScopePrefix(tagName, shadow);
|
|
1366
1775
|
|
|
1367
1776
|
if (styleDef && typeof styleDef === "object") {
|
|
1368
1777
|
const decls: string[] = [];
|
|
@@ -1385,20 +1794,21 @@ export function buildComponentCSS(
|
|
|
1385
1794
|
decls.push(` ${camelToKebab(prop)}: ${value};`);
|
|
1386
1795
|
}
|
|
1387
1796
|
if (decls.length > 0) {
|
|
1388
|
-
rules.push(`${
|
|
1797
|
+
rules.push(`${scope} {\n${decls.join("\n")}\n}`);
|
|
1389
1798
|
}
|
|
1390
1799
|
|
|
1391
1800
|
for (const [prop, val] of Object.entries(styleDef)) {
|
|
1392
1801
|
if (prop.startsWith("@")) {
|
|
1393
|
-
pushConditionalRule(rules, prop, mediaQueries,
|
|
1802
|
+
pushConditionalRule(rules, prop, mediaQueries, scope, val as Record<string, unknown>);
|
|
1394
1803
|
} else if (
|
|
1395
1804
|
prop.startsWith(":") ||
|
|
1396
1805
|
prop.startsWith(".") ||
|
|
1397
1806
|
prop.startsWith("&") ||
|
|
1398
1807
|
prop.startsWith("[")
|
|
1399
1808
|
) {
|
|
1400
|
-
|
|
1401
|
-
|
|
1809
|
+
rules.push(
|
|
1810
|
+
`${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
|
|
1811
|
+
);
|
|
1402
1812
|
}
|
|
1403
1813
|
}
|
|
1404
1814
|
}
|