@jxsuite/compiler 0.28.0 → 0.28.3

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.
@@ -9,15 +9,16 @@
9
9
  */
10
10
 
11
11
  import {
12
- writeFileSync,
13
12
  copyFileSync,
14
- mkdirSync,
15
- existsSync,
16
- rmSync,
17
13
  cpSync,
14
+ existsSync,
15
+ mkdirSync,
18
16
  readdirSync,
17
+ rmSync,
18
+ writeFileSync,
19
19
  } from "node:fs";
20
- import { resolve, dirname, join } from "node:path";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { isMappedArray, isRef } from "@jxsuite/schema/guards";
21
22
  import { loadProjectConfig } from "./site-loader.ts";
22
23
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
23
24
  import { buildProjectFormatRegistry } from "./format-host.ts";
@@ -25,39 +26,42 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
25
26
  import { resolveLayout } from "./layout-resolver.ts";
26
27
  import { mergeHead, renderHead } from "./head-merger.ts";
27
28
  import { injectContext } from "./context-injection.ts";
28
- import { compile, compileServer, compileSiteServer, compilePagesFunctions } from "../compiler.ts";
29
+ import { compile, compileServer, compileSiteServer } from "../compiler.ts";
29
30
  import { compileElement } from "../targets/compile-element.ts";
30
31
  import {
32
+ DEFAULT_LIT_HTML_SRC,
33
+ DEFAULT_REACTIVITY_SRC,
34
+ buildComponentCSS,
31
35
  buildInitialScope,
32
- isTemplateString,
36
+ collectServerEntries,
37
+ collectStyles,
33
38
  evaluateStaticTemplate,
34
- preRenderComponentHtml,
35
39
  isComponentFullyStatic,
36
- buildComponentCSS,
37
- collectServerEntries,
40
+ isTemplateString,
41
+ preRenderComponentHtml,
38
42
  renderStaticNode,
39
- resolveStaticValue,
40
43
  resolveRefValue,
41
- DEFAULT_REACTIVITY_SRC,
42
- DEFAULT_LIT_HTML_SRC,
44
+ resolveStaticValue,
43
45
  } from "../shared.ts";
44
- import { loadContentTypes, loadContentConfig, resolveContentTypeRefs } from "./content-loader.ts";
46
+ import { loadContentConfig, loadContentTypes, resolveContentTypeRefs } from "./content-loader.ts";
45
47
  import { resolvePrototypes } from "./prototype-resolver.ts";
46
48
  import { transformImageNodes } from "./image-transform.ts";
47
- import { loadCache, saveCache, getImageCacheDir } from "./image-cache.ts";
48
- import { compilePagesImageFunction } from "../targets/compile-image-endpoint.ts";
49
+ import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
49
50
  import type { ImageConfig } from "./image-optimizer.ts";
50
51
  import type { ImageMetaCache } from "./image-transform.ts";
51
52
  import type {
53
+ JsonValue,
54
+ JxAttributeValue,
52
55
  JxElement,
53
- JxMutableNode,
54
- JxStyle,
55
56
  JxHeadEntry,
57
+ JxMutableNode,
56
58
  JxStateDefinition,
59
+ JxStyle,
57
60
  ProjectConfig,
58
61
  } from "@jxsuite/schema/types";
59
62
  import type { ContentLoaderEntry } from "@jxsuite/parser/types";
60
63
  import type { SiteRoute } from "../types.ts";
64
+ import type { CacheManifest } from "./image-cache.js";
61
65
 
