@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
|
@@ -7,34 +7,45 @@
|
|
|
7
7
|
|
|
8
8
|
import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
|
|
9
9
|
import {
|
|
10
|
+
PREFORMATTED_TAGS,
|
|
10
11
|
collectStyles,
|
|
11
12
|
compileExpression,
|
|
13
|
+
compileOperandSource,
|
|
12
14
|
compileStatements,
|
|
13
15
|
emitFormulaFn,
|
|
16
|
+
emitRequestFetch,
|
|
14
17
|
escapeHtml,
|
|
15
18
|
isMutating,
|
|
16
19
|
isSchemaOnly,
|
|
20
|
+
srcImportBinding,
|
|
17
21
|
tagNameToClassName,
|
|
18
22
|
} from "../shared.ts";
|
|
19
23
|
import {
|
|
24
|
+
bodyReturnsValue,
|
|
20
25
|
hasStructuredBody,
|
|
21
26
|
isExpressionDef,
|
|
22
27
|
isFunctionDef,
|
|
23
28
|
isMappedArray,
|
|
24
29
|
isNamedFormulaDef,
|
|
25
30
|
isRef,
|
|
31
|
+
isTagExpression,
|
|
26
32
|
paramNames,
|
|
27
33
|
} from "@jxsuite/schema/guards";
|
|
28
34
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
29
35
|
import type { ExpressionNode } from "../shared.ts";
|
|
36
|
+
import { resolveShadowMode } from "../shadow.ts";
|
|
37
|
+
import type { ShadowMode } from "../shadow.ts";
|
|
30
38
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
39
|
+
import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
|
|
31
40
|
import type {
|
|
32
41
|
JxDocument,
|
|
33
42
|
JxExpressionDef,
|
|
34
43
|
JxFunctionDef,
|
|
35
44
|
JxMappedArray,
|
|
36
45
|
JxMutableNode,
|
|
46
|
+
JxPrototypeDef,
|
|
37
47
|
JxStyle,
|
|
48
|
+
ProjectConfig,
|
|
38
49
|
} from "@jxsuite/schema/types";
|
|
39
50
|
|
|
40
51
|
/** Options accepted by compileElement. */
|
|
@@ -50,6 +61,8 @@ export interface CompileElementOptions {
|
|
|
50
61
|
$media?: Record<string, string>;
|
|
51
62
|
formats?: FormatRegistry;
|
|
52
63
|
basePath?: string | null;
|
|
64
|
+
/** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
|
|
65
|
+
defaults?: ProjectConfig["defaults"];
|
|
53
66
|
[key: string]: unknown;
|
|
54
67
|
}
|
|
55
68
|
|
|
@@ -147,6 +160,7 @@ export async function compileElement(
|
|
|
147
160
|
className,
|
|
148
161
|
elementImports,
|
|
149
162
|
rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
|
|
163
|
+
resolveShadowMode(doc, opts.defaults),
|
|
150
164
|
);
|
|
151
165
|
const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
|
|
152
166
|
files.push({ content: jsContent, path: outputPath, tagName });
|
|
@@ -248,6 +262,68 @@ function extractInitialValue(def: JxMutableNode) {
|
|
|
248
262
|
return JSON.stringify(def);
|
|
249
263
|
}
|
|
250
264
|
|
|
265
|
+
/** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
|
|
266
|
+
const LIFECYCLE_KEYS = new Set(["onMount", "onUnmount", "onAdopted"]);
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Collect the state keys a document uses as a _callable_, at any depth: bound to an `on*` event,
|
|
270
|
+
* invoked by an `$expression` `call` node, or called as `state.key(…)` from a template string or
|
|
271
|
+
* another function's body.
|
|
272
|
+
*
|
|
273
|
+
* A bodyless `$src` Function carries no body to classify by, and unlike the interpreter — which
|
|
274
|
+
* introspects the imported function — a compiler only has the document to go on. Anything not used
|
|
275
|
+
* as a callable has its return value read reactively, and so becomes a computed (spec.md §5.3 4b).
|
|
276
|
+
* Defaulting the other way round would turn a called helper into a value and break its call site.
|
|
277
|
+
*
|
|
278
|
+
* @param {JxDocument} doc
|
|
279
|
+
* @returns {Set<string>}
|
|
280
|
+
*/
|
|
281
|
+
function collectCallableRefs(doc: JxDocument) {
|
|
282
|
+
const keys = new Set<string>();
|
|
283
|
+
const seen = new Set<object>();
|
|
284
|
+
|
|
285
|
+
const addStateRef = (ref: unknown) => {
|
|
286
|
+
if (typeof ref === "string" && ref.startsWith("#/state/")) {
|
|
287
|
+
keys.add(ref.slice("#/state/".length).split("/")[0] as string);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const visit = (node: unknown) => {
|
|
292
|
+
if (typeof node === "string") {
|
|
293
|
+
// `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
|
|
294
|
+
for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
295
|
+
keys.add(call[1] as string);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!node || typeof node !== "object" || seen.has(node)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
seen.add(node);
|
|
303
|
+
if (Array.isArray(node)) {
|
|
304
|
+
for (const item of node) {
|
|
305
|
+
visit(item);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const record = node as Record<string, unknown>;
|
|
310
|
+
// An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
|
|
311
|
+
if (record.operator === "call" && isRef(record.target)) {
|
|
312
|
+
addStateRef((record.target as { $ref: string }).$ref);
|
|
313
|
+
}
|
|
314
|
+
for (const [key, val] of Object.entries(record)) {
|
|
315
|
+
if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
|
|
316
|
+
addStateRef(val.$ref);
|
|
317
|
+
}
|
|
318
|
+
visit(val);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
visit(doc.children);
|
|
323
|
+
visit(doc.state);
|
|
324
|
+
return keys;
|
|
325
|
+
}
|
|
326
|
+
|
|
251
327
|
/**
|
|
252
328
|
* Generate a complete ES module string for a custom element.
|
|
253
329
|
*
|
|
@@ -261,6 +337,7 @@ export function emitElementModule(
|
|
|
261
337
|
className: string,
|
|
262
338
|
elementImports: string[],
|
|
263
339
|
rewriteSrc?: (specifier: string) => string,
|
|
340
|
+
shadow: ShadowMode | null = null,
|
|
264
341
|
) {
|
|
265
342
|
const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
|
|
266
343
|
|
|
@@ -282,7 +359,7 @@ export function emitElementModule(
|
|
|
282
359
|
if (!srcImportMap.has(srcPath)) {
|
|
283
360
|
srcImportMap.set(srcPath, []);
|
|
284
361
|
}
|
|
285
|
-
(srcImportMap.get(srcPath) as string[]).push(key);
|
|
362
|
+
(srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
|
|
286
363
|
}
|
|
287
364
|
}
|
|
288
365
|
for (const [srcPath, names] of srcImportMap) {
|
|
@@ -292,12 +369,25 @@ export function emitElementModule(
|
|
|
292
369
|
lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
|
|
293
370
|
}
|
|
294
371
|
|
|
372
|
+
// `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
|
|
373
|
+
// Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
|
|
374
|
+
// Documents without a Request produce exactly the same module as before.
|
|
375
|
+
const requestEntries = Object.entries(defs).filter(([, def]) => {
|
|
376
|
+
const d = def as JxMutableNode;
|
|
377
|
+
return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
|
|
378
|
+
});
|
|
379
|
+
|
|
295
380
|
lines.push(
|
|
296
|
-
`import { reactive, computed, effect } from '@vue/reactivity';`,
|
|
381
|
+
`import { reactive, computed, effect, stop } from '@vue/reactivity';`,
|
|
297
382
|
`import { render, html } from 'lit-html';`,
|
|
298
383
|
"",
|
|
299
384
|
`class ${className} extends HTMLElement {`,
|
|
300
|
-
|
|
385
|
+
// One registry for every effect the element creates — render, dynamic host styles, Request
|
|
386
|
+
// Auto-fetches. Calling an @vue/reactivity runner re-runs it, so teardown has to be `stop()`,
|
|
387
|
+
// And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
|
|
388
|
+
// Held a handle to.
|
|
389
|
+
" #effects = [];",
|
|
390
|
+
...(shadow === null ? [] : [" #root = null;"]),
|
|
301
391
|
"",
|
|
302
392
|
// Constructor: build reactive state
|
|
303
393
|
" constructor() {",
|
|
@@ -309,6 +399,9 @@ export function emitElementModule(
|
|
|
309
399
|
const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
|
|
310
400
|
|
|
311
401
|
const formulaEntries: [string, JxExpressionDef][] = [];
|
|
402
|
+
/** `"${…}"` state entries — computed, exactly as the runtime treats them. */
|
|
403
|
+
const templateEntries: [string, string][] = [];
|
|
404
|
+
const callableRefs = collectCallableRefs(doc);
|
|
312
405
|
for (const [key, def] of Object.entries(defs)) {
|
|
313
406
|
const d = def as JxMutableNode;
|
|
314
407
|
if (isExpressionDef(d)) {
|
|
@@ -321,11 +414,34 @@ export function emitElementModule(
|
|
|
321
414
|
computedEntries.push([key, d]);
|
|
322
415
|
}
|
|
323
416
|
} else if (isFunctionDef(d)) {
|
|
324
|
-
if (typeof d.body
|
|
417
|
+
if (d.$src && typeof d.body !== "string") {
|
|
418
|
+
// Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
|
|
419
|
+
// `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
|
|
420
|
+
// Spec-conformant external Function takes.
|
|
421
|
+
if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
|
|
422
|
+
functionEntries.push([key, d]);
|
|
423
|
+
} else {
|
|
424
|
+
computedEntries.push([key, d]);
|
|
425
|
+
}
|
|
426
|
+
} else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
|
|
325
427
|
computedEntries.push([key, d]);
|
|
326
428
|
} else {
|
|
327
429
|
functionEntries.push([key, d]);
|
|
328
430
|
}
|
|
431
|
+
} else if (typeof def === "string" && def.includes("${")) {
|
|
432
|
+
/* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
|
|
433
|
+
second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
|
|
434
|
+
computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
|
|
435
|
+
reads "string with ${} → computed". This branch did not exist, so the compiler fell
|
|
436
|
+
through to `extractInitialValue` and emitted the template as a LITERAL.
|
|
437
|
+
|
|
438
|
+
The consequence is the worst shape a bug can take: the same component behaved one way in
|
|
439
|
+
Studio's canvas, which runs the runtime, and another way on the deployed site, which runs
|
|
440
|
+
this. A real site's `$switch` discriminant (`imageKey: "${state.image ? 'set' : ''}"`)
|
|
441
|
+
evaluated correctly in the editor and shipped as the literal text of its own expression,
|
|
442
|
+
so the case never matched and the image silently never rendered in production. Found by
|
|
443
|
+
building that site and reading the emitted component. */
|
|
444
|
+
templateEntries.push([key, def]);
|
|
329
445
|
} else {
|
|
330
446
|
// Use extractInitialValue to get the correct initial value
|
|
331
447
|
const initVal = extractInitialValue(d);
|
|
@@ -338,7 +454,11 @@ export function emitElementModule(
|
|
|
338
454
|
// Emit reactive({...}) with initial state values
|
|
339
455
|
lines.push(" this.state = reactive({");
|
|
340
456
|
for (const [key, initVal] of stateEntries) {
|
|
341
|
-
|
|
457
|
+
/*
|
|
458
|
+
* A state key is author data, not an identifier: `{"user.name": 1}` is a legal document, and
|
|
459
|
+
* pasting it raw emitted `user.name: 1,` — a SyntaxError this build reported as a success.
|
|
460
|
+
*/
|
|
461
|
+
lines.push(` ${objectKey(key)}: ${initVal},`);
|
|
342
462
|
}
|
|
343
463
|
lines.push(" });");
|
|
344
464
|
|
|
@@ -350,7 +470,9 @@ export function emitElementModule(
|
|
|
350
470
|
eventParam: "e",
|
|
351
471
|
statePrefix: "s",
|
|
352
472
|
});
|
|
353
|
-
lines.push(
|
|
473
|
+
lines.push(
|
|
474
|
+
` ${refAccessor("this.state", escapeToken(key))} = (s, e) => { ${compiled}; };`,
|
|
475
|
+
);
|
|
354
476
|
} else if (hasStructuredBody(def)) {
|
|
355
477
|
// Structured body (spec §20) — statements compiled against this.state; dispatch target is
|
|
356
478
|
// The component instance itself (WHATWG dispatchEvent on the custom element).
|
|
@@ -360,19 +482,51 @@ export function emitElementModule(
|
|
|
360
482
|
indent: " ",
|
|
361
483
|
statePrefix: "this.state",
|
|
362
484
|
});
|
|
363
|
-
lines.push(
|
|
485
|
+
lines.push(
|
|
486
|
+
` ${refAccessor("this.state", escapeToken(key))} = (s, e) => {`,
|
|
487
|
+
compiled,
|
|
488
|
+
" };",
|
|
489
|
+
);
|
|
364
490
|
} else {
|
|
365
491
|
const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
|
|
366
492
|
const paramList = args.join(", ");
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
493
|
+
const body = typeof def.body === "string" ? def.body : "";
|
|
494
|
+
// Every call site invokes a state function as `(state, event)` — the template's
|
|
495
|
+
// `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
|
|
496
|
+
// Up positionally, so they are emitted directly. Any other declaration — including the
|
|
497
|
+
// `"parameters": ["event"]` form that examples/components/{todo-app,fetch-demo}.json use — is
|
|
498
|
+
// Mapped onto the arguments by name, the way the client target does it
|
|
499
|
+
// (compile-client.ts), with `state` bound as the outer parameter so a body referencing bare
|
|
500
|
+
// `state` resolves regardless of the declared arity.
|
|
501
|
+
if (args[0] === "state") {
|
|
502
|
+
if (def.$src) {
|
|
503
|
+
// $src function — wrap imported function so it receives state
|
|
504
|
+
lines.push(
|
|
505
|
+
` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => ${key}(${paramList});`,
|
|
506
|
+
);
|
|
507
|
+
} else {
|
|
508
|
+
lines.push(
|
|
509
|
+
` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => {`,
|
|
510
|
+
` ${body}`,
|
|
511
|
+
" };",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
370
514
|
} else {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
515
|
+
const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
|
|
516
|
+
if (def.$src) {
|
|
517
|
+
lines.push(
|
|
518
|
+
` ${refAccessor("this.state", escapeToken(key))} = (state, e) => ${key}(${callArgs});`,
|
|
519
|
+
);
|
|
520
|
+
} else {
|
|
521
|
+
lines.push(
|
|
522
|
+
` ${refAccessor("this.state", escapeToken(key))} = (state, e) => {`,
|
|
523
|
+
` const _fn = (${paramList}) => {`,
|
|
524
|
+
` ${body}`,
|
|
525
|
+
" };",
|
|
526
|
+
` return _fn(${callArgs});`,
|
|
527
|
+
" };",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
376
530
|
}
|
|
377
531
|
}
|
|
378
532
|
}
|
|
@@ -384,7 +538,19 @@ export function emitElementModule(
|
|
|
384
538
|
eventParam: "e",
|
|
385
539
|
statePrefix: "this.state",
|
|
386
540
|
});
|
|
387
|
-
lines.push(
|
|
541
|
+
lines.push(
|
|
542
|
+
` ${refAccessor("this.state", escapeToken(key))} = ${emitFormulaFn(def, compiled)};`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses, into a
|
|
547
|
+
template literal — which is what `evaluateTemplate` is, so the result is a string on both
|
|
548
|
+
sides rather than a string here and a raw value there. */
|
|
549
|
+
for (const [key, template] of templateEntries) {
|
|
550
|
+
lines.push(
|
|
551
|
+
"",
|
|
552
|
+
` ${refAccessor("this.state", escapeToken(key))} = computed(() => \`${template.replaceAll("state.", "this.state.")}\`);`,
|
|
553
|
+
);
|
|
388
554
|
}
|
|
389
555
|
|
|
390
556
|
// Emit computed signals — $src or inline body
|
|
@@ -395,11 +561,15 @@ export function emitElementModule(
|
|
|
395
561
|
eventParam: "e",
|
|
396
562
|
statePrefix: "this.state",
|
|
397
563
|
});
|
|
398
|
-
lines.push(
|
|
564
|
+
lines.push(
|
|
565
|
+
` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${compiled});`,
|
|
566
|
+
);
|
|
399
567
|
} else if (def.$src) {
|
|
400
|
-
lines.push(
|
|
568
|
+
lines.push(
|
|
569
|
+
` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${key}(this.state));`,
|
|
570
|
+
);
|
|
401
571
|
} else {
|
|
402
|
-
lines.push(` this.state
|
|
572
|
+
lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
|
|
403
573
|
const body = (typeof def.body === "string" ? def.body : "").replaceAll(
|
|
404
574
|
"state.",
|
|
405
575
|
"this.state.",
|
|
@@ -419,36 +589,101 @@ export function emitElementModule(
|
|
|
419
589
|
}
|
|
420
590
|
}
|
|
421
591
|
|
|
592
|
+
// Subtrees a branching construct would otherwise repeat are lifted into `const _cN = html`…``
|
|
593
|
+
// Declarations emitted inside `template()`, above the `return` — inside it, so they are rebuilt
|
|
594
|
+
// Per render and read the same `s` the template does.
|
|
595
|
+
const hoistScope = createHoistScope();
|
|
596
|
+
const templateBody = emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
|
|
597
|
+
|
|
422
598
|
lines.push(
|
|
423
599
|
// Template method
|
|
424
600
|
" template() {",
|
|
425
601
|
" const s = this.state;",
|
|
602
|
+
...hoistScope.decls,
|
|
426
603
|
" return html`",
|
|
427
|
-
|
|
604
|
+
templateBody,
|
|
428
605
|
" `;",
|
|
429
606
|
" }",
|
|
430
607
|
"",
|
|
608
|
+
...(shadow === null
|
|
609
|
+
? []
|
|
610
|
+
: [
|
|
611
|
+
/*
|
|
612
|
+
* The hydration contract. A declarative shadow root the parser already materialized
|
|
613
|
+
* MUST be adopted rather than replaced — calling `attachShadow` over one throws, and
|
|
614
|
+
* even where it did not, replacing it would discard the server-rendered markup this
|
|
615
|
+
* whole feature exists to ship.
|
|
616
|
+
*
|
|
617
|
+
* An `open` root is on the element. A `closed` one is not, by definition, and
|
|
618
|
+
* `ElementInternals` is the standard's only way back to it — which is why the two modes
|
|
619
|
+
* need different code rather than a mode string in one call.
|
|
620
|
+
*/
|
|
621
|
+
" #renderRoot() {",
|
|
622
|
+
" if (this.#root) return this.#root;",
|
|
623
|
+
shadow === "closed"
|
|
624
|
+
? " this.#root = this.attachInternals().shadowRoot ?? this.attachShadow({ mode: 'closed' });"
|
|
625
|
+
: " this.#root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });",
|
|
626
|
+
" return this.#root;",
|
|
627
|
+
" }",
|
|
628
|
+
"",
|
|
629
|
+
]),
|
|
431
630
|
// ConnectedCallback
|
|
432
631
|
" connectedCallback() {",
|
|
632
|
+
/*
|
|
633
|
+
* Each of the three absorption paths below skips a `#`-prefixed key: private state is not part
|
|
634
|
+
* of the component's interface and is never settable through $props (spec.md §5.6). Emitted as
|
|
635
|
+
* a runtime check rather than resolved at build time because all three read keys that arrive
|
|
636
|
+
* with the instance — an attribute payload, an attribute name, a property a parent set — none
|
|
637
|
+
* of which the compiler can see. The interpreter enforces the same rule in `runtime.ts`; the
|
|
638
|
+
* two tiers agreeing is the point, since a document is authored against whichever runs.
|
|
639
|
+
*/
|
|
433
640
|
// Read $props from data-jx-props attribute (set by compiler for pre-rendered instances)
|
|
434
641
|
" const _pa = this.getAttribute('data-jx-props');",
|
|
435
642
|
" if (_pa) {",
|
|
436
643
|
" try {",
|
|
437
644
|
" const _p = JSON.parse(_pa);",
|
|
438
645
|
" for (const [k, v] of Object.entries(_p)) {",
|
|
646
|
+
" if (k.startsWith('#')) continue;",
|
|
439
647
|
" if (k in this.state) this.state[k] = v;",
|
|
440
648
|
" }",
|
|
441
649
|
" } catch {}",
|
|
442
650
|
" this.removeAttribute('data-jx-props');",
|
|
443
651
|
" }",
|
|
652
|
+
// Literal `props.*` attributes — the live-render mirror of the build-time lift in site-build.
|
|
653
|
+
// String values only: HTML lowercases attribute names, so a state key must be lowercase to match.
|
|
654
|
+
// Names are collected first because removing while iterating the live NamedNodeMap skips entries.
|
|
655
|
+
" const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
|
|
656
|
+
" for (const _n of _pn) {",
|
|
657
|
+
" const _k = _n.slice(6);",
|
|
658
|
+
" if (_k.startsWith('#')) { this.removeAttribute(_n); continue; }",
|
|
659
|
+
" if (_k in this.state) {",
|
|
660
|
+
" this.state[_k] = this.getAttribute(_n);",
|
|
661
|
+
" this.removeAttribute(_n);",
|
|
662
|
+
" }",
|
|
663
|
+
" }",
|
|
444
664
|
// Merge JS properties set before connection (by parent runtime).
|
|
445
665
|
// Only check own properties to avoid inherited DOM properties like `title`.
|
|
446
666
|
" for (const key of Object.keys(this.state)) {",
|
|
667
|
+
" if (key.startsWith('#')) continue;",
|
|
447
668
|
" if (this.hasOwnProperty(key) && this[key] !== undefined) {",
|
|
448
669
|
" this.state[key] = this[key];",
|
|
449
670
|
" }",
|
|
450
671
|
" }",
|
|
451
672
|
);
|
|
673
|
+
|
|
674
|
+
// `$prototype: "Request"` entries fetch on connect — after the `$props`/property merge above, so
|
|
675
|
+
// A template `url` interpolates the values the parent passed in rather than the initial ones.
|
|
676
|
+
// Runners are collected so disconnectedCallback can stop them: a reactive URL would otherwise
|
|
677
|
+
// Keep fetching after removal, and every re-insertion would stack another effect on top.
|
|
678
|
+
for (const [key, def] of requestEntries) {
|
|
679
|
+
lines.push(
|
|
680
|
+
emitRequestFetch(key, def as unknown as JxPrototypeDef, {
|
|
681
|
+
collect: "this.#effects",
|
|
682
|
+
indent: " ",
|
|
683
|
+
statePrefix: "this.state",
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
452
687
|
if (doc.style && typeof doc.style === "object") {
|
|
453
688
|
const dynamicStyles: [string, string][] = [];
|
|
454
689
|
for (const [prop, value] of Object.entries(doc.style)) {
|
|
@@ -470,7 +705,7 @@ export function emitElementModule(
|
|
|
470
705
|
}
|
|
471
706
|
}
|
|
472
707
|
if (dynamicStyles.length > 0) {
|
|
473
|
-
lines.push(" effect(() => {");
|
|
708
|
+
lines.push(" this.#effects.push(effect(() => {");
|
|
474
709
|
for (const [cssProp, value] of dynamicStyles) {
|
|
475
710
|
const expr = value.replaceAll(
|
|
476
711
|
/\$\{([^}]+)\}/g,
|
|
@@ -478,11 +713,18 @@ export function emitElementModule(
|
|
|
478
713
|
);
|
|
479
714
|
lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
|
|
480
715
|
}
|
|
481
|
-
lines.push(" });");
|
|
716
|
+
lines.push(" }));");
|
|
482
717
|
}
|
|
483
718
|
}
|
|
484
719
|
const hasSlot = treeHasSlot(doc.children);
|
|
485
|
-
|
|
720
|
+
/*
|
|
721
|
+
* The slot emulation is a light-DOM construction: save the host's children, render over them,
|
|
722
|
+
* then splice them back where the literal `<slot>` element landed. Inside a shadow root that
|
|
723
|
+
* would be actively wrong — the light children ARE the slotted content and must stay in the
|
|
724
|
+
* light tree, where a real `<slot>` distributes them. This is the single largest behavioural
|
|
725
|
+
* difference between the two modes, and the reason shadow cannot be the default.
|
|
726
|
+
*/
|
|
727
|
+
if (hasSlot && shadow === null) {
|
|
486
728
|
// Save light DOM children (slotted content) before clearing
|
|
487
729
|
lines.push(
|
|
488
730
|
" const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
|
|
@@ -492,10 +734,42 @@ export function emitElementModule(
|
|
|
492
734
|
" if (this.hasAttribute('data-jx-prerendered')) {",
|
|
493
735
|
" this.removeAttribute('data-jx-prerendered');",
|
|
494
736
|
" }",
|
|
495
|
-
" this.innerHTML = '';",
|
|
496
|
-
" this.#dispose = effect(() => render(this.template(), this));",
|
|
497
737
|
);
|
|
498
|
-
if (
|
|
738
|
+
if (shadow === null) {
|
|
739
|
+
lines.push(
|
|
740
|
+
/*
|
|
741
|
+
* `replaceChildren()`, not `innerHTML = ''`: identical semantics for clearing an element, and
|
|
742
|
+
* it is not a DOM injection sink. The runtime made the same change for the same reason — a
|
|
743
|
+
* write that was never injecting anything still costs a page a Trusted Types policy under
|
|
744
|
+
* `require-trusted-types-for 'script'`, and this one is emitted into every light-DOM element
|
|
745
|
+
* module a site ships.
|
|
746
|
+
*/
|
|
747
|
+
" this.replaceChildren();",
|
|
748
|
+
" this.#effects.push(effect(() => render(this.template(), this)));",
|
|
749
|
+
);
|
|
750
|
+
} else {
|
|
751
|
+
/*
|
|
752
|
+
* Nothing clears the LIGHT DOM here: those children are the slotted content, and a real
|
|
753
|
+
* `<slot>` distributes them.
|
|
754
|
+
*
|
|
755
|
+
* The shadow root is cleared, and for the same reason light mode clears `innerHTML`: lit
|
|
756
|
+
* renders into a container by appending its own tree, so leaving the declarative markup in
|
|
757
|
+
* place shows the component twice. A declarative shadow root is a first paint, not a
|
|
758
|
+
* hydration target — Jx does not use lit-ssr's `hydrate`, and the light path discards its
|
|
759
|
+
* prerender too.
|
|
760
|
+
*
|
|
761
|
+
* The stylesheet link is the one thing that survives: it is what styles this root, the
|
|
762
|
+
* document's head cannot reach in here, and re-adding it would refetch it.
|
|
763
|
+
*/
|
|
764
|
+
lines.push(
|
|
765
|
+
" const _root = this.#renderRoot();",
|
|
766
|
+
" for (const _n of [..._root.childNodes]) {",
|
|
767
|
+
" if (!(_n.nodeType === 1 && _n.tagName === 'LINK')) _n.remove();",
|
|
768
|
+
" }",
|
|
769
|
+
" this.#effects.push(effect(() => render(this.template(), _root)));",
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (hasSlot && shadow === null) {
|
|
499
773
|
// Replace <slot> placeholder with saved slotted content
|
|
500
774
|
lines.push(
|
|
501
775
|
" const _slot = this.querySelector('slot');",
|
|
@@ -514,7 +788,8 @@ export function emitElementModule(
|
|
|
514
788
|
"",
|
|
515
789
|
// DisconnectedCallback
|
|
516
790
|
" disconnectedCallback() {",
|
|
517
|
-
"
|
|
791
|
+
" for (const _e of this.#effects) { stop(_e); }",
|
|
792
|
+
" this.#effects.length = 0;",
|
|
518
793
|
" if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
|
|
519
794
|
" }",
|
|
520
795
|
"}",
|
|
@@ -526,18 +801,62 @@ export function emitElementModule(
|
|
|
526
801
|
return lines.join("\n");
|
|
527
802
|
}
|
|
528
803
|
|
|
804
|
+
/**
|
|
805
|
+
* Declarations that must be emitted _beside_ a lit template rather than inside it.
|
|
806
|
+
*
|
|
807
|
+
* `emitLitNode` writes one template string, so a construct with N branches recursed once per branch
|
|
808
|
+
* and wrote the whole subtree N times into the bundle — the authored document contains it once. A
|
|
809
|
+
* scope collects `const _cN = html`…`;` declarations for subtrees that would otherwise repeat, and
|
|
810
|
+
* the branches reference those instead.
|
|
811
|
+
*
|
|
812
|
+
* One scope per template body: `template()` has its own, and each map callback gets a fresh one
|
|
813
|
+
* because its declarations close over that callback's `item`/`index`.
|
|
814
|
+
*/
|
|
815
|
+
interface HoistScope {
|
|
816
|
+
decls: string[];
|
|
817
|
+
byText: Map<string, string>;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function createHoistScope(): HoistScope {
|
|
821
|
+
return { byText: new Map(), decls: [] };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Hoist an emitted subtree into a `const` and return the `${_cN}` expression referencing it.
|
|
826
|
+
* Identical subtrees collapse onto a single declaration.
|
|
827
|
+
*
|
|
828
|
+
* @param {HoistScope} scope
|
|
829
|
+
* @param {string} text
|
|
830
|
+
* @returns {string}
|
|
831
|
+
*/
|
|
832
|
+
function hoistSubtree(scope: HoistScope, text: string): string {
|
|
833
|
+
const existing = scope.byText.get(text);
|
|
834
|
+
if (existing) {
|
|
835
|
+
return existing;
|
|
836
|
+
}
|
|
837
|
+
const name = `_c${scope.decls.length}`;
|
|
838
|
+
scope.decls.push(` const ${name} = html\`\n${text}\n \`;`);
|
|
839
|
+
const ref = `\${${name}}`;
|
|
840
|
+
scope.byText.set(text, ref);
|
|
841
|
+
return ref;
|
|
842
|
+
}
|
|
843
|
+
|
|
529
844
|
/**
|
|
530
845
|
* Convert Jx children to lit-html template content.
|
|
531
846
|
*
|
|
532
847
|
* @param {JxMutableNode["children"]} children
|
|
533
848
|
* @param {JxStyle | null | undefined} parentStyle
|
|
534
849
|
* @param {string} indent
|
|
850
|
+
* @param {boolean} [preformatted]
|
|
535
851
|
* @returns {string}
|
|
536
852
|
*/
|
|
537
853
|
function emitLitChildren(
|
|
538
854
|
children: JxMutableNode["children"],
|
|
539
855
|
_parentStyle: JxStyle | null | undefined,
|
|
540
856
|
indent: string,
|
|
857
|
+
preformatted = false,
|
|
858
|
+
inMap = false,
|
|
859
|
+
scope: HoistScope | null = null,
|
|
541
860
|
) {
|
|
542
861
|
if (!children) {
|
|
543
862
|
return "";
|
|
@@ -555,17 +874,28 @@ function emitLitChildren(
|
|
|
555
874
|
// Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
|
|
556
875
|
return children
|
|
557
876
|
.map((child: JxMutableNode | string) =>
|
|
558
|
-
isMappedArray(child)
|
|
877
|
+
isMappedArray(child)
|
|
878
|
+
? emitMappedArray(child, indent)
|
|
879
|
+
: emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
|
|
559
880
|
)
|
|
560
|
-
.join("\n");
|
|
881
|
+
.join(preformatted ? "" : "\n");
|
|
561
882
|
}
|
|
562
883
|
|
|
563
884
|
/**
|
|
564
885
|
* @param {JxMutableNode | string} def
|
|
565
886
|
* @param {string} indent
|
|
887
|
+
* @param {boolean} [preformatted]
|
|
566
888
|
* @returns {string}
|
|
567
889
|
*/
|
|
568
|
-
function emitLitNode(
|
|
890
|
+
function emitLitNode(
|
|
891
|
+
def: JxMutableNode | string,
|
|
892
|
+
indent: string,
|
|
893
|
+
preformatted = false,
|
|
894
|
+
inMap = false,
|
|
895
|
+
scope: HoistScope | null = null,
|
|
896
|
+
/** Pre-emitted inner content, so a branching construct can share one subtree across branches. */
|
|
897
|
+
innerOverride: string | null = null,
|
|
898
|
+
): string {
|
|
569
899
|
// String children are text nodes
|
|
570
900
|
if (typeof def === "string") {
|
|
571
901
|
if (def.includes("${")) {
|
|
@@ -580,7 +910,70 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
580
910
|
return "";
|
|
581
911
|
}
|
|
582
912
|
|
|
913
|
+
/* A CHOSEN TAG BECOMES ONE TEMPLATE PER CANDIDATE, keyed by the discriminant — the same shape
|
|
914
|
+
this function already emits for `$switch` twenty lines down, because it is the same problem:
|
|
915
|
+
lit cannot bind a tag name, and the alternative (`lit-html/static.js`'s `unsafeStatic`) is an
|
|
916
|
+
HTML-injection primitive with an unbounded template cache, deliberately avoided in this repo.
|
|
917
|
+
|
|
918
|
+
The candidates are literal `TagName`s by schema, so this terminates and every branch is a legal
|
|
919
|
+
element. The subtree is written once in the DOCUMENT, and — since the hoist scope below lifts it
|
|
920
|
+
into a single `const` the branches share — once in the BUNDLE too. */
|
|
921
|
+
if (isTagExpression(def.tagName)) {
|
|
922
|
+
const expression = def.tagName.$expression;
|
|
923
|
+
// The same two shapes `$switch` accepts: a pointer, or an expression node.
|
|
924
|
+
// `compileOperandSource` — the compiler's counterpart to the runtime's `evaluateOperand`, so
|
|
925
|
+
// Both sides read the discriminant the same way.
|
|
926
|
+
const discriminant = compileOperandSource(expression.target, {
|
|
927
|
+
eventParam: "e",
|
|
928
|
+
statePrefix: "s",
|
|
929
|
+
});
|
|
930
|
+
const branches =
|
|
931
|
+
expression.operator === "?:"
|
|
932
|
+
? [
|
|
933
|
+
[true, expression.value],
|
|
934
|
+
[false, expression.initial],
|
|
935
|
+
]
|
|
936
|
+
: [...Object.entries(expression.cases), ["__default__", expression.default]];
|
|
937
|
+
// The inner content is identical across candidates — only the tag differs — so it is emitted
|
|
938
|
+
// Once and every branch references it. Skipped when the candidates disagree about being
|
|
939
|
+
// Preformatted, because `white-space` decides how the subtree itself is indented.
|
|
940
|
+
const candidateTags = branches.map(([, candidate]) => String(candidate));
|
|
941
|
+
const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
|
|
942
|
+
let innerRef: string | null = null;
|
|
943
|
+
if (scope && sharedPre.every((p) => p === sharedPre[0])) {
|
|
944
|
+
const sharedInner = emitNodeInner(def, `${indent} `, sharedPre[0] === true, inMap, scope);
|
|
945
|
+
if (sharedInner.trim() !== "") {
|
|
946
|
+
innerRef = hoistSubtree(scope, sharedInner);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const rendered: [string | boolean | undefined, string][] = branches.map(([key, candidate]) => {
|
|
950
|
+
const asLiteral = { ...def, tagName: candidate as string };
|
|
951
|
+
return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
|
|
952
|
+
});
|
|
953
|
+
if (expression.operator === "?:") {
|
|
954
|
+
const [, yes] = rendered[0]!;
|
|
955
|
+
const [, no] = rendered[1]!;
|
|
956
|
+
return `${indent}\${${discriminant}
|
|
957
|
+
? html\`
|
|
958
|
+
${yes}
|
|
959
|
+
\`
|
|
960
|
+
: html\`
|
|
961
|
+
${no}
|
|
962
|
+
\`}`;
|
|
963
|
+
}
|
|
964
|
+
const entries = rendered
|
|
965
|
+
.filter(([key]) => key !== "__default__")
|
|
966
|
+
.map(([key, text]) => ` ${JSON.stringify(key)}: html\`\n${text}\n \``);
|
|
967
|
+
const [, fallback] = rendered.at(-1)!;
|
|
968
|
+
return (
|
|
969
|
+
`${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
|
|
970
|
+
`[String(${discriminant})] ?? html\`\n${fallback}\n\`}`
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
583
974
|
const tag = def.tagName ?? "div";
|
|
975
|
+
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
976
|
+
const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
|
|
584
977
|
|
|
585
978
|
const parts: string[] = [];
|
|
586
979
|
|
|
@@ -596,11 +989,14 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
596
989
|
}
|
|
597
990
|
}
|
|
598
991
|
|
|
992
|
+
// `id`/`className` take the same template rewriting as every other attribute — inside a map
|
|
993
|
+
// These routinely carry `${$map.item…}`/`${$map.index}`, which emitMappedArray already rewrites
|
|
994
|
+
// On the map root but nothing rewrote on its descendants.
|
|
599
995
|
if (def.id) {
|
|
600
|
-
parts.push(`id="${def.id}"`);
|
|
996
|
+
parts.push(`id="${toLitExpr(String(def.id))}"`);
|
|
601
997
|
}
|
|
602
998
|
if (def.className) {
|
|
603
|
-
parts.push(`class="${def.className}"`);
|
|
999
|
+
parts.push(`class="${toLitExpr(String(def.className))}"`);
|
|
604
1000
|
}
|
|
605
1001
|
|
|
606
1002
|
for (const [key, val] of Object.entries(def)) {
|
|
@@ -624,6 +1020,12 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
624
1020
|
parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
|
|
625
1021
|
} else if (typeof val === "string" && val.includes("${")) {
|
|
626
1022
|
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
1023
|
+
} else {
|
|
1024
|
+
// A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
|
|
1025
|
+
// For. Without this branch it was dropped outright — including every `${…}` a build-time map
|
|
1026
|
+
// Expansion had already resolved to a constant, which is how an unrolled `<option>` lost its
|
|
1027
|
+
// Value and fell back to using its own label as its key.
|
|
1028
|
+
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
627
1029
|
}
|
|
628
1030
|
}
|
|
629
1031
|
|
|
@@ -631,12 +1033,21 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
631
1033
|
for (const [key, val] of Object.entries(def.$props)) {
|
|
632
1034
|
if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
|
|
633
1035
|
parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
|
|
1036
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
1037
|
+
// A template value is a binding, not text. JSON-quoting it emitted the template's own source
|
|
1038
|
+
// As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
|
|
1039
|
+
// String `${state.x}` forever.
|
|
1040
|
+
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
634
1041
|
} else {
|
|
635
1042
|
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
636
1043
|
}
|
|
637
1044
|
}
|
|
638
1045
|
}
|
|
639
1046
|
|
|
1047
|
+
// A handler bound inside a map publishes its iteration to state before running, matching the
|
|
1048
|
+
// Interpreter's child scope and the client target's handler wrapper. Bodies read it as
|
|
1049
|
+
// `state.$map.index` / `state.$map.item`.
|
|
1050
|
+
const mapCtx = inMap ? "s.$map = $map; " : "";
|
|
640
1051
|
for (const [key, val] of Object.entries(def)) {
|
|
641
1052
|
if (!key.startsWith("on") || key === "observedAttributes") {
|
|
642
1053
|
continue;
|
|
@@ -644,7 +1055,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
644
1055
|
const eventName = key.slice(2).toLowerCase();
|
|
645
1056
|
if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
|
|
646
1057
|
parts.push(
|
|
647
|
-
|
|
1058
|
+
mapCtx
|
|
1059
|
+
? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
|
|
1060
|
+
: `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
|
|
648
1061
|
);
|
|
649
1062
|
} else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
|
|
650
1063
|
const compiled = compileExpression(
|
|
@@ -654,9 +1067,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
654
1067
|
statePrefix: "s",
|
|
655
1068
|
},
|
|
656
1069
|
);
|
|
657
|
-
parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
|
|
1070
|
+
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
|
|
658
1071
|
} else if (isFunctionDef(val)) {
|
|
659
|
-
parts.push(`@${eventName}="\${(e) => { ${inlineHandlerBody(val)} }}"`);
|
|
1072
|
+
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
|
|
660
1073
|
}
|
|
661
1074
|
}
|
|
662
1075
|
|
|
@@ -672,29 +1085,70 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
672
1085
|
return `${indent}<${tag}${attrsStr}\n${indent}>`;
|
|
673
1086
|
}
|
|
674
1087
|
|
|
675
|
-
|
|
1088
|
+
const inner = innerOverride ?? emitNodeInner(def, indent, inPre, inMap, scope);
|
|
1089
|
+
|
|
1090
|
+
// Inside a <pre>, the newline before `>` would land in the element's own text.
|
|
1091
|
+
return inPre
|
|
1092
|
+
? `<${tag}${attrsStr}>${inner}</${tag}>`
|
|
1093
|
+
: `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Emit an element's inner content — the `$switch` lookup, text, raw HTML, or children.
|
|
1098
|
+
*
|
|
1099
|
+
* Split out of `emitLitNode` so a chosen `tagName` can emit it once and share it across candidates
|
|
1100
|
+
* instead of recursing into the same subtree per branch.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {JxMutableNode} def
|
|
1103
|
+
* @param {string} indent
|
|
1104
|
+
* @param {boolean} inPre
|
|
1105
|
+
* @param {boolean} inMap
|
|
1106
|
+
* @param {HoistScope | null} scope
|
|
1107
|
+
* @returns {string}
|
|
1108
|
+
*/
|
|
1109
|
+
function emitNodeInner(
|
|
1110
|
+
def: JxMutableNode,
|
|
1111
|
+
indent: string,
|
|
1112
|
+
inPre: boolean,
|
|
1113
|
+
inMap: boolean,
|
|
1114
|
+
scope: HoistScope | null,
|
|
1115
|
+
): string {
|
|
676
1116
|
if (def.$switch) {
|
|
677
1117
|
const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
|
|
678
1118
|
const switchExpr = refToExpr(switchRef);
|
|
679
1119
|
const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
|
|
680
|
-
const
|
|
1120
|
+
const rendered: [string, string][] = [];
|
|
681
1121
|
for (const [key, caseDef] of Object.entries(cases)) {
|
|
682
1122
|
if (!caseDef || isRef(caseDef)) {
|
|
683
1123
|
continue;
|
|
684
1124
|
}
|
|
685
|
-
|
|
686
|
-
caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
|
|
1125
|
+
rendered.push([key, emitLitNode(caseDef, `${indent} `, false, inMap, scope)]);
|
|
687
1126
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1127
|
+
// Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
|
|
1128
|
+
// Once per case — the authored document names the subtree once (#126).
|
|
1129
|
+
const repeated = new Set(
|
|
1130
|
+
rendered
|
|
1131
|
+
.map(([, text]) => text)
|
|
1132
|
+
.filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
|
|
1133
|
+
);
|
|
1134
|
+
const caseEntries = rendered.map(([key, text]) => {
|
|
1135
|
+
const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
|
|
1136
|
+
return ` ${JSON.stringify(key)}: html\`\n${body}\n \``;
|
|
1137
|
+
});
|
|
1138
|
+
return `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
|
|
695
1139
|
}
|
|
696
|
-
|
|
697
|
-
|
|
1140
|
+
if (def.textContent !== undefined) {
|
|
1141
|
+
return toLitTextContent(def.textContent);
|
|
1142
|
+
}
|
|
1143
|
+
if (def.innerHTML !== undefined) {
|
|
1144
|
+
return def.innerHTML;
|
|
1145
|
+
}
|
|
1146
|
+
if (def.children) {
|
|
1147
|
+
return inPre
|
|
1148
|
+
? emitLitChildren(def.children, def.style, "", true, inMap, scope)
|
|
1149
|
+
: `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap, scope)}\n${indent}`;
|
|
1150
|
+
}
|
|
1151
|
+
return "";
|
|
698
1152
|
}
|
|
699
1153
|
|
|
700
1154
|
/**
|
|
@@ -713,10 +1167,65 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
713
1167
|
const tag = mapDef.tagName ?? "div";
|
|
714
1168
|
const parts: string[] = [];
|
|
715
1169
|
|
|
1170
|
+
// The map root gets the same attribute treatment as any other element (emitLitNode above);
|
|
1171
|
+
// `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
|
|
1172
|
+
if (mapDef.attributes) {
|
|
1173
|
+
for (const [key, val] of Object.entries(mapDef.attributes)) {
|
|
1174
|
+
if (val && typeof val === "object" && isRef(val)) {
|
|
1175
|
+
parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
1176
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
1177
|
+
parts.push(`${key}="${toLitExpr(val)}"`);
|
|
1178
|
+
} else {
|
|
1179
|
+
parts.push(`${key}="${val}"`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (mapDef.id) {
|
|
1185
|
+
parts.push(`id="${toLitExpr(String(mapDef.id))}"`);
|
|
1186
|
+
}
|
|
1187
|
+
if (mapDef.className) {
|
|
1188
|
+
parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// IDL properties (`value`, `checked`, `selected`, …) go straight on the element, exactly as they
|
|
1192
|
+
// Do outside a map (emitLitNode above). Without this loop the map body silently dropped them, so
|
|
1193
|
+
// An `<option value="${$map.item.v}">` emitted no value at all and every option fell back to its
|
|
1194
|
+
// Own label — a change handler then received a display string instead of the key.
|
|
1195
|
+
for (const [key, val] of Object.entries(mapDef)) {
|
|
1196
|
+
if (
|
|
1197
|
+
RESERVED_KEYS.has(key) ||
|
|
1198
|
+
key.startsWith("$") ||
|
|
1199
|
+
key.startsWith("on") ||
|
|
1200
|
+
key === "tagName" ||
|
|
1201
|
+
key === "id" ||
|
|
1202
|
+
key === "className" ||
|
|
1203
|
+
key === "style" ||
|
|
1204
|
+
key === "children" ||
|
|
1205
|
+
key === "textContent" ||
|
|
1206
|
+
key === "innerHTML" ||
|
|
1207
|
+
key === "attributes"
|
|
1208
|
+
) {
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
|
|
1213
|
+
parts.push(`.${key}="\${${mapRefToExpr((val as JxMutableNode).$ref as string)}}"`);
|
|
1214
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
1215
|
+
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
1216
|
+
} else {
|
|
1217
|
+
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
716
1221
|
if (mapDef.$props) {
|
|
717
1222
|
for (const [key, val] of Object.entries(mapDef.$props)) {
|
|
718
1223
|
if (isRef(val)) {
|
|
719
1224
|
parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
1225
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
1226
|
+
// `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
|
|
1227
|
+
// Component in a list; JSON-quoting it handed over the template source instead.
|
|
1228
|
+
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
720
1229
|
} else {
|
|
721
1230
|
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
722
1231
|
}
|
|
@@ -734,26 +1243,46 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
734
1243
|
}
|
|
735
1244
|
const eventName = key.slice(2).toLowerCase();
|
|
736
1245
|
if (isRef(val)) {
|
|
737
|
-
parts.push(`@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`);
|
|
1246
|
+
parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
|
|
738
1247
|
} else if (isExpressionDef(val)) {
|
|
739
1248
|
const compiled = compileExpression(val.$expression as ExpressionNode, {
|
|
740
1249
|
eventParam: "e",
|
|
741
1250
|
statePrefix: "s",
|
|
742
1251
|
});
|
|
743
|
-
parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
|
|
1252
|
+
parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${compiled}; }}"`);
|
|
744
1253
|
}
|
|
745
1254
|
}
|
|
746
1255
|
|
|
747
1256
|
const attrsStr = parts.length > 0 ? `\n${indent} ${parts.join(`\n${indent} `)}` : "";
|
|
748
1257
|
|
|
1258
|
+
// The map body gets its own hoist scope: a subtree lifted out of a branch here closes over this
|
|
1259
|
+
// Callback's `item`/`index`, so its declaration belongs inside the callback, not in `template()`.
|
|
1260
|
+
const mapScope = createHoistScope();
|
|
749
1261
|
let inner = "";
|
|
750
1262
|
if (mapDef.textContent !== undefined) {
|
|
751
1263
|
inner = toLitTextContent(mapDef.textContent);
|
|
752
1264
|
} else if (mapDef.children) {
|
|
753
|
-
inner = `\n${emitLitChildren(mapDef.children, null, `${indent}
|
|
1265
|
+
inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true, mapScope)}\n${indent} `;
|
|
754
1266
|
}
|
|
755
1267
|
|
|
756
|
-
|
|
1268
|
+
const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
|
|
1269
|
+
|
|
1270
|
+
// `${$map.item…}` and `${$map.index}` are spec-sanctioned template forms: spec.md §6.6 names
|
|
1271
|
+
// `$map` alongside `item`/`index` as an iteration binding, and the interpreter passes it into the
|
|
1272
|
+
// Template evaluator as a real parameter. The compiled callback bound only `item`/`index`, so
|
|
1273
|
+
// Every such template survived into the module as a dead `$map` reference and threw at render.
|
|
1274
|
+
// Binding the same object here resolves every access form — `$map.item`, `$map?.item`,
|
|
1275
|
+
// `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
|
|
1276
|
+
// The interpreter's child scope does. Emitted only when referenced, so output is otherwise
|
|
1277
|
+
// Unchanged.
|
|
1278
|
+
const prelude =
|
|
1279
|
+
(body.includes("$map") ? `const $map = { item, index }; ` : "") +
|
|
1280
|
+
mapScope.decls.map((d) => `${d.trim()} `).join("");
|
|
1281
|
+
const callback = prelude
|
|
1282
|
+
? `(item, index) => { ${prelude}return ${body}; }`
|
|
1283
|
+
: `(item, index) => ${body}`;
|
|
1284
|
+
|
|
1285
|
+
return `${indent}\${${itemsExpr}.map(${callback})}`;
|
|
757
1286
|
}
|
|
758
1287
|
|
|
759
1288
|
/**
|
|
@@ -764,14 +1293,38 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
764
1293
|
*/
|
|
765
1294
|
function refToExpr(ref: string) {
|
|
766
1295
|
if (ref.startsWith("#/state/")) {
|
|
767
|
-
|
|
768
|
-
return `s.${path.replaceAll("/", ".")}`;
|
|
1296
|
+
return refAccessor("s", ref.slice("#/state/".length));
|
|
769
1297
|
}
|
|
770
1298
|
if (ref.startsWith("$map/")) {
|
|
771
|
-
|
|
772
|
-
|
|
1299
|
+
return mapVarAccessor(ref.slice("$map/".length));
|
|
1300
|
+
}
|
|
1301
|
+
/*
|
|
1302
|
+
* The remaining schemes, mirroring `compileRef` in @jxsuite/runtime. They used to fall through to
|
|
1303
|
+
* the state branch, so `parent#/user` emitted `s.parent#/user` — a syntax error. A prop reaches
|
|
1304
|
+
* this element through `state`, so `parent#/` reads from `s` exactly as `#/state/` does.
|
|
1305
|
+
*/
|
|
1306
|
+
if (ref.startsWith("parent#/")) {
|
|
1307
|
+
return refAccessor("s", ref.slice("parent#/".length));
|
|
773
1308
|
}
|
|
774
|
-
|
|
1309
|
+
if (ref.startsWith("window#/")) {
|
|
1310
|
+
return refAccessor("window", ref.slice("window#/".length));
|
|
1311
|
+
}
|
|
1312
|
+
if (ref.startsWith("document#/")) {
|
|
1313
|
+
return refAccessor("document", ref.slice("document#/".length));
|
|
1314
|
+
}
|
|
1315
|
+
return refAccessor("s", ref);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* `$map/item/title` reads the loop variable `item`, so its first segment is an emitted identifier
|
|
1320
|
+
* rather than a member of some receiver — the one ref form whose base is not fixed.
|
|
1321
|
+
*
|
|
1322
|
+
* @param {string} path
|
|
1323
|
+
* @returns {string}
|
|
1324
|
+
*/
|
|
1325
|
+
function mapVarAccessor(path: string) {
|
|
1326
|
+
const [head, ...rest] = refSegments(path);
|
|
1327
|
+
return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
|
|
775
1328
|
}
|
|
776
1329
|
|
|
777
1330
|
/**
|
|
@@ -780,7 +1333,7 @@ function refToExpr(ref: string) {
|
|
|
780
1333
|
*/
|
|
781
1334
|
function mapRefToExpr(ref: string) {
|
|
782
1335
|
if (ref.startsWith("$map/")) {
|
|
783
|
-
return ref.slice("$map/".length)
|
|
1336
|
+
return mapVarAccessor(ref.slice("$map/".length));
|
|
784
1337
|
}
|
|
785
1338
|
return refToExpr(ref);
|
|
786
1339
|
}
|
|
@@ -828,6 +1381,15 @@ function inlineHandlerBody(def: JxFunctionDef) {
|
|
|
828
1381
|
});
|
|
829
1382
|
}
|
|
830
1383
|
const body = typeof def.body === "string" ? def.body : "";
|
|
1384
|
+
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
1385
|
+
if (declared.length > 0) {
|
|
1386
|
+
// The call site supplies only `(e)`, so map the declared names onto the arguments the handler
|
|
1387
|
+
// Actually has — `state` to the `s` alias, anything else to the event. Binding by name means the
|
|
1388
|
+
// Body needs no textual rewriting, and a declared `event` stops silently resolving to the
|
|
1389
|
+
// Deprecated `window.event` global (any other declared name threw ReferenceError outright).
|
|
1390
|
+
const args = declared.map((name: string) => (name === "state" ? "s" : "e")).join(", ");
|
|
1391
|
+
return `((${declared.join(", ")}) => { ${body} })(${args});`;
|
|
1392
|
+
}
|
|
831
1393
|
return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
|
|
832
1394
|
}
|
|
833
1395
|
|