@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,15 +45,16 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@apidevtools/json-schema-ref-parser": "^15.5.0",
48
- "@jxsuite/create": "^1.1.0",
49
- "@jxsuite/runtime": "^1.1.0",
50
- "@jxsuite/schema": "^1.1.0",
48
+ "@jxsuite/create": "^1.1.1",
49
+ "@jxsuite/runtime": "^1.2.0",
50
+ "@jxsuite/schema": "^1.2.0",
51
+ "esbuild": "^0.28.1",
51
52
  "sharp": "^0.35.3"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@happy-dom/global-registrator": "^20.10.6",
55
- "@jxsuite/connector": "^0.3.0",
56
- "@jxsuite/parser": "^1.1.0",
56
+ "@jxsuite/connector": "^0.3.1",
57
+ "@jxsuite/parser": "^1.2.0",
57
58
  "@types/mdast": "^4.0.4"
58
59
  }
59
60
  }
package/src/compiler.ts CHANGED
@@ -46,6 +46,13 @@ export interface CompileOptions {
46
46
  litHtmlSrc?: string;
47
47
  projectStyle?: JxStyle | null;
48
48
  formats?: FormatRegistry;
49
+ /** Rewrite Function-def `$src` specifiers to served URLs (see CompileElementOptions). */
50
+ rewriteSrc?: (specifier: string) => string;
51
+ /**
52
+ * Inject the color-scheme pre-paint script when `$media` declares a pure scheme query (default
53
+ * true). The site pipeline disables this and injects via the merged <head> instead.
54
+ */
55
+ prePaintScheme?: boolean;
49
56
  [key: string]: unknown;
50
57
  }
51
58
 
@@ -107,6 +114,8 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
107
114
  litHtmlSrc,
108
115
  projectStyle,
109
116
  reactivitySrc,
117
+ ...(opts.prePaintScheme === false ? { prePaintScheme: false } : {}),
118
+ ...(opts.rewriteSrc ? { rewriteSrc: opts.rewriteSrc } : {}),
110
119
  title,
111
120
  });
112
121
  }
@@ -115,7 +124,7 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
115
124
  if (raw.tagName && raw.tagName.includes("-")) {
116
125
  const { tagName } = raw;
117
126
  const className = tagNameToClassName(tagName);
118
- const moduleContent = emitElementModule(raw, className, []);
127
+ const moduleContent = emitElementModule(raw, className, [], opts.rewriteSrc);
119
128
  const moduleFile = {
120
129
  content: moduleContent,
121
130
  path: `${tagName}.js`,
@@ -149,7 +158,14 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
149
158
  }
150
159
 
151
160
  // Route 3: Dynamic with standard tagName → pre-rendered HTML + reactive bindings
152
- return compileClient(raw, { litHtmlSrc, projectStyle, reactivitySrc, title });
161
+ return compileClient(raw, {
162
+ litHtmlSrc,
163
+ projectStyle,
164
+ reactivitySrc,
165
+ ...(opts.prePaintScheme === false ? { prePaintScheme: false } : {}),
166
+ ...(opts.rewriteSrc ? { rewriteSrc: opts.rewriteSrc } : {}),
167
+ title,
168
+ });
153
169
  }
154
170
 
155
171
  // ─── CLI ──────────────────────────────────────────────────────────────────────
package/src/shared.ts CHANGED
@@ -6,7 +6,15 @@
6
6
  * compilation targets (static, client, element, server).
7
7
  */
8
8
 
