@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/dist/cli.js +262 -260
- package/dist/compiler.js +126 -125
- package/package.json +9 -9
- package/src/cli.ts +10 -4
- package/src/shared.ts +514 -299
- package/src/site/dev-command.ts +4 -2
- package/src/site/format-host.ts +54 -1
- package/src/site/prototype-resolver.ts +2 -1
- package/src/site/site-build.ts +38 -37
- package/src/site/validate-command.ts +100 -3
- package/src/targets/compile-client.ts +32 -6
- package/src/targets/compile-element.ts +39 -2
package/src/shared.ts
CHANGED
|
@@ -11,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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
.map(([k, v]) => {
|
|
906
|
-
const value = resolveStaticValue(v, scope);
|
|
907
|
-
return value == null ? null : `${camelToKebab(k)}: ${value}`;
|
|
908
|
-
})
|
|
909
|
-
.filter(Boolean)
|
|
910
|
-
.join("; ");
|
|
911
|
-
if (inline) {
|
|
912
|
-
out += ` style="${inline}"`;
|
|
913
|
-
}
|
|
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 `"` before the CSS parser sees the
|
|
951
|
+
* declaration, so `url("…")` 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
|
-
*
|
|
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
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
* @
|
|
1052
|
-
*
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
mediaQueries: Record<string, string>,
|
|
1057
|
-
rules: string[],
|
|
1058
|
-
): (selector: string, props: string) => void {
|
|
1059
|
-
const query = resolveAtQuery(atKey, mediaQueries);
|
|
1060
|
-
const atRule = query === null ? atKey : `@media ${query}`;
|
|
1061
|
-
const scheme = query === null ? null : pureSchemeOf(query);
|
|
1062
|
-
return (selector: string, props: string) => {
|
|
1063
|
-
if (!props) {
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
if (scheme) {
|
|
1067
|
-
const { auto, forced } = schemeSelectors(selector, scheme);
|
|
1068
|
-
rules.push(`${atRule} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
|
|
1069
|
-
} else {
|
|
1070
|
-
rules.push(`${atRule} { ${selector} { ${props} } }`);
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* Push the rules for one `@`-prefixed style block scoped to `selector`: flat props plus one level
|
|
1077
|
-
* of nested selectors, routed through conditionalRuleEmitter (scheme-aware).
|
|
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 {
|
|
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
|
|
1100
|
+
function pushStyleRules(
|
|
1086
1101
|
rules: string[],
|
|
1087
|
-
|
|
1102
|
+
style: JxStyle,
|
|
1103
|
+
selector: string | null,
|
|
1088
1104
|
mediaQueries: Record<string, string>,
|
|
1089
|
-
selector: string,
|
|
1090
|
-
obj: Record<string, unknown>,
|
|
1091
1105
|
) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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 (
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
prop
|
|
1853
|
-
) {
|
|
1854
|
-
|
|
1855
|
-
}
|
|
1856
|
-
|
|
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
|
|
2089
|
+
pushStyleRules(rules, own, scope, mediaQueries);
|
|
2090
|
+
for (const [prop, val] of blocks) {
|
|
1869
2091
|
if (prop.startsWith("@")) {
|
|
1870
|
-
|
|
1871
|
-
} else if (
|
|
1872
|
-
prop
|
|
1873
|
-
prop.startsWith(".") ||
|
|
1874
|
-
prop.startsWith("&") ||
|
|
1875
|
-
prop.startsWith("[")
|
|
1876
|
-
) {
|
|
1877
|
-
rules.push(
|
|
1878
|
-
`${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
|
|
1879
|
-
);
|
|
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
|
}
|