@jxsuite/compiler 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
40
- "@jxsuite/parser": "^0.5.5",
41
- "@jxsuite/runtime": "^0.5.5",
40
+ "@jxsuite/parser": "^0.6.2",
41
+ "@jxsuite/runtime": "^0.6.2",
42
42
  "remark-gfm": "^4.0.1",
43
43
  "remark-stringify": "^11.0.0",
44
44
  "sharp": "^0.34.5",
package/src/shared.js CHANGED
@@ -393,25 +393,7 @@ export function buildAttrs(def, scope) {
393
393
  if (lang) out += ` lang="${escapeHtml(lang)}"`;
394
394
  if (dir) out += ` dir="${escapeHtml(dir)}"`;
395
395
 
396
- if (def.style) {
397
- // Collect properties that have @media overrides — these must NOT be inline
398
- // because inline styles (specificity 1,0,0,0) always beat stylesheet @media rules.
399
- const mediaOverriddenProps = new Set();
400
- for (const [k, v] of Object.entries(def.style)) {
401
- if (k.startsWith("@") && v && typeof v === "object") {
402
- for (const prop of Object.keys(/** @type {Record<string, any>} */ (v))) {
403
- if (
404
- !prop.startsWith(":") &&
405
- !prop.startsWith(".") &&
406
- !prop.startsWith("&") &&
407
- !prop.startsWith("[")
408
- ) {
409
- mediaOverriddenProps.add(prop);
410
- }
411
- }
412
- }
413
- }
414
-
396
+ if (def.style && scope) {
415
397
  const inline = Object.entries(def.style)
416
398
  .filter(
417
399
  ([k, v]) =>
@@ -420,9 +402,10 @@ export function buildAttrs(def, scope) {
420
402
  !k.startsWith("&") &&
421
403
  !k.startsWith("[") &&
422
404
  !k.startsWith("@") &&
423
- !mediaOverriddenProps.has(k) &&
424
405
  v !== null &&
425
- typeof v !== "object",
406
+ typeof v !== "object" &&
407
+ typeof v === "string" &&
408
+ isTemplateString(v),
426
409
  )
427
410
  .map(([k, v]) => {
428
411
  const value = resolveStaticValue(v, scope);
@@ -432,7 +415,6 @@ export function buildAttrs(def, scope) {
432
415
  .join("; ");
433
416
  if (inline) out += ` style="${inline}"`;
434
417
  }
435
-
436
418
  if (def.attributes) {
437
419
  for (const [k, v] of Object.entries(def.attributes)) {
438
420
  const value = resolveStaticValue(v, scope);
@@ -508,7 +490,16 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
508
490
  if (projectStyle && typeof projectStyle === "object") {
509
491
  for (const [key, val] of Object.entries(projectStyle)) {
510
492
  if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
511
- // Standalone selector (e.g. `.dark`)
493
+ // Standalone selector (e.g. `.dark`, `:focus-visible`)
494
+ rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
495
+ } else if (
496
+ val !== null &&
497
+ typeof val === "object" &&
498
+ !Array.isArray(val) &&
499
+ !key.startsWith("@") &&
500
+ !key.startsWith("--")
501
+ ) {
502
+ // Element selector with object value (e.g. `html`, `*`)
512
503
  rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
513
504
  } else if (key.startsWith("@")) {
514
505
  // @media block
@@ -557,23 +548,19 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
557
548
  * @param {string} [_parentSel]
558
549
  * @param {{ n: number }} [counter]
559
550
  */
560
- export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter = { n: 0 }) {
551
+ export function collectStyles(
552
+ def,
553
+ rules,
554
+ mediaQueries,
555
+ _parentSel = "",
556
+ counter = { n: 0 },
557
+ prefix = "jx",
558
+ ) {
561
559
  if (!def || typeof def !== "object") return;
562
560
 
563
561
  if (def.style) {
564
- // Check if this element needs CSS rules (media queries, pseudo-classes, etc.)
565
- const needsCSS = Object.keys(def.style).some(
566
- (k) =>
567
- k.startsWith("@") ||
568
- k.startsWith(":") ||
569
- k.startsWith(".") ||
570
- k.startsWith("&") ||
571
- k.startsWith("["),
572
- );
573
-
574
- // Auto-scope elements that need CSS rules but lack a unique selector
575
- if (needsCSS && !def.id && !def.className) {
576
- def.className = `jx-${counter.n++}`;
562
+ if (!def.id && !def.className) {
563
+ def.className = `${prefix}-${counter.n++}`;
577
564
  }
578
565
  }
579
566
 
@@ -584,36 +571,22 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter
584
571
  : (def.tagName ?? "*");
585
572
 
586
573
  if (def.style) {
587
- // Collect properties that have @media overrides — these are excluded from
588
- // inline styles in buildAttrs(), so we emit them as base CSS rules here.
589
- const mediaOverriddenProps = new Set();
590
- for (const [k, v] of Object.entries(def.style)) {
591
- if (k.startsWith("@") && v && typeof v === "object") {
592
- for (const p of Object.keys(/** @type {Record<string, any>} */ (v))) {
593
- if (
594
- !p.startsWith(":") &&
595
- !p.startsWith(".") &&
596
- !p.startsWith("&") &&
597
- !p.startsWith("[")
598
- ) {
599
- mediaOverriddenProps.add(p);
600
- }
601
- }
602
- }
574
+ const baseDecls = [];
575
+ for (const [prop, value] of Object.entries(def.style)) {
576
+ if (
577
+ prop.startsWith(":") ||
578
+ prop.startsWith(".") ||
579
+ prop.startsWith("&") ||
580
+ prop.startsWith("[") ||
581
+ prop.startsWith("@")
582
+ )
583
+ continue;
584
+ if (value === null || typeof value === "object") continue;
585
+ if (typeof value === "string" && isTemplateString(value)) continue;
586
+ baseDecls.push(` ${camelToKebab(prop)}: ${value};`);
603
587
  }
604
-
605
- // Emit base CSS rules for media-overridden properties
606
- if (mediaOverriddenProps.size > 0) {
607
- const baseDecls = [];
608
- for (const p of mediaOverriddenProps) {
609
- const v = def.style[p];
610
- if (v !== null && v !== undefined && typeof v !== "object") {
611
- baseDecls.push(`${camelToKebab(p)}: ${v}`);
612
- }
613
- }
614
- if (baseDecls.length > 0) {
615
- rules.push(`${selector} { ${baseDecls.join("; ")} }`);
616
- }
588
+ if (baseDecls.length > 0) {
589
+ rules.push(`${selector} {\n${baseDecls.join("\n")}\n}`);
617
590
  }
618
591
 
619
592
  for (const [prop, val] of Object.entries(def.style)) {
@@ -651,7 +624,7 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter
651
624
 
652
625
  if (Array.isArray(def.children)) {
653
626
  def.children.forEach((/** @type {any} */ c) => {
654
- collectStyles(c, rules, mediaQueries, selector, counter);
627
+ collectStyles(c, rules, mediaQueries, selector, counter, prefix);
655
628
  });
656
629
  }
657
630
  }
@@ -787,7 +760,13 @@ const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area"
787
760
  * @returns {string}
788
761
  */
789
762
  export function renderStaticNode(node, scope, slotContent = null) {
790
- if (typeof node === "string") return escapeHtml(node);
763
+ if (typeof node === "string") {
764
+ if (isTemplateString(node) && scope) {
765
+ const val = evaluateStaticTemplate(node, scope);
766
+ return val != null ? escapeHtml(String(val)) : escapeHtml(node);
767
+ }
768
+ return escapeHtml(node);
769
+ }
791
770
  if (typeof node === "number" || typeof node === "boolean") return escapeHtml(String(node));
792
771
  if (Array.isArray(node))
793
772
  return node.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent)).join("\n");
@@ -913,30 +892,63 @@ function _isStaticNode(node) {
913
892
  }
914
893
 
915
894
  /**
916
- * Generate a CSS rule block for a component's root-level styles. Uses the tag name as the selector.
917
- * Skips pseudo-selectors, media queries, nested rules, and template strings (runtime-only).
895
+ * Generate CSS rules for a component: host-level styles using tag selector, plus inner element
896
+ * styles using .jx-N selectors via collectStyles.
918
897
  *
919
898
  * @param {string} tagName - The custom element tag name (used as CSS selector)
920
899
  * @param {any} styleDef - The component's style object
900
+ * @param {any} [doc] - The full component document (for walking children)
901
+ * @param {Record<string, any>} [mediaQueries] - Project media query definitions
921
902
  * @returns {string} CSS text, or empty string if no styles
922
903
  */
923
- export function buildComponentCSS(tagName, styleDef) {
924
- if (!styleDef || typeof styleDef !== "object") return "";
904
+ export function buildComponentCSS(tagName, styleDef, doc = null, mediaQueries = {}) {
925
905
  /** @type {string[]} */
926
- const decls = [];
927
- for (const [prop, value] of Object.entries(styleDef)) {
928
- if (
929
- prop.startsWith(":") ||
930
- prop.startsWith(".") ||
931
- prop.startsWith("&") ||
932
- prop.startsWith("[") ||
933
- prop.startsWith("@")
934
- )
935
- continue;
936
- if (value === null || typeof value === "object") continue;
937
- if (typeof value === "string" && isTemplateString(value)) continue;
938
- decls.push(` ${camelToKebab(prop)}: ${value};`);
906
+ const rules = [];
907
+
908
+ if (styleDef && typeof styleDef === "object") {
909
+ /** @type {string[]} */
910
+ const decls = [];
911
+ for (const [prop, value] of Object.entries(styleDef)) {
912
+ if (
913
+ prop.startsWith(":") ||
914
+ prop.startsWith(".") ||
915
+ prop.startsWith("&") ||
916
+ prop.startsWith("[") ||
917
+ prop.startsWith("@")
918
+ )
919
+ continue;
920
+ if (value === null || typeof value === "object") continue;
921
+ if (typeof value === "string" && isTemplateString(value)) continue;
922
+ decls.push(` ${camelToKebab(prop)}: ${value};`);
923
+ }
924
+ if (decls.length > 0) {
925
+ rules.push(`${tagName} {\n${decls.join("\n")}\n}`);
926
+ }
927
+
928
+ for (const [prop, val] of Object.entries(styleDef)) {
929
+ if (prop.startsWith("@")) {
930
+ const query = prop.startsWith("@--")
931
+ ? (mediaQueries[prop.slice(1)] ?? prop.slice(1))
932
+ : prop.slice(1);
933
+ rules.push(`@media ${query} { ${tagName} { ${toCSSText(/** @type {any} */ (val))} } }`);
934
+ } else if (
935
+ prop.startsWith(":") ||
936
+ prop.startsWith(".") ||
937
+ prop.startsWith("&") ||
938
+ prop.startsWith("[")
939
+ ) {
940
+ const resolved = prop.startsWith("&") ? prop.replace("&", tagName) : `${tagName}${prop}`;
941
+ rules.push(`${resolved} { ${toCSSText(/** @type {any} */ (val))} }`);
942
+ }
943
+ }
939
944
  }
940
- if (decls.length === 0) return "";
941
- return `${tagName} {\n${decls.join("\n")}\n}\n`;
945
+
946
+ if (doc && Array.isArray(doc.children)) {
947
+ const counter = { n: 0 };
948
+ for (const child of doc.children) {
949
+ collectStyles(child, rules, mediaQueries, "", counter, tagName);
950
+ }
951
+ }
952
+
953
+ return rules.length > 0 ? rules.join("\n") + "\n" : "";
942
954
  }
@@ -74,18 +74,11 @@ export function saveCache(projectRoot, cache) {
74
74
  *
75
75
  * @param {CacheManifest} cache
76
76
  * @param {string} key
77
- * @param {string} outDir - Absolute path to build output dir
78
77
  * @returns {ImageManifest | null}
79
78
  */
80
- export function getCached(cache, key, outDir) {
79
+ export function getCached(cache, key) {
81
80
  const entry = cache.entries[key];
82
81
  if (!entry) return null;
83
-
84
- const allExist = entry.manifest.variants.every((v) =>
85
- existsSync(resolve(outDir, v.outputPath.slice(1))),
86
- );
87
- if (!allExist) return null;
88
-
89
82
  return entry.manifest;
90
83
  }
91
84
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { existsSync } from "node:fs";
10
- import { resolve, extname } from "node:path";
10
+ import { resolve, extname, basename } from "node:path";
11
11
  import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.js";
12
12
  import { getCached, setCached } from "./image-cache.js";
13
13
 
@@ -22,6 +22,29 @@ import { getCached, setCached } from "./image-cache.js";
22
22
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
23
23
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
24
24
 
25
+ /**
26
+ * @param {string} absoluteSrc
27
+ * @param {string} src
28
+ * @param {ImageConfig} config
29
+ * @param {string} outDir
30
+ * @param {CacheManifest} cache
31
+ * @returns {Promise<ImageManifest>}
32
+ */
33
+ async function resolveManifest(absoluteSrc, src, config, outDir, cache) {
34
+ const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
35
+ const cached = getCached(cache, key);
36
+
37
+ if (cached) {
38
+ const allExist = cached.variants.every((v) => existsSync(v.absolutePath));
39
+ if (allExist) return cached;
40
+ }
41
+
42
+ if (!cached) console.log(` Optimizing ${basename(absoluteSrc)}...`);
43
+ const manifest = await processImage(absoluteSrc, outDir, config);
44
+ setCached(cache, key, src, manifest);
45
+ return manifest;
46
+ }
47
+
25
48
  /**
26
49
  * Check if a src value should be skipped for optimization.
27
50
  *
@@ -91,6 +114,17 @@ async function walkAndTransform(node, config, projectRoot, outDir, cache, imageR
91
114
  await transformImgNode(node, config, projectRoot, outDir, cache, imageRefs);
92
115
  }
93
116
 
117
+ if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
118
+ node.innerHTML = await transformInnerHtmlImages(
119
+ node.innerHTML,
120
+ config,
121
+ projectRoot,
122
+ outDir,
123
+ cache,
124
+ imageRefs,
125
+ );
126
+ }
127
+
94
128
  if (Array.isArray(node.children)) {
95
129
  for (const child of node.children) {
96
130
  await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
@@ -119,11 +153,7 @@ async function transformImgNode(node, config, projectRoot, outDir, cache, imageR
119
153
  let manifest = imageRefs.get(absoluteSrc);
120
154
 
121
155
  if (!manifest) {
122
- const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
123
- const cached = getCached(cache, key, outDir);
124
- manifest = cached ?? (await processImage(absoluteSrc, outDir, config));
125
-
126
- setCached(cache, key, src, manifest);
156
+ manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
127
157
  imageRefs.set(absoluteSrc, manifest);
128
158
  }
129
159
 
@@ -147,3 +177,71 @@ async function transformImgNode(node, config, projectRoot, outDir, cache, imageR
147
177
  node.attributes.decoding = "async";
148
178
  }
149
179
  }
180
+
181
+ const IMG_TAG_RE = /<img\b([^>]*)>/gi;
182
+ const SRC_ATTR_RE = /\bsrc="([^"]*)"/;
183
+ const SRCSET_ATTR_RE = /\bsrcset="/;
184
+ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
185
+
186
+ /**
187
+ * Transform `<img>` tags embedded in pre-rendered innerHTML strings.
188
+ *
189
+ * @param {string} html
190
+ * @param {ImageConfig} config
191
+ * @param {string} projectRoot
192
+ * @param {string} outDir
193
+ * @param {CacheManifest} cache
194
+ * @param {Map<string, ImageManifest>} imageRefs
195
+ * @returns {Promise<string>}
196
+ */
197
+ async function transformInnerHtmlImages(html, config, projectRoot, outDir, cache, imageRefs) {
198
+ /** @type {{ match: string; replacement: string }[]} */
199
+ const replacements = [];
200
+
201
+ for (const m of html.matchAll(IMG_TAG_RE)) {
202
+ const tag = m[0];
203
+ const attrs = m[1];
204
+
205
+ if (SRCSET_ATTR_RE.test(attrs)) continue;
206
+ if (DATA_NO_OPT_RE.test(attrs)) continue;
207
+
208
+ const srcMatch = attrs.match(SRC_ATTR_RE);
209
+ if (!srcMatch) continue;
210
+
211
+ const src = srcMatch[1];
212
+ if (shouldSkip(src)) continue;
213
+
214
+ const absoluteSrc = resolveImagePath(src, projectRoot);
215
+ if (!existsSync(absoluteSrc)) continue;
216
+
217
+ let manifest = imageRefs.get(absoluteSrc);
218
+ if (!manifest) {
219
+ manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
220
+ imageRefs.set(absoluteSrc, manifest);
221
+ }
222
+
223
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
224
+ const srcset = buildSrcset(manifest.variants, preferredFormat);
225
+ if (!srcset) continue;
226
+
227
+ let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
228
+ if (!/\bwidth=/.test(attrs) && manifest.original.width) {
229
+ extra += ` width="${manifest.original.width}"`;
230
+ }
231
+ if (!/\bheight=/.test(attrs) && manifest.original.height) {
232
+ extra += ` height="${manifest.original.height}"`;
233
+ }
234
+ if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
235
+ if (!/\bloading=/.test(attrs)) extra += ` loading="lazy"`;
236
+ if (!/\bdecoding=/.test(attrs)) extra += ` decoding="async"`;
237
+ }
238
+
239
+ replacements.push({ match: tag, replacement: `<img${attrs}${extra}>` });
240
+ }
241
+
242
+ let result = html;
243
+ for (const { match, replacement } of replacements) {
244
+ result = result.replace(match, replacement);
245
+ }
246
+ return result;
247
+ }
@@ -32,6 +32,16 @@ const SKIP_PROTOTYPES = new Set([
32
32
  "ContentEntry",
33
33
  ]);
34
34
 
35
+ /**
36
+ * Built-in $prototype → .class.json mappings. These resolve automatically without requiring an
37
+ * explicit `imports` entry in project.json. Project-level imports take precedence on collision.
38
+ */
39
+ /** @type {Record<string, string>} */
40
+ const BUILTIN_CLASS_MAPPINGS = {
41
+ MarkdownFile: "@jxsuite/parser/MarkdownFile.class.json",
42
+ MarkdownCollection: "@jxsuite/parser/MarkdownCollection.class.json",
43
+ };
44
+
35
45
  /**
36
46
  * Keys reserved by the Jx prototype system — stripped before passing config to the external class
37
47
  * constructor. Mirrors runtime's EXTERNAL_RESERVED.
@@ -73,8 +83,8 @@ export async function resolvePrototypes(doc, route, projectRoot) {
73
83
 
74
84
  // Look up in imports if no $src already set
75
85
  if (!def.$src) {
76
- const mapped = imports[def.$prototype];
77
- if (!mapped) continue; // not in imports — leave for runtime
86
+ const mapped = imports[def.$prototype] ?? BUILTIN_CLASS_MAPPINGS[def.$prototype];
87
+ if (!mapped) continue;
78
88
  def.$src = mapped;
79
89
  }
80
90
 
@@ -11,8 +11,10 @@
11
11
  import {
12
12
  readFileSync,
13
13
  writeFileSync,
14
+ copyFileSync,
14
15
  mkdirSync,
15
16
  existsSync,
17
+ renameSync,
16
18
  rmSync,
17
19
  cpSync,
18
20
  readdirSync,
@@ -34,6 +36,7 @@ import {
34
36
  buildComponentCSS,
35
37
  collectServerEntries,
36
38
  renderStaticNode,
39
+ resolveStaticValue,
37
40
  DEFAULT_REACTIVITY_SRC,
38
41
  DEFAULT_LIT_HTML_SRC,
39
42
  } from "../shared.js";
@@ -69,7 +72,19 @@ export async function buildSite(projectRoot, options = {}) {
69
72
 
70
73
  // ── 2. Clean output directory ───────────────────────────────────────────
71
74
  if (clean && existsSync(outDir)) {
75
+ // Preserve optimized images across builds — sharp re-encoding is expensive
76
+ const optimizedDir = resolve(outDir, "images/_optimized");
77
+ const hasOptimized = existsSync(optimizedDir);
78
+ let tmpOptimized = "";
79
+ if (hasOptimized) {
80
+ tmpOptimized = resolve(projectRoot, ".jx-cache/_optimized_tmp");
81
+ renameSync(optimizedDir, tmpOptimized);
82
+ }
72
83
  rmSync(outDir, { recursive: true, force: true });
84
+ if (hasOptimized) {
85
+ mkdirSync(resolve(outDir, "images"), { recursive: true });
86
+ renameSync(tmpOptimized, optimizedDir);
87
+ }
73
88
  }
74
89
  mkdirSync(outDir, { recursive: true });
75
90
 
@@ -137,7 +152,7 @@ export async function buildSite(projectRoot, options = {}) {
137
152
  : JSON.parse(readFileSync(componentPath, "utf8"));
138
153
  if (doc.tagName) {
139
154
  componentDefs.set(doc.tagName, doc);
140
- const css = buildComponentCSS(doc.tagName, doc.style);
155
+ const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
141
156
  if (css) {
142
157
  componentCSS.set(doc.tagName, css);
143
158
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
@@ -266,10 +281,20 @@ export async function buildSite(projectRoot, options = {}) {
266
281
  });
267
282
 
268
283
  if (workerSource) {
269
- const workerPath = resolve(projectRoot, "_worker.js");
284
+ const workerPath = resolve(outDir, "worker.js");
270
285
  writeFileSync(workerPath, workerSource, "utf8");
271
286
  fileCount++;
272
- log(` Generated _worker.js (${deduped.size} server function(s))`);
287
+ log(` Generated dist/worker.js (${deduped.size} server function(s))`);
288
+
289
+ // Copy server source files into dist/components/ so worker imports resolve
290
+ const distComponentsDir = resolve(outDir, "components");
291
+ for (const { src } of deduped.values()) {
292
+ const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
293
+ const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
294
+ if (existsSync(srcFile)) {
295
+ copyFileSync(srcFile, destFile);
296
+ }
297
+ }
273
298
  }
274
299
  }
275
300
 
@@ -611,6 +636,28 @@ function expandComponents(node, componentDefs) {
611
636
 
612
637
  node.innerHTML = innerHTML;
613
638
  delete node.children;
639
+
640
+ // Resolve template-string host styles with props (per-instance values like background-image)
641
+ if (def.style && node.$props) {
642
+ let stateDefs = { ...def.state };
643
+ for (const [key, value] of Object.entries(node.$props)) {
644
+ if (key in stateDefs) stateDefs[key] = value;
645
+ else stateDefs[key] = value;
646
+ }
647
+ const scope = buildInitialScope(stateDefs, null);
648
+ /** @type {Record<string, any>} */
649
+ const resolvedStyle = {};
650
+ for (const [prop, value] of Object.entries(def.style)) {
651
+ if (typeof value === "string" && isTemplateString(value)) {
652
+ const resolved = resolveStaticValue(value, scope);
653
+ if (resolved != null) resolvedStyle[prop] = resolved;
654
+ }
655
+ }
656
+ if (Object.keys(resolvedStyle).length > 0) {
657
+ node.style = { ...node.style, ...resolvedStyle };
658
+ }
659
+ }
660
+
614
661
  delete node.$props;
615
662
 
616
663
  if (isStatic) node.$static = true;
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { camelToKebab, RESERVED_KEYS } from "@jxsuite/runtime";
9
- import { escapeHtml, tagNameToClassName, isSchemaOnly } from "../shared.js";
9
+ import { escapeHtml, tagNameToClassName, isSchemaOnly, collectStyles } from "../shared.js";
10
10
 
11
11
  /**
12
12
  * Compile a Jx custom element document to a JS module string.
@@ -282,6 +282,16 @@ export function emitElementModule(doc, className, elementImports) {
282
282
  lines.push(" }"); // end constructor
283
283
  lines.push("");
284
284
 
285
+ // Collect CSS rules from children tree (assigns .jx-N classes to defs)
286
+ /** @type {string[]} */
287
+ const cssRules = [];
288
+ if (Array.isArray(doc.children)) {
289
+ const counter = { n: 0 };
290
+ for (const child of doc.children) {
291
+ collectStyles(child, cssRules, doc.$media ?? {}, "", counter, doc.tagName);
292
+ }
293
+ }
294
+
285
295
  // Template method
286
296
  lines.push(" template() {");
287
297
  lines.push(" const s = this.state;");
@@ -312,8 +322,6 @@ export function emitElementModule(doc, className, elementImports) {
312
322
  lines.push(" }");
313
323
  lines.push(" }");
314
324
  if (doc.style && typeof doc.style === "object") {
315
- /** @type {[string, any][]} */
316
- const staticStyles = [];
317
325
  /** @type {[string, string][]} */
318
326
  const dynamicStyles = [];
319
327
  for (const [prop, value] of Object.entries(doc.style)) {
@@ -329,13 +337,6 @@ export function emitElementModule(doc, className, elementImports) {
329
337
  const cssProp = camelToKebab(prop);
330
338
  if (typeof value === "string" && value.includes("${")) {
331
339
  dynamicStyles.push([cssProp, value]);
332
- } else {
333
- staticStyles.push([cssProp, value]);
334
- }
335
- }
336
- if (staticStyles.length > 0) {
337
- for (const [cssProp, value] of staticStyles) {
338
- lines.push(` this.style['${cssProp}'] = ${JSON.stringify(value)};`);
339
340
  }
340
341
  }
341
342
  if (dynamicStyles.length > 0) {
@@ -358,12 +359,10 @@ export function emitElementModule(doc, className, elementImports) {
358
359
  " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
359
360
  );
360
361
  }
361
- // Skip clearing innerHTML if content was pre-rendered with correct props
362
362
  lines.push(" if (this.hasAttribute('data-jx-prerendered')) {");
363
363
  lines.push(" this.removeAttribute('data-jx-prerendered');");
364
- lines.push(" } else {");
365
- lines.push(" this.innerHTML = '';");
366
364
  lines.push(" }");
365
+ lines.push(" this.innerHTML = '';");
367
366
  lines.push(" this.#dispose = effect(() => render(this.template(), this));");
368
367
  if (hasSlot) {
369
368
  // Replace <slot> placeholder with saved slotted content
@@ -417,6 +416,7 @@ function emitLitChildren(children, parentStyle, indent) {
417
416
  function emitLitNode(def, indent) {
418
417
  // String children are text nodes
419
418
  if (typeof def === "string") {
419
+ if (def.includes("${")) return `${indent}${toLitTextContent(def)}`;
420
420
  return `${indent}${escapeHtml(def)}`;
421
421
  }
422
422
  if (typeof def === "number" || typeof def === "boolean") {
@@ -646,11 +646,9 @@ function emitStyleString(styleDef) {
646
646
 
647
647
  if (value === null || typeof value === "object") continue;
648
648
 
649
- const cssProp = camelToKebab(prop);
650
649
  if (typeof value === "string" && value.includes("${")) {
650
+ const cssProp = camelToKebab(prop);
651
651
  parts.push(`${cssProp}: ${toLitExpr(value)}`);
652
- } else {
653
- parts.push(`${cssProp}: ${value}`);
654
652
  }
655
653
  }
656
654