@jxsuite/compiler 0.21.3 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.21.3",
3
+ "version": "0.22.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
41
- "@jxsuite/parser": "^0.21.3",
42
- "@jxsuite/runtime": "^0.21.3",
41
+ "@jxsuite/parser": "^0.22.0",
42
+ "@jxsuite/runtime": "^0.22.0",
43
43
  "remark-gfm": "^4.0.1",
44
44
  "remark-stringify": "^11.0.0",
45
45
  "sharp": "^0.34.5",
@@ -381,67 +381,9 @@ function validateEntries(entries, schema, contentTypeName) {
381
381
  }
382
382
  }
383
383
 
384
- // ─── Content Type Querying ───────────────────────────────────────────────────
384
+ // ─── Content Type Querying (re-exported from @jxsuite/parser/content) ────────
385
385
 
386
- /**
387
- * Query a loaded content type with filter, sort, and limit. Implements the ContentCollection
388
- * $prototype resolution.
389
- *
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
398
- */
399
- export function queryContentType(entries, query = {}) {
400
- let result = [...entries];
401
-
402
- // Filter
403
- if (query.filter && typeof query.filter === "object") {
404
- result = result.filter((entry) => {
405
- for (const [key, expected] of Object.entries(
406
- /** @type {Record<string, unknown>} */ (query.filter),
407
- )) {
408
- const actual = entry.data[key];
409
- if (actual !== expected) return false;
410
- }
411
- return true;
412
- });
413
- }
414
-
415
- // Sort
416
- if (query.sort) {
417
- const { field, order = "asc" } = query.sort;
418
- result.sort((a, b) => {
419
- const aVal = /** @type {string | number} */ (a.data[field] ?? "");
420
- const bVal = /** @type {string | number} */ (b.data[field] ?? "");
421
- if (aVal < bVal) return order === "asc" ? -1 : 1;
422
- if (aVal > bVal) return order === "asc" ? 1 : -1;
423
- return 0;
424
- });
425
- }
426
-
427
- // Limit
428
- if (query.limit && query.limit > 0) {
429
- result = result.slice(0, query.limit);
430
- }
431
-
432
- return result;
433
- }
434
-
435
- /**
436
- * Find a single entry by ID in a content type. Implements the ContentEntry $prototype resolution.
437
- *
438
- * @param {ContentLoaderEntry[]} entries - Full content type entries
439
- * @param {string} id - Entry ID to find
440
- * @returns {ContentLoaderEntry | null} The matching entry or null
441
- */
442
- export function findEntry(entries, id) {
443
- return entries.find((e) => e.id === id) ?? null;
444
- }
386
+ export { queryContentType, findEntry } from "@jxsuite/parser/content";
445
387
 
446
388
  // ─── Content Type Reference Resolution ──────────────────────────────────────
447
389
 
@@ -12,7 +12,6 @@
12
12
  * title $page.params — dynamic route parameters (if any)
13
13
  */
14
14
 
15
- import { queryContentType, findEntry } from "./content-loader.js";
16
15
  import { resolve, dirname, relative } from "node:path";
17
16
 
