@jxsuite/compiler 2.0.8 → 3.1.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 +209 -202
- package/dist/compiler.js +118 -115
- package/package.json +12 -11
- package/src/shared.ts +60 -35
- package/src/site/base-path.ts +175 -0
- 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/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +13 -28
- 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/service-worker.ts +22 -5
- package/src/site/site-build.ts +152 -44
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-element.ts +51 -1
- package/src/targets/compile-server.ts +14 -7
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
package/src/site/site-build.ts
CHANGED
|
@@ -21,7 +21,15 @@ import {
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
23
23
|
import { isMappedArray, isRef } from "@jxsuite/schema/guards";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
isNpmSpecifier,
|
|
26
|
+
npmAssetPath,
|
|
27
|
+
sidecarAssetPath,
|
|
28
|
+
siteAbsoluteUrl,
|
|
29
|
+
siteBasePath,
|
|
30
|
+
withBase,
|
|
31
|
+
} from "@jxsuite/schema/asset-paths";
|
|
32
|
+
import { rewriteHtmlBase } from "./base-path.ts";
|
|
25
33
|
import {
|
|
26
34
|
CLIENT_EXTERNALS,
|
|
27
35
|
bundleEntry,
|
|
@@ -40,6 +48,7 @@ import {
|
|
|
40
48
|
import {
|
|
41
49
|
buildHeaderRules,
|
|
42
50
|
contentTypeRules,
|
|
51
|
+
rebaseHeaderRules,
|
|
43
52
|
writeHeaders,
|
|
44
53
|
writeNoJekyll,
|
|
45
54
|
} from "./headers-emitter.ts";
|
|
@@ -50,7 +59,7 @@ import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
|
50
59
|
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
51
60
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
52
61
|
import { resolveLayout } from "./layout-resolver.ts";
|
|
53
|
-
import { mergeHead, renderHead } from "
|
|
62
|
+
import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
|
|
54
63
|
import { unregisteredHeadRelations } from "./link-relations.ts";
|
|
55
64
|
import { injectContext } from "./context-injection.ts";
|
|
56
65
|
import { compile, compileServer, compileSiteServer } from "../compiler.ts";
|
|
@@ -62,6 +71,7 @@ import {
|
|
|
62
71
|
collectServerEntries,
|
|
63
72
|
collectStyles,
|
|
64
73
|
colorSchemePrePaintScript,
|
|
74
|
+
escapeStyleText,
|
|
65
75
|
evaluateStaticTemplate,
|
|
66
76
|
isComponentFullyStatic,
|
|
67
77
|
isSingleExpression,
|
|
@@ -99,7 +109,8 @@ import {
|
|
|
99
109
|
} from "./i18n.ts";
|
|
100
110
|
import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
|
|
101
111
|
import {
|
|
102
|
-
|
|
112
|
+
importMapAssets,
|
|
113
|
+
importMapAssetsInHtml,
|
|
103
114
|
renderImportMap,
|
|
104
115
|
resolveClientRuntime,
|
|
105
116
|
writeClientRuntime,
|
|
@@ -160,6 +171,13 @@ export async function buildSite(
|
|
|
160
171
|
const pagesDir = resolve(projectRoot, "pages");
|
|
161
172
|
const publicDir = resolve(projectRoot, "public");
|
|
162
173
|
const trailingSlash = projectConfig.build.trailingSlash ?? "always";
|
|
174
|
+
/*
|
|
175
|
+
* Browser bundles are minified unless the project opts out. Off, the six scripts jxsuite.com
|
|
176
|
+
* loads weigh 155 kB of source a phone has to parse before it can paint; on, 65 kB. The opt-out
|
|
177
|
+
* exists for two real cases: reading a deployed stack trace, and diffing `dist/` across bundler
|
|
178
|
+
* backends, which agree on semantics but not on minified bytes.
|
|
179
|
+
*/
|
|
180
|
+
const minify = projectConfig.build.minify !== false;
|
|
163
181
|
|
|
164
182
|
// Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
|
|
165
183
|
// Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
|
|
@@ -201,6 +219,11 @@ export async function buildSite(
|
|
|
201
219
|
*/
|
|
202
220
|
const clientRuntime = resolveClientRuntime();
|
|
203
221
|
const runtimeImports = clientRuntime.imports;
|
|
222
|
+
/*
|
|
223
|
+
* What step 6d bundles, scanned back out of each finished page rather than recorded wherever a
|
|
224
|
+
* map is emitted. Several places emit one and only `injectComponentScripts` was recording, so a
|
|
225
|
+
* page whose interactivity came from anything else shipped a map naming files no build wrote.
|
|
226
|
+
*/
|
|
204
227
|
const runtimeAssetsUsed = new Set<string>();
|
|
205
228
|
/*
|
|
206
229
|
* Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
|
|
@@ -505,6 +528,12 @@ export async function buildSite(
|
|
|
505
528
|
// Sitemap is generated from the route table when a production `url` is configured
|
|
506
529
|
// (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
|
|
507
530
|
const siteUrl = projectConfig.url;
|
|
531
|
+
/*
|
|
532
|
+
* The path the site is deployed under, read back off `url` (RFC 3986 §5 — see `base-path.ts`).
|
|
533
|
+
* `""` for a site at an origin root, which is every deployment Jx documents, so every use below
|
|
534
|
+
* is a no-op there rather than a branch.
|
|
535
|
+
*/
|
|
536
|
+
const basePath = siteBasePath(siteUrl);
|
|
508
537
|
const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
|
|
509
538
|
const sitemapEntries: SitemapEntry[] = [];
|
|
510
539
|
|
|
@@ -680,10 +709,18 @@ export async function buildSite(
|
|
|
680
709
|
staticTags,
|
|
681
710
|
result.files.map((f: { content: string }) => f.content).join("\n"),
|
|
682
711
|
runtimeImports,
|
|
683
|
-
runtimeAssetsUsed,
|
|
684
712
|
);
|
|
685
713
|
}
|
|
686
714
|
|
|
715
|
+
/*
|
|
716
|
+
* Whatever import map this page ended up with, step 6d owes it the files it names. Read from
|
|
717
|
+
* the finished HTML so the answer does not depend on which of the emission paths produced
|
|
718
|
+
* the map — the one that reported and the one that shipped were different sets (issue #227).
|
|
719
|
+
*/
|
|
720
|
+
for (const assetPath of importMapAssetsInHtml(result.html)) {
|
|
721
|
+
runtimeAssetsUsed.add(assetPath);
|
|
722
|
+
}
|
|
723
|
+
|
|
687
724
|
// Mounted assets this page actually references — scanned from the finished HTML so a
|
|
688
725
|
// Reference is caught wherever it came from (markdown image, hand-authored page, $head).
|
|
689
726
|
collectAssetRefs(result.html, assetMounts, assetRefs);
|
|
@@ -692,7 +729,9 @@ export async function buildSite(
|
|
|
692
729
|
// Determine output path
|
|
693
730
|
const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
|
|
694
731
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
695
|
-
|
|
732
|
+
/* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
|
|
733
|
+
build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
|
|
734
|
+
writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
|
|
696
735
|
fileCount += 1;
|
|
697
736
|
|
|
698
737
|
// Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
|
|
@@ -713,7 +752,7 @@ export async function buildSite(
|
|
|
713
752
|
typeof route.sourceMtime === "string" && route.sourceMtime !== ""
|
|
714
753
|
? route.sourceMtime
|
|
715
754
|
: toRfc3339(statSync(route.sourcePath).mtime),
|
|
716
|
-
loc:
|
|
755
|
+
loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
|
|
717
756
|
...(routeAlternates.length > 0 && { alternates: routeAlternates }),
|
|
718
757
|
});
|
|
719
758
|
}
|
|
@@ -813,7 +852,13 @@ export async function buildSite(
|
|
|
813
852
|
const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
|
|
814
853
|
const workerSource = skipWorker
|
|
815
854
|
? null
|
|
816
|
-
: compileSiteServer([...deduped.values()], {
|
|
855
|
+
: compileSiteServer([...deduped.values()], {
|
|
856
|
+
adapter,
|
|
857
|
+
base: basePath,
|
|
858
|
+
connectors,
|
|
859
|
+
i18n,
|
|
860
|
+
mounts,
|
|
861
|
+
});
|
|
817
862
|
|
|
818
863
|
if (workerSource) {
|
|
819
864
|
// Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
|
|
@@ -862,6 +907,8 @@ export async function buildSite(
|
|
|
862
907
|
const runtime = await writeClientRuntime(
|
|
863
908
|
{ ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
|
|
864
909
|
outDir,
|
|
910
|
+
undefined,
|
|
911
|
+
minify,
|
|
865
912
|
);
|
|
866
913
|
fileCount += runtime.written;
|
|
867
914
|
for (const error of runtime.errors) {
|
|
@@ -889,7 +936,7 @@ export async function buildSite(
|
|
|
889
936
|
mkdirSync(dirname(outfile), { recursive: true });
|
|
890
937
|
await bundleEntry(
|
|
891
938
|
{ entryPath: bundle.entryPath, outfile },
|
|
892
|
-
{ external: CLIENT_EXTERNALS, target: "browser" },
|
|
939
|
+
{ external: CLIENT_EXTERNALS, minify, target: "browser" },
|
|
893
940
|
);
|
|
894
941
|
fileCount += 1;
|
|
895
942
|
log(` ${bundle.specifier} → ${assetPath}`);
|
|
@@ -914,7 +961,7 @@ export async function buildSite(
|
|
|
914
961
|
await bundleSource(
|
|
915
962
|
`${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
|
|
916
963
|
{ outfile, resolveDir: projectRoot },
|
|
917
|
-
{ target: "browser" },
|
|
964
|
+
{ minify, target: "browser" },
|
|
918
965
|
);
|
|
919
966
|
fileCount += 1;
|
|
920
967
|
log(` ${specifiers.length} element(s) → ${assetPath}`);
|
|
@@ -933,7 +980,7 @@ export async function buildSite(
|
|
|
933
980
|
* discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
|
|
934
981
|
*/
|
|
935
982
|
if (runtimeAssetsUsed.size > 0) {
|
|
936
|
-
const subpaths = await writeRuntimeSubpaths(outDir);
|
|
983
|
+
const subpaths = await writeRuntimeSubpaths(outDir, undefined, minify);
|
|
937
984
|
if (subpaths.written > 0) {
|
|
938
985
|
log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
|
|
939
986
|
}
|
|
@@ -990,6 +1037,7 @@ export async function buildSite(
|
|
|
990
1037
|
log("Generating redirects...");
|
|
991
1038
|
const compiledUrls = new Set(routes.map((r) => r.urlPattern));
|
|
992
1039
|
const redirects = generateRedirects(projectConfig.redirects, outDir, {
|
|
1040
|
+
basePath,
|
|
993
1041
|
compiledUrls,
|
|
994
1042
|
trailingSlash: projectConfig.build.trailingSlash,
|
|
995
1043
|
});
|
|
@@ -1067,7 +1115,8 @@ export async function buildSite(
|
|
|
1067
1115
|
{
|
|
1068
1116
|
const sw = normalizeServiceWorker(projectConfig.serviceWorker);
|
|
1069
1117
|
if (sw !== null) {
|
|
1070
|
-
const output =
|
|
1118
|
+
const output =
|
|
1119
|
+
sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir, basePath);
|
|
1071
1120
|
for (const warning of output.warnings) {
|
|
1072
1121
|
console.warn(warning);
|
|
1073
1122
|
}
|
|
@@ -1091,16 +1140,40 @@ export async function buildSite(
|
|
|
1091
1140
|
);
|
|
1092
1141
|
errors.push(...headerErrors);
|
|
1093
1142
|
// Runs here, after every emitter, so it can see which of these files the build actually wrote.
|
|
1094
|
-
const rules = [...securityRules, ...contentTypeRules(outDir)];
|
|
1143
|
+
const rules = rebaseHeaderRules([...securityRules, ...contentTypeRules(outDir)], basePath);
|
|
1095
1144
|
if (rules.length > 0) {
|
|
1096
1145
|
log("Writing response headers...");
|
|
1097
1146
|
fileCount += writeHeaders(outDir, rules);
|
|
1098
|
-
const { adapter } = projectConfig.build;
|
|
1147
|
+
const { adapter, deploy } = projectConfig.build;
|
|
1099
1148
|
if (adapter === "node" || adapter === "bun") {
|
|
1100
1149
|
console.warn(
|
|
1101
1150
|
`The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
|
|
1102
1151
|
`rather than configuration — apply these headers at the reverse proxy in front of it.`,
|
|
1103
1152
|
);
|
|
1153
|
+
} else if (adapter === undefined && deploy === undefined) {
|
|
1154
|
+
/*
|
|
1155
|
+
* `_headers` is a Cloudflare/Netlify convention, not a web standard, and the build has no
|
|
1156
|
+
* way to know where a plain static `dist/` is going. Most static hosts — GitHub Pages among
|
|
1157
|
+
* them — ignore the file entirely and serve their own `Cache-Control`, which is how a site
|
|
1158
|
+
* ends up shipping a year-long `immutable` rule for its content-addressed images and
|
|
1159
|
+
* getting ten minutes.
|
|
1160
|
+
*
|
|
1161
|
+
* A `public/CNAME` is GitHub Pages' custom-domain marker and nothing else's, so it is a
|
|
1162
|
+
* specific enough signal to say so out loud rather than warn every static build.
|
|
1163
|
+
*/
|
|
1164
|
+
if (existsSync(resolve(publicDir, "CNAME"))) {
|
|
1165
|
+
console.warn(
|
|
1166
|
+
"GitHub Pages ignores dist/_headers and serves its own Cache-Control: max-age=600, " +
|
|
1167
|
+
"so the generated caching and security rules will not take effect. Deploy behind a " +
|
|
1168
|
+
"host that reads _headers (Cloudflare Pages, Cloudflare Workers assets, Netlify) " +
|
|
1169
|
+
"to apply them.",
|
|
1170
|
+
);
|
|
1171
|
+
} else {
|
|
1172
|
+
log(
|
|
1173
|
+
" note: _headers is read by Cloudflare Pages, Cloudflare Workers assets and Netlify; " +
|
|
1174
|
+
"other static hosts ignore it and serve their own Cache-Control.",
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1104
1177
|
}
|
|
1105
1178
|
}
|
|
1106
1179
|
fileCount += writeNoJekyll(outDir);
|
|
@@ -1167,11 +1240,15 @@ async function compilePage(
|
|
|
1167
1240
|
/** Registers a page's npm `$elements` set as one bundle and returns its URL. */
|
|
1168
1241
|
registerElementBundle: (specifiers: string[]) => string,
|
|
1169
1242
|
) {
|
|
1243
|
+
/* Derived rather than passed as a seventeenth parameter: it is a pure function of
|
|
1244
|
+
`projectConfig.url`, which is already here, so the two cannot disagree. */
|
|
1245
|
+
const basePath = siteBasePath(projectConfig.url);
|
|
1246
|
+
|
|
1170
1247
|
// Load the raw page document (.json natively, other formats via the registry)
|
|
1171
1248
|
const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
|
|
1172
1249
|
|
|
1173
1250
|
// Resolve layout (wraps page in layout with slot distribution)
|
|
1174
|
-
const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
|
|
1251
|
+
const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
|
|
1175
1252
|
|
|
1176
1253
|
// Extract head arrays before they get lost in the merge
|
|
1177
1254
|
const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
|
|
@@ -1316,7 +1393,7 @@ async function compilePage(
|
|
|
1316
1393
|
const swHead: JxHeadEntry[] =
|
|
1317
1394
|
swConfig === null || swConfig === false
|
|
1318
1395
|
? []
|
|
1319
|
-
: [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
|
|
1396
|
+
: [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/", basePath) }];
|
|
1320
1397
|
|
|
1321
1398
|
const resolvedSiteHead = [
|
|
1322
1399
|
// The manifest link and theme colour are first-party and unconditional once declared, so they
|
|
@@ -1393,7 +1470,7 @@ async function compilePage(
|
|
|
1393
1470
|
if (slotCss.rules.length > 0) {
|
|
1394
1471
|
result.html = result.html.replace(
|
|
1395
1472
|
"</head>",
|
|
1396
|
-
`<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
|
|
1473
|
+
`<style>\n${escapeStyleText(slotCss.rules.join("\n"))}\n</style>\n</head>`,
|
|
1397
1474
|
);
|
|
1398
1475
|
}
|
|
1399
1476
|
|
|
@@ -1480,6 +1557,7 @@ function buildMountSpecs(
|
|
|
1480
1557
|
if (activeMounts.length === 0) {
|
|
1481
1558
|
return { connectors: [], mounts: [] };
|
|
1482
1559
|
}
|
|
1560
|
+
const base = siteBasePath(projectConfig.url);
|
|
1483
1561
|
|
|
1484
1562
|
const sections: Record<string, unknown> = {};
|
|
1485
1563
|
for (const contribution of registry.projectContributions()) {
|
|
@@ -1498,11 +1576,15 @@ function buildMountSpecs(
|
|
|
1498
1576
|
`to generate a deployable worker`,
|
|
1499
1577
|
);
|
|
1500
1578
|
}
|
|
1579
|
+
/* Both are request paths in the deployed worker: the route it registers, and the prefix the
|
|
1580
|
+
mount strips to find its own sub-path. They must carry the same base or the handler would
|
|
1581
|
+
answer a URL and then mis-read it. */
|
|
1582
|
+
const mountBase = withBase(base, server.basePath);
|
|
1501
1583
|
return {
|
|
1502
|
-
basePath:
|
|
1584
|
+
basePath: mountBase,
|
|
1503
1585
|
className: (entry.classDef.title as string | undefined) ?? entry.name,
|
|
1504
1586
|
module,
|
|
1505
|
-
options: { basePath:
|
|
1587
|
+
options: { basePath: mountBase, sections },
|
|
1506
1588
|
order: server.order ?? 100,
|
|
1507
1589
|
};
|
|
1508
1590
|
});
|
|
@@ -2074,7 +2156,6 @@ function expandComponents(
|
|
|
2074
2156
|
* @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
|
|
2075
2157
|
* @param {string} [islandSource] - Concatenated island modules emitted for this page
|
|
2076
2158
|
* @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
2159
|
* @returns {string}
|
|
2079
2160
|
*/
|
|
2080
2161
|
function injectComponentScripts(
|
|
@@ -2084,7 +2165,6 @@ function injectComponentScripts(
|
|
|
2084
2165
|
staticTags = new Set<string>(),
|
|
2085
2166
|
islandSource = "",
|
|
2086
2167
|
runtimeImports: Record<string, string> = {},
|
|
2087
|
-
runtimeAssetsUsed = new Set<string>(),
|
|
2088
2168
|
) {
|
|
2089
2169
|
// A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
|
|
2090
2170
|
// From inside an island's client template — in which case the tag exists solely in the island's
|
|
@@ -2097,15 +2177,31 @@ function injectComponentScripts(
|
|
|
2097
2177
|
return html;
|
|
2098
2178
|
}
|
|
2099
2179
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2180
|
+
/*
|
|
2181
|
+
* Component CSS is INLINED, not linked.
|
|
2182
|
+
*
|
|
2183
|
+
* A `<link rel="stylesheet">` per component tag is a render-blocking request per component: on
|
|
2184
|
+
* jxsuite.com's home page, 11 of them totalling 22 kB — the whole of its critical request chain,
|
|
2185
|
+
* for sheets averaging 2 kB each. The page already carries its project and page CSS inline
|
|
2186
|
+
* (`buildStyleBlock`), so this is the same delivery, applied consistently.
|
|
2187
|
+
*
|
|
2188
|
+
* The trade is real and deliberate: these bytes now repeat on every page instead of caching
|
|
2189
|
+
* across navigations. They are small, they gzip against the markup around them, and a round trip
|
|
2190
|
+
* on a phone costs more than the bytes do. Components in shadow mode are untouched — their sheet
|
|
2191
|
+
* is linked from inside the declarative shadow root and never enters `cssMap`.
|
|
2192
|
+
*
|
|
2193
|
+
* Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
|
|
2194
|
+
*/
|
|
2195
|
+
const componentCss = usedTags
|
|
2196
|
+
.map((tag: string) => cssMap.get(tag))
|
|
2197
|
+
.filter((css): css is string => css !== undefined && css !== "")
|
|
2198
|
+
.join("\n");
|
|
2199
|
+
const styleBlock =
|
|
2200
|
+
componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
|
|
2201
|
+
const intoHead = (fragments: string[]) => {
|
|
2202
|
+
const block = fragments.filter(Boolean).join("\n ");
|
|
2203
|
+
return block === "" ? html : html.replace("</head>", ` ${block}\n</head>`);
|
|
2204
|
+
};
|
|
2109
2205
|
|
|
2110
2206
|
// Only inject JS for components that have non-static instances. An island-rendered instance is
|
|
2111
2207
|
// Never prerendered, so it needs its module even when the component itself is fully static —
|
|
@@ -2113,22 +2209,30 @@ function injectComponentScripts(
|
|
|
2113
2209
|
// The static HTML: those instances are prerendered, but the ones the island creates are not.
|
|
2114
2210
|
const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
|
|
2115
2211
|
if (jsTags.length === 0) {
|
|
2116
|
-
return
|
|
2212
|
+
return intoHead([styleBlock]);
|
|
2117
2213
|
}
|
|
2118
2214
|
|
|
2119
2215
|
// Build import map (needed for @vue/reactivity and lit-html)
|
|
2120
2216
|
const importMap = renderImportMap(runtimeImports);
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2217
|
+
/*
|
|
2218
|
+
* The runtime modules THIS page needs — the hints are per page, so they are read from this
|
|
2219
|
+
* page's map rather than from the build-wide set, which would have every page hint at every
|
|
2220
|
+
* other page's runtime.
|
|
2221
|
+
*/
|
|
2222
|
+
const pageRuntimeAssets = importMapAssets(runtimeImports);
|
|
2223
|
+
|
|
2224
|
+
/*
|
|
2225
|
+
* An import map declares where a bare specifier lives; it does not tell the browser to go get it.
|
|
2226
|
+
* Without these hints `/assets/vue-reactivity.js` is discovered only after a component module has
|
|
2227
|
+
* been fetched AND parsed — a three-deep request chain on the critical path, on a connection
|
|
2228
|
+
* where each hop is a round trip. The component modules are hinted too so the whole set starts
|
|
2229
|
+
* downloading while the HTML is still being parsed.
|
|
2230
|
+
*/
|
|
2231
|
+
const preloads = [...pageRuntimeAssets, ...jsTags.map((tag: string) => `/components/${tag}.js`)]
|
|
2232
|
+
.map((href) => `<link rel="modulepreload" href="${href}">`)
|
|
2233
|
+
.join("\n ");
|
|
2234
|
+
|
|
2235
|
+
const result = intoHead([preloads, styleBlock]);
|
|
2132
2236
|
|
|
2133
2237
|
const moduleScripts = jsTags
|
|
2134
2238
|
.map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
|
|
@@ -2299,7 +2403,7 @@ function generateRedirects(
|
|
|
2299
2403
|
string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2300
2404
|
>,
|
|
2301
2405
|
outDir: string,
|
|
2302
|
-
opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
|
|
2406
|
+
opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
|
|
2303
2407
|
) {
|
|
2304
2408
|
let files = 0;
|
|
2305
2409
|
const errors: string[] = [];
|
|
@@ -2326,7 +2430,11 @@ function generateRedirects(
|
|
|
2326
2430
|
continue;
|
|
2327
2431
|
}
|
|
2328
2432
|
|
|
2329
|
-
|
|
2433
|
+
/* Both halves are site URLs, and the host matches the source against a REQUEST path — which
|
|
2434
|
+
arrives under the base. A rule left at the old root would never fire, and one that did would
|
|
2435
|
+
send the visitor out of the deployment. */
|
|
2436
|
+
const rebased = opts.basePath ?? "";
|
|
2437
|
+
redirectLines.push(`${withBase(rebased, source)} ${withBase(rebased, dest)} ${status}`);
|
|
2330
2438
|
|
|
2331
2439
|
// A pattern source cannot be a file on disk, so it lives only in `_redirects`.
|
|
2332
2440
|
if (source.includes(":") || source.includes("*")) {
|
|
@@ -2493,7 +2601,7 @@ function ensureRobotsSitemap(outDir: string, siteUrl: string) {
|
|
|
2493
2601
|
if (!content.endsWith("\n")) {
|
|
2494
2602
|
content += "\n";
|
|
2495
2603
|
}
|
|
2496
|
-
content += `\nSitemap: ${
|
|
2604
|
+
content += `\nSitemap: ${siteAbsoluteUrl("/sitemap.xml", siteUrl)}\n`;
|
|
2497
2605
|
writeFileSync(robotsPath, content, "utf8");
|
|
2498
2606
|
return existed ? 0 : 1;
|
|
2499
2607
|
}
|
package/src/site/well-known.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @docs framework/site/deployment
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { siteBasePath, withBase } from "@jxsuite/schema/asset-paths";
|
|
12
13
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { canonicalizeLocale } from "@jxsuite/schema/locale";
|
|
@@ -48,17 +49,27 @@ export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const name = config.name ?? projectConfig.name ?? "Jx Site";
|
|
51
|
-
|
|
52
|
+
/*
|
|
53
|
+
* Every URL in a manifest is fetched or navigated to by the browser, so all three carry the
|
|
54
|
+
* deployment base (`base-path.ts`). `scope` is the one that fails loudly — a scope the manifest's
|
|
55
|
+
* own URL is not inside makes the manifest invalid and the site uninstallable — while a
|
|
56
|
+
* `start_url` at the old root just opens the wrong page from the home screen.
|
|
57
|
+
*/
|
|
58
|
+
const base = siteBasePath(projectConfig.url);
|
|
59
|
+
const icons = [];
|
|
60
|
+
for (const icon of config.icons ?? []) {
|
|
61
|
+
icons.push(typeof icon.src === "string" ? { ...icon, src: withBase(base, icon.src) } : icon);
|
|
62
|
+
}
|
|
52
63
|
const manifest: Record<string, unknown> = {
|
|
53
64
|
display: config.display ?? "standalone",
|
|
54
65
|
icons,
|
|
55
66
|
name,
|
|
56
67
|
short_name: config.shortName ?? name,
|
|
57
|
-
start_url: config.startUrl ?? "/",
|
|
68
|
+
start_url: withBase(base, config.startUrl ?? "/"),
|
|
58
69
|
...(config.description === undefined ? {} : { description: config.description }),
|
|
59
70
|
...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
|
|
60
71
|
...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
|
|
61
|
-
...(config.scope === undefined ? {} : { scope: config.scope }),
|
|
72
|
+
...(config.scope === undefined ? {} : { scope: withBase(base, config.scope) }),
|
|
62
73
|
...(config.orientation === undefined ? {} : { orientation: config.orientation }),
|
|
63
74
|
...(config.lang === undefined ? {} : { lang: config.lang }),
|
|
64
75
|
...(config.dir === undefined ? {} : { dir: config.dir }),
|
|
@@ -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)) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* file.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { withBase } from "@jxsuite/schema/asset-paths";
|
|
8
9
|
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
9
10
|
import { collectServerEntries } from "../shared.ts";
|
|
10
11
|
import { localeNegotiationMiddleware } from "../site/locale-negotiation.ts";
|
|
@@ -110,15 +111,21 @@ export function compileSiteServer(
|
|
|
110
111
|
mounts?: SiteMountSpec[];
|
|
111
112
|
connectors?: SiteConnectorSpec[];
|
|
112
113
|
i18n?: ResolvedI18n | null;
|
|
114
|
+
base?: string;
|
|
113
115
|
} = {},
|
|
114
116
|
) {
|
|
115
|
-
const {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
const { adapter = null, mounts = [], connectors = [], i18n = null, base = "" } = opts;
|
|
118
|
+
/*
|
|
119
|
+
* Routes are registered under the deployment base (`site/base-path.ts`), because the worker IS
|
|
120
|
+
* the origin: the request it sees is the one the visitor's browser sent, and on a site served
|
|
121
|
+
* from `/m/my-site/` that path carries the prefix. A route left at the bare path would simply
|
|
122
|
+
* never match — the 404 nobody attributes to a config value.
|
|
123
|
+
*
|
|
124
|
+
* The dev server takes the opposite approach and strips the base at the edge (`server.ts`), so
|
|
125
|
+
* everything behind it routes on the bare path. Each is internally consistent: production serves
|
|
126
|
+
* exactly one URL space, while `jx dev` deliberately answers both.
|
|
127
|
+
*/
|
|
128
|
+
const baseUrl = withBase(base, opts.baseUrl ?? "/_jx/server");
|
|
122
129
|
if (entries.length === 0 && mounts.length === 0 && !adapter) {
|
|
123
130
|
return null;
|
|
124
131
|
}
|
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;
|