@jxsuite/compiler 0.6.1 → 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.
@@ -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,
@@ -33,6 +35,8 @@ import {
33
35
  isComponentFullyStatic,
34
36
  buildComponentCSS,
35
37
  collectServerEntries,
38
+ renderStaticNode,
39
+ resolveStaticValue,
36
40
  DEFAULT_REACTIVITY_SRC,
37
41
  DEFAULT_LIT_HTML_SRC,
38
42
  } from "../shared.js";
@@ -68,7 +72,19 @@ export async function buildSite(projectRoot, options = {}) {
68
72
 
69
73
  // ── 2. Clean output directory ───────────────────────────────────────────
70
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
+ }
71
83
  rmSync(outDir, { recursive: true, force: true });
84
+ if (hasOptimized) {
85
+ mkdirSync(resolve(outDir, "images"), { recursive: true });
86
+ renameSync(tmpOptimized, optimizedDir);
87
+ }
72
88
  }
73
89
  mkdirSync(outDir, { recursive: true });
74
90
 
@@ -104,8 +120,6 @@ export async function buildSite(projectRoot, options = {}) {
104
120
  /** @type {string[]} */
105
121
  const compiledComponentTags = [];
106
122
  /** @type {Map<string, string>} */
107
- const preRendered = new Map(); // tagName → innerHTML (default state, fallback)
108
- /** @type {Map<string, string>} */
109
123
  const componentCSS = new Map(); // tagName → CSS text
110
124
  /** @type {Map<string, any>} */
111
125
  const componentDefs = new Map(); // tagName → parsed component definition
@@ -138,10 +152,7 @@ export async function buildSite(projectRoot, options = {}) {
138
152
  : JSON.parse(readFileSync(componentPath, "utf8"));
139
153
  if (doc.tagName) {
140
154
  componentDefs.set(doc.tagName, doc);
141
- const innerHTML = preRenderComponentHtml(doc);
142
- if (innerHTML) preRendered.set(doc.tagName, innerHTML);
143
-
144
- const css = buildComponentCSS(doc.tagName, doc.style);
155
+ const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
145
156
  if (css) {
146
157
  componentCSS.set(doc.tagName, css);
147
158
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
@@ -186,16 +197,14 @@ export async function buildSite(projectRoot, options = {}) {
186
197
  collections,
187
198
  imageCache,
188
199
  outDir,
200
+ componentDefs,
189
201
  );
190
202
 
191
- // Inject pre-rendered component HTML scaffolding (instance-aware)
192
- // Must happen before script injection so we know which tags are fully static
203
+ // Determine which component tags are fully static (for script omission)
193
204
  /** @type {Set<string>} */
194
- let staticTags = new Set();
195
- if (componentDefs.size > 0) {
196
- const preResult = injectPreRenderedComponents(result.html, preRendered, componentDefs);
197
- result.html = preResult.html;
198
- staticTags = preResult.staticTags;
205
+ const staticTags = new Set();
206
+ for (const [tag, def] of componentDefs) {
207
+ if (isComponentFullyStatic(def)) staticTags.add(tag);
199
208
  }
200
209
 
201
210
  // Inject component CSS and JS scripts
@@ -272,10 +281,20 @@ export async function buildSite(projectRoot, options = {}) {
272
281
  });
273
282
 
274
283
  if (workerSource) {
275
- const workerPath = resolve(projectRoot, "_worker.js");
284
+ const workerPath = resolve(outDir, "worker.js");
276
285
  writeFileSync(workerPath, workerSource, "utf8");
277
286
  fileCount++;
278
- 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
+ }
279
298
  }
280
299
  }
281
300
 
@@ -332,6 +351,7 @@ async function compilePage(
332
351
  collections = new Map(),
333
352
  imageCache = null,
334
353
  outDir = "",
354
+ componentDefs = new Map(),
335
355
  ) {
336
356
  // Load the raw page document
337
357
  let pageDoc;
@@ -377,6 +397,9 @@ async function compilePage(
377
397
  // so that timing: "compiler" data is baked into the static HTML
378
398
  resolveDocTemplates(layoutDoc, scope);
379
399
 
400
+ // Expand registered custom elements (apply $props, pre-render, mark static/prerendered)
401
+ expandComponents(layoutDoc, componentDefs);
402
+
380
403
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
381
404
  // and keeping them would cause isDynamic() to misclassify the page as dynamic
382
405
  if (layoutDoc.state) {
@@ -551,11 +574,95 @@ function resolveDocTemplates(node, scope) {
551
574
  }
552
575
  }
553
576
  }
577
+ if (typeof node.children === "string" && isTemplateString(node.children)) {
578
+ const resolved = evaluateStaticTemplate(node.children, scope);
579
+ if (Array.isArray(resolved)) {
580
+ node.children = resolved;
581
+ for (const child of node.children) {
582
+ resolveDocTemplates(child, scope);
583
+ }
584
+ }
585
+ } else if (Array.isArray(node.children)) {
586
+ let i = 0;
587
+ while (i < node.children.length) {
588
+ const child = node.children[i];
589
+ if (typeof child === "string" && isTemplateString(child)) {
590
+ const resolved = evaluateStaticTemplate(child, scope);
591
+ if (Array.isArray(resolved)) {
592
+ node.children.splice(i, 1, ...resolved);
593
+ for (const spliced of resolved) {
594
+ resolveDocTemplates(spliced, scope);
595
+ }
596
+ i += resolved.length;
597
+ continue;
598
+ }
599
+ }
600
+ resolveDocTemplates(child, scope);
601
+ i++;
602
+ }
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Walk the document tree and expand registered custom elements in-place. Applies $props via
608
+ * preRenderComponentHtml, marks static/prerendered.
609
+ *
610
+ * @param {any} node
611
+ * @param {Map<string, any>} componentDefs
612
+ */
613
+ function expandComponents(node, componentDefs) {
614
+ if (!node || typeof node !== "object") return;
615
+ if (Array.isArray(node)) {
616
+ node.forEach((n) => expandComponents(n, componentDefs));
617
+ return;
618
+ }
619
+
620
+ // Recurse into children first (bottom-up expansion)
554
621
  if (Array.isArray(node.children)) {
555
622
  for (const child of node.children) {
556
- resolveDocTemplates(child, scope);
623
+ expandComponents(child, componentDefs);
557
624
  }
558
625
  }
626
+
627
+ const def = componentDefs.get(node.tagName);
628
+ if (def) {
629
+ const slotContent =
630
+ Array.isArray(node.children) && node.children.length > 0
631
+ ? node.children.map((/** @type {any} */ c) => renderStaticNode(c, {}, null)).join("\n")
632
+ : null;
633
+
634
+ const innerHTML = preRenderComponentHtml(def, node.$props || null, slotContent);
635
+ const isStatic = isComponentFullyStatic(def);
636
+
637
+ node.innerHTML = innerHTML;
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
+
661
+ delete node.$props;
662
+
663
+ if (isStatic) node.$static = true;
664
+ else node.$prerendered = true;
665
+ }
559
666
  }
560
667
 
561
668
  /**
@@ -616,74 +723,6 @@ function injectComponentScripts(
616
723
  return html.replace("</body>", ` ${injection}\n</body>`);
617
724
  }
618
725
 
619
- /**
620
- * Inject pre-rendered HTML scaffolding into component tags, using instance-specific props.
621
- *
622
- * @param {string} html
623
- * @param {Map<string, string>} preRendered - TagName → default innerHTML (fallback)
624
- * @param {Map<string, any>} componentDefs - TagName → parsed component definition
625
- * @returns {{ html: string; staticTags: Set<string> }}
626
- */
627
- function injectPreRenderedComponents(html, preRendered, componentDefs) {
628
- /** @type {Set<string>} */
629
- const staticTags = new Set();
630
- /** @type {Map<string, boolean>} */
631
- const tagHasNonStaticInstance = new Map();
632
-
633
- for (const [tag] of componentDefs) {
634
- // Match both empty tags and tags with inner content (for slotted components)
635
- const pattern = new RegExp(`<${tag}(\\s[^>]*?)?>([\\s\\S]*?)</${tag}>`, "g");
636
- html = html.replace(pattern, (match, attrsStr, existingInner) => {
637
- const attrs = attrsStr ?? "";
638
- const doc = componentDefs.get(tag);
639
- if (!doc) {
640
- // No definition, use default pre-rendered content
641
- const fallback = preRendered.get(tag) ?? "";
642
- return `<${tag}${attrs}>${fallback}</${tag}>`;
643
- }
644
-
645
- // Extract data-jx-props from the attribute string
646
- /** @type {Record<string, any> | null} */
647
- let props = null;
648
- const propsMatch = attrs.match(/\sdata-jx-props="([^"]*)"/);
649
- if (propsMatch) {
650
- try {
651
- props = JSON.parse(
652
- propsMatch[1]
653
- .replace(/&quot;/g, '"')
654
- .replace(/&amp;/g, "&")
655
- .replace(/&lt;/g, "<")
656
- .replace(/&gt;/g, ">")
657
- .replace(/&#39;/g, "'"),
658
- );
659
- } catch {}
660
- }
661
-
662
- // Pre-render with instance-specific props
663
- const slotContent = existingInner.trim() || null;
664
- const innerHTML = preRenderComponentHtml(doc, props, slotContent);
665
-
666
- const isStatic = isComponentFullyStatic(doc);
667
- if (!isStatic) tagHasNonStaticInstance.set(tag, true);
668
-
669
- if (isStatic) {
670
- // Strip data-jx-props from attrs for fully static instances
671
- let cleanAttrs = attrs.replace(/\s*data-jx-props="[^"]*"/, "");
672
- return `<${tag}${cleanAttrs} data-jx-static>${innerHTML}</${tag}>`;
673
- } else {
674
- return `<${tag}${attrs} data-jx-prerendered>${innerHTML}</${tag}>`;
675
- }
676
- });
677
-
678
- // Track whether ALL instances of this tag are static
679
- if (componentDefs.has(tag) && !tagHasNonStaticInstance.has(tag)) {
680
- staticTags.add(tag);
681
- }
682
- }
683
-
684
- return { html, staticTags };
685
- }
686
-
687
726
  /**
688
727
  * Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
689
728
  * Bare specifiers are resolved to /node_modules/ paths.
@@ -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