9
- import { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
9
+ import {
10
+ COLOR_SCHEME_ATTR,
11
+ COLOR_SCHEME_STORAGE_KEY,
12
+ RESERVED_KEYS,
13
+ camelToKebab,
14
+ pureSchemeOf,
15
+ schemeSelectors,
16
+ toCSSText,
17
+ } from "@jxsuite/runtime";
10
18
  import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
11
19
  import { runStatements } from "@jxsuite/runtime/statements";
12
20
  import {
@@ -36,7 +44,15 @@ import type {
36
44
  } from "@jxsuite/schema/types";
37
45
 
38
46
  // Re-export runtime utilities used by submodules
39
- export { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
47
+ export {
48
+ COLOR_SCHEME_ATTR,
49
+ COLOR_SCHEME_STORAGE_KEY,
50
+ RESERVED_KEYS,
51
+ camelToKebab,
52
+ pureSchemeOf,
53
+ schemeSelectors,
54
+ toCSSText,
55
+ } from "@jxsuite/runtime";
40
56
  export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
41
57
  export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
42
58
 
@@ -642,6 +658,94 @@ export function buildInner(
642
658
 
643
659
  // ─── CSS extraction ───────────────────────────────────────────────────────────
644
660
 
661
+ /**
662
+ * The inline pre-paint script injected into <head> when a project declares a pure color-scheme
663
+ * media query: restores the visitor's persisted forced scheme before first paint (no FOUC).
664
+ *
665
+ * @returns {string}
666
+ * @docs framework/concepts/color-schemes
667
+ */
668
+ export function colorSchemePrePaintScript(): string {
669
+ const key = JSON.stringify(COLOR_SCHEME_STORAGE_KEY);
670
+ const attr = JSON.stringify(COLOR_SCHEME_ATTR);
671
+ return `(function(){try{var s=localStorage.getItem(${key});if(s==="light"||s==="dark"){document.documentElement.setAttribute(${attr},s)}}catch(e){}})()`;
672
+ }
673
+
674
+ /**
675
+ * Resolve an `@`-prefixed style key into an emit function that pushes conditional rules. Pure
676
+ * color-scheme queries dual-emit per the forced-scheme contract (spec §9.5): a media-guarded copy
677
+ * that applies while no scheme is forced plus an unconditional copy under the forced root
678
+ * attribute.
679
+ *
680
+ * @param {string} atKey
681
+ * @param {Record<string, string>} mediaQueries
682
+ * @param {string[]} rules
683
+ * @returns {(selector: string, props: string) => void}
684
+ * @docs framework/concepts/color-schemes
685
+ */
686
+ function conditionalRuleEmitter(
687
+ atKey: string,
688
+ mediaQueries: Record<string, string>,
689
+ rules: string[],
690
+ ): (selector: string, props: string) => void {
691
+ const query = atKey.startsWith("@--")
692
+ ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
693
+ : atKey.startsWith("@(")
694
+ ? atKey.slice(1)
695
+ : null;
696
+ const atRule = query === null ? atKey : `@media ${query}`;
697
+ const scheme = query === null ? null : pureSchemeOf(query);
698
+ return (selector: string, props: string) => {
699
+ if (!props) {
700
+ return;
701
+ }
702
+ if (scheme) {
703
+ const { auto, forced } = schemeSelectors(selector, scheme);
704
+ rules.push(`${atRule} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
705
+ } else {
706
+ rules.push(`${atRule} { ${selector} { ${props} } }`);
707
+ }
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Push the rules for one `@`-prefixed style block scoped to `selector`: flat props plus one level
713
+ * of nested selectors, routed through conditionalRuleEmitter (scheme-aware).
714
+ *
715
+ * @param {string[]} rules
716
+ * @param {string} atKey
717
+ * @param {Record<string, string>} mediaQueries
718
+ * @param {string} selector
719
+ * @param {Record<string, unknown>} obj
720
+ */
721
+ function pushConditionalRule(
722
+ rules: string[],
723
+ atKey: string,
724
+ mediaQueries: Record<string, string>,
725
+ selector: string,
726
+ obj: Record<string, unknown>,
727
+ ) {
728
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
729
+ return;
730
+ }
731
+ const emit = conditionalRuleEmitter(atKey, mediaQueries, rules);
732
+ emit(selector, toCSSText(obj));
733
+ for (const [sel, sub] of Object.entries(obj)) {
734
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
735
+ continue;
736
+ }
737
+ if (sel.startsWith("@")) {
738
+ continue;
739
+ }
740
+ const resolved = sel.startsWith("&")
741
+ ? sel.replace("&", selector)
742
+ : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
743
+ ? `${selector}${sel}`
744
+ : `${selector} ${sel}`;
745
+ emit(resolved, toCSSText(sub));
746
+ }
747
+ }
748
+
645
749
  /**
646
750
  * Walk the entire document tree and collect all static nested CSS rules.
647
751
  *
@@ -671,30 +775,7 @@ export function compileStyles(
671
775
  continue;
672
776
  }
673
777
  if (key.startsWith("@")) {
674
- const atRule = key.startsWith("@--")
675
- ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
676
- : key;
677
- const mProps = toCSSText(val);
678
- if (mProps) {
679
- rules.push(`${atRule} { ${selector} { ${mProps} } }`);
680
- }
681
- for (const [sel, sub] of Object.entries(val as Record<string, unknown>)) {
682
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
683
- continue;
684
- }
685
- if (sel.startsWith("@")) {
686
- continue;
687
- }
688
- const resolved = sel.startsWith("&")
689
- ? sel.replace("&", selector)
690
- : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
691
- ? `${selector}${sel}`
692
- : `${selector} ${sel}`;
693
- const subProps = toCSSText(sub);
694
- if (subProps) {
695
- rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
696
- }
697
- }
778
+ pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
698
779
  continue;
699
780
  }
700
781
  const resolved = key.startsWith("&")
@@ -706,27 +787,6 @@ export function compileStyles(
706
787
  }
707
788
  };
708
789
 
709
- for (const [key, val] of Object.entries(projectStyle)) {
710
- if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
711
- emitProjectRules(key, val as Record<string, unknown>);
712
- } else if (
713
- val !== null &&
714
- typeof val === "object" &&
715
- !Array.isArray(val) &&
716
- !key.startsWith("@") &&
717
- !key.startsWith("--")
718
- ) {
719
- emitProjectRules(key, val as Record<string, unknown>);
720
- } else if (key.startsWith("@")) {
721
- // @media block at top level
722
- const atRule = key.startsWith("@--")
723
- ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
724
- : key.startsWith("@(")
725
- ? `@media ${key.slice(1)}`
726
- : key;
727
- rules.push(`${atRule} { body { ${toCSSText(val as Record<string, unknown>)} } }`);
728
- }
729
- }
730
790
  // Collect CSS custom properties into :root {}
731
791
  const rootProps: Record<string, unknown> = {};
732
792
  // Collect direct CSS properties into body {}
@@ -749,6 +809,7 @@ export function compileStyles(
749
809
  bodyProps[key] = val;
750
810
  }
751
811
  }
812
+ // Base rules precede conditional blocks so equal-specificity overrides win by source order.
752
813
  const rootCSS = toCSSText(rootProps);
753
814
  if (rootCSS) {
754
815
  rules.push(`:root { ${rootCSS} }`);
@@ -757,6 +818,62 @@ export function compileStyles(
757
818
  if (bodyCSS) {
758
819
  rules.push(`body { ${bodyCSS} }`);
759
820
  }
821
+
822
+ for (const [key, val] of Object.entries(projectStyle)) {
823
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
824
+ emitProjectRules(key, val as Record<string, unknown>);
825
+ } else if (
826
+ val !== null &&
827
+ typeof val === "object" &&
828
+ !Array.isArray(val) &&
829
+ !key.startsWith("@") &&
830
+ !key.startsWith("--")
831
+ ) {
832
+ emitProjectRules(key, val as Record<string, unknown>);
833
+ } else if (
834
+ key.startsWith("@") &&
835
+ val !== null &&
836
+ typeof val === "object" &&
837
+ !Array.isArray(val)
838
+ ) {
839
+ // Conditional block at project top level: custom properties override :root, direct
840
+ // Properties override body, selector-keyed sub-objects their own selector.
841
+ const emit = conditionalRuleEmitter(key, mediaQueries, rules);
842
+ const condRoot: Record<string, unknown> = {};
843
+ const condBody: Record<string, unknown> = {};
844
+ const condSubs: [string, Record<string, unknown>][] = [];
845
+ for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
846
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
847
+ if (!k.startsWith("@")) {
848
+ condSubs.push([k, v as Record<string, unknown>]);
849
+ }
850
+ continue;
851
+ }
852
+ if (k.startsWith("--")) {
853
+ condRoot[k] = v;
854
+ } else {
855
+ condBody[k] = v;
856
+ }
857
+ }
858
+ emit(":root", toCSSText(condRoot));
859
+ emit("body", toCSSText(condBody));
860
+ for (const [sel, sub] of condSubs) {
861
+ emit(sel, toCSSText(sub));
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ // Forced-scheme UA hint: native widgets follow the forced attribute, not only the OS scheme.
868
+ if (
869
+ Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null) &&
870
+ !(projectStyle && typeof projectStyle === "object" && "colorScheme" in projectStyle)
871
+ ) {
872
+ rules.push(
873
+ ":root { color-scheme: light dark }",
874
+ `:root:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
875
+ `:root:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
876
+ );
760
877
  }
761
878
 
762
879
  const counter = { n: 0 };
@@ -790,32 +907,7 @@ function emitNestedElement(
790
907
  continue;
791
908
  }
792
909
  if (key.startsWith("@")) {
793
- const atRule = key.startsWith("@--")
794
- ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
795
- : key.startsWith("@(")
796
- ? `@media ${key.slice(1)}`
797
- : key;
798
- const mProps = toCSSText(val);
799
- if (mProps) {
800
- rules.push(`${atRule} { ${selector} { ${mProps} } }`);
801
- }
802
- for (const [sel, sub] of Object.entries(val as Record<string, unknown>)) {
803
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
804
- continue;
805
- }
806
- if (sel.startsWith("@")) {
807
- continue;
808
- }
809
- const resolved = sel.startsWith("&")
810
- ? sel.replace("&", selector)
811
- : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
812
- ? `${selector}${sel}`
813
- : `${selector} ${sel}`;
814
- const subProps = toCSSText(sub);
815
- if (subProps) {
816
- rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
817
- }
818
- }
910
+ pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
819
911
  continue;
820
912
  }
821
913
  const resolved = key.startsWith("&")
@@ -887,35 +979,7 @@ export function collectStyles(
887
979
  continue;
888
980
  }
889
981
  if (prop.startsWith("@")) {
890
- const atRule = prop.startsWith("@--")
891
- ? `@media ${mediaQueries[prop.slice(1)] ?? prop.slice(1)}`
892
- : prop.startsWith("@(")
893
- ? `@media ${prop.slice(1)}`
894
- : prop;
895
- const atBaseProps = toCSSText(val);
896
- if (atBaseProps) {
897
- rules.push(`${atRule} { ${selector} { ${atBaseProps} } }`);
898
- }
899
- for (const [sel, nestedRules] of Object.entries(
900
- /** @type {Record<string, unknown>} */ val,
901
- )) {
902
- if (
903
- nestedRules === null ||
904
- typeof nestedRules !== "object" ||
905
- Array.isArray(nestedRules)
906
- ) {
907
- continue;
908
- }
909
- if (sel.startsWith("@")) {
910
- continue;
911
- }
912
- const resolved = sel.startsWith("&")
913
- ? sel.replace("&", selector)
914
- : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
915
- ? `${selector}${sel}`
916
- : `${selector} ${sel}`;
917
- rules.push(`${atRule} { ${resolved} { ${toCSSText(nestedRules)} } }`);
918
- }
982
+ pushConditionalRule(rules, prop, mediaQueries, selector, val as Record<string, unknown>);
919
983
  } else {
920
984
  const resolved = prop.startsWith("&")
921
985
  ? prop.replace("&", selector)
@@ -1325,12 +1389,7 @@ export function buildComponentCSS(
1325
1389
 
1326
1390
  for (const [prop, val] of Object.entries(styleDef)) {
1327
1391
  if (prop.startsWith("@")) {
1328
- const atRule = prop.startsWith("@--")
1329
- ? `@media ${mediaQueries[prop.slice(1)] ?? prop.slice(1)}`
1330
- : prop.startsWith("@(")
1331
- ? `@media ${prop.slice(1)}`
1332
- : prop;
1333
- rules.push(`${atRule} { ${tagName} { ${toCSSText(val as Record<string, unknown>)} } }`);
1392
+ pushConditionalRule(rules, prop, mediaQueries, tagName, val as Record<string, unknown>);
1334
1393
  } else if (
1335
1394
  prop.startsWith(":") ||
1336
1395
  prop.startsWith(".") ||
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Bundler.js — timing-aware module bundling for compiled sites
3
+ *
4
+ * One backend abstraction serving every bundling need of the build (spec.md §12, compiler.md):
5
+ * client-timing `$src` sidecars today, the server worker in a follow-up. Backend selection is
6
+ * runtime-adaptive: `Bun.build` when the build runs under Bun, esbuild (dynamically imported) under
7
+ * plain Node. Options are kept minimal and identical across backends so output stays as stable as
8
+ * possible; set `JX_BUNDLER=esbuild` to force the fallback (e.g. for parity checks).
9
+ *
10
+ * `timing: "compiler"` code is never bundled — it executes in the build host via the extension
11
+ * registry's `importImplementation` path.
12
+ */
13
+
14
+ import { rmSync, writeFileSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { join, resolve } from "node:path";
17
+ import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
18
+
19
+ /** Import specifiers left external in client bundles — provided by the page importmap. */
20
+ export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
21
+
22
+ /**
23
+ * True when a Function-def `$src` specifier is bundled by the build: `npm:` package specifiers and
24
+ * project-relative files. Absolute URL paths (`/lib/x.js`, `https://…`) are served as-is.
25
+ */
26
+ export function isBundleableSrc(specifier: string): boolean {
27
+ return isNpmSpecifier(specifier) || specifier.startsWith("./") || specifier.startsWith("../");
28
+ }
29
+
30
+ /**
31
+ * True when the Node code path (createRequire resolution, esbuild backend) should be used: always
32
+ * under plain Node, or when `JX_BUNDLER=esbuild` forces it for parity checks.
33
+ */
34
+ function useNodeFallback(): boolean {
35
+ return typeof Bun === "undefined" || process.env.JX_BUNDLER === "esbuild";
36
+ }
37
+
38
+ /**
39
+ * Resolve a bundleable `$src` specifier to the absolute entry file the bundler starts from. `npm:`
40
+ * specifiers resolve from the project root (project node_modules first, then up-tree); relative
41
+ * specifiers resolve against the directory of the document that declared them.
42
+ */
43
+ export function resolveSidecarEntry(
44
+ specifier: string,
45
+ docDir: string,
46
+ projectRoot: string,
47
+ ): string {
48
+ if (isNpmSpecifier(specifier)) {
49
+ const bare = specifier.slice(NPM_SPECIFIER_PREFIX.length);
50
+ if (useNodeFallback()) {
51
+ return createRequire(join(projectRoot, "package.json")).resolve(bare);
52
+ }
53
+ return Bun.resolveSync(bare, projectRoot);
54
+ }
55
+ return resolve(docDir, specifier);
56
+ }
57
+
58
+ export interface BundleRequest {
59
+ /** Absolute path of the module to bundle. */
60
+ entryPath: string;
61
+ /** Absolute path of the bundled output file. */
62
+ outfile: string;
63
+ }
64
+
65
+ /** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
66
+ export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
67
+
68
+ export interface BundleOptions {
69
+ target: BundleTargetName;
70
+ /** Bare specifiers left unresolved in the output. */
71
+ external?: string[];
72
+ /** Package.json export conditions for resolution (e.g. ["workerd", "worker"]). */
73
+ conditions?: string[];
74
+ }
75
+
76
+ /**
77
+ * Per-adapter bundle options for the generated site worker (WinterTC runtimes). Cloudflare resolves
78
+ * with workerd conditions and keeps `cloudflare:*`/`node:*` imports external (nodejs_compat
79
+ * provides node builtins at runtime); node/bun bundle platform-native with builtins external by
80
+ * construction.
81
+ */
82
+ export function workerBundleOptions(adapter: string): BundleOptions {
83
+ if (adapter === "cloudflare-workers" || adapter === "cloudflare-pages") {
84
+ return {
85
+ conditions: ["workerd", "worker"],
86
+ external: ["cloudflare:*", "node:*"],
87
+ target: "workerd",
88
+ };
89
+ }
90
+ return { target: adapter === "bun" ? "bun" : "node" };
91
+ }
92
+
93
+ /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
94
+ function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
95
+ return target === "node" || target === "bun" ? "node" : "browser";
96
+ }
97
+
98
+ /** Map a bundle target to Bun.build's target. */
99
+ function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
100
+ if (target === "workerd") {
101
+ return "browser";
102
+ }
103
+ return target;
104
+ }
105
+
106
+ /**
107
+ * Bundle one entry module into a single self-contained ESM file. Throws with the backend's
108
+ * diagnostics on failure.
109
+ */
110
+ export async function bundleEntry(request: BundleRequest, opts: BundleOptions): Promise<void> {
111
+ const external = opts.external ?? [];
112
+
113
+ if (useNodeFallback()) {
114
+ // Dynamic import keeps esbuild (a native-binary dependency) out of Bun-run builds entirely.
115
+ const esbuild = await import("esbuild");
116
+ await esbuild.build({
117
+ bundle: true,
118
+ ...(opts.conditions ? { conditions: opts.conditions } : {}),
119
+ entryPoints: [request.entryPath],
120
+ external,
121
+ format: "esm",
122
+ logLevel: "silent",
123
+ minify: false,
124
+ outfile: request.outfile,
125
+ platform: esbuildPlatform(opts.target),
126
+ });
127
+ return;
128
+ }
129
+
130
+ const result = await Bun.build({
131
+ ...(opts.conditions ? { conditions: opts.conditions } : {}),
132
+ entrypoints: [request.entryPath],
133
+ external,
134
+ format: "esm",
135
+ minify: false,
136
+ target: bunTarget(opts.target),
137
+ throw: false,
138
+ });
139
+ if (!result.success || result.outputs.length === 0) {
140
+ const logs = result.logs.map(String).join("\n");
141
+ throw new Error(`Bun.build failed for ${request.entryPath}:\n${logs}`);
142
+ }
143
+ writeFileSync(request.outfile, await result.outputs[0]!.text(), "utf8");
144
+ }
145
+
146
+ /**
147
+ * Bundle generated worker source (a string, not a file) into a self-contained module for the
148
+ * adapter's runtime. Bare and relative specifiers resolve from the project root — exactly how the
149
+ * unbundled source would have resolved when served from within the project.
150
+ */
151
+ export async function bundleWorkerSource(
152
+ source: string,
153
+ { projectRoot, outfile, adapter }: { projectRoot: string; outfile: string; adapter: string },
154
+ ): Promise<void> {
155
+ const opts = workerBundleOptions(adapter);
156
+
157
+ if (useNodeFallback()) {
158
+ const esbuild = await import("esbuild");
159
+ await esbuild.build({
160
+ bundle: true,
161
+ ...(opts.conditions ? { conditions: opts.conditions } : {}),
162
+ external: opts.external ?? [],
163
+ format: "esm",
164
+ logLevel: "silent",
165
+ minify: false,
166
+ outfile,
167
+ platform: esbuildPlatform(opts.target),
168
+ stdin: {
169
+ contents: source,
170
+ loader: "js",
171
+ resolveDir: projectRoot,
172
+ sourcefile: "jx-worker-entry.js",
173
+ },
174
+ });
175
+ return;
176
+ }
177
+
178
+ // Bun.build has no stdin input — write a transient entry at the project root so resolution
179
+ // Matches the stdin/resolveDir behavior, and always remove it.
180
+ const entryPath = join(projectRoot, ".jx-worker-entry.mjs");
181
+ writeFileSync(entryPath, source, "utf8");
182
+ try {
183
+ await bundleEntry({ entryPath, outfile }, opts);
184
+ } finally {
185
+ rmSync(entryPath, { force: true });
186
+ }
187
+ }
@@ -79,6 +79,7 @@ export async function resolvePrototypes(
79
79
  config?: Record<string, unknown>;
80
80
  sections?: Record<string, unknown>;
81
81
  registry?: ExtensionRegistry;
82
+ registerBundle?: (specifier: string) => void;
82
83
  } = {},
83
84
  ) {
84
85
  const imports = doc.imports ?? {};
@@ -107,6 +108,20 @@ export async function resolvePrototypes(
107
108
  root: projectRoot,
108
109
  route,
109
110
  });
111
+ // A lowered def may name client modules it depends on via `$bundle`
112
+ // (extensions.md §8.3): the site build bundles each specifier for the browser.
113
+ // The key is host metadata, not part of the core def shape — strip it.
114
+ if (lowered && typeof lowered === "object" && !Array.isArray(lowered)) {
115
+ const bundles = (lowered as JsonObject).$bundle;
116
+ if (Array.isArray(bundles)) {
117
+ for (const specifier of bundles) {
118
+ if (typeof specifier === "string") {
119
+ projectContext.registerBundle?.(specifier);
120
+ }
121
+ }
122
+ }
123
+ delete (lowered as JsonObject).$bundle;
124
+ }
110
125
  state[key] = lowered as JxStateDefinition;
111
126
  } catch (error) {
112
127
  console.warn(