@jxsuite/compiler 2.0.7 → 2.0.8

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": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@apidevtools/json-schema-ref-parser": "^16.0.0",
49
- "@jxsuite/create": "^1.3.6",
50
- "@jxsuite/runtime": "^2.0.5",
51
- "@jxsuite/schema": "^1.8.1",
49
+ "@jxsuite/create": "^1.3.7",
50
+ "@jxsuite/runtime": "^2.1.0",
51
+ "@jxsuite/schema": "^1.9.0",
52
52
  "@vue/reactivity": "3.5.41",
53
53
  "esbuild": "^0.28.2",
54
54
  "lit-html": "^3.3.3",
@@ -56,8 +56,8 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@happy-dom/global-registrator": "^20.11.6",
59
- "@jxsuite/connector": "^0.5.4",
60
- "@jxsuite/parser": "^1.5.5",
59
+ "@jxsuite/connector": "^0.5.5",
60
+ "@jxsuite/parser": "^1.5.6",
61
61
  "@types/mdast": "^4.0.4"
62
62
  }
63
63
  }
package/src/shared.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  RESERVED_KEYS,
13
13
  camelToKebab,
14
14
  pureSchemeOf,
15
+ resolveAtQuery,
15
16
  schemeSelectors,
16
17
  toCSSText,
17
18
  } from "@jxsuite/runtime";
@@ -779,6 +780,37 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
779
780
  return false;
780
781
  }
781
782
 
783
+ /**
784
+ * Whether the whole string is exactly ONE `${…}` — the interpolation that opens at index 0 also
785
+ * closes at the final character.
786
+ *
787
+ * A greedy `/^\$\{(.+)\}$/` also matched `"${a} / ${b}"`, spliced the interior into `return (a} /
788
+ * ${b)`, and the SyntaxError became a silent null: the node rendered empty, and a `$head` entry
789
+ * shipped its own template text. A brace-depth scan is exact where a tightened regex is not —
790
+ * `"${`${a}-x`}"` is still one expression, and must keep returning a raw value.
791
+ *
792
+ * @param {string} str
793
+ * @returns {boolean}
794
+ */
795
+ export function isSingleExpression(str: string): boolean {
796
+ if (!str.startsWith("${") || !str.endsWith("}")) {
797
+ return false;
798
+ }
799
+ let depth = 0;
800
+ for (let i = 1; i < str.length; i += 1) {
801
+ const c = str[i];
802
+ if (c === "{") {
803
+ depth += 1;
804
+ } else if (c === "}") {
805
+ depth -= 1;
806
+ if (depth === 0) {
807
+ return i === str.length - 1;
808
+ }
809
+ }
810
+ }
811
+ return false;
812
+ }
813
+
782
814
  /**
783
815
  * @param {string} str
784
816
  * @param {Record<string, unknown>} scope
@@ -799,8 +831,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
799
831
  const args = ["state", "$map", "$site", "$page"] as const;
800
832
  const values = [scope, scope?.$map, scope?.$site, scope?.$page];
801
833
  try {
802
- const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
803
- const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
834
+ const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
804
835
  const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
805
836
  return fn(...values);
806
837
  } catch {
@@ -1013,11 +1044,7 @@ function conditionalRuleEmitter(
1013
1044
  mediaQueries: Record<string, string>,
1014
1045
  rules: string[],
1015
1046
  ): (selector: string, props: string) => void {
1016
- const query = atKey.startsWith("@--")
1017
- ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
1018
- : atKey.startsWith("@(")
1019
- ? atKey.slice(1)
1020
- : null;
1047
+ const query = resolveAtQuery(atKey, mediaQueries);
1021
1048
  const atRule = query === null ? atKey : `@media ${query}`;
1022
1049
  const scheme = query === null ? null : pureSchemeOf(query);
1023
1050
  return (selector: string, props: string) => {
@@ -1513,6 +1540,40 @@ export function resolveStaticTagName(
1513
1540
  return expression.cases[String(key)] ?? expression.default;
1514
1541
  }
1515
1542
 
1543
+ /**
1544
+ * The content of one node: `textContent`, else `innerHTML`, else rendered `children`.
1545
+ *
1546
+ * Shared with `preRenderComponentHtml`, which returns a component's innerHTML and so cannot call
1547
+ * `renderStaticNode` (that would wrap the result in the host tag). It used to look at `children`
1548
+ * alone and return "" for anything else, so a component whose content is root-level `textContent` —
1549
+ * the shape spec.md §17.2 recommends when every child is a bare string — prerendered to nothing.
1550
+ *
1551
+ * @param {JxElement | JxMutableNode} node
1552
+ * @param {Record<string, unknown> | null} scope
1553
+ * @param {string | null} slotContent
1554
+ * @returns {string}
1555
+ */
1556
+ function renderInner(
1557
+ node: JxElement | JxMutableNode,
1558
+ scope: Record<string, unknown> | null,
1559
+ slotContent: string | null,
1560
+ ): string {
1561
+ if (node.textContent !== undefined) {
1562
+ const val = resolveStaticValue(node.textContent, scope);
1563
+ return val != null ? escapeHtml(String(val)) : "";
1564
+ }
1565
+ if (node.innerHTML) {
1566
+ const val = resolveStaticValue(node.innerHTML, scope);
1567
+ return val != null ? String(val) : (node.innerHTML as string);
1568
+ }
1569
+ if (Array.isArray(node.children)) {
1570
+ return node.children
1571
+ .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1572
+ .join("\n");
1573
+ }
1574
+ return "";
1575
+ }
1576
+
1516
1577
  /**
1517
1578
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1518
1579
  *
@@ -1587,20 +1648,7 @@ export function renderStaticNode(
1587
1648
  return `<${tag}${attrs}>`;
1588
1649
  }
1589
1650
 
1590
- let inner = "";
1591
- if (node.textContent !== undefined) {
1592
- const val = resolveStaticValue(node.textContent, scope);
1593
- inner = val != null ? escapeHtml(String(val)) : "";
1594
- } else if (node.innerHTML) {
1595
- const val = resolveStaticValue(node.innerHTML, scope);
1596
- inner = val != null ? String(val) : (node.innerHTML as string);
1597
- } else if (Array.isArray(node.children)) {
1598
- inner = node.children
1599
- .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1600
- .join("\n");
1601
- }
1602
-
1603
- return `<${tag}${attrs}>${inner}</${tag}>`;
1651
+ return `<${tag}${attrs}>${renderInner(node, scope, slotContent)}</${tag}>`;
1604
1652
  }
1605
1653
 
1606
1654
  /**
@@ -1636,12 +1684,7 @@ export function preRenderComponentHtml(
1636
1684
  }
1637
1685
  }
1638
1686
  const scope = buildInitialScope(stateDefs, null);
1639
- if (!Array.isArray(doc.children)) {
1640
- return "";
1641
- }
1642
- return doc.children
1643
- .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1644
- .join("\n");
1687
+ return renderInner(doc, scope, slotContent);
1645
1688
  }
1646
1689
 
1647
1690
  /**
@@ -15,7 +15,7 @@
15
15
  * @docs framework/build
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import { dirname, join, relative, resolve } from "node:path";
21
21
  import type { BundleResolver } from "./bundler.ts";
@@ -84,7 +84,15 @@ export interface ClientRuntime {
84
84
  * @param {string} [resolveDir] - Directory bare specifiers resolve from; defaults to this package
85
85
  * @returns {ClientRuntime}
86
86
  */
