@jxsuite/compiler 1.0.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.
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
3
+ *
4
+ * The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
5
+ * the dev server is Bun-native while the jx bin runs under Node — so the command is pure resolution
6
+ * + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
7
+ *
8
+ * @module dev-command
9
+ */
10
+
11
+ import { createRequire } from "node:module";
12
+ import { join } from "node:path";
13
+ import { spawn } from "node:child_process";
14
+ import type { ChildProcess } from "node:child_process";
15
+
16
+ /**
17
+ * Resolve the project's `@jxsuite/server/dev` entry module.
18
+ *
19
+ * @param {string} projectRoot - Absolute project root
20
+ * @returns {string} Absolute path to the dev entry module
21
+ * @throws {Error} With an install hint when the package is not present
22
+ */
23
+ export function resolveDevEntry(projectRoot: string): string {
24
+ try {
25
+ const projectRequire = createRequire(join(projectRoot, "package.json"));
26
+ return projectRequire.resolve("@jxsuite/server/dev");
27
+ } catch {
28
+ throw new Error(
29
+ `@jxsuite/server is not installed in ${projectRoot}. ` +
30
+ `Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
31
+ );
32
+ }
33
+ }
34
+
35
+ /** The slice of `process` runDev touches — injectable for tests. */
36
+ export interface ProcessLike {
37
+ on: (event: string, handler: () => void) => unknown;
38
+ exitCode?: number | string | undefined;
39
+ }
40
+
41
+ /**
42
+ * Spawn the dev server under Bun with inherited stdio.
43
+ *
44
+ * @param {string} projectRoot - Absolute project root
45
+ * @param {number | undefined} port - Optional port override
46
+ * @param {typeof spawn} [spawnImpl] - Injectable for tests
47
+ * @param {ProcessLike} [proc] - Injectable for tests
48
+ * @returns {ChildProcess} The spawned Bun process
49
+ */
50
+ export function runDev(
51
+ projectRoot: string,
52
+ port: number | undefined,
53
+ spawnImpl: typeof spawn = spawn,
54
+ proc: ProcessLike = process as unknown as ProcessLike,
55
+ ): ChildProcess {
56
+ const entry = resolveDevEntry(projectRoot);
57
+ const args = [entry, "--root", projectRoot];
58
+ if (port !== undefined) {
59
+ args.push("--port", String(port));
60
+ }
61
+ const child = spawnImpl("bun", args, { stdio: "inherit" });
62
+ child.on("error", (error: NodeJS.ErrnoException) => {
63
+ if (error.code === "ENOENT") {
64
+ console.error(
65
+ "The Jx dev server runs on Bun, which was not found on your PATH. " +
66
+ "Install it from https://bun.sh and retry.",
67
+ );
68
+ } else {
69
+ console.error(`Failed to start the dev server: ${error.message}`);
70
+ }
71
+ proc.exitCode = 1;
72
+ });
73
+ // Ctrl+C reaches the child through the shared process group; also mirror explicit kills.
74
+ proc.on("SIGINT", () => {
75
+ child.kill("SIGINT");
76
+ });
77
+ proc.on("SIGTERM", () => {
78
+ child.kill("SIGTERM");
79
+ });
80
+ child.on("exit", (code) => {
81
+ proc.exitCode = code ?? 0;
82
+ });
83
+ return child;
84
+ }
@@ -63,6 +63,19 @@ export function mergeHead(
63
63
  attributes: { href: canonical, rel: "canonical" },
64
64
  tagName: "link",
65
65
  });
66
+ // Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
67
+ if (!merged.has("meta:og:url")) {
68
+ merged.set("meta:og:url", {
69
+ attributes: { content: canonical, property: "og:url" },
70
+ tagName: "meta",
71
+ });
72
+ }
73
+ }
74
+ if (context.siteName && !merged.has("meta:og:site_name")) {
75
+ merged.set("meta:og:site_name", {
76
+ attributes: { content: context.siteName, property: "og:site_name" },
77
+ tagName: "meta",
78
+ });
66
79
  }
67
80
 
68
81
  return [...merged.values()];
@@ -6,7 +6,7 @@
6
6
  * CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { basename, resolve } from "node:path";
11
11
  import { execSync } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
@@ -23,6 +23,7 @@ interface CacheEntry {
23
23
  export interface CacheManifest {
24
24
  version: number; // Cache format version
25
25
  entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
26
+ touched?: Set<string>; // Keys resolved during this build — runtime only, never serialized
26
27
  }
27
28
 
28
29
  // ─── Cache directory resolution ──────────────────────────────────────────────
@@ -97,25 +98,73 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
97
98
  export function loadCache(projectRoot: string): CacheManifest {
98
99
  const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
99
100
  if (!existsSync(manifestPath)) {
100
- return { entries: {}, version: 1 };
101
+ return { entries: {}, touched: new Set(), version: 1 };
101
102
  }
102
103
  try {
103
- return JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
104
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
105
+ parsed.touched = new Set();
106
+ return parsed;
104
107
  } catch {
105
- return { entries: {}, version: 1 };
108
+ return { entries: {}, touched: new Set(), version: 1 };
106
109
  }
107
110
  }
108
111
 
109
112
  /**
110
113
  * Save the cache manifest to disk.
111
114
  *
115
+ * With `prune: true`, entries whose key was never touched during this build (deleted/replaced
116
+ * sources, superseded configs) are dropped and their variant files deleted first, so a persisted
117
+ * cache — and the dist copy made from it — stays bounded to images the build actually uses.
118
+ *
112
119
  * @param {string} projectRoot
113
120
  * @param {CacheManifest} cache
121
+ * @param {{ prune?: boolean }} [options]
114
122
  */
115
- export function saveCache(projectRoot: string, cache: CacheManifest) {
123
+ export function saveCache(
124
+ projectRoot: string,
125
+ cache: CacheManifest,
126
+ options: { prune?: boolean } = {},
127
+ ) {
128
+ if (options.prune && cache.touched) {
129
+ pruneStale(cache, cache.touched);
130
+ }
116
131
  const cacheDir = getImageCacheDir(projectRoot);
117
132
  mkdirSync(cacheDir, { recursive: true });
118
- writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
133
+ writeFileSync(
134
+ resolve(cacheDir, "manifest.json"),
135
+ JSON.stringify({ entries: cache.entries, version: cache.version }, null, 2),
136
+ "utf8",
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Remove entries (and their variant files) that were not touched during this build. A file shared
142
+ * with a surviving entry is kept — the same source under an older config produces overlapping
143
+ * width/format filenames, so deleting blindly would remove files a live entry still references.
144
+ *
145
+ * @param {CacheManifest} cache
146
+ * @param {Set<string>} touched
147
+ */
148
+ function pruneStale(cache: CacheManifest, touched: Set<string>) {
149
+ const keep = new Set<string>();
150
+ for (const [key, entry] of Object.entries(cache.entries)) {
151
+ if (touched.has(key)) {
152
+ for (const variant of entry.manifest.variants ?? []) {
153
+ keep.add(variant.absolutePath);
154
+ }
155
+ }
156
+ }
157
+ for (const [key, entry] of Object.entries(cache.entries)) {
158
+ if (touched.has(key)) {
159
+ continue;
160
+ }
161
+ for (const variant of entry.manifest.variants ?? []) {
162
+ if (!keep.has(variant.absolutePath)) {
163
+ rmSync(variant.absolutePath, { force: true });
164
+ }
165
+ }
166
+ delete cache.entries[key];
167
+ }
119
168
  }
120
169
 
121
170
  // ─── Entry access ─────────────────────────────────────────────────────────────
@@ -128,6 +177,7 @@ export function saveCache(projectRoot: string, cache: CacheManifest) {
128
177
  * @returns {ImageManifest | null}
129
178
  */
130
179
  export function getCached(cache: CacheManifest, key: string) {
180
+ cache.touched?.add(key);
131
181
  const entry = cache.entries[key];
132
182
  if (!entry) {
133
183
  return null;
@@ -149,6 +199,7 @@ export function setCached(
149
199
  sourcePath: string,
150
200
  manifest: ImageManifest,
151
201
  ) {
202
+ cache.touched?.add(key);
152
203
  cache.entries[key] = {
153
204
  manifest,
154
205
  source: sourcePath,
@@ -318,6 +318,7 @@ async function transformImgNode(
318
318
  }
319
319
 
320
320
  let srcset;
321
+ let dims: { width: number; height: number } | null = null;
321
322
 
322
323
  if (remote) {
323
324
  // Original dimensions are unknown without fetching — emit every configured width and let
@@ -332,6 +333,7 @@ async function transformImgNode(
332
333
  if (config.service === "cloudflare") {
333
334
  const meta = await resolveCfMeta(absoluteSrc, metaCache);
334
335
  srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
336
+ dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
335
337
  } else {
336
338
  let manifest = imageRefs.get(absoluteSrc);
337
339
 
@@ -342,6 +344,10 @@ async function transformImgNode(
342
344
 
343
345
  const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
344
346
  srcset = buildSrcset(manifest.variants, preferredFormat);
347
+ dims =
348
+ manifest.original?.width && manifest.original.height
349
+ ? { height: manifest.original.height, width: manifest.original.width }
350
+ : null;
345
351
  }
346
352
  }
347
353
 
@@ -350,6 +356,12 @@ async function transformImgNode(
350
356
  node.attributes.sizes ??= config.sizes;
351
357
  }
352
358
 
359
+ // Intrinsic dimensions prevent layout shift; author-supplied values win.
360
+ if (dims && node.attributes.width === undefined && node.attributes.height === undefined) {
361
+ node.attributes.width = String(dims.width);
362
+ node.attributes.height = String(dims.height);
363
+ }
364
+
353
365
  if (config.lazyLoad && node.attributes.loading !== "eager") {
354
366
  node.attributes.loading = "lazy";
355
367
  node.attributes.decoding = "async";
@@ -412,12 +424,14 @@ async function transformInnerHtmlImages(
412
424
  }
413
425
 
414
426
  let srcset;
427
+ let dims: { width: number; height: number } | null = null;
415
428
 
416
429
  if (remote) {
417
430
  srcset = buildCloudflareSrcset(src, config, Infinity, null);
418
431
  } else if (config.service === "cloudflare") {
419
432
  const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
420
433
  srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
434
+ dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
421
435
  } else {
422
436
  let manifest = imageRefs.get(absoluteSrc as string);
423
437
  if (!manifest) {
@@ -427,12 +441,19 @@ async function transformInnerHtmlImages(
427
441
 
428
442
  const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
429
443
  srcset = buildSrcset(manifest.variants, preferredFormat);
444
+ dims =
445
+ manifest.original?.width && manifest.original.height
446
+ ? { height: manifest.original.height, width: manifest.original.width }
447
+ : null;
430
448
  }
431
449
  if (!srcset) {
432
450
  continue;
433
451
  }
434
452
 
435
453
  let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
454
+ if (dims && !/\bwidth=/.test(attrs) && !/\bheight=/.test(attrs)) {
455
+ extra += ` width="${dims.width}" height="${dims.height}"`;
456
+ }
436
457
  if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
437
458
  if (!/\bloading=/.test(attrs)) {
438
459
  extra += ` loading="lazy"`;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Preview server — `jx preview` serves an already-built dist/ directory.
3
+ *
4
+ * Dependency-free node:http static server, so the Node-run `jx` bin needs neither Bun nor the
5
+ * dev-server package. Maps directory URLs to index.html (the build's trailingSlash output) and
6
+ * falls back to <dist>/404.html when present.
7
+ *
8
+ * @module preview-server
9
+ */
10
+
11
+ import { createServer } from "node:http";
12
+ import type { Server } from "node:http";
13
+ import { existsSync, readFileSync, statSync } from "node:fs";
14
+ import { extname, join, normalize, resolve, sep } from "node:path";
15
+
16
+ const MIME_TYPES: Record<string, string> = {
17
+ ".avif": "image/avif",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".gif": "image/gif",
20
+ ".html": "text/html; charset=utf-8",
21
+ ".ico": "image/x-icon",
22
+ ".jpeg": "image/jpeg",
23
+ ".jpg": "image/jpeg",
24
+ ".js": "text/javascript; charset=utf-8",
25
+ ".json": "application/json; charset=utf-8",
26
+ ".md": "text/markdown; charset=utf-8",
27
+ ".png": "image/png",
28
+ ".svg": "image/svg+xml",
29
+ ".txt": "text/plain; charset=utf-8",
30
+ ".webp": "image/webp",
31
+ ".woff2": "font/woff2",
32
+ ".xml": "application/xml; charset=utf-8",
33
+ };
34
+
35
+ /**
36
+ * Resolve a request pathname to a file inside distDir, or null. Directory URLs (with or without a
37
+ * trailing slash) map to their index.html; traversal outside distDir is rejected.
38
+ *
39
+ * @param {string} distDir - Absolute dist directory
40
+ * @param {string} pathname - Decoded URL pathname
41
+ * @returns {string | null} Absolute file path to serve, or null
42
+ */
43
+ export function resolvePreviewFile(distDir: string, pathname: string): string | null {
44
+ const safe = normalize(pathname).replaceAll("\\", "/");
45
+ const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
46
+ if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
47
+ return null;
48
+ }
49
+ if (existsSync(candidate)) {
50
+ if (statSync(candidate).isDirectory()) {
51
+ const index = join(candidate, "index.html");
52
+ return existsSync(index) ? index : null;
53
+ }
54
+ return candidate;
55
+ }
56
+ // Extensionless route without trailing slash → its directory index
57
+ const index = join(candidate, "index.html");
58
+ return existsSync(index) ? index : null;
59
+ }
60
+
61
+ /**
62
+ * Start the preview server on the given port (0 picks a free port).
63
+ *
64
+ * @param {string} distDir - Absolute path to the built dist directory
65
+ * @param {number} port - Port to listen on
66
+ * @returns {Server} The listening node:http server
67
+ */
68
+ export function startPreviewServer(distDir: string, port: number): Server {
69
+ const dist = resolve(distDir);
70
+ const server = createServer((req, res) => {
71
+ const url = new URL(req.url ?? "/", "http://localhost");
72
+ const filePath = resolvePreviewFile(dist, decodeURIComponent(url.pathname));
73
+
74
+ if (filePath) {
75
+ res.writeHead(200, {
76
+ "Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream",
77
+ });
78
+ res.end(readFileSync(filePath));
79
+ return;
80
+ }
81
+
82
+ const notFound = join(dist, "404.html");
83
+ if (existsSync(notFound)) {
84
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
85
+ res.end(readFileSync(notFound));
86
+ return;
87
+ }
88
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
89
+ res.end("Not found");
90
+ });
91
+ server.listen(port);
92
+ return server;
93
+ }
@@ -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(