@jxsuite/compiler 0.23.1 → 0.24.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 +155 -155
- package/package.json +18 -16
- package/src/{cli.js → cli.ts} +2 -2
- package/src/{compile-cli.js → compile-cli.ts} +1 -1
- package/src/{compiler.js → compiler.ts} +10 -8
- package/src/{shared.js → shared.ts} +161 -123
- package/src/site/{content-loader.js → content-loader.ts} +59 -52
- package/src/site/{context-injection.js → context-injection.ts} +18 -12
- package/src/site/{head-merger.js → head-merger.ts} +17 -11
- package/src/site/{image-cache.js → image-cache.ts} +22 -22
- package/src/site/{image-optimizer.js → image-optimizer.ts} +35 -44
- package/src/site/{image-transform.js → image-transform.ts} +52 -22
- package/src/site/{layout-resolver.js → layout-resolver.ts} +31 -26
- package/src/site/{pages-discovery.js → pages-discovery.ts} +31 -30
- package/src/site/{prototype-resolver.js → prototype-resolver.ts} +16 -5
- package/src/site/{site-build.js → site-build.ts} +134 -130
- package/src/site/{site-loader.js → site-loader.ts} +2 -2
- package/src/targets/{compile-class.js → compile-class.ts} +20 -19
- package/src/targets/{compile-client.js → compile-client.ts} +84 -105
- package/src/targets/{compile-element.js → compile-element.ts} +82 -88
- package/src/targets/{compile-markdown.js → compile-markdown.ts} +95 -81
- package/src/targets/{compile-server.js → compile-server.ts} +23 -6
- package/src/targets/{compile-static.js → compile-static.ts} +35 -18
- package/src/types.ts +16 -0
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
* @module content-loader
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync, readdirSync, existsSync
|
|
12
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
13
13
|
import { resolve, basename, extname } from "node:path";
|
|
14
|
+
import type {
|
|
15
|
+
ProjectConfig,
|
|
16
|
+
ContentTypeDef,
|
|
17
|
+
ContentTypeSchema,
|
|
18
|
+
JxMutableNode,
|
|
19
|
+
} from "@jxsuite/schema/types";
|
|
20
|
+
import type { MarkdownFileResult, ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
14
21
|
|
|
15
22
|
// ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
|
|
16
23
|
|
|
@@ -21,13 +28,11 @@ import { resolve, basename, extname } from "node:path";
|
|
|
21
28
|
* @param {string} csv - Raw CSV text
|
|
22
29
|
* @returns {Record<string, string>[]} Array of row objects
|
|
23
30
|
*/
|
|
24
|
-
function parseCSV(csv) {
|
|
25
|
-
|
|
26
|
-
const rows = [];
|
|
31
|
+
function parseCSV(csv: string) {
|
|
32
|
+
const rows: Record<string, string>[] = [];
|
|
27
33
|
let current = "";
|
|
28
34
|
let inQuotes = false;
|
|
29
|
-
|
|
30
|
-
const lines = [];
|
|
35
|
+
const lines: string[] = [];
|
|
31
36
|
|
|
32
37
|
// Split into rows respecting quoted newlines (preserve raw characters)
|
|
33
38
|
for (let i = 0; i < csv.length; i++) {
|
|
@@ -53,9 +58,8 @@ function parseCSV(csv) {
|
|
|
53
58
|
if (lines.length === 0) return [];
|
|
54
59
|
|
|
55
60
|
/** @param {string} line */
|
|
56
|
-
const parseRow = (line) => {
|
|
57
|
-
|
|
58
|
-
const fields = [];
|
|
61
|
+
const parseRow = (line: string) => {
|
|
62
|
+
const fields: string[] = [];
|
|
59
63
|
let field = "";
|
|
60
64
|
let q = false;
|
|
61
65
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -81,8 +85,7 @@ function parseCSV(csv) {
|
|
|
81
85
|
const headers = parseRow(lines[0]);
|
|
82
86
|
for (let i = 1; i < lines.length; i++) {
|
|
83
87
|
const fields = parseRow(lines[i]);
|
|
84
|
-
|
|
85
|
-
const obj = {};
|
|
88
|
+
const obj: Record<string, string> = {};
|
|
86
89
|
for (let j = 0; j < headers.length; j++) {
|
|
87
90
|
obj[headers[j].trim()] = fields[j]?.trim() ?? "";
|
|
88
91
|
}
|
|
@@ -98,7 +101,9 @@ function parseCSV(csv) {
|
|
|
98
101
|
* MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
99
102
|
* } | null}
|
|
100
103
|
*/
|
|
101
|
-
let _mdModule
|
|
104
|
+
let _mdModule: {
|
|
105
|
+
MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
106
|
+
} | null = null;
|
|
102
107
|
|
|
103
108
|
/**
|
|
104
109
|
* Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
|
|
@@ -110,9 +115,9 @@ let _mdModule = null;
|
|
|
110
115
|
*/
|
|
111
116
|
async function getMarkdownModule() {
|
|
112
117
|
if (!_mdModule) {
|
|
113
|
-
_mdModule =
|
|
118
|
+
_mdModule = (await import("@jxsuite/parser")) as unknown as NonNullable<typeof _mdModule>;
|
|
114
119
|
}
|
|
115
|
-
return
|
|
120
|
+
return _mdModule as NonNullable<typeof _mdModule>;
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
/**
|
|
@@ -123,21 +128,21 @@ async function getMarkdownModule() {
|
|
|
123
128
|
* @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
|
|
124
129
|
* @returns {Promise<ContentLoaderEntry>} ContentEntry shape
|
|
125
130
|
*/
|
|
126
|
-
async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
131
|
+
async function loadMarkdownEntry(filePath: string, directiveOptions?: unknown) {
|
|
127
132
|
const { MarkdownFile } = await getMarkdownModule();
|
|
128
133
|
const file = new MarkdownFile({ src: filePath, directiveOptions });
|
|
129
134
|
const result = file.resolve();
|
|
135
|
+
const _meta: ContentLoaderEntry["_meta"] = {};
|
|
136
|
+
if (result.$excerpt != null) _meta.excerpt = result.$excerpt;
|
|
137
|
+
if (result.$toc != null) _meta.toc = result.$toc;
|
|
138
|
+
if (result.$readingTime != null) _meta.readingTime = result.$readingTime;
|
|
139
|
+
if (result.$wordCount != null) _meta.wordCount = result.$wordCount;
|
|
130
140
|
return {
|
|
131
141
|
id: result.slug,
|
|
132
142
|
data: result.frontmatter,
|
|
133
143
|
body: readFileSync(filePath, "utf-8"),
|
|
134
144
|
$children: result.$children,
|
|
135
|
-
_meta
|
|
136
|
-
excerpt: result.$excerpt,
|
|
137
|
-
toc: result.$toc,
|
|
138
|
-
readingTime: result.$readingTime,
|
|
139
|
-
wordCount: result.$wordCount,
|
|
140
|
-
},
|
|
145
|
+
_meta,
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -148,21 +153,20 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
|
148
153
|
* @param {string} filePath - Absolute path to .json file
|
|
149
154
|
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
150
155
|
*/
|
|
151
|
-
function loadJSONEntries(filePath) {
|
|
156
|
+
function loadJSONEntries(filePath: string) {
|
|
152
157
|
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
153
158
|
if (Array.isArray(raw)) {
|
|
154
|
-
return raw.map((
|
|
155
|
-
id:
|
|
159
|
+
return raw.map((item: Record<string, unknown>, i: number) => ({
|
|
160
|
+
id: (item.id as string) ?? basename(filePath, ".json") + "-" + i,
|
|
156
161
|
data: item,
|
|
157
162
|
body: null,
|
|
158
163
|
}));
|
|
159
164
|
}
|
|
160
165
|
// Single object file — filename is the id
|
|
161
|
-
|
|
162
|
-
const rawObj = raw;
|
|
166
|
+
const rawObj: Record<string, unknown> = raw;
|
|
163
167
|
return [
|
|
164
168
|
{
|
|
165
|
-
id:
|
|
169
|
+
id: (rawObj.id as string) ?? basename(filePath, ".json"),
|
|
166
170
|
data: rawObj,
|
|
167
171
|
body: null,
|
|
168
172
|
},
|
|
@@ -176,13 +180,12 @@ function loadJSONEntries(filePath) {
|
|
|
176
180
|
* @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
|
|
177
181
|
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
178
182
|
*/
|
|
179
|
-
function loadCSVEntries(filePath, schema) {
|
|
183
|
+
function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
|
|
180
184
|
const csv = readFileSync(filePath, "utf-8");
|
|
181
185
|
const rows = parseCSV(csv);
|
|
182
|
-
return rows.map((
|
|
186
|
+
return rows.map((row: Record<string, string>, i: number) => {
|
|
183
187
|
// Apply type coercion based on schema if available
|
|
184
|
-
|
|
185
|
-
const data = { ...row };
|
|
188
|
+
const data: Record<string, unknown> = { ...row };
|
|
186
189
|
if (schema?.properties) {
|
|
187
190
|
for (const [key, def] of Object.entries(schema.properties)) {
|
|
188
191
|
if (key in data) {
|
|
@@ -192,9 +195,7 @@ function loadCSVEntries(filePath, schema) {
|
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
// Use `id` column, `sku` column, or row index as the entry ID
|
|
195
|
-
const id =
|
|
196
|
-
/** @type {string | undefined} */ (data.sku) ??
|
|
197
|
-
String(i);
|
|
198
|
+
const id = (data.id as string | undefined) ?? (data.sku as string | undefined) ?? String(i);
|
|
198
199
|
return { id, data, body: null };
|
|
199
200
|
});
|
|
200
201
|
}
|
|
@@ -212,7 +213,7 @@ function loadCSVEntries(filePath, schema) {
|
|
|
212
213
|
* } | null}
|
|
213
214
|
* Parsed config or null if no content dir
|
|
214
215
|
*/
|
|
215
|
-
export function loadContentConfig(projectRoot, projectConfig
|
|
216
|
+
export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
|
|
216
217
|
const contentDir = resolve(projectRoot, "content");
|
|
217
218
|
|
|
218
219
|
const config = { contentTypes: projectConfig?.contentTypes ?? {} };
|
|
@@ -230,13 +231,12 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
|
230
231
|
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
|
|
231
232
|
* ContentEntry
|
|
232
233
|
*/
|
|
233
|
-
export async function loadContentTypes(projectRoot, projectConfig
|
|
234
|
+
export async function loadContentTypes(projectRoot: string, projectConfig?: ProjectConfig) {
|
|
234
235
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
235
236
|
if (!result) return new Map();
|
|
236
237
|
|
|
237
238
|
const { config } = result;
|
|
238
|
-
|
|
239
|
-
const contentTypes = new Map();
|
|
239
|
+
const contentTypes: Map<string, ContentLoaderEntry[]> = new Map();
|
|
240
240
|
|
|
241
241
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
242
242
|
const entries = await loadContentType(name, contentTypeDef, projectRoot);
|
|
@@ -254,7 +254,11 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
|
|
|
254
254
|
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
255
255
|
* @returns {(string | JxElement)[] | undefined}
|
|
256
256
|
*/
|
|
257
|
-
export function getContentTypeElements(
|
|
257
|
+
export function getContentTypeElements(
|
|
258
|
+
projectRoot: string,
|
|
259
|
+
contentTypeName: string,
|
|
260
|
+
projectConfig?: ProjectConfig,
|
|
261
|
+
) {
|
|
258
262
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
259
263
|
if (!result) return undefined;
|
|
260
264
|
const def = result.config.contentTypes?.[contentTypeName];
|
|
@@ -269,8 +273,9 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
|
|
|
269
273
|
* @param {string} projectRoot - Absolute path to project root directory
|
|
270
274
|
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
271
275
|
*/
|
|
272
|
-
async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
276
|
+
async function loadContentType(name: string, contentTypeDef: ContentTypeDef, projectRoot: string) {
|
|
273
277
|
const source = contentTypeDef.source;
|
|
278
|
+
if (!source) return [];
|
|
274
279
|
const schema = contentTypeDef.schema;
|
|
275
280
|
|
|
276
281
|
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
@@ -278,22 +283,18 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
278
283
|
? {
|
|
279
284
|
allowedNames: contentTypeDef.$elements
|
|
280
285
|
.filter(
|
|
281
|
-
(e) =>
|
|
282
|
-
typeof e === "string" ||
|
|
283
|
-
(typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
|
|
286
|
+
(e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
|
|
284
287
|
)
|
|
285
|
-
.map((e) => (typeof e === "string" ? e :
|
|
288
|
+
.map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
|
|
286
289
|
}
|
|
287
290
|
: undefined;
|
|
288
291
|
|
|
289
292
|
// Resolve source path and discover files
|
|
290
293
|
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
291
294
|
/** @type {string[]} */
|
|
292
|
-
let files;
|
|
295
|
+
let files: string[];
|
|
293
296
|
|
|
294
|
-
if (
|
|
295
|
-
files = globSync(source, { cwd: projectRoot }).map((f) => resolve(projectRoot, f));
|
|
296
|
-
} else if (extname(source)) {
|
|
297
|
+
if (extname(source)) {
|
|
297
298
|
files = existsSync(resolvedSource) ? [resolvedSource] : [];
|
|
298
299
|
} else {
|
|
299
300
|
const format = contentTypeDef.format || "md";
|
|
@@ -308,8 +309,7 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
308
309
|
}
|
|
309
310
|
}
|
|
310
311
|
|
|
311
|
-
|
|
312
|
-
const entries = [];
|
|
312
|
+
const entries: ContentLoaderEntry[] = [];
|
|
313
313
|
|
|
314
314
|
for (const filePath of files) {
|
|
315
315
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -341,7 +341,11 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
341
341
|
* @param {ContentTypeSchema} schema - JSON Schema for the content type
|
|
342
342
|
* @param {string} contentTypeName - For error messages
|
|
343
343
|
*/
|
|
344
|
-
function validateEntries(
|
|
344
|
+
function validateEntries(
|
|
345
|
+
entries: ContentLoaderEntry[],
|
|
346
|
+
schema: ContentTypeSchema,
|
|
347
|
+
contentTypeName: string,
|
|
348
|
+
) {
|
|
345
349
|
const required = schema.required ?? [];
|
|
346
350
|
const properties = schema.properties ?? {};
|
|
347
351
|
|
|
@@ -395,7 +399,10 @@ export { queryContentType, findEntry } from "@jxsuite/parser/content";
|
|
|
395
399
|
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
|
|
396
400
|
* contentTypes: Record<string, ContentTypeDef> }} config - Content config
|
|
397
401
|
*/
|
|
398
|
-
export function resolveContentTypeRefs(
|
|
402
|
+
export function resolveContentTypeRefs(
|
|
403
|
+
contentTypes: Map<string, ContentLoaderEntry[]>,
|
|
404
|
+
config: { contentTypes: Record<string, ContentTypeDef> },
|
|
405
|
+
) {
|
|
399
406
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
400
407
|
const schema = contentTypeDef.schema;
|
|
401
408
|
if (!schema?.properties) continue;
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { resolve, dirname, relative } from "node:path";
|
|
16
|
+
import type {
|
|
17
|
+
JxElement,
|
|
18
|
+
JxDocument,
|
|
19
|
+
JxStateDefinition,
|
|
20
|
+
ProjectConfig,
|
|
21
|
+
} from "@jxsuite/schema/types";
|
|
22
|
+
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
23
|
+
import type { SiteRoute } from "../types.ts";
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* Inject $site and $page context into a page document's state.
|
|
@@ -25,11 +33,11 @@ import { resolve, dirname, relative } from "node:path";
|
|
|
25
33
|
* @returns {JxDocument} The mutated document
|
|
26
34
|
*/
|
|
27
35
|
export function injectContext(
|
|
28
|
-
doc,
|
|
29
|
-
projectConfig,
|
|
30
|
-
route,
|
|
31
|
-
contentTypes = new Map(),
|
|
32
|
-
projectRoot = null,
|
|
36
|
+
doc: JxDocument,
|
|
37
|
+
projectConfig: ProjectConfig,
|
|
38
|
+
route: SiteRoute,
|
|
39
|
+
contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
|
|
40
|
+
projectRoot: string | null = null,
|
|
33
41
|
) {
|
|
34
42
|
if (!doc.state) doc.state = {};
|
|
35
43
|
|
|
@@ -51,7 +59,7 @@ export function injectContext(
|
|
|
51
59
|
if (projectConfig.state) {
|
|
52
60
|
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
53
61
|
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
54
|
-
doc.state[key] =
|
|
62
|
+
doc.state[key] = value as JxStateDefinition;
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
}
|
|
@@ -66,7 +74,7 @@ export function injectContext(
|
|
|
66
74
|
if (!doc.imports) doc.imports = {};
|
|
67
75
|
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
68
76
|
if (!(name in doc.imports)) {
|
|
69
|
-
const src =
|
|
77
|
+
const src = srcPath as string;
|
|
70
78
|
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
71
79
|
if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
|
|
72
80
|
const abs = resolve(projectRoot, src);
|
|
@@ -83,13 +91,11 @@ export function injectContext(
|
|
|
83
91
|
if (!doc.$elements?.length) {
|
|
84
92
|
doc.$elements = [...projectConfig.$elements];
|
|
85
93
|
} else {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
/** @type {(string | JxElement)[]} */
|
|
89
|
-
const merged = [];
|
|
94
|
+
const seen: Set<string> = new Set();
|
|
95
|
+
const merged: (string | JxElement)[] = [];
|
|
90
96
|
for (const entry of [
|
|
91
97
|
...projectConfig.$elements,
|
|
92
|
-
|
|
98
|
+
...(doc.$elements as (string | JxElement)[]),
|
|
93
99
|
]) {
|
|
94
100
|
const key =
|
|
95
101
|
typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ (entry)?.$ref;
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* - Charset and viewport are auto-injected if missing
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import type { JxHeadEntry } from "@jxsuite/schema/types";
|
|
18
|
+
import type { HeadMergeContext } from "../types.ts";
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* Merge $head arrays from site, layout, and page levels.
|
|
19
22
|
*
|
|
@@ -23,10 +26,14 @@
|
|
|
23
26
|
* @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
|
|
24
27
|
* @returns {JxHeadEntry[]} Merged, deduplicated $head array
|
|
25
28
|
*/
|
|
26
|
-
export function mergeHead(
|
|
29
|
+
export function mergeHead(
|
|
30
|
+
siteHead: JxHeadEntry[] = [],
|
|
31
|
+
layoutHead: JxHeadEntry[] = [],
|
|
32
|
+
pageHead: JxHeadEntry[] = [],
|
|
33
|
+
context: HeadMergeContext = {},
|
|
34
|
+
) {
|
|
27
35
|
// Start with auto-injected defaults
|
|
28
|
-
|
|
29
|
-
const defaults = [
|
|
36
|
+
const defaults: JxHeadEntry[] = [
|
|
30
37
|
{ tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
|
|
31
38
|
{
|
|
32
39
|
tagName: "meta",
|
|
@@ -35,8 +42,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
|
|
|
35
42
|
];
|
|
36
43
|
|
|
37
44
|
// Merge layers: site → layout → page (later wins)
|
|
38
|
-
|
|
39
|
-
const merged = new Map();
|
|
45
|
+
const merged: Map<string, JxHeadEntry> = new Map();
|
|
40
46
|
|
|
41
47
|
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
42
48
|
const key = headEntryKey(entry);
|
|
@@ -66,7 +72,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
|
|
|
66
72
|
* @param {JxHeadEntry} entry
|
|
67
73
|
* @returns {string}
|
|
68
74
|
*/
|
|
69
|
-
function headEntryKey(entry) {
|
|
75
|
+
function headEntryKey(entry: JxHeadEntry) {
|
|
70
76
|
if (!entry || typeof entry !== "object") return String(entry);
|
|
71
77
|
|
|
72
78
|
const tag = entry.tagName ?? "unknown";
|
|
@@ -110,7 +116,7 @@ function headEntryKey(entry) {
|
|
|
110
116
|
* @param {string} str
|
|
111
117
|
* @returns {string}
|
|
112
118
|
*/
|
|
113
|
-
function simpleHash(str) {
|
|
119
|
+
function simpleHash(str: string) {
|
|
114
120
|
let hash = 0;
|
|
115
121
|
for (let i = 0; i < str.length; i++) {
|
|
116
122
|
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
@@ -124,8 +130,8 @@ function simpleHash(str) {
|
|
|
124
130
|
* @param {JxHeadEntry[]} headEntries - Merged head entries
|
|
125
131
|
* @returns {string} HTML string
|
|
126
132
|
*/
|
|
127
|
-
export function renderHead(headEntries) {
|
|
128
|
-
return headEntries.map((
|
|
133
|
+
export function renderHead(headEntries: JxHeadEntry[]) {
|
|
134
|
+
return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
/**
|
|
@@ -134,7 +140,7 @@ export function renderHead(headEntries) {
|
|
|
134
140
|
* @param {JxHeadEntry} entry
|
|
135
141
|
* @returns {string}
|
|
136
142
|
*/
|
|
137
|
-
function renderHeadEntry(entry) {
|
|
143
|
+
function renderHeadEntry(entry: JxHeadEntry) {
|
|
138
144
|
if (typeof entry === "string") return entry;
|
|
139
145
|
if (!entry || typeof entry !== "object") return "";
|
|
140
146
|
|
|
@@ -161,6 +167,6 @@ function renderHeadEntry(entry) {
|
|
|
161
167
|
* @param {unknown} val
|
|
162
168
|
* @returns {string}
|
|
163
169
|
*/
|
|
164
|
-
function escapeAttr(val) {
|
|
170
|
+
function escapeAttr(val: unknown) {
|
|
165
171
|
return String(val).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
166
172
|
}
|
|
@@ -7,26 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
|
-
import { contentHash, configHash } from "./image-optimizer.
|
|
10
|
+
import { contentHash, configHash } from "./image-optimizer.ts";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
* @typedef {import("./image-optimizer.js").ImageConfig} ImageConfig
|
|
16
|
-
*/
|
|
12
|
+
import type { ImageManifest } from "./image-optimizer.ts";
|
|
13
|
+
import type { ImageConfig } from "./image-optimizer.ts";
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
15
|
+
interface CacheEntry {
|
|
16
|
+
source: string; // Relative path to source image
|
|
17
|
+
manifest: ImageManifest; // Processed image manifest
|
|
18
|
+
timestamp: number; // When the entry was cached
|
|
19
|
+
}
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*/
|
|
21
|
+
export interface CacheManifest {
|
|
22
|
+
version: number; // Cache format version
|
|
23
|
+
entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
|
|
24
|
+
}
|
|
30
25
|
|
|
31
26
|
/**
|
|
32
27
|
* Build a cache key from source file content and config.
|
|
@@ -35,7 +30,7 @@ import { contentHash, configHash } from "./image-optimizer.js";
|
|
|
35
30
|
* @param {ImageConfig} config
|
|
36
31
|
* @returns {string}
|
|
37
32
|
*/
|
|
38
|
-
export function cacheKey(srcPath, config) {
|
|
33
|
+
export function cacheKey(srcPath: string, config: ImageConfig) {
|
|
39
34
|
return `${contentHash(srcPath)}:${configHash(config)}`;
|
|
40
35
|
}
|
|
41
36
|
|
|
@@ -45,7 +40,7 @@ export function cacheKey(srcPath, config) {
|
|
|
45
40
|
* @param {string} projectRoot
|
|
46
41
|
* @returns {CacheManifest}
|
|
47
42
|
*/
|
|
48
|
-
export function loadCache(projectRoot) {
|
|
43
|
+
export function loadCache(projectRoot: string) {
|
|
49
44
|
const manifestPath = resolve(projectRoot, ".jx-cache/images/manifest.json");
|
|
50
45
|
if (!existsSync(manifestPath)) {
|
|
51
46
|
return { version: 1, entries: {} };
|
|
@@ -63,7 +58,7 @@ export function loadCache(projectRoot) {
|
|
|
63
58
|
* @param {string} projectRoot
|
|
64
59
|
* @param {CacheManifest} cache
|
|
65
60
|
*/
|
|
66
|
-
export function saveCache(projectRoot, cache) {
|
|
61
|
+
export function saveCache(projectRoot: string, cache: CacheManifest) {
|
|
67
62
|
const cacheDir = resolve(projectRoot, ".jx-cache/images");
|
|
68
63
|
mkdirSync(cacheDir, { recursive: true });
|
|
69
64
|
writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
|
|
@@ -76,7 +71,7 @@ export function saveCache(projectRoot, cache) {
|
|
|
76
71
|
* @param {string} key
|
|
77
72
|
* @returns {ImageManifest | null}
|
|
78
73
|
*/
|
|
79
|
-
export function getCached(cache, key) {
|
|
74
|
+
export function getCached(cache: CacheManifest, key: string) {
|
|
80
75
|
const entry = cache.entries[key];
|
|
81
76
|
if (!entry) return null;
|
|
82
77
|
return entry.manifest;
|
|
@@ -90,7 +85,12 @@ export function getCached(cache, key) {
|
|
|
90
85
|
* @param {string} sourcePath - Relative source path for reference
|
|
91
86
|
* @param {ImageManifest} manifest
|
|
92
87
|
*/
|
|
93
|
-
export function setCached(
|
|
88
|
+
export function setCached(
|
|
89
|
+
cache: CacheManifest,
|
|
90
|
+
key: string,
|
|
91
|
+
sourcePath: string,
|
|
92
|
+
manifest: ImageManifest,
|
|
93
|
+
) {
|
|
94
94
|
cache.entries[key] = {
|
|
95
95
|
source: sourcePath,
|
|
96
96
|
manifest,
|
|
@@ -9,8 +9,29 @@ import { createHash } from "node:crypto";
|
|
|
9
9
|
import { readFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
10
|
import { resolve, basename, extname } from "node:path";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
export interface ImageVariant {
|
|
13
|
+
width: number; // Pixel width of the variant
|
|
14
|
+
format: string; // "webp", "avif", "jpeg", "png"
|
|
15
|
+
outputPath: string; // Relative path from outDir (e.g.
|
|
16
|
+
absolutePath: string; // Absolute filesystem path to the generated file
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ImageManifest {
|
|
20
|
+
variants: ImageVariant[]; // Array of generated responsive variants
|
|
21
|
+
contentHash: string; // 8-char content hash for cache busting
|
|
22
|
+
original?: { width: number; height: number }; // Original image dimensions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ImageConfig {
|
|
26
|
+
optimize: boolean;
|
|
27
|
+
widths: number[];
|
|
28
|
+
formats: string[];
|
|
29
|
+
quality: { webp?: number; avif?: number; jpeg?: number; png?: number };
|
|
30
|
+
sizes: string;
|
|
31
|
+
lazyLoad: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let _sharp: typeof import("sharp") | null = null;
|
|
14
35
|
|
|
15
36
|
async function getSharp() {
|
|
16
37
|
if (_sharp) return _sharp;
|
|
@@ -20,39 +41,11 @@ async function getSharp() {
|
|
|
20
41
|
return _sharp;
|
|
21
42
|
} catch (e) {
|
|
22
43
|
throw new Error(
|
|
23
|
-
`Sharp is required for image optimization but failed to load: ${
|
|
44
|
+
`Sharp is required for image optimization but failed to load: ${(e as Error).message}`,
|
|
24
45
|
);
|
|
25
46
|
}
|
|
26
47
|
}
|
|
27
48
|
|
|
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
49
|
const OPTIMIZED_DIR = "images/_optimized";
|
|
57
50
|
|
|
58
51
|
/**
|
|
@@ -61,13 +54,13 @@ const OPTIMIZED_DIR = "images/_optimized";
|
|
|
61
54
|
* @param {string} srcPath - Absolute path to source image
|
|
62
55
|
* @returns {Promise<{ width: number; height: number; format: string }>}
|
|
63
56
|
*/
|
|
64
|
-
export async function getImageMetadata(srcPath) {
|
|
57
|
+
export async function getImageMetadata(srcPath: string) {
|
|
65
58
|
const sharp = await getSharp();
|
|
66
59
|
const meta = await sharp(srcPath).metadata();
|
|
67
60
|
return {
|
|
68
61
|
width: meta.width ?? 0,
|
|
69
62
|
height: meta.height ?? 0,
|
|
70
|
-
format: meta.format ?? "unknown",
|
|
63
|
+
format: (meta.format ?? "unknown") as string,
|
|
71
64
|
};
|
|
72
65
|
}
|
|
73
66
|
|
|
@@ -77,7 +70,7 @@ export async function getImageMetadata(srcPath) {
|
|
|
77
70
|
* @param {string} srcPath - Absolute path to source image
|
|
78
71
|
* @returns {string} 8-character hex hash
|
|
79
72
|
*/
|
|
80
|
-
export function contentHash(srcPath) {
|
|
73
|
+
export function contentHash(srcPath: string) {
|
|
81
74
|
const buf = readFileSync(srcPath);
|
|
82
75
|
return createHash("md5").update(buf).digest("hex").slice(0, 8);
|
|
83
76
|
}
|
|
@@ -88,7 +81,7 @@ export function contentHash(srcPath) {
|
|
|
88
81
|
* @param {ImageConfig} config
|
|
89
82
|
* @returns {string}
|
|
90
83
|
*/
|
|
91
|
-
export function configHash(config) {
|
|
84
|
+
export function configHash(config: ImageConfig) {
|
|
92
85
|
const key = JSON.stringify({
|
|
93
86
|
widths: config.widths,
|
|
94
87
|
formats: config.formats,
|
|
@@ -106,7 +99,7 @@ export function configHash(config) {
|
|
|
106
99
|
* @param {string} format - "webp", "avif", "jpeg", "png"
|
|
107
100
|
* @returns {string}
|
|
108
101
|
*/
|
|
109
|
-
export function variantFilename(stem, width, hash8, format) {
|
|
102
|
+
export function variantFilename(stem: string, width: number, hash8: string, format: string) {
|
|
110
103
|
return `${stem}-${width}-${hash8}.${format}`;
|
|
111
104
|
}
|
|
112
105
|
|
|
@@ -118,7 +111,7 @@ export function variantFilename(stem, width, hash8, format) {
|
|
|
118
111
|
* @param {ImageConfig} config
|
|
119
112
|
* @returns {Promise<ImageManifest>}
|
|
120
113
|
*/
|
|
121
|
-
export async function processImage(srcPath, outDir, config) {
|
|
114
|
+
export async function processImage(srcPath: string, outDir: string, config: ImageConfig) {
|
|
122
115
|
const sharp = await getSharp();
|
|
123
116
|
const meta = await getImageMetadata(srcPath);
|
|
124
117
|
const hash8 = contentHash(srcPath);
|
|
@@ -127,8 +120,7 @@ export async function processImage(srcPath, outDir, config) {
|
|
|
127
120
|
const optimizedDir = resolve(outDir, OPTIMIZED_DIR);
|
|
128
121
|
mkdirSync(optimizedDir, { recursive: true });
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
const variants = [];
|
|
123
|
+
const variants: ImageVariant[] = [];
|
|
132
124
|
|
|
133
125
|
const widths = config.widths.filter((w) => w <= meta.width);
|
|
134
126
|
if (widths.length === 0 || !widths.includes(meta.width)) {
|
|
@@ -136,8 +128,7 @@ export async function processImage(srcPath, outDir, config) {
|
|
|
136
128
|
}
|
|
137
129
|
widths.sort((a, b) => a - b);
|
|
138
130
|
|
|
139
|
-
|
|
140
|
-
const tasks = [];
|
|
131
|
+
const tasks: Promise<void>[] = [];
|
|
141
132
|
|
|
142
133
|
for (const width of widths) {
|
|
143
134
|
for (const format of config.formats) {
|
|
@@ -149,10 +140,10 @@ export async function processImage(srcPath, outDir, config) {
|
|
|
149
140
|
|
|
150
141
|
if (existsSync(absolutePath)) continue;
|
|
151
142
|
|
|
152
|
-
const quality = config.quality[
|
|
143
|
+
const quality = config.quality[format as keyof ImageConfig["quality"]] ?? 80;
|
|
153
144
|
const task = sharp(srcPath)
|
|
154
145
|
.resize(width)
|
|
155
|
-
.toFormat(
|
|
146
|
+
.toFormat(format as keyof import("sharp").FormatEnum, { quality })
|
|
156
147
|
.toFile(absolutePath)
|
|
157
148
|
.then(() => {});
|
|
158
149
|
|
|
@@ -179,7 +170,7 @@ export async function processImage(srcPath, outDir, config) {
|
|
|
179
170
|
* @param {string} format
|
|
180
171
|
* @returns {string}
|
|
181
172
|
*/
|
|
182
|
-
export function buildSrcset(variants, format) {
|
|
173
|
+
export function buildSrcset(variants: ImageVariant[], format: string) {
|
|
183
174
|
return variants
|
|
184
175
|
.filter((v) => v.format === format)
|
|
185
176
|
.map((v) => `${v.outputPath} ${v.width}w`)
|