@jxsuite/compiler 2.0.8 → 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.
@@ -50,7 +50,7 @@ import type { AssetMount } from "@jxsuite/schema/asset-paths";
50
50
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
51
51
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
52
52
  import { resolveLayout } from "./layout-resolver.ts";
53
- import { mergeHead, renderHead } from "./head-merger.ts";
53
+ import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
54
54
  import { unregisteredHeadRelations } from "./link-relations.ts";
55
55
  import { injectContext } from "./context-injection.ts";
56
56
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
@@ -62,6 +62,7 @@ import {
62
62
  collectServerEntries,
63
63
  collectStyles,
64
64
  colorSchemePrePaintScript,
65
+ escapeStyleText,
65
66
  evaluateStaticTemplate,
66
67
  isComponentFullyStatic,
67
68
  isSingleExpression,
@@ -99,7 +100,8 @@ import {
99
100
  } from "./i18n.ts";
100
101
  import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
101
102
  import {
102
- isPrefixSpecifier,
103
+ importMapAssets,
104
+ importMapAssetsInHtml,
103
105
  renderImportMap,
104
106
  resolveClientRuntime,
105
107
  writeClientRuntime,
@@ -160,6 +162,13 @@ export async function buildSite(
160
162
  const pagesDir = resolve(projectRoot, "pages");
161
163
  const publicDir = resolve(projectRoot, "public");
162
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;
163
172
 
164
173
  // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
165
174
  // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
@@ -201,6 +210,11 @@ export async function buildSite(
201
210
  */
202
211
  const clientRuntime = resolveClientRuntime();
203
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
+ */
204
218
  const runtimeAssetsUsed = new Set<string>();
205
219
  /*
206
220
  * Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
@@ -680,10 +694,18 @@ export async function buildSite(
680
694
  staticTags,
681
695
  result.files.map((f: { content: string }) => f.content).join("\n"),
682
696
  runtimeImports,
683
- runtimeAssetsUsed,
684
697
  );
685
698
  }
686
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
+
687
709
  // Mounted assets this page actually references — scanned from the finished HTML so a
688
710
  // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
689
711
  collectAssetRefs(result.html, assetMounts, assetRefs);
@@ -862,6 +884,8 @@ export async function buildSite(
862
884
  const runtime = await writeClientRuntime(
863
885
  { ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
864
886
  outDir,
887
+ undefined,
888
+ minify,
865
889
  );
866
890
  fileCount += runtime.written;
867
891
  for (const error of runtime.errors) {
@@ -889,7 +913,7 @@ export async function buildSite(
889
913
  mkdirSync(dirname(outfile), { recursive: true });
890
914
  await bundleEntry(
891
915
  { entryPath: bundle.entryPath, outfile },
892
- { external: CLIENT_EXTERNALS, target: "browser" },
916
+ { external: CLIENT_EXTERNALS, minify, target: "browser" },
893
917
  );
894
918
  fileCount += 1;
895
919
  log(` ${bundle.specifier} → ${assetPath}`);
@@ -914,7 +938,7 @@ export async function buildSite(
914
938
  await bundleSource(
915
939
  `${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
916
940
  { outfile, resolveDir: projectRoot },
917
- { target: "browser" },
941
+ { minify, target: "browser" },
918
942
  );
919
943
  fileCount += 1;
920
944
  log(` ${specifiers.length} element(s) → ${assetPath}`);
@@ -933,7 +957,7 @@ export async function buildSite(
933
957
  * discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
934
958
  */
935
959
  if (runtimeAssetsUsed.size > 0) {
936
- const subpaths = await writeRuntimeSubpaths(outDir);
960
+ const subpaths = await writeRuntimeSubpaths(outDir, undefined, minify);
937
961
  if (subpaths.written > 0) {
938
962
  log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
939
963
  }
@@ -1095,12 +1119,36 @@ export async function buildSite(
1095
1119
  if (rules.length > 0) {
1096
1120
  log("Writing response headers...");
1097
1121
  fileCount += writeHeaders(outDir, rules);
1098
- const { adapter } = projectConfig.build;
1122
+ const { adapter, deploy } = projectConfig.build;
1099
1123
  if (adapter === "node" || adapter === "bun") {
1100
1124
  console.warn(
1101
1125
  `The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
1102
1126
  `rather than configuration — apply these headers at the reverse proxy in front of it.`,
1103
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
+ }
1104
1152
  }
1105
1153
  }
1106
1154
  fileCount += writeNoJekyll(outDir);
@@ -1171,7 +1219,7 @@ async function compilePage(
1171
1219
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1172
1220
 
1173
1221
  // Resolve layout (wraps page in layout with slot distribution)
1174
- const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
1222
+ const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1175
1223
 
1176
1224
  // Extract head arrays before they get lost in the merge
1177
1225
  const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
@@ -1393,7 +1441,7 @@ async function compilePage(
1393
1441
  if (slotCss.rules.length > 0) {
1394
1442
  result.html = result.html.replace(
1395
1443
  "</head>",
1396
- `<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
1444
+ `<style>\n${escapeStyleText(slotCss.rules.join("\n"))}\n</style>\n</head>`,
1397
1445
  );
1398
1446
  }
1399
1447
 
@@ -2074,7 +2122,6 @@ function expandComponents(
2074
2122
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
2075
2123
  * @param {string} [islandSource] - Concatenated island modules emitted for this page
2076
2124
  * @param {Record<string, string>} [runtimeImports] - The import map's `imports` object
2077
- * @param {Set<string>} [runtimeAssetsUsed] - Collects the asset paths an emitted import map names
2078
2125
  * @returns {string}
2079
2126
  */
2080
2127
  function injectComponentScripts(
@@ -2084,7 +2131,6 @@ function injectComponentScripts(
2084
2131
  staticTags = new Set<string>(),
2085
2132
  islandSource = "",
2086
2133
  runtimeImports: Record<string, string> = {},
2087
- runtimeAssetsUsed = new Set<string>(),
2088
2134
  ) {
2089
2135
  // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
2090
2136
  // From inside an island's client template — in which case the tag exists solely in the island's
@@ -2097,15 +2143,31 @@ function injectComponentScripts(
2097
2143
  return html;
2098
2144
  }
2099
2145
 
2100
- // Inject CSS links in <head> for ALL components that have CSS sidecars
2101
- const cssLinks = usedTags
2102
- .filter((tag: string) => cssMap.has(tag))
2103
- .map((tag: string) => `<link rel="stylesheet" href="/components/${tag}.css">`)
2104
- .join("\n ");
2105
- let result = html;
2106
- if (cssLinks) {
2107
- result = result.replace("</head>", ` ${cssLinks}\n</head>`);
2108
- }
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
+ };
2109
2171
 
2110
2172
  // Only inject JS for components that have non-static instances. An island-rendered instance is
2111
2173
  // Never prerendered, so it needs its module even when the component itself is fully static —
@@ -2113,22 +2175,30 @@ function injectComponentScripts(
2113
2175
  // The static HTML: those instances are prerendered, but the ones the island creates are not.
2114
2176
  const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
2115
2177
  if (jsTags.length === 0) {
2116
- return result;
2178
+ return intoHead([styleBlock]);
2117
2179
  }
2118
2180
 
2119
2181
  // Build import map (needed for @vue/reactivity and lit-html)
2120
2182
  const importMap = renderImportMap(runtimeImports);
2121
- for (const [specifier, url] of Object.entries(runtimeImports)) {
2122
- /*
2123
- * A prefix key's value is the DIRECTORY its subpaths are served from — `/assets/lit-html/` —
2124
- * not a file any build writes. `writeClientRuntime` matches this set against exact asset
2125
- * paths, so a directory in it bundled nothing and only inflated the count this build logged.
2126
- */
2127
- if (isPrefixSpecifier(specifier) || !url.startsWith("/")) {
2128
- continue;
2129
- }
2130
- runtimeAssetsUsed.add(url);
2131
- }
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]);
2132
2202
 
2133
2203
  const moduleScripts = jsTags
2134
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)) {
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;
@@ -1,251 +0,0 @@
1
- /**
2
- * Head-merger.js — $head merge pipeline
3
- *
4
- * Merges <head> element arrays from three levels:
5
- *
6
- * 1. Site.json.$head — global (e.g., favicon, global stylesheet)
7
- * 2. Layout.$head — layout-level (e.g., shared nav scripts)
8
- * 3. Page.$head — page-specific (e.g., per-page meta tags)
9
- *
10
- * Per site-architecture spec §8:
11
- *
12
- * - Later levels override earlier levels for the same element
13
- * - Deduplication by tagName + key attribute (name, property, rel+href)
14
- * - Charset and viewport are auto-injected if missing
15
- */
16
-
17
- import type { JxHeadEntry } from "@jxsuite/schema/types";
18
- import type { HeadMergeContext } from "../types.ts";
19
-
20
- /**
21
- * Merge $head arrays from site, layout, and page levels.
22
- *
23
- * @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
24
- * @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
25
- * @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
26
- * @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
27
- * @returns {JxHeadEntry[]} Merged, deduplicated $head array
28
- */
29
- export function mergeHead(
30
- siteHead: JxHeadEntry[] = [],
31
- layoutHead: JxHeadEntry[] = [],
32
- pageHead: JxHeadEntry[] = [],
33
- context: HeadMergeContext = {},
34
- ) {
35
- // Start with auto-injected defaults
36
- const defaults: JxHeadEntry[] = [
37
- { attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
38
- {
39
- attributes: {
40
- content: "width=device-width, initial-scale=1",
41
- name: "viewport",
42
- },
43
- tagName: "meta",
44
- },
45
- ];
46
-
47
- // Merge layers: site → layout → page (later wins)
48
- const merged = new Map<string, JxHeadEntry>();
49
-
50
- for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
51
- const key = headEntryKey(entry);
52
- merged.set(key, entry);
53
- }
54
-
55
- // Insert <title> if present
56
- const title = context.title ?? context.siteName ?? "Jx Site";
57
- merged.set("title", { children: [title], tagName: "title" });
58
-
59
- // Add canonical URL if provided
60
- if (context.pageUrl && context.siteUrl) {
61
- const canonical = new URL(context.pageUrl, context.siteUrl).href;
62
- /*
63
- * An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
64
- * hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
65
- * and this one landed under different keys and the page got BOTH — which is the one thing a
66
- * canonical link must not be.
67
- */
68
- const hasAuthored = [...merged.values()].some(
69
- (e) =>
70
- e && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
71
- );
72
- if (!hasAuthored) {
73
- const entry: JxHeadEntry = {
74
- attributes: { href: canonical, rel: "canonical" },
75
- tagName: "link",
76
- };
77
- merged.set(headEntryKey(entry), entry);
78
- }
79
- // Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
80
- if (!merged.has("meta:og:url")) {
81
- merged.set("meta:og:url", {
82
- attributes: { content: canonical, property: "og:url" },
83
- tagName: "meta",
84
- });
85
- }
86
- }
87
- if (context.siteName && !merged.has("meta:og:site_name")) {
88
- merged.set("meta:og:site_name", {
89
- attributes: { content: context.siteName, property: "og:site_name" },
90
- tagName: "meta",
91
- });
92
- }
93
-
94
- /*
95
- * Locale alternates (site-architecture.md §13.5). Keyed through `headEntryKey` like everything
96
- * else, which is what keeps a set of them from collapsing: they share `rel="alternate"` and
97
- * differ only in `hreflang`, and `x-default` conventionally shares its `href` with the default
98
- * locale's entry. Before the key accounted for the qualifying attribute this whole feature was
99
- * impossible — the set became one link.
100
- *
101
- * An author-supplied alternate for the same `hreflang` wins, like every other auto entry.
102
- */
103
- for (const alternate of context.alternates ?? []) {
104
- const entry: JxHeadEntry = {
105
- attributes: { href: alternate.href, hreflang: alternate.hreflang, rel: "alternate" },
106
- tagName: "link",
107
- };
108
- const key = headEntryKey(entry);
109
- if (!merged.has(key)) {
110
- merged.set(key, entry);
111
- }
112
- }
113
-
114
- return [...merged.values()];
115
- }
116
-
117
- /**
118
- * Generate a deduplication key for a <head> element. Elements with the same key are considered
119
- * duplicates; the last one wins.
120
- *
121
- * @param {JxHeadEntry} entry
122
- * @returns {string}
123
- */
124
- function headEntryKey(entry: JxHeadEntry) {
125
- if (!entry || typeof entry !== "object") {
126
- return String(entry);
127
- }
128
-
129
- const tag = entry.tagName ?? "unknown";
130
- const attrs = entry.attributes ?? {};
131
-
132
- // <title> — singleton
133
- if (tag === "title") {
134
- return "title";
135
- }
136
-
137
- // <meta charset> — singleton
138
- if (attrs.charset) {
139
- return "meta:charset";
140
- }
141
-
142
- // <meta name="...""> — keyed by name
143
- if (tag === "meta" && attrs.name) {
144
- return `meta:${attrs.name}`;
145
- }
146
-
147
- // <meta property="..."> — keyed by property (Open Graph)
148
- if (tag === "meta" && attrs.property) {
149
- return `meta:${attrs.property}`;
150
- }
151
-
152
- /*
153
- * <link> — keyed by rel + href + the attribute that distinguishes two links sharing both.
154
- *
155
- * `rel` + `href` alone is not identity, and the cases where it fails are the ones a site actually
156
- * needs: `hreflang="x-default"` conventionally points at the SAME href as the default locale's
157
- * alternate, and an RSS and an Atom feed are both `rel="alternate"` differing only in `type`.
158
- * `icon` differs by `sizes` and `stylesheet` by `media` for the same reason.
159
- */
160
- if (tag === "link" && attrs.rel) {
161
- const qualifier = attrs.hreflang ?? attrs.type ?? attrs.media ?? attrs.sizes ?? "";
162
- return `link:${attrs.rel}:${attrs.href ?? ""}:${String(qualifier)}`;
163
- }
164
-
165
- // <script src="..."> — keyed by src
166
- if (tag === "script" && attrs.src) {
167
- return `script:${attrs.src}`;
168
- }
169
-
170
- // <style> — unique per content hash
171
- if (tag === "style") {
172
- const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
173
- const content = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
174
- return `style:${simpleHash(content)}`;
175
- }
176
-
177
- // Fallback — use full JSON serialization
178
- return `${tag}:${JSON.stringify(entry)}`;
179
- }
180
-
181
- /**
182
- * Simple string hash for deduplication (not cryptographic).
183
- *
184
- * @param {string} str
185
- * @returns {string}
186
- */
187
- function simpleHash(str: string) {
188
- let hash = 0;
189
- for (let i = 0; i < str.length; i++) {
190
- // oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
191
- hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
192
- }
193
- return hash.toString(36);
194
- }
195
-
196
- /**
197
- * Render a merged $head array to HTML string for insertion into <head>.
198
- *
199
- * @param {JxHeadEntry[]} headEntries - Merged head entries
200
- * @returns {string} HTML string
201
- */
202
- export function renderHead(headEntries: JxHeadEntry[]) {
203
- return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
204
- }
205
-
206
- /**
207
- * Render a single $head entry to an HTML string.
208
- *
209
- * @param {JxHeadEntry} entry
210
- * @returns {string}
211
- */
212
- function renderHeadEntry(entry: JxHeadEntry) {
213
- if (typeof entry === "string") {
214
- return entry;
215
- }
216
- if (!entry || typeof entry !== "object") {
217
- return "";
218
- }
219
-
220
- const tag = entry.tagName;
221
- const attrs = entry.attributes ?? {};
222
- const attrStr = Object.entries(attrs)
223
- .map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
224
- .join(" ");
225
-
226
- const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
227
-
228
- // Void elements (no closing tag)
229
- const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
230
- if (VOID.has(tag)) {
231
- return open;
232
- }
233
-
234
- /*
235
- * Elements with content. `textContent` may be an OBJECT: that is how a JSON-LD block is authored
236
- * (§8.5), and serializing it here is what the spec has always promised. Left as-is it reached the
237
- * page as the string "[object Object]".
238
- */
239
- const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
240
- const content =
241
- raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
242
- return `${open}${content}</${tag}>`;
243
- }
244
-
245
- /**
246
- * @param {unknown} val
247
- * @returns {string}
248
- */
249
- function escapeAttr(val: unknown) {
250
- return String(val).replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
251
- }