@jxsuite/compiler 0.26.2 → 0.28.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
- readFileSync,
13
12
  writeFileSync,
14
13
  copyFileSync,
15
14
  mkdirSync,
@@ -20,7 +19,9 @@ import {
20
19
  } from "node:fs";
21
20
  import { resolve, dirname, join } from "node:path";
22
21
  import { loadProjectConfig } from "./site-loader.ts";
23
- import { discoverPages, expandDynamicRoutes } from "./pages-discovery.ts";
22
+ import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
23
+ import { buildProjectFormatRegistry } from "./format-host.ts";
24
+ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
24
25
  import { resolveLayout } from "./layout-resolver.ts";
25
26
  import { mergeHead, renderHead } from "./head-merger.ts";
26
27
  import { injectContext } from "./context-injection.ts";
@@ -42,9 +43,11 @@ import {
42
43
  } from "../shared.ts";
43
44
  import { loadContentTypes, loadContentConfig, resolveContentTypeRefs } from "./content-loader.ts";
44
45
  import { resolvePrototypes } from "./prototype-resolver.ts";
45
- import { compileMarkdown } from "../targets/compile-markdown.ts";
46
46
  import { transformImageNodes } from "./image-transform.ts";
47
47
  import { loadCache, saveCache, getImageCacheDir } from "./image-cache.ts";
