@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/dist/compiler.js +105 -88
- package/package.json +4 -3
- package/src/compiler.js +9 -2
- package/src/shared.js +6 -1
- package/src/site/image-cache.js +106 -0
- package/src/site/image-optimizer.js +187 -0
- package/src/site/image-transform.js +149 -0
- package/src/site/site-build.js +81 -10
- package/src/site/site-loader.js +10 -0
- package/src/targets/compile-server.js +48 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
41
|
-
"@jxsuite/runtime": "^0.5.
|
|
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 {
|
|
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
|
+
}
|
package/src/site/site-build.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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 };
|
package/src/site/site-loader.js
CHANGED
|
@@ -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
|
|