@jxsuite/compiler 0.27.0 → 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.
- package/dist/cli.js +232 -350
- package/dist/compiler.js +182 -293
- package/package.json +6 -8
- package/src/compiler.ts +24 -7
- package/src/shared.ts +14 -7
- package/src/site/content-loader.ts +104 -229
- package/src/site/context-injection.ts +1 -1
- package/src/site/format-host.ts +84 -0
- package/src/site/head-merger.ts +4 -1
- package/src/site/image-optimizer.ts +8 -0
- package/src/site/image-transform.ts +157 -43
- package/src/site/pages-discovery.ts +66 -31
- package/src/site/prototype-resolver.ts +17 -9
- package/src/site/site-build.ts +98 -40
- package/src/site/site-loader.ts +19 -0
- package/src/targets/compile-client.ts +32 -7
- package/src/targets/compile-element.ts +20 -10
- package/src/targets/compile-image-endpoint.ts +138 -0
- package/src/targets/compile-server.ts +12 -3
- package/src/targets/compile-markdown.ts +0 -977
package/src/site/site-build.ts
CHANGED
|
@@ -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
|
|
129
|
-
|
|
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, {
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
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()], {
|
|
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
|
-
|
|
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 (
|
|
527
|
+
if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
|
|
476
528
|
await transformImageNodes(
|
|
477
529
|
layoutDoc,
|
|
478
|
-
projectConfig.images as
|
|
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[]} */
|
|
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 {
|
|
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} */
|
|
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>;
|
package/src/site/site-loader.ts
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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: {
|
|
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, {
|
|
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} */
|
|
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} */
|
|
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: {
|
|
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(".
|
|
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, {
|
|
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} */
|
|
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} */
|
|
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} */
|
|
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} */
|
|
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
|
`;
|