@jxsuite/compiler 1.5.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 +563 -228
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,17 +45,19 @@
|
|
|
45
45
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@apidevtools/json-schema-ref-parser": "^
|
|
49
|
-
"@jxsuite/create": "^1.
|
|
50
|
-
"@jxsuite/runtime": "^
|
|
51
|
-
"@jxsuite/schema": "^1.
|
|
52
|
-
"
|
|
48
|
+
"@apidevtools/json-schema-ref-parser": "^16.0.0",
|
|
49
|
+
"@jxsuite/create": "^1.3.0",
|
|
50
|
+
"@jxsuite/runtime": "^2.0.0",
|
|
51
|
+
"@jxsuite/schema": "^1.6.0",
|
|
52
|
+
"@vue/reactivity": "3.5.41",
|
|
53
|
+
"esbuild": "^0.28.2",
|
|
54
|
+
"lit-html": "^3.3.3",
|
|
53
55
|
"sharp": "^0.35.3"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
57
|
-
"@jxsuite/connector": "^0.
|
|
58
|
-
"@jxsuite/parser": "^1.
|
|
58
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
59
|
+
"@jxsuite/connector": "^0.5.0",
|
|
60
|
+
"@jxsuite/parser": "^1.5.0",
|
|
59
61
|
"@types/mdast": "^4.0.4"
|
|
60
62
|
}
|
|
61
63
|
}
|
package/src/compiler.ts
CHANGED
|
@@ -130,7 +130,7 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
|
|
|
130
130
|
path: `${tagName}.js`,
|
|
131
131
|
tagName,
|
|
132
132
|
};
|
|
133
|
-
const styleBlock = compileStyles(raw, raw.$media ?? {});
|
|
133
|
+
const styleBlock = compileStyles(raw, raw.$media ?? {}, projectStyle);
|
|
134
134
|
|
|
135
135
|
const html = `<!DOCTYPE html>
|
|
136
136
|
<html lang="en">
|
package/src/shadow.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow DOM, opt-in.
|
|
3
|
+
*
|
|
4
|
+
* Light DOM is the default and stays the default. It is not a placeholder for shadow DOM: a Jx
|
|
5
|
+
* component's styles are reachable from the page, which is how a project restyles a component it
|
|
6
|
+
* did not write, and the `<slot>` emulation that makes light-DOM composition work has different
|
|
7
|
+
* distribution semantics from real slotting. Switching a component to a shadow root changes both,
|
|
8
|
+
* so it is a per-component decision an author makes deliberately.
|
|
9
|
+
*
|
|
10
|
+
* `$shadow` on a component document, `defaults.shadow` on the project, `false` if neither says
|
|
11
|
+
* otherwise. `"open"` and `"closed"` are the standard's two modes; the difference matters more here
|
|
12
|
+
* than usual, because a **closed** declarative shadow root is not reachable through
|
|
13
|
+
* `element.shadowRoot` and hydration has to find it another way (§16.6).
|
|
14
|
+
*
|
|
15
|
+
* @docs framework/concepts/components
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { JxDocument, JxElement, ProjectConfig } from "@jxsuite/schema/types";
|
|
19
|
+
|
|
20
|
+
/** The two modes the standard defines, or null for light DOM. */
|
|
21
|
+
export type ShadowMode = "open" | "closed";
|
|
22
|
+
|
|
23
|
+
/** A value `$shadow` or `defaults.shadow` may carry. */
|
|
24
|
+
export type ShadowSetting = ShadowMode | false;
|
|
25
|
+
|
|
26
|
+
function asSetting(value: unknown): ShadowSetting | undefined {
|
|
27
|
+
if (value === false || value === "open" || value === "closed") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The shadow mode a component renders in, or null for light DOM.
|
|
35
|
+
*
|
|
36
|
+
* A component's own `$shadow` wins over the project default in both directions — including
|
|
37
|
+
* `$shadow: false`, which is how one component opts _out_ of a project that opted in.
|
|
38
|
+
*
|
|
39
|
+
* @param {JxDocument | JxElement | null | undefined} doc - The component definition
|
|
40
|
+
* @param {ProjectConfig["defaults"] | undefined} defaults
|
|
41
|
+
* @returns {ShadowMode | null}
|
|
42
|
+
*/
|
|
43
|
+
export function resolveShadowMode(
|
|
44
|
+
doc: JxDocument | JxElement | null | undefined,
|
|
45
|
+
defaults?: ProjectConfig["defaults"],
|
|
46
|
+
): ShadowMode | null {
|
|
47
|
+
const own = asSetting((doc as { $shadow?: unknown } | null | undefined)?.$shadow);
|
|
48
|
+
const setting = own ?? asSetting(defaults?.shadow) ?? false;
|
|
49
|
+
return setting === false ? null : setting;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The CSS scope prefix for a component's own rules.
|
|
54
|
+
*
|
|
55
|
+
* In light DOM the prefix is the tag name, which is what makes `sty-card .inner` reach only that
|
|
56
|
+
* component's descendants. Inside a shadow root the selector cannot see the host's tag name, and
|
|
57
|
+
* `:host` is the standard's way to address it.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} tagName
|
|
60
|
+
* @param {ShadowMode | null} shadow
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function styleScopePrefix(tagName: string, shadow: ShadowMode | null): string {
|
|
64
|
+
return shadow === null ? tagName : ":host";
|
|
65
|
+
}
|
package/src/shared.ts
CHANGED
|
@@ -18,21 +18,26 @@ import {
|
|
|
18
18
|
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
19
19
|
import { runStatements } from "@jxsuite/runtime/statements";
|
|
20
20
|
import {
|
|
21
|
+
bodyReturnsValue,
|
|
21
22
|
childrenContainArray,
|
|
22
23
|
hasStructuredBody,
|
|
23
24
|
isExpressionDef,
|
|
24
25
|
isFunctionDef,
|
|
25
26
|
isMappedArray,
|
|
26
|
-
bodyReturnsValue,
|
|
27
27
|
isNamedFormulaDef,
|
|
28
28
|
isPrototypeDef,
|
|
29
29
|
isRef,
|
|
30
30
|
isSchemaOnlyDef as isSchemaOnly,
|
|
31
31
|
isServerFnDef,
|
|
32
|
+
isTagExpression,
|
|
32
33
|
isTemplateString,
|
|
33
34
|
paramNames,
|
|
35
|
+
tagNameCandidates,
|
|
34
36
|
} from "@jxsuite/schema/guards";
|
|
37
|
+
import { styleScopePrefix } from "./shadow.ts";
|
|
38
|
+
import type { ShadowMode } from "./shadow.ts";
|
|
35
39
|
import type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
40
|
+
import { readPath } from "@jxsuite/runtime/pointer";
|
|
36
41
|
import type {
|
|
37
42
|
JsonValue,
|
|
38
43
|
JxElement,
|
|
@@ -54,7 +59,13 @@ export {
|
|
|
54
59
|
schemeSelectors,
|
|
55
60
|
toCSSText,
|
|
56
61
|
} from "@jxsuite/runtime";
|
|
57
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
compileExpression,
|
|
64
|
+
compileOperandSource,
|
|
65
|
+
evaluateExpression,
|
|
66
|
+
evaluateOperand,
|
|
67
|
+
isMutating,
|
|
68
|
+
} from "@jxsuite/runtime/expression";
|
|
58
69
|
export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
|
|
59
70
|
|
|
60
71
|
/**
|
|
@@ -416,6 +427,62 @@ export function createCompileContext(
|
|
|
416
427
|
return { media, scope, scopeDefs };
|
|
417
428
|
}
|
|
418
429
|
|
|
430
|
+
/**
|
|
431
|
+
* `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
|
|
432
|
+
* state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
|
|
433
|
+
*/
|
|
434
|
+
const STATE_ASSIGNMENT_RE =
|
|
435
|
+
/\bstate\.([A-Za-z_$][\w$]*)\s*(?:\+\+|--|(?:\*\*|\|\||&&|\?\?|<<|>>>|>>|[-+*/%&|^])?=(?!=))/g;
|
|
436
|
+
|
|
437
|
+
/** `state.list.push(…)` and friends mutate in place without ever assigning to the entry. */
|
|
438
|
+
const STATE_MUTATING_METHOD_RE =
|
|
439
|
+
/\bstate\.([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g;
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Collect every state key a handler writes to, across all the forms a body can take: a string body,
|
|
443
|
+
* a structured body (spec §20), and an `$expression` node with a mutating operator.
|
|
444
|
+
*
|
|
445
|
+
* Used to keep prerender from baking a binding over an entry that changes after hydration. A plain
|
|
446
|
+
* `{ "type": "string" }` entry holds a perfectly ordinary build-time value, so nothing else
|
|
447
|
+
* distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
|
|
448
|
+
* replaces the template in the emitted output and the element is dead for the life of the page.
|
|
449
|
+
*
|
|
450
|
+
* @param {unknown} node
|
|
451
|
+
* @param {Set<string>} into
|
|
452
|
+
*/
|
|
453
|
+
function collectAssignedStateKeys(node: unknown, into: Set<string>) {
|
|
454
|
+
if (typeof node === "string") {
|
|
455
|
+
for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
|
|
456
|
+
into.add(m[1] as string);
|
|
457
|
+
}
|
|
458
|
+
for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
|
|
459
|
+
into.add(m[1] as string);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!node || typeof node !== "object") {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(node)) {
|
|
467
|
+
for (const entry of node) {
|
|
468
|
+
collectAssignedStateKeys(entry, into);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const rec = node as Record<string, unknown>;
|
|
473
|
+
// A mutating expression names its destination as `target: { $ref: "#/state/x" }`.
|
|
474
|
+
const op = rec.operator;
|
|
475
|
+
if (typeof op === "string" && isMutating(op)) {
|
|
476
|
+
const target = rec.target as { $ref?: string } | undefined;
|
|
477
|
+
if (target && typeof target.$ref === "string" && target.$ref.startsWith("#/state/")) {
|
|
478
|
+
into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const value of Object.values(rec)) {
|
|
482
|
+
collectAssignedStateKeys(value, into);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
419
486
|
/**
|
|
420
487
|
* @param {Record<string, JxStateDefinition>} [defs]
|
|
421
488
|
* @param {Record<string, unknown> | null} [parentScope]
|
|
@@ -439,6 +506,26 @@ export function buildInitialScope(
|
|
|
439
506
|
writable: true,
|
|
440
507
|
});
|
|
441
508
|
|
|
509
|
+
// Pass 0: every entry some handler in this document writes to is mutable, so prerender must not
|
|
510
|
+
// Bake a binding over it. Narrow on purpose — an entry nothing ever writes stays bakeable, so
|
|
511
|
+
// Prerendered content is preserved for SEO. A `$src` handler's assignments live in a JS file this
|
|
512
|
+
// Builder cannot open, so a write that only happens there is still missed (issue #125).
|
|
513
|
+
const assigned = new Set<string>();
|
|
514
|
+
for (const def of Object.values(defs)) {
|
|
515
|
+
if (def && typeof def === "object" && (isFunctionDef(def) || isExpressionDef(def))) {
|
|
516
|
+
collectAssignedStateKeys(
|
|
517
|
+
(def as { body?: unknown; $expression?: unknown }).body ??
|
|
518
|
+
(def as { $expression?: unknown }).$expression,
|
|
519
|
+
assigned,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const key of assigned) {
|
|
524
|
+
if (key in defs) {
|
|
525
|
+
runtimeOnly.add(key);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
442
529
|
for (const [key, def] of Object.entries(defs)) {
|
|
443
530
|
if (typeof def !== "object" || def === null || Array.isArray(def)) {
|
|
444
531
|
setOwnScopeValue(scope, key, cloneValue(def));
|
|
@@ -457,6 +544,10 @@ export function buildInitialScope(
|
|
|
457
544
|
// Template-string entries are deferred to a third pass: whether one is resolvable depends on the
|
|
458
545
|
// Runtime-only marks the loop below adds, and key order must not decide the answer.
|
|
459
546
|
const templateDefs: [string, string][] = [];
|
|
547
|
+
// Computed (`bodyReturnsValue`) entries are deferred for the same reason. A computed is stored in
|
|
548
|
+
// The scope as its already-evaluated *result*, so a template reading it sees an ordinary string
|
|
549
|
+
// And prerender bakes it — destroying the binding rather than merely staling it (issue #124).
|
|
550
|
+
const computedBodies: [string, string][] = [];
|
|
460
551
|
|
|
461
552
|
for (const [key, def] of Object.entries(defs)) {
|
|
462
553
|
if (typeof def === "string" && isTemplateString(def)) {
|
|
@@ -515,6 +606,7 @@ export function buildInitialScope(
|
|
|
515
606
|
fn(...params.map((name) => (name === "state" ? state : event)));
|
|
516
607
|
if (bodyReturnsValue(body)) {
|
|
517
608
|
defineLazyScopeValue(scope, key, () => invoke(scope));
|
|
609
|
+
computedBodies.push([key, body]);
|
|
518
610
|
} else {
|
|
519
611
|
setOwnScopeValue(scope, key, invoke);
|
|
520
612
|
}
|
|
@@ -539,13 +631,23 @@ export function buildInitialScope(
|
|
|
539
631
|
}
|
|
540
632
|
}
|
|
541
633
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
634
|
+
// A state entry that reads a runtime-only key cannot resolve until hydration either, so it is
|
|
635
|
+
// Runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`) into
|
|
636
|
+
// Every template that reads *this* entry, one step removed from the original. Iterated to a
|
|
637
|
+
// Fixpoint because marking one entry can qualify another, in either direction, and declaration
|
|
638
|
+
// Order must not decide the answer.
|
|
639
|
+
let marked = true;
|
|
640
|
+
while (marked) {
|
|
641
|
+
marked = false;
|
|
642
|
+
for (const [key, source] of [...computedBodies, ...templateDefs]) {
|
|
643
|
+
if (!runtimeOnly.has(key) && readsRuntimeOnlyState(source, scope)) {
|
|
644
|
+
runtimeOnly.add(key);
|
|
645
|
+
marked = true;
|
|
646
|
+
}
|
|
548
647
|
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const [key, template] of templateDefs) {
|
|
549
651
|
defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
|
|
550
652
|
}
|
|
551
653
|
|
|
@@ -622,14 +724,15 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
|
|
|
622
724
|
return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
|
|
623
725
|
}
|
|
624
726
|
if (refValue.startsWith("#/state/")) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
727
|
+
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
728
|
+
// `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
|
|
729
|
+
return getPathValue(scope, refValue.slice("#/state/".length));
|
|
730
|
+
}
|
|
731
|
+
/*
|
|
732
|
+
* An unrecognized scheme is still a path, matching the runtime resolvers and the lowerer.
|
|
733
|
+
* Reading it as one key returned null for `a/b` while the emitted client module walked it.
|
|
734
|
+
*/
|
|
735
|
+
return getPathValue(scope, refValue) ?? null;
|
|
633
736
|
}
|
|
634
737
|
|
|
635
738
|
/**
|
|
@@ -661,7 +764,15 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
|
|
|
661
764
|
if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
|
|
662
765
|
return true;
|
|
663
766
|
}
|
|
664
|
-
|
|
767
|
+
// Reading the key can run a computed's getter, which is free to throw on partial build-time
|
|
768
|
+
// Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
|
|
769
|
+
let value: unknown;
|
|
770
|
+
try {
|
|
771
|
+
value = scope[key];
|
|
772
|
+
} catch {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (typeof value === "function") {
|
|
665
776
|
return true;
|
|
666
777
|
}
|
|
667
778
|
}
|
|
@@ -677,20 +788,21 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
|
|
|
677
788
|
if (readsRuntimeOnlyState(str, scope)) {
|
|
678
789
|
return null;
|
|
679
790
|
}
|
|
791
|
+
/*
|
|
792
|
+
* `$site` and `$page` are bound as parameters, not just left on `state`.
|
|
793
|
+
*
|
|
794
|
+
* `injectContext` puts them on the document's state, so `${state.$site.name}` always worked — but
|
|
795
|
+
* every example in site-architecture.md §8.2 writes the bare `${$site.name}`, and that threw a
|
|
796
|
+
* ReferenceError the catch below turned into a silent null. A `$head` title referencing the site
|
|
797
|
+
* name reached the page as the literal template text.
|
|
798
|
+
*/
|
|
799
|
+
const args = ["state", "$map", "$site", "$page"] as const;
|
|
800
|
+
const values = [scope, scope?.$map, scope?.$site, scope?.$page];
|
|
680
801
|
try {
|
|
681
802
|
const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
$map: unknown,
|
|
686
|
-
) => unknown;
|
|
687
|
-
return fn(scope, scope?.$map);
|
|
688
|
-
}
|
|
689
|
-
const fn = new Function("state", "$map", `return \`${str}\``) as (
|
|
690
|
-
state: Record<string, unknown>,
|
|
691
|
-
$map: unknown,
|
|
692
|
-
) => unknown;
|
|
693
|
-
return fn(scope, scope?.$map);
|
|
803
|
+
const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
|
|
804
|
+
const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
|
|
805
|
+
return fn(...values);
|
|
694
806
|
} catch {
|
|
695
807
|
return null;
|
|
696
808
|
}
|
|
@@ -702,14 +814,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
|
|
|
702
814
|
* @returns {unknown}
|
|
703
815
|
*/
|
|
704
816
|
export function getPathValue(base: unknown, path: string) {
|
|
705
|
-
|
|
706
|
-
return base;
|
|
707
|
-
}
|
|
708
|
-
let acc: unknown = base;
|
|
709
|
-
for (const key of path.split("/")) {
|
|
710
|
-
acc = acc == null ? undefined : (acc as Record<string, unknown>)[key];
|
|
711
|
-
}
|
|
712
|
-
return acc;
|
|
817
|
+
return readPath(base, path);
|
|
713
818
|
}
|
|
714
819
|
|
|
715
820
|
/**
|
|
@@ -801,15 +906,6 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
801
906
|
}
|
|
802
907
|
}
|
|
803
908
|
|
|
804
|
-
if (def.tagName === "img") {
|
|
805
|
-
if (!def.attributes?.loading) {
|
|
806
|
-
out += ` loading="lazy"`;
|
|
807
|
-
}
|
|
808
|
-
if (!def.attributes?.decoding) {
|
|
809
|
-
out += ` decoding="async"`;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
909
|
if (def.$static) {
|
|
814
910
|
out += ` data-jx-static`;
|
|
815
911
|
} else if (def.$prerendered) {
|
|
@@ -1173,11 +1269,16 @@ export function collectStyles(
|
|
|
1173
1269
|
counter.n += 1;
|
|
1174
1270
|
}
|
|
1175
1271
|
|
|
1272
|
+
/* A chosen tag needs a selector that matches every candidate, not the first one.
|
|
1273
|
+
`#id` and `.class` are unaffected — they identify the element regardless of what it turns out
|
|
1274
|
+
to be — but a bare tag selector has to become the union, or styling an `<a>|<div>` wrapper
|
|
1275
|
+
would style exactly one of the two branches and silently miss the other. */
|
|
1276
|
+
const tagSelector = tagNameCandidates(def.tagName).join(", ") || "*";
|
|
1176
1277
|
const selector = def.id
|
|
1177
1278
|
? `#${def.id}`
|
|
1178
1279
|
: def.className
|
|
1179
1280
|
? `.${def.className.split(" ")[0]}`
|
|
1180
|
-
:
|
|
1281
|
+
: tagSelector;
|
|
1181
1282
|
|
|
1182
1283
|
if (def.style) {
|
|
1183
1284
|
const baseDecls = [];
|
|
@@ -1378,6 +1479,40 @@ export const SELF_CLOSING = new Set<string>([
|
|
|
1378
1479
|
"source",
|
|
1379
1480
|
]);
|
|
1380
1481
|
|
|
1482
|
+
/**
|
|
1483
|
+
* A tag for the PRERENDER, resolved against the same scope the attributes are.
|
|
1484
|
+
*
|
|
1485
|
+
* The static renderer produces bytes, so it must commit to one element. Every candidate is a
|
|
1486
|
+
* literal `TagName`, so committing is safe — and it is resolved here rather than left as text
|
|
1487
|
+
* precisely because the old `${…}` form was left as text and then re-resolved against the page
|
|
1488
|
+
* scope, where a component's own state is undefined.
|
|
1489
|
+
*
|
|
1490
|
+
* @param {unknown} tagName
|
|
1491
|
+
* @param {Record<string, unknown>} scope
|
|
1492
|
+
* @returns {string}
|
|
1493
|
+
*/
|
|
1494
|
+
export function resolveStaticTagName(
|
|
1495
|
+
tagName: unknown,
|
|
1496
|
+
scope: Record<string, unknown> | null,
|
|
1497
|
+
): string {
|
|
1498
|
+
if (typeof tagName === "string") {
|
|
1499
|
+
return tagName;
|
|
1500
|
+
}
|
|
1501
|
+
const candidates = tagNameCandidates(tagName);
|
|
1502
|
+
if (candidates.length === 0) {
|
|
1503
|
+
return "div";
|
|
1504
|
+
}
|
|
1505
|
+
if (!isTagExpression(tagName)) {
|
|
1506
|
+
return candidates[0]!;
|
|
1507
|
+
}
|
|
1508
|
+
const expression = tagName.$expression;
|
|
1509
|
+
if (expression.operator === "?:") {
|
|
1510
|
+
return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
|
|
1511
|
+
}
|
|
1512
|
+
const key = resolveStaticValue(expression.target, scope);
|
|
1513
|
+
return expression.cases[String(key)] ?? expression.default;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1381
1516
|
/**
|
|
1382
1517
|
* Recursively render a Jx node tree to static HTML for pre-rendering.
|
|
1383
1518
|
*
|
|
@@ -1435,7 +1570,11 @@ export function renderStaticNode(
|
|
|
1435
1570
|
return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
|
|
1436
1571
|
}
|
|
1437
1572
|
|
|
1438
|
-
|
|
1573
|
+
/* Resolved against the SAME scope the attributes are resolved against, so the prerendered markup
|
|
1574
|
+
and the client's first render agree about what element this is. The old `${…}` form could not
|
|
1575
|
+
do this — it re-resolved the emitted string against the page scope, where a component's own
|
|
1576
|
+
state does not exist, so SSR silently emitted the fallback branch's tag. */
|
|
1577
|
+
const tag = resolveStaticTagName(node.tagName, scope);
|
|
1439
1578
|
|
|
1440
1579
|
// Replace <slot> with provided slot content
|
|
1441
1580
|
if (tag === "slot" && slotContent != null) {
|
|
@@ -1584,13 +1723,55 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
|
|
|
1584
1723
|
* @param {Record<string, string>} [mediaQueries] - Project media query definitions
|
|
1585
1724
|
* @returns {string} CSS text, or empty string if no styles
|
|
1586
1725
|
*/
|
|
1726
|
+
/** `::slotted()` and `::part()` select through a shadow boundary, not the host that owns it. */
|
|
1727
|
+
const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Turn one nested style key into a selector, given the component's scope prefix.
|
|
1731
|
+
*
|
|
1732
|
+
* Three shapes, and the interesting one is `:host`. A style object should mean the same thing in
|
|
1733
|
+
* both modes, so `:host` and `:host(.foo)` are **translated** rather than passed through: inside a
|
|
1734
|
+
* shadow root they stand alone, and in the light DOM they become the tag name and `<tag>.foo` —
|
|
1735
|
+
* which is what "the host, matching this" means when there is no shadow root. Moving a component
|
|
1736
|
+
* between modes therefore does not silently break its styles.
|
|
1737
|
+
*
|
|
1738
|
+
* @param {string} prop - The style-object key, e.g. `":hover"`, `"& .inner"`, `":host(.wide)"`
|
|
1739
|
+
* @param {string} scope - `":host"` in shadow mode, the tag name otherwise
|
|
1740
|
+
* @returns {string}
|
|
1741
|
+
*/
|
|
1742
|
+
function resolveSelector(prop: string, scope: string): string {
|
|
1743
|
+
if (prop.startsWith("&")) {
|
|
1744
|
+
return prop.replace("&", scope);
|
|
1745
|
+
}
|
|
1746
|
+
if (prop.startsWith(":host")) {
|
|
1747
|
+
const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
|
|
1748
|
+
if (scope === ":host") {
|
|
1749
|
+
return prop;
|
|
1750
|
+
}
|
|
1751
|
+
return inner === undefined ? scope : `${scope}${inner}`;
|
|
1752
|
+
}
|
|
1753
|
+
// `:host::slotted(x)` matches nothing — the pseudo-element attaches to a slot, not the host.
|
|
1754
|
+
if (scope === ":host" && SHADOW_STANDALONE.test(prop)) {
|
|
1755
|
+
return prop;
|
|
1756
|
+
}
|
|
1757
|
+
return `${scope}${prop}`;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1587
1760
|
export function buildComponentCSS(
|
|
1588
1761
|
tagName: string,
|
|
1589
1762
|
styleDef?: JxStyle | null | undefined,
|
|
1590
1763
|
doc: JxElement | null = null,
|
|
1591
1764
|
mediaQueries: Record<string, string> = {},
|
|
1765
|
+
shadow: ShadowMode | null = null,
|
|
1592
1766
|
) {
|
|
1593
1767
|
const rules: string[] = [];
|
|
1768
|
+
/*
|
|
1769
|
+
* The scope prefix. In light DOM it is the tag name, which is what keeps `sty-card .inner` from
|
|
1770
|
+
* reaching another component's `.inner`. Inside a shadow root the selector cannot see the host's
|
|
1771
|
+
* tag name at all, and `:host` is the standard's way to address it — so the same style object
|
|
1772
|
+
* produces different, correct CSS in each mode without the author restating it.
|
|
1773
|
+
*/
|
|
1774
|
+
const scope = styleScopePrefix(tagName, shadow);
|
|
1594
1775
|
|
|
1595
1776
|
if (styleDef && typeof styleDef === "object") {
|
|
1596
1777
|
const decls: string[] = [];
|
|
@@ -1613,20 +1794,21 @@ export function buildComponentCSS(
|
|
|
1613
1794
|
decls.push(` ${camelToKebab(prop)}: ${value};`);
|
|
1614
1795
|
}
|
|
1615
1796
|
if (decls.length > 0) {
|
|
1616
|
-
rules.push(`${
|
|
1797
|
+
rules.push(`${scope} {\n${decls.join("\n")}\n}`);
|
|
1617
1798
|
}
|
|
1618
1799
|
|
|
1619
1800
|
for (const [prop, val] of Object.entries(styleDef)) {
|
|
1620
1801
|
if (prop.startsWith("@")) {
|
|
1621
|
-
pushConditionalRule(rules, prop, mediaQueries,
|
|
1802
|
+
pushConditionalRule(rules, prop, mediaQueries, scope, val as Record<string, unknown>);
|
|
1622
1803
|
} else if (
|
|
1623
1804
|
prop.startsWith(":") ||
|
|
1624
1805
|
prop.startsWith(".") ||
|
|
1625
1806
|
prop.startsWith("&") ||
|
|
1626
1807
|
prop.startsWith("[")
|
|
1627
1808
|
) {
|
|
1628
|
-
|
|
1629
|
-
|
|
1809
|
+
rules.push(
|
|
1810
|
+
`${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
|
|
1811
|
+
);
|
|
1630
1812
|
}
|
|
1631
1813
|
}
|
|
1632
1814
|
}
|