@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.
- package/README.md +8 -8
- package/dist/cli.js +193 -189
- package/dist/compiler.js +110 -110
- package/package.json +11 -10
- package/src/shared.ts +60 -35
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +51 -1
- package/src/site/context-injection.ts +30 -128
- package/src/site/i18n.ts +10 -26
- package/src/site/image-optimizer.ts +31 -1
- package/src/site/image-transform.ts +141 -7
- package/src/site/layout-resolver.ts +30 -192
- package/src/site/pages-discovery.ts +9 -49
- package/src/site/site-build.ts +102 -32
- package/src/targets/compile-element.ts +51 -1
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
package/src/site/site-build.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
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
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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;
|
package/src/site/head-merger.ts
DELETED
|
@@ -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("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
251
|
-
}
|