@jxsuite/compiler 1.1.0 → 1.2.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.
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import {
12
- copyFileSync,
13
12
  cpSync,
14
13
  existsSync,
15
14
  mkdirSync,
@@ -19,8 +18,16 @@ import {
19
18
  statSync,
20
19
  writeFileSync,
21
20
  } from "node:fs";
22
- import { basename, dirname, join, resolve } from "node:path";
21
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
22
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
+ import { isNpmSpecifier, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
+ import {
25
+ CLIENT_EXTERNALS,
26
+ bundleEntry,
27
+ bundleWorkerSource,
28
+ isBundleableSrc,
29
+ resolveSidecarEntry,
30
+ } from "./bundler.ts";
24
31
  import { loadProjectConfig } from "./site-loader.ts";
25
32
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
26
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
@@ -40,10 +47,12 @@ import {
40
47
  buildInitialScope,
41
48
  collectServerEntries,
42
49
  collectStyles,
50
+ colorSchemePrePaintScript,
43
51
  evaluateStaticTemplate,
44
52
  isComponentFullyStatic,
45
53
  isTemplateString,
46
54
  preRenderComponentHtml,
55
+ pureSchemeOf,
47
56
  renderStaticNode,
48
57
  resolveRefValue,
49
58
  resolveStaticValue,
@@ -106,6 +115,43 @@ export async function buildSite(
106
115
  const publicDir = resolve(projectRoot, "public");
107
116
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
108
117
 
118
+ // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
119
+ // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
120
+ // In step 6d (spec.md §12). Keyed by asset path so duplicate specifiers bundle once.
121
+ const sidecarBundles = new Map<string, { entryPath: string; specifier: string }>();
122
+ const rewriteSidecarSrc = (specifier: string, docDir: string | null): string => {
123
+ if (!isBundleableSrc(specifier)) {
124
+ return specifier;
125
+ }
126
+ try {
127
+ const entryPath = resolveSidecarEntry(specifier, docDir ?? projectRoot, projectRoot);
128
+ // A declared-but-absent relative sidecar keeps its verbatim import (the historical
129
+ // Behavior) with a warning — the doc may only ever run interpreted. Unresolvable npm
130
+ // Specifiers stay hard errors: a missing dependency should fail the build.
131
+ if (!isNpmSpecifier(specifier) && !existsSync(entryPath)) {
132
+ console.warn(`Sidecar "${specifier}" not found at ${entryPath} — emitting unbundled`);
133
+ return specifier;
134
+ }
135
+ // Relative specifiers key on their project-relative resolved path so `./x.js` declared in
136
+ // Two directories cannot collide on one slug; npm specifiers are location-independent.
137
+ const assetKey = isNpmSpecifier(specifier)
138
+ ? specifier
139
+ : `./${relative(projectRoot, entryPath)}`;
140
+ const assetPath = sidecarAssetPath(assetKey);
141
+ const existing = sidecarBundles.get(assetPath);
142
+ if (existing && existing.entryPath !== entryPath) {
143
+ throw new Error(
144
+ `"${specifier}" and "${existing.specifier}" both bundle to ${assetPath} — rename one`,
145
+ );
146
+ }
147
+ sidecarBundles.set(assetPath, { entryPath, specifier });
148
+ return assetPath;
149
+ } catch (error) {
150
+ errors.push(`Sidecar "${specifier}": ${(error as Error).message}`);
151
+ return specifier;
152
+ }
153
+ };
154
+
109
155
  // ── 1b. Build the extension registry from project.json#/extensions ─────
110
156
  const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
111
157
  const formatRegistry = registry.formats;
@@ -194,6 +240,7 @@ export async function buildSite(
194
240
  const result = await compileElement(componentPath, {
195
241
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
196
242
  formats: formatRegistry,
243
+ rewriteSrc: rewriteSidecarSrc,
197
244
  });
198
245
  for (const f of result.files) {
199
246
  // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
@@ -277,6 +324,7 @@ export async function buildSite(
277
324
  imageMetaCache,
278
325
  formatRegistry,
279
326
  registry,
327
+ rewriteSidecarSrc,
280
328
  );
281
329
 
282
330
  // Determine which component tags are fully static (for script omission)
@@ -412,10 +460,23 @@ export async function buildSite(
412
460
  : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
413
461
 
414
462
  if (workerSource) {
463
+ // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
464
+ // Modules, connectors, hono, and user server functions are inlined, so dist deploys
465
+ // Without node_modules or deploy-time bundling.
415
466
  const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
416
- writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
417
- fileCount += 1;
418
- log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
467
+ try {
468
+ await bundleWorkerSource(workerSource, {
469
+ adapter,
470
+ outfile: resolve(outDir, workerName),
471
+ projectRoot,
472
+ });
473
+ fileCount += 1;
474
+ log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
475
+ } catch (error) {
476
+ const msg = `Error bundling dist/${workerName}: ${(error as Error).message}`;
477
+ errors.push(msg);
478
+ console.error(msg);
479
+ }
419
480
 
420
481
  if (adapter === "cloudflare-pages") {
421
482
  // Only invoke the worker for server routes; everything else stays static.
@@ -426,17 +487,68 @@ export async function buildSite(
426
487
  );
427
488
  fileCount += 1;
428
489
  }
490
+ }
491
+ }
492
+
493
+ // ── 6d. Bundle client sidecar modules (spec.md §12) ─────────────────────
494
+ if (sidecarBundles.size > 0) {
495
+ log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
496
+ for (const [assetPath, bundle] of sidecarBundles) {
497
+ try {
498
+ const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
499
+ mkdirSync(dirname(outfile), { recursive: true });
500
+ await bundleEntry(
501
+ { entryPath: bundle.entryPath, outfile },
502
+ { external: CLIENT_EXTERNALS, target: "browser" },
503
+ );
504
+ fileCount += 1;
505
+ log(` ${bundle.specifier} → ${assetPath}`);
506
+ } catch (error) {
507
+ const msg = `Error bundling sidecar "${bundle.specifier}": ${(error as Error).message}`;
508
+ errors.push(msg);
509
+ console.error(msg);
510
+ }
511
+ }
512
+ }
429
513
 
430
- // Copy server source files into dist/components/ so worker imports resolve
431
- const distComponentsDir = resolve(outDir, "components");
432
- mkdirSync(distComponentsDir, { recursive: true });
433
- for (const { src } of deduped.values()) {
434
- const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
435
- const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
436
- if (existsSync(srcFile)) {
437
- copyFileSync(srcFile, destFile);
514
+ // ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
515
+ // Section-owner classes with an `emit` capability contribute derived build artifacts
516
+ // (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
517
+ // Paths are outDir-relative and guarded against traversal. Note the `public/` copy (7c)
518
+ // Runs later and can shadow emitted files — same semantics as sitemap.xml.
519
+ for (const entry of registry.emitters()) {
520
+ const sectionKey = entry.project?.key;
521
+ const sectionValue = sectionKey
522
+ ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
523
+ : undefined;
524
+ if (
525
+ sectionKey &&
526
+ (sectionValue == null ||
527
+ (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
528
+ ) {
529
+ continue; // Section owner with nothing declared — same gating as sections/mounts
530
+ }
531
+ try {
532
+ const emitted = (await entry.call("emit", sectionValue ?? null, {
533
+ projectConfig,
534
+ root: projectRoot,
535
+ routes,
536
+ sections,
537
+ })) as { path: string; content: string | Uint8Array }[] | null;
538
+ for (const file of emitted ?? []) {
539
+ const target = resolve(outDir, file.path.replace(/^\//, ""));
540
+ if (!target.startsWith(outDir + sep)) {
541
+ throw new Error(`emit path escapes outDir: ${file.path}`);
438
542
  }
543
+ mkdirSync(dirname(target), { recursive: true });
544
+ writeFileSync(target, file.content);
545
+ fileCount += 1;
546
+ log(` ${entry.name} emitted ${file.path}`);
439
547
  }
548
+ } catch (error) {
549
+ const msg = `Error in ${entry.name}.emit: ${(error as Error).message}`;
550
+ errors.push(msg);
551
+ console.error(msg);
440
552
  }
441
553
  }
442
554
 
@@ -517,6 +629,7 @@ async function compilePage(
517
629
  imageMetaCache: ImageMetaCache | null = null,
518
630
  formatRegistry?: FormatRegistry,
519
631
  registry?: ExtensionRegistry,
632
+ rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
520
633
  ) {
521
634
  // Load the raw page document (.json natively, other formats via the registry)
522
635
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -541,6 +654,13 @@ async function compilePage(
541
654
  config: projectConfig,
542
655
  sections,
543
656
  ...(registry === undefined ? {} : { registry }),
657
+ ...(rewriteSidecarSrc === undefined
658
+ ? {}
659
+ : {
660
+ registerBundle: (specifier: string) => {
661
+ rewriteSidecarSrc(specifier, null);
662
+ },
663
+ }),
544
664
  });
545
665
 
546
666
  // Build scope from resolved state so template strings in title/$head can be evaluated
@@ -609,6 +729,13 @@ async function compilePage(
609
729
  layoutDoc.$media = { ...projectConfig.$media, ...layoutDoc.$media };
610
730
  }
611
731
 
732
+ // Declaring a pure color-scheme query opts the page into the forced-scheme contract: restore
733
+ // The visitor's persisted scheme before first paint (spec §9.5). Injected after the
734
+ // Charset/viewport defaults and ahead of every preserved <style> block.
735
+ if (Object.values(layoutDoc.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)) {
736
+ mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
737
+ }
738
+
612
739
  // Transform <img> nodes for responsive image optimization
613
740
  if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
614
741
  await transformImageNodes(
@@ -623,7 +750,14 @@ async function compilePage(
623
750
  // Compile the document using the existing compiler
624
751
  const result = await compile(layoutDoc, {
625
752
  lang: projectConfig.defaults?.lang ?? "en",
753
+ prePaintScheme: false, // Injected via the merged <head> above, not the target template
626
754
  projectStyle: projectConfig.style ?? null,
755
+ ...(rewriteSidecarSrc === undefined
756
+ ? {}
757
+ : {
758
+ rewriteSrc: (specifier: string) =>
759
+ rewriteSidecarSrc(specifier, dirname(route.sourcePath as string)),
760
+ }),
627
761
  title,
628
762
  });
629
763
 
@@ -16,6 +16,7 @@ import {
16
16
  DEFAULT_LIT_HTML_SRC,
17
17
  DEFAULT_REACTIVITY_SRC,
18
18
  buildAttrs,
19
+ colorSchemePrePaintScript,
19
20
  compileExpression,
20
21
  compileStatements,
21
22
  compileStyles,
@@ -26,6 +27,7 @@ import {
26
27
  isRefObject,
27
28
  isSchemaOnly,
28
29
  isTemplateString,
30
+ pureSchemeOf,
29
31
  resolveStaticValue,
30
32
  } from "../shared.ts";
31
33
  import {
@@ -76,6 +78,7 @@ export function compileClient(
76
78
  litHtmlSrc?: string;
77
79
  modulePath?: string;
78
80
  projectStyle?: JxStyle | null;
81
+ prePaintScheme?: boolean;
79
82
  [key: string]: unknown;
80
83
  },
81
84
  ) {
@@ -238,6 +241,7 @@ export function compileClient(
238
241
  srcImportMap,
239
242
  counter,
240
243
  reactivitySrc,
244
+ opts.rewriteSrc as ((specifier: string) => string) | undefined,
241
245
  );
242
246
 
243
247
  // Build importmap entries — always include lit-html since compiled custom elements need it
@@ -246,13 +250,20 @@ export function compileClient(
246
250
  ` "lit-html": "${litHtmlSrc}"`,
247
251
  ];
248
252
 
253
+ // Forced-scheme contract (spec §9.5): restore the persisted scheme before any styles apply
254
+ const prePaint =
255
+ opts.prePaintScheme !== false &&
256
+ Object.values(raw.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)
257
+ ? `<script>${colorSchemePrePaintScript()}</script>\n `
258
+ : "";
259
+
249
260
  const html = `<!DOCTYPE html>
250
261
  <html lang="en">
251
262
  <head>
252
263
  <meta charset="utf-8">
253
264
  <meta name="viewport" content="width=device-width, initial-scale=1">
254
265
  <title>${escapeHtml(title)}</title>
255
- <script type="importmap">
266
+ ${prePaint}<script type="importmap">
256
267
  {
257
268
  "imports": {
258
269
  ${importmapEntries.join(",\n")}
@@ -757,6 +768,7 @@ function emitClientModule(
757
768
  needsLit: boolean;
758
769
  },
759
770
  _reactivitySrc: string,
771
+ rewriteSrc?: (specifier: string) => string,
760
772
  ) {
761
773
  const lines: string[] = [];
762
774
  const { needsLit } = counter;
@@ -776,9 +788,11 @@ function emitClientModule(
776
788
  lines.push("import { html, render } from 'lit-html';");
777
789
  }
778
790
 
779
- // $src imports
791
+ // $src imports — bundleable specifiers (npm:…, ./relative) are rewritten to their /assets/
792
+ // Bundle URL; the site build bundles them after all documents compile (spec.md §12).
780
793
  for (const [src, names] of srcImportMap) {
781
- lines.push(`import { ${[...names].join(", ")} } from '${src}';`);
794
+ const importPath = rewriteSrc ? rewriteSrc(src) : src;
795
+ lines.push(`import { ${[...names].join(", ")} } from '${importPath}';`);
782
796
  }
783
797
 
784
798
  // State — reactive state
@@ -41,6 +41,12 @@ import type {
41
41
  export interface CompileElementOptions {
42
42
  /** Resolve an `$elements` ref path to its emitted JS module path. */
43
43
  resolveElementPath?: (refPath: string, currentDir: string | null) => string;
44
+ /**
45
+ * Rewrite a Function-def `$src` specifier to its served URL (bundleable specifiers → their
46
+ * `/assets/` bundle path). Receives the directory of the declaring document for relative
47
+ * specifiers. Identity when omitted.
48
+ */
49
+ rewriteSrc?: (specifier: string, docDir: string | null) => string;
44
50
  $media?: Record<string, string>;
45
51
  formats?: FormatRegistry;
46
52
  basePath?: string | null;
@@ -135,7 +141,13 @@ export async function compileElement(
135
141
  if (optsMedia) {
136
142
  doc.$media = { ...optsMedia, ...doc.$media };
137
143
  }
138
- const jsContent = emitElementModule(doc, className, elementImports);
144
+ const { rewriteSrc } = opts;
145
+ const jsContent = emitElementModule(
146
+ doc,
147
+ className,
148
+ elementImports,
149
+ rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
150
+ );
139
151
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
140
152
  files.push({ content: jsContent, path: outputPath, tagName });
141
153
  }
@@ -244,7 +256,12 @@ function extractInitialValue(def: JxMutableNode) {
244
256
  * @param {string[]} elementImports
245
257
  * @returns {string}
246
258
  */
247
- export function emitElementModule(doc: JxDocument, className: string, elementImports: string[]) {
259
+ export function emitElementModule(
260
+ doc: JxDocument,
261
+ className: string,
262
+ elementImports: string[],
263
+ rewriteSrc?: (specifier: string) => string,
264
+ ) {
248
265
  const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
249
266
 
250
267
  if (doc.$id) {
@@ -269,7 +286,10 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
269
286
  }
270
287
  }
271
288
  for (const [srcPath, names] of srcImportMap) {
272
- lines.push(`import { ${names.join(", ")} } from '${srcPath}';`);
289
+ // Bundleable specifiers (npm:…, ./relative) are rewritten to their /assets/ bundle URL;
290
+ // The site build bundles them after all documents compile (spec.md §12).
291
+ const importPath = rewriteSrc ? rewriteSrc(srcPath) : srcPath;
292
+ lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
273
293
  }
274
294
 
275
295
  lines.push(
@@ -486,11 +506,16 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
486
506
  );
487
507
  }
488
508
  lines.push(
509
+ // Lifecycle: onMount (spec §16.4) — same microtask contract as the runtime
510
+ " if (typeof this.state.onMount === 'function') {",
511
+ " queueMicrotask(() => this.state.onMount(this.state));",
512
+ " }",
489
513
  " }",
490
514
  "",
491
515
  // DisconnectedCallback
492
516
  " disconnectedCallback() {",
493
517
  " if (this.#dispose) { this.#dispose(); this.#dispose = null; }",
518
+ " if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
494
519
  " }",
495
520
  "}",
496
521
  "",
@@ -7,10 +7,12 @@
7
7
 
8
8
  import {
9
9
  buildAttrs,
10
+ colorSchemePrePaintScript,
10
11
  compileStyles,
11
12
  createCompileContext,
12
13
  escapeHtml,
13
14
  isNodeDynamic,
15
+ pureSchemeOf,
14
16
  resolveStaticValue,
15
17
  SELF_CLOSING,
16
18
  } from "../shared.ts";
@@ -31,6 +33,7 @@ export function compileStaticPage(
31
33
  reactivitySrc?: string;
32
34
  litHtmlSrc?: string;
33
35
  projectStyle?: JxStyle | null;
36
+ prePaintScheme?: boolean;
34
37
  [key: string]: unknown;
35
38
  },
36
39
  ) {
@@ -51,7 +54,12 @@ export function compileStaticPage(
51
54
  let moduleScripts = "";
52
55
  if (islands.length > 0) {
53
56
  for (const island of islands) {
54
- const moduleContent = emitElementModule(island.def as JxDocument, island.className, []);
57
+ const moduleContent = emitElementModule(
58
+ island.def as JxDocument,
59
+ island.className,
60
+ [],
61
+ opts.rewriteSrc as ((specifier: string) => string) | undefined,
62
+ );
55
63
  files.push({
56
64
  content: moduleContent,
57
65
  path: `_islands/${island.tagName}.js`,
@@ -73,13 +81,20 @@ export function compileStaticPage(
73
81
  .join("\n ");
74
82
  }
75
83
 
84
+ // Forced-scheme contract (spec §9.5): restore the persisted scheme before any styles apply
85
+ const prePaint =
86
+ opts.prePaintScheme !== false &&
87
+ Object.values(raw.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)
88
+ ? `<script>${colorSchemePrePaintScript()}</script>\n `
89
+ : "";
90
+
76
91
  const html = `<!DOCTYPE html>
77
92
  <html lang="en">
78
93
  <head>
79
94
  <meta charset="utf-8">
80
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
81
96
  <title>${escapeHtml(title)}</title>
82
- ${importMap}
97
+ ${prePaint}${importMap}
83
98
  ${styleBlock}
84
99
  </head>
85
100
  <body>