@jxsuite/compiler 1.5.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +567 -232
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- 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 +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
|
@@ -7,15 +7,16 @@
|
|
|
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,
|
|
14
16
|
emitRequestFetch,
|
|
15
17
|
escapeHtml,
|
|
16
18
|
isMutating,
|
|
17
19
|
isSchemaOnly,
|
|
18
|
-
PREFORMATTED_TAGS,
|
|
19
20
|
srcImportBinding,
|
|
20
21
|
tagNameToClassName,
|
|
21
22
|
} from "../shared.ts";
|
|
@@ -27,11 +28,15 @@ import {
|
|
|
27
28
|
isMappedArray,
|
|
28
29
|
isNamedFormulaDef,
|
|
29
30
|
isRef,
|
|
31
|
+
isTagExpression,
|
|
30
32
|
paramNames,
|
|
31
33
|
} from "@jxsuite/schema/guards";
|
|
32
34
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
33
35
|
import type { ExpressionNode } from "../shared.ts";
|
|
36
|
+
import { resolveShadowMode } from "../shadow.ts";
|
|
37
|
+
import type { ShadowMode } from "../shadow.ts";
|
|
34
38
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
39
|
+
import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
|
|
35
40
|
import type {
|
|
36
41
|
JxDocument,
|
|
37
42
|
JxExpressionDef,
|
|
@@ -40,6 +45,7 @@ import type {
|
|
|
40
45
|
JxMutableNode,
|
|
41
46
|
JxPrototypeDef,
|
|
42
47
|
JxStyle,
|
|
48
|
+
ProjectConfig,
|
|
43
49
|
} from "@jxsuite/schema/types";
|
|
44
50
|
|
|
45
51
|
/** Options accepted by compileElement. */
|
|
@@ -55,6 +61,8 @@ export interface CompileElementOptions {
|
|
|
55
61
|
$media?: Record<string, string>;
|
|
56
62
|
formats?: FormatRegistry;
|
|
57
63
|
basePath?: string | null;
|
|
64
|
+
/** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
|
|
65
|
+
defaults?: ProjectConfig["defaults"];
|
|
58
66
|
[key: string]: unknown;
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -152,6 +160,7 @@ export async function compileElement(
|
|
|
152
160
|
className,
|
|
153
161
|
elementImports,
|
|
154
162
|
rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
|
|
163
|
+
resolveShadowMode(doc, opts.defaults),
|
|
155
164
|
);
|
|
156
165
|
const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
|
|
157
166
|
files.push({ content: jsContent, path: outputPath, tagName });
|
|
@@ -328,6 +337,7 @@ export function emitElementModule(
|
|
|
328
337
|
className: string,
|
|
329
338
|
elementImports: string[],
|
|
330
339
|
rewriteSrc?: (specifier: string) => string,
|
|
340
|
+
shadow: ShadowMode | null = null,
|
|
331
341
|
) {
|
|
332
342
|
const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
|
|
333
343
|
|
|
@@ -377,6 +387,7 @@ export function emitElementModule(
|
|
|
377
387
|
// And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
|
|
378
388
|
// Held a handle to.
|
|
379
389
|
" #effects = [];",
|
|
390
|
+
...(shadow === null ? [] : [" #root = null;"]),
|
|
380
391
|
"",
|
|
381
392
|
// Constructor: build reactive state
|
|
382
393
|
" constructor() {",
|
|
@@ -388,6 +399,8 @@ export function emitElementModule(
|
|
|
388
399
|
const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
|
|
389
400
|
|
|
390
401
|
const formulaEntries: [string, JxExpressionDef][] = [];
|
|
402
|
+
/** `"${…}"` state entries — computed, exactly as the runtime treats them. */
|
|
403
|
+
const templateEntries: [string, string][] = [];
|
|
391
404
|
const callableRefs = collectCallableRefs(doc);
|
|
392
405
|
for (const [key, def] of Object.entries(defs)) {
|
|
393
406
|
const d = def as JxMutableNode;
|
|
@@ -415,6 +428,20 @@ export function emitElementModule(
|
|
|
415
428
|
} else {
|
|
416
429
|
functionEntries.push([key, d]);
|
|
417
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]);
|
|
418
445
|
} else {
|
|
419
446
|
// Use extractInitialValue to get the correct initial value
|
|
420
447
|
const initVal = extractInitialValue(d);
|
|
@@ -427,7 +454,11 @@ export function emitElementModule(
|
|
|
427
454
|
// Emit reactive({...}) with initial state values
|
|
428
455
|
lines.push(" this.state = reactive({");
|
|
429
456
|
for (const [key, initVal] of stateEntries) {
|
|
430
|
-
|
|
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},`);
|
|
431
462
|
}
|
|
432
463
|
lines.push(" });");
|
|
433
464
|
|
|
@@ -439,7 +470,9 @@ export function emitElementModule(
|
|
|
439
470
|
eventParam: "e",
|
|
440
471
|
statePrefix: "s",
|
|
441
472
|
});
|
|
442
|
-
lines.push(
|
|
473
|
+
lines.push(
|
|
474
|
+
` ${refAccessor("this.state", escapeToken(key))} = (s, e) => { ${compiled}; };`,
|
|
475
|
+
);
|
|
443
476
|
} else if (hasStructuredBody(def)) {
|
|
444
477
|
// Structured body (spec §20) — statements compiled against this.state; dispatch target is
|
|
445
478
|
// The component instance itself (WHATWG dispatchEvent on the custom element).
|
|
@@ -449,7 +482,11 @@ export function emitElementModule(
|
|
|
449
482
|
indent: " ",
|
|
450
483
|
statePrefix: "this.state",
|
|
451
484
|
});
|
|
452
|
-
lines.push(
|
|
485
|
+
lines.push(
|
|
486
|
+
` ${refAccessor("this.state", escapeToken(key))} = (s, e) => {`,
|
|
487
|
+
compiled,
|
|
488
|
+
" };",
|
|
489
|
+
);
|
|
453
490
|
} else {
|
|
454
491
|
const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
|
|
455
492
|
const paramList = args.join(", ");
|
|
@@ -464,17 +501,25 @@ export function emitElementModule(
|
|
|
464
501
|
if (args[0] === "state") {
|
|
465
502
|
if (def.$src) {
|
|
466
503
|
// $src function — wrap imported function so it receives state
|
|
467
|
-
lines.push(
|
|
504
|
+
lines.push(
|
|
505
|
+
` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => ${key}(${paramList});`,
|
|
506
|
+
);
|
|
468
507
|
} else {
|
|
469
|
-
lines.push(
|
|
508
|
+
lines.push(
|
|
509
|
+
` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => {`,
|
|
510
|
+
` ${body}`,
|
|
511
|
+
" };",
|
|
512
|
+
);
|
|
470
513
|
}
|
|
471
514
|
} else {
|
|
472
515
|
const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
|
|
473
516
|
if (def.$src) {
|
|
474
|
-
lines.push(
|
|
517
|
+
lines.push(
|
|
518
|
+
` ${refAccessor("this.state", escapeToken(key))} = (state, e) => ${key}(${callArgs});`,
|
|
519
|
+
);
|
|
475
520
|
} else {
|
|
476
521
|
lines.push(
|
|
477
|
-
` this.state
|
|
522
|
+
` ${refAccessor("this.state", escapeToken(key))} = (state, e) => {`,
|
|
478
523
|
` const _fn = (${paramList}) => {`,
|
|
479
524
|
` ${body}`,
|
|
480
525
|
" };",
|
|
@@ -493,7 +538,19 @@ export function emitElementModule(
|
|
|
493
538
|
eventParam: "e",
|
|
494
539
|
statePrefix: "this.state",
|
|
495
540
|
});
|
|
496
|
-
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
|
+
);
|
|
497
554
|
}
|
|
498
555
|
|
|
499
556
|
// Emit computed signals — $src or inline body
|
|
@@ -504,11 +561,15 @@ export function emitElementModule(
|
|
|
504
561
|
eventParam: "e",
|
|
505
562
|
statePrefix: "this.state",
|
|
506
563
|
});
|
|
507
|
-
lines.push(
|
|
564
|
+
lines.push(
|
|
565
|
+
` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${compiled});`,
|
|
566
|
+
);
|
|
508
567
|
} else if (def.$src) {
|
|
509
|
-
lines.push(
|
|
568
|
+
lines.push(
|
|
569
|
+
` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${key}(this.state));`,
|
|
570
|
+
);
|
|
510
571
|
} else {
|
|
511
|
-
lines.push(` this.state
|
|
572
|
+
lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
|
|
512
573
|
const body = (typeof def.body === "string" ? def.body : "").replaceAll(
|
|
513
574
|
"state.",
|
|
514
575
|
"this.state.",
|
|
@@ -528,23 +589,61 @@ export function emitElementModule(
|
|
|
528
589
|
}
|
|
529
590
|
}
|
|
530
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
|
+
|
|
531
598
|
lines.push(
|
|
532
599
|
// Template method
|
|
533
600
|
" template() {",
|
|
534
601
|
" const s = this.state;",
|
|
602
|
+
...hoistScope.decls,
|
|
535
603
|
" return html`",
|
|
536
|
-
|
|
604
|
+
templateBody,
|
|
537
605
|
" `;",
|
|
538
606
|
" }",
|
|
539
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
|
+
]),
|
|
540
630
|
// ConnectedCallback
|
|
541
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
|
+
*/
|
|
542
640
|
// Read $props from data-jx-props attribute (set by compiler for pre-rendered instances)
|
|
543
641
|
" const _pa = this.getAttribute('data-jx-props');",
|
|
544
642
|
" if (_pa) {",
|
|
545
643
|
" try {",
|
|
546
644
|
" const _p = JSON.parse(_pa);",
|
|
547
645
|
" for (const [k, v] of Object.entries(_p)) {",
|
|
646
|
+
" if (k.startsWith('#')) continue;",
|
|
548
647
|
" if (k in this.state) this.state[k] = v;",
|
|
549
648
|
" }",
|
|
550
649
|
" } catch {}",
|
|
@@ -556,6 +655,7 @@ export function emitElementModule(
|
|
|
556
655
|
" const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
|
|
557
656
|
" for (const _n of _pn) {",
|
|
558
657
|
" const _k = _n.slice(6);",
|
|
658
|
+
" if (_k.startsWith('#')) { this.removeAttribute(_n); continue; }",
|
|
559
659
|
" if (_k in this.state) {",
|
|
560
660
|
" this.state[_k] = this.getAttribute(_n);",
|
|
561
661
|
" this.removeAttribute(_n);",
|
|
@@ -564,6 +664,7 @@ export function emitElementModule(
|
|
|
564
664
|
// Merge JS properties set before connection (by parent runtime).
|
|
565
665
|
// Only check own properties to avoid inherited DOM properties like `title`.
|
|
566
666
|
" for (const key of Object.keys(this.state)) {",
|
|
667
|
+
" if (key.startsWith('#')) continue;",
|
|
567
668
|
" if (this.hasOwnProperty(key) && this[key] !== undefined) {",
|
|
568
669
|
" this.state[key] = this[key];",
|
|
569
670
|
" }",
|
|
@@ -616,7 +717,14 @@ export function emitElementModule(
|
|
|
616
717
|
}
|
|
617
718
|
}
|
|
618
719
|
const hasSlot = treeHasSlot(doc.children);
|
|
619
|
-
|
|
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) {
|
|
620
728
|
// Save light DOM children (slotted content) before clearing
|
|
621
729
|
lines.push(
|
|
622
730
|
" const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
|
|
@@ -626,10 +734,42 @@ export function emitElementModule(
|
|
|
626
734
|
" if (this.hasAttribute('data-jx-prerendered')) {",
|
|
627
735
|
" this.removeAttribute('data-jx-prerendered');",
|
|
628
736
|
" }",
|
|
629
|
-
" this.innerHTML = '';",
|
|
630
|
-
" this.#effects.push(effect(() => render(this.template(), this)));",
|
|
631
737
|
);
|
|
632
|
-
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) {
|
|
633
773
|
// Replace <slot> placeholder with saved slotted content
|
|
634
774
|
lines.push(
|
|
635
775
|
" const _slot = this.querySelector('slot');",
|
|
@@ -661,6 +801,46 @@ export function emitElementModule(
|
|
|
661
801
|
return lines.join("\n");
|
|
662
802
|
}
|
|
663
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
|
+
|
|
664
844
|
/**
|
|
665
845
|
* Convert Jx children to lit-html template content.
|
|
666
846
|
*
|
|
@@ -676,6 +856,7 @@ function emitLitChildren(
|
|
|
676
856
|
indent: string,
|
|
677
857
|
preformatted = false,
|
|
678
858
|
inMap = false,
|
|
859
|
+
scope: HoistScope | null = null,
|
|
679
860
|
) {
|
|
680
861
|
if (!children) {
|
|
681
862
|
return "";
|
|
@@ -695,7 +876,7 @@ function emitLitChildren(
|
|
|
695
876
|
.map((child: JxMutableNode | string) =>
|
|
696
877
|
isMappedArray(child)
|
|
697
878
|
? emitMappedArray(child, indent)
|
|
698
|
-
: emitLitNode(child, preformatted ? "" : indent, preformatted, inMap),
|
|
879
|
+
: emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
|
|
699
880
|
)
|
|
700
881
|
.join(preformatted ? "" : "\n");
|
|
701
882
|
}
|
|
@@ -711,7 +892,10 @@ function emitLitNode(
|
|
|
711
892
|
indent: string,
|
|
712
893
|
preformatted = false,
|
|
713
894
|
inMap = false,
|
|
714
|
-
|
|
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 {
|
|
715
899
|
// String children are text nodes
|
|
716
900
|
if (typeof def === "string") {
|
|
717
901
|
if (def.includes("${")) {
|
|
@@ -726,6 +910,67 @@ function emitLitNode(
|
|
|
726
910
|
return "";
|
|
727
911
|
}
|
|
728
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
|
+
|
|
729
974
|
const tag = def.tagName ?? "div";
|
|
730
975
|
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
731
976
|
const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
|
|
@@ -775,6 +1020,12 @@ function emitLitNode(
|
|
|
775
1020
|
parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
|
|
776
1021
|
} else if (typeof val === "string" && val.includes("${")) {
|
|
777
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)}}"`);
|
|
778
1029
|
}
|
|
779
1030
|
}
|
|
780
1031
|
|
|
@@ -834,34 +1085,70 @@ function emitLitNode(
|
|
|
834
1085
|
return `${indent}<${tag}${attrsStr}\n${indent}>`;
|
|
835
1086
|
}
|
|
836
1087
|
|
|
837
|
-
|
|
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 {
|
|
838
1116
|
if (def.$switch) {
|
|
839
1117
|
const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
|
|
840
1118
|
const switchExpr = refToExpr(switchRef);
|
|
841
1119
|
const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
|
|
842
|
-
const
|
|
1120
|
+
const rendered: [string, string][] = [];
|
|
843
1121
|
for (const [key, caseDef] of Object.entries(cases)) {
|
|
844
1122
|
if (!caseDef || isRef(caseDef)) {
|
|
845
1123
|
continue;
|
|
846
1124
|
}
|
|
847
|
-
|
|
848
|
-
caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
|
|
1125
|
+
rendered.push([key, emitLitNode(caseDef, `${indent} `, false, inMap, scope)]);
|
|
849
1126
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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}]}`;
|
|
859
1139
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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 "";
|
|
865
1152
|
}
|
|
866
1153
|
|
|
867
1154
|
/**
|
|
@@ -901,6 +1188,36 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
901
1188
|
parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
|
|
902
1189
|
}
|
|
903
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
|
+
|
|
904
1221
|
if (mapDef.$props) {
|
|
905
1222
|
for (const [key, val] of Object.entries(mapDef.$props)) {
|
|
906
1223
|
if (isRef(val)) {
|
|
@@ -938,11 +1255,14 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
938
1255
|
|
|
939
1256
|
const attrsStr = parts.length > 0 ? `\n${indent} ${parts.join(`\n${indent} `)}` : "";
|
|
940
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();
|
|
941
1261
|
let inner = "";
|
|
942
1262
|
if (mapDef.textContent !== undefined) {
|
|
943
1263
|
inner = toLitTextContent(mapDef.textContent);
|
|
944
1264
|
} else if (mapDef.children) {
|
|
945
|
-
inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true)}\n${indent} `;
|
|
1265
|
+
inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true, mapScope)}\n${indent} `;
|
|
946
1266
|
}
|
|
947
1267
|
|
|
948
1268
|
const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
|
|
@@ -955,8 +1275,11 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
955
1275
|
// `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
|
|
956
1276
|
// The interpreter's child scope does. Emitted only when referenced, so output is otherwise
|
|
957
1277
|
// Unchanged.
|
|
958
|
-
const
|
|
959
|
-
|
|
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}; }`
|
|
960
1283
|
: `(item, index) => ${body}`;
|
|
961
1284
|
|
|
962
1285
|
return `${indent}\${${itemsExpr}.map(${callback})}`;
|
|
@@ -970,14 +1293,38 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
970
1293
|
*/
|
|
971
1294
|
function refToExpr(ref: string) {
|
|
972
1295
|
if (ref.startsWith("#/state/")) {
|
|
973
|
-
|
|
974
|
-
return `s.${path.replaceAll("/", ".")}`;
|
|
1296
|
+
return refAccessor("s", ref.slice("#/state/".length));
|
|
975
1297
|
}
|
|
976
1298
|
if (ref.startsWith("$map/")) {
|
|
977
|
-
|
|
978
|
-
|
|
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));
|
|
1308
|
+
}
|
|
1309
|
+
if (ref.startsWith("window#/")) {
|
|
1310
|
+
return refAccessor("window", ref.slice("window#/".length));
|
|
979
1311
|
}
|
|
980
|
-
|
|
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("/"));
|
|
981
1328
|
}
|
|
982
1329
|
|
|
983
1330
|
/**
|
|
@@ -986,7 +1333,7 @@ function refToExpr(ref: string) {
|
|
|
986
1333
|
*/
|
|
987
1334
|
function mapRefToExpr(ref: string) {
|
|
988
1335
|
if (ref.startsWith("$map/")) {
|
|
989
|
-
return ref.slice("$map/".length)
|
|
1336
|
+
return mapVarAccessor(ref.slice("$map/".length));
|
|
990
1337
|
}
|
|
991
1338
|
return refToExpr(ref);
|
|
992
1339
|
}
|