@jxsuite/compiler 0.19.0 → 0.21.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 +4 -4
- package/package.json +6 -4
- package/src/cli.js +1 -1
- package/src/compiler.js +2 -2
- package/src/shared.js +183 -145
- package/src/site/content-loader.js +106 -76
- package/src/site/context-injection.js +30 -24
- package/src/site/head-merger.js +13 -12
- package/src/site/image-optimizer.js +2 -2
- package/src/site/image-transform.js +5 -4
- package/src/site/layout-resolver.js +32 -25
- package/src/site/pages-discovery.js +6 -6
- package/src/site/prototype-resolver.js +1 -1
- package/src/site/site-build.js +71 -51
- package/src/site/site-loader.js +1 -1
- package/src/targets/compile-class.js +27 -21
- package/src/targets/compile-client.js +75 -33
- package/src/targets/compile-element.js +57 -39
- package/src/targets/compile-markdown.js +99 -78
- package/src/targets/compile-server.js +1 -1
- package/src/targets/compile-static.js +24 -13
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* @module content-loader
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
12
|
+
import { readFileSync, readdirSync, existsSync, globSync } from "node:fs";
|
|
13
13
|
import { resolve, basename, extname } from "node:path";
|
|
14
|
-
import { globSync } from "glob";
|
|
15
14
|
|
|
16
15
|
// ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
|
|
17
16
|
|
|
@@ -20,10 +19,10 @@ import { globSync } from "glob";
|
|
|
20
19
|
* with commas and newlines.
|
|
21
20
|
*
|
|
22
21
|
* @param {string} csv - Raw CSV text
|
|
23
|
-
* @returns {Record<string,
|
|
22
|
+
* @returns {Record<string, string>[]} Array of row objects
|
|
24
23
|
*/
|
|
25
24
|
function parseCSV(csv) {
|
|
26
|
-
/** @type {Record<string,
|
|
25
|
+
/** @type {Record<string, string>[]} */
|
|
27
26
|
const rows = [];
|
|
28
27
|
let current = "";
|
|
29
28
|
let inQuotes = false;
|
|
@@ -82,7 +81,7 @@ function parseCSV(csv) {
|
|
|
82
81
|
const headers = parseRow(lines[0]);
|
|
83
82
|
for (let i = 1; i < lines.length; i++) {
|
|
84
83
|
const fields = parseRow(lines[i]);
|
|
85
|
-
/** @type {Record<string,
|
|
84
|
+
/** @type {Record<string, string>} */
|
|
86
85
|
const obj = {};
|
|
87
86
|
for (let j = 0; j < headers.length; j++) {
|
|
88
87
|
obj[headers[j].trim()] = fields[j]?.trim() ?? "";
|
|
@@ -94,35 +93,35 @@ function parseCSV(csv) {
|
|
|
94
93
|
|
|
95
94
|
// ─── Markdown loader ──────────────────────────────────────────────────────────
|
|
96
95
|
|
|
97
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* @type {{
|
|
98
|
+
* MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
99
|
+
* } | null}
|
|
100
|
+
*/
|
|
98
101
|
let _mdModule = null;
|
|
99
102
|
|
|
100
103
|
/**
|
|
101
104
|
* Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
|
|
102
105
|
* MD content types exist.
|
|
103
106
|
*
|
|
104
|
-
* @returns {Promise<
|
|
107
|
+
* @returns {Promise<{
|
|
108
|
+
* MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
109
|
+
* }>}
|
|
105
110
|
*/
|
|
106
111
|
async function getMarkdownModule() {
|
|
107
112
|
if (!_mdModule) {
|
|
108
|
-
_mdModule = await import("@jxsuite/parser");
|
|
113
|
+
_mdModule = /** @type {any} */ (await import("@jxsuite/parser"));
|
|
109
114
|
}
|
|
110
|
-
return _mdModule;
|
|
115
|
+
return /** @type {NonNullable<typeof _mdModule>} */ (_mdModule);
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
/**
|
|
114
|
-
* Load a single markdown file into a ContentEntry.
|
|
115
|
-
*
|
|
116
|
-
* @param {string} filePath - Absolute path to .md file
|
|
117
|
-
* @returns {Promise<object>} ContentEntry shape
|
|
118
|
-
*/
|
|
119
118
|
/**
|
|
120
119
|
* Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
|
|
121
120
|
* custom element directives are available in the markdown.
|
|
122
121
|
*
|
|
123
122
|
* @param {string} filePath - Absolute path to .md file
|
|
124
|
-
* @param {
|
|
125
|
-
* @returns {Promise<
|
|
123
|
+
* @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
|
|
124
|
+
* @returns {Promise<ContentLoaderEntry>} ContentEntry shape
|
|
126
125
|
*/
|
|
127
126
|
async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
128
127
|
const { MarkdownFile } = await getMarkdownModule();
|
|
@@ -147,22 +146,24 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
|
147
146
|
* an object with an `id` field, it's a single entry.
|
|
148
147
|
*
|
|
149
148
|
* @param {string} filePath - Absolute path to .json file
|
|
150
|
-
* @returns {
|
|
149
|
+
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
151
150
|
*/
|
|
152
151
|
function loadJSONEntries(filePath) {
|
|
153
152
|
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
154
153
|
if (Array.isArray(raw)) {
|
|
155
|
-
return raw.map((/** @type {
|
|
156
|
-
id: item.id ?? basename(filePath, ".json") + "-" + i,
|
|
154
|
+
return raw.map((/** @type {Record<string, unknown>} */ item, /** @type {number} */ i) => ({
|
|
155
|
+
id: /** @type {string} */ (item.id) ?? basename(filePath, ".json") + "-" + i,
|
|
157
156
|
data: item,
|
|
158
157
|
body: null,
|
|
159
158
|
}));
|
|
160
159
|
}
|
|
161
160
|
// Single object file — filename is the id
|
|
161
|
+
/** @type {Record<string, unknown>} */
|
|
162
|
+
const rawObj = raw;
|
|
162
163
|
return [
|
|
163
164
|
{
|
|
164
|
-
id:
|
|
165
|
-
data:
|
|
165
|
+
id: /** @type {string} */ (rawObj.id) ?? basename(filePath, ".json"),
|
|
166
|
+
data: rawObj,
|
|
166
167
|
body: null,
|
|
167
168
|
},
|
|
168
169
|
];
|
|
@@ -172,26 +173,29 @@ function loadJSONEntries(filePath) {
|
|
|
172
173
|
* Load a CSV file into ContentEntry(s).
|
|
173
174
|
*
|
|
174
175
|
* @param {string} filePath - Absolute path to .csv file
|
|
175
|
-
* @param {
|
|
176
|
-
* @returns {
|
|
176
|
+
* @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
|
|
177
|
+
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
177
178
|
*/
|
|
178
179
|
function loadCSVEntries(filePath, schema) {
|
|
179
180
|
const csv = readFileSync(filePath, "utf-8");
|
|
180
181
|
const rows = parseCSV(csv);
|
|
181
|
-
return rows.map((/** @type {Record<string,
|
|
182
|
+
return rows.map((/** @type {Record<string, string>} */ row, /** @type {number} */ i) => {
|
|
182
183
|
// Apply type coercion based on schema if available
|
|
184
|
+
/** @type {Record<string, unknown>} */
|
|
185
|
+
const data = { ...row };
|
|
183
186
|
if (schema?.properties) {
|
|
184
187
|
for (const [key, def] of Object.entries(schema.properties)) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
else if (d.type === "boolean") row[key] = row[key] === "true";
|
|
188
|
+
if (key in data) {
|
|
189
|
+
if (def.type === "number") data[key] = Number(data[key]);
|
|
190
|
+
else if (def.type === "boolean") data[key] = data[key] === "true";
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
// Use `id` column, `sku` column, or row index as the entry ID
|
|
193
|
-
const id =
|
|
194
|
-
|
|
195
|
+
const id = /** @type {string | undefined} */ (data.id) ??
|
|
196
|
+
/** @type {string | undefined} */ (data.sku) ??
|
|
197
|
+
String(i);
|
|
198
|
+
return { id, data, body: null };
|
|
195
199
|
});
|
|
196
200
|
}
|
|
197
201
|
|
|
@@ -201,14 +205,16 @@ function loadCSVEntries(filePath, schema) {
|
|
|
201
205
|
* Load and parse content types config from project.json.
|
|
202
206
|
*
|
|
203
207
|
* @param {string} projectRoot - Project root directory
|
|
204
|
-
* @param {
|
|
205
|
-
*
|
|
206
|
-
*
|
|
208
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
|
|
209
|
+
* @returns {{
|
|
210
|
+
* config: { contentTypes: Record<string, ContentTypeDef> };
|
|
211
|
+
* contentDir: string;
|
|
212
|
+
* } | null}
|
|
213
|
+
* Parsed config or null if no content dir
|
|
207
214
|
*/
|
|
208
215
|
export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
209
216
|
const contentDir = resolve(projectRoot, "content");
|
|
210
217
|
|
|
211
|
-
/** @type {any} */
|
|
212
218
|
const config = { contentTypes: projectConfig?.contentTypes ?? {} };
|
|
213
219
|
|
|
214
220
|
return { config, contentDir };
|
|
@@ -220,19 +226,20 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
|
220
226
|
* Load all content types defined in project.json.
|
|
221
227
|
*
|
|
222
228
|
* @param {string} projectRoot - Project root directory
|
|
223
|
-
* @param {
|
|
224
|
-
* @returns {Promise<Map<string,
|
|
229
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
230
|
+
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
|
|
231
|
+
* ContentEntry
|
|
225
232
|
*/
|
|
226
233
|
export async function loadContentTypes(projectRoot, projectConfig = undefined) {
|
|
227
234
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
228
235
|
if (!result) return new Map();
|
|
229
236
|
|
|
230
237
|
const { config } = result;
|
|
231
|
-
/** @type {Map<string,
|
|
238
|
+
/** @type {Map<string, ContentLoaderEntry[]>} */
|
|
232
239
|
const contentTypes = new Map();
|
|
233
240
|
|
|
234
241
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
235
|
-
const entries = await loadContentType(name,
|
|
242
|
+
const entries = await loadContentType(name, contentTypeDef, projectRoot);
|
|
236
243
|
contentTypes.set(name, entries);
|
|
237
244
|
}
|
|
238
245
|
|
|
@@ -244,8 +251,8 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
|
|
|
244
251
|
*
|
|
245
252
|
* @param {string} projectRoot - Project root directory
|
|
246
253
|
* @param {string} contentTypeName - Name of the content type
|
|
247
|
-
* @param {
|
|
248
|
-
* @returns {
|
|
254
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
255
|
+
* @returns {(string | JxElement)[] | undefined}
|
|
249
256
|
*/
|
|
250
257
|
export function getContentTypeElements(projectRoot, contentTypeName, projectConfig = undefined) {
|
|
251
258
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
@@ -258,29 +265,50 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
|
|
|
258
265
|
* Load a single content type by its definition.
|
|
259
266
|
*
|
|
260
267
|
* @param {string} name - Content type name
|
|
261
|
-
* @param {
|
|
268
|
+
* @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
|
|
262
269
|
* @param {string} projectRoot - Absolute path to project root directory
|
|
263
|
-
* @returns {Promise<
|
|
270
|
+
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
264
271
|
*/
|
|
265
272
|
async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
266
273
|
const source = contentTypeDef.source;
|
|
267
274
|
const schema = contentTypeDef.schema;
|
|
268
275
|
|
|
269
276
|
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
270
|
-
/** @type {any} */
|
|
271
277
|
const directiveOptions = contentTypeDef.$elements?.length
|
|
272
278
|
? {
|
|
273
279
|
allowedNames: contentTypeDef.$elements
|
|
274
|
-
.filter(
|
|
275
|
-
|
|
280
|
+
.filter(
|
|
281
|
+
(e) =>
|
|
282
|
+
typeof e === "string" ||
|
|
283
|
+
(typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
|
|
284
|
+
)
|
|
285
|
+
.map((e) => (typeof e === "string" ? e : /** @type {JxMutableNode} */ (e).$ref)),
|
|
276
286
|
}
|
|
277
287
|
: undefined;
|
|
278
288
|
|
|
279
|
-
// Resolve
|
|
280
|
-
const
|
|
281
|
-
|
|
289
|
+
// Resolve source path and discover files
|
|
290
|
+
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
291
|
+
/** @type {string[]} */
|
|
292
|
+
let files;
|
|
293
|
+
|
|
294
|
+
if (source.includes("*") || source.includes("?")) {
|
|
295
|
+
files = globSync(source, { cwd: projectRoot }).map((f) => resolve(projectRoot, f));
|
|
296
|
+
} else if (extname(source)) {
|
|
297
|
+
files = existsSync(resolvedSource) ? [resolvedSource] : [];
|
|
298
|
+
} else {
|
|
299
|
+
const format = contentTypeDef.format || "md";
|
|
300
|
+
const ext = `.${format}`;
|
|
301
|
+
const dir = resolvedSource.endsWith("/") ? resolvedSource : resolvedSource + "/";
|
|
302
|
+
try {
|
|
303
|
+
files = readdirSync(dir, { recursive: true })
|
|
304
|
+
.filter((f) => String(f).endsWith(ext))
|
|
305
|
+
.map((f) => resolve(dir, String(f)));
|
|
306
|
+
} catch {
|
|
307
|
+
files = [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
282
310
|
|
|
283
|
-
/** @type {
|
|
311
|
+
/** @type {ContentLoaderEntry[]} */
|
|
284
312
|
const entries = [];
|
|
285
313
|
|
|
286
314
|
for (const filePath of files) {
|
|
@@ -309,8 +337,8 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
309
337
|
* Validate content entries against their content type schema. Logs warnings for missing required
|
|
310
338
|
* fields and type mismatches.
|
|
311
339
|
*
|
|
312
|
-
* @param {
|
|
313
|
-
* @param {
|
|
340
|
+
* @param {ContentLoaderEntry[]} entries - Array of ContentEntry
|
|
341
|
+
* @param {ContentTypeSchema} schema - JSON Schema for the content type
|
|
314
342
|
* @param {string} contentTypeName - For error messages
|
|
315
343
|
*/
|
|
316
344
|
function validateEntries(entries, schema, contentTypeName) {
|
|
@@ -329,23 +357,22 @@ function validateEntries(entries, schema, contentTypeName) {
|
|
|
329
357
|
|
|
330
358
|
// Check types
|
|
331
359
|
for (const [field, def] of Object.entries(properties)) {
|
|
332
|
-
const d = /** @type {any} */ (def);
|
|
333
360
|
const value = entry.data[field];
|
|
334
361
|
if (value == null) continue;
|
|
335
362
|
|
|
336
|
-
if (
|
|
363
|
+
if (def.type === "string" && typeof value !== "string") {
|
|
337
364
|
console.warn(
|
|
338
365
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
|
|
339
366
|
);
|
|
340
|
-
} else if (
|
|
367
|
+
} else if (def.type === "number" && typeof value !== "number") {
|
|
341
368
|
console.warn(
|
|
342
369
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
|
|
343
370
|
);
|
|
344
|
-
} else if (
|
|
371
|
+
} else if (def.type === "boolean" && typeof value !== "boolean") {
|
|
345
372
|
console.warn(
|
|
346
373
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
|
|
347
374
|
);
|
|
348
|
-
} else if (
|
|
375
|
+
} else if (def.type === "array" && !Array.isArray(value)) {
|
|
349
376
|
console.warn(
|
|
350
377
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
|
|
351
378
|
);
|
|
@@ -360,18 +387,23 @@ function validateEntries(entries, schema, contentTypeName) {
|
|
|
360
387
|
* Query a loaded content type with filter, sort, and limit. Implements the ContentCollection
|
|
361
388
|
* $prototype resolution.
|
|
362
389
|
*
|
|
363
|
-
* @param {
|
|
364
|
-
* @param {
|
|
365
|
-
*
|
|
390
|
+
* @param {ContentLoaderEntry[]} entries - Full content type entries
|
|
391
|
+
* @param {{
|
|
392
|
+
* filter?: Record<string, unknown>;
|
|
393
|
+
* sort?: { field: string; order?: string };
|
|
394
|
+
* limit?: number;
|
|
395
|
+
* }} [query]
|
|
396
|
+
* - Query options
|
|
397
|
+
* @returns {ContentLoaderEntry[]} Filtered, sorted, limited entries
|
|
366
398
|
*/
|
|
367
399
|
export function queryContentType(entries, query = {}) {
|
|
368
400
|
let result = [...entries];
|
|
369
401
|
|
|
370
402
|
// Filter
|
|
371
403
|
if (query.filter && typeof query.filter === "object") {
|
|
372
|
-
result = result.filter((
|
|
404
|
+
result = result.filter((entry) => {
|
|
373
405
|
for (const [key, expected] of Object.entries(
|
|
374
|
-
/** @type {Record<string,
|
|
406
|
+
/** @type {Record<string, unknown>} */ (query.filter),
|
|
375
407
|
)) {
|
|
376
408
|
const actual = entry.data[key];
|
|
377
409
|
if (actual !== expected) return false;
|
|
@@ -383,9 +415,9 @@ export function queryContentType(entries, query = {}) {
|
|
|
383
415
|
// Sort
|
|
384
416
|
if (query.sort) {
|
|
385
417
|
const { field, order = "asc" } = query.sort;
|
|
386
|
-
result.sort((
|
|
387
|
-
const aVal = a.data[field] ?? "";
|
|
388
|
-
const bVal = b.data[field] ?? "";
|
|
418
|
+
result.sort((a, b) => {
|
|
419
|
+
const aVal = /** @type {string | number} */ (a.data[field] ?? "");
|
|
420
|
+
const bVal = /** @type {string | number} */ (b.data[field] ?? "");
|
|
389
421
|
if (aVal < bVal) return order === "asc" ? -1 : 1;
|
|
390
422
|
if (aVal > bVal) return order === "asc" ? 1 : -1;
|
|
391
423
|
return 0;
|
|
@@ -403,12 +435,12 @@ export function queryContentType(entries, query = {}) {
|
|
|
403
435
|
/**
|
|
404
436
|
* Find a single entry by ID in a content type. Implements the ContentEntry $prototype resolution.
|
|
405
437
|
*
|
|
406
|
-
* @param {
|
|
438
|
+
* @param {ContentLoaderEntry[]} entries - Full content type entries
|
|
407
439
|
* @param {string} id - Entry ID to find
|
|
408
|
-
* @returns {
|
|
440
|
+
* @returns {ContentLoaderEntry | null} The matching entry or null
|
|
409
441
|
*/
|
|
410
442
|
export function findEntry(entries, id) {
|
|
411
|
-
return entries.find((
|
|
443
|
+
return entries.find((e) => e.id === id) ?? null;
|
|
412
444
|
}
|
|
413
445
|
|
|
414
446
|
// ─── Content Type Reference Resolution ──────────────────────────────────────
|
|
@@ -418,29 +450,27 @@ export function findEntry(entries, id) {
|
|
|
418
450
|
* "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
|
|
419
451
|
* entry.
|
|
420
452
|
*
|
|
421
|
-
* @param {Map<string,
|
|
422
|
-
* Content
|
|
453
|
+
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
|
|
454
|
+
* contentTypes: Record<string, ContentTypeDef> }} config - Content config
|
|
423
455
|
*/
|
|
424
456
|
export function resolveContentTypeRefs(contentTypes, config) {
|
|
425
457
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
426
|
-
const
|
|
427
|
-
const schema = cd.schema;
|
|
458
|
+
const schema = contentTypeDef.schema;
|
|
428
459
|
if (!schema?.properties) continue;
|
|
429
460
|
|
|
430
461
|
const entries = contentTypes.get(name);
|
|
431
462
|
if (!entries) continue;
|
|
432
463
|
|
|
433
464
|
for (const [field, def] of Object.entries(schema.properties)) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const refContentType = d.$ref.replace("#/contentTypes/", "");
|
|
465
|
+
if (!def.$ref?.startsWith("#/contentTypes/")) continue;
|
|
466
|
+
const refContentType = def.$ref.replace("#/contentTypes/", "");
|
|
437
467
|
const refEntries = contentTypes.get(refContentType);
|
|
438
468
|
if (!refEntries) continue;
|
|
439
469
|
|
|
440
470
|
for (const entry of entries) {
|
|
441
471
|
const refId = entry.data[field];
|
|
442
472
|
if (typeof refId === "string") {
|
|
443
|
-
const resolved = refEntries.find((
|
|
473
|
+
const resolved = refEntries.find((e) => e.id === refId);
|
|
444
474
|
if (resolved) {
|
|
445
475
|
entry.data[field] = resolved;
|
|
446
476
|
}
|
|
@@ -18,12 +18,12 @@ import { resolve, dirname, relative } from "node:path";
|
|
|
18
18
|
/**
|
|
19
19
|
* Inject $site and $page context into a page document's state.
|
|
20
20
|
*
|
|
21
|
-
* @param {
|
|
22
|
-
* @param {
|
|
23
|
-
* @param {
|
|
24
|
-
* @param {Map<string,
|
|
21
|
+
* @param {JxDocument} doc - The page document (mutated)
|
|
22
|
+
* @param {ProjectConfig} projectConfig - Loaded project configuration
|
|
23
|
+
* @param {SiteRoute} route - The resolved route for this page
|
|
24
|
+
* @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types
|
|
25
25
|
* @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
|
|
26
|
-
* @returns {
|
|
26
|
+
* @returns {JxDocument} The mutated document
|
|
27
27
|
*/
|
|
28
28
|
export function injectContext(
|
|
29
29
|
doc,
|
|
@@ -57,7 +57,7 @@ export function injectContext(
|
|
|
57
57
|
if (projectConfig.state) {
|
|
58
58
|
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
59
59
|
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
60
|
-
doc.state[key] = value;
|
|
60
|
+
doc.state[key] = /** @type {JxStateDefinition} */ (value);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -91,10 +91,14 @@ export function injectContext(
|
|
|
91
91
|
} else {
|
|
92
92
|
/** @type {Set<string>} */
|
|
93
93
|
const seen = new Set();
|
|
94
|
-
/** @type {
|
|
94
|
+
/** @type {(string | JxElement)[]} */
|
|
95
95
|
const merged = [];
|
|
96
|
-
for (const entry of [
|
|
97
|
-
|
|
96
|
+
for (const entry of [
|
|
97
|
+
...projectConfig.$elements,
|
|
98
|
+
.../** @type {(string | JxElement)[]} */ (doc.$elements),
|
|
99
|
+
]) {
|
|
100
|
+
const key =
|
|
101
|
+
typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ (entry)?.$ref;
|
|
98
102
|
if (key && !seen.has(key)) {
|
|
99
103
|
seen.add(key);
|
|
100
104
|
merged.push(entry);
|
|
@@ -113,40 +117,42 @@ export function injectContext(
|
|
|
113
117
|
* Replaces state entries like: { "$prototype": "ContentCollection", "contentType": "blog", ... }
|
|
114
118
|
* with the actual resolved content type data.
|
|
115
119
|
*
|
|
116
|
-
* @param {Record<string,
|
|
117
|
-
* @param {Map<string,
|
|
118
|
-
* @param {Record<string,
|
|
120
|
+
* @param {Record<string, unknown>} state - Page state (mutated)
|
|
121
|
+
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - Loaded content types
|
|
122
|
+
* @param {Record<string, string>} params - Route parameters for $ref resolution
|
|
119
123
|
*/
|
|
120
124
|
function resolveContentPrototypes(state, contentTypes, params) {
|
|
121
125
|
for (const [key, value] of Object.entries(state)) {
|
|
122
|
-
if (!value || typeof value !== "object" || !value.$prototype)
|
|
126
|
+
if (!value || typeof value !== "object" || !(/** @type {JxMutableNode} */ (value).$prototype))
|
|
127
|
+
continue;
|
|
128
|
+
const v = /** @type {JxPrototypeDef} */ (value);
|
|
123
129
|
|
|
124
|
-
if (
|
|
125
|
-
const entries = contentTypes.get(
|
|
130
|
+
if (v.$prototype === "ContentCollection") {
|
|
131
|
+
const entries = contentTypes.get(/** @type {string} */ (v.contentType));
|
|
126
132
|
if (!entries) {
|
|
127
|
-
console.warn(`ContentCollection: content type "${
|
|
133
|
+
console.warn(`ContentCollection: content type "${v.contentType}" not found`);
|
|
128
134
|
state[key] = [];
|
|
129
135
|
continue;
|
|
130
136
|
}
|
|
131
137
|
state[key] = queryContentType(entries, {
|
|
132
|
-
filter:
|
|
133
|
-
sort:
|
|
134
|
-
limit:
|
|
138
|
+
filter: v.filter,
|
|
139
|
+
sort: v.sort,
|
|
140
|
+
limit: v.limit,
|
|
135
141
|
});
|
|
136
|
-
} else if (
|
|
137
|
-
const entries = contentTypes.get(
|
|
142
|
+
} else if (v.$prototype === "ContentEntry") {
|
|
143
|
+
const entries = contentTypes.get(/** @type {string} */ (v.contentType));
|
|
138
144
|
if (!entries) {
|
|
139
|
-
console.warn(`ContentEntry: content type "${
|
|
145
|
+
console.warn(`ContentEntry: content type "${v.contentType}" not found`);
|
|
140
146
|
state[key] = null;
|
|
141
147
|
continue;
|
|
142
148
|
}
|
|
143
149
|
// Resolve the ID — may reference $params
|
|
144
|
-
let id =
|
|
150
|
+
let id = v.id;
|
|
145
151
|
if (id && typeof id === "object" && id.$ref?.startsWith("#/$params/")) {
|
|
146
152
|
const paramName = id.$ref.replace("#/$params/", "");
|
|
147
153
|
id = params[paramName];
|
|
148
154
|
}
|
|
149
|
-
state[key] = findEntry(entries, id);
|
|
155
|
+
state[key] = findEntry(entries, /** @type {string} */ (id));
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
}
|
package/src/site/head-merger.js
CHANGED
|
@@ -17,14 +17,15 @@
|
|
|
17
17
|
/**
|
|
18
18
|
* Merge $head arrays from site, layout, and page levels.
|
|
19
19
|
*
|
|
20
|
-
* @param {
|
|
21
|
-
* @param {
|
|
22
|
-
* @param {
|
|
23
|
-
* @param {
|
|
24
|
-
* @returns {
|
|
20
|
+
* @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
|
|
21
|
+
* @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
|
|
22
|
+
* @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
|
|
23
|
+
* @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
|
|
24
|
+
* @returns {JxHeadEntry[]} Merged, deduplicated $head array
|
|
25
25
|
*/
|
|
26
26
|
export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context = {}) {
|
|
27
27
|
// Start with auto-injected defaults
|
|
28
|
+
/** @type {JxHeadEntry[]} */
|
|
28
29
|
const defaults = [
|
|
29
30
|
{ tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
|
|
30
31
|
{
|
|
@@ -34,7 +35,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
|
|
|
34
35
|
];
|
|
35
36
|
|
|
36
37
|
// Merge layers: site → layout → page (later wins)
|
|
37
|
-
/** @type {Map<string,
|
|
38
|
+
/** @type {Map<string, JxHeadEntry>} */
|
|
38
39
|
const merged = new Map();
|
|
39
40
|
|
|
40
41
|
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
@@ -62,7 +63,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
|
|
|
62
63
|
* Generate a deduplication key for a <head> element. Elements with the same key are considered
|
|
63
64
|
* duplicates; the last one wins.
|
|
64
65
|
*
|
|
65
|
-
* @param {
|
|
66
|
+
* @param {JxHeadEntry} entry
|
|
66
67
|
* @returns {string}
|
|
67
68
|
*/
|
|
68
69
|
function headEntryKey(entry) {
|
|
@@ -118,17 +119,17 @@ function simpleHash(str) {
|
|
|
118
119
|
/**
|
|
119
120
|
* Render a merged $head array to HTML string for insertion into <head>.
|
|
120
121
|
*
|
|
121
|
-
* @param {
|
|
122
|
+
* @param {JxHeadEntry[]} headEntries - Merged head entries
|
|
122
123
|
* @returns {string} HTML string
|
|
123
124
|
*/
|
|
124
125
|
export function renderHead(headEntries) {
|
|
125
|
-
return headEntries.map((/** @type {
|
|
126
|
+
return headEntries.map((/** @type {JxHeadEntry} */ e) => renderHeadEntry(e)).join("\n ");
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
/**
|
|
129
130
|
* Render a single $head entry to an HTML string.
|
|
130
131
|
*
|
|
131
|
-
* @param {
|
|
132
|
+
* @param {JxHeadEntry} entry
|
|
132
133
|
* @returns {string}
|
|
133
134
|
*/
|
|
134
135
|
function renderHeadEntry(entry) {
|
|
@@ -138,7 +139,7 @@ function renderHeadEntry(entry) {
|
|
|
138
139
|
const tag = entry.tagName;
|
|
139
140
|
const attrs = entry.attributes ?? {};
|
|
140
141
|
const attrStr = Object.entries(attrs)
|
|
141
|
-
.map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(
|
|
142
|
+
.map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
|
|
142
143
|
.join(" ");
|
|
143
144
|
|
|
144
145
|
const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
|
|
@@ -153,7 +154,7 @@ function renderHeadEntry(entry) {
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/**
|
|
156
|
-
* @param {
|
|
157
|
+
* @param {unknown} val
|
|
157
158
|
* @returns {string}
|
|
158
159
|
*/
|
|
159
160
|
function escapeAttr(val) {
|
|
@@ -20,7 +20,7 @@ async function getSharp() {
|
|
|
20
20
|
return _sharp;
|
|
21
21
|
} catch (e) {
|
|
22
22
|
throw new Error(
|
|
23
|
-
`Sharp is required for image optimization but failed to load: ${/** @type {
|
|
23
|
+
`Sharp is required for image optimization but failed to load: ${/** @type {Error} */ (e).message}`,
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -152,7 +152,7 @@ export async function processImage(srcPath, outDir, config) {
|
|
|
152
152
|
const quality = config.quality[/** @type {keyof ImageConfig["quality"]} */ (format)] ?? 80;
|
|
153
153
|
const task = sharp(srcPath)
|
|
154
154
|
.resize(width)
|
|
155
|
-
.toFormat(/** @type {
|
|
155
|
+
.toFormat(/** @type {keyof import("sharp").FormatEnum} */ (format), { quality })
|
|
156
156
|
.toFile(absolutePath)
|
|
157
157
|
.then(() => {});
|
|
158
158
|
|
|
@@ -48,7 +48,7 @@ async function resolveManifest(absoluteSrc, src, config, outDir, cache) {
|
|
|
48
48
|
/**
|
|
49
49
|
* Check if a src value should be skipped for optimization.
|
|
50
50
|
*
|
|
51
|
-
* @param {
|
|
51
|
+
* @param {string} src
|
|
52
52
|
* @returns {boolean}
|
|
53
53
|
*/
|
|
54
54
|
function shouldSkip(src) {
|
|
@@ -81,7 +81,7 @@ function resolveImagePath(src, projectRoot) {
|
|
|
81
81
|
* Mutates img nodes in place, injecting srcset, sizes, width, height, loading, and decoding.
|
|
82
82
|
* Returns a set of absolute source paths that need processing.
|
|
83
83
|
*
|
|
84
|
-
* @param {
|
|
84
|
+
* @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
|
|
85
85
|
* @param {ImageConfig} config
|
|
86
86
|
* @param {string} projectRoot
|
|
87
87
|
* @param {string} outDir
|
|
@@ -100,7 +100,7 @@ export async function transformImageNodes(doc, config, projectRoot, outDir, cach
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* @param {
|
|
103
|
+
* @param {JxMutableNode | JxDocument} node
|
|
104
104
|
* @param {ImageConfig} config
|
|
105
105
|
* @param {string} projectRoot
|
|
106
106
|
* @param {string} outDir
|
|
@@ -127,13 +127,14 @@ async function walkAndTransform(node, config, projectRoot, outDir, cache, imageR
|
|
|
127
127
|
|
|
128
128
|
if (Array.isArray(node.children)) {
|
|
129
129
|
for (const child of node.children) {
|
|
130
|
+
if (typeof child === "string") continue;
|
|
130
131
|
await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
/**
|
|
136
|
-
* @param {
|
|
137
|
+
* @param {JxMutableNode | JxDocument} node
|
|
137
138
|
* @param {ImageConfig} config
|
|
138
139
|
* @param {string} projectRoot
|
|
139
140
|
* @param {string} outDir
|