@jxsuite/compiler 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +167 -168
- package/dist/compiler.js +105 -107
- package/package.json +8 -7
- package/src/shared.ts +236 -8
- package/src/site/asset-mounts.ts +140 -0
- package/src/site/image-transform.ts +25 -7
- package/src/site/schema-command.ts +37 -77
- package/src/site/site-build.ts +96 -15
- package/src/site/validate-command.ts +133 -37
- package/src/targets/compile-client.ts +63 -67
- package/src/targets/compile-element.ts +246 -31
- package/src/targets/compile-static.ts +8 -2
|
@@ -11,12 +11,16 @@ import {
|
|
|
11
11
|
compileExpression,
|
|
12
12
|
compileStatements,
|
|
13
13
|
emitFormulaFn,
|
|
14
|
+
emitRequestFetch,
|
|
14
15
|
escapeHtml,
|
|
15
16
|
isMutating,
|
|
16
17
|
isSchemaOnly,
|
|
18
|
+
PREFORMATTED_TAGS,
|
|
19
|
+
srcImportBinding,
|
|
17
20
|
tagNameToClassName,
|
|
18
21
|
} from "../shared.ts";
|
|
19
22
|
import {
|
|
23
|
+
bodyReturnsValue,
|
|
20
24
|
hasStructuredBody,
|
|
21
25
|
isExpressionDef,
|
|
22
26
|
isFunctionDef,
|
|
@@ -34,6 +38,7 @@ import type {
|
|
|
34
38
|
JxFunctionDef,
|
|
35
39
|
JxMappedArray,
|
|
36
40
|
JxMutableNode,
|
|
41
|
+
JxPrototypeDef,
|
|
37
42
|
JxStyle,
|
|
38
43
|
} from "@jxsuite/schema/types";
|
|
39
44
|
|
|
@@ -248,6 +253,68 @@ function extractInitialValue(def: JxMutableNode) {
|
|
|
248
253
|
return JSON.stringify(def);
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
/** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
|
|
257
|
+
const LIFECYCLE_KEYS = new Set(["onMount", "onUnmount", "onAdopted"]);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Collect the state keys a document uses as a _callable_, at any depth: bound to an `on*` event,
|
|
261
|
+
* invoked by an `$expression` `call` node, or called as `state.key(…)` from a template string or
|
|
262
|
+
* another function's body.
|
|
263
|
+
*
|
|
264
|
+
* A bodyless `$src` Function carries no body to classify by, and unlike the interpreter — which
|
|
265
|
+
* introspects the imported function — a compiler only has the document to go on. Anything not used
|
|
266
|
+
* as a callable has its return value read reactively, and so becomes a computed (spec.md §5.3 4b).
|
|
267
|
+
* Defaulting the other way round would turn a called helper into a value and break its call site.
|
|
268
|
+
*
|
|
269
|
+
* @param {JxDocument} doc
|
|
270
|
+
* @returns {Set<string>}
|
|
271
|
+
*/
|
|
272
|
+
function collectCallableRefs(doc: JxDocument) {
|
|
273
|
+
const keys = new Set<string>();
|
|
274
|
+
const seen = new Set<object>();
|
|
275
|
+
|
|
276
|
+
const addStateRef = (ref: unknown) => {
|
|
277
|
+
if (typeof ref === "string" && ref.startsWith("#/state/")) {
|
|
278
|
+
keys.add(ref.slice("#/state/".length).split("/")[0] as string);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const visit = (node: unknown) => {
|
|
283
|
+
if (typeof node === "string") {
|
|
284
|
+
// `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
|
|
285
|
+
for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
286
|
+
keys.add(call[1] as string);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!node || typeof node !== "object" || seen.has(node)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
seen.add(node);
|
|
294
|
+
if (Array.isArray(node)) {
|
|
295
|
+
for (const item of node) {
|
|
296
|
+
visit(item);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const record = node as Record<string, unknown>;
|
|
301
|
+
// An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
|
|
302
|
+
if (record.operator === "call" && isRef(record.target)) {
|
|
303
|
+
addStateRef((record.target as { $ref: string }).$ref);
|
|
304
|
+
}
|
|
305
|
+
for (const [key, val] of Object.entries(record)) {
|
|
306
|
+
if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
|
|
307
|
+
addStateRef(val.$ref);
|
|
308
|
+
}
|
|
309
|
+
visit(val);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
visit(doc.children);
|
|
314
|
+
visit(doc.state);
|
|
315
|
+
return keys;
|
|
316
|
+
}
|
|
317
|
+
|
|
251
318
|
/**
|
|
252
319
|
* Generate a complete ES module string for a custom element.
|
|
253
320
|
*
|
|
@@ -282,7 +349,7 @@ export function emitElementModule(
|
|
|
282
349
|
if (!srcImportMap.has(srcPath)) {
|
|
283
350
|
srcImportMap.set(srcPath, []);
|
|
284
351
|
}
|
|
285
|
-
(srcImportMap.get(srcPath) as string[]).push(key);
|
|
352
|
+
(srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
|
|
286
353
|
}
|
|
287
354
|
}
|
|
288
355
|
for (const [srcPath, names] of srcImportMap) {
|
|
@@ -292,12 +359,24 @@ export function emitElementModule(
|
|
|
292
359
|
lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
|
|
293
360
|
}
|
|
294
361
|
|
|
362
|
+
// `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
|
|
363
|
+
// Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
|
|
364
|
+
// Documents without a Request produce exactly the same module as before.
|
|
365
|
+
const requestEntries = Object.entries(defs).filter(([, def]) => {
|
|
366
|
+
const d = def as JxMutableNode;
|
|
367
|
+
return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
|
|
368
|
+
});
|
|
369
|
+
|
|
295
370
|
lines.push(
|
|
296
|
-
`import { reactive, computed, effect } from '@vue/reactivity';`,
|
|
371
|
+
`import { reactive, computed, effect, stop } from '@vue/reactivity';`,
|
|
297
372
|
`import { render, html } from 'lit-html';`,
|
|
298
373
|
"",
|
|
299
374
|
`class ${className} extends HTMLElement {`,
|
|
300
|
-
|
|
375
|
+
// One registry for every effect the element creates — render, dynamic host styles, Request
|
|
376
|
+
// Auto-fetches. Calling an @vue/reactivity runner re-runs it, so teardown has to be `stop()`,
|
|
377
|
+
// And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
|
|
378
|
+
// Held a handle to.
|
|
379
|
+
" #effects = [];",
|
|
301
380
|
"",
|
|
302
381
|
// Constructor: build reactive state
|
|
303
382
|
" constructor() {",
|
|
@@ -309,6 +388,7 @@ export function emitElementModule(
|
|
|
309
388
|
const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
|
|
310
389
|
|
|
311
390
|
const formulaEntries: [string, JxExpressionDef][] = [];
|
|
391
|
+
const callableRefs = collectCallableRefs(doc);
|
|
312
392
|
for (const [key, def] of Object.entries(defs)) {
|
|
313
393
|
const d = def as JxMutableNode;
|
|
314
394
|
if (isExpressionDef(d)) {
|
|
@@ -321,7 +401,16 @@ export function emitElementModule(
|
|
|
321
401
|
computedEntries.push([key, d]);
|
|
322
402
|
}
|
|
323
403
|
} else if (isFunctionDef(d)) {
|
|
324
|
-
if (typeof d.body
|
|
404
|
+
if (d.$src && typeof d.body !== "string") {
|
|
405
|
+
// Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
|
|
406
|
+
// `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
|
|
407
|
+
// Spec-conformant external Function takes.
|
|
408
|
+
if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
|
|
409
|
+
functionEntries.push([key, d]);
|
|
410
|
+
} else {
|
|
411
|
+
computedEntries.push([key, d]);
|
|
412
|
+
}
|
|
413
|
+
} else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
|
|
325
414
|
computedEntries.push([key, d]);
|
|
326
415
|
} else {
|
|
327
416
|
functionEntries.push([key, d]);
|
|
@@ -364,15 +453,35 @@ export function emitElementModule(
|
|
|
364
453
|
} else {
|
|
365
454
|
const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
|
|
366
455
|
const paramList = args.join(", ");
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
456
|
+
const body = typeof def.body === "string" ? def.body : "";
|
|
457
|
+
// Every call site invokes a state function as `(state, event)` — the template's
|
|
458
|
+
// `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
|
|
459
|
+
// Up positionally, so they are emitted directly. Any other declaration — including the
|
|
460
|
+
// `"parameters": ["event"]` form that examples/components/{todo-app,fetch-demo}.json use — is
|
|
461
|
+
// Mapped onto the arguments by name, the way the client target does it
|
|
462
|
+
// (compile-client.ts), with `state` bound as the outer parameter so a body referencing bare
|
|
463
|
+
// `state` resolves regardless of the declared arity.
|
|
464
|
+
if (args[0] === "state") {
|
|
465
|
+
if (def.$src) {
|
|
466
|
+
// $src function — wrap imported function so it receives state
|
|
467
|
+
lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
|
|
468
|
+
} else {
|
|
469
|
+
lines.push(` this.state.${key} = (${paramList}) => {`, ` ${body}`, " };");
|
|
470
|
+
}
|
|
370
471
|
} else {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
`
|
|
374
|
-
|
|
375
|
-
|
|
472
|
+
const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
|
|
473
|
+
if (def.$src) {
|
|
474
|
+
lines.push(` this.state.${key} = (state, e) => ${key}(${callArgs});`);
|
|
475
|
+
} else {
|
|
476
|
+
lines.push(
|
|
477
|
+
` this.state.${key} = (state, e) => {`,
|
|
478
|
+
` const _fn = (${paramList}) => {`,
|
|
479
|
+
` ${body}`,
|
|
480
|
+
" };",
|
|
481
|
+
` return _fn(${callArgs});`,
|
|
482
|
+
" };",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
376
485
|
}
|
|
377
486
|
}
|
|
378
487
|
}
|
|
@@ -441,6 +550,17 @@ export function emitElementModule(
|
|
|
441
550
|
" } catch {}",
|
|
442
551
|
" this.removeAttribute('data-jx-props');",
|
|
443
552
|
" }",
|
|
553
|
+
// Literal `props.*` attributes — the live-render mirror of the build-time lift in site-build.
|
|
554
|
+
// String values only: HTML lowercases attribute names, so a state key must be lowercase to match.
|
|
555
|
+
// Names are collected first because removing while iterating the live NamedNodeMap skips entries.
|
|
556
|
+
" const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
|
|
557
|
+
" for (const _n of _pn) {",
|
|
558
|
+
" const _k = _n.slice(6);",
|
|
559
|
+
" if (_k in this.state) {",
|
|
560
|
+
" this.state[_k] = this.getAttribute(_n);",
|
|
561
|
+
" this.removeAttribute(_n);",
|
|
562
|
+
" }",
|
|
563
|
+
" }",
|
|
444
564
|
// Merge JS properties set before connection (by parent runtime).
|
|
445
565
|
// Only check own properties to avoid inherited DOM properties like `title`.
|
|
446
566
|
" for (const key of Object.keys(this.state)) {",
|
|
@@ -449,6 +569,20 @@ export function emitElementModule(
|
|
|
449
569
|
" }",
|
|
450
570
|
" }",
|
|
451
571
|
);
|
|
572
|
+
|
|
573
|
+
// `$prototype: "Request"` entries fetch on connect — after the `$props`/property merge above, so
|
|
574
|
+
// A template `url` interpolates the values the parent passed in rather than the initial ones.
|
|
575
|
+
// Runners are collected so disconnectedCallback can stop them: a reactive URL would otherwise
|
|
576
|
+
// Keep fetching after removal, and every re-insertion would stack another effect on top.
|
|
577
|
+
for (const [key, def] of requestEntries) {
|
|
578
|
+
lines.push(
|
|
579
|
+
emitRequestFetch(key, def as unknown as JxPrototypeDef, {
|
|
580
|
+
collect: "this.#effects",
|
|
581
|
+
indent: " ",
|
|
582
|
+
statePrefix: "this.state",
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
452
586
|
if (doc.style && typeof doc.style === "object") {
|
|
453
587
|
const dynamicStyles: [string, string][] = [];
|
|
454
588
|
for (const [prop, value] of Object.entries(doc.style)) {
|
|
@@ -470,7 +604,7 @@ export function emitElementModule(
|
|
|
470
604
|
}
|
|
471
605
|
}
|
|
472
606
|
if (dynamicStyles.length > 0) {
|
|
473
|
-
lines.push(" effect(() => {");
|
|
607
|
+
lines.push(" this.#effects.push(effect(() => {");
|
|
474
608
|
for (const [cssProp, value] of dynamicStyles) {
|
|
475
609
|
const expr = value.replaceAll(
|
|
476
610
|
/\$\{([^}]+)\}/g,
|
|
@@ -478,7 +612,7 @@ export function emitElementModule(
|
|
|
478
612
|
);
|
|
479
613
|
lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
|
|
480
614
|
}
|
|
481
|
-
lines.push(" });");
|
|
615
|
+
lines.push(" }));");
|
|
482
616
|
}
|
|
483
617
|
}
|
|
484
618
|
const hasSlot = treeHasSlot(doc.children);
|
|
@@ -493,7 +627,7 @@ export function emitElementModule(
|
|
|
493
627
|
" this.removeAttribute('data-jx-prerendered');",
|
|
494
628
|
" }",
|
|
495
629
|
" this.innerHTML = '';",
|
|
496
|
-
" this.#
|
|
630
|
+
" this.#effects.push(effect(() => render(this.template(), this)));",
|
|
497
631
|
);
|
|
498
632
|
if (hasSlot) {
|
|
499
633
|
// Replace <slot> placeholder with saved slotted content
|
|
@@ -514,7 +648,8 @@ export function emitElementModule(
|
|
|
514
648
|
"",
|
|
515
649
|
// DisconnectedCallback
|
|
516
650
|
" disconnectedCallback() {",
|
|
517
|
-
"
|
|
651
|
+
" for (const _e of this.#effects) { stop(_e); }",
|
|
652
|
+
" this.#effects.length = 0;",
|
|
518
653
|
" if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
|
|
519
654
|
" }",
|
|
520
655
|
"}",
|
|
@@ -532,12 +667,15 @@ export function emitElementModule(
|
|
|
532
667
|
* @param {JxMutableNode["children"]} children
|
|
533
668
|
* @param {JxStyle | null | undefined} parentStyle
|
|
534
669
|
* @param {string} indent
|
|
670
|
+
* @param {boolean} [preformatted]
|
|
535
671
|
* @returns {string}
|
|
536
672
|
*/
|
|
537
673
|
function emitLitChildren(
|
|
538
674
|
children: JxMutableNode["children"],
|
|
539
675
|
_parentStyle: JxStyle | null | undefined,
|
|
540
676
|
indent: string,
|
|
677
|
+
preformatted = false,
|
|
678
|
+
inMap = false,
|
|
541
679
|
) {
|
|
542
680
|
if (!children) {
|
|
543
681
|
return "";
|
|
@@ -555,17 +693,25 @@ function emitLitChildren(
|
|
|
555
693
|
// Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
|
|
556
694
|
return children
|
|
557
695
|
.map((child: JxMutableNode | string) =>
|
|
558
|
-
isMappedArray(child)
|
|
696
|
+
isMappedArray(child)
|
|
697
|
+
? emitMappedArray(child, indent)
|
|
698
|
+
: emitLitNode(child, preformatted ? "" : indent, preformatted, inMap),
|
|
559
699
|
)
|
|
560
|
-
.join("\n");
|
|
700
|
+
.join(preformatted ? "" : "\n");
|
|
561
701
|
}
|
|
562
702
|
|
|
563
703
|
/**
|
|
564
704
|
* @param {JxMutableNode | string} def
|
|
565
705
|
* @param {string} indent
|
|
706
|
+
* @param {boolean} [preformatted]
|
|
566
707
|
* @returns {string}
|
|
567
708
|
*/
|
|
568
|
-
function emitLitNode(
|
|
709
|
+
function emitLitNode(
|
|
710
|
+
def: JxMutableNode | string,
|
|
711
|
+
indent: string,
|
|
712
|
+
preformatted = false,
|
|
713
|
+
inMap = false,
|
|
714
|
+
) {
|
|
569
715
|
// String children are text nodes
|
|
570
716
|
if (typeof def === "string") {
|
|
571
717
|
if (def.includes("${")) {
|
|
@@ -581,6 +727,8 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
581
727
|
}
|
|
582
728
|
|
|
583
729
|
const tag = def.tagName ?? "div";
|
|
730
|
+
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
731
|
+
const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
|
|
584
732
|
|
|
585
733
|
const parts: string[] = [];
|
|
586
734
|
|
|
@@ -596,11 +744,14 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
596
744
|
}
|
|
597
745
|
}
|
|
598
746
|
|
|
747
|
+
// `id`/`className` take the same template rewriting as every other attribute — inside a map
|
|
748
|
+
// These routinely carry `${$map.item…}`/`${$map.index}`, which emitMappedArray already rewrites
|
|
749
|
+
// On the map root but nothing rewrote on its descendants.
|
|
599
750
|
if (def.id) {
|
|
600
|
-
parts.push(`id="${def.id}"`);
|
|
751
|
+
parts.push(`id="${toLitExpr(String(def.id))}"`);
|
|
601
752
|
}
|
|
602
753
|
if (def.className) {
|
|
603
|
-
parts.push(`class="${def.className}"`);
|
|
754
|
+
parts.push(`class="${toLitExpr(String(def.className))}"`);
|
|
604
755
|
}
|
|
605
756
|
|
|
606
757
|
for (const [key, val] of Object.entries(def)) {
|
|
@@ -631,12 +782,21 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
631
782
|
for (const [key, val] of Object.entries(def.$props)) {
|
|
632
783
|
if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
|
|
633
784
|
parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
|
|
785
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
786
|
+
// A template value is a binding, not text. JSON-quoting it emitted the template's own source
|
|
787
|
+
// As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
|
|
788
|
+
// String `${state.x}` forever.
|
|
789
|
+
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
634
790
|
} else {
|
|
635
791
|
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
636
792
|
}
|
|
637
793
|
}
|
|
638
794
|
}
|
|
639
795
|
|
|
796
|
+
// A handler bound inside a map publishes its iteration to state before running, matching the
|
|
797
|
+
// Interpreter's child scope and the client target's handler wrapper. Bodies read it as
|
|
798
|
+
// `state.$map.index` / `state.$map.item`.
|
|
799
|
+
const mapCtx = inMap ? "s.$map = $map; " : "";
|
|
640
800
|
for (const [key, val] of Object.entries(def)) {
|
|
641
801
|
if (!key.startsWith("on") || key === "observedAttributes") {
|
|
642
802
|
continue;
|
|
@@ -644,7 +804,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
644
804
|
const eventName = key.slice(2).toLowerCase();
|
|
645
805
|
if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
|
|
646
806
|
parts.push(
|
|
647
|
-
|
|
807
|
+
mapCtx
|
|
808
|
+
? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
|
|
809
|
+
: `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
|
|
648
810
|
);
|
|
649
811
|
} else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
|
|
650
812
|
const compiled = compileExpression(
|
|
@@ -654,9 +816,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
654
816
|
statePrefix: "s",
|
|
655
817
|
},
|
|
656
818
|
);
|
|
657
|
-
parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
|
|
819
|
+
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
|
|
658
820
|
} else if (isFunctionDef(val)) {
|
|
659
|
-
parts.push(`@${eventName}="\${(e) => { ${inlineHandlerBody(val)} }}"`);
|
|
821
|
+
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
|
|
660
822
|
}
|
|
661
823
|
}
|
|
662
824
|
|
|
@@ -682,7 +844,7 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
682
844
|
if (!caseDef || isRef(caseDef)) {
|
|
683
845
|
continue;
|
|
684
846
|
}
|
|
685
|
-
const renderedCase = emitLitNode(caseDef, `${indent}
|
|
847
|
+
const renderedCase = emitLitNode(caseDef, `${indent} `, false, inMap);
|
|
686
848
|
caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
|
|
687
849
|
}
|
|
688
850
|
inner = `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
|
|
@@ -691,10 +853,15 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
|
|
|
691
853
|
} else if (def.innerHTML !== undefined) {
|
|
692
854
|
inner = def.innerHTML;
|
|
693
855
|
} else if (def.children) {
|
|
694
|
-
inner =
|
|
856
|
+
inner = inPre
|
|
857
|
+
? emitLitChildren(def.children, def.style, "", true, inMap)
|
|
858
|
+
: `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap)}\n${indent}`;
|
|
695
859
|
}
|
|
696
860
|
|
|
697
|
-
|
|
861
|
+
// Inside a <pre>, the newline before `>` would land in the element's own text.
|
|
862
|
+
return inPre
|
|
863
|
+
? `<${tag}${attrsStr}>${inner}</${tag}>`
|
|
864
|
+
: `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
|
|
698
865
|
}
|
|
699
866
|
|
|
700
867
|
/**
|
|
@@ -713,10 +880,35 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
713
880
|
const tag = mapDef.tagName ?? "div";
|
|
714
881
|
const parts: string[] = [];
|
|
715
882
|
|
|
883
|
+
// The map root gets the same attribute treatment as any other element (emitLitNode above);
|
|
884
|
+
// `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
|
|
885
|
+
if (mapDef.attributes) {
|
|
886
|
+
for (const [key, val] of Object.entries(mapDef.attributes)) {
|
|
887
|
+
if (val && typeof val === "object" && isRef(val)) {
|
|
888
|
+
parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
889
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
890
|
+
parts.push(`${key}="${toLitExpr(val)}"`);
|
|
891
|
+
} else {
|
|
892
|
+
parts.push(`${key}="${val}"`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (mapDef.id) {
|
|
898
|
+
parts.push(`id="${toLitExpr(String(mapDef.id))}"`);
|
|
899
|
+
}
|
|
900
|
+
if (mapDef.className) {
|
|
901
|
+
parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
|
|
902
|
+
}
|
|
903
|
+
|
|
716
904
|
if (mapDef.$props) {
|
|
717
905
|
for (const [key, val] of Object.entries(mapDef.$props)) {
|
|
718
906
|
if (isRef(val)) {
|
|
719
907
|
parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
908
|
+
} else if (typeof val === "string" && val.includes("${")) {
|
|
909
|
+
// `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
|
|
910
|
+
// Component in a list; JSON-quoting it handed over the template source instead.
|
|
911
|
+
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
720
912
|
} else {
|
|
721
913
|
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
722
914
|
}
|
|
@@ -734,13 +926,13 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
734
926
|
}
|
|
735
927
|
const eventName = key.slice(2).toLowerCase();
|
|
736
928
|
if (isRef(val)) {
|
|
737
|
-
parts.push(`@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`);
|
|
929
|
+
parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
|
|
738
930
|
} else if (isExpressionDef(val)) {
|
|
739
931
|
const compiled = compileExpression(val.$expression as ExpressionNode, {
|
|
740
932
|
eventParam: "e",
|
|
741
933
|
statePrefix: "s",
|
|
742
934
|
});
|
|
743
|
-
parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
|
|
935
|
+
parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${compiled}; }}"`);
|
|
744
936
|
}
|
|
745
937
|
}
|
|
746
938
|
|
|
@@ -750,10 +942,24 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
750
942
|
if (mapDef.textContent !== undefined) {
|
|
751
943
|
inner = toLitTextContent(mapDef.textContent);
|
|
752
944
|
} else if (mapDef.children) {
|
|
753
|
-
inner = `\n${emitLitChildren(mapDef.children, null, `${indent}
|
|
945
|
+
inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true)}\n${indent} `;
|
|
754
946
|
}
|
|
755
947
|
|
|
756
|
-
|
|
948
|
+
const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
|
|
949
|
+
|
|
950
|
+
// `${$map.item…}` and `${$map.index}` are spec-sanctioned template forms: spec.md §6.6 names
|
|
951
|
+
// `$map` alongside `item`/`index` as an iteration binding, and the interpreter passes it into the
|
|
952
|
+
// Template evaluator as a real parameter. The compiled callback bound only `item`/`index`, so
|
|
953
|
+
// Every such template survived into the module as a dead `$map` reference and threw at render.
|
|
954
|
+
// Binding the same object here resolves every access form — `$map.item`, `$map?.item`,
|
|
955
|
+
// `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
|
|
956
|
+
// The interpreter's child scope does. Emitted only when referenced, so output is otherwise
|
|
957
|
+
// Unchanged.
|
|
958
|
+
const callback = body.includes("$map")
|
|
959
|
+
? `(item, index) => { const $map = { item, index }; return ${body}; }`
|
|
960
|
+
: `(item, index) => ${body}`;
|
|
961
|
+
|
|
962
|
+
return `${indent}\${${itemsExpr}.map(${callback})}`;
|
|
757
963
|
}
|
|
758
964
|
|
|
759
965
|
/**
|
|
@@ -828,6 +1034,15 @@ function inlineHandlerBody(def: JxFunctionDef) {
|
|
|
828
1034
|
});
|
|
829
1035
|
}
|
|
830
1036
|
const body = typeof def.body === "string" ? def.body : "";
|
|
1037
|
+
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
1038
|
+
if (declared.length > 0) {
|
|
1039
|
+
// The call site supplies only `(e)`, so map the declared names onto the arguments the handler
|
|
1040
|
+
// Actually has — `state` to the `s` alias, anything else to the event. Binding by name means the
|
|
1041
|
+
// Body needs no textual rewriting, and a declared `event` stops silently resolving to the
|
|
1042
|
+
// Deprecated `window.event` global (any other declared name threw ReferenceError outright).
|
|
1043
|
+
const args = declared.map((name: string) => (name === "state" ? "s" : "e")).join(", ");
|
|
1044
|
+
return `((${declared.join(", ")}) => { ${body} })(${args});`;
|
|
1045
|
+
}
|
|
831
1046
|
return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
|
|
832
1047
|
}
|
|
833
1048
|
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
buildAttrs,
|
|
10
|
+
childSeparator,
|
|
10
11
|
colorSchemePrePaintScript,
|
|
11
12
|
compileStyles,
|
|
12
13
|
createCompileContext,
|
|
13
14
|
escapeHtml,
|
|
14
15
|
isNodeDynamic,
|
|
16
|
+
PREFORMATTED_TAGS,
|
|
15
17
|
pureSchemeOf,
|
|
16
18
|
resolveStaticValue,
|
|
17
19
|
SELF_CLOSING,
|
|
@@ -127,6 +129,7 @@ function compileNode(
|
|
|
127
129
|
scope: Record<string, unknown> | null;
|
|
128
130
|
scopeDefs: Record<string, unknown>;
|
|
129
131
|
media: Record<string, string>;
|
|
132
|
+
preformatted?: boolean;
|
|
130
133
|
},
|
|
131
134
|
islands: { def: JxMutableNode; tagName: string; className: string }[],
|
|
132
135
|
): string {
|
|
@@ -166,7 +169,9 @@ function compileNode(
|
|
|
166
169
|
return `<${tag}${attrs}>`;
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
|
|
172
|
+
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
173
|
+
const preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
|
|
174
|
+
const inner = buildInnerWithIslands(def, raw, { ...nextContext, preformatted }, islands);
|
|
170
175
|
|
|
171
176
|
return `<${tag}${attrs}>${inner}</${tag}>`;
|
|
172
177
|
}
|
|
@@ -188,6 +193,7 @@ function buildInnerWithIslands(
|
|
|
188
193
|
scope: Record<string, unknown> | null;
|
|
189
194
|
scopeDefs: Record<string, unknown>;
|
|
190
195
|
media: Record<string, string>;
|
|
196
|
+
preformatted?: boolean;
|
|
191
197
|
},
|
|
192
198
|
islands: { def: JxMutableNode; tagName: string; className: string }[],
|
|
193
199
|
): string {
|
|
@@ -209,7 +215,7 @@ function buildInnerWithIslands(
|
|
|
209
215
|
const childRaw = (rawChildren?.[i] ?? c) as JxMutableNode;
|
|
210
216
|
return compileNode(child, childDynamic, childRaw, context, islands);
|
|
211
217
|
})
|
|
212
|
-
.join(
|
|
218
|
+
.join(childSeparator(context.preformatted));
|
|
213
219
|
}
|
|
214
220
|
return "";
|
|
215
221
|
}
|