@jxsuite/compiler 0.1.0 → 0.5.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,8 +1,13 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.1.0",
3
+ "version": "0.5.1",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jxsuite/jx.git",
9
+ "directory": "packages/compiler"
10
+ },
6
11
  "bin": {
7
12
  "jx": "./src/cli.js"
8
13
  },
@@ -13,24 +18,30 @@
13
18
  "type": "module",
14
19
  "exports": {
15
20
  ".": "./src/compiler.js",
16
- "./site": "./src/site/site-build.js",
17
- "./site-loader": "./src/site/site-loader.js",
18
- "./pages-discovery": "./src/site/pages-discovery.js",
19
- "./layout-resolver": "./src/site/layout-resolver.js",
20
- "./head-merger": "./src/site/head-merger.js",
21
+ "./content-loader": "./src/site/content-loader.js",
21
22
  "./context-injection": "./src/site/context-injection.js",
22
- "./content-loader": "./src/site/content-loader.js"
23
+ "./head-merger": "./src/site/head-merger.js",
24
+ "./layout-resolver": "./src/site/layout-resolver.js",
25
+ "./pages-discovery": "./src/site/pages-discovery.js",
26
+ "./site": "./src/site/site-build.js",
27
+ "./site-loader": "./src/site/site-loader.js"
28
+ },
29
+ "publishConfig": {
30
+ "provenance": true
23
31
  },
24
32
  "scripts": {
25
- "test": "bun test",
26
33
  "build": "bun build src/compiler.js --outdir=dist --format=esm --minify",
27
34
  "build:site": "bun src/cli.js build",
35
+ "test": "bun test",
28
36
  "upgrade": "bunx npm-check-updates -u && bun install"
29
37
  },
30
38
  "dependencies": {
31
39
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
32
- "@jxsuite/parser": "0.0.1",
33
- "@jxsuite/runtime": "0.0.1"
40
+ "@jxsuite/parser": "workspace:^",
41
+ "@jxsuite/runtime": "workspace:^",
42
+ "remark-gfm": "^4.0.1",
43
+ "remark-stringify": "^11.0.0",
44
+ "unified": "^11.0.5"
34
45
  },
