@jxsuite/compiler 0.21.4 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.21.4",
3
+ "version": "0.22.1",
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.4",
42
- "@jxsuite/runtime": "^0.21.4",
41
+ "@jxsuite/parser": "^0.22.1",
42
+ "@jxsuite/runtime": "^0.22.1",
43
43
  "remark-gfm": "^4.0.1",
44
44
  "remark-stringify": "^11.0.0",
45
45
  "sharp": "^0.34.5",
package/src/shared.js CHANGED
@@ -518,11 +518,11 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
518
518
  for (const [key, val] of Object.entries(obj)) {
519
519
  if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
520
520
  if (key.startsWith("@")) {
521
- const query = key.startsWith("@--")
522
- ? (mediaQueries[key.slice(1)] ?? key.slice(1))
523
- : key.slice(1);
521
+ const atRule = key.startsWith("@--")
522
+ ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
523
+ : key;
524
524
  const mProps = toCSSText(val);
525
- if (mProps) rules.push(`@media ${query} { ${selector} { ${mProps} } }`);
525
+ if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
526
526
  for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ (val))) {
527
527
  if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
528
528
  if (sel.startsWith("@")) continue;
@@ -532,7 +532,7 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
532
532
  ? `${selector}${sel}`
533
533
  : `${selector} ${sel}`;
534
534
  const subProps = toCSSText(sub);
535
- if (subProps) rules.push(`@media ${query} { ${resolved} { ${subProps} } }`);
535
+ if (subProps) rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
536
536
  }
537
537
  continue;
538
538
  }
@@ -558,10 +558,12 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
558
558
  emitProjectRules(key, val);
559
559
  } else if (key.startsWith("@")) {
560
560
  // @media block at top level
561
- const query = key.startsWith("@--")
562
- ? (mediaQueries[key.slice(1)] ?? key.slice(1))
563
- : key.slice(1);
564
- rules.push(`@media ${query} { body { ${toCSSText(/** @type {object} */ (val))} } }`);
561
+ const atRule = key.startsWith("@--")
562
+ ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
563
+ : key.startsWith("@(")
564
+ ? `@media ${key.slice(1)}`
565
+ : key;
566
+ rules.push(`${atRule} { body { ${toCSSText(/** @type {object} */ (val))} } }`);
565
567
  }
566
568
  }
567
569
  // Collect CSS custom properties into :root {}
