@jxsuite/compiler 0.6.1 → 0.7.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.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
40
- "@jxsuite/parser": "^0.5.5",
41
- "@jxsuite/runtime": "^0.5.5",
40
+ "@jxsuite/parser": "^0.6.2",
41
+ "@jxsuite/runtime": "^0.6.2",
42
42
  "remark-gfm": "^4.0.1",
43
43
  "remark-stringify": "^11.0.0",
44
44
  "sharp": "^0.34.5",
package/src/shared.js CHANGED
@@ -393,25 +393,7 @@ export function buildAttrs(def, scope) {
393
393
  if (lang) out += ` lang="${escapeHtml(lang)}"`;
394
394
  if (dir) out += ` dir="${escapeHtml(dir)}"`;
395
395
 
396
- if (def.style) {
397
- // Collect properties that have @media overrides — these must NOT be inline
398
- // because inline styles (specificity 1,0,0,0) always beat stylesheet @media rules.
399
- const mediaOverriddenProps = new Set();
400
- for (const [k, v] of Object.entries(def.style)) {
401
- if (k.startsWith("@") && v && typeof v === "object") {
402
- for (const prop of Object.keys(/** @type {Record<string, any>} */ (v))) {
403
- if (
404
- !prop.startsWith(":") &&
405
- !prop.startsWith(".") &&
406
- !prop.startsWith("&") &&
407
- !prop.startsWith("[")
408
- ) {
409
- mediaOverriddenProps.add(prop);
410
- }
411
- }
412
- }
413
- }
414
-
396
+ if (def.style && scope) {
415
397
  const inline = Object.entries(def.style)
416
398
  .filter(
417
399
  ([k, v]) =>
@@ -420,9 +402,10 @@ export function buildAttrs(def, scope) {
420
402
  !k.startsWith("&") &&
421
403
  !k.startsWith("[") &&
422
404
  !k.startsWith("@") &&
423
- !mediaOverriddenProps.has(k) &&
424
405
  v !== null &&
425
- typeof v !== "object",
406
+ typeof v !== "object" &&
407
+ typeof v === "string" &&
408
+ isTemplateString(v),
426
409
  )
427
410
  .map(([k, v]) => {
428
411
  const value = resolveStaticValue(v, scope);
@@ -432,7 +415,6 @@ export function buildAttrs(def, scope) {
432
415
  .join("; ");
433
416
  if (inline) out += ` style="${inline}"`;
434
417
  }
435
-
436
418
  if (def.attributes) {
437
419
  for (const [k, v] of Object.entries(def.attributes)) {
438
420
  const value = resolveStaticValue(v, scope);
@@ -451,8 +433,10 @@ export function buildAttrs(def, scope) {
451
433
  if (!def.attributes?.decoding) out += ` decoding="async"`;
452
434
  }
453
435
 
454
- if (def.$props && typeof def.$props === "object") {
455
- out += ` data-jx-props="${escapeHtml(JSON.stringify(def.$props))}"`;
436
+ if (def.$static) {
437
+ out += ` data-jx-static`;
438
+ } else if (def.$prerendered) {
439
+ out += ` data-jx-prerendered`;
456
440
  }
457
441
 
458
442
  return out;
@@ -506,7 +490,16 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
506
490
  if (projectStyle && typeof projectStyle === "object") {
507
491
  for (const [key, val] of Object.entries(projectStyle)) {
508
492
  if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
509
- // Standalone selector (e.g. `.dark`)
493
+ // Standalone selector (e.g. `.dark`, `:focus-visible`)
494
+ rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
495
+ } else if (
496
+ val !== null &&
497
+ typeof val === "object" &&
498
+ !Array.isArray(val) &&
499
+ !key.startsWith("@") &&
500
+ !key.startsWith("--")
501
+ ) {
502
+ // Element selector with object value (e.g. `html`, `*`)
510
503
  rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
511
504
  } else if (key.startsWith("@")) {
512
505
  // @media block
@@ -555,23 +548,19 @@ export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
555
548
  * @param {string} [_parentSel]
556
549
  * @param {{ n: number }} [counter]
557
550
  */
558
- export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter = { n: 0 }) {
551
+ export function collectStyles(
552
+ def,
553
+ rules,
554
+ mediaQueries,
555
+ _parentSel = "",
556
+ counter = { n: 0 },
557
+ prefix = "jx",
558
+ ) {
559
559
  if (!def || typeof def !== "object") return;
560
560
 
561
561
  if (def.style) {
562
- // Check if this element needs CSS rules (media queries, pseudo-classes, etc.)
563
- const needsCSS = Object.keys(def.style).some(
564
- (k) =>
565
- k.startsWith("@") ||
566
- k.startsWith(":") ||
567
- k.startsWith(".") ||
568
- k.startsWith("&") ||
569
- k.startsWith("["),
570
- );
571
-
572
- // Auto-scope elements that need CSS rules but lack a unique selector
573
- if (needsCSS && !def.id && !def.className) {
574
- def.className = `jx-${counter.n++}`;
562
+ if (!def.id && !def.className) {
563
+ def.className = `${prefix}-${counter.n++}`;
575
564
  }
576
565
  }
577
566
 
@@ -582,36 +571,22 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter
582
571
  : (def.tagName ?? "*");
583
572
 
584
573
  if (def.style) {
585
- // Collect properties that have @media overrides — these are excluded from
586
- // inline styles in buildAttrs(), so we emit them as base CSS rules here.
587
- const mediaOverriddenProps = new Set();
588
- for (const [k, v] of Object.entries(def.style)) {
589
- if (k.startsWith("@") && v && typeof v === "object") {
590
- for (const p of Object.keys(/** @type {Record<string, any>} */ (v))) {
591
- if (
592
- !p.startsWith(":") &&
593
- !p.startsWith(".") &&
594
- !p.startsWith("&") &&
595
- !p.startsWith("[")
596
- ) {
597
- mediaOverriddenProps.add(p);
598
- }
599
- }
600
- }
574
+ const baseDecls = [];
575
+ for (const [prop, value] of Object.entries(def.style)) {
576
+ if (
577
+ prop.startsWith(":") ||
578
+ prop.startsWith(".") ||
579
+ prop.startsWith("&") ||
580
+ prop.startsWith("[") ||
581
+ prop.startsWith("@")
582
+ )
583
+ continue;
584
+ if (value === null || typeof value === "object") continue;
585
+ if (typeof value === "string" && isTemplateString(value)) continue;
586
+ baseDecls.push(` ${camelToKebab(prop)}: ${value};`);
601
587
  }
602
-
603
- // Emit base CSS rules for media-overridden properties
604
- if (mediaOverriddenProps.size > 0) {
605
- const baseDecls = [];
606
- for (const p of mediaOverriddenProps) {
607
- const v = def.style[p];
608
- if (v !== null && v !== undefined && typeof v !== "object") {
609
- baseDecls.push(`${camelToKebab(p)}: ${v}`);
610
- }
611
- }
612
- if (baseDecls.length > 0) {
613
- rules.push(`${selector} { ${baseDecls.join("; ")} }`);
614
- }
588
+ if (baseDecls.length > 0) {
589
+ rules.push(`${selector} {\n${baseDecls.join("\n")}\n}`);
615
590
  }
616
591
 
617
592
  for (const [prop, val] of Object.entries(def.style)) {
@@ -649,7 +624,7 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter
649
624
 
650
625
  if (Array.isArray(def.children)) {
651
626
  def.children.forEach((/** @type {any} */ c) => {
652
- collectStyles(c, rules, mediaQueries, selector, counter);
627
+ collectStyles(c, rules, mediaQueries, selector, counter, prefix);
653
628
  });
654
629
  }
655
630
  }
@@ -785,7 +760,13 @@ const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area"
785
760
  * @returns {string}
786
761
  */
787
762
  export function renderStaticNode(node, scope, slotContent = null) {
788
- if (typeof node === "string") return escapeHtml(node);
763
+ if (typeof node === "string") {
764
+ if (isTemplateString(node) && scope) {
765
+ const val = evaluateStaticTemplate(node, scope);
766
+ return val != null ? escapeHtml(String(val)) : escapeHtml(node);
767
+ }
768
+ return escapeHtml(node);
769
+ }
789
770
  if (typeof node === "number" || typeof node === "boolean") return escapeHtml(String(node));
790
771
  if (Array.isArray(node))
791
772
  return node.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent)).join("\n");
@@ -911,30 +892,63 @@ function _isStaticNode(node) {
911
892
  }
912
893
 
913
894
  /**
914
- * Generate a CSS rule block for a component's root-level styles. Uses the tag name as the selector.
915
- * Skips pseudo-selectors, media queries, nested rules, and template strings (runtime-only).
895
+ * Generate CSS rules for a component: host-level styles using tag selector, plus inner element
896
+ * styles using .jx-N selectors via collectStyles.
916
897
  *
917
898
  * @param {string} tagName - The custom element tag name (used as CSS selector)
918
899
  * @param {any} styleDef - The component's style object
900
+ * @param {any} [doc] - The full component document (for walking children)
901
+ * @param {Record<string, any>} [mediaQueries] - Project media query definitions
919
902
  * @returns {string} CSS text, or empty string if no styles
920
903
  */
921
- export function buildComponentCSS(tagName, styleDef) {
922
- if (!styleDef || typeof styleDef !== "object") return "";
904
+ export function buildComponentCSS(tagName, styleDef, doc = null, mediaQueries = {}) {
923
905
  /** @type {string[]} */
924
- const decls = [];
925
- for (const [prop, value] of Object.entries(styleDef)) {
926
- if (
927
- prop.startsWith(":") ||
928
- prop.startsWith(".") ||
929
- prop.startsWith("&") ||
930
- prop.startsWith("[") ||
931
- prop.startsWith("@")
932
- )
933
- continue;
934
- if (value === null || typeof value === "object") continue;
935
- if (typeof value === "string" && isTemplateString(value)) continue;
936
- decls.push(` ${camelToKebab(prop)}: ${value};`);
906
+ const rules = [];
907
+
908
+ if (styleDef && typeof styleDef === "object") {
909
+ /** @type {string[]} */
910
+ const decls = [];
911
+ for (const [prop, value] of Object.entries(styleDef)) {
912
+ if (
913
+ prop.startsWith(":") ||
914
+ prop.startsWith(".") ||
915
+ prop.startsWith("&") ||
916
+ prop.startsWith("[") ||
917
+ prop.startsWith("@")
918
+ )
919
+ continue;
920
+ if (value === null || typeof value === "object") continue;
921
+ if (typeof value === "string" && isTemplateString(value)) continue;
922
+ decls.push(` ${camelToKebab(prop)}: ${value};`);
923
+ }
924
+ if (decls.length > 0) {
925
+ rules.push(`${tagName} {\n${decls.join("\n")}\n}`);
926
+ }
927
+
928
+ for (const [prop, val] of Object.entries(styleDef)) {
929
+ if (prop.startsWith("@")) {
930
+ const query = prop.startsWith("@--")
931
+ ? (mediaQueries[prop.slice(1)] ?? prop.slice(1))
932
+ : prop.slice(1);
933
+ rules.push(`@media ${query} { ${tagName} { ${toCSSText(/** @type {any} */ (val))} } }`);
934
+ } else if (
935
+ prop.startsWith(":") ||
936
+ prop.startsWith(".") ||
937
+ prop.startsWith("&") ||
938
+ prop.startsWith("[")
939
+ ) {
940
+ const resolved = prop.startsWith("&") ? prop.replace("&", tagName) : `${tagName}${prop}`;
941
+ rules.push(`${resolved} { ${toCSSText(/** @type {any} */ (val))} }`);
942
+ }
943
+ }
937
944
  }
938
- if (decls.length === 0) return "";
939
- return `${tagName} {\n${decls.join("\n")}\n}\n`;
945
+
946
+ if (doc && Array.isArray(doc.children)) {
947
+ const counter = { n: 0 };
948
+ for (const child of doc.children) {
949
+ collectStyles(child, rules, mediaQueries, "", counter, tagName);
950
+ }
951
+ }
952
+
953
+ return rules.length > 0 ? rules.join("\n") + "\n" : "";
940
954
  }
@@ -126,12 +126,12 @@ async function getMarkdownModule() {
126
126
  async function loadMarkdownEntry(filePath, directiveOptions) {
127
127
  const { MarkdownFile } = await getMarkdownModule();
128
128
  const file = new MarkdownFile({ src: filePath, directiveOptions });
129
- const result = await file.resolve();
129
+ const result = file.resolve();
130
130
  return {
131
131
  id: result.slug,
132
132
  data: result.frontmatter,
133
133
  body: readFileSync(filePath, "utf-8"),
134
- rendered: result.$body,
134
+ $children: result.$children,
135
135
  _meta: {
136
136
  excerpt: result.$excerpt,
137
137
  toc: result.$toc,
@@ -74,18 +74,11 @@ export function saveCache(projectRoot, cache) {
74
74
  *
75
75
  * @param {CacheManifest} cache
76
76
  * @param {string} key
77
- * @param {string} outDir - Absolute path to build output dir
78
77
  * @returns {ImageManifest | null}
79
78
  */
80
- export function getCached(cache, key, outDir) {
79
+ export function getCached(cache, key) {
81
80
  const entry = cache.entries[key];
82
81
  if (!entry) return null;
83
-
84
- const allExist = entry.manifest.variants.every((v) =>
85
- existsSync(resolve(outDir, v.outputPath.slice(1))),
86
- );
87
- if (!allExist) return null;
88
-
89
82
  return entry.manifest;
90
83
  }
91
84
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { existsSync } from "node:fs";
10
- import { resolve, extname } from "node:path";
10
+ import { resolve, extname, basename } from "node:path";
11
11
  import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.js";
12
12
  import { getCached, setCached } from "./image-cache.js";
13
13
 
@@ -22,6 +22,29 @@ import { getCached, setCached } from "./image-cache.js";
22
22
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
23
23
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
24
24
 
25
+ /**
26
+ * @param {string} absoluteSrc
27
+ * @param {string} src
28
+ * @param {ImageConfig} config
29
+ * @param {string} outDir
30
+ * @param {CacheManifest} cache
31
+ * @returns {Promise<ImageManifest>}
32
+ */
33
+ async function resolveManifest(absoluteSrc, src, config, outDir, cache) {
34
+ const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
35
+ const cached = getCached(cache, key);
36
+
37
+ if (cached) {
38
+ const allExist = cached.variants.every((v) => existsSync(v.absolutePath));
39
+ if (allExist) return cached;
40
+ }
41
+
42
+ if (!cached) console.log(` Optimizing ${basename(absoluteSrc)}...`);
43
+ const manifest = await processImage(absoluteSrc, outDir, config);
44
+ setCached(cache, key, src, manifest);
45
+ return manifest;
46
+ }
47
+
25
48
  /**
26
49
  * Check if a src value should be skipped for optimization.
27
50
  *
@@ -91,6 +114,17 @@ async function walkAndTransform(node, config, projectRoot, outDir, cache, imageR
91
114
  await transformImgNode(node, config, projectRoot, outDir, cache, imageRefs);
92
115
  }
93
116
 
117
+ if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
118
+ node.innerHTML = await transformInnerHtmlImages(
119
+ node.innerHTML,
120
+ config,
121
+ projectRoot,
122
+ outDir,
123
+ cache,
124
+ imageRefs,
125
+ );
126
+ }
127
+
94
128
  if (Array.isArray(node.children)) {
95
129
  for (const child of node.children) {
96
130
  await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
@@ -119,11 +153,7 @@ async function transformImgNode(node, config, projectRoot, outDir, cache, imageR
119
153
  let manifest = imageRefs.get(absoluteSrc);
120
154
 
121
155
  if (!manifest) {
122
- const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
123
- const cached = getCached(cache, key, outDir);
124
- manifest = cached ?? (await processImage(absoluteSrc, outDir, config));
125
-
126
- setCached(cache, key, src, manifest);
156
+ manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
127
157
  imageRefs.set(absoluteSrc, manifest);
128
158
  }
129
159
 
@@ -147,3 +177,71 @@ async function transformImgNode(node, config, projectRoot, outDir, cache, imageR
147
177
  node.attributes.decoding = "async";
148
178
  }
149
179
  }
180
+
181
+ const IMG_TAG_RE = /<img\b([^>]*)>/gi;
182
+ const SRC_ATTR_RE = /\bsrc="([^"]*)"/;
183
+ const SRCSET_ATTR_RE = /\bsrcset="/;
184
+ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
185
+
186
+ /**
187
+ * Transform `<img>` tags embedded in pre-rendered innerHTML strings.
188
+ *
189
+ * @param {string} html
190
+ * @param {ImageConfig} config
191
+ * @param {string} projectRoot
192
+ * @param {string} outDir
193
+ * @param {CacheManifest} cache
194
+ * @param {Map<string, ImageManifest>} imageRefs
195
+ * @returns {Promise<string>}
196
+ */
197
+ async function transformInnerHtmlImages(html, config, projectRoot, outDir, cache, imageRefs) {
198
+ /** @type {{ match: string; replacement: string }[]} */
199
+ const replacements = [];
200
+
201
+ for (const m of html.matchAll(IMG_TAG_RE)) {
202
+ const tag = m[0];
203
+ const attrs = m[1];
204
+
205
+ if (SRCSET_ATTR_RE.test(attrs)) continue;
206
+ if (DATA_NO_OPT_RE.test(attrs)) continue;
207
+
208
+ const srcMatch = attrs.match(SRC_ATTR_RE);
209
+ if (!srcMatch) continue;
210
+
211
+ const src = srcMatch[1];
212
+ if (shouldSkip(src)) continue;
213
+
214
+ const absoluteSrc = resolveImagePath(src, projectRoot);
215
+ if (!existsSync(absoluteSrc)) continue;
216
+
217
+ let manifest = imageRefs.get(absoluteSrc);
218
+ if (!manifest) {
219
+ manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
220
+ imageRefs.set(absoluteSrc, manifest);
221
+ }
222
+
223
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
224
+ const srcset = buildSrcset(manifest.variants, preferredFormat);
225
+ if (!srcset) continue;
226
+
227
+ let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
228
+ if (!/\bwidth=/.test(attrs) && manifest.original.width) {
229
+ extra += ` width="${manifest.original.width}"`;
230
+ }
231
+ if (!/\bheight=/.test(attrs) && manifest.original.height) {
232
+ extra += ` height="${manifest.original.height}"`;
233
+ }
234
+ if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
235
+ if (!/\bloading=/.test(attrs)) extra += ` loading="lazy"`;
236
+ if (!/\bdecoding=/.test(attrs)) extra += ` decoding="async"`;
237
+ }
238
+
239
+ replacements.push({ match: tag, replacement: `<img${attrs}${extra}>` });
240
+ }
241
+
242
+ let result = html;
243
+ for (const { match, replacement } of replacements) {
244
+ result = result.replace(match, replacement);
245
+ }
246
+ return result;
247
+ }
@@ -54,7 +54,8 @@ export function resolveLayout(pageDoc, projectConfig, projectRoot) {
54
54
  }
55
55
 
56
56
  // Distribute page children into layout slots
57
- const pageChildren = pageDoc.children ?? [];
57
+ const rawChildren = pageDoc.children ?? [];
58
+ const pageChildren = typeof rawChildren === "string" ? [rawChildren] : rawChildren;
58
59
  const merged = deepClone(layoutDoc);
59
60
 
60
61
  distributeSlots(merged, pageChildren);
@@ -32,6 +32,16 @@ const SKIP_PROTOTYPES = new Set([
32
32
  "ContentEntry",
33
33
  ]);
34
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
+ };
44
+
35
45
  /**
36
46
  * Keys reserved by the Jx prototype system — stripped before passing config to the external class
37
47
  * constructor. Mirrors runtime's EXTERNAL_RESERVED.
@@ -73,8 +83,8 @@ export async function resolvePrototypes(doc, route, projectRoot) {
73
83
 
74
84
  // Look up in imports if no $src already set
75
85
  if (!def.$src) {
76
- const mapped = imports[def.$prototype];
77
- if (!mapped) continue; // not in imports — leave for runtime
86
+ const mapped = imports[def.$prototype] ?? BUILTIN_CLASS_MAPPINGS[def.$prototype];
87
+ if (!mapped) continue;
78
88
  def.$src = mapped;
79
89
  }
80
90