35
46
  "devDependencies": {
36
47
  "@happy-dom/global-registrator": "^20.9.0"
package/src/compiler.js CHANGED
@@ -54,10 +54,21 @@ export async function compile(sourcePath, opts = {}) {
54
54
  title = "Jx App",
55
55
  reactivitySrc = DEFAULT_REACTIVITY_SRC,
56
56
  litHtmlSrc = DEFAULT_LIT_HTML_SRC,
57
+ projectStyle = null,
57
58
  } = opts;
58
59
 
59
- const raw =
60
- typeof sourcePath === "string" ? JSON.parse(readFileSync(sourcePath, "utf8")) : sourcePath;
60
+ let raw;
61
+ if (typeof sourcePath === "string") {
62
+ const source = readFileSync(sourcePath, "utf8");
63
+ if (sourcePath.endsWith(".md")) {
64
+ const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
65
+ raw = transpileJxMarkdown(source);
66
+ } else {
67
+ raw = JSON.parse(source);
68
+ }
69
+ } else {
70
+ raw = sourcePath;
71
+ }
61
72
 
62
73
  // Route 0: .class.json schema-defined class → JS class module
63
74
  if (raw.$prototype === "Class") {
@@ -72,7 +83,7 @@ export async function compile(sourcePath, opts = {}) {
72
83
 
73
84
  // Route 1: Fully static → plain HTML/CSS
74
85
  if (!isDynamic(raw)) {
75
- return compileStaticPage(raw, { title, reactivitySrc, litHtmlSrc });
86
+ return compileStaticPage(raw, { title, reactivitySrc, litHtmlSrc, projectStyle });
76
87
  }
77
88
 
78
89
  // Route 2: Custom element tagName (contains hyphen) → lit-html web component
@@ -109,7 +120,7 @@ export async function compile(sourcePath, opts = {}) {
109
120
  }
110
121
 
111
122
  // Route 3: Dynamic with standard tagName → pre-rendered HTML + reactive bindings
112
- return compileClient(raw, { title, reactivitySrc, litHtmlSrc });
123
+ return compileClient(raw, { title, reactivitySrc, litHtmlSrc, projectStyle });
113
124
  }
114
125
 
115
126
  // ─── CLI ──────────────────────────────────────────────────────────────────────
package/src/shared.js CHANGED
@@ -325,8 +325,19 @@ export function resolveRefValue(refValue, scope) {
325
325
  * @returns {any}
326
326
  */
327
327
  export function evaluateStaticTemplate(str, scope) {
328
- const fn = new Function("state", "$map", `return \`${str}\``);
329
- return fn(scope, scope?.$map);
328
+ try {
329
+ // When the entire string is a single ${...} expression, evaluate it directly
330
+ // to preserve the return type (boolean, number, etc.) instead of stringifying.
331
+ const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
332
+ if (singleExprMatch) {
333
+ const fn = new Function("state", "$map", `return (${singleExprMatch[1]})`);
334
+ return fn(scope, scope?.$map);
335
+ }
336
+ const fn = new Function("state", "$map", `return \`${str}\``);
337
+ return fn(scope, scope?.$map);
338
+ } catch {
339
+ return null;
340
+ }
330
341
  }
331
342
 
332
343
  /**
@@ -435,6 +446,10 @@ export function buildAttrs(def, scope) {
435
446
  }
436
447
  }
437
448
 
449
+ if (def.$props && typeof def.$props === "object") {
450
+ out += ` data-jx-props="${escapeHtml(JSON.stringify(def.$props))}"`;
451
+ }
452
+
438
453
  return out;
439
454
  }
440
455
 
@@ -476,10 +491,54 @@ export function buildInner(def, raw, context, childCompiler) {
476
491
  * @param {Record<string, any>} [mediaQueries]
477
492
  * @returns {string}
478
493
  */
479
- export function compileStyles(doc, mediaQueries = {}) {
494
+ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
480
495
  /** @type {string[]} */
481
496
  const rules = [];
482
- collectStyles(doc, rules, mediaQueries, "");
497
+
498
+ // Emit project-level (site-wide) styles — CSS custom properties go on :root,
499
+ // everything else on body. Project-level style is implicitly :root, so a
500
+ // flat object like { "--bg": "#000", "margin": "0" } is the expected format.
501
+ if (projectStyle && typeof projectStyle === "object") {
502
+ for (const [key, val] of Object.entries(projectStyle)) {
503
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
504
+ // Standalone selector (e.g. `.dark`)
505
+ rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
506
+ } else if (key.startsWith("@")) {
507
+ // @media block
508
+ const query = key.startsWith("@--")
509
+ ? (mediaQueries[key.slice(1)] ?? key.slice(1))
510
+ : key.slice(1);
511
+ rules.push(`@media ${query} { body { ${toCSSText(/** @type {any} */ (val))} } }`);
512
+ }
513
+ }
514
+ // Collect CSS custom properties into :root {}
515
+ /** @type {Record<string, any>} */
516
+ const rootProps = {};
517
+ // Collect direct CSS properties into body {}
518
+ /** @type {Record<string, any>} */
519
+ const bodyProps = {};
520
+ for (const [key, val] of Object.entries(projectStyle)) {
521
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@"))
522
+ continue;
523
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) continue;
524
+ if (key.startsWith("--")) {
525
+ rootProps[key] = val;
526
+ } else {
527
+ bodyProps[key] = val;
528
+ }
529
+ }
530
+ const rootCSS = toCSSText(/** @type {any} */ (rootProps));
531
+ if (rootCSS) {
532
+ rules.push(`:root { ${rootCSS} }`);
533
+ }
534
+ const bodyCSS = toCSSText(/** @type {any} */ (bodyProps));
535
+ if (bodyCSS) {
536
+ rules.push(`body { ${bodyCSS} }`);
537
+ }
538
+ }
539
+
540
+ const counter = { n: 0 };
541
+ collectStyles(doc, rules, mediaQueries, "", counter);
483
542
  if (rules.length === 0) return "";
484
543
  return `<style>\n${rules.join("\n")}\n</style>`;
485
544
  }
@@ -489,10 +548,28 @@ export function compileStyles(doc, mediaQueries = {}) {
489
548
  * @param {string[]} rules
490
549
  * @param {Record<string, any>} mediaQueries
491
550
  * @param {string} [_parentSel]
551
+ * @param {{ n: number }} [counter]
492
552
  */
493
- export function collectStyles(def, rules, mediaQueries, _parentSel = "") {
553
+ export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter = { n: 0 }) {
494
554
  if (!def || typeof def !== "object") return;
495
555
 
556
+ if (def.style) {
557
+ // Check if this element needs CSS rules (media queries, pseudo-classes, etc.)
558
+ const needsCSS = Object.keys(def.style).some(
559
+ (k) =>
560
+ k.startsWith("@") ||
561
+ k.startsWith(":") ||
562
+ k.startsWith(".") ||
563
+ k.startsWith("&") ||
564
+ k.startsWith("["),
565
+ );
566
+
567
+ // Auto-scope elements that need CSS rules but lack a unique selector
568
+ if (needsCSS && !def.id && !def.className) {
569
+ def.className = `jx-${counter.n++}`;
570
+ }
571
+ }
572
+
496
573
  const selector = def.id
497
574
  ? `#${def.id}`
498
575
  : def.className
@@ -567,7 +644,7 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "") {
567
644
 
568
645
  if (Array.isArray(def.children)) {
569
646
  def.children.forEach((/** @type {any} */ c) => {
570
- collectStyles(c, rules, mediaQueries, selector);
647
+ collectStyles(c, rules, mediaQueries, selector, counter);
571
648
  });
572
649
  }
573
650
  }
@@ -688,3 +765,171 @@ function _walkServerEntries(def, entries) {
688
765
  def.children.forEach((/** @type {any} */ c) => _walkServerEntries(c, entries));
689
766
  }
690
767
  }
768
+
769
+ // ─── Component pre-rendering ─────────────────────────────────────────────────
770
+
771
+ /** @type {Set<string>} */
772
+ const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area", "col"]);
773
+
774
+ /**
775
+ * Recursively render a Jx node tree to static HTML for pre-rendering.
776
+ *
777
+ * @param {any} node
778
+ * @param {any} scope
779
+ * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
780
+ * @returns {string}
781
+ */
782
+ export function renderStaticNode(node, scope, slotContent = null) {
783
+ if (typeof node === "string") return escapeHtml(node);
784
+ if (typeof node === "number" || typeof node === "boolean") return escapeHtml(String(node));
785
+ if (Array.isArray(node))
786
+ return node.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent)).join("\n");
787
+ if (!node || typeof node !== "object") return "";
788
+
789
+ // Skip mapped arrays — can't pre-render dynamic lists
790
+ if (node.$prototype === "Array") return "";
791
+
792
+ const tag = node.tagName ?? "div";
793
+
794
+ // Replace <slot> with provided slot content
795
+ if (tag === "slot" && slotContent != null) return slotContent;
796
+
797
+ const attrs = buildAttrs(node, scope);
798
+
799
+ if (SELF_CLOSING.has(tag)) return `<${tag}${attrs}>`;
800
+
801
+ let inner = "";
802
+ if (node.textContent !== undefined) {
803
+ const val = resolveStaticValue(node.textContent, scope);
804
+ inner = val != null ? escapeHtml(String(val)) : "";
805
+ } else if (node.innerHTML) {
806
+ const val = resolveStaticValue(node.innerHTML, scope);
807
+ inner = val != null ? val : node.innerHTML;
808
+ } else if (Array.isArray(node.children)) {
809
+ inner = node.children
810
+ .map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent))
811
+ .join("\n");
812
+ }
813
+
814
+ return `<${tag}${attrs}>${inner}</${tag}>`;
815
+ }
816
+
817
+ /**
818
+ * Pre-render a component definition to static HTML for its inner content.
819
+ *
820
+ * @param {any} doc - Component JSON definition
821
+ * @param {Record<string, any> | null} [propsOverride] - Instance-specific prop values to merge into
822
+ * state
823
+ * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
824
+ * @returns {string} The pre-rendered innerHTML
825
+ */
826
+ export function preRenderComponentHtml(doc, propsOverride = null, slotContent = null) {
827
+ let stateDefs = doc.state ?? {};
828
+ if (propsOverride) {
829
+ stateDefs = { ...stateDefs };
830
+ for (const [key, value] of Object.entries(propsOverride)) {
831
+ if (key in stateDefs) {
832
+ const existing = stateDefs[key];
833
+ // If the existing definition is an expanded signal with "default", override the default
834
+ if (
835
+ existing &&
836
+ typeof existing === "object" &&
837
+ !Array.isArray(existing) &&
838
+ "default" in existing
839
+ ) {
840
+ stateDefs[key] = { ...existing, default: value };
841
+ } else {
842
+ stateDefs[key] = value;
843
+ }
844
+ } else {
845
+ stateDefs[key] = value;
846
+ }
847
+ }
848
+ }
849
+ const scope = buildInitialScope(stateDefs, null);
850
+ if (!Array.isArray(doc.children)) return "";
851
+ return doc.children
852
+ .map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent))
853
+ .join("\n");
854
+ }
855
+
856
+ /**
857
+ * Check if a component definition is fully static (no runtime behavior needed).
858
+ *
859
+ * Returns true when: no event handlers, no $prototype entries (Functions, Request, Storage), no
860
+ * $ref values. Conservative — returns false when uncertain.
861
+ *
862
+ * @param {any} doc - Component JSON definition
863
+ * @returns {boolean}
864
+ */
865
+ export function isComponentFullyStatic(doc) {
866
+ return _isStaticNode(doc);
867
+ }
868
+
869
+ /**
870
+ * @param {any} node
871
+ * @returns {boolean}
872
+ */
873
+ function _isStaticNode(node) {
874
+ if (!node || typeof node !== "object") return true;
875
+ if (Array.isArray(node)) return node.every(_isStaticNode);
876
+
877
+ // Check for $prototype (Functions, Request, Storage, etc.)
878
+ if (node.$prototype) return false;
879
+ // Check for $ref
880
+ if (node.$ref) return false;
881
+
882
+ // Check state entries
883
+ if (node.state) {
884
+ for (const def of Object.values(node.state)) {
885
+ if (!def || typeof def !== "object") continue;
886
+ const d = /** @type {any} */ (def);
887
+ if (d.$prototype) return false;
888
+ if (d.$ref) return false;
889
+ }
890
+ }
891
+
892
+ // Check for event handlers
893
+ for (const key of Object.keys(node)) {
894
+ if (key.startsWith("on") && key !== "observedAttributes") return false;
895
+ }
896
+
897
+ // Recurse into children
898
+ if (Array.isArray(node.children)) {
899
+ if (!node.children.every(_isStaticNode)) return false;
900
+ } else if (node.children && typeof node.children === "object") {
901
+ // children descriptor object ($prototype: "Array", etc.)
902
+ if (node.children.$prototype) return false;
903
+ }
904
+
905
+ return true;
906
+ }
907
+
908
+ /**
909
+ * Generate a CSS rule block for a component's root-level styles. Uses the tag name as the selector.
910
+ * Skips pseudo-selectors, media queries, nested rules, and template strings (runtime-only).
911
+ *
912
+ * @param {string} tagName - The custom element tag name (used as CSS selector)
913
+ * @param {any} styleDef - The component's style object
914
+ * @returns {string} CSS text, or empty string if no styles
915
+ */
916
+ export function buildComponentCSS(tagName, styleDef) {
917
+ if (!styleDef || typeof styleDef !== "object") return "";
918
+ /** @type {string[]} */
919
+ const decls = [];
920
+ for (const [prop, value] of Object.entries(styleDef)) {
921
+ if (
922
+ prop.startsWith(":") ||
923
+ prop.startsWith(".") ||
924
+ prop.startsWith("&") ||
925
+ prop.startsWith("[") ||
926
+ prop.startsWith("@")
927
+ )
928
+ continue;
929
+ if (value === null || typeof value === "object") continue;
930
+ if (typeof value === "string" && isTemplateString(value)) continue;
931
+ decls.push(` ${camelToKebab(prop)}: ${value};`);
932
+ }
933
+ if (decls.length === 0) return "";
934
+ return `${tagName} {\n${decls.join("\n")}\n}\n`;
935
+ }
@@ -97,14 +97,14 @@ function parseCSV(csv) {
97
97
  let _mdModule = null;
98
98
 
99
99
  /**
100
- * Lazily import @jxplatform/parser for Markdown support. This avoids hard dependency — only loads
101
- * when MD collections exist.
100
+ * Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
101
+ * MD collections exist.
102
102
  *
103
103
  * @returns {Promise<any>}
104
104
  */
105
105
  async function getMarkdownModule() {
106
106
  if (!_mdModule) {
107
- _mdModule = await import("@jxplatform/parser");
107
+ _mdModule = await import("@jxsuite/parser");
108
108
  }
109
109
  return _mdModule;
110
110
  }
@@ -66,8 +66,9 @@ function walkDir(dir, pagesRoot, routes) {
66
66
  continue;
67
67
  }
68
68
 
69
- // Only process .json files
70
- if (extname(entry.name) !== ".json") continue;
69
+ // Only process .json and .md files
70
+ const ext = extname(entry.name);
71
+ if (ext !== ".json" && ext !== ".md") continue;
71
72
 
72
73
  // Skip underscore-prefixed files (local components, not routes)
73
74
  if (entry.name.startsWith("_")) continue;
@@ -86,8 +87,8 @@ function walkDir(dir, pagesRoot, routes) {
86
87
  * @returns {Route}
87
88
  */
88
89
  function fileToRoute(relativePath, absolutePath) {
89
- // Remove .json extension
90
- let urlPath = relativePath.replace(/\.json$/, "");
90
+ // Remove .json or .md extension
91
+ let urlPath = relativePath.replace(/\.(json|md)$/, "");
91
92
 
92
93
  // Normalize path separators
93
94
  urlPath = urlPath.split("\\").join("/");
@@ -124,13 +125,24 @@ function fileToRoute(relativePath, absolutePath) {
124
125
  },
125
126
  );
126
127
 
127
- // Peek at the page JSON to extract $layout if present
128
+ // Peek at the page to extract $layout if present
128
129
  /** @type {string | null} */
129
130
  let $layout = null;
130
131
  try {
131
- const raw = JSON.parse(readFileSync(absolutePath, "utf8"));
132
- if (typeof raw.$layout === "string") {
133
- $layout = raw.$layout;
132
+ const source = readFileSync(absolutePath, "utf8");
133
+ if (absolutePath.endsWith(".md")) {
134
+ // Parse YAML frontmatter for $layout
135
+ const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
136
+ if (fmMatch) {
137
+ // Quick regex extraction — avoids full YAML dependency
138
+ const layoutMatch = fmMatch[1].match(/^\$layout:\s*(.+)/m);
139
+ if (layoutMatch) $layout = layoutMatch[1].trim().replace(/^['"]|['"]$/g, "");
140
+ }
141
+ } else {
142
+ const raw = JSON.parse(source);
143
+ if (typeof raw.$layout === "string") {
144
+ $layout = raw.$layout;
145
+ }
134
146
  }
135
147
  } catch {
136
148
  // Skip unreadable files — will error during compilation