@jxsuite/compiler 3.1.0 → 4.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/src/shared.ts CHANGED
@@ -11,12 +11,15 @@ import {
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
12
  RESERVED_KEYS,
13
13
  booleanAttrValue,
14
+ buildStyleRules,
15
+ enumeratedAttrNames,
16
+ isDeclarationAtRule,
17
+ isKeyframesAtRule,
18
+ isNestedSelectorKey,
14
19
  camelToKebab,
15
20
  isSingleExpression,
16
21
  pureSchemeOf,
17
- resolveAtQuery,
18
- schemeSelectors,
19
- toCSSText,
22
+ splitSelectorList,
20
23
  } from "@jxsuite/runtime";
21
24
  import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
22
25
  import { runStatements } from "@jxsuite/runtime/statements";
@@ -37,7 +40,7 @@ import {
37
40
  paramNames,
38
41
  tagNameCandidates,
39
42
  } from "@jxsuite/schema/guards";
40
- import { styleScopePrefix } from "./shadow.ts";
43
+ import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
41
44
  import type { ShadowMode } from "./shadow.ts";
42
45
  import type { ExpressionNode } from "@jxsuite/runtime/expression";
43
46
  import { readPath } from "@jxsuite/runtime/pointer";
@@ -50,6 +53,7 @@ import type {
50
53
  JxStateDefinition,
51
54
  JxStateObject,
52
55
  JxStyle,
56
+ ProjectConfig,
53
57
  } from "@jxsuite/schema/types";
54
58
 
55
59
  // Re-export runtime utilities used by submodules
