@jxsuite/compiler 1.1.0 → 1.3.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.3.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.2.0",
49
+ "@jxsuite/runtime": "^1.3.0",
50
+ "@jxsuite/schema": "^1.3.0",
51
+ "esbuild": "^0.28.1",
51
52
  "sharp": "^0.35.3"
52
53
  },
53
54
  "devDependencies": {
54
- "@happy-dom/global-registrator": "^20.10.6",
55
- "@jxsuite/connector": "^0.3.0",
56
- "@jxsuite/parser": "^1.1.0",
55
+ "@happy-dom/global-registrator": "^20.11.0",
56
+ "@jxsuite/connector": "^0.4.0",
57
+ "@jxsuite/parser": "^1.3.0",
57
58
  "@types/mdast": "^4.0.4"
58
59
  }
59
60
  }
package/src/cli.ts CHANGED
@@ -26,7 +26,7 @@ Commands:
26
26
  dev [root] Start the dev server (requires @jxsuite/server and Bun)
27
27
  preview [root] Serve an already-built dist/ directory
28
28
  schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
29
- validate [root] Validate project.json against its generated project.schema.json
29
+ validate [root] Validate the whole project: project.json, documents, classes, and fragments
30
30
  db push [root] Sync the data section's tables to their connections (additive-only)
31
31
 
32
32
  Options:
@@ -161,18 +161,15 @@ Options:
161
161
  }