87
- export function resolveClientRuntime(resolveDir: string = import.meta.dir): ClientRuntime {
87
+ /**
88
+ * This package's own directory, resolved under both runtimes. `import.meta.dir` is Bun-only and is
89
+ * `undefined` under Node — and `bin/jx.js` carries a Node shebang, so every `bunx jx build` took
90
+ * that path, `join(undefined, …)` threw, the bare catch read it as "cannot resolve", and the import
91
+ * map fell back to the esm.sh CDN. `import.meta.dirname` is defined on both.
92
+ */
93
+ const PACKAGE_DIR = import.meta.dirname;
94
+
95
+ export function resolveClientRuntime(resolveDir: string = PACKAGE_DIR): ClientRuntime {
88
96
  const imports: Record<string, string> = {};
89
97
  const warnings: string[] = [];
90
98
  const assetPaths: string[] = [];
@@ -122,7 +130,7 @@ export function resolveClientRuntime(resolveDir: string = import.meta.dir): Clie
122
130
  export async function writeClientRuntime(
123
131
  runtime: ClientRuntime,
124
132
  outDir: string,
125
- resolveDir: string = import.meta.dir,
133
+ resolveDir: string = PACKAGE_DIR,
126
134
  ): Promise<{ written: number; errors: string[] }> {
127
135
  const errors: string[] = [];
128
136
  let written = 0;
@@ -212,7 +220,7 @@ const MAX_SUBPATH_PASSES = 10;
212
220
  */
213
221
  export async function writeRuntimeSubpaths(
214
222
  outDir: string,
215
- resolveDir: string = import.meta.dir,
223
+ resolveDir: string = PACKAGE_DIR,
216
224
  ): Promise<{ written: number; errors: string[] }> {
217
225
  const errors: string[] = [];
218
226
  const done = new Set<string>();
@@ -235,7 +243,7 @@ export async function writeRuntimeSubpaths(
235
243
 
236
244
  /** `from "lit-html/directives/class-map.js"` — the import forms a bundler emits. */
237
245
  const importedSpecifiers = async (file: string): Promise<string[]> => {
238
- const text = await Bun.file(file).text();
246
+ const text = readFileSync(file, "utf8");
239
247
  return [...text.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1] as string);
240
248
  };
241
249
 
@@ -243,9 +251,10 @@ export async function writeRuntimeSubpaths(
243
251
  let discovered = 0;
244
252
  let files: string[] = [];
245
253
  try {
246
- files = await Array.fromAsync(
247
- new Bun.Glob("**/*.js").scan({ absolute: true, cwd: assetsDir }),
248
- );
254
+ files = readdirSync(assetsDir, { recursive: true })
255
+ .map(String)
256
+ .filter((entry) => entry.endsWith(".js"))
257
+ .map((entry) => join(assetsDir, entry));
249
258
  } catch {
250
259
  return finish(outDir, mirrors, errors, written);
251
260
  }
@@ -18,6 +18,7 @@ import {
18
18
  statSync,
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
+ import { createHash } from "node:crypto";
21
22
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
22
23
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
24
  import { isNpmSpecifier, npmAssetPath, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
@@ -63,6 +64,7 @@ import {
63
64
  colorSchemePrePaintScript,
64
65
  evaluateStaticTemplate,
65
66
  isComponentFullyStatic,
67
+ isSingleExpression,
66
68
  isTemplateString,
67
69
  preRenderComponentHtml,
68
70
  pureSchemeOf,
@@ -180,7 +182,11 @@ export async function buildSite(
180
182
  const elementBundles = new Map<string, string[]>();
181
183
  const registerElementBundle = (specifiers: string[]): string => {
182
184
  const sorted = specifiers.toSorted();
183
- const key = Bun.hash(sorted.join("\u0000")).toString(36);
185
+ /*
186
+ * `node:crypto` rather than `Bun.hash`: the jx bin runs under Node, where the Bun global does
187
+ * not exist, and this line is reached by any page registering npm `$elements`.
188
+ */
189
+ const key = createHash("sha1").update(sorted.join("\u0000")).digest("hex").slice(0, 12);
184
190
  const assetPath = `/assets/elements-${key}.js`;
185
191
  elementBundles.set(assetPath, sorted);
186
192
  return assetPath;
@@ -1739,15 +1745,8 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
1739
1745
  try {
1740
1746
  const item = (scope.$map as Record<string, unknown>)?.item;
1741
1747
  const index = (scope.$map as Record<string, unknown>)?.index;
1742
- const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
1743
- if (singleExprMatch) {
1744
- const fn = new Function(
1745
- "state",
1746
- "$map",
1747
- "item",
1748
- "index",
1749
- `return (${singleExprMatch[1]})`,
1750
- ) as (
1748
+ if (isSingleExpression(str)) {
1749
+ const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
1751
1750
  state: Record<string, unknown>,
1752
1751
  $map: unknown,
1753
1752
  item: unknown,
@@ -2038,6 +2037,23 @@ function expandComponents(
2038
2037
  }
2039
2038
  }
2040
2039
 
2040
+ /*
2041
+ * A non-static instance discards its prerendered markup on upgrade and re-renders, so the
2042
+ * props have to survive as data or the element comes back with its state defaults — correct
2043
+ * content painting first and then being replaced by the wrong content. compiler.md §5.2 gives
2044
+ * connectedCallback three prop sources and this is the first of them; the compiler read it but
2045
+ * never wrote it, and lifting `props.*` into $props above removed the other channel too.
2046
+ *
2047
+ * JSON rather than re-emitted `props.*` attributes because those are strings by spec: a number
2048
+ * or an array would arrive stringified, and a key that is not lowercase would be dropped by
2049
+ * attribute-name folding.
2050
+ */
2051
+ if (!isStatic && node.$props && Object.keys(node.$props).length > 0) {
2052
+ node.attributes = {
2053
+ ...node.attributes,
2054
+ "data-jx-props": JSON.stringify(node.$props),
2055
+ };
2056
+ }
2041
2057
  delete node.$props;
2042
2058
 
2043
2059
  if (isStatic) {
@@ -593,7 +593,15 @@ export function emitElementModule(
593
593
  // Declarations emitted inside `template()`, above the `return` — inside it, so they are rebuilt
594
594
  // Per render and read the same `s` the template does.
595
595
  const hoistScope = createHoistScope();
596
- const templateBody = emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
596
+ /*
597
+ * A component whose content is root-level `textContent` has no `children`, and emitLitChildren
598
+ * returns "" for that — so the element rendered nothing, matching a prerender that dropped the
599
+ * same shape. spec.md §17.2 recommends `textContent` whenever every child is a bare string.
600
+ */
601
+ const templateBody =
602
+ !Array.isArray(doc.children) && doc.textContent !== undefined
603
+ ? ` ${toLitTextContent(doc.textContent)}`
604
+ : emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
597
605
 
598
606
  lines.push(
599
607
  // Template method