62
66
  /**
63
67
  * Build an entire Jx site from a project directory.
@@ -100,7 +104,7 @@ export async function buildSite(
100
104
 
101
105
  // ── 2. Clean output directory ───────────────────────────────────────────
102
106
  if (clean && existsSync(outDir)) {
103
- rmSync(outDir, { recursive: true, force: true });
107
+ rmSync(outDir, { force: true, recursive: true });
104
108
  }
105
109
  mkdirSync(outDir, { recursive: true });
106
110
 
@@ -134,8 +138,8 @@ export async function buildSite(
134
138
  // ── 5. Compile site components ──────────────────────────────────────────
135
139
  const componentsDir = resolve(projectRoot, "components");
136
140
  const compiledComponentTags: string[] = [];
137
- const componentCSS: Map<string, string> = new Map(); // tagName → CSS text
138
- const componentDefs: Map<string, JxElement> = new Map(); // tagName → parsed component definition
141
+ const componentCSS = new Map<string, string>(); // TagName → CSS text
142
+ const componentDefs = new Map<string, JxElement>(); // TagName → parsed component definition
139
143
  if (existsSync(componentsDir)) {
140
144
  log("Compiling components...");
141
145
  const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
@@ -149,14 +153,16 @@ export async function buildSite(
149
153
  try {
150
154
  const componentPath = resolve(componentsDir, file);
151
155
  const result = await compileElement(componentPath, {
152
- $media: projectConfig.$media,
156
+ ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
153
157
  formats: formatRegistry,
154
158
  });
155
159
  for (const f of result.files) {
156
160
  const outName = f.path.includes("/") ? (f.path.split("/").pop() as string) : f.path;
157
161
  writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
158
- if (f.tagName) compiledComponentTags.push(f.tagName);
159
- fileCount++;
162
+ if (f.tagName) {
163
+ compiledComponentTags.push(f.tagName);
164
+ }
165
+ fileCount += 1;
160
166
  }
161
167
 
162
168
  // Pre-render component HTML scaffold and CSS sidecar
@@ -167,11 +173,11 @@ export async function buildSite(
167
173
  if (css) {
168
174
  componentCSS.set(doc.tagName, css);
169
175
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
170
- fileCount++;
176
+ fileCount += 1;
171
177
  }
172
178
  }
173
- } catch (e) {
174
- const err = e as Error;
179
+ } catch (error) {
180
+ const err = error as Error;
175
181
  errors.push(`Error compiling component ${file}: ${err.message}`);
176
182
  console.error(`Error compiling component ${file}: ${err.message}`);
177
183
  }
@@ -188,7 +194,7 @@ export async function buildSite(
188
194
  for (const [, doc] of componentDefs) {
189
195
  const entries = collectServerEntries(doc);
190
196
  for (const entry of entries) {
191
- const resolvedSrc = "./components/" + entry.src.replace(/^\.\//, "");
197
+ const resolvedSrc = `./components/${entry.src.replace(/^\.\//, "")}`;
192
198
  siteServerEntries.push({
193
199
  exportName: entry.exportName,
194
200
  src: resolvedSrc,
@@ -205,11 +211,9 @@ export async function buildSite(
205
211
  const imageMetaCache = cfImages ? new Map() : null;
206
212
  if (cfImages) {
207
213
  console.log(
208
- `images.service is "cloudflare" — ensure your wrangler config declares the Images binding ` +
209
- `("images": { "binding": "${projectConfig.images.binding ?? "IMAGES"}" })` +
210
- (projectConfig.build.adapter === "cloudflare-workers"
211
- ? ` and an assets binding ("assets": { "binding": "ASSETS" }).`
212
- : `.`),
214
+ `images.service is "cloudflare" — srcsets use /cdn-cgi/image transform URLs. ` +
215
+ `Ensure Image Transformations are enabled for your zone (Cloudflare dashboard → ` +
216
+ `Images → Transformations); these URLs do not work on *.pages.dev / *.workers.dev previews.`,
213
217
  );
214
218
  }
215
219
 
@@ -228,9 +232,11 @@ export async function buildSite(
228
232
  );
229
233
 
230
234
  // Determine which component tags are fully static (for script omission)
231
- const staticTags: Set<string> = new Set();
235
+ const staticTags = new Set<string>();
232
236
  for (const [tag, def] of componentDefs) {
233
- if (isComponentFullyStatic(def)) staticTags.add(tag);
237
+ if (isComponentFullyStatic(def)) {
238
+ staticTags.add(tag);
239
+ }
234
240
  }
235
241
 
236
242
  // Inject component CSS and JS scripts
@@ -247,29 +253,33 @@ export async function buildSite(
247
253
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
248
254
  mkdirSync(dirname(outPath), { recursive: true });
249
255
  writeFileSync(outPath, result.html, "utf8");
250
- fileCount++;
256
+ fileCount += 1;
251
257
 
252
258
  // Write serialized export sidecars alongside HTML (formats with exportTarget: true)
253
259
  for (const fmt of formatRegistry.withCapability("serialize")) {
254
- if (!fmt.exportTarget) continue;
260
+ if (!fmt.exportTarget) {
261
+ continue;
262
+ }
255
263
  try {
256
264
  const content = (await fmt.call("serialize", result.doc, {
257
- mode: "export",
265
+ buildScope: (state: Record<string, JxStateDefinition>) =>
266
+ buildInitialScope(state, null),
258
267
  componentDefs,
259
268
  evaluateTemplate: (value: string, scope: Record<string, unknown>) => {
260
- if (!isTemplateString(value)) return undefined;
269
+ if (!isTemplateString(value)) {
270
+ return;
271
+ }
261
272
  return evaluateStaticTemplate(value, scope) ?? value;
262
273
  },
263
- buildScope: (state: Record<string, JxStateDefinition>) =>
264
- buildInitialScope(state, null),
274
+ mode: "export",
265
275
  })) as string;
266
276
  if (content) {
267
277
  const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]);
268
278
  writeFileSync(sidecarPath, content, "utf8");
269
- fileCount++;
279
+ fileCount += 1;
270
280
  }
271
- } catch (e) {
272
- const err = e as Error;
281
+ } catch (error) {
282
+ const err = error as Error;
273
283
  errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
274
284
  }
275
285
  }
@@ -279,17 +289,17 @@ export async function buildSite(
279
289
  const filePath = resolve(dirname(outPath), file.path);
280
290
  mkdirSync(dirname(filePath), { recursive: true });
281
291
  writeFileSync(filePath, file.content, "utf8");
282
- fileCount++;
292
+ fileCount += 1;
283
293
  }
284
294
 
285
295
  // Write server handler if present
286
296
  if (result.serverHandler) {
287
297
  const serverPath = resolve(dirname(outPath), "_server.js");
288
298
  writeFileSync(serverPath, result.serverHandler, "utf8");
289
- fileCount++;
299
+ fileCount += 1;
290
300
  }
291
- } catch (e) {
292
- const err = e as Error;
301
+ } catch (error) {
302
+ const err = error as Error;
293
303
  const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
294
304
  errors.push(msg);
295
305
  console.error(msg);
@@ -313,32 +323,39 @@ export async function buildSite(
313
323
 
314
324
  // ── 6c. Generate site-wide server worker ────────────────────────────────
315
325
  if (projectConfig.build.adapter) {
316
- const adapter = projectConfig.build.adapter;
326
+ const { adapter } = projectConfig.build;
317
327
  log("Generating site-wide server worker...");
318
328
 
319
329
  const deduped = new Map();
320
330
  for (const entry of siteServerEntries) {
321
- if (!deduped.has(entry.exportName)) deduped.set(entry.exportName, entry);
331
+ if (!deduped.has(entry.exportName)) {
332
+ deduped.set(entry.exportName, entry);
333
+ }
322
334
  }
323
335
 
324
- if (adapter === "cloudflare-pages") {
325
- const functions = compilePagesFunctions([...deduped.values()]);
326
-
327
- if (cfImages) {
328
- functions.set(
329
- "functions/_jx/image.js",
330
- compilePagesImageFunction(projectConfig.images as ImageConfig),
336
+ // Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
337
+ // Functions/ directory convention only works from the project root, not from dist/.
338
+ // A static-only Pages site needs no worker at all.
339
+ const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0;
340
+ const workerSource = skipWorker ? null : compileSiteServer([...deduped.values()], { adapter });
341
+
342
+ if (workerSource) {
343
+ const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
344
+ writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
345
+ fileCount += 1;
346
+ log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
347
+
348
+ if (adapter === "cloudflare-pages") {
349
+ // Only invoke the worker for server routes; everything else stays static.
350
+ writeFileSync(
351
+ resolve(outDir, "_routes.json"),
352
+ `${JSON.stringify({ exclude: [], include: ["/_jx/*"], version: 1 }, null, 2)}\n`,
353
+ "utf8",
331
354
  );
355
+ fileCount += 1;
332
356
  }
333
357
 
334
- for (const [filePath, source] of functions) {
335
- const fullPath = resolve(outDir, filePath);
336
- mkdirSync(resolve(fullPath, ".."), { recursive: true });
337
- writeFileSync(fullPath, source, "utf8");
338
- fileCount++;
339
- }
340
-
341
- // Copy server source files so function imports resolve
358
+ // Copy server source files into dist/components/ so worker imports resolve
342
359
  const distComponentsDir = resolve(outDir, "components");
343
360
  mkdirSync(distComponentsDir, { recursive: true });
344
361
  for (const { src } of deduped.values()) {
@@ -348,35 +365,6 @@ export async function buildSite(
348
365
  copyFileSync(srcFile, destFile);
349
366
  }
350
367
  }
351
-
352
- if (functions.size > 0) {
353
- log(` Generated ${functions.size} Pages function(s) in dist/functions/`);
354
- }
355
- } else {
356
- const workerSource = compileSiteServer([...deduped.values()], {
357
- adapter,
358
- images:
359
- cfImages && adapter === "cloudflare-workers"
360
- ? (projectConfig.images as ImageConfig)
361
- : null,
362
- });
363
-
364
- if (workerSource) {
365
- const workerPath = resolve(outDir, "worker.js");
366
- writeFileSync(workerPath, workerSource, "utf8");
367
- fileCount++;
368
- log(` Generated dist/worker.js (${deduped.size} server function(s))`);
369
-
370
- // Copy server source files into dist/components/ so worker imports resolve
371
- const distComponentsDir = resolve(outDir, "components");
372
- for (const { src } of deduped.values()) {
373
- const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
374
- const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
375
- if (existsSync(srcFile)) {
376
- copyFileSync(srcFile, destFile);
377
- }
378
- }
379
- }
380
368
  }
381
369
  }
382
370
 
@@ -410,7 +398,7 @@ export async function buildSite(
410
398
  log(` ${errors.length} error(s)`);
411
399
  }
412
400
 
413
- return { routes: routes.length, files: fileCount, errors };
401
+ return { errors, files: fileCount, routes: routes.length };
414
402
  }
415
403
 
416
404
  /**
@@ -436,9 +424,9 @@ async function compilePage(
436
424
  route: SiteRoute,
437
425
  projectConfig: ProjectConfig,
438
426
  projectRoot: string,
439
- contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
440
- imageCache: import("./image-cache.js").CacheManifest | null = null,
441
- componentDefs: Map<string, JxElement> = new Map(),
427
+ contentTypes = new Map<string, ContentLoaderEntry[]>(),
428
+ imageCache: CacheManifest | null = null,
429
+ componentDefs = new Map<string, JxElement>(),
442
430
  imageMetaCache: ImageMetaCache | null = null,
443
431
  formatRegistry?: FormatRegistry,
444
432
  ) {
@@ -449,9 +437,9 @@ async function compilePage(
449
437
  const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
450
438
 
451
439
  // Extract head arrays before they get lost in the merge
452
- const pageHead = pageDoc.$head ?? layoutDoc._pageHead ?? ([] as JxHeadEntry[]);
453
- const layoutHead = layoutDoc.$head ?? [];
454
- const pageTitle = pageDoc.title ?? layoutDoc._pageTitle ?? (null as string | null);
440
+ const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
441
+ const layoutHead = (layoutDoc.$head ?? []) as JxHeadEntry[];
442
+ const pageTitle = (pageDoc.title ?? layoutDoc._pageTitle ?? null) as string | null;
455
443
 
456
444
  // Clean up internal properties
457
445
  delete layoutDoc._pageHead;
@@ -480,19 +468,28 @@ async function compilePage(
480
468
  const resolvedLayoutHead = resolveHeadTemplates(layoutHead, scope);
481
469
 
482
470
  // Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
483
- // so that timing: "compiler" data is baked into the static HTML
471
+ // So that timing: "compiler" data is baked into the static HTML
484
472
  resolveDocTemplates(layoutDoc, scope);
485
473
 
486
- // Expand registered custom elements (apply $props, pre-render, mark static/prerendered)
487
- expandComponents(layoutDoc, componentDefs);
474
+ // Expand registered custom elements (apply $props, pre-render, mark static/prerendered).
475
+ // Slot children are serialized to HTML strings during expansion — before compileStyles can walk
476
+ // Them — so their static styles are collected here and injected as a page style block below.
477
+ const slotCss: SlotCssCollector = {
478
+ counter: { n: 0 },
479
+ media: { ...projectConfig.$media, ...layoutDoc.$media },
480
+ rules: [],
481
+ };
482
+ expandComponents(layoutDoc, componentDefs, slotCss);
488
483
 
489
484
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
490
- // and keeping them would cause isDynamic() to misclassify the page as dynamic.
485
+ // And keeping them would cause isDynamic() to misclassify the page as dynamic.
491
486
  // Also strip resolved content arrays (from ContentCollection) that have been
492
- // baked into unrolled map templates.
487
+ // Baked into unrolled map templates.
493
488
  if (layoutDoc.state) {
494
489
  for (const [key, def] of Object.entries(layoutDoc.state)) {
495
- if (key === "$site" || key === "$page") continue;
490
+ if (key === "$site" || key === "$page") {
491
+ continue;
492
+ }
496
493
  if (
497
494
  def &&
498
495
  typeof def === "object" &&
@@ -512,7 +509,7 @@ async function compilePage(
512
509
  // Merge $head from site + layout + page
513
510
  const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
514
511
  title,
515
- charset: projectConfig.defaults?.charset ?? "utf-8",
512
+ charset: projectConfig.defaults?.charset ?? "utf8",
516
513
  ...(projectConfig.name != null && { siteName: projectConfig.name }),
517
514
  ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
518
515
  pageUrl: route.urlPattern,
@@ -536,11 +533,19 @@ async function compilePage(
536
533
 
537
534
  // Compile the document using the existing compiler
538
535
  const result = await compile(layoutDoc, {
539
- title,
540
536
  lang: projectConfig.defaults?.lang ?? "en",
541
537
  projectStyle: projectConfig.style ?? null,
538
+ title,
542
539
  });
543
540
 
541
+ // Inject CSS rules collected from component slot content
542
+ if (slotCss.rules.length > 0) {
543
+ result.html = result.html.replace(
544
+ "</head>",
545
+ `<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
546
+ );
547
+ }
548
+
544
549
  // Post-process: inject merged <head> content into the compiled HTML
545
550
  result.html = injectHead(result.html, mergedHead, projectConfig.defaults?.lang ?? "en");
546
551
 
@@ -549,7 +554,7 @@ async function compilePage(
549
554
  (e: JxElement | string) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
550
555
  );
551
556
  if (npmElements.length > 0) {
552
- result.html = injectNpmElementScripts(result.html, /** @type {string[]} */ npmElements);
557
+ result.html = injectNpmElementScripts(result.html, npmElements as string[]);
553
558
  }