18
17
  /**
@@ -48,11 +47,6 @@ export function injectContext(
48
47
  params: route._pathParams ?? {},
49
48
  };
50
49
 
51
- // Resolve ContentCollection and ContentEntry $prototype entries
52
- if (contentTypes.size > 0) {
53
- resolveContentPrototypes(doc.state, contentTypes, route._pathParams ?? {});
54
- }
55
-
56
50
  // Merge project-level state into page state (page wins on conflicts)
57
51
  if (projectConfig.state) {
58
52
  for (const [key, value] of Object.entries(projectConfig.state)) {
@@ -110,49 +104,3 @@ export function injectContext(
110
104
 
111
105
  return doc;
112
106
  }
113
-
114
- /**
115
- * Resolve ContentCollection and ContentEntry $prototype state entries.
116
- *
117
- * Replaces state entries like: { "$prototype": "ContentCollection", "contentType": "blog", ... }
118
- * with the actual resolved content type data.
119
- *
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
123
- */
124
- function resolveContentPrototypes(state, contentTypes, params) {
125
- for (const [key, value] of Object.entries(state)) {
126
- if (!value || typeof value !== "object" || !(/** @type {JxMutableNode} */ (value).$prototype))
127
- continue;
128
- const v = /** @type {JxPrototypeDef} */ (value);
129
-
130
- if (v.$prototype === "ContentCollection") {
131
- const entries = contentTypes.get(/** @type {string} */ (v.contentType));
132
- if (!entries) {
133
- console.warn(`ContentCollection: content type "${v.contentType}" not found`);
134
- state[key] = [];
135
- continue;
136
- }
137
- state[key] = queryContentType(entries, {
138
- filter: v.filter,
139
- sort: v.sort,
140
- limit: v.limit,
141
- });
142
- } else if (v.$prototype === "ContentEntry") {
143
- const entries = contentTypes.get(/** @type {string} */ (v.contentType));
144
- if (!entries) {
145
- console.warn(`ContentEntry: content type "${v.contentType}" not found`);
146
- state[key] = null;
147
- continue;
148
- }
149
- // Resolve the ID — may reference $params
150
- let id = v.id;
151
- if (id && typeof id === "object" && id.$ref?.startsWith("#/$params/")) {
152
- const paramName = id.$ref.replace("#/$params/", "");
153
- id = params[paramName];
154
- }
155
- state[key] = findEntry(entries, /** @type {string} */ (id));
156
- }
157
- }
158
- }
@@ -94,7 +94,9 @@ function headEntryKey(entry) {
94
94
 
95
95
  // <style> — unique per content hash
96
96
  if (tag === "style") {
97
- const content = Array.isArray(entry.children) ? entry.children.join("") : "";
97
+ const content = Array.isArray(entry.children)
98
+ ? entry.children.join("")
99
+ : (entry.textContent ?? "");
98
100
  return `style:${simpleHash(content)}`;
99
101
  }
100
102
 
@@ -149,7 +151,9 @@ function renderHeadEntry(entry) {
149
151
  if (VOID.has(tag)) return open;
150
152
 
151
153
  // Elements with content
152
- const content = Array.isArray(entry.children) ? entry.children.join("") : "";
154
+ const content = Array.isArray(entry.children)
155
+ ? entry.children.join("")
156
+ : (entry.textContent ?? "");
153
157
  return `${open}${content}</${tag}>`;
154
158
  }
155
159
 
@@ -23,24 +23,7 @@ import { createRequire } from "node:module";
23
23
  * Prototype names handled elsewhere (builtins + legacy content system). These are skipped by the
24
24
  * generic resolver.
25
25
  */
26
- const SKIP_PROTOTYPES = new Set([
27
- "Function",
28
- "LocalStorage",
29
- "SessionStorage",
30
- "Array",
31
- "ContentCollection",
32
- "ContentEntry",
33
- ]);
34
-
35
- /**
36
- * Built-in $prototype → .class.json mappings. These resolve automatically without requiring an
37
- * explicit `imports` entry in project.json. Project-level imports take precedence on collision.
38
- */
39
- /** @type {Record<string, string>} */
40
- const BUILTIN_CLASS_MAPPINGS = {
41
- MarkdownFile: "@jxsuite/parser/MarkdownFile.class.json",
42
- MarkdownCollection: "@jxsuite/parser/MarkdownCollection.class.json",
43
- };
26
+ const SKIP_PROTOTYPES = new Set(["Function", "LocalStorage", "SessionStorage", "Array"]);
44
27
 
45
28
  /**
46
29
  * Keys reserved by the Jx prototype system — stripped before passing config to the external class
@@ -66,10 +49,12 @@ const RESERVED_KEYS = new Set([
66
49
  * result
67
50
  *
68
51
  * @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
69
- * @param {{ sourcePath?: string }} route - Route info (sourcePath = absolute path to page .json)
52
+ * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
70
53
  * @param {string} projectRoot - Absolute path to the project root
54
+ * @param {{ config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> }} [projectContext] -
55
+ * Project-level context injected into all classes
71
56
  */
72
- export async function resolvePrototypes(doc, route, projectRoot) {
57
+ export async function resolvePrototypes(doc, route, projectRoot, projectContext = {}) {
73
58
  const imports = doc.imports ?? {};
74
59
  const state = doc.state;
75
60
  if (!state) return;
@@ -83,13 +68,13 @@ export async function resolvePrototypes(doc, route, projectRoot) {
83
68
 
84
69
  // Look up in imports if no $src already set
85
70
  if (!def.$src) {
86
- const mapped = imports[def.$prototype] ?? BUILTIN_CLASS_MAPPINGS[def.$prototype];
71
+ const mapped = imports[def.$prototype];
87
72
  if (!mapped) continue;
88
73
  def.$src = mapped;
89
74
  }
90
75
 
91
76
  try {
92
- const resolved = await resolveClassPrototype(def, route, projectRoot);
77
+ const resolved = await resolveClassPrototype(def, route, projectRoot, state, projectContext);
93
78
  // Preserve timing metadata on the resolved value so compilePage() can strip it
94
79
  if (def.timing && resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
95
80
  resolved.timing = def.timing;
@@ -108,11 +93,13 @@ export async function resolvePrototypes(doc, route, projectRoot) {
108
93
  * Resolve a single $prototype entry via its .class.json.
109
94
  *
110
95
  * @param {Record<string, any>} def - The state entry definition
111
- * @param {{ sourcePath?: string }} route
96
+ * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route
112
97
  * @param {string} projectRoot
98
+ * @param {Record<string, unknown>} state - The full page state object
99
+ * @param {{ config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> }} projectContext
113
100
  * @returns {Promise<any>} The resolved value
114
101
  */
115
- async function resolveClassPrototype(def, route, projectRoot) {
102
+ async function resolveClassPrototype(def, route, projectRoot, state, projectContext) {
116
103
  const src = def.$src;
117
104
 
118
105
  // 1. Resolve .class.json path — handles both npm specifiers and relative paths
@@ -162,6 +149,14 @@ async function resolveClassPrototype(def, route, projectRoot) {
162
149
  config.basePath = dirname(route.sourcePath);
163
150
  }
164
151
 
152
+ // Inject universal context — available to all classes
153
+ config._project = {
154
+ config: projectContext.config ?? null,
155
+ contentTypes: projectContext.contentTypes ?? null,
156
+ root: projectRoot,
157
+ };
158
+ config._document = { route, state };
159
+
165
160
  // 7. Instantiate and resolve
166
161
  const instance = new ExportedClass(config);
167
162
  if (typeof instance.resolve === "function") {
@@ -405,11 +405,14 @@ async function compilePage(
405
405
  delete layoutDoc._pageHead;
406
406
  delete layoutDoc._pageTitle;
407
407
 
408
- // Inject $site and $page context, resolve ContentCollection/ContentEntry
408
+ // Inject $site and $page context
409
409
  injectContext(layoutDoc, projectConfig, route, contentTypes, projectRoot);
410
410
 
411
411
  // Resolve generic $prototype entries via .class.json imports
412
- await resolvePrototypes(layoutDoc, route, projectRoot);
412
+ await resolvePrototypes(layoutDoc, route, projectRoot, {
413
+ config: projectConfig,
414
+ contentTypes,
415
+ });
413
416
 
414
417
  // Build scope from resolved state so template strings in title/$head can be evaluated
415
418
  const scope = buildInitialScope(layoutDoc.state ?? {});