@@ -849,13 +853,59 @@ export function cloneValue(value: unknown) {
849
853
 
850
854
  // ─── HTML building ────────────────────────────────────────────────────────────
851
855
 
856
+ /**
857
+ * The inline `style` declarations of one node: its template-string entries, resolved against
858
+ * `scope`. Everything else in a `style` object is a stylesheet rule (`collectStyles` gives it a
859
+ * class handle), so only a per-instance value is written on the element.
860
+ *
861
+ * @param {JxStyle | null | undefined} style
862
+ * @param {Record<string, unknown> | null} scope
863
+ * @returns {string} `prop: value; prop: value`, or "" when nothing resolves
864
+ */
865
+ export function inlineStyleDeclarations(
866
+ style: JxStyle | null | undefined,
867
+ scope: Record<string, unknown> | null,
868
+ ): string {
869
+ if (!style || !scope) {
870
+ return "";
871
+ }
872
+ return Object.entries(style)
873
+ .filter(
874
+ ([k, v]) =>
875
+ !k.startsWith(":") &&
876
+ !k.startsWith(".") &&
877
+ !k.startsWith("&") &&
878
+ !k.startsWith("[") &&
879
+ !k.startsWith("@") &&
880
+ v !== null &&
881
+ typeof v !== "object" &&
882
+ typeof v === "string" &&
883
+ isTemplateString(v),
884
+ )
885
+ .map(([k, v]) => {
886
+ const value = resolveStaticValue(v, scope);
887
+ return value == null ? null : `${camelToKebab(k)}: ${value}`;
888
+ })
889
+ .filter(Boolean)
890
+ .join("; ");
891
+ }
892
+
852
893
  /**
853
894
  * Build an HTML attribute string from a static element definition.
854
895
  *
855
896
  * @param {JxElement | JxMutableNode} def
856
- * @param {Record<string, unknown>} scope @returns {string}
897
+ * @param {Record<string, unknown>} scope
898
+ * @param {string} [hostStyle] - Declarations resolved by the caller against ANOTHER scope, folded
899
+ * into the same `style` attribute after the node's own. A nested component instance is the one
900
+ * caller: its own `style` resolves against the parent's scope, while the definition's host style
901
+ * resolves against the instance's, and an element cannot carry two `style` attributes.
902
+ * @returns {string}
857
903
  */
858
- export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string, unknown> | null) {
904
+ export function buildAttrs(
905
+ def: JxElement | JxMutableNode,
906
+ scope: Record<string, unknown> | null,
907
+ hostStyle = "",
908
+ ) {
859
909
  let out = "";
860
910
 
861
911
  const id = resolveStaticValue(def.id, scope);
@@ -888,29 +938,20 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
888
938
  out += ` dir="${escapeHtml(String(dir))}"`;
889
939
  }
890
940
 
891
- if (def.style && scope) {
892
- const inline = Object.entries(def.style)
893
- .filter(
894
- ([k, v]) =>
895
- !k.startsWith(":") &&
896
- !k.startsWith(".") &&
897
- !k.startsWith("&") &&
898
- !k.startsWith("[") &&
899
- !k.startsWith("@") &&
900
- v !== null &&
901
- typeof v !== "object" &&
902
- typeof v === "string" &&
903
- isTemplateString(v),
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
- }
941
+ // The host declarations go last: in one `style` attribute the later declaration of a property
942
+ // Wins, and the page walk gives the definition's resolved host style the same precedence over an
943
+ // Instance's own (`{ ...node.style, ...resolvedStyle }`), so the two paths agree.
944
+ const inline = [inlineStyleDeclarations(def.style, scope), hostStyle].filter(Boolean).join("; ");
945
+ if (inline) {
946
+ /*
947
+ * Escaped like every other attribute. Both halves carry values a template string resolved
948
+ * from data — a `background-image: url("…")` host style reads its target from a prop — and a
949
+ * `"` in that data would otherwise end the attribute and hand the rest of the value to the
950
+ * parser as attributes. The HTML parser decodes `&quot;` before the CSS parser sees the
951
+ * declaration, so `url(&quot;…&quot;)` renders exactly as `url("…")`, and `rewriteHtmlBase`
952
+ * already reads the entity-wrapped form back when it re-roots a `url()`.
953
+ */
954
+ out += ` style="${escapeHtml(inline)}"`;
914
955
  }
915
956
  if (def.attributes) {
916
957
  for (const [k, v] of Object.entries(def.attributes)) {
@@ -1040,76 +1081,110 @@ export function colorSchemePrePaintScript(): string {
1040
1081
  }
1041
1082
 
1042
1083
  /**
1043
- * Resolve an `@`-prefixed style key into an emit function that pushes conditional rules. Pure
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.
1084
+ * Push the rules one style object becomes, scoped to `selector`.
1047
1085
  *
1048
- * @param {string} atKey
1049
- * @param {Record<string, string>} mediaQueries
1050
- * @param {string[]} rules
1051
- * @returns {(selector: string, props: string) => void}
1052
- * @docs framework/concepts/color-schemes
1053
- */
1054
- function conditionalRuleEmitter(
1055
- atKey: string,
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).
1086
+ * The single door from the compiler to `buildStyleRules`, which is now the ONE definition of what a
1087
+ * Jx style object means as CSS — shared with the DOM runtime and the site-style builder. Three
1088
+ * emitters used to answer that question separately, and each dropped a different combination: this
1089
+ * one emitted `selector → @media` but only ever ONE selector level inside an at-rule group, so
1090
+ * `@media → .card → :hover` was silently lost, while the runtime dropped the opposite order.
1091
+ *
1092
+ * Template-string and `$ref` values are omitted rather than resolved: a compiled page's CSS is
1093
+ * static by construction, and the runtime writes those declarations at render time.
1078
1094
  *
1079
1095
  * @param {string[]} rules
1080
- * @param {string} atKey
1096
+ * @param {JxStyle} style
1097
+ * @param {string | null} selector - The scope, or null for a bare declaration-body at-rule
1081
1098
  * @param {Record<string, string>} mediaQueries
1082
- * @param {string} selector
1083
- * @param {Record<string, unknown>} obj
1084
1099
  */
1085
- function pushConditionalRule(
1100
+ function pushStyleRules(
1086
1101
  rules: string[],
1087
- atKey: string,
1102
+ style: JxStyle,
1103
+ selector: string | null,
1088
1104
  mediaQueries: Record<string, string>,
1089
- selector: string,
1090
- obj: Record<string, unknown>,
1091
1105
  ) {
1092
- if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
1093
- return;
1094
- }
1095
- const emit = conditionalRuleEmitter(atKey, mediaQueries, rules);
1096
- emit(selector, toCSSText(obj));
1097
- for (const [sel, sub] of Object.entries(obj)) {
1098
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
1099
- continue;
1100
- }
1101
- if (sel.startsWith("@")) {
1102
- continue;
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));
1106
+ for (const rule of buildStyleRules(style, {
1107
+ mediaQueries,
1108
+ /* Records what a STATIC build drops, and returns `null` so it drops exactly as before — the
1109
+ emitted bytes are unchanged. A reactive declaration is a runtime declaration: it resolves
1110
+ against a live scope, which a compiled page has only where the runtime is present, so a
1111
+ static emitter has always dropped it. Silently, which is the defect: a document is correct
1112
+ in Studio and simply unstyled in the built page, with nothing said. */
1113
+ resolveValue: (property, value) => {
1114
+ const source = typeof value === "string" ? value : (value as { $ref: string }).$ref;
1115
+ _droppedReactive.push({ property, selector, source });
1116
+ return null;
1117
+ },
1118
+ scope: selector,
1119
+ })) {
1120
+ rules.push(rule.text);
1110
1121
  }
1111
1122
  }
1112
1123
 
1124
+ /** One entry per reactive declaration a static build dropped, since the last drain. */
1125
+ const _droppedReactive: { property: string; selector: string | null; source: string }[] = [];
1126
+
1127
+ /**
1128
+ * Take the reactive declarations dropped since the last call, as warning lines, and clear them.
1129
+ *
1130
+ * A build drains this once and reports what it finds. Draining is what keeps two builds in one
1131
+ * process from inheriting each other's, and what keeps the list from growing without bound.
1132
+ *
1133
+ * @returns {string[]} One line per dropped declaration, naming the property and where it was.
1134
+ */
1135
+ export function takeDroppedReactiveStyles(): string[] {
1136
+ const lines = _droppedReactive.map(
1137
+ ({ property, selector, source }) =>
1138
+ `A static build drops the reactive style declaration \`${property}: ${source}\`` +
1139
+ `${selector ? ` on \`${selector}\`` : ""}. It resolves against a live scope, which a ` +
1140
+ "built page has only where the runtime is present, so the declaration is absent from the " +
1141
+ "page. Give it a static value, or move it to an element the runtime renders.",
1142
+ );
1143
+ _droppedReactive.length = 0;
1144
+ return lines;
1145
+ }
1146
+
1147
+ /**
1148
+ * The name of the boolean-attribute helper the generated modules call.
1149
+ *
1150
+ * Prefixed and improbable because it lands in the same scope as the author's own bindings.
1151
+ */
1152
+ export const ATTR_HELPER = "__jxAttrText";
1153
+
1154
+ /**
1155
+ * The source of that helper, to be emitted into a generated module's preamble.
1156
+ *
1157
+ * **Why an inlined copy rather than an import.** `booleanAttrValue` is the single decision about
1158
+ * which family an attribute belongs to, and `buildAttrs` here calls it directly — but the element
1159
+ * and client targets emit standalone ES modules that a built site loads with `lit-html` and
1160
+ * `@vue/reactivity` and nothing else. Importing `@jxsuite/runtime` into every compiled component to
1161
+ * reach four lines would put the whole renderer on the critical path of pages that never use it.
1162
+ *
1163
+ * So the rule is inlined, and the enumerated names are SERIALIZED from `enumeratedAttrNames()`
1164
+ * rather than retyped. `compile-element.test.ts` asserts the emitted literal still equals that
1165
+ * export, which is what keeps this from becoming a fourth, drifting definition — the exact failure
1166
+ * the essay above `buildAttrs` exists to prevent, and the one these two targets shipped for months:
1167
+ * they stringified booleans, so a component's `open: true` compiled to `open="true"` and a bound
1168
+ * `open` that flipped false wrote `open="false"` — an OPEN `<details>` the author had closed.
1169
+ *
1170
+ * Returns the text to write, or `null` to mean "remove the attribute"; each caller adapts that null
1171
+ * to its own idiom (`nothing` in a lit template, `removeAttribute` in the hydration script).
1172
+ *
1173
+ * @returns {string} One `function` declaration, ready to push into a module's line list.
1174
+ */
1175
+ export function attrHelperSource(): string {
1176
+ const names = JSON.stringify(enumeratedAttrNames());
1177
+ return (
1178
+ `const __jxEnumAttrs = new Set(${names});\n` +
1179
+ `function ${ATTR_HELPER}(n, v) {\n` +
1180
+ ` if (typeof v !== 'boolean') return v;\n` +
1181
+ ` const l = n.toLowerCase();\n` +
1182
+ ` if (l.startsWith('aria-') || __jxEnumAttrs.has(l)) return String(v);\n` +
1183
+ ` return v ? '' : null;\n` +
1184
+ `}`
1185
+ );
1186
+ }
1187
+
1113
1188
  /**
1114
1189
  * Walk the entire document tree and collect all static nested CSS rules.
1115
1190
  *
@@ -1129,42 +1204,24 @@ export function compileStyles(
1129
1204
  // Everything else on body. Project-level style is implicitly :root, so a
1130
1205
  // Flat object like { "--bg": "#000", "margin": "0" } is the expected format.
1131
1206
  if (projectStyle && typeof projectStyle === "object") {
1132
- const emitProjectRules = (selector: string, obj: Record<string, unknown>) => {
1133
- const props = toCSSText(obj);
1134
- if (props) {
1135
- rules.push(`${selector} { ${props} }`);
1136
- }
1137
- for (const [key, val] of Object.entries(obj)) {
1138
- if (val === null || typeof val !== "object" || Array.isArray(val)) {
1139
- continue;
1140
- }
1207
+ /* The project's own style is implicitly `:root`, but only half of it: a custom property has to
1208
+ land on `:root` so a forced-scheme selector can override it, while an ordinary property is
1209
+ page chrome and belongs on `body`. That split is the one thing the shared builder cannot do
1210
+ for us, so it is done here and each half is handed over whole. */
1211
+ const rootProps: JxStyle = {};
1212
+ const bodyProps: JxStyle = {};
1213
+ const selectorBlocks: [string, JxStyle][] = [];
1214
+ const conditionalBlocks: [string, JxStyle][] = [];
1215
+ for (const [key, val] of Object.entries(projectStyle)) {
1216
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
1141
1217
  if (key.startsWith("@")) {
1142
- pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
1143
- continue;
1218
+ conditionalBlocks.push([key, val]);
1219
+ } else if (!key.startsWith("--")) {
1220
+ selectorBlocks.push([key, val]);
1144
1221
  }
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
1222
  continue;
1166
1223
  }
1167
- if (val !== null && typeof val === "object" && !Array.isArray(val)) {
1224
+ if (isNestedSelectorKey(key) || key.startsWith("@")) {
1168
1225
  continue;
1169
1226
  }
1170
1227
  if (key.startsWith("--")) {
@@ -1173,58 +1230,49 @@ export function compileStyles(
1173
1230
  bodyProps[key] = val;
1174
1231
  }
1175
1232
  }
1233
+
1176
1234
  // 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} }`);
1235
+ pushStyleRules(rules, rootProps, ":root", mediaQueries);
1236
+ pushStyleRules(rules, bodyProps, "body", mediaQueries);
1237
+
1238
+ for (const [key, val] of selectorBlocks) {
1239
+ // A top-level selector key IS the selector — `.card` styles `.card`, not `:root .card`.
1240
+ pushStyleRules(rules, val, key, mediaQueries);
1184
1241
  }
1185
1242
 
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;
1243
+ for (const [key, val] of conditionalBlocks) {
1244
+ /* An unscoped at-rule has no selector to split across, and its name is global —
1245
+ `@font-face` at project level is one block, not one per target. `@keyframes` is here for
1246
+ a stronger reason than tidiness: splitting it would emit one same-named block per stop,
1247
+ and the last definition of a name replaces every earlier one, so the animation would keep
1248
+ only its final stop. */
1249
+ if (isDeclarationAtRule(key) || isKeyframesAtRule(key)) {
1250
+ pushStyleRules(rules, { [key]: val }, null, mediaQueries);
1251
+ continue;
1252
+ }
1253
+ // Conditional block at project top level: custom properties override :root, direct
1254
+ // Properties override body, selector-keyed sub-objects their own selector.
1255
+ const condRoot: JxStyle = {};
1256
+ const condBody: JxStyle = {};
1257
+ const condSubs: [string, JxStyle][] = [];
1258
+ for (const [k, v] of Object.entries(val)) {
1259
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
1260
+ if (!k.startsWith("@")) {
1261
+ condSubs.push([k, v]);
1220
1262
  }
1263
+ continue;
1221
1264
  }
1222
- emit(":root", toCSSText(condRoot));
1223
- emit("body", toCSSText(condBody));
1224
- for (const [sel, sub] of condSubs) {
1225
- emit(sel, toCSSText(sub));
1265
+ if (k.startsWith("--")) {
1266
+ condRoot[k] = v;
1267
+ } else {
1268
+ condBody[k] = v;
1226
1269
  }
1227
1270
  }
1271
+ pushStyleRules(rules, { [key]: condRoot }, ":root", mediaQueries);
1272
+ pushStyleRules(rules, { [key]: condBody }, "body", mediaQueries);
1273
+ for (const [sel, sub] of condSubs) {
1274
+ pushStyleRules(rules, { [key]: sub }, sel, mediaQueries);
1275
+ }
1228
1276
  }
1229
1277
  }
1230
1278
 
@@ -1261,41 +1309,6 @@ export function escapeStyleText(css: string): string {
1261
1309
  return css.replaceAll(/<\/(?=style)/gi, String.raw`<\/`);
1262
1310
  }
1263
1311
 
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
1312
  /**
1300
1313
  * @param {JxElement | JxMutableNode | string} def
1301
1314
  * @param {string[]} rules
@@ -1333,49 +1346,7 @@ export function collectStyles(
1333
1346
  : tagSelector;
1334
1347
 
1335
1348
  if (def.style) {
1336
- const baseDecls = [];
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
- }
1349
+ pushStyleRules(rules, def.style, selector, mediaQueries);
1379
1350
  }
1380
1351
 
1381
1352
  if (Array.isArray(def.children)) {
@@ -1565,6 +1536,148 @@ export function resolveStaticTagName(
1565
1536
  return expression.cases[String(key)] ?? expression.default;
1566
1537
  }
1567
1538
 
1539
+ /**
1540
+ * What the static renderer needs to expand a component instance it meets INSIDE a definition.
1541
+ *
1542
+ * Without one, a custom-element tag in a component's `children` is an element like any other: the
1543
+ * generic path emits `<inner-chip></inner-chip>` with the instance's own (empty) children, no
1544
+ * content and no host style — and because the parent still looks static, no module ever loads to
1545
+ * fill it (issue #286). The page walk in `site-build` has always expanded the instances it meets in
1546
+ * the PAGE tree; this is the same registry, handed down so the definition walk expands too.
1547
+ *
1548
+ * `path` is the chain of instances currently being expanded, outermost first. It is what makes the
1549
+ * recursion terminate: a component graph is bounded, but a definition may name itself.
1550
+ */
1551
+ export interface ComponentPrerenderContext {
1552
+ /** Tag name → parsed definition, the registry the page walk expands from. */
1553
+ componentDefs: ReadonlyMap<string, JxElement>;
1554
+ /** Project defaults, read for `defaults.shadow` (spec.md §16.6). */
1555
+ defaults?: ProjectConfig["defaults"] | undefined;
1556
+ /** The instances being expanded, outermost first; empty at a page-level instance. */
1557
+ path?: readonly PrerenderFrame[] | undefined;
1558
+ }
1559
+
1560
+ /** One instance on the expansion path: its tag, and its resolved props as an identity. */
1561
+ interface PrerenderFrame {
1562
+ tag: string;
1563
+ /** `JSON.stringify` of the resolved props — two frames with equal keys are the same instance. */
1564
+ key: string;
1565
+ }
1566
+
1567
+ /**
1568
+ * How deep component nesting may go before the build refuses to continue.
1569
+ *
1570
+ * The cycle check below catches a definition that renders ITSELF — same tag, same props — which is
1571
+ * the only shape that can never terminate. A self-reference whose props change at every level
1572
+ * (`depth: "${state.depth + 1}"`) is data-driven recursion, legitimate when a `$switch` bottoms it
1573
+ * out and unbounded when the author forgot to. The seen-set cannot tell those apart, so this cap is
1574
+ * what turns the second into a diagnostic instead of a stack overflow. Thirty-two is far beyond any
1575
+ * component library's real depth and far below the call stack's.
1576
+ */
1577
+ export const MAX_COMPONENT_NESTING = 32;
1578
+
1579
+ /**
1580
+ * Lift literal `props.*` attribute keys off an instance's attributes.
1581
+ *
1582
+ * JSON-authored instances pass props as literal `props.*` attribute keys (markdown directives are
1583
+ * normalized to `$props` by the parser's `expandDotPaths`, but JSON is parsed verbatim). They are
1584
+ * lifted so the prerender sees them, and stripped so they do not leak into the emitted HTML. Values
1585
+ * stay raw strings — no coercion, matching the markdown path and the runtime's `$props` semantics.
1586
+ * Pure: the page walk writes the result back onto its node, the definition walk must not touch a
1587
+ * definition every page shares.
1588
+ *
1589
+ * @param {Record<string, JxAttributeValue> | undefined} attributes
1590
+ * @returns {{ lifted: Record<string, JsonValue> | null; rest: Record<string, JxAttributeValue> }}
1591
+ * The lifted props (null when there were none) and the attributes that remain
1592
+ */
1593
+ export function liftPropsAttributes(attributes: JxElement["attributes"]): {
1594
+ lifted: Record<string, JsonValue> | null;
1595
+ rest: NonNullable<JxElement["attributes"]>;
1596
+ } {
1597
+ let lifted: Record<string, JsonValue> | null = null;
1598
+ const rest: NonNullable<JxElement["attributes"]> = {};
1599
+ for (const [key, value] of Object.entries(attributes ?? {})) {
1600
+ if (key.startsWith("props.") && key.length > "props.".length) {
1601
+ lifted ??= {};
1602
+ // JxAttributeValue is JSON-representable (primitives or a $ref object), so the narrowing to
1603
+ // JsonValue is sound.
1604
+ lifted[key.slice("props.".length)] = value as JsonValue;
1605
+ } else {
1606
+ rest[key] = value;
1607
+ }
1608
+ }
1609
+ return { lifted, rest };
1610
+ }
1611
+
1612
+ /**
1613
+ * The build-time scope of one component instance: the definition's `state` with the instance's
1614
+ * props laid over it.
1615
+ *
1616
+ * A prop for an entry declared as an object keeps the declaration and replaces its `default`, so a
1617
+ * typed entry stays typed; a bare or undeclared entry is the value itself. Shared by the inner
1618
+ * render and the host-style resolution so both read the same instance — they used to build the
1619
+ * scope separately, in two slightly different ways.
1620
+ *
1621
+ * @param {JxElement} doc - Component definition
1622
+ * @param {Record<string, JsonValue> | null} props - Instance-specific prop values
1623
+ * @returns {Record<string, unknown>}
1624
+ */
1625
+ export function buildInstanceScope(
1626
+ doc: JxElement,
1627
+ props: Record<string, JsonValue> | null,
1628
+ ): Record<string, unknown> {
1629
+ let stateDefs: Record<string, JxStateDefinition> = doc.state ?? {};
1630
+ if (props) {
1631
+ stateDefs = { ...stateDefs };
1632
+ for (const [key, value] of Object.entries(props)) {
1633
+ if (key in stateDefs) {
1634
+ const existing = stateDefs[key];
1635
+ stateDefs[key] =
1636
+ existing &&
1637
+ typeof existing === "object" &&
1638
+ !Array.isArray(existing) &&
1639
+ "default" in existing
1640
+ ? { .../** @type {JxStateObject} */ existing, default: value }
1641
+ : (value as JxStateDefinition);
1642
+ } else {
1643
+ stateDefs[key] = value as JxStateDefinition;
1644
+ }
1645
+ }
1646
+ }
1647
+ return buildInitialScope(stateDefs, null);
1648
+ }
1649
+
1650
+ /**
1651
+ * The definition's host `style` entries that depend on the instance, resolved against it.
1652
+ *
1653
+ * Only template-string values are per-instance (`maskImage: "${'var(--icon-' + state.name +
1654
+ * ')'}"`); a literal declaration is in the component's stylesheet already. This is the resolution
1655
+ * the page walk performs for a page-level instance, extracted so a nested instance gets the same
1656
+ * one.
1657
+ *
1658
+ * @param {JxStyle | null | undefined} style - The definition's `style`
1659
+ * @param {Record<string, unknown>} scope - The instance's scope (`buildInstanceScope`)
1660
+ * @returns {Record<string, unknown>} Resolved values by (camelCase) property
1661
+ */
1662
+ export function resolveHostStyle(
1663
+ style: JxStyle | null | undefined,
1664
+ scope: Record<string, unknown>,
1665
+ ): Record<string, unknown> {
1666
+ const resolved: Record<string, unknown> = {};
1667
+ if (!style) {
1668
+ return resolved;
1669
+ }
1670
+ for (const [prop, value] of Object.entries(style)) {
1671
+ if (typeof value === "string" && isTemplateString(value)) {
1672
+ const val = resolveStaticValue(value, scope);
1673
+ if (val != null) {
1674
+ resolved[prop] = val;
1675
+ }
1676
+ }
1677
+ }
1678
+ return resolved;
1679
+ }
1680
+
1568
1681
  /**
1569
1682
  * The content of one node: `textContent`, else `innerHTML`, else rendered `children`.
1570
1683
  *
@@ -1576,12 +1689,14 @@ export function resolveStaticTagName(
1576
1689
  * @param {JxElement | JxMutableNode} node
1577
1690
  * @param {Record<string, unknown> | null} scope
1578
1691
  * @param {string | null} slotContent
1692
+ * @param {ComponentPrerenderContext | null} context
1579
1693
  * @returns {string}
1580
1694
  */
1581
1695
  function renderInner(
1582
1696
  node: JxElement | JxMutableNode,
1583
1697
  scope: Record<string, unknown> | null,
1584
1698
  slotContent: string | null,
1699
+ context: ComponentPrerenderContext | null,
1585
1700
  ): string {
1586
1701
  if (node.textContent !== undefined) {
1587
1702
  const val = resolveStaticValue(node.textContent, scope);
@@ -1593,7 +1708,9 @@ function renderInner(
1593
1708
  }
1594
1709
  if (Array.isArray(node.children)) {
1595
1710
  return node.children
1596
- .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1711
+ .map((c: JxElement | JxMutableNode | string) =>
1712
+ renderStaticNode(c, scope, slotContent, context),
1713
+ )
1597
1714
  .join("\n");
1598
1715
  }
1599
1716
  return "";
@@ -1605,12 +1722,15 @@ function renderInner(
1605
1722
  * @param {JxElement | JxMutableNode | string} node
1606
1723
  * @param {Record<string, unknown>} scope
1607
1724
  * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
1725
+ * @param {ComponentPrerenderContext | null} [context] - The component registry, when a custom
1726
+ * element tag met here should be expanded rather than emitted bare
1608
1727
  * @returns {string}
1609
1728
  */
1610
1729
  export function renderStaticNode(
1611
1730
  node: JxElement | JxMutableNode | string,
1612
1731
  scope: Record<string, unknown> | null,
1613
1732
  slotContent: string | null = null,
1733
+ context: ComponentPrerenderContext | null = null,
1614
1734
  ): string {
1615
1735
  if (typeof node === "string") {
1616
1736
  if (isTemplateString(node) && scope) {
@@ -1625,7 +1745,7 @@ export function renderStaticNode(
1625
1745
  if (Array.isArray(node)) {
1626
1746
  return (node as (JxElement | JxMutableNode | string)[])
1627
1747
  .map((c: JxElement | JxMutableNode | string): string =>
1628
- renderStaticNode(c, scope, slotContent),
1748
+ renderStaticNode(c, scope, slotContent, context),
1629
1749
  )
1630
1750
  .join("\n");
1631
1751
  }
@@ -1651,7 +1771,7 @@ export function renderStaticNode(
1651
1771
  : (node.cases as Record<string, JxElement | string> | undefined)?.[String(key)];
1652
1772
  const inner =
1653
1773
  caseDef !== undefined && !isRefObject(caseDef)
1654
- ? renderStaticNode(caseDef, scope, slotContent)
1774
+ ? renderStaticNode(caseDef, scope, slotContent, context)
1655
1775
  : "";
1656
1776
  return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1657
1777
  }
@@ -1667,13 +1787,119 @@ export function renderStaticNode(
1667
1787
  return slotContent;
1668
1788
  }
1669
1789
 
1790
+ // A registered component: expanded exactly as the page walk expands one, not emitted bare.
1791
+ const def = context?.componentDefs.get(tag);
1792
+ if (def && context) {
1793
+ return renderComponentInstance(node, def, scope, slotContent, context);
1794
+ }
1795
+
1670
1796
  const attrs = buildAttrs(node, scope);
1671
1797
 
1672
1798
  if (SELF_CLOSING.has(tag)) {
1673
1799
  return `<${tag}${attrs}>`;
1674
1800
  }
1675
1801
 
1676
- return `<${tag}${attrs}>${renderInner(node, scope, slotContent)}</${tag}>`;
1802
+ return `<${tag}${attrs}>${renderInner(node, scope, slotContent, context)}</${tag}>`;
1803
+ }
1804
+
1805
+ /**
1806
+ * Render one component instance met inside a definition, with everything the page walk gives a
1807
+ * page-level instance — the expansion is the same in both places, so the pieces are shared.
1808
+ *
1809
+ * - Props: `props.*` attributes lifted, then `$props`; a template value resolves against the PARENT's
1810
+ * scope, which is what `resolveDocTemplates` does for a page-level instance before the page walk
1811
+ * reaches it.
1812
+ * - Slot content: the instance's own children, rendered in the parent's scope and with the parent's
1813
+ * slot content, so a `<slot>` written among them passes the grandparent's through.
1814
+ * - Shadow mode: a declarative shadow root with the stylesheet link inside it (spec.md §16.6).
1815
+ * - Host style: the definition's template-string entries resolved against the INSTANCE, written on
1816
+ * the element — the page walk's `resolvedStyle` in inline form, since there is no page stylesheet
1817
+ * to append a class rule to from here.
1818
+ * - `data-jx-props` when the instance is not static, so the props survive the element's re-render on
1819
+ * upgrade (compiler.md §4.4); `data-jx-static` / `data-jx-prerendered` by the same predicate.
1820
+ *
1821
+ * Termination: a frame equal to one already on the path — same tag, same props — is the instance
1822
+ * rendering itself, and a path at the cap is recursion that never settled. Both are the author's
1823
+ * error and both name the chain, because "Maximum call stack size exceeded" names nothing.
1824
+ *
1825
+ * @param {JxElement | JxMutableNode} node - The instance as written in the parent's tree
1826
+ * @param {JxElement} def - The registered definition for its tag
1827
+ * @param {Record<string, unknown> | null} scope - The parent's scope
1828
+ * @param {string | null} slotContent - The parent's slot content
1829
+ * @param {ComponentPrerenderContext} context
1830
+ * @returns {string}
1831
+ * @docs framework/build
1832
+ */
1833
+ function renderComponentInstance(
1834
+ node: JxElement | JxMutableNode,
1835
+ def: JxElement,
1836
+ scope: Record<string, unknown> | null,
1837
+ slotContent: string | null,
1838
+ context: ComponentPrerenderContext,
1839
+ ): string {
1840
+ const tag = def.tagName as string;
1841
+ const { lifted, rest: attributes } = liftPropsAttributes(node.attributes);
1842
+ const written = { ...lifted, ...node.$props };
1843
+ const props: Record<string, JsonValue> = {};
1844
+ for (const [key, value] of Object.entries(written)) {
1845
+ props[key] = (resolveStaticValue(value, scope) as JsonValue | null) ?? value;
1846
+ }
1847
+ const hasProps = Object.keys(props).length > 0;
1848
+
1849
+ const path = context.path ?? [];
1850
+ const frame: PrerenderFrame = { key: JSON.stringify(props), tag };
1851
+ const chain = [...path.map((f) => f.tag), tag].join(" → ");
1852
+ if (path.some((f) => f.tag === frame.tag && f.key === frame.key)) {
1853
+ throw new Error(
1854
+ `Component <${tag}> renders itself: ${chain}. A component cannot appear inside its own ` +
1855
+ `definition with the same props — the expansion would never end.`,
1856
+ );
1857
+ }
1858
+ if (path.length >= MAX_COMPONENT_NESTING) {
1859
+ throw new Error(
1860
+ `Component nesting exceeds ${MAX_COMPONENT_NESTING} levels: ${chain}. A component that ` +
1861
+ `renders itself with changing props needs a case that stops.`,
1862
+ );
1863
+ }
1864
+ const nested: ComponentPrerenderContext = { ...context, path: [...path, frame] };
1865
+
1866
+ // The instance's children are the parent's nodes: parent scope, parent slot, parent path.
1867
+ const slot =
1868
+ Array.isArray(node.children) && node.children.length > 0
1869
+ ? node.children
1870
+ .map((c: JxElement | JxMutableNode | string) =>
1871
+ renderStaticNode(c, scope, slotContent, context),
1872
+ )
1873
+ .join("\n")
1874
+ : null;
1875
+
1876
+ const shadow = resolveShadowMode(def, context.defaults);
1877
+ const isStatic = isComponentFullyStatic(def);
1878
+ const instanceScope = buildInstanceScope(def, hasProps ? props : null);
1879
+ const inner = renderInner(def, instanceScope, shadow ? null : slot, nested);
1880
+ const innerHTML = shadow
1881
+ ? `<template shadowrootmode="${shadow}">` +
1882
+ `<link rel="stylesheet" href="/components/${tag}.css">` +
1883
+ `${inner}</template>${slot ?? ""}`
1884
+ : inner;
1885
+
1886
+ const hostStyle = Object.entries(resolveHostStyle(def.style, instanceScope))
1887
+ .map(([prop, value]) => `${camelToKebab(prop)}: ${value}`)
1888
+ .join("; ");
1889
+
1890
+ // The element as the page walk would leave it: props consumed, children rendered, stamped. A
1891
+ // Copy, because `node` is a definition every page shares.
1892
+ const instance: JxElement = { ...(node as JxElement) };
1893
+ delete instance.$props;
1894
+ delete instance.children;
1895
+ instance.attributes =
1896
+ !isStatic && hasProps ? { ...attributes, "data-jx-props": JSON.stringify(props) } : attributes;
1897
+ if (isStatic) {
1898
+ instance.$static = true;
1899
+ } else {
1900
+ instance.$prerendered = true;
1901
+ }
1902
+ return `<${tag}${buildAttrs(instance, scope, hostStyle)}>${innerHTML}</${tag}>`;
1677
1903
  }
1678
1904
 
1679
1905
  /**
@@ -1683,33 +1909,17 @@ export function renderStaticNode(
1683
1909
  * @param {Record<string, JsonValue> | null} [propsOverride] - Instance-specific prop values to
1684
1910
  * merge into state
1685
1911
  * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
1912
+ * @param {ComponentPrerenderContext | null} [context] - The component registry; with one, an
1913
+ * instance of a registered component inside `doc` is expanded rather than emitted bare
1686
1914
  * @returns {string} The pre-rendered innerHTML
1687
1915
  */
1688
1916
  export function preRenderComponentHtml(
1689
1917
  doc: JxElement,
1690
1918
  propsOverride: Record<string, JsonValue> | null = null,
1691
1919
  slotContent: string | null = null,
1920
+ context: ComponentPrerenderContext | null = null,
1692
1921
  ) {
1693
- let stateDefs: Record<string, JxStateDefinition> = doc.state ?? {};
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);
1922
+ return renderInner(doc, buildInstanceScope(doc, propsOverride), slotContent, context);
1713
1923
  }
1714
1924
 
1715
1925
  /**
@@ -1808,8 +2018,21 @@ const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
1808
2018
  * @returns {string}
1809
2019
  */
1810
2020
  function resolveSelector(prop: string, scope: string): string {
2021
+ /* Member by member, for the reason css.ts's `resolveNestedSelector` does it: a key may be a
2022
+ SELECTOR LIST, and splicing one as a single string spliced only its first `&`. `"& .a, & .b"`
2023
+ came out as `sty-card .a, & .b`, and a raw `&` in a built stylesheet is not a nesting selector
2024
+ at all — the browser discards the list and the component silently loses those rules. This is
2025
+ the one selector path that does not go through the shared builder, because `:host` has to be
2026
+ translated before the scope is applied, so it needed the same fix separately. */
2027
+ return splitSelectorList(prop)
2028
+ .map((member) => resolveSelectorMember(member, scope))
2029
+ .join(", ");
2030
+ }
2031
+
2032
+ /** One member of {@link resolveSelector}'s list against the scope. */
2033
+ function resolveSelectorMember(prop: string, scope: string): string {
1811
2034
  if (prop.startsWith("&")) {
1812
- return prop.replace("&", scope);
2035
+ return prop.replaceAll("&", scope);
1813
2036
  }
1814
2037
  if (prop.startsWith(":host")) {
1815
2038
  const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
@@ -1842,41 +2065,33 @@ export function buildComponentCSS(
1842
2065
  const scope = styleScopePrefix(tagName, shadow);
1843
2066
 
1844
2067
  if (styleDef && typeof styleDef === "object") {
1845
- const decls: string[] = [];
2068
+ /* The top level is walked here rather than handed straight to the builder, because ONE of its
2069
+ keys does not mean what `resolveNestedSelector` would make of it: `:host` and `:host(.foo)`
2070
+ are TRANSLATED per {@link resolveSelector} so a style object means the same thing in both
2071
+ modes. Everything below the top level is ordinary nesting, so each block goes to the builder
2072
+ whole — which is also what gives a component's nested selectors the recursion they never had
2073
+ (this used to emit exactly one level and drop anything under it). */
2074
+ const own: JxStyle = {};
2075
+ const blocks: [string, JxStyle][] = [];
1846
2076
  for (const [prop, value] of Object.entries(styleDef)) {
1847
- if (
1848
- prop.startsWith(":") ||
1849
- prop.startsWith(".") ||
1850
- prop.startsWith("&") ||
1851
- prop.startsWith("[") ||
1852
- prop.startsWith("@")
1853
- ) {
1854
- continue;
1855
- }
1856
- if (value === null || typeof value === "object") {
1857
- continue;
1858
- }
1859
- if (typeof value === "string" && isTemplateString(value)) {
1860
- continue;
2077
+ /* `isRef` first, and it is the whole point: an object carrying `$ref` is a reactive VALUE,
2078
+ not a nested block (spec.md §9.1). Sorted into `blocks` it matched neither branch below —
2079
+ not an at-rule, not a nested selector — and was dropped without reaching the builder at
2080
+ all, so the one emitter that could have reported it never saw it. */
2081
+ if (isRef(value)) {
2082
+ own[prop] = value as never;
2083
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2084
+ blocks.push([prop, value]);
2085
+ } else if (!prop.startsWith("@") && !isNestedSelectorKey(prop)) {
2086
+ own[prop] = value;
1861
2087
  }
1862
- decls.push(` ${camelToKebab(prop)}: ${value};`);
1863
- }
1864
- if (decls.length > 0) {
1865
- rules.push(`${scope} {\n${decls.join("\n")}\n}`);
1866
2088
  }
1867
-
1868
- for (const [prop, val] of Object.entries(styleDef)) {
2089
+ pushStyleRules(rules, own, scope, mediaQueries);
2090
+ for (const [prop, val] of blocks) {
1869
2091
  if (prop.startsWith("@")) {
1870
- pushConditionalRule(rules, prop, mediaQueries, scope, val as Record<string, unknown>);
1871
- } else if (
1872
- prop.startsWith(":") ||
1873
- prop.startsWith(".") ||
1874
- prop.startsWith("&") ||
1875
- prop.startsWith("[")
1876
- ) {
1877
- rules.push(
1878
- `${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
1879
- );
2092
+ pushStyleRules(rules, { [prop]: val }, scope, mediaQueries);
2093
+ } else if (isNestedSelectorKey(prop)) {
2094
+ pushStyleRules(rules, val, resolveSelector(prop, scope), mediaQueries);
1880
2095
  }
1881
2096
  }
1882
2097
  }