@jxsuite/compiler 0.0.1
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 +165 -0
- package/package.json +38 -0
- package/src/cli.js +59 -0
- package/src/compiler.js +148 -0
- package/src/shared.js +690 -0
- package/src/site/content-loader.js +452 -0
- package/src/site/context-injection.js +152 -0
- package/src/site/head-merger.js +161 -0
- package/src/site/layout-resolver.js +182 -0
- package/src/site/pages-discovery.js +272 -0
- package/src/site/prototype-resolver.js +161 -0
- package/src/site/site-build.js +600 -0
- package/src/site/site-loader.js +85 -0
- package/src/targets/compile-class.js +194 -0
- package/src/targets/compile-client.js +806 -0
- package/src/targets/compile-element.js +619 -0
- package/src/targets/compile-server.js +57 -0
- package/src/targets/compile-static.js +155 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-loader.js — Content collection loader
|
|
3
|
+
*
|
|
4
|
+
* Loads content collections defined in project.json's `collections` key. Supports Markdown (.md),
|
|
5
|
+
* JSON (.json), and CSV (.csv) source files.
|
|
6
|
+
*
|
|
7
|
+
* Phase 2 implementation of site-architecture spec §6.
|
|
8
|
+
*
|
|
9
|
+
* @module content-loader
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { resolve, basename, extname } from "node:path";
|
|
14
|
+
import { globSync } from "glob";
|
|
15
|
+
|
|
16
|
+
// ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a CSV string into an array of objects using the first row as headers. Handles quoted fields
|
|
20
|
+
* with commas and newlines.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} csv - Raw CSV text
|
|
23
|
+
* @returns {Record<string, any>[]} Array of row objects
|
|
24
|
+
*/
|
|
25
|
+
function parseCSV(csv) {
|
|
26
|
+
/** @type {Record<string, any>[]} */
|
|
27
|
+
const rows = [];
|
|
28
|
+
let current = "";
|
|
29
|
+
let inQuotes = false;
|
|
30
|
+
/** @type {string[]} */
|
|
31
|
+
const lines = [];
|
|
32
|
+
|
|
33
|
+
// Split into rows respecting quoted newlines
|
|
34
|
+
for (let i = 0; i < csv.length; i++) {
|
|
35
|
+
const ch = csv[i];
|
|
36
|
+
if (ch === '"') {
|
|
37
|
+
if (inQuotes && csv[i + 1] === '"') {
|
|
38
|
+
current += '"';
|
|
39
|
+
i++;
|
|
40
|
+
} else {
|
|
41
|
+
inQuotes = !inQuotes;
|
|
42
|
+
}
|
|
43
|
+
} else if ((ch === "\n" || (ch === "\r" && csv[i + 1] === "\n")) && !inQuotes) {
|
|
44
|
+
lines.push(current);
|
|
45
|
+
current = "";
|
|
46
|
+
if (ch === "\r") i++;
|
|
47
|
+
} else {
|
|
48
|
+
current += ch;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (current.trim()) lines.push(current);
|
|
52
|
+
|
|
53
|
+
if (lines.length === 0) return [];
|
|
54
|
+
|
|
55
|
+
/** @param {string} line */
|
|
56
|
+
const parseRow = (line) => {
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const fields = [];
|
|
59
|
+
let field = "";
|
|
60
|
+
let q = false;
|
|
61
|
+
for (let i = 0; i < line.length; i++) {
|
|
62
|
+
const ch = line[i];
|
|
63
|
+
if (ch === '"') {
|
|
64
|
+
if (q && line[i + 1] === '"') {
|
|
65
|
+
field += '"';
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
q = !q;
|
|
69
|
+
}
|
|
70
|
+
} else if (ch === "," && !q) {
|
|
71
|
+
fields.push(field);
|
|
72
|
+
field = "";
|
|
73
|
+
} else {
|
|
74
|
+
field += ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
fields.push(field);
|
|
78
|
+
return fields;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const headers = parseRow(lines[0]);
|
|
82
|
+
for (let i = 1; i < lines.length; i++) {
|
|
83
|
+
const fields = parseRow(lines[i]);
|
|
84
|
+
/** @type {Record<string, any>} */
|
|
85
|
+
const obj = {};
|
|
86
|
+
for (let j = 0; j < headers.length; j++) {
|
|
87
|
+
obj[headers[j].trim()] = fields[j]?.trim() ?? "";
|
|
88
|
+
}
|
|
89
|
+
rows.push(obj);
|
|
90
|
+
}
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Markdown loader ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** @type {any} */
|
|
97
|
+
let _mdModule = null;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Lazily import @jxplatform/parser for Markdown support. This avoids hard dependency — only loads
|
|
101
|
+
* when MD collections exist.
|
|
102
|
+
*
|
|
103
|
+
* @returns {Promise<any>}
|
|
104
|
+
*/
|
|
105
|
+
async function getMarkdownModule() {
|
|
106
|
+
if (!_mdModule) {
|
|
107
|
+
_mdModule = await import("@jxplatform/parser");
|
|
108
|
+
}
|
|
109
|
+
return _mdModule;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load a single markdown file into a ContentEntry.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} filePath - Absolute path to .md file
|
|
116
|
+
* @returns {Promise<object>} ContentEntry shape
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
|
|
120
|
+
* custom element directives are available in the markdown.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} filePath - Absolute path to .md file
|
|
123
|
+
* @param {any} [directiveOptions] - Options for the MarkdownDirective plugin
|
|
124
|
+
* @returns {Promise<object>} ContentEntry shape
|
|
125
|
+
*/
|
|
126
|
+
async function loadMarkdownEntry(filePath, directiveOptions) {
|
|
127
|
+
const { MarkdownFile } = await getMarkdownModule();
|
|
128
|
+
const file = new MarkdownFile({ src: filePath, directiveOptions });
|
|
129
|
+
const result = await file.resolve();
|
|
130
|
+
return {
|
|
131
|
+
id: result.slug,
|
|
132
|
+
data: result.frontmatter,
|
|
133
|
+
body: readFileSync(filePath, "utf-8"),
|
|
134
|
+
rendered: result.$body,
|
|
135
|
+
_meta: {
|
|
136
|
+
excerpt: result.$excerpt,
|
|
137
|
+
toc: result.$toc,
|
|
138
|
+
readingTime: result.$readingTime,
|
|
139
|
+
wordCount: result.$wordCount,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load a JSON file into ContentEntry(s). If the file is an array, each element is an entry. If it's
|
|
146
|
+
* an object with an `id` field, it's a single entry.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} filePath - Absolute path to .json file
|
|
149
|
+
* @returns {object[]} Array of ContentEntry shapes
|
|
150
|
+
*/
|
|
151
|
+
function loadJSONEntries(filePath) {
|
|
152
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
153
|
+
if (Array.isArray(raw)) {
|
|
154
|
+
return raw.map((/** @type {any} */ item, /** @type {number} */ i) => ({
|
|
155
|
+
id: item.id ?? basename(filePath, ".json") + "-" + i,
|
|
156
|
+
data: item,
|
|
157
|
+
body: null,
|
|
158
|
+
rendered: null,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
// Single object file — filename is the id
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
id: raw.id ?? basename(filePath, ".json"),
|
|
165
|
+
data: raw,
|
|
166
|
+
body: null,
|
|
167
|
+
rendered: null,
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Load a CSV file into ContentEntry(s).
|
|
174
|
+
*
|
|
175
|
+
* @param {string} filePath - Absolute path to .csv file
|
|
176
|
+
* @param {any} [schema] - Collection schema (for type coercion)
|
|
177
|
+
* @returns {object[]} Array of ContentEntry shapes
|
|
178
|
+
*/
|
|
179
|
+
function loadCSVEntries(filePath, schema) {
|
|
180
|
+
const csv = readFileSync(filePath, "utf-8");
|
|
181
|
+
const rows = parseCSV(csv);
|
|
182
|
+
return rows.map((/** @type {Record<string, any>} */ row, /** @type {number} */ i) => {
|
|
183
|
+
// Apply type coercion based on schema if available
|
|
184
|
+
if (schema?.properties) {
|
|
185
|
+
for (const [key, def] of Object.entries(schema.properties)) {
|
|
186
|
+
const d = /** @type {any} */ (def);
|
|
187
|
+
if (key in row) {
|
|
188
|
+
if (d.type === "number") row[key] = Number(row[key]);
|
|
189
|
+
else if (d.type === "boolean") row[key] = row[key] === "true";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Use `id` column, `sku` column, or row index as the entry ID
|
|
194
|
+
const id = row.id ?? row.sku ?? String(i);
|
|
195
|
+
return { id, data: row, body: null, rendered: null };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Content Config ───────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Load and parse content collections config from project.json.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} projectRoot - Project root directory
|
|
205
|
+
* @param {Record<string, any>} [projectConfig] - Already-loaded project config with `collections`
|
|
206
|
+
* key
|
|
207
|
+
* @returns {{ config: any; contentDir: string } | null} Parsed config or null if no content dir
|
|
208
|
+
*/
|
|
209
|
+
export function loadContentConfig(projectRoot, projectConfig = undefined) {
|
|
210
|
+
const contentDir = resolve(projectRoot, "content");
|
|
211
|
+
|
|
212
|
+
/** @type {any} */
|
|
213
|
+
const config = { collections: projectConfig?.collections ?? {} };
|
|
214
|
+
|
|
215
|
+
return { config, contentDir };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Collection Loading ───────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Load all content collections defined in project.json.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} projectRoot - Project root directory
|
|
224
|
+
* @param {Record<string, any>} [projectConfig] - Already-loaded project config
|
|
225
|
+
* @returns {Promise<Map<string, any[]>>} Map of collection name → array of ContentEntry
|
|
226
|
+
*/
|
|
227
|
+
export async function loadCollections(projectRoot, projectConfig = undefined) {
|
|
228
|
+
const result = loadContentConfig(projectRoot, projectConfig);
|
|
229
|
+
if (!result) return new Map();
|
|
230
|
+
|
|
231
|
+
const { config } = result;
|
|
232
|
+
/** @type {Map<string, any[]>} */
|
|
233
|
+
const collections = new Map();
|
|
234
|
+
|
|
235
|
+
for (const [name, collectionDef] of Object.entries(config.collections)) {
|
|
236
|
+
const entries = await loadCollection(name, /** @type {any} */ (collectionDef), projectRoot);
|
|
237
|
+
collections.set(name, entries);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return collections;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the $elements array for a specific collection, if defined in project.json collections.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} projectRoot - Project root directory
|
|
247
|
+
* @param {string} collectionName - Name of the collection
|
|
248
|
+
* @param {Record<string, any>} [projectConfig] - Already-loaded project config
|
|
249
|
+
* @returns {any[] | undefined}
|
|
250
|
+
*/
|
|
251
|
+
export function getCollectionElements(projectRoot, collectionName, projectConfig = undefined) {
|
|
252
|
+
const result = loadContentConfig(projectRoot, projectConfig);
|
|
253
|
+
if (!result) return undefined;
|
|
254
|
+
const def = result.config.collections?.[collectionName];
|
|
255
|
+
return def?.$elements;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Load a single collection by its definition.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} name - Collection name
|
|
262
|
+
* @param {any} collectionDef - Collection definition from project.json
|
|
263
|
+
* @param {string} projectRoot - Absolute path to project root directory
|
|
264
|
+
* @returns {Promise<any[]>} Array of ContentEntry
|
|
265
|
+
*/
|
|
266
|
+
async function loadCollection(name, collectionDef, projectRoot) {
|
|
267
|
+
const source = collectionDef.source;
|
|
268
|
+
const schema = collectionDef.schema;
|
|
269
|
+
|
|
270
|
+
// Derive directive allowedNames from collection $elements (tag names from npm packages)
|
|
271
|
+
/** @type {any} */
|
|
272
|
+
const directiveOptions = collectionDef.$elements?.length
|
|
273
|
+
? {
|
|
274
|
+
allowedNames: collectionDef.$elements
|
|
275
|
+
.filter((/** @type {any} */ e) => typeof e === "string" || e?.$ref)
|
|
276
|
+
.map((/** @type {any} */ e) => (typeof e === "string" ? e : e.$ref)),
|
|
277
|
+
}
|
|
278
|
+
: undefined;
|
|
279
|
+
|
|
280
|
+
// Resolve the glob pattern relative to project root
|
|
281
|
+
const pattern = resolve(projectRoot, source).split("\\").join("/");
|
|
282
|
+
const files = globSync(pattern, { absolute: true });
|
|
283
|
+
|
|
284
|
+
/** @type {any[]} */
|
|
285
|
+
const entries = [];
|
|
286
|
+
|
|
287
|
+
for (const filePath of files) {
|
|
288
|
+
const ext = extname(filePath).toLowerCase();
|
|
289
|
+
|
|
290
|
+
if (ext === ".md") {
|
|
291
|
+
entries.push(await loadMarkdownEntry(filePath, directiveOptions));
|
|
292
|
+
} else if (ext === ".json") {
|
|
293
|
+
entries.push(...loadJSONEntries(filePath));
|
|
294
|
+
} else if (ext === ".csv") {
|
|
295
|
+
entries.push(...loadCSVEntries(filePath, schema));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Validate entries against schema if present
|
|
300
|
+
if (schema) {
|
|
301
|
+
validateEntries(entries, schema, name);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return entries;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Schema Validation ────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Validate content entries against their collection schema. Logs warnings for missing required
|
|
311
|
+
* fields and type mismatches.
|
|
312
|
+
*
|
|
313
|
+
* @param {any[]} entries - Array of ContentEntry
|
|
314
|
+
* @param {any} schema - JSON Schema for the collection
|
|
315
|
+
* @param {string} collectionName - For error messages
|
|
316
|
+
*/
|
|
317
|
+
function validateEntries(entries, schema, collectionName) {
|
|
318
|
+
const required = schema.required ?? [];
|
|
319
|
+
const properties = schema.properties ?? {};
|
|
320
|
+
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
// Check required fields
|
|
323
|
+
for (const field of required) {
|
|
324
|
+
if (!(field in entry.data) || entry.data[field] == null) {
|
|
325
|
+
console.warn(
|
|
326
|
+
`Content validation: "${collectionName}/${entry.id}" missing required field "${field}"`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Check types
|
|
332
|
+
for (const [field, def] of Object.entries(properties)) {
|
|
333
|
+
const d = /** @type {any} */ (def);
|
|
334
|
+
const value = entry.data[field];
|
|
335
|
+
if (value == null) continue;
|
|
336
|
+
|
|
337
|
+
if (d.type === "string" && typeof value !== "string") {
|
|
338
|
+
console.warn(
|
|
339
|
+
`Content validation: "${collectionName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
|
|
340
|
+
);
|
|
341
|
+
} else if (d.type === "number" && typeof value !== "number") {
|
|
342
|
+
console.warn(
|
|
343
|
+
`Content validation: "${collectionName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
|
|
344
|
+
);
|
|
345
|
+
} else if (d.type === "boolean" && typeof value !== "boolean") {
|
|
346
|
+
console.warn(
|
|
347
|
+
`Content validation: "${collectionName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
|
|
348
|
+
);
|
|
349
|
+
} else if (d.type === "array" && !Array.isArray(value)) {
|
|
350
|
+
console.warn(
|
|
351
|
+
`Content validation: "${collectionName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Collection Querying ──────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Query a loaded collection with filter, sort, and limit. Implements the ContentCollection
|
|
362
|
+
* $prototype resolution.
|
|
363
|
+
*
|
|
364
|
+
* @param {any[]} entries - Full collection entries
|
|
365
|
+
* @param {any} [query] - Query options
|
|
366
|
+
* @returns {any[]} Filtered, sorted, limited entries
|
|
367
|
+
*/
|
|
368
|
+
export function queryCollection(entries, query = {}) {
|
|
369
|
+
let result = [...entries];
|
|
370
|
+
|
|
371
|
+
// Filter
|
|
372
|
+
if (query.filter && typeof query.filter === "object") {
|
|
373
|
+
result = result.filter((/** @type {any} */ entry) => {
|
|
374
|
+
for (const [key, expected] of Object.entries(
|
|
375
|
+
/** @type {Record<string, any>} */ (query.filter),
|
|
376
|
+
)) {
|
|
377
|
+
const actual = entry.data[key];
|
|
378
|
+
if (actual !== expected) return false;
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Sort
|
|
385
|
+
if (query.sort) {
|
|
386
|
+
const { field, order = "asc" } = query.sort;
|
|
387
|
+
result.sort((/** @type {any} */ a, /** @type {any} */ b) => {
|
|
388
|
+
const aVal = a.data[field] ?? "";
|
|
389
|
+
const bVal = b.data[field] ?? "";
|
|
390
|
+
if (aVal < bVal) return order === "asc" ? -1 : 1;
|
|
391
|
+
if (aVal > bVal) return order === "asc" ? 1 : -1;
|
|
392
|
+
return 0;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Limit
|
|
397
|
+
if (query.limit && query.limit > 0) {
|
|
398
|
+
result = result.slice(0, query.limit);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Find a single entry by ID in a collection. Implements the ContentEntry $prototype resolution.
|
|
406
|
+
*
|
|
407
|
+
* @param {any[]} entries - Full collection entries
|
|
408
|
+
* @param {string} id - Entry ID to find
|
|
409
|
+
* @returns {any | null} The matching entry or null
|
|
410
|
+
*/
|
|
411
|
+
export function findEntry(entries, id) {
|
|
412
|
+
return entries.find((/** @type {any} */ e) => e.id === id) ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Collection Reference Resolution ─────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Resolve cross-collection $ref references in entry data. For example, a blog post's `author:
|
|
419
|
+
* "jane-doe"` with a schema `$ref` to the authors collection gets resolved to the full author
|
|
420
|
+
* entry.
|
|
421
|
+
*
|
|
422
|
+
* @param {Map<string, any[]>} collections - All loaded collections @param {any} config -
|
|
423
|
+
* Content.config.json
|
|
424
|
+
*/
|
|
425
|
+
export function resolveCollectionRefs(collections, config) {
|
|
426
|
+
for (const [name, collectionDef] of Object.entries(config.collections)) {
|
|
427
|
+
const cd = /** @type {any} */ (collectionDef);
|
|
428
|
+
const schema = cd.schema;
|
|
429
|
+
if (!schema?.properties) continue;
|
|
430
|
+
|
|
431
|
+
const entries = collections.get(name);
|
|
432
|
+
if (!entries) continue;
|
|
433
|
+
|
|
434
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
435
|
+
const d = /** @type {any} */ (def);
|
|
436
|
+
if (!d.$ref?.startsWith("#/collections/")) continue;
|
|
437
|
+
const refCollection = d.$ref.replace("#/collections/", "");
|
|
438
|
+
const refEntries = collections.get(refCollection);
|
|
439
|
+
if (!refEntries) continue;
|
|
440
|
+
|
|
441
|
+
for (const entry of entries) {
|
|
442
|
+
const refId = entry.data[field];
|
|
443
|
+
if (typeof refId === "string") {
|
|
444
|
+
const resolved = refEntries.find((/** @type {any} */ e) => e.id === refId);
|
|
445
|
+
if (resolved) {
|
|
446
|
+
entry.data[field] = resolved;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-injection.js — $page and $site context injection
|
|
3
|
+
*
|
|
4
|
+
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
|
+
* These are available as $site.* and $page.* in template expressions.
|
|
6
|
+
*
|
|
7
|
+
* Also resolves ContentCollection and ContentEntry $prototype entries against loaded content
|
|
8
|
+
* collections (Phase 2, spec §6.4).
|
|
9
|
+
*
|
|
10
|
+
* Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
|
|
11
|
+
* url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
|
|
12
|
+
* title $page.params — dynamic route parameters (if any)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { queryCollection, findEntry } from "./content-loader.js";
|
|
16
|
+
import { resolve, dirname, relative } from "node:path";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inject $site and $page context into a page document's state.
|
|
20
|
+
*
|
|
21
|
+
* @param {any} doc - The page document (mutated)
|
|
22
|
+
* @param {any} projectConfig - Loaded project configuration
|
|
23
|
+
* @param {any} route - The resolved route for this page
|
|
24
|
+
* @param {Map<string, any[]>} [collections] - Loaded content collections
|
|
25
|
+
* @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
|
|
26
|
+
* @returns {any} The mutated document
|
|
27
|
+
*/
|
|
28
|
+
export function injectContext(
|
|
29
|
+
doc,
|
|
30
|
+
projectConfig,
|
|
31
|
+
route,
|
|
32
|
+
collections = new Map(),
|
|
33
|
+
projectRoot = null,
|
|
34
|
+
) {
|
|
35
|
+
if (!doc.state) doc.state = {};
|
|
36
|
+
|
|
37
|
+
// $site context — read-only project-level data
|
|
38
|
+
doc.state.$site = {
|
|
39
|
+
name: projectConfig.name ?? "Jx Site",
|
|
40
|
+
url: projectConfig.url ?? "",
|
|
41
|
+
...projectConfig.state,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// $page context — read-only page-level data
|
|
45
|
+
doc.state.$page = {
|
|
46
|
+
url: route.urlPattern,
|
|
47
|
+
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
48
|
+
params: route._pathParams ?? {},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Resolve ContentCollection and ContentEntry $prototype entries
|
|
52
|
+
if (collections.size > 0) {
|
|
53
|
+
resolveContentPrototypes(doc.state, collections, route._pathParams ?? {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Merge project-level state into page state (page wins on conflicts)
|
|
57
|
+
if (projectConfig.state) {
|
|
58
|
+
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
59
|
+
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
60
|
+
doc.state[key] = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Merge project-level $media into page $media
|
|
66
|
+
if (projectConfig.$media) {
|
|
67
|
+
doc.$media = { ...projectConfig.$media, ...doc.$media };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Merge project-level imports into page imports (page wins on collision)
|
|
71
|
+
if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
|
|
72
|
+
if (!doc.imports) doc.imports = {};
|
|
73
|
+
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
74
|
+
if (!(name in doc.imports)) {
|
|
75
|
+
const src = /** @type {string} */ (srcPath);
|
|
76
|
+
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
77
|
+
if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
|
|
78
|
+
const abs = resolve(projectRoot, src);
|
|
79
|
+
doc.imports[name] = "./" + relative(dirname(route.sourcePath), abs);
|
|
80
|
+
} else {
|
|
81
|
+
doc.imports[name] = src;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Merge project-level $elements into page $elements (union, dedup)
|
|
88
|
+
if (projectConfig.$elements?.length) {
|
|
89
|
+
if (!doc.$elements?.length) {
|
|
90
|
+
doc.$elements = [...projectConfig.$elements];
|
|
91
|
+
} else {
|
|
92
|
+
/** @type {Set<string>} */
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
/** @type {any[]} */
|
|
95
|
+
const merged = [];
|
|
96
|
+
for (const entry of [...projectConfig.$elements, ...doc.$elements]) {
|
|
97
|
+
const key = typeof entry === "string" ? entry : entry?.$ref;
|
|
98
|
+
if (key && !seen.has(key)) {
|
|
99
|
+
seen.add(key);
|
|
100
|
+
merged.push(entry);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
doc.$elements = merged;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return doc;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve ContentCollection and ContentEntry $prototype state entries.
|
|
112
|
+
*
|
|
113
|
+
* Replaces state entries like: { "$prototype": "ContentCollection", "collection": "blog", ... }
|
|
114
|
+
* with the actual resolved collection data.
|
|
115
|
+
*
|
|
116
|
+
* @param {Record<string, any>} state - Page state (mutated)
|
|
117
|
+
* @param {Map<string, any[]>} collections - Loaded collections
|
|
118
|
+
* @param {Record<string, any>} params - Route parameters for $ref resolution
|
|
119
|
+
*/
|
|
120
|
+
function resolveContentPrototypes(state, collections, params) {
|
|
121
|
+
for (const [key, value] of Object.entries(state)) {
|
|
122
|
+
if (!value || typeof value !== "object" || !value.$prototype) continue;
|
|
123
|
+
|
|
124
|
+
if (value.$prototype === "ContentCollection") {
|
|
125
|
+
const entries = collections.get(value.collection);
|
|
126
|
+
if (!entries) {
|
|
127
|
+
console.warn(`ContentCollection: collection "${value.collection}" not found`);
|
|
128
|
+
state[key] = [];
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
state[key] = queryCollection(entries, {
|
|
132
|
+
filter: value.filter,
|
|
133
|
+
sort: value.sort,
|
|
134
|
+
limit: value.limit,
|
|
135
|
+
});
|
|
136
|
+
} else if (value.$prototype === "ContentEntry") {
|
|
137
|
+
const entries = collections.get(value.collection);
|
|
138
|
+
if (!entries) {
|
|
139
|
+
console.warn(`ContentEntry: collection "${value.collection}" not found`);
|
|
140
|
+
state[key] = null;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Resolve the ID — may reference $params
|
|
144
|
+
let id = value.id;
|
|
145
|
+
if (id && typeof id === "object" && id.$ref?.startsWith("#/$params/")) {
|
|
146
|
+
const paramName = id.$ref.replace("#/$params/", "");
|
|
147
|
+
id = params[paramName];
|
|
148
|
+
}
|
|
149
|
+
state[key] = findEntry(entries, id);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|