@jxsuite/compiler 1.5.0 → 2.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/dist/cli.js +563 -228
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
package/src/site/site-build.ts
CHANGED
|
@@ -20,16 +20,28 @@ import {
|
|
|
20
20
|
} from "node:fs";
|
|
21
21
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
22
22
|
import { isMappedArray, isRef } from "@jxsuite/schema/guards";
|
|
23
|
-
import { isNpmSpecifier, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
|
|
23
|
+
import { isNpmSpecifier, npmAssetPath, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
|
|
24
24
|
import {
|
|
25
25
|
CLIENT_EXTERNALS,
|
|
26
26
|
bundleEntry,
|
|
27
27
|
bundleWorkerSource,
|
|
28
28
|
isBundleableSrc,
|
|
29
|
+
bundleSource,
|
|
29
30
|
resolveSidecarEntry,
|
|
30
31
|
} from "./bundler.ts";
|
|
31
32
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
discoverPages,
|
|
35
|
+
expandDynamicRoutes,
|
|
36
|
+
readPageDocument,
|
|
37
|
+
readTranslationKeys,
|
|
38
|
+
} from "./pages-discovery.ts";
|
|
39
|
+
import {
|
|
40
|
+
buildHeaderRules,
|
|
41
|
+
contentTypeRules,
|
|
42
|
+
writeHeaders,
|
|
43
|
+
writeNoJekyll,
|
|
44
|
+
} from "./headers-emitter.ts";
|
|
33
45
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
34
46
|
import { loadProjectSections } from "./project-sections.ts";
|
|
35
47
|
import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
|
|
@@ -38,13 +50,12 @@ import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registr
|
|
|
38
50
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
39
51
|
import { resolveLayout } from "./layout-resolver.ts";
|
|
40
52
|
import { mergeHead, renderHead } from "./head-merger.ts";
|
|
53
|
+
import { unregisteredHeadRelations } from "./link-relations.ts";
|
|
41
54
|
import { injectContext } from "./context-injection.ts";
|
|
42
55
|
import { compile, compileServer, compileSiteServer } from "../compiler.ts";
|
|
43
56
|
import { compileElement } from "../targets/compile-element.ts";
|
|
44
57
|
import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
|
|
45
58
|
import {
|
|
46
|
-
DEFAULT_LIT_HTML_SRC,
|
|
47
|
-
DEFAULT_REACTIVITY_SRC,
|
|
48
59
|
buildComponentCSS,
|
|
49
60
|
buildInitialScope,
|
|
50
61
|
collectServerEntries,
|
|
@@ -61,6 +72,37 @@ import {
|
|
|
61
72
|
} from "../shared.ts";
|
|
62
73
|
import { resolvePrototypes } from "./prototype-resolver.ts";
|
|
63
74
|
import { transformImageNodes } from "./image-transform.ts";
|
|
75
|
+
import { collectCspSources, emptyCspSources } from "./csp.ts";
|
|
76
|
+
import { resolveShadowMode } from "../shadow.ts";
|
|
77
|
+
import {
|
|
78
|
+
buildServiceWorker,
|
|
79
|
+
normalizeServiceWorker,
|
|
80
|
+
registrationScript,
|
|
81
|
+
tombstoneServiceWorker,
|
|
82
|
+
} from "./service-worker.ts";
|
|
83
|
+
import {
|
|
84
|
+
buildManifest,
|
|
85
|
+
buildSecurityTxt,
|
|
86
|
+
manifestHeadEntries,
|
|
87
|
+
writeWellKnown,
|
|
88
|
+
} from "./well-known.ts";
|
|
89
|
+
import {
|
|
90
|
+
localeAlternates,
|
|
91
|
+
localeOfRoute,
|
|
92
|
+
pageLanguage,
|
|
93
|
+
resolveI18n,
|
|
94
|
+
translationSets,
|
|
95
|
+
undeclaredLocalePrefix,
|
|
96
|
+
unprefixedRoutes,
|
|
97
|
+
} from "./i18n.ts";
|
|
98
|
+
import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
|
|
99
|
+
import {
|
|
100
|
+
isPrefixSpecifier,
|
|
101
|
+
renderImportMap,
|
|
102
|
+
resolveClientRuntime,
|
|
103
|
+
writeClientRuntime,
|
|
104
|
+
writeRuntimeSubpaths,
|
|
105
|
+
} from "./client-runtime.ts";
|
|
64
106
|
import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
|
|
65
107
|
import type { ImageConfig } from "./image-optimizer.ts";
|
|
66
108
|
import type { ImageMetaCache } from "./image-transform.ts";
|
|
@@ -121,6 +163,69 @@ export async function buildSite(
|
|
|
121
163
|
// Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
|
|
122
164
|
// In step 6d (spec.md §12). Keyed by asset path so duplicate specifiers bundle once.
|
|
123
165
|
const sidecarBundles = new Map<string, { entryPath: string; specifier: string }>();
|
|
166
|
+
/*
|
|
167
|
+
* Files a `$head` entry names by bare specifier — a package's stylesheet, most often. Copied
|
|
168
|
+
* rather than bundled.
|
|
169
|
+
*
|
|
170
|
+
* These used to be rewritten to `/node_modules/<specifier>`, which resolves in dev, where the
|
|
171
|
+
* server serves that path, and 404s in production, where nothing copies node_modules into dist.
|
|
172
|
+
*/
|
|
173
|
+
const npmAssets = new Map<string, string>();
|
|
174
|
+
/*
|
|
175
|
+
* One entry per distinct SET of npm `$elements`, bundled self-contained. Keyed by the set rather
|
|
176
|
+
* than by specifier so two pages registering the same components share one file, and so a package
|
|
177
|
+
* that ships its own framework copy inlines it exactly once per page instead of once per
|
|
178
|
+
* component. See `injectNpmElementScripts` for why sharing Jx's copy is not an option.
|
|
179
|
+
*/
|
|
180
|
+
const elementBundles = new Map<string, string[]>();
|
|
181
|
+
const registerElementBundle = (specifiers: string[]): string => {
|
|
182
|
+
const sorted = specifiers.toSorted();
|
|
183
|
+
const key = Bun.hash(sorted.join("\u0000")).toString(36);
|
|
184
|
+
const assetPath = `/assets/elements-${key}.js`;
|
|
185
|
+
elementBundles.set(assetPath, sorted);
|
|
186
|
+
return assetPath;
|
|
187
|
+
};
|
|
188
|
+
/*
|
|
189
|
+
* Bundles and copies land in one URL directory, so one map arbitrates it. Two different files
|
|
190
|
+
* that slug to the same name is a build error, not a silent last-writer-wins overwrite.
|
|
191
|
+
*/
|
|
192
|
+
/*
|
|
193
|
+
* The two modules the import map names. Resolved up front so every page's map is identical, and
|
|
194
|
+
* written at the end only if some page actually emitted one.
|
|
195
|
+
*/
|
|
196
|
+
const clientRuntime = resolveClientRuntime();
|
|
197
|
+
const runtimeImports = clientRuntime.imports;
|
|
198
|
+
const runtimeAssetsUsed = new Set<string>();
|
|
199
|
+
/*
|
|
200
|
+
* Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
|
|
201
|
+
* some emission site believed it wrote.
|
|
202
|
+
*/
|
|
203
|
+
const cspSources = emptyCspSources();
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* Locale routing (§13). Validated once: a malformed BCP 47 tag is a build error, because a
|
|
207
|
+
* locale is a URL prefix, an `hreflang` value and an `<html lang>` at once — a typo does not
|
|
208
|
+
* degrade, it produces a site claiming a language that does not exist.
|
|
209
|
+
*/
|
|
210
|
+
const { errors: i18nErrors, i18n } = resolveI18n(projectConfig);
|
|
211
|
+
for (const error of i18nErrors) {
|
|
212
|
+
errors.push(error);
|
|
213
|
+
console.error(error);
|
|
214
|
+
}
|
|
215
|
+
for (const warning of clientRuntime.warnings) {
|
|
216
|
+
console.warn(warning);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const assetClaims = new Map<string, { entryPath: string; specifier: string }>();
|
|
220
|
+
const claimAsset = (assetPath: string, entryPath: string, specifier: string) => {
|
|
221
|
+
const existing = assetClaims.get(assetPath);
|
|
222
|
+
if (existing !== undefined && existing.entryPath !== entryPath) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`"${specifier}" and "${existing.specifier}" both map to ${assetPath} — rename one`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
assetClaims.set(assetPath, { entryPath, specifier });
|
|
228
|
+
};
|
|
124
229
|
const rewriteSidecarSrc = (specifier: string, docDir: string | null): string => {
|
|
125
230
|
if (!isBundleableSrc(specifier)) {
|
|
126
231
|
return specifier;
|
|
@@ -140,12 +245,7 @@ export async function buildSite(
|
|
|
140
245
|
? specifier
|
|
141
246
|
: `./${relative(projectRoot, entryPath)}`;
|
|
142
247
|
const assetPath = sidecarAssetPath(assetKey);
|
|
143
|
-
|
|
144
|
-
if (existing && existing.entryPath !== entryPath) {
|
|
145
|
-
throw new Error(
|
|
146
|
-
`"${specifier}" and "${existing.specifier}" both bundle to ${assetPath} — rename one`,
|
|
147
|
-
);
|
|
148
|
-
}
|
|
248
|
+
claimAsset(assetPath, entryPath, specifier);
|
|
149
249
|
sidecarBundles.set(assetPath, { entryPath, specifier });
|
|
150
250
|
return assetPath;
|
|
151
251
|
} catch (error) {
|
|
@@ -154,6 +254,21 @@ export async function buildSite(
|
|
|
154
254
|
}
|
|
155
255
|
};
|
|
156
256
|
|
|
257
|
+
const rewriteNpmAsset = (specifier: string): string => {
|
|
258
|
+
const assetPath = npmAssetPath(specifier);
|
|
259
|
+
try {
|
|
260
|
+
const entryPath = resolveSidecarEntry(`npm:${specifier}`, projectRoot, projectRoot);
|
|
261
|
+
claimAsset(assetPath, entryPath, specifier);
|
|
262
|
+
npmAssets.set(assetPath, entryPath);
|
|
263
|
+
return assetPath;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// A missing dependency is a hard error, like an unresolvable sidecar: the alternative is a
|
|
266
|
+
// Page that looks fine in dev and loses its stylesheet on deploy.
|
|
267
|
+
errors.push(`$head specifier "${specifier}": ${(error as Error).message}`);
|
|
268
|
+
return specifier;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
157
272
|
// ── 1b. Build the extension registry from project.json#/extensions ─────
|
|
158
273
|
const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
|
|
159
274
|
const formatRegistry = registry.formats;
|
|
@@ -231,13 +346,28 @@ export async function buildSite(
|
|
|
231
346
|
const assetRefs = new Set<string>();
|
|
232
347
|
|
|
233
348
|
// ── 4. Expand dynamic routes ────────────────────────────────────────────
|
|
349
|
+
const extensionHead = registry
|
|
350
|
+
? await collectExtensionHead(registry, projectConfig, projectRoot)
|
|
351
|
+
: [];
|
|
352
|
+
|
|
234
353
|
const routes = await expandDynamicRoutes(
|
|
235
354
|
staticRoutes,
|
|
236
355
|
projectRoot,
|
|
237
356
|
sections,
|
|
238
357
|
registry,
|
|
239
358
|
projectConfig,
|
|
359
|
+
i18n,
|
|
240
360
|
);
|
|
361
|
+
/*
|
|
362
|
+
* Every route carries the locale it serves, resolved once here rather than re-derived by each
|
|
363
|
+
* reader. It is what scopes a `ContentEntry` lookup on a localized collection: two translations
|
|
364
|
+
* of one entry share an id (§13.3), so a page that asked for `hello` without saying in which
|
|
365
|
+
* language got whichever translation was loaded first — the English copy under a French route,
|
|
366
|
+
* silently, on a page whose `<html lang>` said otherwise.
|
|
367
|
+
*/
|
|
368
|
+
for (const route of routes) {
|
|
369
|
+
(route as { locale?: string | null }).locale = localeOfRoute(route.urlPattern, i18n);
|
|
370
|
+
}
|
|
241
371
|
log(` ${routes.length} route(s) after expansion`);
|
|
242
372
|
|
|
243
373
|
let fileCount = 0;
|
|
@@ -305,9 +435,23 @@ export async function buildSite(
|
|
|
305
435
|
const doc = await readPageDocument(componentPath, formatRegistry);
|
|
306
436
|
if (doc.tagName) {
|
|
307
437
|
componentDefs.set(doc.tagName, doc);
|
|
308
|
-
const
|
|
438
|
+
const componentShadow = resolveShadowMode(doc, projectConfig.defaults);
|
|
439
|
+
const css = buildComponentCSS(
|
|
440
|
+
doc.tagName,
|
|
441
|
+
doc.style,
|
|
442
|
+
doc,
|
|
443
|
+
projectConfig.$media ?? {},
|
|
444
|
+
componentShadow,
|
|
445
|
+
);
|
|
309
446
|
if (css) {
|
|
310
|
-
|
|
447
|
+
/*
|
|
448
|
+
* A shadow component's sheet is linked from inside its own declarative shadow root, so
|
|
449
|
+
* it is written to disk but kept OUT of the page-level map: a `:host`-rooted rule in
|
|
450
|
+
* the document head matches nothing, and the duplicate request is pure cost.
|
|
451
|
+
*/
|
|
452
|
+
if (componentShadow === null) {
|
|
453
|
+
componentCSS.set(doc.tagName, css);
|
|
454
|
+
}
|
|
311
455
|
collectAssetRefs(css, assetMounts, assetRefs);
|
|
312
456
|
writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
|
|
313
457
|
fileCount += 1;
|
|
@@ -356,9 +500,127 @@ export async function buildSite(
|
|
|
356
500
|
// (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
|
|
357
501
|
const siteUrl = projectConfig.url;
|
|
358
502
|
const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
|
|
359
|
-
const sitemapEntries:
|
|
503
|
+
const sitemapEntries: SitemapEntry[] = [];
|
|
504
|
+
|
|
505
|
+
// Warned once per distinct prefix rather than once per route: one mistyped directory is one
|
|
506
|
+
// Mistake, however many pages live under it.
|
|
507
|
+
/*
|
|
508
|
+
* Translation sets, computed from the whole route table before the first page is compiled: a
|
|
509
|
+
* page's alternates include its siblings, which do not exist until every route is known.
|
|
510
|
+
* Dynamic patterns are excluded — an unexpanded `:slug` is not a URL.
|
|
511
|
+
*/
|
|
512
|
+
const concreteRoutes = routes.filter(
|
|
513
|
+
(r) => !r.urlPattern.includes(":") && !r.urlPattern.includes("*"),
|
|
514
|
+
);
|
|
515
|
+
/*
|
|
516
|
+
* A localized slug shares nothing with the page it translates — `/fr-ca/a-propos/` and `/about/`
|
|
517
|
+
* reduce to different keys — so the document says so itself, and the whole annotation follows
|
|
518
|
+
* from the one key. Read only when the project has locales to group across.
|
|
519
|
+
*/
|
|
520
|
+
const declaredKeys =
|
|
521
|
+
i18n === null
|
|
522
|
+
? new Map<string, string>()
|
|
523
|
+
: await readTranslationKeys(
|
|
524
|
+
concreteRoutes as { sourcePath: string; urlPattern: string }[],
|
|
525
|
+
formatRegistry,
|
|
526
|
+
);
|
|
527
|
+
const keyedRoutes = concreteRoutes.map((r) => ({
|
|
528
|
+
translationKey: declaredKeys.get(r.urlPattern),
|
|
529
|
+
urlPattern: r.urlPattern,
|
|
530
|
+
}));
|
|
531
|
+
const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "");
|
|
532
|
+
/*
|
|
533
|
+
* The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
|
|
534
|
+
* same reason the alternates are — and separately from them because a switcher must work in a
|
|
535
|
+
* project that has not configured `url` yet, which is every project in development.
|
|
536
|
+
*/
|
|
537
|
+
const { conflicts, sets: translationMap } = translationSets(keyedRoutes, i18n);
|
|
538
|
+
|
|
539
|
+
/*
|
|
540
|
+
* Two routes claiming to be the same page in the same language. A set is single-valued, so one
|
|
541
|
+
* of them is dropped either way; what differs is whether the author asserted it.
|
|
542
|
+
*
|
|
543
|
+
* A **declared** collision fails the build: `$translationKey` is a promise written down twice,
|
|
544
|
+
* and silently advertising one of the two URLs as *the* page in that language is a wrong answer
|
|
545
|
+
* nobody can see. A derived one warns — parallel paths that happen to collide may be a deliberate
|
|
546
|
+
* alias, and failing a build over pages that work would be the compiler overruling a decision it
|
|
547
|
+
* cannot see the reason for.
|
|
548
|
+
*/
|
|
549
|
+
for (const conflict of conflicts) {
|
|
550
|
+
const [kept, dropped] = conflict.urlPatterns;
|
|
551
|
+
const fix = conflict.declared
|
|
552
|
+
? "Give one of them a different $translationKey."
|
|
553
|
+
: "Move one out of the locale tree, or give it its own $translationKey.";
|
|
554
|
+
const message =
|
|
555
|
+
`${dropped} and ${kept} are both the "${conflict.locale}" version of ` +
|
|
556
|
+
`"${conflict.key}". A translation set names one URL per language, so ${dropped} is ` +
|
|
557
|
+
`dropped from it — it carries no alternates and no switcher. ${fix}`;
|
|
558
|
+
if (conflict.declared) {
|
|
559
|
+
errors.push(message);
|
|
560
|
+
console.error(message);
|
|
561
|
+
} else {
|
|
562
|
+
console.warn(message);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/*
|
|
567
|
+
* `prefix-always` says every URL names its language, and until now nothing checked it: a page
|
|
568
|
+
* outside the locale tree built, served, and claimed the default locale, so a site that promised
|
|
569
|
+
* no unprefixed URLs shipped them silently. Reported once with the whole list, not once per
|
|
570
|
+
* page — an author who moved a directory wants to see the extent of it.
|
|
571
|
+
*/
|
|
572
|
+
const unprefixed = unprefixedRoutes(routes, i18n);
|
|
573
|
+
if (unprefixed.length > 0) {
|
|
574
|
+
console.warn(
|
|
575
|
+
`i18n.routing is "prefix-always", but ${unprefixed.length} route(s) sit outside the locale ` +
|
|
576
|
+
`tree and are served as "${i18n?.defaultLocale}": ${unprefixed.slice(0, 10).join(", ")}` +
|
|
577
|
+
`${unprefixed.length > 10 ? `, and ${unprefixed.length - 10} more` : ""}. ` +
|
|
578
|
+
`Move them under a locale directory, or use "prefix-except-default".`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
360
581
|
|
|
582
|
+
/*
|
|
583
|
+
* `prefix-always` with no adapter, and no page at `/`. The mode leaves the root for negotiation
|
|
584
|
+
* to answer (§13.6), negotiation needs a request, and a static deployment never sees one — so the
|
|
585
|
+
* site's front door is a 404 and nothing else in the build says so. `unprefixedRoutes` above
|
|
586
|
+
* deliberately never reports `/`, because under an adapter it is handled; this is the one shape
|
|
587
|
+
* where it is not.
|
|
588
|
+
*
|
|
589
|
+
* A warning rather than a generated redirect: which URL the root should send a visitor to is a
|
|
590
|
+
* deployment decision, and inventing one would be the compiler overruling a choice it cannot see.
|
|
591
|
+
*/
|
|
592
|
+
if (
|
|
593
|
+
i18n !== null &&
|
|
594
|
+
i18n.routing === "prefix-always" &&
|
|
595
|
+
!projectConfig.build.adapter &&
|
|
596
|
+
!routes.some((r) => r.urlPattern === "/")
|
|
597
|
+
) {
|
|
598
|
+
console.warn(
|
|
599
|
+
`i18n.routing is "prefix-always" and no page claims "/", so the site root is a 404. ` +
|
|
600
|
+
`Locale negotiation answers "/" only when build.adapter is set — a static deployment has ` +
|
|
601
|
+
`no runtime to read Accept-Language. Add a redirect from "/" to "/${i18n.defaultLocale.toLowerCase()}/", ` +
|
|
602
|
+
`set build.adapter, or use "prefix-except-default".`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const warnedLocalePrefixes = new Set<string>();
|
|
607
|
+
/*
|
|
608
|
+
* A mistyped `rel` is silent: the tag is still valid HTML, still renders, and simply does
|
|
609
|
+
* nothing — the stylesheet never loads, the canonical never consolidates. Warned once per
|
|
610
|
+
* distinct value across the whole build, because the one that matters lives in the layout and is
|
|
611
|
+
* therefore on every page.
|
|
612
|
+
*/
|
|
613
|
+
const warnedRelations = new Set<string>();
|
|
361
614
|
for (const route of routes) {
|
|
615
|
+
const mismatch = undeclaredLocalePrefix(route.urlPattern, i18n);
|
|
616
|
+
if (mismatch && !warnedLocalePrefixes.has(mismatch.segment)) {
|
|
617
|
+
warnedLocalePrefixes.add(mismatch.segment);
|
|
618
|
+
console.warn(
|
|
619
|
+
`Routes under /${mismatch.segment}/ are served as "${i18n?.defaultLocale}" — ` +
|
|
620
|
+
`i18n.locales declares "${mismatch.meant}", not "${mismatch.segment}". ` +
|
|
621
|
+
`Rename the directory to "${mismatch.meant.toLowerCase()}" or declare the shorter tag.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
362
624
|
try {
|
|
363
625
|
log(` Compiling ${route.urlPattern} ...`);
|
|
364
626
|
const result = await compilePage(
|
|
@@ -373,8 +635,28 @@ export async function buildSite(
|
|
|
373
635
|
registry,
|
|
374
636
|
rewriteSidecarSrc,
|
|
375
637
|
assetMounts,
|
|
638
|
+
extensionHead,
|
|
639
|
+
rewriteNpmAsset,
|
|
640
|
+
{
|
|
641
|
+
alternates: alternateMap.get(route.urlPattern) ?? [],
|
|
642
|
+
i18n,
|
|
643
|
+
translations: translationMap.get(route.urlPattern) ?? [],
|
|
644
|
+
},
|
|
645
|
+
runtimeImports,
|
|
646
|
+
registerElementBundle,
|
|
376
647
|
);
|
|
377
648
|
|
|
649
|
+
for (const relation of result.unregisteredRelations) {
|
|
650
|
+
if (!warnedRelations.has(relation)) {
|
|
651
|
+
warnedRelations.add(relation);
|
|
652
|
+
console.warn(
|
|
653
|
+
`<link rel="${relation}"> — "${relation}" is not an IANA link relation, and a ` +
|
|
654
|
+
`relation nobody recognizes does nothing. Check the spelling, or use an absolute ` +
|
|
655
|
+
`URI if it is an extension relation (RFC 8288 §2.1.2).`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
378
660
|
// Determine which component tags are fully static (for script omission)
|
|
379
661
|
const staticTags = new Set<string>();
|
|
380
662
|
for (const [tag, def] of componentDefs) {
|
|
@@ -391,12 +673,15 @@ export async function buildSite(
|
|
|
391
673
|
componentCSS,
|
|
392
674
|
staticTags,
|
|
393
675
|
result.files.map((f: { content: string }) => f.content).join("\n"),
|
|
676
|
+
runtimeImports,
|
|
677
|
+
runtimeAssetsUsed,
|
|
394
678
|
);
|
|
395
679
|
}
|
|
396
680
|
|
|
397
681
|
// Mounted assets this page actually references — scanned from the finished HTML so a
|
|
398
682
|
// Reference is caught wherever it came from (markdown image, hand-authored page, $head).
|
|
399
683
|
collectAssetRefs(result.html, assetMounts, assetRefs);
|
|
684
|
+
collectCspSources(result.html, cspSources);
|
|
400
685
|
|
|
401
686
|
// Determine output path
|
|
402
687
|
const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
|
|
@@ -409,9 +694,21 @@ export async function buildSite(
|
|
|
409
694
|
// The two always agree.
|
|
410
695
|
const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
|
|
411
696
|
if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
|
|
697
|
+
const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
|
|
412
698
|
sitemapEntries.push({
|
|
413
|
-
|
|
699
|
+
/*
|
|
700
|
+
* The route's own timestamp when it has one, and only then the template's. A concrete
|
|
701
|
+
* route expanded from a collection carries the entry it was generated from
|
|
702
|
+
* (`sourceMtime`), because `sourcePath` still points at the `[slug]` template — so
|
|
703
|
+
* without this every post in an archive claims to have been edited the moment the
|
|
704
|
+
* template was, which is precisely the signal `<lastmod>` exists to give.
|
|
705
|
+
*/
|
|
706
|
+
lastmod:
|
|
707
|
+
typeof route.sourceMtime === "string" && route.sourceMtime !== ""
|
|
708
|
+
? route.sourceMtime
|
|
709
|
+
: toRfc3339(statSync(route.sourcePath).mtime),
|
|
414
710
|
loc: new URL(route.urlPattern, siteUrl).href,
|
|
711
|
+
...(routeAlternates.length > 0 && { alternates: routeAlternates }),
|
|
415
712
|
});
|
|
416
713
|
}
|
|
417
714
|
|
|
@@ -510,7 +807,7 @@ export async function buildSite(
|
|
|
510
807
|
const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
|
|
511
808
|
const workerSource = skipWorker
|
|
512
809
|
? null
|
|
513
|
-
: compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
|
|
810
|
+
: compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
|
|
514
811
|
|
|
515
812
|
if (workerSource) {
|
|
516
813
|
// Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
|
|
@@ -535,7 +832,17 @@ export async function buildSite(
|
|
|
535
832
|
// Only invoke the worker for server routes; everything else stays static.
|
|
536
833
|
writeFileSync(
|
|
537
834
|
resolve(outDir, "_routes.json"),
|
|
538
|
-
|
|
835
|
+
// `/` joins the include list only when there is a locale to negotiate: Pages invokes
|
|
836
|
+
// The worker for included paths only, so an uninvoked middleware is an inert one.
|
|
837
|
+
`${JSON.stringify(
|
|
838
|
+
{
|
|
839
|
+
exclude: [],
|
|
840
|
+
include: i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"],
|
|
841
|
+
version: 1,
|
|
842
|
+
},
|
|
843
|
+
null,
|
|
844
|
+
2,
|
|
845
|
+
)}\n`,
|
|
539
846
|
"utf8",
|
|
540
847
|
);
|
|
541
848
|
fileCount += 1;
|
|
@@ -544,6 +851,30 @@ export async function buildSite(
|
|
|
544
851
|
}
|
|
545
852
|
|
|
546
853
|
// ── 6d. Bundle client sidecar modules (spec.md §12) ─────────────────────
|
|
854
|
+
if (runtimeAssetsUsed.size > 0) {
|
|
855
|
+
log(`Bundling ${runtimeAssetsUsed.size} client runtime module(s)...`);
|
|
856
|
+
const runtime = await writeClientRuntime(
|
|
857
|
+
{ ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
|
|
858
|
+
outDir,
|
|
859
|
+
);
|
|
860
|
+
fileCount += runtime.written;
|
|
861
|
+
for (const error of runtime.errors) {
|
|
862
|
+
errors.push(error);
|
|
863
|
+
console.error(error);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (npmAssets.size > 0) {
|
|
868
|
+
log(`Copying ${npmAssets.size} package asset(s)...`);
|
|
869
|
+
for (const [assetPath, entryPath] of npmAssets) {
|
|
870
|
+
const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
|
|
871
|
+
mkdirSync(dirname(outfile), { recursive: true });
|
|
872
|
+
cpSync(entryPath, outfile);
|
|
873
|
+
fileCount += 1;
|
|
874
|
+
log(` ${entryPath} → ${assetPath}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
547
878
|
if (sidecarBundles.size > 0) {
|
|
548
879
|
log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
|
|
549
880
|
for (const [assetPath, bundle] of sidecarBundles) {
|
|
@@ -564,6 +895,49 @@ export async function buildSite(
|
|
|
564
895
|
}
|
|
565
896
|
}
|
|
566
897
|
|
|
898
|
+
if (elementBundles.size > 0) {
|
|
899
|
+
log(`Bundling ${elementBundles.size} npm element set(s)...`);
|
|
900
|
+
for (const [assetPath, specifiers] of elementBundles) {
|
|
901
|
+
const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
|
|
902
|
+
mkdirSync(dirname(outfile), { recursive: true });
|
|
903
|
+
try {
|
|
904
|
+
/*
|
|
905
|
+
* `external: []` — the whole point. A component package re-exporting its framework is
|
|
906
|
+
* exactly the shape Bun mis-compiles when that framework is external.
|
|
907
|
+
*/
|
|
908
|
+
await bundleSource(
|
|
909
|
+
`${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
|
|
910
|
+
{ outfile, resolveDir: projectRoot },
|
|
911
|
+
{ target: "browser" },
|
|
912
|
+
);
|
|
913
|
+
fileCount += 1;
|
|
914
|
+
log(` ${specifiers.length} element(s) → ${assetPath}`);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${
|
|
917
|
+
(error as Error).message
|
|
918
|
+
}`;
|
|
919
|
+
errors.push(msg);
|
|
920
|
+
console.error(msg);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/*
|
|
926
|
+
* Package subpaths the emitted bundles import — run AFTER every bundle exists, because the set is
|
|
927
|
+
* discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
|
|
928
|
+
*/
|
|
929
|
+
if (runtimeAssetsUsed.size > 0) {
|
|
930
|
+
const subpaths = await writeRuntimeSubpaths(outDir);
|
|
931
|
+
if (subpaths.written > 0) {
|
|
932
|
+
log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
|
|
933
|
+
}
|
|
934
|
+
fileCount += subpaths.written;
|
|
935
|
+
for (const error of subpaths.errors) {
|
|
936
|
+
errors.push(error);
|
|
937
|
+
console.error(error);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
567
941
|
// ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
|
|
568
942
|
// Section-owner classes with an `emit` capability contribute derived build artifacts
|
|
569
943
|
// (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
|
|
@@ -609,8 +983,12 @@ export async function buildSite(
|
|
|
609
983
|
if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
|
|
610
984
|
log("Generating redirects...");
|
|
611
985
|
const compiledUrls = new Set(routes.map((r) => r.urlPattern));
|
|
612
|
-
const
|
|
613
|
-
|
|
986
|
+
const redirects = generateRedirects(projectConfig.redirects, outDir, {
|
|
987
|
+
compiledUrls,
|
|
988
|
+
trailingSlash: projectConfig.build.trailingSlash,
|
|
989
|
+
});
|
|
990
|
+
fileCount += redirects.files;
|
|
991
|
+
errors.push(...redirects.errors);
|
|
614
992
|
}
|
|
615
993
|
|
|
616
994
|
// ── 7b. Generate sitemap.xml ────────────────────────────────────────────
|
|
@@ -645,6 +1023,83 @@ export async function buildSite(
|
|
|
645
1023
|
fileCount += ensureRobotsSitemap(outDir, siteUrl);
|
|
646
1024
|
}
|
|
647
1025
|
|
|
1026
|
+
// ── 7d.1 manifest.webmanifest and .well-known/security.txt ──────────────
|
|
1027
|
+
/*
|
|
1028
|
+
* After the public/ copy, and skipping anything already there. That skip is how an author ships
|
|
1029
|
+
* a **clearsigned** security.txt: signing needs a private key at build time, so the build cannot
|
|
1030
|
+
* do it, but `public/.well-known/security.txt` shadows this at zero cost.
|
|
1031
|
+
*/
|
|
1032
|
+
{
|
|
1033
|
+
const generated = [buildManifest(projectConfig), buildSecurityTxt(projectConfig, new Date())];
|
|
1034
|
+
for (const output of generated) {
|
|
1035
|
+
for (const error of output.errors) {
|
|
1036
|
+
errors.push(error);
|
|
1037
|
+
console.error(error);
|
|
1038
|
+
}
|
|
1039
|
+
for (const warning of output.warnings) {
|
|
1040
|
+
console.warn(warning);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const { skipped, written } = writeWellKnown(
|
|
1044
|
+
generated.flatMap((o) => o.files),
|
|
1045
|
+
outDir,
|
|
1046
|
+
existsSync,
|
|
1047
|
+
);
|
|
1048
|
+
fileCount += written;
|
|
1049
|
+
for (const path of skipped) {
|
|
1050
|
+
log(` ${path} — kept the copy from public/`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ── 7d.2 The service worker, or the tombstone that removes one ──────────
|
|
1055
|
+
/*
|
|
1056
|
+
* `serviceWorker: false` is not the same as omitting the key, and this is the one place in the
|
|
1057
|
+
* build where that distinction carries weight. A worker is sticky: deleting the file leaves
|
|
1058
|
+
* every previous visitor running the old one forever, because a 404 at that URL is not an
|
|
1059
|
+
* instruction to stop. `false` says "I had one" and emits the instruction.
|
|
1060
|
+
*/
|
|
1061
|
+
{
|
|
1062
|
+
const sw = normalizeServiceWorker(projectConfig.serviceWorker);
|
|
1063
|
+
if (sw !== null) {
|
|
1064
|
+
const output = sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir);
|
|
1065
|
+
for (const warning of output.warnings) {
|
|
1066
|
+
console.warn(warning);
|
|
1067
|
+
}
|
|
1068
|
+
for (const error of output.errors) {
|
|
1069
|
+
errors.push(error);
|
|
1070
|
+
console.error(error);
|
|
1071
|
+
}
|
|
1072
|
+
writeFileSync(join(outDir, output.path as string), output.source, "utf8");
|
|
1073
|
+
fileCount += 1;
|
|
1074
|
+
log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ── 7e. Response headers and the Jekyll opt-out ─────────────────────────
|
|
1079
|
+
// Also after the public/ copy, for the same reason — but PREPENDING rather than appending, since
|
|
1080
|
+
// A later `_headers` rule wins for a duplicate header name and the author's block must override.
|
|
1081
|
+
{
|
|
1082
|
+
const { errors: headerErrors, rules: securityRules } = buildHeaderRules(
|
|
1083
|
+
projectConfig.build,
|
|
1084
|
+
cspSources,
|
|
1085
|
+
);
|
|
1086
|
+
errors.push(...headerErrors);
|
|
1087
|
+
// Runs here, after every emitter, so it can see which of these files the build actually wrote.
|
|
1088
|
+
const rules = [...securityRules, ...contentTypeRules(outDir)];
|
|
1089
|
+
if (rules.length > 0) {
|
|
1090
|
+
log("Writing response headers...");
|
|
1091
|
+
fileCount += writeHeaders(outDir, rules);
|
|
1092
|
+
const { adapter } = projectConfig.build;
|
|
1093
|
+
if (adapter === "node" || adapter === "bun") {
|
|
1094
|
+
console.warn(
|
|
1095
|
+
`The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
|
|
1096
|
+
`rather than configuration — apply these headers at the reverse proxy in front of it.`,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
fileCount += writeNoJekyll(outDir);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
648
1103
|
// ── 8. Copy declarative file mappings ──────────────────────────────────
|
|
649
1104
|
if (projectConfig.copy) {
|
|
650
1105
|
log("Copying mapped files...");
|
|
@@ -683,19 +1138,28 @@ export async function buildSite(
|
|
|
683
1138
|
* serverHandler: string | null;
|
|
684
1139
|
* doc: JxDocument;
|
|
685
1140
|
* }>}
|
|
1141
|
+
* Every parameter is required. There is exactly one caller and it passes all fifteen, so the
|
|
1142
|
+
* defaults this list used to carry described no reachable call — and one of them, an identity
|
|
1143
|
+
* `rewriteNpmAsset`, was a function that could never run pretending to be a safety net.
|
|
686
1144
|
*/
|
|
687
1145
|
async function compilePage(
|
|
688
1146
|
route: SiteRoute,
|
|
689
1147
|
projectConfig: ProjectConfig,
|
|
690
1148
|
projectRoot: string,
|
|
691
|
-
sections: Record<string, unknown
|
|
692
|
-
imageCache: CacheManifest | null
|
|
693
|
-
componentDefs
|
|
694
|
-
imageMetaCache: ImageMetaCache | null
|
|
695
|
-
formatRegistry
|
|
696
|
-
registry
|
|
697
|
-
rewriteSidecarSrc
|
|
698
|
-
assetMounts: readonly AssetMount[]
|
|
1149
|
+
sections: Record<string, unknown>,
|
|
1150
|
+
imageCache: CacheManifest | null,
|
|
1151
|
+
componentDefs: Map<string, JxElement>,
|
|
1152
|
+
imageMetaCache: ImageMetaCache | null,
|
|
1153
|
+
formatRegistry: FormatRegistry | undefined,
|
|
1154
|
+
registry: ExtensionRegistry | undefined,
|
|
1155
|
+
rewriteSidecarSrc: ((specifier: string, docDir: string | null) => string) | undefined,
|
|
1156
|
+
assetMounts: readonly AssetMount[],
|
|
1157
|
+
extensionHead: readonly JxHeadEntry[],
|
|
1158
|
+
rewriteNpmAsset: (specifier: string) => string,
|
|
1159
|
+
locale: PageLocaleContext,
|
|
1160
|
+
runtimeImports: Record<string, string>,
|
|
1161
|
+
/** Registers a page's npm `$elements` set as one bundle and returns its URL. */
|
|
1162
|
+
registerElementBundle: (specifiers: string[]) => string,
|
|
699
1163
|
) {
|
|
700
1164
|
// Load the raw page document (.json natively, other formats via the registry)
|
|
701
1165
|
const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
|
|
@@ -713,7 +1177,19 @@ async function compilePage(
|
|
|
713
1177
|
delete layoutDoc._pageTitle;
|
|
714
1178
|
|
|
715
1179
|
// Inject $site and $page context
|
|
716
|
-
injectContext(layoutDoc, projectConfig, route, projectRoot);
|
|
1180
|
+
injectContext(layoutDoc, projectConfig, route, projectRoot, locale.i18n, locale.translations);
|
|
1181
|
+
|
|
1182
|
+
/*
|
|
1183
|
+
* The page's language and writing direction. `$lang` on the document wins over the locale its
|
|
1184
|
+
* route implies, and `dir` is emitted only when it is `rtl` or the author asked — `ltr` is
|
|
1185
|
+
* HTML's default and writing it on every page says nothing.
|
|
1186
|
+
*/
|
|
1187
|
+
const language = pageLanguage({
|
|
1188
|
+
defaults: projectConfig.defaults,
|
|
1189
|
+
pageDir: typeof pageDoc.$dir === "string" ? pageDoc.$dir : undefined,
|
|
1190
|
+
pageLang: typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined,
|
|
1191
|
+
routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
|
|
1192
|
+
});
|
|
717
1193
|
|
|
718
1194
|
// Resolve generic $prototype entries via .class.json imports (and lower registry classes)
|
|
719
1195
|
await resolvePrototypes(layoutDoc, route, projectRoot, {
|
|
@@ -739,8 +1215,25 @@ async function compilePage(
|
|
|
739
1215
|
}
|
|
740
1216
|
|
|
741
1217
|
// Resolve template strings in $head entries
|
|
742
|
-
|
|
743
|
-
|
|
1218
|
+
/*
|
|
1219
|
+
* Bare specifiers resolve on ALL THREE heads, not just the project's.
|
|
1220
|
+
*
|
|
1221
|
+
* A `$head` entry may name a package file — a component library's stylesheet, most often — and
|
|
1222
|
+
* `rewriteNpmAsset` copies it into `/assets/` so it survives deploy. That ran only over
|
|
1223
|
+
* `projectConfig.$head`, so the same link written on a PAGE shipped its bare specifier verbatim
|
|
1224
|
+
* into the HTML: the browser resolved `@scope/pkg/x.css` against the page URL and 404'd, and the
|
|
1225
|
+
* page rendered unstyled with nothing in the build log. `resolveHeadBareSpecifiers` leaves an
|
|
1226
|
+
* entry with no bare specifier untouched, so widening the scope costs nothing where it does not
|
|
1227
|
+
* apply.
|
|
1228
|
+
*/
|
|
1229
|
+
const resolvedPageHead = resolveHeadBareSpecifiers(
|
|
1230
|
+
resolveHeadTemplates(pageHead, scope),
|
|
1231
|
+
rewriteNpmAsset,
|
|
1232
|
+
);
|
|
1233
|
+
const resolvedLayoutHead = resolveHeadBareSpecifiers(
|
|
1234
|
+
resolveHeadTemplates(layoutHead, scope),
|
|
1235
|
+
rewriteNpmAsset,
|
|
1236
|
+
);
|
|
744
1237
|
|
|
745
1238
|
// Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
|
|
746
1239
|
// So that timing: "compiler" data is baked into the static HTML
|
|
@@ -754,13 +1247,19 @@ async function compilePage(
|
|
|
754
1247
|
media: { ...projectConfig.$media, ...layoutDoc.$media },
|
|
755
1248
|
rules: [],
|
|
756
1249
|
};
|
|
757
|
-
expandComponents(layoutDoc, componentDefs, slotCss);
|
|
1250
|
+
expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
|
|
758
1251
|
|
|
759
1252
|
// Strip resolved timing: "compiler" state entries — they're now baked into the tree
|
|
760
1253
|
// And keeping them would cause isDynamic() to misclassify the page as dynamic.
|
|
761
1254
|
// Also strip resolved content arrays (from ContentCollection) that have been
|
|
762
1255
|
// Baked into unrolled map templates.
|
|
763
1256
|
if (layoutDoc.state) {
|
|
1257
|
+
// An array is only strippable if nothing that survives the build still reads it. A map
|
|
1258
|
+
// Expansion is one consumer, not the only one: the same array is routinely also read by a
|
|
1259
|
+
// Computed at runtime, and dropping it there left `state.rows` undefined in the browser while
|
|
1260
|
+
// The build reported success (issue #122). `timing: "compiler"` is excluded from this rescue —
|
|
1261
|
+
// That is the author declaring the entry build-time-only, so it is stripped as before.
|
|
1262
|
+
const strippable = new Set<string>();
|
|
764
1263
|
for (const [key, def] of Object.entries(layoutDoc.state)) {
|
|
765
1264
|
if (key === "$site" || key === "$page") {
|
|
766
1265
|
continue;
|
|
@@ -773,13 +1272,54 @@ async function compilePage(
|
|
|
773
1272
|
) {
|
|
774
1273
|
delete layoutDoc.state[key];
|
|
775
1274
|
} else if (Array.isArray(def)) {
|
|
776
|
-
|
|
1275
|
+
strippable.add(key);
|
|
777
1276
|
}
|
|
778
1277
|
}
|
|
1278
|
+
// Iterated to a fixpoint: rescuing one array can reveal a read of another from its own def.
|
|
1279
|
+
let rescued = true;
|
|
1280
|
+
while (rescued) {
|
|
1281
|
+
rescued = false;
|
|
1282
|
+
const surviving = { ...layoutDoc.state } as Record<string, unknown>;
|
|
1283
|
+
for (const key of strippable) {
|
|
1284
|
+
delete surviving[key];
|
|
1285
|
+
}
|
|
1286
|
+
const haystack = JSON.stringify({ children: layoutDoc.children, state: surviving });
|
|
1287
|
+
for (const key of strippable) {
|
|
1288
|
+
if (referencesStateKey(haystack, key)) {
|
|
1289
|
+
strippable.delete(key);
|
|
1290
|
+
rescued = true;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
for (const key of strippable) {
|
|
1295
|
+
delete layoutDoc.state[key];
|
|
1296
|
+
}
|
|
779
1297
|
}
|
|
780
1298
|
|
|
781
1299
|
// Resolve bare npm specifiers in $head (e.g. "@pkg/name/file.css" → "/node_modules/@pkg/name/file.css")
|
|
782
|
-
|
|
1300
|
+
/*
|
|
1301
|
+
* Extension `head` contributions sit BELOW the project's own `$head`, so a project that writes
|
|
1302
|
+
* its own feed link keeps it — the same "author wins" rule every auto-injected entry follows.
|
|
1303
|
+
*/
|
|
1304
|
+
/*
|
|
1305
|
+
* The registration script. Byte-identical on every page, so a strict `script-src` needs exactly
|
|
1306
|
+
* one hash for it (§14.3.1) — and it is only emitted when a worker actually exists, since a
|
|
1307
|
+
* tombstone must not be registered by the page that is trying to get rid of it.
|
|
1308
|
+
*/
|
|
1309
|
+
const swConfig = normalizeServiceWorker(projectConfig.serviceWorker);
|
|
1310
|
+
const swHead: JxHeadEntry[] =
|
|
1311
|
+
swConfig === null || swConfig === false
|
|
1312
|
+
? []
|
|
1313
|
+
: [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
|
|
1314
|
+
|
|
1315
|
+
const resolvedSiteHead = [
|
|
1316
|
+
// The manifest link and theme colour are first-party and unconditional once declared, so they
|
|
1317
|
+
// Sit with the extension contributions: below the project's own $head, which still wins.
|
|
1318
|
+
...(manifestHeadEntries(projectConfig) as JxHeadEntry[]),
|
|
1319
|
+
...swHead,
|
|
1320
|
+
...extensionHead,
|
|
1321
|
+
...resolveHeadBareSpecifiers(projectConfig.$head ?? [], rewriteNpmAsset),
|
|
1322
|
+
];
|
|
783
1323
|
|
|
784
1324
|
// Merge $head from site + layout + page
|
|
785
1325
|
const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
|
|
@@ -787,6 +1327,7 @@ async function compilePage(
|
|
|
787
1327
|
charset: projectConfig.defaults?.charset ?? "utf8",
|
|
788
1328
|
...(projectConfig.name != null && { siteName: projectConfig.name }),
|
|
789
1329
|
...(projectConfig.url != null && { siteUrl: projectConfig.url }),
|
|
1330
|
+
...(locale.alternates.length > 0 && { alternates: locale.alternates }),
|
|
790
1331
|
pageUrl: route.urlPattern,
|
|
791
1332
|
});
|
|
792
1333
|
|
|
@@ -802,8 +1343,12 @@ async function compilePage(
|
|
|
802
1343
|
mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
|
|
803
1344
|
}
|
|
804
1345
|
|
|
805
|
-
|
|
806
|
-
|
|
1346
|
+
/*
|
|
1347
|
+
* Responsive images. This runs whether or not `images.optimize` is on: the loading pass inside
|
|
1348
|
+
* it is governed by `images.lazyLoad`, and declining to generate variants for an image says
|
|
1349
|
+
* nothing about when the browser should fetch it.
|
|
1350
|
+
*/
|
|
1351
|
+
if (projectConfig.images) {
|
|
807
1352
|
await transformImageNodes(
|
|
808
1353
|
layoutDoc,
|
|
809
1354
|
projectConfig.images as ImageConfig,
|
|
@@ -816,7 +1361,17 @@ async function compilePage(
|
|
|
816
1361
|
|
|
817
1362
|
// Compile the document using the existing compiler
|
|
818
1363
|
const result = await compile(layoutDoc, {
|
|
819
|
-
lang:
|
|
1364
|
+
lang: language.lang,
|
|
1365
|
+
/*
|
|
1366
|
+
* The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
|
|
1367
|
+
* that reached compile-static/compile-client kept loading its runtime from esm.sh even after
|
|
1368
|
+
* the self-hosting landed, because `injectComponentScripts` sees a map already present and
|
|
1369
|
+
* declines to add its own. Both halves have to name the same URLs.
|
|
1370
|
+
*/
|
|
1371
|
+
...(runtimeImports["@vue/reactivity"] === undefined
|
|
1372
|
+
? {}
|
|
1373
|
+
: { reactivitySrc: runtimeImports["@vue/reactivity"] }),
|
|
1374
|
+
...(runtimeImports["lit-html"] === undefined ? {} : { litHtmlSrc: runtimeImports["lit-html"] }),
|
|
820
1375
|
prePaintScheme: false, // Injected via the merged <head> above, not the target template
|
|
821
1376
|
projectStyle: projectConfig.style ?? null,
|
|
822
1377
|
...(rewriteSidecarSrc === undefined
|
|
@@ -837,14 +1392,35 @@ async function compilePage(
|
|
|
837
1392
|
}
|
|
838
1393
|
|
|
839
1394
|
// Post-process: inject merged <head> content into the compiled HTML
|
|
840
|
-
result.html = injectHead(result.html, mergedHead,
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1395
|
+
result.html = injectHead(result.html, mergedHead, language);
|
|
1396
|
+
|
|
1397
|
+
/*
|
|
1398
|
+
* Inject <script type="module"> for npm $elements (cherry-picked component imports).
|
|
1399
|
+
*
|
|
1400
|
+
* BOTH documents, because `$elements` is legal on either and `resolveLayout` does not merge the
|
|
1401
|
+
* page's into the layout's. Reading only the layout dropped every bare specifier a PAGE declared:
|
|
1402
|
+
* the page still rendered `<sl-card>` and friends into the markup, nothing imported the modules
|
|
1403
|
+
* that define them, and the result was a page of inert unknown elements with no error anywhere.
|
|
1404
|
+
* A local `./x.json` element never hit this, because those are registered through `componentDefs`
|
|
1405
|
+
* on a different path — which is why the failure looked component-specific rather than general.
|
|
1406
|
+
*/
|
|
1407
|
+
/*
|
|
1408
|
+
* Deliberately the original predicate, not the stricter module-level `isBareSpecifier`: this fix
|
|
1409
|
+
* is about WHERE entries are read from, and narrowing WHICH ones count would silently drop an
|
|
1410
|
+
* absolute-URL `$element` that reaches this path today.
|
|
1411
|
+
*/
|
|
1412
|
+
const isNpmElementEntry = (e: JxElement | string): e is string =>
|
|
1413
|
+
typeof e === "string" && !e.startsWith("./") && !e.startsWith("../");
|
|
1414
|
+
const npmElements = [
|
|
1415
|
+
...new Set(
|
|
1416
|
+
[
|
|
1417
|
+
...((layoutDoc.$elements ?? []) as (JxElement | string)[]),
|
|
1418
|
+
...((pageDoc.$elements ?? []) as (JxElement | string)[]),
|
|
1419
|
+
].filter((e) => isNpmElementEntry(e)),
|
|
1420
|
+
),
|
|
1421
|
+
];
|
|
846
1422
|
if (npmElements.length > 0) {
|
|
847
|
-
result.html = injectNpmElementScripts(result.html, npmElements
|
|
1423
|
+
result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
|
|
848
1424
|
}
|
|
849
1425
|
|
|
850
1426
|
// Compile server handler if applicable (skip when provider bundles site-wide)
|
|
@@ -867,6 +1443,12 @@ async function compilePage(
|
|
|
867
1443
|
excludeFromSitemap: pageDoc.$sitemap === false,
|
|
868
1444
|
files: result.files,
|
|
869
1445
|
html: result.html,
|
|
1446
|
+
/*
|
|
1447
|
+
* Reported rather than warned about here: a mistyped `rel` almost always lives in the site or
|
|
1448
|
+
* layout `$head`, which means it is on every page, and the caller is the only scope that can
|
|
1449
|
+
* say it once instead of four hundred times.
|
|
1450
|
+
*/
|
|
1451
|
+
unregisteredRelations: unregisteredHeadRelations(mergedHead),
|
|
870
1452
|
serverHandler,
|
|
871
1453
|
};
|
|
872
1454
|
}
|
|
@@ -957,6 +1539,39 @@ function buildMountSpecs(
|
|
|
957
1539
|
return { connectors, mounts };
|
|
958
1540
|
}
|
|
959
1541
|
|
|
1542
|
+
/**
|
|
1543
|
+
* Gather every extension's `<head>` contribution, once, before the first page is built.
|
|
1544
|
+
*
|
|
1545
|
+
* Separate from `emit` because the two answer different questions at different times: `emit`
|
|
1546
|
+
* derives files from loaded content long after every page was written, while this derives entries
|
|
1547
|
+
* from CONFIGURATION and must run first. Gated like every other section capability — a class only
|
|
1548
|
+
* contributes when the project declares a non-empty value for its section key.
|
|
1549
|
+
*/
|
|
1550
|
+
async function collectExtensionHead(
|
|
1551
|
+
registry: ExtensionRegistry,
|
|
1552
|
+
projectConfig: ProjectConfig,
|
|
1553
|
+
projectRoot: string,
|
|
1554
|
+
): Promise<JxHeadEntry[]> {
|
|
1555
|
+
const out: JxHeadEntry[] = [];
|
|
1556
|
+
for (const entry of registry.headProviders()) {
|
|
1557
|
+
const key = entry.project?.key;
|
|
1558
|
+
const sectionValue = key === undefined ? null : (projectConfig as Record<string, unknown>)[key];
|
|
1559
|
+
if (key !== undefined && (sectionValue === undefined || sectionValue === null)) {
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
const contributed = (await entry.call("head", sectionValue ?? null, {
|
|
1564
|
+
projectConfig,
|
|
1565
|
+
root: projectRoot,
|
|
1566
|
+
})) as JxHeadEntry[] | null;
|
|
1567
|
+
out.push(...(contributed ?? []));
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
console.warn(`${entry.name} head capability failed: ${(error as Error).message}`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return out;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
960
1575
|
/**
|
|
961
1576
|
* Resolve template strings in $head entries against the compiled scope.
|
|
962
1577
|
*
|
|
@@ -964,6 +1579,21 @@ function buildMountSpecs(
|
|
|
964
1579
|
* @param {Record<string, unknown>} scope
|
|
965
1580
|
* @returns {JxHeadEntry[]}
|
|
966
1581
|
*/
|
|
1582
|
+
function resolveTemplatesDeep(value: unknown, scope: Record<string, unknown>): unknown {
|
|
1583
|
+
if (typeof value === "string") {
|
|
1584
|
+
return isTemplateString(value) ? (evaluateStaticTemplate(value, scope) ?? value) : value;
|
|
1585
|
+
}
|
|
1586
|
+
if (Array.isArray(value)) {
|
|
1587
|
+
return value.map((v) => resolveTemplatesDeep(v, scope));
|
|
1588
|
+
}
|
|
1589
|
+
if (value !== null && typeof value === "object") {
|
|
1590
|
+
return Object.fromEntries(
|
|
1591
|
+
Object.entries(value).map(([k, v]) => [k, resolveTemplatesDeep(v, scope)]),
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
return value;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
967
1597
|
function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
|
|
968
1598
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
969
1599
|
if (!entry || typeof entry !== "object") {
|
|
@@ -984,20 +1614,32 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
|
|
|
984
1614
|
resolved.textContent =
|
|
985
1615
|
(evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
|
|
986
1616
|
resolved.textContent;
|
|
1617
|
+
} else if (resolved.textContent !== null && typeof resolved.textContent === "object") {
|
|
1618
|
+
// A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
|
|
1619
|
+
// Never reference the page it describes.
|
|
1620
|
+
resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as Record<
|
|
1621
|
+
string,
|
|
1622
|
+
unknown
|
|
1623
|
+
>;
|
|
987
1624
|
}
|
|
988
1625
|
return resolved;
|
|
989
1626
|
});
|
|
990
1627
|
}
|
|
991
1628
|
|
|
992
1629
|
/**
|
|
993
|
-
* Resolve bare npm specifiers in $head entry attributes (href, src)
|
|
994
|
-
*
|
|
995
|
-
* "
|
|
1630
|
+
* Resolve bare npm specifiers in $head entry attributes (href, src) to their copied `/assets/` URL.
|
|
1631
|
+
*
|
|
1632
|
+
* `"@shoelace-style/shoelace/dist/themes/light.css"` →
|
|
1633
|
+
* `"/assets/shoelace-style-shoelace-dist-themes-light.css"`.
|
|
996
1634
|
*
|
|
997
1635
|
* @param {JxHeadEntry[]} headEntries
|
|
1636
|
+
* @param {(specifier: string) => string} rewrite
|
|
998
1637
|
* @returns {JxHeadEntry[]}
|
|
999
1638
|
*/
|
|
1000
|
-
function resolveHeadBareSpecifiers(
|
|
1639
|
+
function resolveHeadBareSpecifiers(
|
|
1640
|
+
headEntries: JxHeadEntry[],
|
|
1641
|
+
rewrite: (specifier: string) => string,
|
|
1642
|
+
) {
|
|
1001
1643
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
1002
1644
|
if (!entry || typeof entry !== "object" || !entry.attributes) {
|
|
1003
1645
|
return entry;
|
|
@@ -1006,7 +1648,7 @@ function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
|
|
|
1006
1648
|
for (const key of ["href", "src"]) {
|
|
1007
1649
|
const val = resolved.attributes[key];
|
|
1008
1650
|
if (typeof val === "string" && isBareSpecifier(val)) {
|
|
1009
|
-
resolved.attributes[key] =
|
|
1651
|
+
resolved.attributes[key] = rewrite(val);
|
|
1010
1652
|
}
|
|
1011
1653
|
}
|
|
1012
1654
|
return resolved;
|
|
@@ -1229,6 +1871,19 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1229
1871
|
}
|
|
1230
1872
|
}
|
|
1231
1873
|
|
|
1874
|
+
/**
|
|
1875
|
+
* Whether a serialized document fragment still reads a given state key — as a `${state.key}`
|
|
1876
|
+
* template, a bare `state.key` inside a handler or computed body, or a `#/state/key` $ref.
|
|
1877
|
+
*
|
|
1878
|
+
* @param {string} haystack - JSON-serialized document fragment
|
|
1879
|
+
* @param {string} key
|
|
1880
|
+
* @returns {boolean}
|
|
1881
|
+
*/
|
|
1882
|
+
function referencesStateKey(haystack: string, key: string): boolean {
|
|
1883
|
+
const escaped = key.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
1884
|
+
return new RegExp(String.raw`\bstate\.${escaped}\b|#/state/${escaped}(?=["/])`).test(haystack);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1232
1887
|
/**
|
|
1233
1888
|
* Statically expand a mapped array to its resolved item nodes when `items` resolves to an array at
|
|
1234
1889
|
* build time, or null otherwise (leave the array node for client-side rendering).
|
|
@@ -1278,18 +1933,20 @@ interface SlotCssCollector {
|
|
|
1278
1933
|
* @param {JxElement | string} node
|
|
1279
1934
|
* @param {Map<string, JxElement>} componentDefs
|
|
1280
1935
|
* @param {SlotCssCollector} [slotCss]
|
|
1936
|
+
* @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
|
|
1281
1937
|
*/
|
|
1282
1938
|
function expandComponents(
|
|
1283
1939
|
node: JxElement | string,
|
|
1284
1940
|
componentDefs: Map<string, JxElement>,
|
|
1285
1941
|
slotCss?: SlotCssCollector,
|
|
1942
|
+
defaults?: ProjectConfig["defaults"],
|
|
1286
1943
|
) {
|
|
1287
1944
|
if (!node || typeof node !== "object") {
|
|
1288
1945
|
return;
|
|
1289
1946
|
}
|
|
1290
1947
|
if (Array.isArray(node)) {
|
|
1291
1948
|
for (const n of node as (JxElement | string)[]) {
|
|
1292
|
-
expandComponents(n, componentDefs, slotCss);
|
|
1949
|
+
expandComponents(n, componentDefs, slotCss, defaults);
|
|
1293
1950
|
}
|
|
1294
1951
|
return;
|
|
1295
1952
|
}
|
|
@@ -1297,7 +1954,7 @@ function expandComponents(
|
|
|
1297
1954
|
// Recurse into children first (bottom-up expansion)
|
|
1298
1955
|
if (Array.isArray(node.children)) {
|
|
1299
1956
|
for (const child of node.children) {
|
|
1300
|
-
expandComponents(child, componentDefs, slotCss);
|
|
1957
|
+
expandComponents(child, componentDefs, slotCss, defaults);
|
|
1301
1958
|
}
|
|
1302
1959
|
}
|
|
1303
1960
|
|
|
@@ -1336,10 +1993,27 @@ function expandComponents(
|
|
|
1336
1993
|
.join("\n")
|
|
1337
1994
|
: null;
|
|
1338
1995
|
|
|
1339
|
-
|
|
1996
|
+
/*
|
|
1997
|
+
* In shadow mode the slotted children are NOT substituted into the prerendered markup: they
|
|
1998
|
+
* stay in the light tree as siblings of the template, where a real `<slot>` distributes them.
|
|
1999
|
+
* Passing them here instead would bake them into the shadow root, and the browser would then
|
|
2000
|
+
* render them twice.
|
|
2001
|
+
*/
|
|
2002
|
+
const shadow = resolveShadowMode(def, defaults);
|
|
2003
|
+
const innerHTML = preRenderComponentHtml(def, node.$props || null, shadow ? null : slotContent);
|
|
1340
2004
|
const isStatic = isComponentFullyStatic(def);
|
|
1341
2005
|
|
|
1342
|
-
|
|
2006
|
+
/*
|
|
2007
|
+
* A declarative shadow root: markup the parser materializes before any script runs, which the
|
|
2008
|
+
* element then adopts rather than replaces (spec.md §16.6). The stylesheet link moves inside
|
|
2009
|
+
* it because a shadow root does not inherit the document's stylesheets — and it stays an
|
|
2010
|
+
* external `<link>`, so no Content-Security-Policy hash changes.
|
|
2011
|
+
*/
|
|
2012
|
+
node.innerHTML = shadow
|
|
2013
|
+
? `<template shadowrootmode="${shadow}">` +
|
|
2014
|
+
`<link rel="stylesheet" href="/components/${node.tagName}.css">` +
|
|
2015
|
+
`${innerHTML}</template>${slotContent ?? ""}`
|
|
2016
|
+
: innerHTML;
|
|
1343
2017
|
delete node.children;
|
|
1344
2018
|
|
|
1345
2019
|
// Resolve template-string host styles with props (per-instance values like background-image)
|
|
@@ -1383,6 +2057,8 @@ function expandComponents(
|
|
|
1383
2057
|
* @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
|
|
1384
2058
|
* @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
|
|
1385
2059
|
* @param {string} [islandSource] - Concatenated island modules emitted for this page
|
|
2060
|
+
* @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
|
|
1386
2062
|
* @returns {string}
|
|
1387
2063
|
*/
|
|
1388
2064
|
function injectComponentScripts(
|
|
@@ -1391,6 +2067,8 @@ function injectComponentScripts(
|
|
|
1391
2067
|
cssMap = new Map<string, string>(),
|
|
1392
2068
|
staticTags = new Set<string>(),
|
|
1393
2069
|
islandSource = "",
|
|
2070
|
+
runtimeImports: Record<string, string> = {},
|
|
2071
|
+
runtimeAssetsUsed = new Set<string>(),
|
|
1394
2072
|
) {
|
|
1395
2073
|
// A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
|
|
1396
2074
|
// From inside an island's client template — in which case the tag exists solely in the island's
|
|
@@ -1423,14 +2101,18 @@ function injectComponentScripts(
|
|
|
1423
2101
|
}
|
|
1424
2102
|
|
|
1425
2103
|
// Build import map (needed for @vue/reactivity and lit-html)
|
|
1426
|
-
const importMap =
|
|
1427
|
-
{
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
2104
|
+
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;
|
|
1431
2113
|
}
|
|
2114
|
+
runtimeAssetsUsed.add(url);
|
|
1432
2115
|
}
|
|
1433
|
-
</script>`;
|
|
1434
2116
|
|
|
1435
2117
|
const moduleScripts = jsTags
|
|
1436
2118
|
.map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
|
|
@@ -1445,19 +2127,31 @@ function injectComponentScripts(
|
|
|
1445
2127
|
|
|
1446
2128
|
/**
|
|
1447
2129
|
* Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
|
|
1448
|
-
*
|
|
2130
|
+
*
|
|
2131
|
+
* Bare specifiers are BUNDLED to `/assets/`, not linked into node_modules. Bundling rather than
|
|
2132
|
+
* copying because a component package imports its own dependencies by bare specifier too, and the
|
|
2133
|
+
* import map only ever carried two entries.
|
|
1449
2134
|
*
|
|
1450
2135
|
* @param {string} html
|
|
1451
2136
|
* @param {string[]} npmElements - Bare specifier strings, e.g.
|
|
1452
2137
|
* "@shoelace-style/shoelace/components/button/button.js"
|
|
1453
2138
|
* @returns {string}
|
|
1454
2139
|
*/
|
|
1455
|
-
function injectNpmElementScripts(html: string,
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
2140
|
+
function injectNpmElementScripts(html: string, bundleUrl: string) {
|
|
2141
|
+
/*
|
|
2142
|
+
* ONE self-contained module for the whole set, and no import map.
|
|
2143
|
+
*
|
|
2144
|
+
* Each specifier used to be bundled separately with `lit-html` external, shared through the page
|
|
2145
|
+
* import map. Two things were wrong with that. Bun's codegen for `export *` from an external
|
|
2146
|
+
* emits a `__reExport(…, lit_html)` against a namespace it never imports, so the bundle threw
|
|
2147
|
+
* `lit_html is not defined` before defining anything — measured, not inferred. And a package
|
|
2148
|
+
* brings its own framework copy anyway, so sharing Jx's was a version-skew bet with no upside.
|
|
2149
|
+
*
|
|
2150
|
+
* Inlining per specifier would fix the codegen and cost seven copies of Lit on this page — a
|
|
2151
|
+
* documented breakage, not merely 462kb. Bundling the SET as one entry gives one copy, no
|
|
2152
|
+
* external, and 190kb for the seven-component demo that motivated this.
|
|
2153
|
+
*/
|
|
2154
|
+
return html.replace("</body>", ` <script type="module" src="${bundleUrl}"></script>\n</body>`);
|
|
1461
2155
|
}
|
|
1462
2156
|
|
|
1463
2157
|
/**
|
|
@@ -1468,7 +2162,11 @@ function injectNpmElementScripts(html: string, npmElements: string[]) {
|
|
|
1468
2162
|
* @param {string} lang
|
|
1469
2163
|
* @returns {string}
|
|
1470
2164
|
*/
|
|
1471
|
-
function injectHead(
|
|
2165
|
+
function injectHead(
|
|
2166
|
+
html: string,
|
|
2167
|
+
headEntries: JxHeadEntry[],
|
|
2168
|
+
root: { lang: string; dir?: string },
|
|
2169
|
+
) {
|
|
1472
2170
|
const headHtml = renderHead(headEntries);
|
|
1473
2171
|
|
|
1474
2172
|
// Replace the existing <head>...</head> block, preserving compiler-generated <style> and <script> blocks
|
|
@@ -1490,12 +2188,18 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
|
|
|
1490
2188
|
result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
|
|
1491
2189
|
}
|
|
1492
2190
|
|
|
1493
|
-
// Set
|
|
2191
|
+
// Set lang and dir on <html>. `dir` matters for the same reason `lang` does: without it, a
|
|
2192
|
+
// Right-to-left page renders left-to-right, and the default is not "unset" but "ltr".
|
|
1494
2193
|
result = result.replace(/<html\s[^>]*>/i, (match: string) => {
|
|
1495
|
-
|
|
1496
|
-
|
|
2194
|
+
let tag = /lang=/.test(match)
|
|
2195
|
+
? match.replace(/lang="[^"]*"/, `lang="${root.lang}"`)
|
|
2196
|
+
: match.replace("<html", `<html lang="${root.lang}"`);
|
|
2197
|
+
if (root.dir !== undefined) {
|
|
2198
|
+
tag = /\sdir=/.test(tag)
|
|
2199
|
+
? tag.replace(/\sdir="[^"]*"/, ` dir="${root.dir}"`)
|
|
2200
|
+
: tag.replace("<html", `<html dir="${root.dir}"`);
|
|
1497
2201
|
}
|
|
1498
|
-
return
|
|
2202
|
+
return tag;
|
|
1499
2203
|
});
|
|
1500
2204
|
|
|
1501
2205
|
return result;
|
|
@@ -1536,41 +2240,113 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
|
|
|
1536
2240
|
* @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
|
|
1537
2241
|
* @returns {number} Number of files written
|
|
1538
2242
|
*/
|
|
2243
|
+
/**
|
|
2244
|
+
* Which statuses get an HTML fallback, and why each other one does not.
|
|
2245
|
+
*
|
|
2246
|
+
* An HTML meta-refresh is a _client-side_ redirect: the browser fetches the source, then navigates.
|
|
2247
|
+
* That is a reasonable stand-in for a 301 or a 303 on a host that ignores `_redirects`, and it is
|
|
2248
|
+
* actively wrong for everything else.
|
|
2249
|
+
*/
|
|
2250
|
+
const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean; why: string }> = {
|
|
2251
|
+
// The permanent case: a canonical link is exactly the right signal.
|
|
2252
|
+
301: { canonical: true, html: true, why: "" },
|
|
2253
|
+
// Temporary — a canonical link would assert the permanence the status denies.
|
|
2254
|
+
302: { canonical: false, html: true, why: "" },
|
|
2255
|
+
// "See other, with GET" is what a meta-refresh already does.
|
|
2256
|
+
303: { canonical: false, html: true, why: "" },
|
|
2257
|
+
307: {
|
|
2258
|
+
canonical: false,
|
|
2259
|
+
html: false,
|
|
2260
|
+
why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
|
|
2261
|
+
},
|
|
2262
|
+
308: {
|
|
2263
|
+
canonical: false,
|
|
2264
|
+
html: false,
|
|
2265
|
+
why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
|
|
2266
|
+
},
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Emit `dist/_redirects` plus an HTML fallback for the literal sources whose status has one.
|
|
2271
|
+
*
|
|
2272
|
+
* @param {Record<
|
|
2273
|
+
* string,
|
|
2274
|
+
* string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2275
|
+
* >} redirects
|
|
2276
|
+
* @param {string} outDir
|
|
2277
|
+
* @param {{ compiledUrls?: Set<string>; trailingSlash?: string }} [opts]
|
|
2278
|
+
* @returns {{ files: number; errors: string[] }}
|
|
2279
|
+
*/
|
|
1539
2280
|
function generateRedirects(
|
|
1540
|
-
redirects: Record<
|
|
2281
|
+
redirects: Record<
|
|
2282
|
+
string,
|
|
2283
|
+
string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2284
|
+
>,
|
|
1541
2285
|
outDir: string,
|
|
1542
|
-
|
|
2286
|
+
opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
|
|
1543
2287
|
) {
|
|
1544
|
-
let
|
|
2288
|
+
let files = 0;
|
|
2289
|
+
const errors: string[] = [];
|
|
1545
2290
|
const redirectLines: string[] = [];
|
|
1546
2291
|
const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
|
|
1547
|
-
const compiled = new Set([...compiledUrls].map((u) => normalizeUrl(u)));
|
|
2292
|
+
const compiled = new Set([...(opts.compiledUrls ?? [])].map((u) => normalizeUrl(u)));
|
|
2293
|
+
const trailingSlash = opts.trailingSlash ?? "always";
|
|
1548
2294
|
|
|
1549
2295
|
for (const [source, target] of Object.entries(redirects)) {
|
|
1550
2296
|
const dest = typeof target === "object" ? target.destination : target;
|
|
1551
|
-
const
|
|
2297
|
+
const isRewrite = typeof target === "object" && "rewrite" in target && target.rewrite;
|
|
2298
|
+
const status = isRewrite
|
|
2299
|
+
? 200
|
|
2300
|
+
: typeof target === "object" && "status" in target
|
|
2301
|
+
? (target.status ?? 301)
|
|
2302
|
+
: 301;
|
|
2303
|
+
|
|
2304
|
+
if (!isRewrite && REDIRECT_HTML_POLICY[status] === undefined) {
|
|
2305
|
+
errors.push(
|
|
2306
|
+
`Redirect "${source}" has status ${status}, which is not an RFC 9110 §15.4 redirection ` +
|
|
2307
|
+
`status. Use one of ${Object.keys(REDIRECT_HTML_POLICY).join(", ")}, or ` +
|
|
2308
|
+
`{ destination, rewrite: true } for a proxy.`,
|
|
2309
|
+
);
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
1552
2312
|
|
|
1553
|
-
|
|
2313
|
+
redirectLines.push(`${source} ${dest} ${status}`);
|
|
2314
|
+
|
|
2315
|
+
// A pattern source cannot be a file on disk, so it lives only in `_redirects`.
|
|
1554
2316
|
if (source.includes(":") || source.includes("*")) {
|
|
1555
|
-
redirectLines.push(`${source} ${dest} ${status}`);
|
|
1556
2317
|
continue;
|
|
1557
2318
|
}
|
|
1558
2319
|
|
|
1559
2320
|
if (compiled.has(normalizeUrl(source))) {
|
|
2321
|
+
const what = isRewrite ? "rewrite" : "redirect";
|
|
1560
2322
|
console.warn(
|
|
1561
|
-
`
|
|
1562
|
-
`remove
|
|
2323
|
+
`The ${what} "${source}" collides with a compiled page at the same route — ` +
|
|
2324
|
+
`remove one or the other.`,
|
|
1563
2325
|
);
|
|
1564
2326
|
}
|
|
1565
2327
|
|
|
1566
|
-
|
|
1567
|
-
|
|
2328
|
+
/*
|
|
2329
|
+
* A rewrite serves the destination's content AT the source URL. Writing an HTML file there
|
|
2330
|
+
* shadows the rewrite on hosts that honour `_redirects`, and turns it into a redirect on the
|
|
2331
|
+
* hosts that do not — so it is wrong in both directions. This was the bug.
|
|
2332
|
+
*/
|
|
2333
|
+
if (isRewrite) {
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2336
|
+
const policy = REDIRECT_HTML_POLICY[status]!;
|
|
2337
|
+
if (!policy.html) {
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const htmlPath = routeToOutputPath(source, outDir, trailingSlash);
|
|
2342
|
+
const canonical = policy.canonical
|
|
2343
|
+
? `\n <link rel="canonical" href="${escapeAttr(dest)}">`
|
|
2344
|
+
: `\n <meta name="robots" content="noindex">`;
|
|
1568
2345
|
const html = `<!DOCTYPE html>
|
|
1569
2346
|
<html>
|
|
1570
2347
|
<head>
|
|
1571
2348
|
<meta charset="utf-8">
|
|
1572
|
-
<meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}"
|
|
1573
|
-
<link rel="canonical" href="${escapeAttr(dest)}">
|
|
2349
|
+
<meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">${canonical}
|
|
1574
2350
|
<title>Redirecting...</title>
|
|
1575
2351
|
</head>
|
|
1576
2352
|
<body>
|
|
@@ -1579,17 +2355,15 @@ function generateRedirects(
|
|
|
1579
2355
|
</html>`;
|
|
1580
2356
|
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
1581
2357
|
writeFileSync(htmlPath, html, "utf8");
|
|
1582
|
-
|
|
1583
|
-
redirectLines.push(`${source} ${dest} ${status}`);
|
|
2358
|
+
files += 1;
|
|
1584
2359
|
}
|
|
1585
2360
|
|
|
1586
|
-
// Write _redirects file (Netlify/Cloudflare format)
|
|
1587
2361
|
if (redirectLines.length > 0) {
|
|
1588
2362
|
writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
|
|
1589
|
-
|
|
2363
|
+
files += 1;
|
|
1590
2364
|
}
|
|
1591
2365
|
|
|
1592
|
-
return
|
|
2366
|
+
return { errors, files };
|
|
1593
2367
|
}
|
|
1594
2368
|
|
|
1595
2369
|
/**
|
|
@@ -1626,26 +2400,60 @@ function escapeXml(str: string) {
|
|
|
1626
2400
|
|
|
1627
2401
|
/**
|
|
1628
2402
|
* Write a sitemap.xml from the collected page entries (sitemaps.org urlset 0.9). Each entry emits a
|
|
1629
|
-
* `<loc>` plus a
|
|
2403
|
+
* `<loc>` plus a full RFC 3339 `<lastmod>`.
|
|
1630
2404
|
*
|
|
1631
|
-
*
|
|
2405
|
+
* The W3C Datetime profile sitemaps.org cites admits both `YYYY-MM-DD` and a complete timestamp.
|
|
2406
|
+
* The date-only form threw away the time and, with it, any way to tell two edits on one day apart.
|
|
2407
|
+
*
|
|
2408
|
+
* @param {{ loc: string; lastmod: string }[]} entries
|
|
1632
2409
|
* @param {string} outDir
|
|
1633
2410
|
* @returns {number} Number of files written
|
|
1634
2411
|
*/
|
|
1635
|
-
|
|
2412
|
+
/** A `Date` to RFC 3339 in UTC, without fractional seconds. */
|
|
2413
|
+
function toRfc3339(d: Date): string {
|
|
2414
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/** The namespace `xhtml:link` alternates live in, declared only when some entry has them. */
|
|
2418
|
+
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
2419
|
+
|
|
2420
|
+
interface SitemapEntry {
|
|
2421
|
+
loc: string;
|
|
2422
|
+
lastmod: string;
|
|
2423
|
+
alternates?: readonly LocaleAlternate[];
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function generateSitemap(entries: SitemapEntry[], outDir: string) {
|
|
1636
2427
|
if (entries.length === 0) {
|
|
1637
2428
|
return 0;
|
|
1638
2429
|
}
|
|
1639
2430
|
const urls = entries
|
|
1640
|
-
.map(
|
|
1641
|
-
|
|
2431
|
+
.map((e) => {
|
|
2432
|
+
/*
|
|
2433
|
+
* Every member of a translation set lists every member including itself — that reciprocity
|
|
2434
|
+
* is what the annotation means, and a validator checks for it.
|
|
2435
|
+
*/
|
|
2436
|
+
const links = (e.alternates ?? [])
|
|
2437
|
+
.map(
|
|
2438
|
+
(a) =>
|
|
2439
|
+
` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" ` +
|
|
2440
|
+
`href="${escapeXml(a.href)}"/>`,
|
|
2441
|
+
)
|
|
2442
|
+
.join("\n");
|
|
2443
|
+
return (
|
|
1642
2444
|
` <url>\n <loc>${escapeXml(e.loc)}</loc>\n` +
|
|
1643
|
-
` <lastmod>${e.lastmod
|
|
1644
|
-
|
|
2445
|
+
` <lastmod>${e.lastmod}</lastmod>\n${links === "" ? "" : `${links}\n`} </url>`
|
|
2446
|
+
);
|
|
2447
|
+
})
|
|
1645
2448
|
.join("\n");
|
|
2449
|
+
// The namespace declaration is conditional: a monolingual sitemap should not carry a namespace
|
|
2450
|
+
// It never uses.
|
|
2451
|
+
const ns = entries.some((e) => (e.alternates?.length ?? 0) > 0)
|
|
2452
|
+
? ` xmlns:xhtml="${XHTML_NS}"`
|
|
2453
|
+
: "";
|
|
1646
2454
|
const xml =
|
|
1647
2455
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
1648
|
-
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
2456
|
+
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${ns}>\n${urls}\n</urlset>\n`;
|
|
1649
2457
|
writeFileSync(join(outDir, "sitemap.xml"), xml, "utf8");
|
|
1650
2458
|
return 1;
|
|
1651
2459
|
}
|