@jxsuite/compiler 0.18.0 → 0.20.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 +111 -111
- package/package.json +6 -4
- package/src/cli.js +1 -1
- package/src/compiler.js +2 -2
- package/src/shared.js +260 -149
- package/src/site/content-loader.js +84 -71
- 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
|
@@ -20,10 +20,10 @@ import { globSync } from "glob";
|
|
|
20
20
|
* with commas and newlines.
|
|
21
21
|
*
|
|
22
22
|
* @param {string} csv - Raw CSV text
|
|
23
|
-
* @returns {Record<string,
|
|
23
|
+
* @returns {Record<string, string>[]} Array of row objects
|
|
24
24
|
*/
|
|
25
25
|
function parseCSV(csv) {
|
|
26
|
-
/** @type {Record<string,
|
|
26
|
+
/** @type {Record<string, string>[]} */
|
|
27
27
|
const rows = [];
|
|
28
28
|
let current = "";
|
|
29
29
|
let inQuotes = false;
|
|
@@ -82,7 +82,7 @@ function parseCSV(csv) {
|
|
|
82
82
|
const headers = parseRow(lines[0]);
|
|
83
83
|
for (let i = 1; i < lines.length; i++) {
|
|
84
84
|
const fields = parseRow(lines[i]);
|
|
85
|
-
/** @type {Record<string,
|
|
85
|
+
/** @type {Record<string, string>} */
|
|
86
86
|
const obj = {};
|
|
87
87
|
for (let j = 0; j < headers.length; j++) {
|
|
88
88
|
obj[headers[j].trim()] = fields[j]?.trim() ?? "";
|
|
@@ -94,35 +94,35 @@ function parseCSV(csv) {
|
|
|
94
94
|
|
|
95
95
|
// ─── Markdown loader ──────────────────────────────────────────────────────────
|
|
96
96
|
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* @type {{
|
|
99
|
+
* MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
100
|
+
* } | null}
|
|
101
|
+
*/
|
|
98
102
|
let _mdModule = null;
|
|
99
103
|
|
|
100
104
|
/**
|
|
101
105
|
* Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
|
|
102
106
|
* MD content types exist.
|
|
103
107
|
*
|
|
104
|
-
* @returns {Promise<
|
|
108
|
+
* @returns {Promise<{
|
|
109
|
+
* MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
|
|
110
|
+
* }>}
|
|
105
111
|
*/
|
|
106
112
|
async function getMarkdownModule() {
|
|
107
113
|
if (!_mdModule) {
|
|
108
|
-
_mdModule = await import("@jxsuite/parser");
|
|
114
|
+
_mdModule = /** @type {any} */ (await import("@jxsuite/parser"));
|
|
109
115
|
}
|
|
110
|
-
return _mdModule;
|
|
116
|
+
return /** @type {NonNullable<typeof _mdModule>} */ (_mdModule);
|
|
111
117
|
}
|
|
112
118
|
|
|
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
119
|
/**
|
|
120
120
|
* Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
|
|
121
121
|
* custom element directives are available in the markdown.
|
|
122
122
|
*
|
|
123
123
|
* @param {string} filePath - Absolute path to .md file
|
|
124
|
-
* @param {
|
|
125
|
-
* @returns {Promise<
|
|
124
|
+
* @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
|
|
125
|
+
* @returns {Promise<ContentLoaderEntry>} ContentEntry shape
|
|
126
126
|
*/
|
|
127
127
|
async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
128
128
|
const { MarkdownFile } = await getMarkdownModule();
|
|
@@ -147,22 +147,24 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
|
147
147
|
* an object with an `id` field, it's a single entry.
|
|
148
148
|
*
|
|
149
149
|
* @param {string} filePath - Absolute path to .json file
|
|
150
|
-
* @returns {
|
|
150
|
+
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
151
151
|
*/
|
|
152
152
|
function loadJSONEntries(filePath) {
|
|
153
153
|
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
154
154
|
if (Array.isArray(raw)) {
|
|
155
|
-
return raw.map((/** @type {
|
|
156
|
-
id: item.id ?? basename(filePath, ".json") + "-" + i,
|
|
155
|
+
return raw.map((/** @type {Record<string, unknown>} */ item, /** @type {number} */ i) => ({
|
|
156
|
+
id: /** @type {string} */ (item.id) ?? basename(filePath, ".json") + "-" + i,
|
|
157
157
|
data: item,
|
|
158
158
|
body: null,
|
|
159
159
|
}));
|
|
160
160
|
}
|
|
161
161
|
// Single object file — filename is the id
|
|
162
|
+
/** @type {Record<string, unknown>} */
|
|
163
|
+
const rawObj = raw;
|
|
162
164
|
return [
|
|
163
165
|
{
|
|
164
|
-
id:
|
|
165
|
-
data:
|
|
166
|
+
id: /** @type {string} */ (rawObj.id) ?? basename(filePath, ".json"),
|
|
167
|
+
data: rawObj,
|
|
166
168
|
body: null,
|
|
167
169
|
},
|
|
168
170
|
];
|
|
@@ -172,26 +174,29 @@ function loadJSONEntries(filePath) {
|
|
|
172
174
|
* Load a CSV file into ContentEntry(s).
|
|
173
175
|
*
|
|
174
176
|
* @param {string} filePath - Absolute path to .csv file
|
|
175
|
-
* @param {
|
|
176
|
-
* @returns {
|
|
177
|
+
* @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
|
|
178
|
+
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
177
179
|
*/
|
|
178
180
|
function loadCSVEntries(filePath, schema) {
|
|
179
181
|
const csv = readFileSync(filePath, "utf-8");
|
|
180
182
|
const rows = parseCSV(csv);
|
|
181
|
-
return rows.map((/** @type {Record<string,
|
|
183
|
+
return rows.map((/** @type {Record<string, string>} */ row, /** @type {number} */ i) => {
|
|
182
184
|
// Apply type coercion based on schema if available
|
|
185
|
+
/** @type {Record<string, unknown>} */
|
|
186
|
+
const data = { ...row };
|
|
183
187
|
if (schema?.properties) {
|
|
184
188
|
for (const [key, def] of Object.entries(schema.properties)) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
else if (d.type === "boolean") row[key] = row[key] === "true";
|
|
189
|
+
if (key in data) {
|
|
190
|
+
if (def.type === "number") data[key] = Number(data[key]);
|
|
191
|
+
else if (def.type === "boolean") data[key] = data[key] === "true";
|
|
189
192
|
}
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
// Use `id` column, `sku` column, or row index as the entry ID
|
|
193
|
-
const id =
|
|
194
|
-
|
|
196
|
+
const id = /** @type {string | undefined} */ (data.id) ??
|
|
197
|
+
/** @type {string | undefined} */ (data.sku) ??
|
|
198
|
+
String(i);
|
|
199
|
+
return { id, data, body: null };
|
|
195
200
|
});
|
|
196
201
|
}
|
|
197
202
|
|
|
@@ -201,14 +206,16 @@ function loadCSVEntries(filePath, schema) {
|
|
|
201
206
|
* Load and parse content types config from project.json.
|
|
202
207
|
*
|
|
203
208
|
* @param {string} projectRoot - Project root directory
|
|
204
|
-
* @param {
|
|
205
|
-
*
|
|
206
|
-
*
|
|
209
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
|
|
210
|
+
* @returns {{
|
|
211
|
+
* config: { contentTypes: Record<string, ContentTypeDef> };
|
|
212
|
+
* contentDir: string;
|
|
213
|
+
* } | null}
|
|
214
|
+
* Parsed config or null if no content dir
|
|
207
215
|
*/
|
|
208
216
|
export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
209
217
|
const contentDir = resolve(projectRoot, "content");
|
|
210
218
|
|
|
211
|
-
/** @type {any} */
|
|
212
219
|
const config = { contentTypes: projectConfig?.contentTypes ?? {} };
|
|
213
220
|
|
|
214
221
|
return { config, contentDir };
|
|
@@ -220,19 +227,20 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
|
220
227
|
* Load all content types defined in project.json.
|
|
221
228
|
*
|
|
222
229
|
* @param {string} projectRoot - Project root directory
|
|
223
|
-
* @param {
|
|
224
|
-
* @returns {Promise<Map<string,
|
|
230
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
231
|
+
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
|
|
232
|
+
* ContentEntry
|
|
225
233
|
*/
|
|
226
234
|
export async function loadContentTypes(projectRoot, projectConfig = undefined) {
|
|
227
235
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
228
236
|
if (!result) return new Map();
|
|
229
237
|
|
|
230
238
|
const { config } = result;
|
|
231
|
-
/** @type {Map<string,
|
|
239
|
+
/** @type {Map<string, ContentLoaderEntry[]>} */
|
|
232
240
|
const contentTypes = new Map();
|
|
233
241
|
|
|
234
242
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
235
|
-
const entries = await loadContentType(name,
|
|
243
|
+
const entries = await loadContentType(name, contentTypeDef, projectRoot);
|
|
236
244
|
contentTypes.set(name, entries);
|
|
237
245
|
}
|
|
238
246
|
|
|
@@ -244,8 +252,8 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
|
|
|
244
252
|
*
|
|
245
253
|
* @param {string} projectRoot - Project root directory
|
|
246
254
|
* @param {string} contentTypeName - Name of the content type
|
|
247
|
-
* @param {
|
|
248
|
-
* @returns {
|
|
255
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
256
|
+
* @returns {(string | JxElement)[] | undefined}
|
|
249
257
|
*/
|
|
250
258
|
export function getContentTypeElements(projectRoot, contentTypeName, projectConfig = undefined) {
|
|
251
259
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
@@ -258,21 +266,24 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
|
|
|
258
266
|
* Load a single content type by its definition.
|
|
259
267
|
*
|
|
260
268
|
* @param {string} name - Content type name
|
|
261
|
-
* @param {
|
|
269
|
+
* @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
|
|
262
270
|
* @param {string} projectRoot - Absolute path to project root directory
|
|
263
|
-
* @returns {Promise<
|
|
271
|
+
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
264
272
|
*/
|
|
265
273
|
async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
266
274
|
const source = contentTypeDef.source;
|
|
267
275
|
const schema = contentTypeDef.schema;
|
|
268
276
|
|
|
269
277
|
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
270
|
-
/** @type {any} */
|
|
271
278
|
const directiveOptions = contentTypeDef.$elements?.length
|
|
272
279
|
? {
|
|
273
280
|
allowedNames: contentTypeDef.$elements
|
|
274
|
-
.filter(
|
|
275
|
-
|
|
281
|
+
.filter(
|
|
282
|
+
(e) =>
|
|
283
|
+
typeof e === "string" ||
|
|
284
|
+
(typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
|
|
285
|
+
)
|
|
286
|
+
.map((e) => (typeof e === "string" ? e : /** @type {JxMutableNode} */ (e).$ref)),
|
|
276
287
|
}
|
|
277
288
|
: undefined;
|
|
278
289
|
|
|
@@ -280,7 +291,7 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
280
291
|
const pattern = resolve(projectRoot, source).split("\\").join("/");
|
|
281
292
|
const files = globSync(pattern, { absolute: true });
|
|
282
293
|
|
|
283
|
-
/** @type {
|
|
294
|
+
/** @type {ContentLoaderEntry[]} */
|
|
284
295
|
const entries = [];
|
|
285
296
|
|
|
286
297
|
for (const filePath of files) {
|
|
@@ -309,8 +320,8 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
|
|
|
309
320
|
* Validate content entries against their content type schema. Logs warnings for missing required
|
|
310
321
|
* fields and type mismatches.
|
|
311
322
|
*
|
|
312
|
-
* @param {
|
|
313
|
-
* @param {
|
|
323
|
+
* @param {ContentLoaderEntry[]} entries - Array of ContentEntry
|
|
324
|
+
* @param {ContentTypeSchema} schema - JSON Schema for the content type
|
|
314
325
|
* @param {string} contentTypeName - For error messages
|
|
315
326
|
*/
|
|
316
327
|
function validateEntries(entries, schema, contentTypeName) {
|
|
@@ -329,23 +340,22 @@ function validateEntries(entries, schema, contentTypeName) {
|
|
|
329
340
|
|
|
330
341
|
// Check types
|
|
331
342
|
for (const [field, def] of Object.entries(properties)) {
|
|
332
|
-
const d = /** @type {any} */ (def);
|
|
333
343
|
const value = entry.data[field];
|
|
334
344
|
if (value == null) continue;
|
|
335
345
|
|
|
336
|
-
if (
|
|
346
|
+
if (def.type === "string" && typeof value !== "string") {
|
|
337
347
|
console.warn(
|
|
338
348
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
|
|
339
349
|
);
|
|
340
|
-
} else if (
|
|
350
|
+
} else if (def.type === "number" && typeof value !== "number") {
|
|
341
351
|
console.warn(
|
|
342
352
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
|
|
343
353
|
);
|
|
344
|
-
} else if (
|
|
354
|
+
} else if (def.type === "boolean" && typeof value !== "boolean") {
|
|
345
355
|
console.warn(
|
|
346
356
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
|
|
347
357
|
);
|
|
348
|
-
} else if (
|
|
358
|
+
} else if (def.type === "array" && !Array.isArray(value)) {
|
|
349
359
|
console.warn(
|
|
350
360
|
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
|
|
351
361
|
);
|
|
@@ -360,18 +370,23 @@ function validateEntries(entries, schema, contentTypeName) {
|
|
|
360
370
|
* Query a loaded content type with filter, sort, and limit. Implements the ContentCollection
|
|
361
371
|
* $prototype resolution.
|
|
362
372
|
*
|
|
363
|
-
* @param {
|
|
364
|
-
* @param {
|
|
365
|
-
*
|
|
373
|
+
* @param {ContentLoaderEntry[]} entries - Full content type entries
|
|
374
|
+
* @param {{
|
|
375
|
+
* filter?: Record<string, unknown>;
|
|
376
|
+
* sort?: { field: string; order?: string };
|
|
377
|
+
* limit?: number;
|
|
378
|
+
* }} [query]
|
|
379
|
+
* - Query options
|
|
380
|
+
* @returns {ContentLoaderEntry[]} Filtered, sorted, limited entries
|
|
366
381
|
*/
|
|
367
382
|
export function queryContentType(entries, query = {}) {
|
|
368
383
|
let result = [...entries];
|
|
369
384
|
|
|
370
385
|
// Filter
|
|
371
386
|
if (query.filter && typeof query.filter === "object") {
|
|
372
|
-
result = result.filter((
|
|
387
|
+
result = result.filter((entry) => {
|
|
373
388
|
for (const [key, expected] of Object.entries(
|
|
374
|
-
/** @type {Record<string,
|
|
389
|
+
/** @type {Record<string, unknown>} */ (query.filter),
|
|
375
390
|
)) {
|
|
376
391
|
const actual = entry.data[key];
|
|
377
392
|
if (actual !== expected) return false;
|
|
@@ -383,9 +398,9 @@ export function queryContentType(entries, query = {}) {
|
|
|
383
398
|
// Sort
|
|
384
399
|
if (query.sort) {
|
|
385
400
|
const { field, order = "asc" } = query.sort;
|
|
386
|
-
result.sort((
|
|
387
|
-
const aVal = a.data[field] ?? "";
|
|
388
|
-
const bVal = b.data[field] ?? "";
|
|
401
|
+
result.sort((a, b) => {
|
|
402
|
+
const aVal = /** @type {string | number} */ (a.data[field] ?? "");
|
|
403
|
+
const bVal = /** @type {string | number} */ (b.data[field] ?? "");
|
|
389
404
|
if (aVal < bVal) return order === "asc" ? -1 : 1;
|
|
390
405
|
if (aVal > bVal) return order === "asc" ? 1 : -1;
|
|
391
406
|
return 0;
|
|
@@ -403,12 +418,12 @@ export function queryContentType(entries, query = {}) {
|
|
|
403
418
|
/**
|
|
404
419
|
* Find a single entry by ID in a content type. Implements the ContentEntry $prototype resolution.
|
|
405
420
|
*
|
|
406
|
-
* @param {
|
|
421
|
+
* @param {ContentLoaderEntry[]} entries - Full content type entries
|
|
407
422
|
* @param {string} id - Entry ID to find
|
|
408
|
-
* @returns {
|
|
423
|
+
* @returns {ContentLoaderEntry | null} The matching entry or null
|
|
409
424
|
*/
|
|
410
425
|
export function findEntry(entries, id) {
|
|
411
|
-
return entries.find((
|
|
426
|
+
return entries.find((e) => e.id === id) ?? null;
|
|
412
427
|
}
|
|
413
428
|
|
|
414
429
|
// ─── Content Type Reference Resolution ──────────────────────────────────────
|
|
@@ -418,29 +433,27 @@ export function findEntry(entries, id) {
|
|
|
418
433
|
* "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
|
|
419
434
|
* entry.
|
|
420
435
|
*
|
|
421
|
-
* @param {Map<string,
|
|
422
|
-
* Content
|
|
436
|
+
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
|
|
437
|
+
* contentTypes: Record<string, ContentTypeDef> }} config - Content config
|
|
423
438
|
*/
|
|
424
439
|
export function resolveContentTypeRefs(contentTypes, config) {
|
|
425
440
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
426
|
-
const
|
|
427
|
-
const schema = cd.schema;
|
|
441
|
+
const schema = contentTypeDef.schema;
|
|
428
442
|
if (!schema?.properties) continue;
|
|
429
443
|
|
|
430
444
|
const entries = contentTypes.get(name);
|
|
431
445
|
if (!entries) continue;
|
|
432
446
|
|
|
433
447
|
for (const [field, def] of Object.entries(schema.properties)) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const refContentType = d.$ref.replace("#/contentTypes/", "");
|
|
448
|
+
if (!def.$ref?.startsWith("#/contentTypes/")) continue;
|
|
449
|
+
const refContentType = def.$ref.replace("#/contentTypes/", "");
|
|
437
450
|
const refEntries = contentTypes.get(refContentType);
|
|
438
451
|
if (!refEntries) continue;
|
|
439
452
|
|
|
440
453
|
for (const entry of entries) {
|
|
441
454
|
const refId = entry.data[field];
|
|
442
455
|
if (typeof refId === "string") {
|
|
443
|
-
const resolved = refEntries.find((
|
|
456
|
+
const resolved = refEntries.find((e) => e.id === refId);
|
|
444
457
|
if (resolved) {
|
|
445
458
|
entry.data[field] = resolved;
|
|
446
459
|
}
|
|
@@ -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
|