@jxsuite/compiler 2.0.7 → 3.0.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.
@@ -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";
@@ -49,7 +50,7 @@ import type { AssetMount } from "@jxsuite/schema/asset-paths";
49
50
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
50
51
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
51
52
  import { resolveLayout } from "./layout-resolver.ts";
52
- import { mergeHead, renderHead } from "./head-merger.ts";
53
+ import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
53
54
  import { unregisteredHeadRelations } from "./link-relations.ts";
54
55
  import { injectContext } from "./context-injection.ts";
55
56
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
@@ -61,8 +62,10 @@ import {
61
62
  collectServerEntries,
62
63
  collectStyles,
63
64
  colorSchemePrePaintScript,
65
+ escapeStyleText,
64
66
  evaluateStaticTemplate,
65
67
  isComponentFullyStatic,
68
+ isSingleExpression,
66
69
  isTemplateString,
67
70
  preRenderComponentHtml,
68
71
  pureSchemeOf,
@@ -97,7 +100,8 @@ import {
97
100
  } from "./i18n.ts";
98
101
  import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
99
102
  import {
100
- isPrefixSpecifier,
103
+ importMapAssets,
104
+ importMapAssetsInHtml,
101
105
  renderImportMap,
102
106
  resolveClientRuntime,
103
107
  writeClientRuntime,
@@ -158,6 +162,13 @@ export async function buildSite(
158
162
  const pagesDir = resolve(projectRoot, "pages");
159
163
  const publicDir = resolve(projectRoot, "public");
160
164
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
165
+ /*
166
+ * Browser bundles are minified unless the project opts out. Off, the six scripts jxsuite.com
167
+ * loads weigh 155 kB of source a phone has to parse before it can paint; on, 65 kB. The opt-out
168
+ * exists for two real cases: reading a deployed stack trace, and diffing `dist/` across bundler
169
+ * backends, which agree on semantics but not on minified bytes.
170
+ */
171
+ const minify = projectConfig.build.minify !== false;
161
172
 
162
173
  // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
163
174
  // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
@@ -180,7 +191,11 @@ export async function buildSite(
180
191
  const elementBundles = new Map<string, string[]>();
181
192
  const registerElementBundle = (specifiers: string[]): string => {
182
193
  const sorted = specifiers.toSorted();
183
- const key = Bun.hash(sorted.join("\u0000")).toString(36);
194
+ /*
195
+ * `node:crypto` rather than `Bun.hash`: the jx bin runs under Node, where the Bun global does
196
+ * not exist, and this line is reached by any page registering npm `$elements`.
197
+ */
198
+ const key = createHash("sha1").update(sorted.join("\u0000")).digest("hex").slice(0, 12);
184
199
  const assetPath = `/assets/elements-${key}.js`;
185
200
  elementBundles.set(assetPath, sorted);
186
201
  return assetPath;
@@ -195,6 +210,11 @@ export async function buildSite(
195
210
  */
196
211
  const clientRuntime = resolveClientRuntime();
197
212
  const runtimeImports = clientRuntime.imports;
213
+ /*
214
+ * What step 6d bundles, scanned back out of each finished page rather than recorded wherever a
215
+ * map is emitted. Several places emit one and only `injectComponentScripts` was recording, so a
216
+ * page whose interactivity came from anything else shipped a map naming files no build wrote.
217
+ */
198
218
  const runtimeAssetsUsed = new Set<string>();
199
219
  /*
200
220
  * Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
@@ -674,10 +694,18 @@ export async function buildSite(
674
694
  staticTags,
675
695
  result.files.map((f: { content: string }) => f.content).join("\n"),
676
696
  runtimeImports,
677
- runtimeAssetsUsed,
678
697
  );
679
698
  }
680
699
 
700
+ /*
701
+ * Whatever import map this page ended up with, step 6d owes it the files it names. Read from
702
+ * the finished HTML so the answer does not depend on which of the emission paths produced
703
+ * the map — the one that reported and the one that shipped were different sets (issue #227).
704
+ */
705
+ for (const assetPath of importMapAssetsInHtml(result.html)) {
706
+ runtimeAssetsUsed.add(assetPath);
707
+ }
708
+
681
709
  // Mounted assets this page actually references — scanned from the finished HTML so a
682
710
  // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
683
711
  collectAssetRefs(result.html, assetMounts, assetRefs);
@@ -856,6 +884,8 @@ export async function buildSite(
856
884
  const runtime = await writeClientRuntime(
857
885
  { ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
858
886
  outDir,
887
+ undefined,
888
+ minify,
859
889
  );
860
890
  fileCount += runtime.written;
861
891
  for (const error of runtime.errors) {
@@ -883,7 +913,7 @@ export async function buildSite(
883
913
  mkdirSync(dirname(outfile), { recursive: true });
884
914
  await bundleEntry(
885
915
  { entryPath: bundle.entryPath, outfile },
886
- { external: CLIENT_EXTERNALS, target: "browser" },
916
+ { external: CLIENT_EXTERNALS, minify, target: "browser" },
887
917
  );
888
918
  fileCount += 1;
889
919
  log(` ${bundle.specifier} → ${assetPath}`);
@@ -908,7 +938,7 @@ export async function buildSite(
908
938
  await bundleSource(
909
939
  `${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
910
940
  { outfile, resolveDir: projectRoot },
911
- { target: "browser" },
941
+ { minify, target: "browser" },
912
942
  );
913
943
  fileCount += 1;
914
944
  log(` ${specifiers.length} element(s) → ${assetPath}`);
@@ -927,7 +957,7 @@ export async function buildSite(
927
957
  * discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
928
958
  */
929
959
  if (runtimeAssetsUsed.size > 0) {
930
- const subpaths = await writeRuntimeSubpaths(outDir);
960
+ const subpaths = await writeRuntimeSubpaths(outDir, undefined, minify);
931
961
  if (subpaths.written > 0) {
932
962
  log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
933
963
  }
@@ -1089,12 +1119,36 @@ export async function buildSite(
1089
1119
  if (rules.length > 0) {
1090
1120
  log("Writing response headers...");
1091
1121
  fileCount += writeHeaders(outDir, rules);
1092
- const { adapter } = projectConfig.build;
1122
+ const { adapter, deploy } = projectConfig.build;
1093
1123
  if (adapter === "node" || adapter === "bun") {
1094
1124
  console.warn(
1095
1125
  `The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
1096
1126
  `rather than configuration — apply these headers at the reverse proxy in front of it.`,
1097
1127
  );
1128
+ } else if (adapter === undefined && deploy === undefined) {
1129
+ /*
1130
+ * `_headers` is a Cloudflare/Netlify convention, not a web standard, and the build has no
1131
+ * way to know where a plain static `dist/` is going. Most static hosts — GitHub Pages among
1132
+ * them — ignore the file entirely and serve their own `Cache-Control`, which is how a site
1133
+ * ends up shipping a year-long `immutable` rule for its content-addressed images and
1134
+ * getting ten minutes.
1135
+ *
1136
+ * A `public/CNAME` is GitHub Pages' custom-domain marker and nothing else's, so it is a
1137
+ * specific enough signal to say so out loud rather than warn every static build.
1138
+ */
1139
+ if (existsSync(resolve(publicDir, "CNAME"))) {
1140
+ console.warn(
1141
+ "GitHub Pages ignores dist/_headers and serves its own Cache-Control: max-age=600, " +
1142
+ "so the generated caching and security rules will not take effect. Deploy behind a " +
1143
+ "host that reads _headers (Cloudflare Pages, Cloudflare Workers assets, Netlify) " +
1144
+ "to apply them.",
1145
+ );
1146
+ } else {
1147
+ log(
1148
+ " note: _headers is read by Cloudflare Pages, Cloudflare Workers assets and Netlify; " +
1149
+ "other static hosts ignore it and serve their own Cache-Control.",
1150
+ );
1151
+ }
1098
1152
  }
1099
1153
  }
1100
1154
  fileCount += writeNoJekyll(outDir);
@@ -1165,7 +1219,7 @@ async function compilePage(
1165
1219
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1166
1220
 
1167
1221
  // Resolve layout (wraps page in layout with slot distribution)
1168
- const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
1222
+ const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1169
1223
 
1170
1224
  // Extract head arrays before they get lost in the merge
1171
1225
  const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
@@ -1387,7 +1441,7 @@ async function compilePage(
1387
1441
  if (slotCss.rules.length > 0) {
1388
1442
  result.html = result.html.replace(
1389
1443
  "</head>",
1390
- `<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
1444
+ `<style>\n${escapeStyleText(slotCss.rules.join("\n"))}\n</style>\n</head>`,
1391
1445
  );
1392
1446
  }
1393
1447
 
@@ -1739,15 +1793,8 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
1739
1793
  try {
1740
1794
  const item = (scope.$map as Record<string, unknown>)?.item;
1741
1795
  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 (
1796
+ if (isSingleExpression(str)) {
1797
+ const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
1751
1798
  state: Record<string, unknown>,
1752
1799
  $map: unknown,
1753
1800
  item: unknown,
@@ -2038,6 +2085,23 @@ function expandComponents(
2038
2085
  }
2039
2086
  }
2040
2087
 
2088
+ /*
2089
+ * A non-static instance discards its prerendered markup on upgrade and re-renders, so the
2090
+ * props have to survive as data or the element comes back with its state defaults — correct
2091
+ * content painting first and then being replaced by the wrong content. compiler.md §5.2 gives
2092
+ * connectedCallback three prop sources and this is the first of them; the compiler read it but
2093
+ * never wrote it, and lifting `props.*` into $props above removed the other channel too.
2094
+ *
2095
+ * JSON rather than re-emitted `props.*` attributes because those are strings by spec: a number
2096
+ * or an array would arrive stringified, and a key that is not lowercase would be dropped by
2097
+ * attribute-name folding.
2098
+ */
2099
+ if (!isStatic && node.$props && Object.keys(node.$props).length > 0) {
2100
+ node.attributes = {
2101
+ ...node.attributes,
2102
+ "data-jx-props": JSON.stringify(node.$props),
2103
+ };
2104
+ }
2041
2105
  delete node.$props;
2042
2106
 
2043
2107
  if (isStatic) {
@@ -2058,7 +2122,6 @@ function expandComponents(
2058
2122
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
2059
2123
  * @param {string} [islandSource] - Concatenated island modules emitted for this page
2060
2124
  * @param {Record<string, string>} [runtimeImports] - The import map's `imports` object
2061
- * @param {Set<string>} [runtimeAssetsUsed] - Collects the asset paths an emitted import map names
2062
2125
  * @returns {string}
2063
2126
  */
2064
2127
  function injectComponentScripts(
@@ -2068,7 +2131,6 @@ function injectComponentScripts(
2068
2131
  staticTags = new Set<string>(),
2069
2132
  islandSource = "",
2070
2133
  runtimeImports: Record<string, string> = {},
2071
- runtimeAssetsUsed = new Set<string>(),
2072
2134
  ) {
2073
2135
  // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
2074
2136
  // From inside an island's client template — in which case the tag exists solely in the island's
@@ -2081,15 +2143,31 @@ function injectComponentScripts(
2081
2143
  return html;
2082
2144
  }
2083
2145
 
2084
- // Inject CSS links in <head> for ALL components that have CSS sidecars
2085
- const cssLinks = usedTags
2086
- .filter((tag: string) => cssMap.has(tag))
2087
- .map((tag: string) => `<link rel="stylesheet" href="/components/${tag}.css">`)
2088
- .join("\n ");
2089
- let result = html;
2090
- if (cssLinks) {
2091
- result = result.replace("</head>", ` ${cssLinks}\n</head>`);
2092
- }
2146
+ /*
2147
+ * Component CSS is INLINED, not linked.
2148
+ *
2149
+ * A `<link rel="stylesheet">` per component tag is a render-blocking request per component: on
2150
+ * jxsuite.com's home page, 11 of them totalling 22 kB — the whole of its critical request chain,
2151
+ * for sheets averaging 2 kB each. The page already carries its project and page CSS inline
2152
+ * (`buildStyleBlock`), so this is the same delivery, applied consistently.
2153
+ *
2154
+ * The trade is real and deliberate: these bytes now repeat on every page instead of caching
2155
+ * across navigations. They are small, they gzip against the markup around them, and a round trip
2156
+ * on a phone costs more than the bytes do. Components in shadow mode are untouched — their sheet
2157
+ * is linked from inside the declarative shadow root and never enters `cssMap`.
2158
+ *
2159
+ * Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
2160
+ */
2161
+ const componentCss = usedTags
2162
+ .map((tag: string) => cssMap.get(tag))
2163
+ .filter((css): css is string => css !== undefined && css !== "")
2164
+ .join("\n");
2165
+ const styleBlock =
2166
+ componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
2167
+ const intoHead = (fragments: string[]) => {
2168
+ const block = fragments.filter(Boolean).join("\n ");
2169
+ return block === "" ? html : html.replace("</head>", ` ${block}\n</head>`);
2170
+ };
2093
2171
 
2094
2172
  // Only inject JS for components that have non-static instances. An island-rendered instance is
2095
2173
  // Never prerendered, so it needs its module even when the component itself is fully static —
@@ -2097,22 +2175,30 @@ function injectComponentScripts(
2097
2175
  // The static HTML: those instances are prerendered, but the ones the island creates are not.
2098
2176
  const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
2099
2177
  if (jsTags.length === 0) {
2100
- return result;
2178
+ return intoHead([styleBlock]);
2101
2179
  }
2102
2180
 
2103
2181
  // Build import map (needed for @vue/reactivity and lit-html)
2104
2182
  const importMap = renderImportMap(runtimeImports);
2105
- for (const [specifier, url] of Object.entries(runtimeImports)) {
2106
- /*
2107
- * A prefix key's value is the DIRECTORY its subpaths are served from — `/assets/lit-html/` —
2108
- * not a file any build writes. `writeClientRuntime` matches this set against exact asset
2109
- * paths, so a directory in it bundled nothing and only inflated the count this build logged.
2110
- */
2111
- if (isPrefixSpecifier(specifier) || !url.startsWith("/")) {
2112
- continue;
2113
- }
2114
- runtimeAssetsUsed.add(url);
2115
- }
2183
+ /*
2184
+ * The runtime modules THIS page needs — the hints are per page, so they are read from this
2185
+ * page's map rather than from the build-wide set, which would have every page hint at every
2186
+ * other page's runtime.
2187
+ */
2188
+ const pageRuntimeAssets = importMapAssets(runtimeImports);
2189
+
2190
+ /*
2191
+ * An import map declares where a bare specifier lives; it does not tell the browser to go get it.
2192
+ * Without these hints `/assets/vue-reactivity.js` is discovered only after a component module has
2193
+ * been fetched AND parsed — a three-deep request chain on the critical path, on a connection
2194
+ * where each hop is a round trip. The component modules are hinted too so the whole set starts
2195
+ * downloading while the HTML is still being parsed.
2196
+ */
2197
+ const preloads = [...pageRuntimeAssets, ...jsTags.map((tag: string) => `/components/${tag}.js`)]
2198
+ .map((href) => `<link rel="modulepreload" href="${href}">`)
2199
+ .join("\n ");
2200
+
2201
+ const result = intoHead([preloads, styleBlock]);
2116
2202
 
2117
2203
  const moduleScripts = jsTags
2118
2204
  .map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
@@ -17,6 +17,7 @@ import {
17
17
  escapeHtml,
18
18
  isMutating,
19
19
  isSchemaOnly,
20
+ srcExportName,
20
21
  srcImportBinding,
21
22
  tagNameToClassName,
22
23
  } from "../shared.ts";
@@ -351,11 +352,36 @@ export function emitElementModule(
351
352
 
352
353
  // Collect $src imports from state entries before emitting other imports
353
354
  const srcImportMap = new Map<string, string[]>();
355
+ /** `$lazy` `$src` modules → the (export, local binding) pairs each one supplies. */
356
+ const lazySrcMap = new Map<string, { binding: string; exportName: string }[]>();
354
357
  const defs = doc.state ?? {};
358
+ const callableRefs = collectCallableRefs(doc);
355
359
  for (const [key, def] of Object.entries(defs)) {
356
360
  const d = def as JxMutableNode;
357
361
  if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function" && d.$src) {
358
362
  const srcPath = d.$src;
363
+ if (d.$lazy === true) {
364
+ /*
365
+ * A computed reads its value synchronously, and a lazy binding can only ever hand back a
366
+ * promise. Silently producing `computed(() => Promise)` would render "[object Promise]" in
367
+ * whatever the template bound it to, so this is a build error rather than a surprise.
368
+ */
369
+ if (!callableRefs.has(key) && !LIFECYCLE_KEYS.has(key)) {
370
+ throw new Error(
371
+ `"${key}" declares "$lazy": true but is used as a computed value, not called. ` +
372
+ `A lazily loaded function resolves to a promise; a computed must resolve now. ` +
373
+ `Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
374
+ );
375
+ }
376
+ if (!lazySrcMap.has(srcPath)) {
377
+ lazySrcMap.set(srcPath, []);
378
+ }
379
+ (lazySrcMap.get(srcPath) as { binding: string; exportName: string }[]).push({
380
+ binding: key,
381
+ exportName: srcExportName(key, d),
382
+ });
383
+ continue;
384
+ }
359
385
  if (!srcImportMap.has(srcPath)) {
360
386
  srcImportMap.set(srcPath, []);
361
387
  }
@@ -380,6 +406,31 @@ export function emitElementModule(
380
406
  lines.push(
381
407
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
382
408
  `import { render, html } from 'lit-html';`,
409
+ );
410
+
411
+ /*
412
+ * `$lazy` swaps the static import for a memoized dynamic one. A static import is a download, a
413
+ * parse and an evaluate on every page that renders the component, whether or not anybody calls
414
+ * the function: jxsuite.com's search client is 70 kB of MiniSearch fetched by every visitor, most
415
+ * of whom never open search. The local binding keeps its name so every call site is unchanged;
416
+ * what changes is that it now returns a promise.
417
+ */
418
+ let lazyIndex = 0;
419
+ for (const [srcPath, entries] of lazySrcMap) {
420
+ const importPath = rewriteSrc ? rewriteSrc(srcPath) : srcPath;
421
+ const mod = `_jxLazy${lazyIndex}`;
422
+ lazyIndex += 1;
423
+ lines.push(
424
+ `let ${mod};`,
425
+ `const ${mod}Load = () => (${mod} ??= import('${importPath}'));`,
426
+ ...entries.map(
427
+ ({ binding, exportName }) =>
428
+ `const ${binding} = (...args) => ${mod}Load().then((m) => m.${exportName}(...args));`,
429
+ ),
430
+ );
431
+ }
432
+
433
+ lines.push(
383
434
  "",
384
435
  `class ${className} extends HTMLElement {`,
385
436
  // One registry for every effect the element creates — render, dynamic host styles, Request
@@ -401,7 +452,6 @@ export function emitElementModule(
401
452
  const formulaEntries: [string, JxExpressionDef][] = [];
402
453
  /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
403
454
  const templateEntries: [string, string][] = [];
404
- const callableRefs = collectCallableRefs(doc);
405
455
  for (const [key, def] of Object.entries(defs)) {
406
456
  const d = def as JxMutableNode;
407
457
  if (isExpressionDef(d)) {
@@ -593,7 +643,15 @@ export function emitElementModule(
593
643
  // Declarations emitted inside `template()`, above the `return` — inside it, so they are rebuilt
594
644
  // Per render and read the same `s` the template does.
595
645
  const hoistScope = createHoistScope();
596
- const templateBody = emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
646
+ /*
647
+ * A component whose content is root-level `textContent` has no `children`, and emitLitChildren
648
+ * returns "" for that — so the element rendered nothing, matching a prerender that dropped the
649
+ * same shape. spec.md §17.2 recommends `textContent` whenever every child is a bare string.
650
+ */
651
+ const templateBody =
652
+ !Array.isArray(doc.children) && doc.textContent !== undefined
653
+ ? ` ${toLitTextContent(doc.textContent)}`
654
+ : emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
597
655
 
598
656
  lines.push(
599
657
  // Template method
package/src/types.ts CHANGED
@@ -1,14 +1,8 @@
1
- export interface HeadMergeContext {
2
- title?: string;
3
- siteName?: string;
4
- lang?: string;
5
- charset?: string;
6
- url?: string;
7
- siteUrl?: string;
8
- pageUrl?: string;
9
- /** `rel="alternate"` links for this page's translations (site-architecture.md §13.5). */
10
- alternates?: readonly { hreflang: string; href: string }[];
11
- }
1
+ /**
2
+ * `HeadMergeContext` moved to `@jxsuite/site/head-merger` with the merger itself — a browser bundle
3
+ * and a Worker both assemble the same `<head>`, and neither can import this package.
4
+ */
5
+ export type { HeadMergeContext } from "@jxsuite/site/head-merger";
12
6
 
13
7
  export interface SiteRoute {
14
8
  urlPattern: string;