@@ -610,11 +612,13 @@ function emitNestedElement(selector, obj, rules, mediaQueries) {
610
612
  for (const [key, val] of Object.entries(obj)) {
611
613
  if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
612
614
  if (key.startsWith("@")) {
613
- const query = key.startsWith("@--")
614
- ? (mediaQueries[key.slice(1)] ?? key.slice(1))
615
- : key.slice(1);
615
+ const atRule = key.startsWith("@--")
616
+ ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
617
+ : key.startsWith("@(")
618
+ ? `@media ${key.slice(1)}`
619
+ : key;
616
620
  const mProps = toCSSText(val);
617
- if (mProps) rules.push(`@media ${query} { ${selector} { ${mProps} } }`);
621
+ if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
618
622
  for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ (val))) {
619
623
  if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
620
624
  if (sel.startsWith("@")) continue;
@@ -624,7 +628,7 @@ function emitNestedElement(selector, obj, rules, mediaQueries) {
624
628
  ? `${selector}${sel}`
625
629
  : `${selector} ${sel}`;
626
630
  const subProps = toCSSText(sub);
627
- if (subProps) rules.push(`@media ${query} { ${resolved} { ${subProps} } }`);
631
+ if (subProps) rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
628
632
  }
629
633
  continue;
630
634
  }
@@ -688,10 +692,13 @@ export function collectStyles(
688
692
  for (const [prop, val] of Object.entries(def.style)) {
689
693
  if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
690
694
  if (prop.startsWith("@")) {
691
- const query = prop.startsWith("@--")
692
- ? (mediaQueries[prop.slice(1)] ?? prop.slice(1))
693
- : prop.slice(1);
694
- rules.push(`@media ${query} { ${selector} { ${toCSSText(val)} } }`);
695
+ const atRule = prop.startsWith("@--")
696
+ ? `@media ${mediaQueries[prop.slice(1)] ?? prop.slice(1)}`
697
+ : prop.startsWith("@(")
698
+ ? `@media ${prop.slice(1)}`
699
+ : prop;
700
+ const atBaseProps = toCSSText(val);
701
+ if (atBaseProps) rules.push(`${atRule} { ${selector} { ${atBaseProps} } }`);
695
702
  for (const [sel, nestedRules] of Object.entries(
696
703
  /** @type {Record<string, unknown>} */ (val),
697
704
  )) {
@@ -703,7 +710,7 @@ export function collectStyles(
703
710
  : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
704
711
  ? `${selector}${sel}`
705
712
  : `${selector} ${sel}`;
706
- rules.push(`@media ${query} { ${resolved} { ${toCSSText(nestedRules)} } }`);
713
+ rules.push(`${atRule} { ${resolved} { ${toCSSText(nestedRules)} } }`);
707
714
  }
708
715
  } else {
709
716
  const resolved = prop.startsWith("&")
@@ -1038,10 +1045,12 @@ export function buildComponentCSS(tagName, styleDef, doc = null, mediaQueries =
1038
1045
 
1039
1046
  for (const [prop, val] of Object.entries(styleDef)) {
1040
1047
  if (prop.startsWith("@")) {
1041
- const query = prop.startsWith("@--")
1042
- ? (mediaQueries[prop.slice(1)] ?? prop.slice(1))
1043
- : prop.slice(1);
1044
- rules.push(`@media ${query} { ${tagName} { ${toCSSText(/** @type {object} */ (val))} } }`);
1048
+ const atRule = prop.startsWith("@--")
1049
+ ? `@media ${mediaQueries[prop.slice(1)] ?? prop.slice(1)}`
1050
+ : prop.startsWith("@(")
1051
+ ? `@media ${prop.slice(1)}`
1052
+ : prop;
1053
+ rules.push(`${atRule} { ${tagName} { ${toCSSText(/** @type {object} */ (val))} } }`);
1045
1054
  } else if (
1046
1055
  prop.startsWith(":") ||
1047
1056
  prop.startsWith(".") ||
@@ -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") {
@@ -134,7 +134,7 @@ export async function buildSite(projectRoot, options = {}) {
134
134
  for (const file of componentFiles) {
135
135
  try {
136
136
  const componentPath = resolve(componentsDir, file);
137
- const result = await compileElement(componentPath);
137
+ const result = await compileElement(componentPath, { $media: projectConfig.$media });
138
138
  for (const f of result.files) {
139
139
  const outName = f.path.includes("/")
140
140
  ? /** @type {string} */ (f.path.split("/").pop())
@@ -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 ?? {});
@@ -16,7 +16,7 @@ import { escapeHtml, tagNameToClassName, isSchemaOnly, collectStyles } from "../
16
16
  * @returns {Promise<{ files: { path: string; content: string; tagName: string }[] }>}
17
17
  */
18
18
  export async function compileElement(sourcePath, opts = {}) {
19
- const { resolveElementPath } = /** @type {JxMutableNode} */ (opts);
19
+ const { resolveElementPath, $media: optsMedia } = /** @type {JxMutableNode} */ (opts);
20
20
  /** @type {{ path: string; content: string; tagName: string }[]} */
21
21
  const files = [];
22
22
  /** @type {Set<string>} */
@@ -82,6 +82,9 @@ export async function compileElement(sourcePath, opts = {}) {
82
82
  }
83
83
 
84
84
  const className = tagNameToClassName(tagName);
85
+ if (optsMedia) {
86
+ doc.$media = { ...optsMedia, ...doc.$media };
87
+ }
85
88
  const jsContent = emitElementModule(doc, className, elementImports);
86
89
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
87
90
  files.push({ path: outputPath, content: jsContent, tagName });