@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/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
- pureSchemeOf,
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 @returns {string}
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(def: JxElement | JxMutableNode, scope: Record<string, unknown> | null) {
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
- 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
- }
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 `&quot;` before the CSS parser sees the
949
+ * declaration, so `url(&quot;…&quot;)` 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
- * 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.
1082
+ * Push the rules one style object becomes, scoped to `selector`.
1047
1083
  *
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).
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 {string} atKey
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 pushConditionalRule(
1098
+ function pushStyleRules(
1086
1099
  rules: string[],
1087
- atKey: string,
1100
+ style: JxStyle,
1101
+ selector: string | null,
1088
1102
  mediaQueries: Record<string, string>,
1089
- selector: string,
1090
- obj: Record<string, unknown>,
1091
1103
  ) {
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));
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
- // Emit project-level (site-wide) styles — CSS custom properties go on :root,
1129
- // Everything else on body. Project-level style is implicitly :root, so a
1130
- // Flat object like { "--bg": "#000", "margin": "0" } is the expected format.
1131
- 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
- }
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
- 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
- }
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) => renderStaticNode(c, scope, slotContent))
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
- 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);
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.replace("&", scope);
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
- const decls: string[] = [];
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
- if (
1848
- prop.startsWith(":") ||
1849
- prop.startsWith(".") ||
1850
- prop.startsWith("&") ||
1851
- prop.startsWith("[") ||
1852
- prop.startsWith("@")
1853
- ) {
1854
- continue;
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 Object.entries(styleDef)) {
2040
+ pushStyleRules(rules, own, scope, mediaQueries);
2041
+ for (const [prop, val] of blocks) {
1869
2042
  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
- );
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
  }