@jxsuite/compiler 0.5.5 → 0.6.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": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,10 +37,11 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
40
- "@jxsuite/parser": "^0.5.0",
41
- "@jxsuite/runtime": "^0.5.0",
40
+ "@jxsuite/parser": "^0.5.5",
41
+ "@jxsuite/runtime": "^0.5.5",
42
42
  "remark-gfm": "^4.0.1",
43
43
  "remark-stringify": "^11.0.0",
44
+ "sharp": "^0.34.5",
44
45
  "unified": "^11.0.5"
45
46
  },
46
47
  "devDependencies": {
package/src/compiler.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  DEFAULT_REACTIVITY_SRC,
23
23
  DEFAULT_LIT_HTML_SRC,
24
24
  } from "./shared.js";
25
- import { compileServer } from "./targets/compile-server.js";
25
+ import { compileServer, compileSiteServer } from "./targets/compile-server.js";
26
26
  import {
27
27
  compileElement,
28
28
  compileElementPage,
@@ -32,7 +32,14 @@ import { compileStaticPage } from "./targets/compile-static.js";
32
32
  import { compileClient } from "./targets/compile-client.js";
33
33
 
34
34
  // Re-exports for consumers
35
- export { isDynamic, compileServer, compileElement, compileElementPage, compileClient };
35
+ export {
36
+ isDynamic,
37
+ compileServer,
38
+ compileSiteServer,
39
+ compileElement,
40
+ compileElementPage,
41
+ compileClient,
42
+ };
36
43
 
37
44
  // ─── Entry ────────────────────────────────────────────────────────────────────
38
45
 
package/src/shared.js CHANGED
@@ -446,6 +446,11 @@ export function buildAttrs(def, scope) {
446
446
  }
447
447
  }
448
448
 
449
+ if (def.tagName === "img") {
450
+ if (!def.attributes?.loading) out += ` loading="lazy"`;
451
+ if (!def.attributes?.decoding) out += ` decoding="async"`;
452
+ }
453
+
449
454
  if (def.$props && typeof def.$props === "object") {
450
455
  out += ` data-jx-props="${escapeHtml(JSON.stringify(def.$props))}"`;
451
456
  }
