@jxsuite/compiler 0.28.0 → 0.28.3
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 +166 -249
- package/dist/compiler.js +100 -188
- package/package.json +5 -5
- package/src/cli.ts +6 -6
- package/src/compile-cli.ts +6 -4
- package/src/compiler.ts +35 -29
- package/src/shared.ts +402 -231
- package/src/site/content-loader.ts +54 -29
- package/src/site/context-injection.ts +13 -9
- package/src/site/format-host.ts +26 -15
- package/src/site/head-merger.ts +37 -18
- package/src/site/image-cache.ts +13 -12
- package/src/site/image-optimizer.ts +32 -23
- package/src/site/image-transform.ts +172 -81
- package/src/site/layout-resolver.ts +33 -18
- package/src/site/pages-discovery.ts +61 -39
- package/src/site/prototype-resolver.ts +45 -21
- package/src/site/site-build.ts +232 -183
- package/src/site/site-loader.ts +71 -52
- package/src/targets/compile-class.ts +70 -51
- package/src/targets/compile-client.ts +271 -227
- package/src/targets/compile-element.ts +230 -146
- package/src/targets/compile-server.ts +16 -62
- package/src/targets/compile-static.ts +27 -19
- package/src/targets/compile-image-endpoint.ts +0 -138
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
* @module content-loader
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFileSync, readdirSync
|
|
17
|
-
import {
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
18
|
+
import { basename, extname, resolve } from "node:path";
|
|
18
19
|
import { buildProjectFormatRegistry, unknownFormatError } from "./format-host.ts";
|
|
19
|
-
import type {
|
|
20
|
+
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
20
21
|
import type {
|
|
21
|
-
ProjectConfig,
|
|
22
22
|
ContentTypeDef,
|
|
23
23
|
ContentTypeSchema,
|
|
24
24
|
JxMutableNode,
|
|
25
|
+
ProjectConfig,
|
|
25
26
|
} from "@jxsuite/schema/types";
|
|
26
27
|
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
27
28
|
|
|
@@ -35,21 +36,21 @@ import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
|
35
36
|
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
36
37
|
*/
|
|
37
38
|
function loadJSONEntries(filePath: string) {
|
|
38
|
-
const raw = JSON.parse(readFileSync(filePath, "
|
|
39
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
39
40
|
if (Array.isArray(raw)) {
|
|
40
41
|
return raw.map((item: Record<string, unknown>, i: number) => ({
|
|
41
|
-
id: (item.id as string) ?? basename(filePath, ".json") + "-" + i,
|
|
42
|
-
data: item,
|
|
43
42
|
body: null,
|
|
43
|
+
data: item,
|
|
44
|
+
id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
46
47
|
// Single object file — filename is the id
|
|
47
48
|
const rawObj: Record<string, unknown> = raw;
|
|
48
49
|
return [
|
|
49
50
|
{
|
|
50
|
-
id: (rawObj.id as string) ?? basename(filePath, ".json"),
|
|
51
|
-
data: rawObj,
|
|
52
51
|
body: null,
|
|
52
|
+
data: rawObj,
|
|
53
|
+
id: (rawObj.id as string) ?? basename(filePath, ".json"),
|
|
53
54
|
},
|
|
54
55
|
];
|
|
55
56
|
}
|
|
@@ -106,11 +107,15 @@ export async function loadContentTypes(
|
|
|
106
107
|
registry?: FormatRegistry,
|
|
107
108
|
) {
|
|
108
109
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
109
|
-
if (!result)
|
|
110
|
+
if (!result) {
|
|
111
|
+
return new Map();
|
|
112
|
+
}
|
|
110
113
|
|
|
111
114
|
const { config } = result;
|
|
112
|
-
const contentTypes
|
|
113
|
-
if (Object.keys(config.contentTypes).length === 0)
|
|
115
|
+
const contentTypes = new Map<string, ContentLoaderEntry[]>();
|
|
116
|
+
if (Object.keys(config.contentTypes).length === 0) {
|
|
117
|
+
return contentTypes;
|
|
118
|
+
}
|
|
114
119
|
|
|
115
120
|
const formats = registry ?? (await buildProjectFormatRegistry(projectRoot, projectConfig));
|
|
116
121
|
|
|
@@ -136,7 +141,9 @@ export function getContentTypeElements(
|
|
|
136
141
|
projectConfig?: ProjectConfig,
|
|
137
142
|
) {
|
|
138
143
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
139
|
-
if (!result)
|
|
144
|
+
if (!result) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
140
147
|
const def = result.config.contentTypes?.[contentTypeName];
|
|
141
148
|
return def?.$elements;
|
|
142
149
|
}
|
|
@@ -156,9 +163,11 @@ async function loadContentType(
|
|
|
156
163
|
projectRoot: string,
|
|
157
164
|
registry: FormatRegistry,
|
|
158
165
|
) {
|
|
159
|
-
const source = contentTypeDef
|
|
160
|
-
if (!source)
|
|
161
|
-
|
|
166
|
+
const { source } = contentTypeDef;
|
|
167
|
+
if (!source) {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const { schema } = contentTypeDef;
|
|
162
171
|
|
|
163
172
|
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
164
173
|
const directiveOptions = contentTypeDef.$elements?.length
|
|
@@ -200,13 +209,15 @@ async function loadContentType(
|
|
|
200
209
|
}
|
|
201
210
|
try {
|
|
202
211
|
const entries = (await entry.call("load", source, {
|
|
203
|
-
schema,
|
|
204
212
|
directiveOptions,
|
|
213
|
+
schema,
|
|
205
214
|
})) as ContentLoaderEntry[];
|
|
206
|
-
if (schema)
|
|
215
|
+
if (schema) {
|
|
216
|
+
validateEntries(entries, schema, name);
|
|
217
|
+
}
|
|
207
218
|
return entries;
|
|
208
|
-
} catch (
|
|
209
|
-
console.warn(`Content type "${name}": ${(
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn(`Content type "${name}": ${errorMessage(error)}`);
|
|
210
221
|
return [];
|
|
211
222
|
}
|
|
212
223
|
}
|
|
@@ -218,8 +229,12 @@ async function loadContentType(
|
|
|
218
229
|
if (formatName === "json" || (!entry && ext === ".json")) {
|
|
219
230
|
const files = discoverJSONFiles(resolvedSource);
|
|
220
231
|
const entries: ContentLoaderEntry[] = [];
|
|
221
|
-
for (const filePath of files)
|
|
222
|
-
|
|
232
|
+
for (const filePath of files) {
|
|
233
|
+
entries.push(...loadJSONEntries(filePath));
|
|
234
|
+
}
|
|
235
|
+
if (schema) {
|
|
236
|
+
validateEntries(entries, schema, name);
|
|
237
|
+
}
|
|
223
238
|
return entries;
|
|
224
239
|
}
|
|
225
240
|
|
|
@@ -245,8 +260,8 @@ async function loadContentType(
|
|
|
245
260
|
for (const filePath of files) {
|
|
246
261
|
entries.push(
|
|
247
262
|
...((await entry.call("load", filePath, {
|
|
248
|
-
schema,
|
|
249
263
|
directiveOptions,
|
|
264
|
+
schema,
|
|
250
265
|
})) as ContentLoaderEntry[]),
|
|
251
266
|
);
|
|
252
267
|
}
|
|
@@ -290,7 +305,9 @@ function validateEntries(
|
|
|
290
305
|
// Check types
|
|
291
306
|
for (const [field, def] of Object.entries(properties)) {
|
|
292
307
|
const value = entry.data[field];
|
|
293
|
-
if (value == null)
|
|
308
|
+
if (value == null) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
294
311
|
|
|
295
312
|
if (def.type === "string" && typeof value !== "string") {
|
|
296
313
|
console.warn(
|
|
@@ -332,17 +349,25 @@ export function resolveContentTypeRefs(
|
|
|
332
349
|
config: { contentTypes: Record<string, ContentTypeDef> },
|
|
333
350
|
) {
|
|
334
351
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
335
|
-
const schema = contentTypeDef
|
|
336
|
-
if (!schema?.properties)
|
|
352
|
+
const { schema } = contentTypeDef;
|
|
353
|
+
if (!schema?.properties) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
337
356
|
|
|
338
357
|
const entries = contentTypes.get(name);
|
|
339
|
-
if (!entries)
|
|
358
|
+
if (!entries) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
340
361
|
|
|
341
362
|
for (const [field, def] of Object.entries(schema.properties)) {
|
|
342
|
-
if (!def.$ref?.startsWith("#/contentTypes/"))
|
|
363
|
+
if (!def.$ref?.startsWith("#/contentTypes/")) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
343
366
|
const refContentType = def.$ref.replace("#/contentTypes/", "");
|
|
344
367
|
const refEntries = contentTypes.get(refContentType);
|
|
345
|
-
if (!refEntries)
|
|
368
|
+
if (!refEntries) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
346
371
|
|
|
347
372
|
for (const entry of entries) {
|
|
348
373
|
const refId = entry.data[field];
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* title $page.params — dynamic route parameters (if any)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { dirname, relative, resolve } from "node:path";
|
|
16
16
|
import type {
|
|
17
|
-
JxElement,
|
|
18
17
|
JxDocument,
|
|
18
|
+
JxElement,
|
|
19
19
|
JxStateDefinition,
|
|
20
20
|
ProjectConfig,
|
|
21
21
|
} from "@jxsuite/schema/types";
|
|
@@ -36,10 +36,12 @@ export function injectContext(
|
|
|
36
36
|
doc: JxDocument,
|
|
37
37
|
projectConfig: ProjectConfig,
|
|
38
38
|
route: SiteRoute,
|
|
39
|
-
contentTypes
|
|
39
|
+
contentTypes = new Map<string, ContentLoaderEntry[]>(),
|
|
40
40
|
projectRoot: string | null = null,
|
|
41
41
|
) {
|
|
42
|
-
if (!doc.state)
|
|
42
|
+
if (!doc.state) {
|
|
43
|
+
doc.state = {};
|
|
44
|
+
}
|
|
43
45
|
|
|
44
46
|
// $site context — read-only project-level data
|
|
45
47
|
doc.state.$site = {
|
|
@@ -50,9 +52,9 @@ export function injectContext(
|
|
|
50
52
|
|
|
51
53
|
// $page context — read-only page-level data
|
|
52
54
|
doc.state.$page = {
|
|
53
|
-
url: route.urlPattern,
|
|
54
|
-
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
55
55
|
params: route._pathParams ?? {},
|
|
56
|
+
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
57
|
+
url: route.urlPattern,
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
// Merge project-level state into page state (page wins on conflicts)
|
|
@@ -71,14 +73,16 @@ export function injectContext(
|
|
|
71
73
|
|
|
72
74
|
// Merge project-level imports into page imports (page wins on collision)
|
|
73
75
|
if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
|
|
74
|
-
if (!doc.imports)
|
|
76
|
+
if (!doc.imports) {
|
|
77
|
+
doc.imports = {};
|
|
78
|
+
}
|
|
75
79
|
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
76
80
|
if (!(name in doc.imports)) {
|
|
77
81
|
const src = srcPath as string;
|
|
78
82
|
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
79
83
|
if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
|
|
80
84
|
const abs = resolve(projectRoot, src);
|
|
81
|
-
doc.imports[name] =
|
|
85
|
+
doc.imports[name] = `./${relative(dirname(route.sourcePath), abs)}`;
|
|
82
86
|
} else {
|
|
83
87
|
doc.imports[name] = src;
|
|
84
88
|
}
|
|
@@ -91,7 +95,7 @@ export function injectContext(
|
|
|
91
95
|
if (!doc.$elements?.length) {
|
|
92
96
|
doc.$elements = [...projectConfig.$elements];
|
|
93
97
|
} else {
|
|
94
|
-
const seen
|
|
98
|
+
const seen = new Set<string>();
|
|
95
99
|
const merged: (string | JxElement)[] = [];
|
|
96
100
|
for (const entry of [
|
|
97
101
|
...projectConfig.$elements,
|
package/src/site/format-host.ts
CHANGED
|
@@ -7,14 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import { dirname,
|
|
10
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
type FormatRegistry,
|
|
16
|
-
type FormatHostIO,
|
|
17
|
-
} from "@jxsuite/schema/format-registry";
|
|
13
|
+
import { buildFormatRegistry } from "@jxsuite/schema/format-registry";
|
|
14
|
+
import type { FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
18
15
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
19
16
|
|
|
20
17
|
export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
|
|
@@ -32,32 +29,46 @@ export async function importImplementation(path: string): Promise<Record<string,
|
|
|
32
29
|
const candidates = [path];
|
|
33
30
|
const srcSeg = `${sep}src${sep}`;
|
|
34
31
|
const distSeg = `${sep}dist${sep}`;
|
|
35
|
-
if (path.includes(srcSeg))
|
|
36
|
-
|
|
32
|
+
if (path.includes(srcSeg)) {
|
|
33
|
+
candidates.push(path.replace(srcSeg, distSeg));
|
|
34
|
+
}
|
|
35
|
+
if (path.endsWith(".js")) {
|
|
36
|
+
candidates.push(`${path.slice(0, -3)}.ts`);
|
|
37
|
+
}
|
|
37
38
|
|
|
38
39
|
let lastError: unknown;
|
|
39
40
|
for (const candidate of candidates) {
|
|
40
41
|
try {
|
|
41
42
|
return await import(pathToFileURL(candidate).href);
|
|
42
|
-
} catch (
|
|
43
|
-
lastError =
|
|
43
|
+
} catch (error) {
|
|
44
|
+
lastError = error;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
throw lastError;
|
|
47
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** Create a node FormatHostIO rooted at a project (bare specifiers resolve via node_modules). */
|
|
50
51
|
export function createNodeFormatIO(projectRoot: string): FormatHostIO {
|
|
51
52
|
return {
|
|
52
|
-
loadJson: async (path) => JSON.parse(readFileSync(path, "utf-8")),
|
|
53
53
|
importModule: (path) => importImplementation(path),
|
|
54
|
+
loadJson: async (path) => JSON.parse(readFileSync(path, "utf8")),
|
|
54
55
|
resolvePath: (base, ref) => {
|
|
55
56
|
if (ref.startsWith("./") || ref.startsWith("../")) {
|
|
56
57
|
return resolve(dirname(base), ref);
|
|
57
58
|
}
|
|
58
|
-
if (isAbsolute(ref))
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
if (isAbsolute(ref)) {
|
|
60
|
+
return ref;
|
|
61
|
+
}
|
|
62
|
+
// Bare specifier — prefer the project's own node_modules, then fall back to the host's
|
|
63
|
+
// Resolution (the desktop app and dev server ship @jxsuite/parser, so format classes work
|
|
64
|
+
// Even when the project's dependencies are not installed).
|
|
65
|
+
const projRequire = createRequire(resolve(projectRoot, "package.json"));
|
|
66
|
+
try {
|
|
67
|
+
return projRequire.resolve(ref);
|
|
68
|
+
} catch {
|
|
69
|
+
const selfRequire = createRequire(import.meta.url);
|
|
70
|
+
return selfRequire.resolve(ref);
|
|
71
|
+
}
|
|
61
72
|
},
|
|
62
73
|
};
|
|
63
74
|
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -34,18 +34,18 @@ export function mergeHead(
|
|
|
34
34
|
) {
|
|
35
35
|
// Start with auto-injected defaults
|
|
36
36
|
const defaults: JxHeadEntry[] = [
|
|
37
|
-
{
|
|
37
|
+
{ attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
|
|
38
38
|
{
|
|
39
|
-
tagName: "meta",
|
|
40
39
|
attributes: {
|
|
41
|
-
name: "viewport",
|
|
42
40
|
content: "width=device-width, initial-scale=1",
|
|
41
|
+
name: "viewport",
|
|
43
42
|
},
|
|
43
|
+
tagName: "meta",
|
|
44
44
|
},
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
// Merge layers: site → layout → page (later wins)
|
|
48
|
-
const merged
|
|
48
|
+
const merged = new Map<string, JxHeadEntry>();
|
|
49
49
|
|
|
50
50
|
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
51
51
|
const key = headEntryKey(entry);
|
|
@@ -54,18 +54,18 @@ export function mergeHead(
|
|
|
54
54
|
|
|
55
55
|
// Insert <title> if present
|
|
56
56
|
const title = context.title ?? context.siteName ?? "Jx Site";
|
|
57
|
-
merged.set("title", {
|
|
57
|
+
merged.set("title", { children: [title], tagName: "title" });
|
|
58
58
|
|
|
59
59
|
// Add canonical URL if provided
|
|
60
60
|
if (context.pageUrl && context.siteUrl) {
|
|
61
61
|
const canonical = new URL(context.pageUrl, context.siteUrl).href;
|
|
62
62
|
merged.set("link:canonical", {
|
|
63
|
+
attributes: { href: canonical, rel: "canonical" },
|
|
63
64
|
tagName: "link",
|
|
64
|
-
attributes: { rel: "canonical", href: canonical },
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
return
|
|
68
|
+
return [...merged.values()];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
@@ -76,22 +76,32 @@ export function mergeHead(
|
|
|
76
76
|
* @returns {string}
|
|
77
77
|
*/
|
|
78
78
|
function headEntryKey(entry: JxHeadEntry) {
|
|
79
|
-
if (!entry || typeof entry !== "object")
|
|
79
|
+
if (!entry || typeof entry !== "object") {
|
|
80
|
+
return String(entry);
|
|
81
|
+
}
|
|
80
82
|
|
|
81
83
|
const tag = entry.tagName ?? "unknown";
|
|
82
84
|
const attrs = entry.attributes ?? {};
|
|
83
85
|
|
|
84
86
|
// <title> — singleton
|
|
85
|
-
if (tag === "title")
|
|
87
|
+
if (tag === "title") {
|
|
88
|
+
return "title";
|
|
89
|
+
}
|
|
86
90
|
|
|
87
91
|
// <meta charset> — singleton
|
|
88
|
-
if (attrs.charset)
|
|
92
|
+
if (attrs.charset) {
|
|
93
|
+
return "meta:charset";
|
|
94
|
+
}
|
|
89
95
|
|
|
90
96
|
// <meta name="...""> — keyed by name
|
|
91
|
-
if (tag === "meta" && attrs.name)
|
|
97
|
+
if (tag === "meta" && attrs.name) {
|
|
98
|
+
return `meta:${attrs.name}`;
|
|
99
|
+
}
|
|
92
100
|
|
|
93
101
|
// <meta property="..."> — keyed by property (Open Graph)
|
|
94
|
-
if (tag === "meta" && attrs.property)
|
|
102
|
+
if (tag === "meta" && attrs.property) {
|
|
103
|
+
return `meta:${attrs.property}`;
|
|
104
|
+
}
|
|
95
105
|
|
|
96
106
|
// <link rel="..." href="..."> — keyed by rel+href
|
|
97
107
|
if (tag === "link" && attrs.rel) {
|
|
@@ -99,7 +109,9 @@ function headEntryKey(entry: JxHeadEntry) {
|
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
// <script src="..."> — keyed by src
|
|
102
|
-
if (tag === "script" && attrs.src)
|
|
112
|
+
if (tag === "script" && attrs.src) {
|
|
113
|
+
return `script:${attrs.src}`;
|
|
114
|
+
}
|
|
103
115
|
|
|
104
116
|
// <style> — unique per content hash
|
|
105
117
|
if (tag === "style") {
|
|
@@ -122,7 +134,8 @@ function headEntryKey(entry: JxHeadEntry) {
|
|
|
122
134
|
function simpleHash(str: string) {
|
|
123
135
|
let hash = 0;
|
|
124
136
|
for (let i = 0; i < str.length; i++) {
|
|
125
|
-
|
|
137
|
+
// oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
|
|
138
|
+
hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
|
|
126
139
|
}
|
|
127
140
|
return hash.toString(36);
|
|
128
141
|
}
|
|
@@ -144,8 +157,12 @@ export function renderHead(headEntries: JxHeadEntry[]) {
|
|
|
144
157
|
* @returns {string}
|
|
145
158
|
*/
|
|
146
159
|
function renderHeadEntry(entry: JxHeadEntry) {
|
|
147
|
-
if (typeof entry === "string")
|
|
148
|
-
|
|
160
|
+
if (typeof entry === "string") {
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
if (!entry || typeof entry !== "object") {
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
149
166
|
|
|
150
167
|
const tag = entry.tagName;
|
|
151
168
|
const attrs = entry.attributes ?? {};
|
|
@@ -157,7 +174,9 @@ function renderHeadEntry(entry: JxHeadEntry) {
|
|
|
157
174
|
|
|
158
175
|
// Void elements (no closing tag)
|
|
159
176
|
const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
|
|
160
|
-
if (VOID.has(tag))
|
|
177
|
+
if (VOID.has(tag)) {
|
|
178
|
+
return open;
|
|
179
|
+
}
|
|
161
180
|
|
|
162
181
|
// Elements with content
|
|
163
182
|
const content = Array.isArray(entry.children)
|
|
@@ -171,5 +190,5 @@ function renderHeadEntry(entry: JxHeadEntry) {
|
|
|
171
190
|
* @returns {string}
|
|
172
191
|
*/
|
|
173
192
|
function escapeAttr(val: unknown) {
|
|
174
|
-
return String(val).
|
|
193
|
+
return String(val).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
175
194
|
}
|
package/src/site/image-cache.ts
CHANGED
|
@@ -6,14 +6,13 @@
|
|
|
6
6
|
* CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, resolve } from "node:path";
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
|
-
import {
|
|
13
|
+
import { configHash, contentHash } from "./image-optimizer.ts";
|
|
14
14
|
|
|
15
|
-
import type { ImageManifest } from "./image-optimizer.ts";
|
|
16
|
-
import type { ImageConfig } from "./image-optimizer.ts";
|
|
15
|
+
import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
|
|
17
16
|
|
|
18
17
|
interface CacheEntry {
|
|
19
18
|
source: string; // Relative path to source image
|
|
@@ -28,7 +27,7 @@ export interface CacheManifest {
|
|
|
28
27
|
|
|
29
28
|
// ─── Cache directory resolution ──────────────────────────────────────────────
|
|
30
29
|
|
|
31
|
-
let _npmCacheBase: string | null | undefined
|
|
30
|
+
let _npmCacheBase: string | null | undefined; // Undefined = not yet resolved
|
|
32
31
|
|
|
33
32
|
/** Reset the memoized npm cache base so the next call re-evaluates. Tests only. */
|
|
34
33
|
export function _testResetNpmCacheBase() {
|
|
@@ -59,9 +58,9 @@ export function getImageCacheDir(projectRoot: string): string {
|
|
|
59
58
|
if (_npmCacheBase === undefined) {
|
|
60
59
|
try {
|
|
61
60
|
const result = execSync("npm config get cache", {
|
|
62
|
-
|
|
61
|
+
cwd: tmpdir(), // Avoid ENOWORKSPACES when cwd is inside an npm workspace
|
|
62
|
+
encoding: "utf8",
|
|
63
63
|
stdio: ["ignore", "pipe", "ignore"],
|
|
64
|
-
cwd: tmpdir(), // avoid ENOWORKSPACES when cwd is inside an npm workspace
|
|
65
64
|
}).trim();
|
|
66
65
|
_npmCacheBase = result && result !== "undefined" ? result : null;
|
|
67
66
|
} catch {
|
|
@@ -98,12 +97,12 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
|
|
|
98
97
|
export function loadCache(projectRoot: string) {
|
|
99
98
|
const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
|
|
100
99
|
if (!existsSync(manifestPath)) {
|
|
101
|
-
return {
|
|
100
|
+
return { entries: {}, version: 1 };
|
|
102
101
|
}
|
|
103
102
|
try {
|
|
104
103
|
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
105
104
|
} catch {
|
|
106
|
-
return {
|
|
105
|
+
return { entries: {}, version: 1 };
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -130,7 +129,9 @@ export function saveCache(projectRoot: string, cache: CacheManifest) {
|
|
|
130
129
|
*/
|
|
131
130
|
export function getCached(cache: CacheManifest, key: string) {
|
|
132
131
|
const entry = cache.entries[key];
|
|
133
|
-
if (!entry)
|
|
132
|
+
if (!entry) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
134
135
|
return entry.manifest;
|
|
135
136
|
}
|
|
136
137
|
|
|
@@ -149,8 +150,8 @@ export function setCached(
|
|
|
149
150
|
manifest: ImageManifest,
|
|
150
151
|
) {
|
|
151
152
|
cache.entries[key] = {
|
|
152
|
-
source: sourcePath,
|
|
153
153
|
manifest,
|
|
154
|
+
source: sourcePath,
|
|
154
155
|
timestamp: Date.now(),
|
|
155
156
|
};
|
|
156
157
|
}
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { basename, extname, resolve } from "node:path";
|
|
11
12
|
import { createRequire } from "node:module";
|
|
13
|
+
import type { FormatEnum } from "sharp";
|
|
12
14
|
|
|
13
15
|
export interface ImageVariant {
|
|
14
16
|
width: number; // Pixel width of the variant
|
|
@@ -31,39 +33,44 @@ export interface ImageConfig {
|
|
|
31
33
|
sizes: string;
|
|
32
34
|
lazyLoad: boolean;
|
|
33
35
|
service?: "build" | "cloudflare";
|
|
34
|
-
binding?: string;
|
|
35
36
|
/**
|
|
36
37
|
* Hostnames whose remote (https) images are routed through the optimization pipeline. Cloudflare
|
|
37
|
-
* service only —
|
|
38
|
-
*
|
|
38
|
+
* service only — allowlisted remote sources get /cdn-cgi/image transform srcsets (the zone must
|
|
39
|
+
* permit resizing from the remote origin); everything else is left untouched.
|
|
39
40
|
*/
|
|
40
41
|
remoteDomains?: string[];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// oxlint-disable-next-line typescript/consistent-type-imports -- sharp is an optional native dep loaded lazily; its `export =` module type is only nameable via import()
|
|
45
|
+
type SharpModule = typeof import("sharp");
|
|
46
|
+
|
|
47
|
+
let _sharp: SharpModule | null = null;
|
|
44
48
|
|
|
45
49
|
async function getSharp() {
|
|
46
|
-
if (_sharp)
|
|
50
|
+
if (_sharp) {
|
|
51
|
+
return _sharp;
|
|
52
|
+
}
|
|
47
53
|
// Primary: dynamic import — works in tests (mock.module intercepts it) and in
|
|
48
|
-
//
|
|
54
|
+
// Production installs where @img/sharp-* native packages are adjacent to cli.js.
|
|
49
55
|
try {
|
|
50
56
|
const sharpMod = await import("sharp");
|
|
51
|
-
_sharp = sharpMod.default as
|
|
57
|
+
_sharp = sharpMod.default as SharpModule;
|
|
52
58
|
return _sharp;
|
|
53
59
|
} catch {
|
|
54
60
|
// Fall through to CJS fallback below.
|
|
55
61
|
}
|
|
56
62
|
// Fallback: resolve from the project being compiled. This covers symlinked
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
63
|
+
// Monorepo dev environments (e.g. NixOS) where Node.js resolves import.meta.url
|
|
64
|
+
// To the compiler package's real path, making the @img/sharp-* packages
|
|
65
|
+
// Unreachable via the primary import path.
|
|
60
66
|
try {
|
|
61
67
|
const req = createRequire(resolve(process.cwd(), "package.json"));
|
|
62
|
-
_sharp = req("sharp") as
|
|
68
|
+
_sharp = req("sharp") as SharpModule;
|
|
63
69
|
return _sharp;
|
|
64
|
-
} catch (
|
|
70
|
+
} catch (error) {
|
|
65
71
|
throw new Error(
|
|
66
|
-
`Sharp is required for image optimization but failed to load: ${(
|
|
72
|
+
`Sharp is required for image optimization but failed to load: ${errorMessage(error)}`,
|
|
73
|
+
{ cause: error },
|
|
67
74
|
);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
@@ -80,9 +87,9 @@ export async function getImageMetadata(srcPath: string) {
|
|
|
80
87
|
const sharp = await getSharp();
|
|
81
88
|
const meta = await sharp(srcPath).metadata();
|
|
82
89
|
return {
|
|
83
|
-
width: meta.width ?? 0,
|
|
84
|
-
height: meta.height ?? 0,
|
|
85
90
|
format: (meta.format ?? "unknown") as string,
|
|
91
|
+
height: meta.height ?? 0,
|
|
92
|
+
width: meta.width ?? 0,
|
|
86
93
|
};
|
|
87
94
|
}
|
|
88
95
|
|
|
@@ -105,9 +112,9 @@ export function contentHash(srcPath: string) {
|
|
|
105
112
|
*/
|
|
106
113
|
export function configHash(config: ImageConfig) {
|
|
107
114
|
const key = JSON.stringify({
|
|
108
|
-
widths: config.widths,
|
|
109
115
|
formats: config.formats,
|
|
110
116
|
quality: config.quality,
|
|
117
|
+
widths: config.widths,
|
|
111
118
|
});
|
|
112
119
|
return createHash("md5").update(key).digest("hex").slice(0, 8);
|
|
113
120
|
}
|
|
@@ -159,14 +166,16 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
|
|
|
159
166
|
const outputPath = `/${OPTIMIZED_DIR}/${filename}`;
|
|
160
167
|
const absolutePath = resolve(optimizedDir, filename);
|
|
161
168
|
|
|
162
|
-
variants.push({
|
|
169
|
+
variants.push({ absolutePath, format, outputPath, width });
|
|
163
170
|
|
|
164
|
-
if (existsSync(absolutePath))
|
|
171
|
+
if (existsSync(absolutePath)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
165
174
|
|
|
166
175
|
const quality = config.quality[format as keyof ImageConfig["quality"]] ?? 80;
|
|
167
176
|
const task = sharp(srcPath)
|
|
168
177
|
.resize(width)
|
|
169
|
-
.toFormat(format as keyof
|
|
178
|
+
.toFormat(format as keyof FormatEnum, { quality })
|
|
170
179
|
.toFile(absolutePath)
|
|
171
180
|
.then(() => {});
|
|
172
181
|
|
|
@@ -180,9 +189,9 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
|
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
return {
|
|
183
|
-
original: { width: meta.width, height: meta.height, format: meta.format },
|
|
184
|
-
variants,
|
|
185
192
|
contentHash: hash8,
|
|
193
|
+
original: { format: meta.format, height: meta.height, width: meta.width },
|
|
194
|
+
variants,
|
|
186
195
|
};
|
|
187
196
|
}
|
|
188
197
|
|