@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.
@@ -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
+ }