@@ -769,7 +774,7 @@ function _walkServerEntries(def, entries) {
769
774
  // ─── Component pre-rendering ─────────────────────────────────────────────────
770
775
 
771
776
  /** @type {Set<string>} */
772
- const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area", "col"]);
777
+ const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area", "col", "source"]);
773
778
 
774
779
  /**
775
780
  * Recursively render a Jx node tree to static HTML for pre-rendering.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Image-cache.js — Content-hash based cache for processed image variants.
3
+ *
4
+ * Stores a manifest of previously processed images so that unchanged sources can skip re-encoding
5
+ * on subsequent builds. Cache lives in .jx-cache/images/.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { contentHash, configHash } from "./image-optimizer.js";
11
+
12
+ /**
13
+ * @typedef {import("./image-optimizer.js").ImageManifest} ImageManifest
14
+ *
15
+ * @typedef {import("./image-optimizer.js").ImageConfig} ImageConfig
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} CacheEntry
20
+ * @property {string} source - Relative path to source image
21
+ * @property {ImageManifest} manifest - Processed image manifest
22
+ * @property {number} timestamp - When the entry was cached
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} CacheManifest
27
+ * @property {number} version - Cache format version
28
+ * @property {Record<string, CacheEntry>} entries - Cached entries keyed by content+config hash
29
+ */
30
+
31
+ /**
32
+ * Build a cache key from source file content and config.
33
+ *
34
+ * @param {string} srcPath - Absolute path to source image
35
+ * @param {ImageConfig} config
36
+ * @returns {string}
37
+ */
38
+ export function cacheKey(srcPath, config) {
39
+ return `${contentHash(srcPath)}:${configHash(config)}`;
40
+ }
41
+
42
+ /**
43
+ * Load the cache manifest from disk, or return an empty one.
44
+ *
45
+ * @param {string} projectRoot
46
+ * @returns {CacheManifest}
47
+ */
48
+ export function loadCache(projectRoot) {
49
+ const manifestPath = resolve(projectRoot, ".jx-cache/images/manifest.json");
50
+ if (!existsSync(manifestPath)) {
51
+ return { version: 1, entries: {} };
52
+ }
53
+ try {
54
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
55
+ } catch {
56
+ return { version: 1, entries: {} };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save the cache manifest to disk.
62
+ *
63
+ * @param {string} projectRoot
64
+ * @param {CacheManifest} cache
65
+ */
66
+ export function saveCache(projectRoot, cache) {
67
+ const cacheDir = resolve(projectRoot, ".jx-cache/images");
68
+ mkdirSync(cacheDir, { recursive: true });
69
+ writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
70
+ }
71
+
72
+ /**
73
+ * Check if a cache entry exists and its output files are still present.
74
+ *
75
+ * @param {CacheManifest} cache
76
+ * @param {string} key
77
+ * @param {string} outDir - Absolute path to build output dir
78
+ * @returns {ImageManifest | null}
79
+ */
80
+ export function getCached(cache, key, outDir) {
81
+ const entry = cache.entries[key];
82
+ if (!entry) return null;
83
+
84
+ const allExist = entry.manifest.variants.every((v) =>
85
+ existsSync(resolve(outDir, v.outputPath.slice(1))),
86
+ );
87
+ if (!allExist) return null;
88
+
89
+ return entry.manifest;
90
+ }
91
+
92
+ /**
93
+ * Store a processed result in the cache.
94
+ *
95
+ * @param {CacheManifest} cache
96
+ * @param {string} key
97
+ * @param {string} sourcePath - Relative source path for reference
98
+ * @param {ImageManifest} manifest
99
+ */
100
+ export function setCached(cache, key, sourcePath, manifest) {
101
+ cache.entries[key] = {
102
+ source: sourcePath,
103
+ manifest,
104
+ timestamp: Date.now(),
105
+ };
106
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Image-optimizer.js — Sharp wrapper for image resizing and format conversion.
3
+ *
4
+ * Generates responsive image variants (WebP, AVIF) at configured breakpoint widths. Returns an
5
+ * ImageManifest describing all generated variants with their output paths.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { resolve, basename, extname } from "node:path";
11
+
12
+ /** @type {typeof import("sharp") | null} */
13
+ let _sharp = null;
14
+
15
+ async function getSharp() {
16
+ if (_sharp) return _sharp;
17
+ try {
18
+ const sharpMod = await import("sharp");
19
+ _sharp = sharpMod.default;
20
+ return _sharp;
21
+ } catch (e) {
22
+ throw new Error(
23
+ `Sharp is required for image optimization but failed to load: ${/** @type {any} */ (e).message}`,
24
+ );
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @typedef {Object} ImageVariant
30
+ * @property {number} width - Pixel width of the variant
31
+ * @property {string} format - "webp", "avif", "jpeg", "png"
32
+ * @property {string} outputPath - Relative path from outDir (e.g.
33
+ * "/images/_optimized/hero-640-a1b2c3d4.webp")
34
+ * @property {string} absolutePath - Absolute filesystem path to the generated file
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} ImageManifest
39
+ * @property {{ width: number; height: number; format: string }} original - Original image
40
+ * dimensions and format
41
+ * @property {ImageVariant[]} variants - Array of generated responsive variants
42
+ * @property {string} contentHash - 8-char content hash for cache busting
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} ImageConfig
47
+ * @property {boolean} optimize - Enable image optimization
48
+ * @property {number[]} widths - Breakpoint widths to generate
49
+ * @property {string[]} formats - Output formats ("webp", "avif", etc.)
50
+ * @property {{ webp?: number; avif?: number; jpeg?: number; png?: number }} quality - Compression
51
+ * quality per format
52
+ * @property {string} sizes - CSS sizes attribute for srcset
53
+ * @property {boolean} lazyLoad - Add loading="lazy" and decoding="async"
54
+ */
55
+
56
+ const OPTIMIZED_DIR = "images/_optimized";
57
+
58
+ /**
59
+ * Get image metadata (dimensions and format) via Sharp.
60
+ *
61
+ * @param {string} srcPath - Absolute path to source image
62
+ * @returns {Promise<{ width: number; height: number; format: string }>}
63
+ */
64
+ export async function getImageMetadata(srcPath) {
65
+ const sharp = await getSharp();
66
+ const meta = await sharp(srcPath).metadata();
67
+ return {
68
+ width: meta.width ?? 0,
69
+ height: meta.height ?? 0,
70
+ format: meta.format ?? "unknown",
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Compute a content hash for a source image file.
76
+ *
77
+ * @param {string} srcPath - Absolute path to source image
78
+ * @returns {string} 8-character hex hash
79
+ */
80
+ export function contentHash(srcPath) {
81
+ const buf = readFileSync(srcPath);
82
+ return createHash("md5").update(buf).digest("hex").slice(0, 8);
83
+ }
84
+
85
+ /**
86
+ * Compute a config hash from the image optimization settings.
87
+ *
88
+ * @param {ImageConfig} config
89
+ * @returns {string}
90
+ */
91
+ export function configHash(config) {
92
+ const key = JSON.stringify({
93
+ widths: config.widths,
94
+ formats: config.formats,
95
+ quality: config.quality,
96
+ });
97
+ return createHash("md5").update(key).digest("hex").slice(0, 8);
98
+ }
99
+
100
+ /**
101
+ * Build the output filename for a variant.
102
+ *
103
+ * @param {string} stem - Original filename without extension
104
+ * @param {number} width
105
+ * @param {string} hash8 - 8-char content hash
106
+ * @param {string} format - "webp", "avif", "jpeg", "png"
107
+ * @returns {string}
108
+ */
109
+ export function variantFilename(stem, width, hash8, format) {
110
+ return `${stem}-${width}-${hash8}.${format}`;
111
+ }
112
+
113
+ /**
114
+ * Process a single source image: resize to each configured width, encode to each format.
115
+ *
116
+ * @param {string} srcPath - Absolute path to source image
117
+ * @param {string} outDir - Absolute path to the build output directory (dist/)
118
+ * @param {ImageConfig} config
119
+ * @returns {Promise<ImageManifest>}
120
+ */
121
+ export async function processImage(srcPath, outDir, config) {
122
+ const sharp = await getSharp();
123
+ const meta = await getImageMetadata(srcPath);
124
+ const hash8 = contentHash(srcPath);
125
+ const stem = basename(srcPath, extname(srcPath));
126
+
127
+ const optimizedDir = resolve(outDir, OPTIMIZED_DIR);
128
+ mkdirSync(optimizedDir, { recursive: true });
129
+
130
+ /** @type {ImageVariant[]} */
131
+ const variants = [];
132
+
133
+ const widths = config.widths.filter((w) => w <= meta.width);
134
+ if (widths.length === 0 || !widths.includes(meta.width)) {
135
+ widths.push(meta.width);
136
+ }
137
+ widths.sort((a, b) => a - b);
138
+
139
+ /** @type {Promise<void>[]} */
140
+ const tasks = [];
141
+
142
+ for (const width of widths) {
143
+ for (const format of config.formats) {
144
+ const filename = variantFilename(stem, width, hash8, format);
145
+ const outputPath = `/${OPTIMIZED_DIR}/${filename}`;
146
+ const absolutePath = resolve(optimizedDir, filename);
147
+
148
+ variants.push({ width, format, outputPath, absolutePath });
149
+
150
+ if (existsSync(absolutePath)) continue;
151
+
152
+ const quality = config.quality[/** @type {keyof ImageConfig["quality"]} */ (format)] ?? 80;
153
+ const task = sharp(srcPath)
154
+ .resize(width)
155
+ .toFormat(/** @type {any} */ (format), { quality })
156
+ .toFile(absolutePath)
157
+ .then(() => {});
158
+
159
+ tasks.push(task);
160
+ }
161
+ }
162
+
163
+ const CONCURRENCY = 4;
164
+ for (let i = 0; i < tasks.length; i += CONCURRENCY) {
165
+ await Promise.all(tasks.slice(i, i + CONCURRENCY));
166
+ }
167
+
168
+ return {
169
+ original: { width: meta.width, height: meta.height, format: meta.format },
170
+ variants,
171
+ contentHash: hash8,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Build a srcset string from variants of a specific format.
177
+ *
178
+ * @param {ImageVariant[]} variants
179
+ * @param {string} format
180
+ * @returns {string}
181
+ */
182
+ export function buildSrcset(variants, format) {
183
+ return variants
184
+ .filter((v) => v.format === format)
185
+ .map((v) => `${v.outputPath} ${v.width}w`)
186
+ .join(", ");
187
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Image-transform.js — Document tree walker for responsive image optimization.
3
+ *
4
+ * Walks a Jx document tree, finds <img> nodes with static src paths, and injects srcset, sizes,
5
+ * width, height, loading, and decoding attributes. Collects image references so the build
6
+ * orchestrator knows which files to process.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { resolve, extname } from "node:path";
11
+ import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.js";
12
+ import { getCached, setCached } from "./image-cache.js";
13
+
14
+ /**
15
+ * @typedef {import("./image-optimizer.js").ImageConfig} ImageConfig
16
+ *
17
+ * @typedef {import("./image-optimizer.js").ImageManifest} ImageManifest
18
+ *
19
+ * @typedef {import("./image-cache.js").CacheManifest} CacheManifest
20
+ */
21
+
22
+ const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
23
+ const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
24
+
25
+ /**
26
+ * Check if a src value should be skipped for optimization.
27
+ *
28
+ * @param {any} src
29
+ * @returns {boolean}
30
+ */
31
+ function shouldSkip(src) {
32
+ if (typeof src !== "string") return true;
33
+ if (src.includes("${")) return true;
34
+ if (EXTERNAL_PREFIXES.some((p) => src.startsWith(p))) return true;
35
+ if (SKIP_EXTENSIONS.has(extname(src).toLowerCase())) return true;
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Resolve a src path to an absolute filesystem path.
41
+ *
42
+ * Handles paths starting with "/" (relative to public dir or project root).
43
+ *
44
+ * @param {string} src
45
+ * @param {string} projectRoot
46
+ * @returns {string}
47
+ */
48
+ function resolveImagePath(src, projectRoot) {
49
+ if (src.startsWith("/")) {
50
+ return resolve(projectRoot, "public", src.slice(1));
51
+ }
52
+ return resolve(projectRoot, src);
53
+ }
54
+
55
+ /**
56
+ * Transform image nodes in a Jx document tree.
57
+ *
58
+ * Mutates img nodes in place, injecting srcset, sizes, width, height, loading, and decoding.
59
+ * Returns a set of absolute source paths that need processing.
60
+ *
61
+ * @param {any} doc - The Jx document tree (mutated in place)
62
+ * @param {ImageConfig} config
63
+ * @param {string} projectRoot
64
+ * @param {string} outDir
65
+ * @param {CacheManifest} cache
66
+ * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
67
+ */
68
+ export async function transformImageNodes(doc, config, projectRoot, outDir, cache) {
69
+ /** @type {Map<string, ImageManifest>} */
70
+ const imageRefs = new Map();
71
+
72
+ if (!config.optimize) return { imageRefs };
73
+
74
+ await walkAndTransform(doc, config, projectRoot, outDir, cache, imageRefs);
75
+
76
+ return { imageRefs };
77
+ }
78
+
79
+ /**
80
+ * @param {any} node
81
+ * @param {ImageConfig} config
82
+ * @param {string} projectRoot
83
+ * @param {string} outDir
84
+ * @param {CacheManifest} cache
85
+ * @param {Map<string, ImageManifest>} imageRefs
86
+ */
87
+ async function walkAndTransform(node, config, projectRoot, outDir, cache, imageRefs) {
88
+ if (!node || typeof node !== "object") return;
89
+
90
+ if (node.tagName === "img") {
91
+ await transformImgNode(node, config, projectRoot, outDir, cache, imageRefs);
92
+ }
93
+
94
+ if (Array.isArray(node.children)) {
95
+ for (const child of node.children) {
96
+ await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {any} node
103
+ * @param {ImageConfig} config
104
+ * @param {string} projectRoot
105
+ * @param {string} outDir
106
+ * @param {CacheManifest} cache
107
+ * @param {Map<string, ImageManifest>} imageRefs
108
+ */
109
+ async function transformImgNode(node, config, projectRoot, outDir, cache, imageRefs) {
110
+ if (!node.attributes) node.attributes = {};
111
+
112
+ const src = node.attributes.src ?? node.src;
113
+ if (shouldSkip(src)) return;
114
+ if (node.attributes["data-no-optimize"] !== undefined) return;
115
+
116
+ const absoluteSrc = resolveImagePath(src, projectRoot);
117
+ if (!existsSync(absoluteSrc)) return;
118
+
119
+ let manifest = imageRefs.get(absoluteSrc);
120
+
121
+ if (!manifest) {
122
+ const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
123
+ const cached = getCached(cache, key, outDir);
124
+ manifest = cached ?? (await processImage(absoluteSrc, outDir, config));
125
+
126
+ setCached(cache, key, src, manifest);
127
+ imageRefs.set(absoluteSrc, manifest);
128
+ }
129
+
130
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
131
+ const srcset = buildSrcset(manifest.variants, preferredFormat);
132
+
133
+ if (srcset) {
134
+ node.attributes.srcset = srcset;
135
+ node.attributes.sizes = node.attributes.sizes ?? config.sizes;
136
+ }
137
+
138
+ if (!node.attributes.width && manifest.original.width) {
139
+ node.attributes.width = String(manifest.original.width);
140
+ }
141
+ if (!node.attributes.height && manifest.original.height) {
142
+ node.attributes.height = String(manifest.original.height);
143
+ }
144
+
145
+ if (config.lazyLoad && node.attributes.loading !== "eager") {
146
+ node.attributes.loading = "lazy";
147
+ node.attributes.decoding = "async";
148
+ }
149
+ }
@@ -23,7 +23,7 @@ import { discoverPages, expandDynamicRoutes } from "./pages-discovery.js";
23
23
  import { resolveLayout } from "./layout-resolver.js";
24
24
  import { mergeHead, renderHead } from "./head-merger.js";
25
25
  import { injectContext } from "./context-injection.js";
26
- import { compile, compileServer } from "../compiler.js";
26
+ import { compile, compileServer, compileSiteServer } from "../compiler.js";
27
27
  import { compileElement } from "../targets/compile-element.js";
28
28
  import {
29
29
  buildInitialScope,
@@ -32,12 +32,15 @@ import {
32
32
  preRenderComponentHtml,
33
33
  isComponentFullyStatic,
34
34
  buildComponentCSS,
35
+ collectServerEntries,
35
36
  DEFAULT_REACTIVITY_SRC,
36
37
  DEFAULT_LIT_HTML_SRC,
37
38
  } from "../shared.js";
38
39
  import { loadCollections, loadContentConfig, resolveCollectionRefs } from "./content-loader.js";
39
40
  import { resolvePrototypes } from "./prototype-resolver.js";
40
41
  import { compileMarkdown } from "../targets/compile-markdown.js";
42
+ import { transformImageNodes } from "./image-transform.js";
43
+ import { loadCache, saveCache } from "./image-cache.js";
41
44
 
42
45
  /**
43
46
  * Build an entire Jx site from a project directory.
@@ -156,12 +159,34 @@ export async function buildSite(projectRoot, options = {}) {
156
159
  );
157
160
  }
158
161
 
162
+ // ── 5b. Collect server entries from components (for site-wide bundling) ──
163
+ /** @type {{ exportName: string; src: string }[]} */
164
+ const siteServerEntries = [];
165
+ if (projectConfig.build.provider) {
166
+ for (const [, doc] of componentDefs) {
167
+ const entries = collectServerEntries(doc);
168
+ for (const entry of entries) {
169
+ const resolvedSrc = "./" + join("components", entry.src.replace(/^\.\//, ""));
170
+ siteServerEntries.push({ exportName: entry.exportName, src: resolvedSrc });
171
+ }
172
+ }
173
+ }
174
+
159
175
  // ── 6. Compile each route ───────────────────────────────────────────────
160
176
 
177
+ const imageCache = projectConfig.images.optimize ? loadCache(projectRoot) : null;
178
+
161
179
  for (const route of routes) {
162
180
  try {
163
181
  log(` Compiling ${route.urlPattern} ...`);
164
- const result = await compilePage(route, projectConfig, projectRoot, collections);
182
+ const result = await compilePage(
183
+ route,
184
+ projectConfig,
185
+ projectRoot,
186
+ collections,
187
+ imageCache,
188
+ outDir,
189
+ );
165
190
 
166
191
  // Inject pre-rendered component HTML scaffolding (instance-aware)
167
192
  // Must happen before script injection so we know which tags are fully static
@@ -224,6 +249,36 @@ export async function buildSite(projectRoot, options = {}) {
224
249
  }
225
250
  }
226
251
 
252
+ // ── 6b. Save image cache ─────────────────────────────────────────────
253
+ if (imageCache && projectConfig.images.optimize) {
254
+ saveCache(projectRoot, imageCache);
255
+ const totalImages = Object.keys(imageCache.entries).length;
256
+ if (totalImages > 0) {
257
+ log(` Optimized ${totalImages} image(s)`);
258
+ }
259
+ }
260
+
261
+ // ── 6c. Generate site-wide server worker ────────────────────────────────
262
+ if (projectConfig.build.provider && siteServerEntries.length > 0) {
263
+ log("Generating site-wide server worker...");
264
+
265
+ const deduped = new Map();
266
+ for (const entry of siteServerEntries) {
267
+ if (!deduped.has(entry.exportName)) deduped.set(entry.exportName, entry);
268
+ }
269
+
270
+ const workerSource = compileSiteServer([...deduped.values()], {
271
+ provider: projectConfig.build.provider,
272
+ });
273
+
274
+ if (workerSource) {
275
+ const workerPath = resolve(projectRoot, "_worker.js");
276
+ writeFileSync(workerPath, workerSource, "utf8");
277
+ fileCount++;
278
+ log(` Generated _worker.js (${deduped.size} server function(s))`);
279
+ }
280
+ }
281
+
227
282
  // ── 7. Generate redirects ───────────────────────────────────────────────
228
283
  if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
229
284
  log("Generating redirects...");
@@ -266,9 +321,18 @@ export async function buildSite(projectRoot, options = {}) {
266
321
  * @param {any} projectConfig
267
322
  * @param {string} projectRoot
268
323
  * @param {Map<string, any[]>} [collections]
324
+ * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
325
+ * @param {string} [outDir]
269
326
  * @returns {Promise<{ html: string; files: any[]; serverHandler: string | null; doc: any }>}
270
327
  */
271
- async function compilePage(route, projectConfig, projectRoot, collections = new Map()) {
328
+ async function compilePage(
329
+ route,
330
+ projectConfig,
331
+ projectRoot,
332
+ collections = new Map(),
333
+ imageCache = null,
334
+ outDir = "",
335
+ ) {
272
336
  // Load the raw page document
273
337
  let pageDoc;
274
338
  if (route.sourcePath.endsWith(".md")) {
@@ -346,6 +410,11 @@ async function compilePage(route, projectConfig, projectRoot, collections = new
346
410
  layoutDoc.$media = { ...projectConfig.$media, ...layoutDoc.$media };
347
411
  }
348
412
 
413
+ // Transform <img> nodes for responsive image optimization
414
+ if (imageCache && projectConfig.images?.optimize && outDir) {
415
+ await transformImageNodes(layoutDoc, projectConfig.images, projectRoot, outDir, imageCache);
416
+ }
417
+
349
418
  // Compile the document using the existing compiler
350
419
  const result = await compile(layoutDoc, {
351
420
  title,
@@ -364,16 +433,18 @@ async function compilePage(route, projectConfig, projectRoot, collections = new
364
433
  result.html = injectNpmElementScripts(result.html, npmElements);
365
434
  }
366
435
 
367
- // Compile server handler if applicable
436
+ // Compile server handler if applicable (skip when provider bundles site-wide)
368
437
  /** @type {string | null} */
369
438
  let serverHandler = null;
370
- try {
371
- const serverResult = await compileServer(route.sourcePath);
372
- if (serverResult) {
373
- serverHandler = serverResult;
439
+ if (!projectConfig.build.provider) {
440
+ try {
441
+ const serverResult = await compileServer(route.sourcePath);
442
+ if (serverResult) {
443
+ serverHandler = serverResult;
444
+ }
445
+ } catch {
446
+ // No server entries — that's fine
374
447
  }
375
- } catch {
376
- // No server entries — that's fine
377
448
  }
378
449
 
379
450
  return { html: result.html, files: result.files, serverHandler, doc: layoutDoc };
@@ -27,10 +27,19 @@ const DEFAULTS = {
27
27
  state: {},
28
28
  collections: {},
29
29
  redirects: {},
30
+ images: {
31
+ optimize: true,
32
+ widths: [320, 640, 960, 1280, 1920],
33
+ formats: ["webp", "avif"],
34
+ quality: { webp: 80, avif: 65, jpeg: 80, png: 80 },
35
+ sizes: "(max-width: 768px) 100vw, 50vw",
36
+ lazyLoad: true,
37
+ },
30
38
  build: {
31
39
  outDir: "./dist",
32
40
  format: "directory",
33
41
  trailingSlash: "always",
42
+ provider: null,
34
43
  },
35
44
  };
36
45
 
@@ -65,6 +74,7 @@ export function loadProjectConfig(projectRoot) {
65
74
  ...DEFAULTS,
66
75
  ...raw,
67
76
  defaults: { ...DEFAULTS.defaults, ...raw.defaults },
77
+ images: { ...DEFAULTS.images, ...raw.images },
68
78
  build: { ...DEFAULTS.build, ...raw.build },
69
79
  };
70
80