162
162
  } else if (command === "validate") {
163
163
  try {
164
- const { validateProjectFile } = await import("@jxsuite/schema/validate-project");
165
- const { valid, errors } = await validateProjectFile(projectRoot);
166
- if (valid) {
167
- console.log(`project.json is valid (${projectRoot})`);
164
+ const { formatProjectTreeIssues, validateProjectTree } =
165
+ await import("./site/validate-command.ts");
166
+ const result = await validateProjectTree(projectRoot);
167
+ if (result.valid) {
168
+ console.log(`Project is valid (${result.checked} files checked in ${projectRoot})`);
168
169
  } else {
169
- console.error(`project.json is INVALID (${projectRoot}):`);
170
- for (const error of errors ?? []) {
171
- const { instancePath, message } = (error ?? {}) as {
172
- instancePath?: string;
173
- message?: string;
174
- };
175
- console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
170
+ console.error(`Project is INVALID (${projectRoot}):`);
171
+ for (const line of formatProjectTreeIssues(result)) {
172
+ console.error(line);
176
173
  }
177
174
  process.exit(1);
178
175
  }
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 {
@@ -15,6 +23,7 @@ import {
15
23
  isExpressionDef,
16
24
  isFunctionDef,
17
25
  isMappedArray,
26
+ bodyReturnsValue,
18
27
  isNamedFormulaDef,
19
28
  isPrototypeDef,
20
29
  isRef,
@@ -36,7 +45,15 @@ import type {
36
45
  } from "@jxsuite/schema/types";
37
46
 
38
47
  // Re-export runtime utilities used by submodules
39
- export { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
48
+ export {
49
+ COLOR_SCHEME_ATTR,
50
+ COLOR_SCHEME_STORAGE_KEY,
51
+ RESERVED_KEYS,
52
+ camelToKebab,
53
+ pureSchemeOf,
54
+ schemeSelectors,
55
+ toCSSText,
56
+ } from "@jxsuite/runtime";
40
57
  export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
41
58
  export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
42
59
 
@@ -63,7 +80,7 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
63
80
  export type { ExpressionNode } from "@jxsuite/runtime/expression";
64
81
 
65
82
  // CDN defaults
66
- export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.32";
83
+ export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
67
84
  export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
68
85
 
69
86
  // ─── Schema keywords & detection ─────────────────────────────────────────────
@@ -347,7 +364,7 @@ export function buildInitialScope(
347
364
  const fn = new Function("state", ...names, body) as (
348
365
  state: Record<string, unknown>,
349
366
  ) => unknown;
350
- if (body.includes("return")) {
367
+ if (bodyReturnsValue(body)) {
351
368
  defineLazyScopeValue(scope, key, () => fn(scope));
352
369
  } else {
353
370
  setOwnScopeValue(scope, key, fn);
@@ -642,6 +659,94 @@ export function buildInner(
642
659
 
643
660
  // ─── CSS extraction ───────────────────────────────────────────────────────────
644
661
 
662
+ /**
663
+ * The inline pre-paint script injected into <head> when a project declares a pure color-scheme
664
+ * media query: restores the visitor's persisted forced scheme before first paint (no FOUC).
665
+ *
666
+ * @returns {string}
667
+ * @docs framework/concepts/color-schemes
668
+ */
669
+ export function colorSchemePrePaintScript(): string {
670
+ const key = JSON.stringify(COLOR_SCHEME_STORAGE_KEY);
671
+ const attr = JSON.stringify(COLOR_SCHEME_ATTR);
672
+ return `(function(){try{var s=localStorage.getItem(${key});if(s==="light"||s==="dark"){document.documentElement.setAttribute(${attr},s)}}catch(e){}})()`;
673
+ }
674
+
675
+ /**
676
+ * Resolve an `@`-prefixed style key into an emit function that pushes conditional rules. Pure
677
+ * color-scheme queries dual-emit per the forced-scheme contract (spec §9.5): a media-guarded copy
678
+ * that applies while no scheme is forced plus an unconditional copy under the forced root
679
+ * attribute.
680
+ *
681
+ * @param {string} atKey
682
+ * @param {Record<string, string>} mediaQueries
683
+ * @param {string[]} rules
684
+ * @returns {(selector: string, props: string) => void}
685
+ * @docs framework/concepts/color-schemes
686
+ */
687
+ function conditionalRuleEmitter(
688
+ atKey: string,
689
+ mediaQueries: Record<string, string>,
690
+ rules: string[],
691
+ ): (selector: string, props: string) => void {
692
+ const query = atKey.startsWith("@--")
693
+ ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
694
+ : atKey.startsWith("@(")
695
+ ? atKey.slice(1)
696
+ : null;
697
+ const atRule = query === null ? atKey : `@media ${query}`;
698
+ const scheme = query === null ? null : pureSchemeOf(query);
699
+ return (selector: string, props: string) => {
700
+ if (!props) {
701
+ return;
702
+ }
703
+ if (scheme) {
704
+ const { auto, forced } = schemeSelectors(selector, scheme);
705
+ rules.push(`${atRule} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
706
+ } else {
707
+ rules.push(`${atRule} { ${selector} { ${props} } }`);
708
+ }
709
+ };
710
+ }
711
+
712
+ /**
713
+ * Push the rules for one `@`-prefixed style block scoped to `selector`: flat props plus one level
714
+ * of nested selectors, routed through conditionalRuleEmitter (scheme-aware).
715
+ *
716
+ * @param {string[]} rules
717
+ * @param {string} atKey
718
+ * @param {Record<string, string>} mediaQueries
719
+ * @param {string} selector
720
+ * @param {Record<string, unknown>} obj
721
+ */
722
+ function pushConditionalRule(
723
+ rules: string[],
724
+ atKey: string,
725
+ mediaQueries: Record<string, string>,
726
+ selector: string,
727
+ obj: Record<string, unknown>,
728
+ ) {
729
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
730
+ return;
731
+ }
732
+ const emit = conditionalRuleEmitter(atKey, mediaQueries, rules);
733
+ emit(selector, toCSSText(obj));
734
+ for (const [sel, sub] of Object.entries(obj)) {
735
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
736
+ continue;
737
+ }
738
+ if (sel.startsWith("@")) {
739
+ continue;
740
+ }
741
+ const resolved = sel.startsWith("&")
742
+ ? sel.replace("&", selector)
743
+ : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
744
+ ? `${selector}${sel}`
745
+ : `${selector} ${sel}`;
746
+ emit(resolved, toCSSText(sub));
747
+ }
748
+ }
749
+
645
750
  /**
646
751
  * Walk the entire document tree and collect all static nested CSS rules.
647
752
  *
@@ -671,30 +776,7 @@ export function compileStyles(
671
776
  continue;
672
777
  }
673
778
  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
- }
779
+ pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
698
780
  continue;
699
781
  }
700
782
  const resolved = key.startsWith("&")
@@ -706,27 +788,6 @@ export function compileStyles(
706
788
  }
707
789
  };
708
790
 
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
791
  // Collect CSS custom properties into :root {}
731
792
  const rootProps: Record<string, unknown> = {};
732
793
  // Collect direct CSS properties into body {}
@@ -749,6 +810,7 @@ export function compileStyles(
749
810
  bodyProps[key] = val;
750
811
  }
751
812
  }
813
+ // Base rules precede conditional blocks so equal-specificity overrides win by source order.
752
814
  const rootCSS = toCSSText(rootProps);
753
815
  if (rootCSS) {
754
816
  rules.push(`:root { ${rootCSS} }`);
@@ -757,6 +819,62 @@ export function compileStyles(
757
819
  if (bodyCSS) {
758
820
  rules.push(`body { ${bodyCSS} }`);
759
821
  }
822
+
823
+ for (const [key, val] of Object.entries(projectStyle)) {
824
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
825
+ emitProjectRules(key, val as Record<string, unknown>);
826
+ } else if (
827
+ val !== null &&
828
+ typeof val === "object" &&
829
+ !Array.isArray(val) &&
830
+ !key.startsWith("@") &&
831
+ !key.startsWith("--")
832
+ ) {
833
+ emitProjectRules(key, val as Record<string, unknown>);
834
+ } else if (
835
+ key.startsWith("@") &&
836
+ val !== null &&
837
+ typeof val === "object" &&
838
+ !Array.isArray(val)
839
+ ) {
840
+ // Conditional block at project top level: custom properties override :root, direct
841
+ // Properties override body, selector-keyed sub-objects their own selector.
842
+ const emit = conditionalRuleEmitter(key, mediaQueries, rules);
843
+ const condRoot: Record<string, unknown> = {};
844
+ const condBody: Record<string, unknown> = {};
845
+ const condSubs: [string, Record<string, unknown>][] = [];
846
+ for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
847
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
848
+ if (!k.startsWith("@")) {
849
+ condSubs.push([k, v as Record<string, unknown>]);
850
+ }
851
+ continue;
852
+ }
853
+ if (k.startsWith("--")) {
854
+ condRoot[k] = v;
855
+ } else {
856
+ condBody[k] = v;
857
+ }
858
+ }
859
+ emit(":root", toCSSText(condRoot));
860
+ emit("body", toCSSText(condBody));
861
+ for (const [sel, sub] of condSubs) {
862
+ emit(sel, toCSSText(sub));
863
+ }
864
+ }
865
+ }
866
+ }
867
+
868
+ // Forced-scheme UA hint: native widgets follow the forced attribute, not only the OS scheme.
869
+ if (
870
+ Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null) &&
871
+ !(projectStyle && typeof projectStyle === "object" && "colorScheme" in projectStyle)
872
+ ) {
873
+ rules.push(
874
+ ":root { color-scheme: light dark }",
875
+ `:root:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
876
+ `:root:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
877
+ );
760
878
  }
761
879
 
762
880
  const counter = { n: 0 };
@@ -790,32 +908,7 @@ function emitNestedElement(
790
908
  continue;
791
909
  }
792
910
  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
- }
911
+ pushConditionalRule(rules, key, mediaQueries, selector, val as Record<string, unknown>);
819
912
  continue;
820
913
  }
821
914
  const resolved = key.startsWith("&")
@@ -887,35 +980,7 @@ export function collectStyles(
887
980
  continue;
888
981
  }
889
982
  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
- }
983
+ pushConditionalRule(rules, prop, mediaQueries, selector, val as Record<string, unknown>);
919
984
  } else {
920
985
  const resolved = prop.startsWith("&")
921
986
  ? prop.replace("&", selector)
@@ -1325,12 +1390,7 @@ export function buildComponentCSS(
1325
1390
 
1326
1391
  for (const [prop, val] of Object.entries(styleDef)) {
1327
1392
  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>)} } }`);
1393
+ pushConditionalRule(rules, prop, mediaQueries, tagName, val as Record<string, unknown>);
1334
1394
  } else if (
1335
1395
  prop.startsWith(":") ||
1336
1396
  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
+ }