@jxsuite/compiler 3.1.0 → 4.0.1
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 +263 -260
- package/dist/compiler.js +127 -125
- package/package.json +9 -9
- package/src/cli.ts +10 -4
- package/src/shared.ts +502 -336
- package/src/site/dev-command.ts +4 -2
- package/src/site/format-host.ts +54 -1
- package/src/site/prototype-resolver.ts +2 -1
- package/src/site/site-build.ts +54 -40
- package/src/site/validate-command.ts +100 -3
- package/src/targets/compile-client.ts +32 -6
- package/src/targets/compile-element.ts +39 -2
package/src/shared.ts
CHANGED
|
@@ -11,15 +11,16 @@ import {
|
|
|
11
11
|
COLOR_SCHEME_STORAGE_KEY,
|
|
12
12
|
RESERVED_KEYS,
|
|
13
13
|
booleanAttrValue,
|
|
14
|
+
buildStyleRules,
|
|
15
|
+
enumeratedAttrNames,
|
|
16
|
+
isNestedSelectorKey,
|
|
14
17
|
camelToKebab,
|
|
15
18
|
isSingleExpression,
|
|
16
|
-
|
|
17
|
-
resolveAtQuery,
|
|
18
|
-
schemeSelectors,
|
|
19
|
-
toCSSText,
|
|
19
|
+
splitSelectorList,
|
|
20
20
|
} from "@jxsuite/runtime";
|
|
21
21
|
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
22
22
|
import { runStatements } from "@jxsuite/runtime/statements";
|
|
23
|
+
import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
|
|
23
24
|
import {
|
|
24
25
|
bodyReturnsValue,
|
|
25
26
|
childrenContainArray,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
paramNames,
|
|
38
39
|
tagNameCandidates,
|
|
39
40
|
} from "@jxsuite/schema/guards";
|
|
40
|
-
import { styleScopePrefix } from "./shadow.ts";
|
|
41
|
+
import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
|
|
41
42
|
import type { ShadowMode } from "./shadow.ts";
|
|
42
43
|
import type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
43
44
|
import { readPath } from "@jxsuite/runtime/pointer";
|
|
@@ -50,6 +51,7 @@ import type {
|
|
|
50
51
|
JxStateDefinition,
|
|
51
52
|
JxStateObject,
|
|
52
53
|
JxStyle,
|
|
54
|
+
ProjectConfig,
|
|
53
55
|
} from "@jxsuite/schema/types";
|
|
54
56
|
|
|
55
57
|
// Re-export runtime utilities used by submodules
|
|
@@ -849,13 +851,59 @@ export function cloneValue(value: unknown) {
|
|
|
849
851
|
|
|
850
852
|
// ─── HTML building ────────────────────────────────────────────────────────────
|
|
851
853
|
|
|
854
|
+
/**
|
|
855
|
+
* The inline `style` declarations of one node: its template-string entries, resolved against
|
|
856
|
+
* `scope`. Everything else in a `style` object is a stylesheet rule (`collectStyles` gives it a
|
|
857
|
+
* class handle), so only a per-instance value is written on the element.
|
|
858
|
+
*
|
|
859
|
+
* @param {JxStyle | null | undefined} style
|
|
860
|
+
* @param {Record<string, unknown> | null} scope
|
|
861
|
+
* @returns {string} `prop: value; prop: value`, or "" when nothing resolves
|
|
862
|
+
*/
|
|
863
|
+
export function inlineStyleDeclarations(
|
|
864
|
+
style: JxStyle | null | undefined,
|
|
865
|
+
scope: Record<string, unknown> | null,
|
|
866
|
+
): string {
|
|
867
|
+
if (!style || !scope) {
|
|
868
|
+
return "";
|
|
869
|
+
}
|
|
870
|
+
return Object.entries(style)
|
|
871
|
+
.filter(
|
|
872
|
+
([k, v]) =>
|
|
873
|
+
!k.startsWith(":") &&
|
|
874
|
+
!k.startsWith(".") &&
|
|
875
|
+
!k.startsWith("&") &&
|
|
876
|
+
!k.startsWith("[") &&
|
|
877
|
+
!k.startsWith("@") &&
|
|
878
|
+
v !== null &&
|
|
879
|
+
typeof v !== "object" &&
|
|
880
|
+
typeof v === "string" &&
|
|
881
|
+
isTemplateString(v),
|
|
882
|
+
)
|
|
883
|
+
.map(([k, v]) => {
|
|
884
|
+
const value = resolveStaticValue(v, scope);
|
|
885
|
+
return value == null ? null : `${camelToKebab(k)}: ${value}`;
|
|
886
|
+
})
|
|
887
|
+
.filter(Boolean)
|
|
888
|
+
.join("; ");
|
|
889
|
+
}
|
|
890
|
+
|
|
852
891
|
/**
|
|
853
892
|
* Build an HTML attribute string from a static element definition.
|
|
854
893
|
*
|
|
855
894
|
* @param {JxElement | JxMutableNode} def
|
|
856
|
-
* @param {Record<string, unknown>} scope
|
|
895
|
+
* @param {Record<string, unknown>} scope
|
|
896
|
+
* @param {string} [hostStyle] - Declarations resolved by the caller against ANOTHER scope, folded
|
|
897
|
+
* into the same `style` attribute after the node's own. A nested component instance is the one
|
|
898
|
+
* caller: its own `style` resolves against the parent's scope, while the definition's host style
|
|
899
|
+
* resolves against the instance's, and an element cannot carry two `style` attributes.
|
|
900
|
+
* @returns {string}
|
|
857
901
|
*/
|
|
858
|
-
export function buildAttrs(
|
|
902
|
+
export function buildAttrs(
|
|
903
|
+
def: JxElement | JxMutableNode,
|
|
904
|
+
scope: Record<string, unknown> | null,
|
|
905
|
+
hostStyle = "",
|
|
906
|
+
) {
|
|
859
907
|
let out = "";
|
|
860
908
|
|
|
861
909
|
const id = resolveStaticValue(def.id, scope);
|
|
@@ -888,29 +936,20 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
888
936
|
out += ` dir="${escapeHtml(String(dir))}"`;
|
|
889
937
|
}
|
|
890
938
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
.map(([k, v]) => {
|
|
906
|
-
const value = resolveStaticValue(v, scope);
|
|
907
|
-
return value == null ? null : `${camelToKebab(k)}: ${value}`;
|
|
908
|
-
})
|
|
909
|
-
.filter(Boolean)
|
|
910
|
-
.join("; ");
|
|
911
|
-
if (inline) {
|
|
912
|
-
out += ` style="${inline}"`;
|
|
913
|
-
}
|
|
939
|
+
// The host declarations go last: in one `style` attribute the later declaration of a property
|
|
940
|
+
// Wins, and the page walk gives the definition's resolved host style the same precedence over an
|
|
941
|
+
// Instance's own (`{ ...node.style, ...resolvedStyle }`), so the two paths agree.
|
|
942
|
+
const inline = [inlineStyleDeclarations(def.style, scope), hostStyle].filter(Boolean).join("; ");
|
|
943
|
+
if (inline) {
|
|
944
|
+
/*
|
|
945
|
+
* Escaped like every other attribute. Both halves carry values a template string resolved
|
|
946
|
+
* from data — a `background-image: url("…")` host style reads its target from a prop — and a
|
|
947
|
+
* `"` in that data would otherwise end the attribute and hand the rest of the value to the
|
|
948
|
+
* parser as attributes. The HTML parser decodes `"` before the CSS parser sees the
|
|
949
|
+
* declaration, so `url("…")` renders exactly as `url("…")`, and `rewriteHtmlBase`
|
|
950
|
+
* already reads the entity-wrapped form back when it re-roots a `url()`.
|
|
951
|
+
*/
|
|
952
|
+
out += ` style="${escapeHtml(inline)}"`;
|
|
914
953
|
}
|
|
915
954
|
if (def.attributes) {
|
|
916
955
|
for (const [k, v] of Object.entries(def.attributes)) {
|
|
@@ -1040,79 +1079,141 @@ export function colorSchemePrePaintScript(): string {
|
|
|
1040
1079
|
}
|
|
1041
1080
|
|
|
1042
1081
|
/**
|
|
1043
|
-
*
|
|
1044
|
-
* color-scheme queries dual-emit per the forced-scheme contract (spec §9.5): a media-guarded copy
|
|
1045
|
-
* that applies while no scheme is forced plus an unconditional copy under the forced root
|
|
1046
|
-
* attribute.
|
|
1082
|
+
* Push the rules one style object becomes, scoped to `selector`.
|
|
1047
1083
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
* @
|
|
1052
|
-
*
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
mediaQueries: Record<string, string>,
|
|
1057
|
-
rules: string[],
|
|
1058
|
-
): (selector: string, props: string) => void {
|
|
1059
|
-
const query = resolveAtQuery(atKey, mediaQueries);
|
|
1060
|
-
const atRule = query === null ? atKey : `@media ${query}`;
|
|
1061
|
-
const scheme = query === null ? null : pureSchemeOf(query);
|
|
1062
|
-
return (selector: string, props: string) => {
|
|
1063
|
-
if (!props) {
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
if (scheme) {
|
|
1067
|
-
const { auto, forced } = schemeSelectors(selector, scheme);
|
|
1068
|
-
rules.push(`${atRule} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
|
|
1069
|
-
} else {
|
|
1070
|
-
rules.push(`${atRule} { ${selector} { ${props} } }`);
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* Push the rules for one `@`-prefixed style block scoped to `selector`: flat props plus one level
|
|
1077
|
-
* of nested selectors, routed through conditionalRuleEmitter (scheme-aware).
|
|
1084
|
+
* The single door from the compiler to `buildStyleRules`, which is now the ONE definition of what a
|
|
1085
|
+
* Jx style object means as CSS — shared with the DOM runtime and the site-style builder. Three
|
|
1086
|
+
* emitters used to answer that question separately, and each dropped a different combination: this
|
|
1087
|
+
* one emitted `selector → @media` but only ever ONE selector level inside an at-rule group, so
|
|
1088
|
+
* `@media → .card → :hover` was silently lost, while the runtime dropped the opposite order.
|
|
1089
|
+
*
|
|
1090
|
+
* Template-string and `$ref` values are omitted rather than resolved: a compiled page's CSS is
|
|
1091
|
+
* static by construction, and the runtime writes those declarations at render time.
|
|
1078
1092
|
*
|
|
1079
1093
|
* @param {string[]} rules
|
|
1080
|
-
* @param {
|
|
1094
|
+
* @param {JxStyle} style
|
|
1095
|
+
* @param {string | null} selector - The scope, or null for a bare declaration-body at-rule
|
|
1081
1096
|
* @param {Record<string, string>} mediaQueries
|
|
1082
|
-
* @param {string} selector
|
|
1083
|
-
* @param {Record<string, unknown>} obj
|
|
1084
1097
|
*/
|
|
1085
|
-
function
|
|
1098
|
+
function pushStyleRules(
|
|
1086
1099
|
rules: string[],
|
|
1087
|
-
|
|
1100
|
+
style: JxStyle,
|
|
1101
|
+
selector: string | null,
|
|
1088
1102
|
mediaQueries: Record<string, string>,
|
|
1089
|
-
selector: string,
|
|
1090
|
-
obj: Record<string, unknown>,
|
|
1091
1103
|
) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
const resolved = sel.startsWith("&")
|
|
1105
|
-
? sel.replace("&", selector)
|
|
1106
|
-
: sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
|
|
1107
|
-
? `${selector}${sel}`
|
|
1108
|
-
: `${selector} ${sel}`;
|
|
1109
|
-
emit(resolved, toCSSText(sub));
|
|
1104
|
+
for (const rule of buildStyleRules(style, {
|
|
1105
|
+
mediaQueries,
|
|
1106
|
+
/* Records what a STATIC build drops, and returns `null` so it drops exactly as before — the
|
|
1107
|
+
emitted bytes are unchanged. A reactive declaration is a runtime declaration: it resolves
|
|
1108
|
+
against a live scope, which a compiled page has only where the runtime is present, so a
|
|
1109
|
+
static emitter has always dropped it. Silently, which is the defect: a document is correct
|
|
1110
|
+
in Studio and simply unstyled in the built page, with nothing said. */
|
|
1111
|
+
resolveValue: (property, value) => recordDroppedReactive(property, value, selector),
|
|
1112
|
+
scope: selector,
|
|
1113
|
+
})) {
|
|
1114
|
+
rules.push(rule.text);
|
|
1110
1115
|
}
|
|
1111
1116
|
}
|
|
1112
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Record a reactive declaration a static build drops, and answer `null` so it is dropped.
|
|
1120
|
+
*
|
|
1121
|
+
* One recorder for both emitters the build reaches — `pushStyleRules` for element styles, and
|
|
1122
|
+
* `buildSiteStyleCSS` for the project block, which takes it as its resolver — so a `${…}` in
|
|
1123
|
+
* `project.json#/style` is reported the way one in an element's `style` is.
|
|
1124
|
+
*/
|
|
1125
|
+
function recordDroppedReactive(
|
|
1126
|
+
property: string,
|
|
1127
|
+
value: string | JxRef,
|
|
1128
|
+
selector: string | null,
|
|
1129
|
+
): null {
|
|
1130
|
+
const source = typeof value === "string" ? value : value.$ref;
|
|
1131
|
+
_droppedReactive.push({ property, selector, source });
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** One entry per reactive declaration a static build dropped, since the last drain. */
|
|
1136
|
+
const _droppedReactive: { property: string; selector: string | null; source: string }[] = [];
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Take the reactive declarations dropped since the last call, as warning lines, and clear them.
|
|
1140
|
+
*
|
|
1141
|
+
* A build drains this once and reports what it finds. Draining is what keeps two builds in one
|
|
1142
|
+
* process from inheriting each other's, and what keeps the list from growing without bound.
|
|
1143
|
+
*
|
|
1144
|
+
* @returns {string[]} One line per dropped declaration, naming the property and where it was.
|
|
1145
|
+
*/
|
|
1146
|
+
export function takeDroppedReactiveStyles(): string[] {
|
|
1147
|
+
const lines = _droppedReactive.map(
|
|
1148
|
+
({ property, selector, source }) =>
|
|
1149
|
+
`A static build drops the reactive style declaration \`${property}: ${source}\`` +
|
|
1150
|
+
`${selector ? ` on \`${selector}\`` : ""}. It resolves against a live scope, which a ` +
|
|
1151
|
+
"built page has only where the runtime is present, so the declaration is absent from the " +
|
|
1152
|
+
"page. Give it a static value, or move it to an element the runtime renders.",
|
|
1153
|
+
);
|
|
1154
|
+
_droppedReactive.length = 0;
|
|
1155
|
+
return lines;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* The name of the boolean-attribute helper the generated modules call.
|
|
1160
|
+
*
|
|
1161
|
+
* Prefixed and improbable because it lands in the same scope as the author's own bindings.
|
|
1162
|
+
*/
|
|
1163
|
+
export const ATTR_HELPER = "__jxAttrText";
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* The source of that helper, to be emitted into a generated module's preamble.
|
|
1167
|
+
*
|
|
1168
|
+
* **Why an inlined copy rather than an import.** `booleanAttrValue` is the single decision about
|
|
1169
|
+
* which family an attribute belongs to, and `buildAttrs` here calls it directly — but the element
|
|
1170
|
+
* and client targets emit standalone ES modules that a built site loads with `lit-html` and
|
|
1171
|
+
* `@vue/reactivity` and nothing else. Importing `@jxsuite/runtime` into every compiled component to
|
|
1172
|
+
* reach four lines would put the whole renderer on the critical path of pages that never use it.
|
|
1173
|
+
*
|
|
1174
|
+
* So the rule is inlined, and the enumerated names are SERIALIZED from `enumeratedAttrNames()`
|
|
1175
|
+
* rather than retyped. `compile-element.test.ts` asserts the emitted literal still equals that
|
|
1176
|
+
* export, which is what keeps this from becoming a fourth, drifting definition — the exact failure
|
|
1177
|
+
* the essay above `buildAttrs` exists to prevent, and the one these two targets shipped for months:
|
|
1178
|
+
* they stringified booleans, so a component's `open: true` compiled to `open="true"` and a bound
|
|
1179
|
+
* `open` that flipped false wrote `open="false"` — an OPEN `<details>` the author had closed.
|
|
1180
|
+
*
|
|
1181
|
+
* Returns the text to write, or `null` to mean "remove the attribute"; each caller adapts that null
|
|
1182
|
+
* to its own idiom (`nothing` in a lit template, `removeAttribute` in the hydration script).
|
|
1183
|
+
*
|
|
1184
|
+
* @returns {string} One `function` declaration, ready to push into a module's line list.
|
|
1185
|
+
*/
|
|
1186
|
+
export function attrHelperSource(): string {
|
|
1187
|
+
const names = JSON.stringify(enumeratedAttrNames());
|
|
1188
|
+
return (
|
|
1189
|
+
`const __jxEnumAttrs = new Set(${names});\n` +
|
|
1190
|
+
`function ${ATTR_HELPER}(n, v) {\n` +
|
|
1191
|
+
` if (typeof v !== 'boolean') return v;\n` +
|
|
1192
|
+
` const l = n.toLowerCase();\n` +
|
|
1193
|
+
` if (l.startsWith('aria-') || __jxEnumAttrs.has(l)) return String(v);\n` +
|
|
1194
|
+
` return v ? '' : null;\n` +
|
|
1195
|
+
`}`
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1113
1199
|
/**
|
|
1114
1200
|
* Walk the entire document tree and collect all static nested CSS rules.
|
|
1115
1201
|
*
|
|
1202
|
+
* The project block is NOT split here. `buildSiteStyleCSS` (`@jxsuite/site/site-style`) is the one
|
|
1203
|
+
* definition of how `project.json#/style` becomes a page's stylesheet — custom properties and
|
|
1204
|
+
* `color-scheme` on `:root`, other declarations on `body`, `&`-keyed blocks as states of `:root`,
|
|
1205
|
+
* selector keys as their own rules, `@`-blocks resolved and dual-emitted, then the forced-scheme
|
|
1206
|
+
* `color-scheme` triplet — and the built page calls it with an identity transposer, so the canvas
|
|
1207
|
+
* and the live preview show what the build ships by construction. This function used to carry its
|
|
1208
|
+
* own copy of that split, and the two copies disagreed on two shapes the site builder had right: a
|
|
1209
|
+
* top-level `&[data-theme="light"]` reached the page as a raw `&` selector, which no engine
|
|
1210
|
+
* matches, and a top-level `colorScheme` went to `body`, where `light-dark()` on the root reads the
|
|
1211
|
+
* UA default instead (#329, spec.md §9.5).
|
|
1212
|
+
*
|
|
1213
|
+
* The one thing the build adds is the resolver: a host drops a reactive project declaration in
|
|
1214
|
+
* silence because it has nothing to evaluate it against, and a static build drops it for the same
|
|
1215
|
+
* reason but SAYS SO, through the same recorder `pushStyleRules` uses for element styles.
|
|
1216
|
+
*
|
|
1116
1217
|
* @param {JxElement | JxMutableNode} doc
|
|
1117
1218
|
* @param {Record<string, string>} [mediaQueries]
|
|
1118
1219
|
* @param {JxStyle | null} [projectStyle]
|
|
@@ -1125,119 +1226,17 @@ export function compileStyles(
|
|
|
1125
1226
|
) {
|
|
1126
1227
|
const rules: string[] = [];
|
|
1127
1228
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
continue;
|
|
1140
|
-
}
|
|
1141
|
-
if (key.startsWith("@")) {
|
|
1142
|
-
pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
|
|
1143
|
-
continue;
|
|
1144
|
-
}
|
|
1145
|
-
const resolved = key.startsWith("&")
|
|
1146
|
-
? key.replace("&", selector)
|
|
1147
|
-
: key.startsWith(":") || key.startsWith(".") || key.startsWith("[")
|
|
1148
|
-
? `${selector}${key}`
|
|
1149
|
-
: `${selector} ${key}`;
|
|
1150
|
-
emitProjectRules(resolved, val as Record<string, unknown>);
|
|
1151
|
-
}
|
|
1152
|
-
};
|
|
1153
|
-
|
|
1154
|
-
// Collect CSS custom properties into :root {}
|
|
1155
|
-
const rootProps: Record<string, unknown> = {};
|
|
1156
|
-
// Collect direct CSS properties into body {}
|
|
1157
|
-
const bodyProps: Record<string, unknown> = {};
|
|
1158
|
-
for (const [key, val] of Object.entries(projectStyle)) {
|
|
1159
|
-
if (
|
|
1160
|
-
key.startsWith(":") ||
|
|
1161
|
-
key.startsWith(".") ||
|
|
1162
|
-
key.startsWith("[") ||
|
|
1163
|
-
key.startsWith("@")
|
|
1164
|
-
) {
|
|
1165
|
-
continue;
|
|
1166
|
-
}
|
|
1167
|
-
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
1168
|
-
continue;
|
|
1169
|
-
}
|
|
1170
|
-
if (key.startsWith("--")) {
|
|
1171
|
-
rootProps[key] = val;
|
|
1172
|
-
} else {
|
|
1173
|
-
bodyProps[key] = val;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
// Base rules precede conditional blocks so equal-specificity overrides win by source order.
|
|
1177
|
-
const rootCSS = toCSSText(rootProps);
|
|
1178
|
-
if (rootCSS) {
|
|
1179
|
-
rules.push(`:root { ${rootCSS} }`);
|
|
1180
|
-
}
|
|
1181
|
-
const bodyCSS = toCSSText(bodyProps);
|
|
1182
|
-
if (bodyCSS) {
|
|
1183
|
-
rules.push(`body { ${bodyCSS} }`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
for (const [key, val] of Object.entries(projectStyle)) {
|
|
1187
|
-
if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
|
|
1188
|
-
emitProjectRules(key, val as Record<string, unknown>);
|
|
1189
|
-
} else if (
|
|
1190
|
-
val !== null &&
|
|
1191
|
-
typeof val === "object" &&
|
|
1192
|
-
!Array.isArray(val) &&
|
|
1193
|
-
!key.startsWith("@") &&
|
|
1194
|
-
!key.startsWith("--")
|
|
1195
|
-
) {
|
|
1196
|
-
emitProjectRules(key, val as Record<string, unknown>);
|
|
1197
|
-
} else if (
|
|
1198
|
-
key.startsWith("@") &&
|
|
1199
|
-
val !== null &&
|
|
1200
|
-
typeof val === "object" &&
|
|
1201
|
-
!Array.isArray(val)
|
|
1202
|
-
) {
|
|
1203
|
-
// Conditional block at project top level: custom properties override :root, direct
|
|
1204
|
-
// Properties override body, selector-keyed sub-objects their own selector.
|
|
1205
|
-
const emit = conditionalRuleEmitter(key, mediaQueries, rules);
|
|
1206
|
-
const condRoot: Record<string, unknown> = {};
|
|
1207
|
-
const condBody: Record<string, unknown> = {};
|
|
1208
|
-
const condSubs: [string, Record<string, unknown>][] = [];
|
|
1209
|
-
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
|
1210
|
-
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
1211
|
-
if (!k.startsWith("@")) {
|
|
1212
|
-
condSubs.push([k, v as Record<string, unknown>]);
|
|
1213
|
-
}
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
if (k.startsWith("--")) {
|
|
1217
|
-
condRoot[k] = v;
|
|
1218
|
-
} else {
|
|
1219
|
-
condBody[k] = v;
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
emit(":root", toCSSText(condRoot));
|
|
1223
|
-
emit("body", toCSSText(condBody));
|
|
1224
|
-
for (const [sel, sub] of condSubs) {
|
|
1225
|
-
emit(sel, toCSSText(sub));
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
// Forced-scheme UA hint: native widgets follow the forced attribute, not only the OS scheme.
|
|
1232
|
-
if (
|
|
1233
|
-
Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null) &&
|
|
1234
|
-
!(projectStyle && typeof projectStyle === "object" && "colorScheme" in projectStyle)
|
|
1235
|
-
) {
|
|
1236
|
-
rules.push(
|
|
1237
|
-
":root { color-scheme: light dark }",
|
|
1238
|
-
`:root:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
|
|
1239
|
-
`:root:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
|
|
1240
|
-
);
|
|
1229
|
+
/* An absent project block is an empty one: the builder emits nothing for `{}` except the
|
|
1230
|
+
`color-scheme` triplet a scheme query in `$media` still calls for, which is exactly what a
|
|
1231
|
+
page with a scheme query and no project style has always carried. */
|
|
1232
|
+
const projectSheet = buildSiteStyleCSS(
|
|
1233
|
+
projectStyle && typeof projectStyle === "object" ? projectStyle : {},
|
|
1234
|
+
mediaQueries,
|
|
1235
|
+
(value) => value,
|
|
1236
|
+
recordDroppedReactive,
|
|
1237
|
+
);
|
|
1238
|
+
if (projectSheet !== "") {
|
|
1239
|
+
rules.push(projectSheet);
|
|
1241
1240
|
}
|
|
1242
1241
|
|
|
1243
1242
|
const counter = { n: 0 };
|
|
@@ -1261,41 +1260,6 @@ export function escapeStyleText(css: string): string {
|
|
|
1261
1260
|
return css.replaceAll(/<\/(?=style)/gi, String.raw`<\/`);
|
|
1262
1261
|
}
|
|
1263
1262
|
|
|
1264
|
-
/**
|
|
1265
|
-
* Recursively emit CSS rules for a nested element selector.
|
|
1266
|
-
*
|
|
1267
|
-
* @param {string} selector
|
|
1268
|
-
* @param {Record<string, unknown>} obj
|
|
1269
|
-
* @param {string[]} rules
|
|
1270
|
-
* @param {Record<string, string>} mediaQueries
|
|
1271
|
-
*/
|
|
1272
|
-
function emitNestedElement(
|
|
1273
|
-
selector: string,
|
|
1274
|
-
obj: Record<string, unknown>,
|
|
1275
|
-
rules: string[],
|
|
1276
|
-
mediaQueries: Record<string, string>,
|
|
1277
|
-
) {
|
|
1278
|
-
const props = toCSSText(obj);
|
|
1279
|
-
if (props) {
|
|
1280
|
-
rules.push(`${selector} { ${props} }`);
|
|
1281
|
-
}
|
|
1282
|
-
for (const [key, val] of Object.entries(obj)) {
|
|
1283
|
-
if (val === null || typeof val !== "object" || Array.isArray(val)) {
|
|
1284
|
-
continue;
|
|
1285
|
-
}
|
|
1286
|
-
if (key.startsWith("@")) {
|
|
1287
|
-
pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
|
|
1288
|
-
continue;
|
|
1289
|
-
}
|
|
1290
|
-
const resolved = key.startsWith("&")
|
|
1291
|
-
? key.replace("&", selector)
|
|
1292
|
-
: key.startsWith(":") || key.startsWith(".") || key.startsWith("[")
|
|
1293
|
-
? `${selector}${key}`
|
|
1294
|
-
: `${selector} ${key}`;
|
|
1295
|
-
emitNestedElement(resolved, val as Record<string, unknown>, rules, mediaQueries);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
1263
|
/**
|
|
1300
1264
|
* @param {JxElement | JxMutableNode | string} def
|
|
1301
1265
|
* @param {string[]} rules
|
|
@@ -1333,49 +1297,7 @@ export function collectStyles(
|
|
|
1333
1297
|
: tagSelector;
|
|
1334
1298
|
|
|
1335
1299
|
if (def.style) {
|
|
1336
|
-
|
|
1337
|
-
for (const [prop, value] of Object.entries(def.style)) {
|
|
1338
|
-
if (
|
|
1339
|
-
prop.startsWith(":") ||
|
|
1340
|
-
prop.startsWith(".") ||
|
|
1341
|
-
prop.startsWith("&") ||
|
|
1342
|
-
prop.startsWith("[") ||
|
|
1343
|
-
prop.startsWith("@")
|
|
1344
|
-
) {
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
if (value === null || typeof value === "object") {
|
|
1348
|
-
continue;
|
|
1349
|
-
}
|
|
1350
|
-
if (typeof value === "string" && isTemplateString(value)) {
|
|
1351
|
-
continue;
|
|
1352
|
-
}
|
|
1353
|
-
baseDecls.push(` ${camelToKebab(prop)}: ${value};`);
|
|
1354
|
-
}
|
|
1355
|
-
if (baseDecls.length > 0) {
|
|
1356
|
-
rules.push(`${selector} {\n${baseDecls.join("\n")}\n}`);
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
for (const [prop, val] of Object.entries(def.style)) {
|
|
1360
|
-
if (val === null || typeof val !== "object" || Array.isArray(val)) {
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
if (prop.startsWith("@")) {
|
|
1364
|
-
pushConditionalRule(rules, prop, mediaQueries, selector, val as Record<string, unknown>);
|
|
1365
|
-
} else {
|
|
1366
|
-
const resolved = prop.startsWith("&")
|
|
1367
|
-
? prop.replace("&", selector)
|
|
1368
|
-
: prop.startsWith(":") || prop.startsWith(".") || prop.startsWith("[")
|
|
1369
|
-
? `${selector}${prop}`
|
|
1370
|
-
: `${selector} ${prop}`;
|
|
1371
|
-
emitNestedElement(
|
|
1372
|
-
resolved,
|
|
1373
|
-
/** @type {Record<string, unknown>} */ val,
|
|
1374
|
-
rules,
|
|
1375
|
-
mediaQueries,
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1300
|
+
pushStyleRules(rules, def.style, selector, mediaQueries);
|
|
1379
1301
|
}
|
|
1380
1302
|
|
|
1381
1303
|
if (Array.isArray(def.children)) {
|
|
@@ -1565,6 +1487,148 @@ export function resolveStaticTagName(
|
|
|
1565
1487
|
return expression.cases[String(key)] ?? expression.default;
|
|
1566
1488
|
}
|
|
1567
1489
|
|
|
1490
|
+
/**
|
|
1491
|
+
* What the static renderer needs to expand a component instance it meets INSIDE a definition.
|
|
1492
|
+
*
|
|
1493
|
+
* Without one, a custom-element tag in a component's `children` is an element like any other: the
|
|
1494
|
+
* generic path emits `<inner-chip></inner-chip>` with the instance's own (empty) children, no
|
|
1495
|
+
* content and no host style — and because the parent still looks static, no module ever loads to
|
|
1496
|
+
* fill it (issue #286). The page walk in `site-build` has always expanded the instances it meets in
|
|
1497
|
+
* the PAGE tree; this is the same registry, handed down so the definition walk expands too.
|
|
1498
|
+
*
|
|
1499
|
+
* `path` is the chain of instances currently being expanded, outermost first. It is what makes the
|
|
1500
|
+
* recursion terminate: a component graph is bounded, but a definition may name itself.
|
|
1501
|
+
*/
|
|
1502
|
+
export interface ComponentPrerenderContext {
|
|
1503
|
+
/** Tag name → parsed definition, the registry the page walk expands from. */
|
|
1504
|
+
componentDefs: ReadonlyMap<string, JxElement>;
|
|
1505
|
+
/** Project defaults, read for `defaults.shadow` (spec.md §16.6). */
|
|
1506
|
+
defaults?: ProjectConfig["defaults"] | undefined;
|
|
1507
|
+
/** The instances being expanded, outermost first; empty at a page-level instance. */
|
|
1508
|
+
path?: readonly PrerenderFrame[] | undefined;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** One instance on the expansion path: its tag, and its resolved props as an identity. */
|
|
1512
|
+
interface PrerenderFrame {
|
|
1513
|
+
tag: string;
|
|
1514
|
+
/** `JSON.stringify` of the resolved props — two frames with equal keys are the same instance. */
|
|
1515
|
+
key: string;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* How deep component nesting may go before the build refuses to continue.
|
|
1520
|
+
*
|
|
1521
|
+
* The cycle check below catches a definition that renders ITSELF — same tag, same props — which is
|
|
1522
|
+
* the only shape that can never terminate. A self-reference whose props change at every level
|
|
1523
|
+
* (`depth: "${state.depth + 1}"`) is data-driven recursion, legitimate when a `$switch` bottoms it
|
|
1524
|
+
* out and unbounded when the author forgot to. The seen-set cannot tell those apart, so this cap is
|
|
1525
|
+
* what turns the second into a diagnostic instead of a stack overflow. Thirty-two is far beyond any
|
|
1526
|
+
* component library's real depth and far below the call stack's.
|
|
1527
|
+
*/
|
|
1528
|
+
export const MAX_COMPONENT_NESTING = 32;
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Lift literal `props.*` attribute keys off an instance's attributes.
|
|
1532
|
+
*
|
|
1533
|
+
* JSON-authored instances pass props as literal `props.*` attribute keys (markdown directives are
|
|
1534
|
+
* normalized to `$props` by the parser's `expandDotPaths`, but JSON is parsed verbatim). They are
|
|
1535
|
+
* lifted so the prerender sees them, and stripped so they do not leak into the emitted HTML. Values
|
|
1536
|
+
* stay raw strings — no coercion, matching the markdown path and the runtime's `$props` semantics.
|
|
1537
|
+
* Pure: the page walk writes the result back onto its node, the definition walk must not touch a
|
|
1538
|
+
* definition every page shares.
|
|
1539
|
+
*
|
|
1540
|
+
* @param {Record<string, JxAttributeValue> | undefined} attributes
|
|
1541
|
+
* @returns {{ lifted: Record<string, JsonValue> | null; rest: Record<string, JxAttributeValue> }}
|
|
1542
|
+
* The lifted props (null when there were none) and the attributes that remain
|
|
1543
|
+
*/
|
|
1544
|
+
export function liftPropsAttributes(attributes: JxElement["attributes"]): {
|
|
1545
|
+
lifted: Record<string, JsonValue> | null;
|
|
1546
|
+
rest: NonNullable<JxElement["attributes"]>;
|
|
1547
|
+
} {
|
|
1548
|
+
let lifted: Record<string, JsonValue> | null = null;
|
|
1549
|
+
const rest: NonNullable<JxElement["attributes"]> = {};
|
|
1550
|
+
for (const [key, value] of Object.entries(attributes ?? {})) {
|
|
1551
|
+
if (key.startsWith("props.") && key.length > "props.".length) {
|
|
1552
|
+
lifted ??= {};
|
|
1553
|
+
// JxAttributeValue is JSON-representable (primitives or a $ref object), so the narrowing to
|
|
1554
|
+
// JsonValue is sound.
|
|
1555
|
+
lifted[key.slice("props.".length)] = value as JsonValue;
|
|
1556
|
+
} else {
|
|
1557
|
+
rest[key] = value;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return { lifted, rest };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* The build-time scope of one component instance: the definition's `state` with the instance's
|
|
1565
|
+
* props laid over it.
|
|
1566
|
+
*
|
|
1567
|
+
* A prop for an entry declared as an object keeps the declaration and replaces its `default`, so a
|
|
1568
|
+
* typed entry stays typed; a bare or undeclared entry is the value itself. Shared by the inner
|
|
1569
|
+
* render and the host-style resolution so both read the same instance — they used to build the
|
|
1570
|
+
* scope separately, in two slightly different ways.
|
|
1571
|
+
*
|
|
1572
|
+
* @param {JxElement} doc - Component definition
|
|
1573
|
+
* @param {Record<string, JsonValue> | null} props - Instance-specific prop values
|
|
1574
|
+
* @returns {Record<string, unknown>}
|
|
1575
|
+
*/
|
|
1576
|
+
export function buildInstanceScope(
|
|
1577
|
+
doc: JxElement,
|
|
1578
|
+
props: Record<string, JsonValue> | null,
|
|
1579
|
+
): Record<string, unknown> {
|
|
1580
|
+
let stateDefs: Record<string, JxStateDefinition> = doc.state ?? {};
|
|
1581
|
+
if (props) {
|
|
1582
|
+
stateDefs = { ...stateDefs };
|
|
1583
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1584
|
+
if (key in stateDefs) {
|
|
1585
|
+
const existing = stateDefs[key];
|
|
1586
|
+
stateDefs[key] =
|
|
1587
|
+
existing &&
|
|
1588
|
+
typeof existing === "object" &&
|
|
1589
|
+
!Array.isArray(existing) &&
|
|
1590
|
+
"default" in existing
|
|
1591
|
+
? { .../** @type {JxStateObject} */ existing, default: value }
|
|
1592
|
+
: (value as JxStateDefinition);
|
|
1593
|
+
} else {
|
|
1594
|
+
stateDefs[key] = value as JxStateDefinition;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return buildInitialScope(stateDefs, null);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* The definition's host `style` entries that depend on the instance, resolved against it.
|
|
1603
|
+
*
|
|
1604
|
+
* Only template-string values are per-instance (`maskImage: "${'var(--icon-' + state.name +
|
|
1605
|
+
* ')'}"`); a literal declaration is in the component's stylesheet already. This is the resolution
|
|
1606
|
+
* the page walk performs for a page-level instance, extracted so a nested instance gets the same
|
|
1607
|
+
* one.
|
|
1608
|
+
*
|
|
1609
|
+
* @param {JxStyle | null | undefined} style - The definition's `style`
|
|
1610
|
+
* @param {Record<string, unknown>} scope - The instance's scope (`buildInstanceScope`)
|
|
1611
|
+
* @returns {Record<string, unknown>} Resolved values by (camelCase) property
|
|
1612
|
+
*/
|
|
1613
|
+
export function resolveHostStyle(
|
|
1614
|
+
style: JxStyle | null | undefined,
|
|
1615
|
+
scope: Record<string, unknown>,
|
|
1616
|
+
): Record<string, unknown> {
|
|
1617
|
+
const resolved: Record<string, unknown> = {};
|
|
1618
|
+
if (!style) {
|
|
1619
|
+
return resolved;
|
|
1620
|
+
}
|
|
1621
|
+
for (const [prop, value] of Object.entries(style)) {
|
|
1622
|
+
if (typeof value === "string" && isTemplateString(value)) {
|
|
1623
|
+
const val = resolveStaticValue(value, scope);
|
|
1624
|
+
if (val != null) {
|
|
1625
|
+
resolved[prop] = val;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return resolved;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1568
1632
|
/**
|
|
1569
1633
|
* The content of one node: `textContent`, else `innerHTML`, else rendered `children`.
|
|
1570
1634
|
*
|
|
@@ -1576,12 +1640,14 @@ export function resolveStaticTagName(
|
|
|
1576
1640
|
* @param {JxElement | JxMutableNode} node
|
|
1577
1641
|
* @param {Record<string, unknown> | null} scope
|
|
1578
1642
|
* @param {string | null} slotContent
|
|
1643
|
+
* @param {ComponentPrerenderContext | null} context
|
|
1579
1644
|
* @returns {string}
|
|
1580
1645
|
*/
|
|
1581
1646
|
function renderInner(
|
|
1582
1647
|
node: JxElement | JxMutableNode,
|
|
1583
1648
|
scope: Record<string, unknown> | null,
|
|
1584
1649
|
slotContent: string | null,
|
|
1650
|
+
context: ComponentPrerenderContext | null,
|
|
1585
1651
|
): string {
|
|
1586
1652
|
if (node.textContent !== undefined) {
|
|
1587
1653
|
const val = resolveStaticValue(node.textContent, scope);
|
|
@@ -1593,7 +1659,9 @@ function renderInner(
|
|
|
1593
1659
|
}
|
|
1594
1660
|
if (Array.isArray(node.children)) {
|
|
1595
1661
|
return node.children
|
|
1596
|
-
.map((c: JxElement | JxMutableNode | string) =>
|
|
1662
|
+
.map((c: JxElement | JxMutableNode | string) =>
|
|
1663
|
+
renderStaticNode(c, scope, slotContent, context),
|
|
1664
|
+
)
|
|
1597
1665
|
.join("\n");
|
|
1598
1666
|
}
|
|
1599
1667
|
return "";
|
|
@@ -1605,12 +1673,15 @@ function renderInner(
|
|
|
1605
1673
|
* @param {JxElement | JxMutableNode | string} node
|
|
1606
1674
|
* @param {Record<string, unknown>} scope
|
|
1607
1675
|
* @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
|
|
1676
|
+
* @param {ComponentPrerenderContext | null} [context] - The component registry, when a custom
|
|
1677
|
+
* element tag met here should be expanded rather than emitted bare
|
|
1608
1678
|
* @returns {string}
|
|
1609
1679
|
*/
|
|
1610
1680
|
export function renderStaticNode(
|
|
1611
1681
|
node: JxElement | JxMutableNode | string,
|
|
1612
1682
|
scope: Record<string, unknown> | null,
|
|
1613
1683
|
slotContent: string | null = null,
|
|
1684
|
+
context: ComponentPrerenderContext | null = null,
|
|
1614
1685
|
): string {
|
|
1615
1686
|
if (typeof node === "string") {
|
|
1616
1687
|
if (isTemplateString(node) && scope) {
|
|
@@ -1625,7 +1696,7 @@ export function renderStaticNode(
|
|
|
1625
1696
|
if (Array.isArray(node)) {
|
|
1626
1697
|
return (node as (JxElement | JxMutableNode | string)[])
|
|
1627
1698
|
.map((c: JxElement | JxMutableNode | string): string =>
|
|
1628
|
-
renderStaticNode(c, scope, slotContent),
|
|
1699
|
+
renderStaticNode(c, scope, slotContent, context),
|
|
1629
1700
|
)
|
|
1630
1701
|
.join("\n");
|
|
1631
1702
|
}
|
|
@@ -1651,7 +1722,7 @@ export function renderStaticNode(
|
|
|
1651
1722
|
: (node.cases as Record<string, JxElement | string> | undefined)?.[String(key)];
|
|
1652
1723
|
const inner =
|
|
1653
1724
|
caseDef !== undefined && !isRefObject(caseDef)
|
|
1654
|
-
? renderStaticNode(caseDef, scope, slotContent)
|
|
1725
|
+
? renderStaticNode(caseDef, scope, slotContent, context)
|
|
1655
1726
|
: "";
|
|
1656
1727
|
return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
|
|
1657
1728
|
}
|
|
@@ -1667,13 +1738,119 @@ export function renderStaticNode(
|
|
|
1667
1738
|
return slotContent;
|
|
1668
1739
|
}
|
|
1669
1740
|
|
|
1741
|
+
// A registered component: expanded exactly as the page walk expands one, not emitted bare.
|
|
1742
|
+
const def = context?.componentDefs.get(tag);
|
|
1743
|
+
if (def && context) {
|
|
1744
|
+
return renderComponentInstance(node, def, scope, slotContent, context);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1670
1747
|
const attrs = buildAttrs(node, scope);
|
|
1671
1748
|
|
|
1672
1749
|
if (SELF_CLOSING.has(tag)) {
|
|
1673
1750
|
return `<${tag}${attrs}>`;
|
|
1674
1751
|
}
|
|
1675
1752
|
|
|
1676
|
-
return `<${tag}${attrs}>${renderInner(node, scope, slotContent)}</${tag}>`;
|
|
1753
|
+
return `<${tag}${attrs}>${renderInner(node, scope, slotContent, context)}</${tag}>`;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Render one component instance met inside a definition, with everything the page walk gives a
|
|
1758
|
+
* page-level instance — the expansion is the same in both places, so the pieces are shared.
|
|
1759
|
+
*
|
|
1760
|
+
* - Props: `props.*` attributes lifted, then `$props`; a template value resolves against the PARENT's
|
|
1761
|
+
* scope, which is what `resolveDocTemplates` does for a page-level instance before the page walk
|
|
1762
|
+
* reaches it.
|
|
1763
|
+
* - Slot content: the instance's own children, rendered in the parent's scope and with the parent's
|
|
1764
|
+
* slot content, so a `<slot>` written among them passes the grandparent's through.
|
|
1765
|
+
* - Shadow mode: a declarative shadow root with the stylesheet link inside it (spec.md §16.6).
|
|
1766
|
+
* - Host style: the definition's template-string entries resolved against the INSTANCE, written on
|
|
1767
|
+
* the element — the page walk's `resolvedStyle` in inline form, since there is no page stylesheet
|
|
1768
|
+
* to append a class rule to from here.
|
|
1769
|
+
* - `data-jx-props` when the instance is not static, so the props survive the element's re-render on
|
|
1770
|
+
* upgrade (compiler.md §4.4); `data-jx-static` / `data-jx-prerendered` by the same predicate.
|
|
1771
|
+
*
|
|
1772
|
+
* Termination: a frame equal to one already on the path — same tag, same props — is the instance
|
|
1773
|
+
* rendering itself, and a path at the cap is recursion that never settled. Both are the author's
|
|
1774
|
+
* error and both name the chain, because "Maximum call stack size exceeded" names nothing.
|
|
1775
|
+
*
|
|
1776
|
+
* @param {JxElement | JxMutableNode} node - The instance as written in the parent's tree
|
|
1777
|
+
* @param {JxElement} def - The registered definition for its tag
|
|
1778
|
+
* @param {Record<string, unknown> | null} scope - The parent's scope
|
|
1779
|
+
* @param {string | null} slotContent - The parent's slot content
|
|
1780
|
+
* @param {ComponentPrerenderContext} context
|
|
1781
|
+
* @returns {string}
|
|
1782
|
+
* @docs framework/build
|
|
1783
|
+
*/
|
|
1784
|
+
function renderComponentInstance(
|
|
1785
|
+
node: JxElement | JxMutableNode,
|
|
1786
|
+
def: JxElement,
|
|
1787
|
+
scope: Record<string, unknown> | null,
|
|
1788
|
+
slotContent: string | null,
|
|
1789
|
+
context: ComponentPrerenderContext,
|
|
1790
|
+
): string {
|
|
1791
|
+
const tag = def.tagName as string;
|
|
1792
|
+
const { lifted, rest: attributes } = liftPropsAttributes(node.attributes);
|
|
1793
|
+
const written = { ...lifted, ...node.$props };
|
|
1794
|
+
const props: Record<string, JsonValue> = {};
|
|
1795
|
+
for (const [key, value] of Object.entries(written)) {
|
|
1796
|
+
props[key] = (resolveStaticValue(value, scope) as JsonValue | null) ?? value;
|
|
1797
|
+
}
|
|
1798
|
+
const hasProps = Object.keys(props).length > 0;
|
|
1799
|
+
|
|
1800
|
+
const path = context.path ?? [];
|
|
1801
|
+
const frame: PrerenderFrame = { key: JSON.stringify(props), tag };
|
|
1802
|
+
const chain = [...path.map((f) => f.tag), tag].join(" → ");
|
|
1803
|
+
if (path.some((f) => f.tag === frame.tag && f.key === frame.key)) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`Component <${tag}> renders itself: ${chain}. A component cannot appear inside its own ` +
|
|
1806
|
+
`definition with the same props — the expansion would never end.`,
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
if (path.length >= MAX_COMPONENT_NESTING) {
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`Component nesting exceeds ${MAX_COMPONENT_NESTING} levels: ${chain}. A component that ` +
|
|
1812
|
+
`renders itself with changing props needs a case that stops.`,
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
const nested: ComponentPrerenderContext = { ...context, path: [...path, frame] };
|
|
1816
|
+
|
|
1817
|
+
// The instance's children are the parent's nodes: parent scope, parent slot, parent path.
|
|
1818
|
+
const slot =
|
|
1819
|
+
Array.isArray(node.children) && node.children.length > 0
|
|
1820
|
+
? node.children
|
|
1821
|
+
.map((c: JxElement | JxMutableNode | string) =>
|
|
1822
|
+
renderStaticNode(c, scope, slotContent, context),
|
|
1823
|
+
)
|
|
1824
|
+
.join("\n")
|
|
1825
|
+
: null;
|
|
1826
|
+
|
|
1827
|
+
const shadow = resolveShadowMode(def, context.defaults);
|
|
1828
|
+
const isStatic = isComponentFullyStatic(def);
|
|
1829
|
+
const instanceScope = buildInstanceScope(def, hasProps ? props : null);
|
|
1830
|
+
const inner = renderInner(def, instanceScope, shadow ? null : slot, nested);
|
|
1831
|
+
const innerHTML = shadow
|
|
1832
|
+
? `<template shadowrootmode="${shadow}">` +
|
|
1833
|
+
`<link rel="stylesheet" href="/components/${tag}.css">` +
|
|
1834
|
+
`${inner}</template>${slot ?? ""}`
|
|
1835
|
+
: inner;
|
|
1836
|
+
|
|
1837
|
+
const hostStyle = Object.entries(resolveHostStyle(def.style, instanceScope))
|
|
1838
|
+
.map(([prop, value]) => `${camelToKebab(prop)}: ${value}`)
|
|
1839
|
+
.join("; ");
|
|
1840
|
+
|
|
1841
|
+
// The element as the page walk would leave it: props consumed, children rendered, stamped. A
|
|
1842
|
+
// Copy, because `node` is a definition every page shares.
|
|
1843
|
+
const instance: JxElement = { ...(node as JxElement) };
|
|
1844
|
+
delete instance.$props;
|
|
1845
|
+
delete instance.children;
|
|
1846
|
+
instance.attributes =
|
|
1847
|
+
!isStatic && hasProps ? { ...attributes, "data-jx-props": JSON.stringify(props) } : attributes;
|
|
1848
|
+
if (isStatic) {
|
|
1849
|
+
instance.$static = true;
|
|
1850
|
+
} else {
|
|
1851
|
+
instance.$prerendered = true;
|
|
1852
|
+
}
|
|
1853
|
+
return `<${tag}${buildAttrs(instance, scope, hostStyle)}>${innerHTML}</${tag}>`;
|
|
1677
1854
|
}
|
|
1678
1855
|
|
|
1679
1856
|
/**
|
|
@@ -1683,33 +1860,17 @@ export function renderStaticNode(
|
|
|
1683
1860
|
* @param {Record<string, JsonValue> | null} [propsOverride] - Instance-specific prop values to
|
|
1684
1861
|
* merge into state
|
|
1685
1862
|
* @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
|
|
1863
|
+
* @param {ComponentPrerenderContext | null} [context] - The component registry; with one, an
|
|
1864
|
+
* instance of a registered component inside `doc` is expanded rather than emitted bare
|
|
1686
1865
|
* @returns {string} The pre-rendered innerHTML
|
|
1687
1866
|
*/
|
|
1688
1867
|
export function preRenderComponentHtml(
|
|
1689
1868
|
doc: JxElement,
|
|
1690
1869
|
propsOverride: Record<string, JsonValue> | null = null,
|
|
1691
1870
|
slotContent: string | null = null,
|
|
1871
|
+
context: ComponentPrerenderContext | null = null,
|
|
1692
1872
|
) {
|
|
1693
|
-
|
|
1694
|
-
if (propsOverride) {
|
|
1695
|
-
stateDefs = { ...stateDefs };
|
|
1696
|
-
for (const [key, value] of Object.entries(propsOverride)) {
|
|
1697
|
-
if (key in stateDefs) {
|
|
1698
|
-
const existing = stateDefs[key];
|
|
1699
|
-
stateDefs[key] =
|
|
1700
|
-
existing &&
|
|
1701
|
-
typeof existing === "object" &&
|
|
1702
|
-
!Array.isArray(existing) &&
|
|
1703
|
-
"default" in existing
|
|
1704
|
-
? { .../** @type {JxStateObject} */ existing, default: value }
|
|
1705
|
-
: (value as JxStateDefinition);
|
|
1706
|
-
} else {
|
|
1707
|
-
stateDefs[key] = value as JxStateDefinition;
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
const scope = buildInitialScope(stateDefs, null);
|
|
1712
|
-
return renderInner(doc, scope, slotContent);
|
|
1873
|
+
return renderInner(doc, buildInstanceScope(doc, propsOverride), slotContent, context);
|
|
1713
1874
|
}
|
|
1714
1875
|
|
|
1715
1876
|
/**
|
|
@@ -1808,8 +1969,21 @@ const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
|
|
|
1808
1969
|
* @returns {string}
|
|
1809
1970
|
*/
|
|
1810
1971
|
function resolveSelector(prop: string, scope: string): string {
|
|
1972
|
+
/* Member by member, for the reason css.ts's `resolveNestedSelector` does it: a key may be a
|
|
1973
|
+
SELECTOR LIST, and splicing one as a single string spliced only its first `&`. `"& .a, & .b"`
|
|
1974
|
+
came out as `sty-card .a, & .b`, and a raw `&` in a built stylesheet is not a nesting selector
|
|
1975
|
+
at all — the browser discards the list and the component silently loses those rules. This is
|
|
1976
|
+
the one selector path that does not go through the shared builder, because `:host` has to be
|
|
1977
|
+
translated before the scope is applied, so it needed the same fix separately. */
|
|
1978
|
+
return splitSelectorList(prop)
|
|
1979
|
+
.map((member) => resolveSelectorMember(member, scope))
|
|
1980
|
+
.join(", ");
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** One member of {@link resolveSelector}'s list against the scope. */
|
|
1984
|
+
function resolveSelectorMember(prop: string, scope: string): string {
|
|
1811
1985
|
if (prop.startsWith("&")) {
|
|
1812
|
-
return prop.
|
|
1986
|
+
return prop.replaceAll("&", scope);
|
|
1813
1987
|
}
|
|
1814
1988
|
if (prop.startsWith(":host")) {
|
|
1815
1989
|
const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
|
|
@@ -1842,41 +2016,33 @@ export function buildComponentCSS(
|
|
|
1842
2016
|
const scope = styleScopePrefix(tagName, shadow);
|
|
1843
2017
|
|
|
1844
2018
|
if (styleDef && typeof styleDef === "object") {
|
|
1845
|
-
|
|
2019
|
+
/* The top level is walked here rather than handed straight to the builder, because ONE of its
|
|
2020
|
+
keys does not mean what `resolveNestedSelector` would make of it: `:host` and `:host(.foo)`
|
|
2021
|
+
are TRANSLATED per {@link resolveSelector} so a style object means the same thing in both
|
|
2022
|
+
modes. Everything below the top level is ordinary nesting, so each block goes to the builder
|
|
2023
|
+
whole — which is also what gives a component's nested selectors the recursion they never had
|
|
2024
|
+
(this used to emit exactly one level and drop anything under it). */
|
|
2025
|
+
const own: JxStyle = {};
|
|
2026
|
+
const blocks: [string, JxStyle][] = [];
|
|
1846
2027
|
for (const [prop, value] of Object.entries(styleDef)) {
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
prop
|
|
1853
|
-
) {
|
|
1854
|
-
|
|
2028
|
+
/* `isRef` first, and it is the whole point: an object carrying `$ref` is a reactive VALUE,
|
|
2029
|
+
not a nested block (spec.md §9.1). Sorted into `blocks` it matched neither branch below —
|
|
2030
|
+
not an at-rule, not a nested selector — and was dropped without reaching the builder at
|
|
2031
|
+
all, so the one emitter that could have reported it never saw it. */
|
|
2032
|
+
if (isRef(value)) {
|
|
2033
|
+
own[prop] = value as never;
|
|
2034
|
+
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2035
|
+
blocks.push([prop, value]);
|
|
2036
|
+
} else if (!prop.startsWith("@") && !isNestedSelectorKey(prop)) {
|
|
2037
|
+
own[prop] = value;
|
|
1855
2038
|
}
|
|
1856
|
-
if (value === null || typeof value === "object") {
|
|
1857
|
-
continue;
|
|
1858
|
-
}
|
|
1859
|
-
if (typeof value === "string" && isTemplateString(value)) {
|
|
1860
|
-
continue;
|
|
1861
|
-
}
|
|
1862
|
-
decls.push(` ${camelToKebab(prop)}: ${value};`);
|
|
1863
|
-
}
|
|
1864
|
-
if (decls.length > 0) {
|
|
1865
|
-
rules.push(`${scope} {\n${decls.join("\n")}\n}`);
|
|
1866
2039
|
}
|
|
1867
|
-
|
|
1868
|
-
for (const [prop, val] of
|
|
2040
|
+
pushStyleRules(rules, own, scope, mediaQueries);
|
|
2041
|
+
for (const [prop, val] of blocks) {
|
|
1869
2042
|
if (prop.startsWith("@")) {
|
|
1870
|
-
|
|
1871
|
-
} else if (
|
|
1872
|
-
prop
|
|
1873
|
-
prop.startsWith(".") ||
|
|
1874
|
-
prop.startsWith("&") ||
|
|
1875
|
-
prop.startsWith("[")
|
|
1876
|
-
) {
|
|
1877
|
-
rules.push(
|
|
1878
|
-
`${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
|
|
1879
|
-
);
|
|
2043
|
+
pushStyleRules(rules, { [prop]: val }, scope, mediaQueries);
|
|
2044
|
+
} else if (isNestedSelectorKey(prop)) {
|
|
2045
|
+
pushStyleRules(rules, val, resolveSelector(prop, scope), mediaQueries);
|
|
1880
2046
|
}
|
|
1881
2047
|
}
|
|
1882
2048
|
}
|