48
+ import { compilePagesImageFunction } from "../targets/compile-image-endpoint.ts";
49
+ import type { ImageConfig } from "./image-optimizer.ts";
50
+ import type { ImageMetaCache } from "./image-transform.ts";
48
51
  import type {
49
52
  JxElement,
50
53
  JxMutableNode,
@@ -85,6 +88,16 @@ export async function buildSite(
85
88
  const publicDir = resolve(projectRoot, "public");
86
89
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
87
90
 
91
+ // ── 1b. Build the format registry from project imports ─────────────────
92
+ const formatRegistry = await buildProjectFormatRegistry(projectRoot, projectConfig);
93
+ if (formatRegistry.entries.length > 0) {
94
+ log(
95
+ ` Registered ${formatRegistry.entries.length} format(s): ${formatRegistry.entries
96
+ .map((e) => e.name)
97
+ .join(", ")}`,
98
+ );
99
+ }
100
+
88
101
  // ── 2. Clean output directory ───────────────────────────────────────────
89
102
  if (clean && existsSync(outDir)) {
90
103
  rmSync(outDir, { recursive: true, force: true });
@@ -97,12 +110,12 @@ export async function buildSite(
97
110
  }
98
111
 
99
112
  log("Discovering pages...");
100
- const staticRoutes = discoverPages(pagesDir);
113
+ const staticRoutes = await discoverPages(pagesDir, formatRegistry);
101
114
  log(` Found ${staticRoutes.length} page(s)`);
102
115
 
103
116
  // ── 3b. Load content types ─────────────────────────────────────────────
104
117
  log("Loading content types...");
105
- const contentTypes = await loadContentTypes(projectRoot, projectConfig);
118
+ const contentTypes = await loadContentTypes(projectRoot, projectConfig, formatRegistry);
106
119
  if (contentTypes.size > 0) {
107
120
  log(` Loaded ${contentTypes.size} content type(s): ${[...contentTypes.keys()].join(", ")}`);
108
121
  // Resolve cross-content-type $ref references
@@ -113,7 +126,7 @@ export async function buildSite(
113
126
  }
114
127
 
115
128
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
116
- const routes = await expandDynamicRoutes(staticRoutes, projectRoot, contentTypes);
129
+ const routes = await expandDynamicRoutes(staticRoutes, projectRoot, contentTypes, formatRegistry);
117
130
  log(` ${routes.length} route(s) after expansion`);
118
131
 
119
132
  let fileCount = 0;
@@ -125,8 +138,9 @@ export async function buildSite(
125
138
  const componentDefs: Map<string, JxElement> = new Map(); // tagName → parsed component definition
126
139
  if (existsSync(componentsDir)) {
127
140
  log("Compiling components...");
128
- const componentFiles = readdirSync(componentsDir).filter(
129
- (f: string) => f.endsWith(".json") || f.endsWith(".md"),
141
+ const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
142
+ const componentFiles = readdirSync(componentsDir).filter((f: string) =>
143
+ componentExtensions.some((ext) => f.endsWith(ext)),
130
144
  );
131
145
  const componentOutDir = resolve(outDir, "components");
132
146
  mkdirSync(componentOutDir, { recursive: true });
@@ -134,7 +148,10 @@ export async function buildSite(
134
148
  for (const file of componentFiles) {
135
149
  try {
136
150
  const componentPath = resolve(componentsDir, file);
137
- const result = await compileElement(componentPath, { $media: projectConfig.$media });
151
+ const result = await compileElement(componentPath, {
152
+ $media: projectConfig.$media,
153
+ formats: formatRegistry,
154
+ });
138
155
  for (const f of result.files) {
139
156
  const outName = f.path.includes("/") ? (f.path.split("/").pop() as string) : f.path;
140
157
  writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
@@ -143,11 +160,7 @@ export async function buildSite(
143
160
  }
144
161
 
145
162
  // Pre-render component HTML scaffold and CSS sidecar
146
- const doc = componentPath.endsWith(".md")
147
- ? (await import("@jxsuite/parser/transpile")).transpileJxMarkdown(
148
- readFileSync(componentPath, "utf8"),
149
- )
150
- : JSON.parse(readFileSync(componentPath, "utf8"));
163
+ const doc = await readPageDocument(componentPath, formatRegistry);
151
164
  if (doc.tagName) {
152
165
  componentDefs.set(doc.tagName, doc);
153
166
  const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
@@ -176,14 +189,29 @@ export async function buildSite(
176
189
  const entries = collectServerEntries(doc);
177
190
  for (const entry of entries) {
178
191
  const resolvedSrc = "./components/" + entry.src.replace(/^\.\//, "");
179
- siteServerEntries.push({ exportName: entry.exportName, src: resolvedSrc });
192
+ siteServerEntries.push({
193
+ exportName: entry.exportName,
194
+ src: resolvedSrc,
195
+ });
180
196
  }
181
197
  }
182
198
  }
183
199
 
184
200
  // ── 6. Compile each route ───────────────────────────────────────────────
185
201
 
186
- const imageCache = projectConfig.images.optimize ? loadCache(projectRoot) : null;
202
+ const cfImages = projectConfig.images.optimize && projectConfig.images.service === "cloudflare";
203
+ const imageCache = projectConfig.images.optimize && !cfImages ? loadCache(projectRoot) : null;
204
+ /** @type {import("./image-transform.ts").ImageMetaCache | null} */
205
+ const imageMetaCache = cfImages ? new Map() : null;
206
+ if (cfImages) {
207
+ console.log(
208
+ `images.service is "cloudflare" — ensure your wrangler config declares the Images binding ` +
209
+ `("images": { "binding": "${projectConfig.images.binding ?? "IMAGES"}" })` +
210
+ (projectConfig.build.adapter === "cloudflare-workers"
211
+ ? ` and an assets binding ("assets": { "binding": "ASSETS" }).`
212
+ : `.`),
213
+ );
214
+ }
187
215
 
188
216
  for (const route of routes) {
189
217
  try {
@@ -195,6 +223,8 @@ export async function buildSite(
195
223
  contentTypes,
196
224
  imageCache,
197
225
  componentDefs,
226
+ imageMetaCache,
227
+ formatRegistry,
198
228
  );
199
229
 
200
230
  // Determine which component tags are fully static (for script omission)
@@ -219,17 +249,29 @@ export async function buildSite(
219
249
  writeFileSync(outPath, result.html, "utf8");
220
250
  fileCount++;
221
251
 
222
- // Write markdown export alongside HTML
223
- try {
224
- const md = compileMarkdown(result.doc, componentDefs);
225
- if (md.content) {
226
- const mdPath = outPath.replace(/\.html$/, ".md");
227
- writeFileSync(mdPath, md.content, "utf8");
228
- fileCount++;
252
+ // Write serialized export sidecars alongside HTML (formats with exportTarget: true)
253
+ for (const fmt of formatRegistry.withCapability("serialize")) {
254
+ if (!fmt.exportTarget) continue;
255
+ try {
256
+ const content = (await fmt.call("serialize", result.doc, {
257
+ mode: "export",
258
+ componentDefs,
259
+ evaluateTemplate: (value: string, scope: Record<string, unknown>) => {
260
+ if (!isTemplateString(value)) return undefined;
261
+ return evaluateStaticTemplate(value, scope) ?? value;
262
+ },
263
+ buildScope: (state: Record<string, JxStateDefinition>) =>
264
+ buildInitialScope(state, null),
265
+ })) as string;
266
+ if (content) {
267
+ const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]);
268
+ writeFileSync(sidecarPath, content, "utf8");
269
+ fileCount++;
270
+ }
271
+ } catch (e) {
272
+ const err = e as Error;
273
+ errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
229
274
  }
230
- } catch (e) {
231
- const err = e as Error;
232
- errors.push(`Error exporting markdown for ${route.urlPattern}: ${err.message}`);
233
275
  }
234
276
 
235
277
  // Write any additional files (island modules, etc.)
@@ -282,6 +324,13 @@ export async function buildSite(
282
324
  if (adapter === "cloudflare-pages") {
283
325
  const functions = compilePagesFunctions([...deduped.values()]);
284
326
 
327
+ if (cfImages) {
328
+ functions.set(
329
+ "functions/_jx/image.js",
330
+ compilePagesImageFunction(projectConfig.images as ImageConfig),
331
+ );
332
+ }
333
+
285
334
  for (const [filePath, source] of functions) {
286
335
  const fullPath = resolve(outDir, filePath);
287
336
  mkdirSync(resolve(fullPath, ".."), { recursive: true });
@@ -304,7 +353,13 @@ export async function buildSite(
304
353
  log(` Generated ${functions.size} Pages function(s) in dist/functions/`);
305
354
  }
306
355
  } else {
307
- const workerSource = compileSiteServer([...deduped.values()], { adapter });
356
+ const workerSource = compileSiteServer([...deduped.values()], {
357
+ adapter,
358
+ images:
359
+ cfImages && adapter === "cloudflare-workers"
360
+ ? (projectConfig.images as ImageConfig)
361
+ : null,
362
+ });
308
363
 
309
364
  if (workerSource) {
310
365
  const workerPath = resolve(outDir, "worker.js");
@@ -369,6 +424,7 @@ export async function buildSite(
369
424
  * @param {Map<string, ContentLoaderEntry[]>} [contentTypes]
370
425
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
371
426
  * @param {Map<string, JxElement>} [componentDefs]
427
+ * @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
372
428
  * @returns {Promise<{
373
429
  * html: string;
374
430
  * files: { path: string; content: string; tagName?: string }[];
@@ -383,15 +439,11 @@ async function compilePage(
383
439
  contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
384
440
  imageCache: import("./image-cache.js").CacheManifest | null = null,
385
441
  componentDefs: Map<string, JxElement> = new Map(),
442
+ imageMetaCache: ImageMetaCache | null = null,
443
+ formatRegistry?: FormatRegistry,
386
444
  ) {
387
- // Load the raw page document
388
- let pageDoc;
389
- if ((route.sourcePath as string).endsWith(".md")) {
390
- const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
391
- pageDoc = transpileJxMarkdown(readFileSync(route.sourcePath as string, "utf8"));
392
- } else {
393
- pageDoc = JSON.parse(readFileSync(route.sourcePath as string, "utf8"));
394
- }
445
+ // Load the raw page document (.json natively, other formats via the registry)
446
+ const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
395
447
 
396
448
  // Resolve layout (wraps page in layout with slot distribution)
397
449
  const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
@@ -472,12 +524,13 @@ async function compilePage(
472
524
  }
473
525
 
474
526
  // Transform <img> nodes for responsive image optimization
475
- if (imageCache && projectConfig.images?.optimize) {
527
+ if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
476
528
  await transformImageNodes(
477
529
  layoutDoc,
478
- projectConfig.images as import("./image-optimizer").ImageConfig,
530
+ projectConfig.images as ImageConfig,
479
531
  projectRoot,
480
532
  imageCache,
533
+ imageMetaCache ?? undefined,
481
534
  );
482
535
  }
483
536
 
@@ -496,7 +549,7 @@ async function compilePage(
496
549
  (e: JxElement | string) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
497
550
  );
498
551
  if (npmElements.length > 0) {
499
- result.html = injectNpmElementScripts(result.html, /** @type {string[]} */ (npmElements));
552
+ result.html = injectNpmElementScripts(result.html, /** @type {string[]} */ npmElements);
500
553
  }
501
554
 
502
555
  // Compile server handler if applicable (skip when provider bundles site-wide)
@@ -512,7 +565,12 @@ async function compilePage(
512
565
  }
513
566
  }
514
567
 
515
- return { html: result.html, files: result.files, serverHandler, doc: layoutDoc };
568
+ return {
569
+ html: result.html,
570
+ files: result.files,
571
+ serverHandler,
572
+ doc: layoutDoc,
573
+ };
516
574
  }
517
575
 
518
576
  /**
@@ -595,7 +653,7 @@ function expandMapTemplate(template: JxMutableNode, scope: Record<string, unknow
595
653
  if (k === "children" && Array.isArray(v)) {
596
654
  node.children = v.map((child) => {
597
655
  if (typeof child === "string") return child;
598
- return expandMapTemplate(/** @type {JxMutableNode} */ (child), scope);
656
+ return expandMapTemplate(/** @type {JxMutableNode} */ child, scope);
599
657
  });
600
658
  } else if (k === "style" && v && typeof v === "object") {
601
659
  const style = { ...v } as Record<string, unknown>;
@@ -34,6 +34,8 @@ const DEFAULTS = {
34
34
  quality: { webp: 80, avif: 65, jpeg: 80, png: 80 },
35
35
  sizes: "(max-width: 768px) 100vw, 50vw",
36
36
  lazyLoad: true,
37
+ service: "build",
38
+ binding: "IMAGES",
37
39
  },
38
40
  build: {
39
41
  outDir: "./dist",
@@ -96,6 +98,23 @@ export function loadProjectConfig(projectRoot: string) {
96
98
  );
97
99
  }
98
100
 
101
+ // Validate image service
102
+ const VALID_IMAGE_SERVICES = ["build", "cloudflare"];
103
+ if (!VALID_IMAGE_SERVICES.includes(config.images.service)) {
104
+ throw new Error(
105
+ `Unknown images.service "${config.images.service}" in project.json. ` +
106
+ `Valid services: ${VALID_IMAGE_SERVICES.join(", ")}`,
107
+ );
108
+ }
109
+ const CF_ADAPTERS = ["cloudflare-workers", "cloudflare-pages"];
110
+ if (config.images.service === "cloudflare" && !CF_ADAPTERS.includes(config.build.adapter)) {
111
+ console.warn(
112
+ `images.service "cloudflare" requires a Cloudflare adapter ` +
113
+ `(${CF_ADAPTERS.join(", ")}) — falling back to "build"`,
114
+ );
115
+ config.images.service = "build";
116
+ }
117
+
99
118
  return {
100
119
  config,
101
120
  configPath,
@@ -88,10 +88,16 @@ export function compileClient(
88
88
  if ("$expression" in d) {
89
89
  const node = (d as Record<string, unknown>).$expression as ExpressionNode;
90
90
  if (isMutating(node.operator)) {
91
- const compiled = compileExpression(node, { statePrefix: "state", eventParam: "e" });
91
+ const compiled = compileExpression(node, {
92
+ statePrefix: "state",
93
+ eventParam: "e",
94
+ });
92
95
  onEntries.push([key, { args: ["state", "e"], body: compiled }]);
93
96
  } else {
94
- const compiled = compileExpression(node, { statePrefix: "state", eventParam: "e" });
97
+ const compiled = compileExpression(node, {
98
+ statePrefix: "state",
99
+ eventParam: "e",
100
+ });
95
101
  computedEntries.push([key, `() => ${compiled}`]);
96
102
  }
97
103
  continue;
@@ -235,7 +241,15 @@ function buildClientNode(
235
241
  context: any,
236
242
  bindings: Map<string, string>,
237
243
  handlers: Map<string, any>,
238
- counter: { t: number; s: number; h: number; m: number; sw: number; l: number; needsLit: boolean },
244
+ counter: {
245
+ t: number;
246
+ s: number;
247
+ h: number;
248
+ m: number;
249
+ sw: number;
250
+ l: number;
251
+ needsLit: boolean;
252
+ },
239
253
  ) {
240
254
  // String children are text nodes
241
255
  if (typeof def === "string") {
@@ -285,9 +299,12 @@ function buildClientNode(
285
299
  const v = val as JxMutableNode;
286
300
  const key = `_h${counter.h++}`;
287
301
  bindAttrs.push(`@${eventName}="${key}"`);
288
- handlers.set(key, { args: v.parameters ?? v.arguments ?? ["state", "event"], body: v.body });
302
+ handlers.set(key, {
303
+ args: v.parameters ?? v.arguments ?? ["state", "event"],
304
+ body: v.body,
305
+ });
289
306
  needsBind = true;
290
- } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ (val)) {
307
+ } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
291
308
  const key = `_h${counter.h++}`;
292
309
  bindAttrs.push(`@${eventName}="${key}"`);
293
310
  const compiled = compileExpression(
@@ -513,7 +530,7 @@ function emitLitMapTemplate(def: JxMutableNode) {
513
530
  } else if (val && typeof val === "object" && (val as JxMutableNode).$prototype === "Function") {
514
531
  const body = mapRefsToLit((val as JxMutableNode).body as string);
515
532
  attrs += " @" + eventName + "=${(e) => { " + body + " }}";
516
- } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ (val)) {
533
+ } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
517
534
  const compiled = compileExpression(
518
535
  (val as Record<string, unknown>).$expression as ExpressionNode,
519
536
  {
@@ -594,7 +611,15 @@ function emitClientModule(
594
611
  onEntries: [string, any][],
595
612
  initBlocks: string[],
596
613
  srcImportMap: Map<string, Set<string>>,
597
- counter: { t: number; s: number; h: number; m: number; sw: number; l: number; needsLit: boolean },
614
+ counter: {
615
+ t: number;
616
+ s: number;
617
+ h: number;
618
+ m: number;
619
+ sw: number;
620
+ l: number;
621
+ needsLit: boolean;
622
+ },
598
623
  _reactivitySrc: string,
599
624
  ) {
600
625
  const lines: string[] = [];
@@ -25,7 +25,7 @@ import type { JxStyle, JxMutableNode } from "@jxsuite/schema/types";
25
25
  * @returns {Promise<{ files: { path: string; content: string; tagName: string }[] }>}
26
26
  */
27
27
  export async function compileElement(sourcePath: string | any, opts: Record<string, unknown> = {}) {
28
- const { resolveElementPath, $media: optsMedia } = opts as JxMutableNode;
28
+ const { resolveElementPath, $media: optsMedia, formats } = opts as JxMutableNode;
29
29
  /** @type {{ path: string; content: string; tagName: string }[]} */
30
30
  const files: { path: string; content: string; tagName: string }[] = [];
31
31
  const visited: Set<string> = new Set();
@@ -45,11 +45,18 @@ export async function compileElement(sourcePath: string | any, opts: Record<stri
45
45
  filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
46
46
  if (visited.has(filePath)) return;
47
47
  visited.add(filePath);
48
- if (filePath.endsWith(".md")) {
49
- const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
50
- doc = transpileJxMarkdown(readFileSync(filePath, "utf8"));
51
- } else {
48
+ if (filePath.endsWith(".json")) {
52
49
  doc = JSON.parse(readFileSync(filePath, "utf8"));
50
+ } else {
51
+ const { extname } = await import("node:path");
52
+ const ext = extname(filePath).toLowerCase();
53
+ const registry = formats as import("@jxsuite/schema/format-registry").FormatRegistry;
54
+ const entry = registry?.byExtension?.(ext, "parse");
55
+ if (!entry) {
56
+ const { unknownFormatError } = await import("../site/format-host.ts");
57
+ throw unknownFormatError(filePath, ext);
58
+ }
59
+ doc = (await entry.call("parse", readFileSync(filePath, "utf8"))) as JxMutableNode;
53
60
  }
54
61
  } else {
55
62
  doc = srcPath;
@@ -272,7 +279,10 @@ export function emitElementModule(
272
279
  for (const [key, def] of functionEntries) {
273
280
  lines.push("");
274
281
  if ("$expression" in def) {
275
- const compiled = compileExpression(def.$expression, { statePrefix: "s", eventParam: "e" });
282
+ const compiled = compileExpression(def.$expression, {
283
+ statePrefix: "s",
284
+ eventParam: "e",
285
+ });
276
286
  lines.push(` this.state.${key} = (s, e) => { ${compiled}; };`);
277
287
  } else if (def.$src) {
278
288
  // $src function — wrap imported function so it receives state
@@ -429,7 +439,7 @@ function emitLitChildren(
429
439
  if (!children) return "";
430
440
 
431
441
  if ((children as JxMutableNode).$prototype === "Array") {
432
- return emitMappedArray(/** @type {JxMutableNode} */ (children), indent);
442
+ return emitMappedArray(/** @type {JxMutableNode} */ children, indent);
433
443
  }
434
444
 
435
445
  if (!Array.isArray(children)) return "";
@@ -510,7 +520,7 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
510
520
  parts.push(
511
521
  `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
512
522
  );
513
- } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ (val)) {
523
+ } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
514
524
  const compiled = compileExpression(
515
525
  (val as Record<string, unknown>).$expression as ExpressionNode,
516
526
  {
@@ -521,7 +531,7 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
521
531
  parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
522
532
  } else if (val && typeof val === "object" && (val as JxMutableNode).$prototype === "Function") {
523
533
  parts.push(
524
- `@${eventName}="\${(e) => { ${inlineHandlerBody(/** @type {JxMutableNode} */ (val))} }}"`,
534
+ `@${eventName}="\${(e) => { ${inlineHandlerBody(/** @type {JxMutableNode} */ val)} }}"`,
525
535
  );
526
536
  }
527
537
  }
@@ -582,7 +592,7 @@ function emitMappedArray(arrayDef: JxMutableNode, indent: string) {
582
592
  parts.push(
583
593
  `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
584
594
  );
585
- } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ (val)) {
595
+ } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
586
596
  const compiled = compileExpression(
587
597
  (val as Record<string, unknown>).$expression as ExpressionNode,
588
598
  {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Compile-image-endpoint.js — Cloudflare Images binding endpoint codegen
3
+ *
4
+ * Generates the `/_jx/image` runtime image-transformation handler used when `images.service:
5
+ * "cloudflare"` is set in project.json. The handler fetches the original static asset via the
6
+ * ASSETS binding and pipes it through the Cloudflare Images binding, negotiating the output format
7
+ * from the request's Accept header.
8
+ */
9
+
10
+ import type { ImageConfig } from "../site/image-optimizer.ts";
11
+
12
+ const MIME: Record<string, string> = {
13
+ avif: "image/avif",
14
+ webp: "image/webp",
15
+ jpeg: "image/jpeg",
16
+ png: "image/png",
17
+ };
18
+
19
+ /**
20
+ * Generate the shared `jxHandleImage(request, env, waitUntil)` handler source. Widths, format
21
+ * preference order (avif first, mirroring the build-time pipeline), per-format quality, and the
22
+ * Images binding name are baked in at codegen time.
23
+ *
24
+ * @param {ImageConfig} images - The project's images config
25
+ * @returns {string} JavaScript source defining `jxHandleImage`
26
+ */
27
+ export function buildImageHandlerSource(images: ImageConfig) {
28
+ const binding = images.binding ?? "IMAGES";
29
+ const formats = [...(images.formats ?? [])]
30
+ .filter((f) => f in MIME)
31
+ .sort((a, b) => Number(b === "avif") - Number(a === "avif"));
32
+ const tuples = formats.map((f) => [
33
+ MIME[f],
34
+ images.quality?.[f as keyof ImageConfig["quality"]] ?? 80,
35
+ ]);
36
+
37
+ return `const JX_IMG_WIDTHS = new Set(${JSON.stringify(images.widths ?? [])})
38
+ const JX_IMG_FORMATS = ${JSON.stringify(tuples)} // [mime, quality] in preference order
39
+ const JX_IMG_BINDING = ${JSON.stringify(binding)}
40
+ const JX_IMG_REMOTE_DOMAINS = new Set(${JSON.stringify(images.remoteDomains ?? [])})
41
+
42
+ async function jxHandleImage(request, env, waitUntil) {
43
+ const url = new URL(request.url)
44
+ const src = url.searchParams.get('src') ?? ''
45
+ const w = Number(url.searchParams.get('w') ?? '')
46
+
47
+ // Same-origin asset paths, or https sources from allowlisted remote domains —
48
+ // never act as an open proxy.
49
+ let remoteSrc = null
50
+ if (src.startsWith('https://')) {
51
+ try {
52
+ const remote = new URL(src)
53
+ if (JX_IMG_REMOTE_DOMAINS.has(remote.hostname)) remoteSrc = remote
54
+ } catch {}
55
+ if (!remoteSrc) return new Response('Invalid src', { status: 400 })
56
+ } else if (!src.startsWith('/') || src.startsWith('//') || src.includes('..') ||
57
+ src.includes(':') || src.includes('\\\\') || src.includes('?') || src.includes('#')) {
58
+ return new Response('Invalid src', { status: 400 })
59
+ }
60
+ if (!JX_IMG_WIDTHS.has(w)) return new Response('Invalid width', { status: 400 })
61
+
62
+ const accept = request.headers.get('accept') ?? ''
63
+ let target = null
64
+ for (const [mime, quality] of JX_IMG_FORMATS) {
65
+ if (accept.includes(mime)) { target = { mime, quality }; break }
66
+ }
67
+
68
+ const v = url.searchParams.get('v') ?? ''
69
+ const cacheKey = new Request(url.origin + '/_jx/image?src=' + encodeURIComponent(src) +
70
+ '&w=' + w + '&f=' + (target ? target.mime : 'orig') + '&v=' + encodeURIComponent(v))
71
+ const cache = caches.default
72
+ const hit = await cache.match(cacheKey)
73
+ if (hit) {
74
+ const out = new Response(hit.body, hit)
75
+ out.headers.set('Vary', 'Accept')
76
+ return out
77
+ }
78
+
79
+ const fetchSource = () =>
80
+ remoteSrc
81
+ ? fetch(remoteSrc.href, { redirect: 'follow' })
82
+ : env.ASSETS.fetch(new Request(new URL(src, url.origin)))
83
+
84
+ const asset = await fetchSource()
85
+ if (!asset.ok || !asset.body) return new Response('Not found', { status: 404 })
86
+
87
+ let response
88
+ if (target && env[JX_IMG_BINDING]) {
89
+ try {
90
+ const result = await env[JX_IMG_BINDING]
91
+ .input(asset.body)
92
+ .transform({ width: w, fit: 'scale-down' })
93
+ .output({ format: target.mime, quality: target.quality })
94
+ response = result.response()
95
+ } catch (e) {
96
+ // Transform failed — serve the original source untouched.
97
+ const retry = await fetchSource()
98
+ response = new Response(retry.body, retry)
99
+ }
100
+ } else {
101
+ response = new Response(asset.body, asset)
102
+ }
103
+
104
+ response = new Response(response.body, response)
105
+ response.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
106
+ if (waitUntil) waitUntil(cache.put(cacheKey, response.clone()))
107
+ response.headers.set('Vary', 'Accept')
108
+ return response
109
+ }`;
110
+ }
111
+
112
+ /**
113
+ * Generate the Hono route registering `jxHandleImage` at `/_jx/image`. Must be injected before the
114
+ * cloudflare-workers `app.all('*')` ASSETS catch-all.
115
+ *
116
+ * @returns {string}
117
+ */
118
+ export function buildImageWorkerRoute() {
119
+ return `
120
+ app.get('/_jx/image', (c) => jxHandleImage(c.req.raw, c.env, (p) => c.executionCtx?.waitUntil?.(p)))`;
121
+ }
122
+
123
+ /**
124
+ * Generate a complete Cloudflare Pages Function (`functions/_jx/image.js`) wrapping `jxHandleImage`
125
+ * in an `onRequestGet` handler.
126
+ *
127
+ * @param {ImageConfig} images - The project's images config
128
+ * @returns {string}
129
+ */
130
+ export function compilePagesImageFunction(images: ImageConfig) {
131
+ return `// Generated by @jxsuite/compiler — do not edit manually
132
+ ${buildImageHandlerSource(images)}
133
+
134
+ export async function onRequestGet(context) {
135
+ return jxHandleImage(context.request, context.env, (p) => context.waitUntil(p))
136
+ }
137
+ `;
138
+ }
@@ -7,7 +7,9 @@
7
7
 
8
8
  import $RefParser from "@apidevtools/json-schema-ref-parser";
9
9
  import { collectServerEntries } from "../shared.ts";
10
+ import { buildImageHandlerSource, buildImageWorkerRoute } from "./compile-image-endpoint.ts";
10
11
  import type { JxElement } from "@jxsuite/schema/types";
12
+ import type { ImageConfig } from "../site/image-optimizer.ts";
11
13
 
12
14
  /**
13
15
  * Compile a Jx document to a Hono server handler file. The handler exposes each `timing: "server"`
@@ -62,6 +64,8 @@ export default app
62
64
  * @param {object} [opts]
63
65
  * @param {string} [opts.baseUrl] - Base path for server endpoints. Default is `'/_jx/server'`
64
66
  * @param {string | null} [opts.adapter] - Deployment adapter: `"cloudflare"`, `"node"`, `"bun"`
67
+ * @param {ImageConfig | null} [opts.images] - When set, embed the Cloudflare Images binding
68
+ * endpoint at `/_jx/image` (images.service: "cloudflare")
65
69
  * @returns {string | null} Complete worker/server source string, or null
66
70
  */
67
71
  export function compileSiteServer(
@@ -69,9 +73,10 @@ export function compileSiteServer(
69
73
  opts: {
70
74
  baseUrl?: string;
71
75
  adapter?: string | null;
76
+ images?: ImageConfig | null;
72
77
  } = {},
73
78
  ) {
74
- const { baseUrl = "/_jx/server", adapter = null } = opts;
79
+ const { baseUrl = "/_jx/server", adapter = null, images = null } = opts;
75
80
  if (entries.length === 0 && !adapter) return null;
76
81
 
77
82
  const imports = entries
@@ -80,6 +85,10 @@ export function compileSiteServer(
80
85
 
81
86
  const routes = entries.map(({ exportName }) => buildRoute(exportName, baseUrl)).join("\n");
82
87
 
88
+ const imageHandler = images ? `\n${buildImageHandlerSource(images)}\n` : "";
89
+ // The image route must register before the cloudflare-workers ASSETS catch-all below.
90
+ const imageRoute = images ? buildImageWorkerRoute() : "";
91
+
83
92
  const adapterBlock =
84
93
  adapter === "cloudflare-workers"
85
94
  ? "\napp.all('*', (c) => c.env.ASSETS.fetch(c.req.raw))\n"
@@ -88,9 +97,9 @@ export function compileSiteServer(
88
97
  return `// Generated by @jxsuite/compiler — do not edit manually
89
98
  import { Hono } from 'hono'
90
99
  ${imports}
91
-
100
+ ${imageHandler}
92
101
  const app = new Hono()
93
- ${routes}
102
+ ${routes}${imageRoute}
94
103
  ${adapterBlock}
95
104
  export default app
96
105
  `;