554
559
 
555
560
  // Compile server handler if applicable (skip when provider bundles site-wide)
@@ -566,10 +571,10 @@ async function compilePage(
566
571
  }
567
572
 
568
573
  return {
569
- html: result.html,
574
+ doc: layoutDoc,
570
575
  files: result.files,
576
+ html: result.html,
571
577
  serverHandler,
572
- doc: layoutDoc,
573
578
  };
574
579
  }
575
580
 
@@ -582,7 +587,9 @@ async function compilePage(
582
587
  */
583
588
  function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
584
589
  return headEntries.map((entry: JxHeadEntry) => {
585
- if (!entry || typeof entry !== "object") return entry;
590
+ if (!entry || typeof entry !== "object") {
591
+ return entry;
592
+ }
586
593
  const resolved = { ...entry };
587
594
  if (resolved.attributes) {
588
595
  resolved.attributes = { ...resolved.attributes };
@@ -611,7 +618,9 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
611
618
  */
612
619
  function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
613
620
  return headEntries.map((entry: JxHeadEntry) => {
614
- if (!entry || typeof entry !== "object" || !entry.attributes) return entry;
621
+ if (!entry || typeof entry !== "object" || !entry.attributes) {
622
+ return entry;
623
+ }
615
624
  const resolved = { ...entry, attributes: { ...entry.attributes } };
616
625
  for (const key of ["href", "src"]) {
617
626
  const val = resolved.attributes[key];
@@ -642,41 +651,46 @@ function isBareSpecifier(s: string) {
642
651
  /**
643
652
  * Deep-clone a map template, resolving template strings and $ref values against the given scope.
644
653
  *
645
- * @param {JxMutableNode} template
654
+ * @param {JxElement} template
646
655
  * @param {Record<string, unknown>} scope
647
- * @returns {JxMutableNode}
656
+ * @returns {JxElement}
648
657
  */
649
- function expandMapTemplate(template: JxMutableNode, scope: Record<string, unknown>) {
650
- if (!template || typeof template !== "object") return template;
651
- const node = {} as JxMutableNode;
658
+ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>): JxElement {
659
+ if (!template || typeof template !== "object") {
660
+ return template;
661
+ }
662
+ const node = {} as JxElement;
652
663
  for (const [k, v] of Object.entries(template)) {
653
664
  if (k === "children" && Array.isArray(v)) {
654
665
  node.children = v.map((child) => {
655
- if (typeof child === "string") return child;
666
+ if (typeof child === "string") {
667
+ return child;
668
+ }
656
669
  return expandMapTemplate(/** @type {JxMutableNode} */ child, scope);
657
670
  });
658
671
  } else if (k === "style" && v && typeof v === "object") {
659
- const style = { ...v } as Record<string, unknown>;
672
+ const style: JxStyle = { ...(v as JxStyle) };
660
673
  for (const [sk, sv] of Object.entries(style)) {
661
- if (typeof sv === "string" && isTemplateString(sv)) {
662
- style[sk] = evaluateMapTemplate(sv, scope) ?? sv;
674
+ if (isTemplateString(sv)) {
675
+ // Template evaluation yields a substituted scalar for style values.
676
+ style[sk] = (evaluateMapTemplate(sv, scope) as string | number | undefined) ?? sv;
663
677
  }
664
678
  }
665
679
  node.style = style;
666
680
  } else if (k === "attributes" && v && typeof v === "object") {
667
- const attrs = { ...v } as Record<string, unknown>;
681
+ const attrs = { ...(v as Record<string, JxAttributeValue>) };
668
682
  for (const [ak, av] of Object.entries(attrs)) {
669
- if (typeof av === "string" && isTemplateString(av)) {
670
- attrs[ak] = evaluateMapTemplate(av, scope) ?? av;
683
+ if (isTemplateString(av)) {
684
+ attrs[ak] = (evaluateMapTemplate(av, scope) as JxAttributeValue | undefined) ?? av;
671
685
  }
672
686
  }
673
687
  node.attributes = attrs;
674
688
  } else if (k === "$props" && v && typeof v === "object") {
675
- const props = { ...v } as Record<string, unknown>;
689
+ const props = { ...(v as Record<string, JsonValue>) };
676
690
  for (const [pk, pv] of Object.entries(props)) {
677
- if (typeof pv === "string" && isTemplateString(pv)) {
678
- const resolved = evaluateMapTemplate(pv, scope);
679
- // null = evaluation error → keep template string; undefined = missing data → use null
691
+ if (isTemplateString(pv)) {
692
+ const resolved = evaluateMapTemplate(pv, scope) as JsonValue | undefined;
693
+ // Null = evaluation error → keep template string; undefined = missing data → use null
680
694
  props[pk] = resolved !== null ? (resolved ?? null) : pv;
681
695
  }
682
696
  }
@@ -722,14 +736,16 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
722
736
  * @param {Record<string, unknown>} scope
723
737
  */
724
738
  function resolveDocTemplates(node: JxElement | string, scope: Record<string, unknown>) {
725
- if (!node || typeof node !== "object") return;
739
+ if (!node || typeof node !== "object") {
740
+ return;
741
+ }
726
742
 
727
743
  if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
728
744
  const resolved = evaluateStaticTemplate(node.innerHTML, scope);
729
745
  if (resolved != null) {
730
746
  // Encode any remaining `${` as HTML entities so the compile phase won't
731
- // re-interpret them as template expressions. After resolution, any `${` in the
732
- // result is literal content (e.g., code examples), not an intentional template.
747
+ // Re-interpret them as template expressions. After resolution, any `${` in the
748
+ // Result is literal content (e.g., code examples), not an intentional template.
733
749
  node.innerHTML = String(resolved).replaceAll("${", "&#36;{");
734
750
  }
735
751
  }
@@ -758,32 +774,26 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
758
774
  }
759
775
  }
760
776
  }
761
- const rawChildren = node.children as unknown;
762
- if (
763
- rawChildren &&
764
- typeof rawChildren === "object" &&
765
- !Array.isArray(rawChildren) &&
766
- (rawChildren as JxMutableNode).$prototype === "Array"
767
- ) {
768
- const arrayDef = rawChildren as JxMutableNode;
769
- const itemsSrc = arrayDef.items;
777
+ const rawChildren = node.children;
778
+ if (isMappedArray(rawChildren)) {
779
+ const itemsSrc = rawChildren.items;
770
780
  let items = null;
771
- if (itemsSrc && typeof itemsSrc === "object" && (itemsSrc as JxMutableNode).$ref) {
772
- const ref = (itemsSrc as JxMutableNode).$ref as string;
773
- items = resolveRefValue(ref, scope);
781
+ if (isRef(itemsSrc)) {
782
+ items = resolveRefValue(itemsSrc.$ref, scope);
774
783
  } else if (Array.isArray(itemsSrc)) {
775
784
  items = itemsSrc;
776
785
  }
777
- if (Array.isArray(items) && arrayDef.map) {
786
+ const mapTemplate = rawChildren.map;
787
+ if (Array.isArray(items) && mapTemplate) {
778
788
  node.children = items.map((item, index) => {
779
789
  const childScope = Object.create(scope);
780
- childScope.$map = { item, index };
790
+ childScope.$map = { index, item };
781
791
  childScope["$map/item"] = item;
782
792
  childScope["$map/index"] = index;
783
- const expanded = expandMapTemplate(arrayDef.map, childScope);
784
- resolveDocTemplates(expanded as JxElement, childScope);
793
+ const expanded = expandMapTemplate(mapTemplate, childScope);
794
+ resolveDocTemplates(expanded, childScope);
785
795
  return expanded;
786
- }) as (string | JxElement)[];
796
+ });
787
797
  return;
788
798
  }
789
799
  }
@@ -813,29 +823,46 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
813
823
  }
814
824
  }
815
825
  resolveDocTemplates(child, scope);
816
- i++;
826
+ i += 1;
817
827
  }
818
828
  }
819
829
  }
820
830
 
831
+ /** Collector for CSS rules extracted from component slot content during expansion. */
832
+ interface SlotCssCollector {
833
+ rules: string[];
834
+ counter: { n: number };
835
+ media: Record<string, string>;
836
+ }
837
+
821
838
  /**
822
839
  * Walk the document tree and expand registered custom elements in-place. Applies $props via
823
- * preRenderComponentHtml, marks static/prerendered.
840
+ * preRenderComponentHtml, marks static/prerendered. Slot children get their static styles collected
841
+ * into `slotCss` (assigning jxs-N classes) before being serialized to HTML.
824
842
  *
825
843
  * @param {JxElement | string} node
826
844
  * @param {Map<string, JxElement>} componentDefs
845
+ * @param {SlotCssCollector} [slotCss]
827
846
  */
828
- function expandComponents(node: JxElement | string, componentDefs: Map<string, JxElement>) {
829
- if (!node || typeof node !== "object") return;
847
+ function expandComponents(
848
+ node: JxElement | string,
849
+ componentDefs: Map<string, JxElement>,
850
+ slotCss?: SlotCssCollector,
851
+ ) {
852
+ if (!node || typeof node !== "object") {
853
+ return;
854
+ }
830
855
  if (Array.isArray(node)) {
831
- node.forEach((n) => expandComponents(n, componentDefs));
856
+ for (const n of node) {
857
+ expandComponents(n, componentDefs, slotCss);
858
+ }
832
859
  return;
833
860
  }
834
861
 
835
862
  // Recurse into children first (bottom-up expansion)
836
863
  if (Array.isArray(node.children)) {
837
864
  for (const child of node.children) {
838
- expandComponents(child, componentDefs);
865
+ expandComponents(child, componentDefs, slotCss);
839
866
  }
840
867
  }
841
868
 
@@ -843,7 +870,14 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
843
870
  if (def) {
844
871
  const slotContent =
845
872
  Array.isArray(node.children) && node.children.length > 0
846
- ? node.children.map((c: JxElement | string) => renderStaticNode(c, {}, null)).join("\n")
873
+ ? node.children
874
+ .map((c: JxElement | string) => {
875
+ if (slotCss && c && typeof c === "object") {
876
+ collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
877
+ }
878
+ return renderStaticNode(c, {}, null);
879
+ })
880
+ .join("\n")
847
881
  : null;
848
882
 
849
883
  const innerHTML = preRenderComponentHtml(def, node.$props || null, slotContent);
@@ -854,17 +888,19 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
854
888
 
855
889
  // Resolve template-string host styles with props (per-instance values like background-image)
856
890
  if (def.style && node.$props) {
857
- let stateDefs: Record<string, JxStateDefinition> = { ...def.state };
891
+ const stateDefs: Record<string, JxStateDefinition> = { ...def.state };
858
892
  for (const [key, value] of Object.entries(node.$props)) {
859
- if (key in stateDefs) stateDefs[key] = value as JxStateDefinition;
860
- else stateDefs[key] = value as JxStateDefinition;
893
+ stateDefs[key] =
894
+ key in stateDefs ? (value as JxStateDefinition) : (value as JxStateDefinition);
861
895
  }
862
896
  const scope = buildInitialScope(stateDefs, null);
863
897
  const resolvedStyle: Record<string, unknown> = {};
864
898
  for (const [prop, value] of Object.entries(def.style)) {
865
899
  if (typeof value === "string" && isTemplateString(value)) {
866
900
  const resolved = resolveStaticValue(value, scope);
867
- if (resolved != null) resolvedStyle[prop] = resolved;
901
+ if (resolved != null) {
902
+ resolvedStyle[prop] = resolved;
903
+ }
868
904
  }
869
905
  }
870
906
  if (Object.keys(resolvedStyle).length > 0) {
@@ -874,8 +910,11 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
874
910
 
875
911
  delete node.$props;
876
912
 
877
- if (isStatic) node.$static = true;
878
- else node.$prerendered = true;
913
+ if (isStatic) {
914
+ node.$static = true;
915
+ } else {
916
+ node.$prerendered = true;
917
+ }
879
918
  }
880
919
  }
881
920
 
@@ -892,27 +931,32 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
892
931
  function injectComponentScripts(
893
932
  html: string,
894
933
  allComponentTags: string[],
895
- cssMap: Map<string, string> = new Map(),
896
- staticTags: Set<string> = new Set(),
934
+ cssMap = new Map<string, string>(),
935
+ staticTags = new Set<string>(),
897
936
  ) {
898
937
  // Find which components are actually referenced in this page
899
938
  const usedTags = allComponentTags.filter(
900
- (tag: string) => html.includes(`<${tag}`), // matches <tag> and <tag ...>
939
+ (tag: string) => html.includes(`<${tag}`), // Matches <tag> and <tag ...>
901
940
  );
902
- if (usedTags.length === 0) return html;
941
+ if (usedTags.length === 0) {
942
+ return html;
943
+ }
903
944
 
904
945
  // Inject CSS links in <head> for ALL components that have CSS sidecars
905
946
  const cssLinks = usedTags
906
947
  .filter((tag: string) => cssMap.has(tag))
907
948
  .map((tag: string) => `<link rel="stylesheet" href="/components/${tag}.css">`)
908
949
  .join("\n ");
950
+ let result = html;
909
951
  if (cssLinks) {
910
- html = html.replace("</head>", ` ${cssLinks}\n</head>`);
952
+ result = result.replace("</head>", ` ${cssLinks}\n</head>`);
911
953
  }
912
954
 
913
955
  // Only inject JS for components that have non-static instances
914
956
  const jsTags = usedTags.filter((tag: string) => !staticTags.has(tag));
915
- if (jsTags.length === 0) return html;
957
+ if (jsTags.length === 0) {
958
+ return result;
959
+ }
916
960
 
917
961
  // Build import map (needed for @vue/reactivity and lit-html)
918
962
  const importMap = `<script type="importmap">
@@ -929,10 +973,10 @@ function injectComponentScripts(
929
973
  .join("\n ");
930
974
 
931
975
  // Check if an import map already exists (from islands etc.)
932
- const hasImportMap = html.includes('<script type="importmap">');
976
+ const hasImportMap = result.includes('<script type="importmap">');
933
977
  const injection = (hasImportMap ? "" : `${importMap}\n `) + moduleScripts;
934
978
 
935
- return html.replace("</body>", ` ${injection}\n</body>`);
979
+ return result.replace("</body>", ` ${injection}\n</body>`);
936
980
  }
937
981
 
938
982
  /**
@@ -969,23 +1013,28 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
969
1013
  let preservedBlocks = "";
970
1014
  if (existingMatch) {
971
1015
  const styles = existingMatch[1].match(/<style>[\s\S]*?<\/style>/gi);
972
- if (styles) preservedBlocks += "\n " + styles.join("\n ");
1016
+ if (styles) {
1017
+ preservedBlocks += `\n ${styles.join("\n ")}`;
1018
+ }
973
1019
  const scripts = existingMatch[1].match(/<script[\s\S]*?<\/script>/gi);
974
- if (scripts) preservedBlocks += "\n " + scripts.join("\n ");
1020
+ if (scripts) {
1021
+ preservedBlocks += `\n ${scripts.join("\n ")}`;
1022
+ }
975
1023
  }
976
- if (headPattern.test(html)) {
977
- html = html.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
1024
+ let result = html;
1025
+ if (headPattern.test(result)) {
1026
+ result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
978
1027
  }
979
1028
 
980
1029
  // Set the lang attribute on <html>
981
- html = html.replace(/<html\s[^>]*>/i, (match: string) => {
1030
+ result = result.replace(/<html\s[^>]*>/i, (match: string) => {
982
1031
  if (/lang=/.test(match)) {
983
1032
  return match.replace(/lang="[^"]*"/, `lang="${lang}"`);
984
1033
  }
985
1034
  return match.replace("<html", `<html lang="${lang}"`);
986
1035
  });
987
1036
 
988
- return html;
1037
+ return result;
989
1038
  }
990
1039
 
991
1040
  /**
@@ -1011,7 +1060,7 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
1011
1060
  return join(outDir, segments, "index.html");
1012
1061
  }
1013
1062
 
1014
- // trailingSlash: "never" or default
1063
+ // TrailingSlash: "never" or default
1015
1064
  return join(outDir, `${segments}.html`);
1016
1065
  }
1017
1066
 
@@ -1055,14 +1104,14 @@ function generateRedirects(
1055
1104
  </html>`;
1056
1105
  mkdirSync(dirname(htmlPath), { recursive: true });
1057
1106
  writeFileSync(htmlPath, html, "utf8");
1058
- count++;
1107
+ count += 1;
1059
1108
  redirectLines.push(`${source} ${dest} ${status}`);
1060
1109
  }
1061
1110
 
1062
1111
  // Write _redirects file (Netlify/Cloudflare format)
1063
1112
  if (redirectLines.length > 0) {
1064
- writeFileSync(join(outDir, "_redirects"), redirectLines.join("\n") + "\n", "utf8");
1065
- count++;
1113
+ writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
1114
+ count += 1;
1066
1115
  }
1067
1116
 
1068
1117
  return count;
@@ -1073,7 +1122,7 @@ function generateRedirects(
1073
1122
  * @returns {string}
1074
1123
  */
1075
1124
  function escapeHtml(str: string) {
1076
- return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1125
+ return String(str).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1077
1126
  }
1078
1127
 
1079
1128
  /**
@@ -1081,5 +1130,5 @@ function escapeHtml(str: string) {
1081
1130
  * @returns {string}
1082
1131
  */
1083
1132
  function escapeAttr(str: string) {
1084
- return String(str).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1133
+ return String(str).replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
1085
1134
  }