@jxsuite/compiler 1.4.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 +564 -230
- package/dist/compiler.js +334 -133
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +461 -51
- 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 +138 -87
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +956 -109
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +134 -38
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +144 -81
- package/src/targets/compile-element.ts +620 -58
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +12 -3
- 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;
|
|
@@ -256,20 +386,44 @@ export async function buildSite(
|
|
|
256
386
|
const componentOutDir = resolve(outDir, "components");
|
|
257
387
|
mkdirSync(componentOutDir, { recursive: true });
|
|
258
388
|
|
|
389
|
+
// Emitted modules are named after the component's `tagName` — the name the page's loader
|
|
390
|
+
// `<script>` and the CSS sidecar both use. So a component whose source basename differs from
|
|
391
|
+
// Its tag needs its `$elements` imports renamed to match, and those get resolved before the
|
|
392
|
+
// Dependency itself is compiled. Hence: read every component's tag up front.
|
|
393
|
+
const componentTagByPath = new Map<string, string>();
|
|
394
|
+
for (const file of componentFiles) {
|
|
395
|
+
const componentPath = resolve(componentsDir, file);
|
|
396
|
+
try {
|
|
397
|
+
const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
|
|
398
|
+
if (depTag) {
|
|
399
|
+
componentTagByPath.set(componentPath, depTag);
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
// Left to the compile pass below, which reports the parse failure with its file context.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
259
406
|
for (const file of componentFiles) {
|
|
260
407
|
try {
|
|
261
408
|
const componentPath = resolve(componentsDir, file);
|
|
262
409
|
const result = await compileElement(componentPath, {
|
|
263
410
|
...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
|
|
264
411
|
formats: formatRegistry,
|
|
412
|
+
resolveElementPath: (refPath: string, currentDir: string | null) => {
|
|
413
|
+
const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
|
|
414
|
+
const depTag = componentTagByPath.get(depPath);
|
|
415
|
+
return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
|
|
416
|
+
},
|
|
265
417
|
rewriteSrc: rewriteSidecarSrc,
|
|
266
418
|
});
|
|
267
419
|
for (const f of result.files) {
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
420
|
+
// Name the output after the tag. `injectComponentScripts` emits
|
|
421
|
+
// `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
|
|
422
|
+
// A source basename here left the loader pointing at a file that was never written.
|
|
423
|
+
// Basename is the fallback, and handles both / and \ — a forward-slash-only split left
|
|
424
|
+
// The full drive path on Windows, so resolve() then wrote the sidecar back into the
|
|
425
|
+
// Source tree instead of dist.
|
|
426
|
+
const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
|
|
273
427
|
writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
|
|
274
428
|
if (f.tagName) {
|
|
275
429
|
compiledComponentTags.push(f.tagName);
|
|
@@ -281,9 +435,23 @@ export async function buildSite(
|
|
|
281
435
|
const doc = await readPageDocument(componentPath, formatRegistry);
|
|
282
436
|
if (doc.tagName) {
|
|
283
437
|
componentDefs.set(doc.tagName, doc);
|
|
284
|
-
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
|
+
);
|
|
285
446
|
if (css) {
|
|
286
|
-
|
|
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
|
+
}
|
|
287
455
|
collectAssetRefs(css, assetMounts, assetRefs);
|
|
288
456
|
writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
|
|
289
457
|
fileCount += 1;
|
|
@@ -332,9 +500,127 @@ export async function buildSite(
|
|
|
332
500
|
// (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
|
|
333
501
|
const siteUrl = projectConfig.url;
|
|
334
502
|
const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
|
|
335
|
-
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
|
+
}
|
|
336
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>();
|
|
337
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
|
+
}
|
|
338
624
|
try {
|
|
339
625
|
log(` Compiling ${route.urlPattern} ...`);
|
|
340
626
|
const result = await compilePage(
|
|
@@ -349,8 +635,28 @@ export async function buildSite(
|
|
|
349
635
|
registry,
|
|
350
636
|
rewriteSidecarSrc,
|
|
351
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,
|
|
352
647
|
);
|
|
353
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
|
+
|
|
354
660
|
// Determine which component tags are fully static (for script omission)
|
|
355
661
|
const staticTags = new Set<string>();
|
|
356
662
|
for (const [tag, def] of componentDefs) {
|
|
@@ -366,12 +672,16 @@ export async function buildSite(
|
|
|
366
672
|
compiledComponentTags,
|
|
367
673
|
componentCSS,
|
|
368
674
|
staticTags,
|
|
675
|
+
result.files.map((f: { content: string }) => f.content).join("\n"),
|
|
676
|
+
runtimeImports,
|
|
677
|
+
runtimeAssetsUsed,
|
|
369
678
|
);
|
|
370
679
|
}
|
|
371
680
|
|
|
372
681
|
// Mounted assets this page actually references — scanned from the finished HTML so a
|
|
373
682
|
// Reference is caught wherever it came from (markdown image, hand-authored page, $head).
|
|
374
683
|
collectAssetRefs(result.html, assetMounts, assetRefs);
|
|
684
|
+
collectCspSources(result.html, cspSources);
|
|
375
685
|
|
|
376
686
|
// Determine output path
|
|
377
687
|
const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
|
|
@@ -384,9 +694,21 @@ export async function buildSite(
|
|
|
384
694
|
// The two always agree.
|
|
385
695
|
const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
|
|
386
696
|
if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
|
|
697
|
+
const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
|
|
387
698
|
sitemapEntries.push({
|
|
388
|
-
|
|
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),
|
|
389
710
|
loc: new URL(route.urlPattern, siteUrl).href,
|
|
711
|
+
...(routeAlternates.length > 0 && { alternates: routeAlternates }),
|
|
390
712
|
});
|
|
391
713
|
}
|
|
392
714
|
|
|
@@ -485,7 +807,7 @@ export async function buildSite(
|
|
|
485
807
|
const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
|
|
486
808
|
const workerSource = skipWorker
|
|
487
809
|
? null
|
|
488
|
-
: compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
|
|
810
|
+
: compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
|
|
489
811
|
|
|
490
812
|
if (workerSource) {
|
|
491
813
|
// Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
|
|
@@ -510,7 +832,17 @@ export async function buildSite(
|
|
|
510
832
|
// Only invoke the worker for server routes; everything else stays static.
|
|
511
833
|
writeFileSync(
|
|
512
834
|
resolve(outDir, "_routes.json"),
|
|
513
|
-
|
|
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`,
|
|
514
846
|
"utf8",
|
|
515
847
|
);
|
|
516
848
|
fileCount += 1;
|
|
@@ -519,6 +851,30 @@ export async function buildSite(
|
|
|
519
851
|
}
|
|
520
852
|
|
|
521
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
|
+
|
|
522
878
|
if (sidecarBundles.size > 0) {
|
|
523
879
|
log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
|
|
524
880
|
for (const [assetPath, bundle] of sidecarBundles) {
|
|
@@ -539,6 +895,49 @@ export async function buildSite(
|
|
|
539
895
|
}
|
|
540
896
|
}
|
|
541
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
|
+
|
|
542
941
|
// ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
|
|
543
942
|
// Section-owner classes with an `emit` capability contribute derived build artifacts
|
|
544
943
|
// (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
|
|
@@ -584,8 +983,12 @@ export async function buildSite(
|
|
|
584
983
|
if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
|
|
585
984
|
log("Generating redirects...");
|
|
586
985
|
const compiledUrls = new Set(routes.map((r) => r.urlPattern));
|
|
587
|
-
const
|
|
588
|
-
|
|
986
|
+
const redirects = generateRedirects(projectConfig.redirects, outDir, {
|
|
987
|
+
compiledUrls,
|
|
988
|
+
trailingSlash: projectConfig.build.trailingSlash,
|
|
989
|
+
});
|
|
990
|
+
fileCount += redirects.files;
|
|
991
|
+
errors.push(...redirects.errors);
|
|
589
992
|
}
|
|
590
993
|
|
|
591
994
|
// ── 7b. Generate sitemap.xml ────────────────────────────────────────────
|
|
@@ -620,6 +1023,83 @@ export async function buildSite(
|
|
|
620
1023
|
fileCount += ensureRobotsSitemap(outDir, siteUrl);
|
|
621
1024
|
}
|
|
622
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
|
+
|
|
623
1103
|
// ── 8. Copy declarative file mappings ──────────────────────────────────
|
|
624
1104
|
if (projectConfig.copy) {
|
|
625
1105
|
log("Copying mapped files...");
|
|
@@ -658,19 +1138,28 @@ export async function buildSite(
|
|
|
658
1138
|
* serverHandler: string | null;
|
|
659
1139
|
* doc: JxDocument;
|
|
660
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.
|
|
661
1144
|
*/
|
|
662
1145
|
async function compilePage(
|
|
663
1146
|
route: SiteRoute,
|
|
664
1147
|
projectConfig: ProjectConfig,
|
|
665
1148
|
projectRoot: string,
|
|
666
|
-
sections: Record<string, unknown
|
|
667
|
-
imageCache: CacheManifest | null
|
|
668
|
-
componentDefs
|
|
669
|
-
imageMetaCache: ImageMetaCache | null
|
|
670
|
-
formatRegistry
|
|
671
|
-
registry
|
|
672
|
-
rewriteSidecarSrc
|
|
673
|
-
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,
|
|
674
1163
|
) {
|
|
675
1164
|
// Load the raw page document (.json natively, other formats via the registry)
|
|
676
1165
|
const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
|
|
@@ -688,7 +1177,19 @@ async function compilePage(
|
|
|
688
1177
|
delete layoutDoc._pageTitle;
|
|
689
1178
|
|
|
690
1179
|
// Inject $site and $page context
|
|
691
|
-
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
|
+
});
|
|
692
1193
|
|
|
693
1194
|
// Resolve generic $prototype entries via .class.json imports (and lower registry classes)
|
|
694
1195
|
await resolvePrototypes(layoutDoc, route, projectRoot, {
|
|
@@ -714,8 +1215,25 @@ async function compilePage(
|
|
|
714
1215
|
}
|
|
715
1216
|
|
|
716
1217
|
// Resolve template strings in $head entries
|
|
717
|
-
|
|
718
|
-
|
|
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
|
+
);
|
|
719
1237
|
|
|
720
1238
|
// Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
|
|
721
1239
|
// So that timing: "compiler" data is baked into the static HTML
|
|
@@ -729,13 +1247,19 @@ async function compilePage(
|
|
|
729
1247
|
media: { ...projectConfig.$media, ...layoutDoc.$media },
|
|
730
1248
|
rules: [],
|
|
731
1249
|
};
|
|
732
|
-
expandComponents(layoutDoc, componentDefs, slotCss);
|
|
1250
|
+
expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
|
|
733
1251
|
|
|
734
1252
|
// Strip resolved timing: "compiler" state entries — they're now baked into the tree
|
|
735
1253
|
// And keeping them would cause isDynamic() to misclassify the page as dynamic.
|
|
736
1254
|
// Also strip resolved content arrays (from ContentCollection) that have been
|
|
737
1255
|
// Baked into unrolled map templates.
|
|
738
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>();
|
|
739
1263
|
for (const [key, def] of Object.entries(layoutDoc.state)) {
|
|
740
1264
|
if (key === "$site" || key === "$page") {
|
|
741
1265
|
continue;
|
|
@@ -748,13 +1272,54 @@ async function compilePage(
|
|
|
748
1272
|
) {
|
|
749
1273
|
delete layoutDoc.state[key];
|
|
750
1274
|
} else if (Array.isArray(def)) {
|
|
751
|
-
|
|
1275
|
+
strippable.add(key);
|
|
1276
|
+
}
|
|
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];
|
|
752
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];
|
|
753
1296
|
}
|
|
754
1297
|
}
|
|
755
1298
|
|
|
756
1299
|
// Resolve bare npm specifiers in $head (e.g. "@pkg/name/file.css" → "/node_modules/@pkg/name/file.css")
|
|
757
|
-
|
|
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
|
+
];
|
|
758
1323
|
|
|
759
1324
|
// Merge $head from site + layout + page
|
|
760
1325
|
const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
|
|
@@ -762,6 +1327,7 @@ async function compilePage(
|
|
|
762
1327
|
charset: projectConfig.defaults?.charset ?? "utf8",
|
|
763
1328
|
...(projectConfig.name != null && { siteName: projectConfig.name }),
|
|
764
1329
|
...(projectConfig.url != null && { siteUrl: projectConfig.url }),
|
|
1330
|
+
...(locale.alternates.length > 0 && { alternates: locale.alternates }),
|
|
765
1331
|
pageUrl: route.urlPattern,
|
|
766
1332
|
});
|
|
767
1333
|
|
|
@@ -777,8 +1343,12 @@ async function compilePage(
|
|
|
777
1343
|
mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
|
|
778
1344
|
}
|
|
779
1345
|
|
|
780
|
-
|
|
781
|
-
|
|
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) {
|
|
782
1352
|
await transformImageNodes(
|
|
783
1353
|
layoutDoc,
|
|
784
1354
|
projectConfig.images as ImageConfig,
|
|
@@ -791,7 +1361,17 @@ async function compilePage(
|
|
|
791
1361
|
|
|
792
1362
|
// Compile the document using the existing compiler
|
|
793
1363
|
const result = await compile(layoutDoc, {
|
|
794
|
-
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"] }),
|
|
795
1375
|
prePaintScheme: false, // Injected via the merged <head> above, not the target template
|
|
796
1376
|
projectStyle: projectConfig.style ?? null,
|
|
797
1377
|
...(rewriteSidecarSrc === undefined
|
|
@@ -812,14 +1392,35 @@ async function compilePage(
|
|
|
812
1392
|
}
|
|
813
1393
|
|
|
814
1394
|
// Post-process: inject merged <head> content into the compiled HTML
|
|
815
|
-
result.html = injectHead(result.html, mergedHead,
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
];
|
|
821
1422
|
if (npmElements.length > 0) {
|
|
822
|
-
result.html = injectNpmElementScripts(result.html, npmElements
|
|
1423
|
+
result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
|
|
823
1424
|
}
|
|
824
1425
|
|
|
825
1426
|
// Compile server handler if applicable (skip when provider bundles site-wide)
|
|
@@ -842,6 +1443,12 @@ async function compilePage(
|
|
|
842
1443
|
excludeFromSitemap: pageDoc.$sitemap === false,
|
|
843
1444
|
files: result.files,
|
|
844
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),
|
|
845
1452
|
serverHandler,
|
|
846
1453
|
};
|
|
847
1454
|
}
|
|
@@ -932,6 +1539,39 @@ function buildMountSpecs(
|
|
|
932
1539
|
return { connectors, mounts };
|
|
933
1540
|
}
|
|
934
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
|
+
|
|
935
1575
|
/**
|
|
936
1576
|
* Resolve template strings in $head entries against the compiled scope.
|
|
937
1577
|
*
|
|
@@ -939,6 +1579,21 @@ function buildMountSpecs(
|
|
|
939
1579
|
* @param {Record<string, unknown>} scope
|
|
940
1580
|
* @returns {JxHeadEntry[]}
|
|
941
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
|
+
|
|
942
1597
|
function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
|
|
943
1598
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
944
1599
|
if (!entry || typeof entry !== "object") {
|
|
@@ -959,20 +1614,32 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
|
|
|
959
1614
|
resolved.textContent =
|
|
960
1615
|
(evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
|
|
961
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
|
+
>;
|
|
962
1624
|
}
|
|
963
1625
|
return resolved;
|
|
964
1626
|
});
|
|
965
1627
|
}
|
|
966
1628
|
|
|
967
1629
|
/**
|
|
968
|
-
* Resolve bare npm specifiers in $head entry attributes (href, src)
|
|
969
|
-
*
|
|
970
|
-
* "
|
|
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"`.
|
|
971
1634
|
*
|
|
972
1635
|
* @param {JxHeadEntry[]} headEntries
|
|
1636
|
+
* @param {(specifier: string) => string} rewrite
|
|
973
1637
|
* @returns {JxHeadEntry[]}
|
|
974
1638
|
*/
|
|
975
|
-
function resolveHeadBareSpecifiers(
|
|
1639
|
+
function resolveHeadBareSpecifiers(
|
|
1640
|
+
headEntries: JxHeadEntry[],
|
|
1641
|
+
rewrite: (specifier: string) => string,
|
|
1642
|
+
) {
|
|
976
1643
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
977
1644
|
if (!entry || typeof entry !== "object" || !entry.attributes) {
|
|
978
1645
|
return entry;
|
|
@@ -981,7 +1648,7 @@ function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
|
|
|
981
1648
|
for (const key of ["href", "src"]) {
|
|
982
1649
|
const val = resolved.attributes[key];
|
|
983
1650
|
if (typeof val === "string" && isBareSpecifier(val)) {
|
|
984
|
-
resolved.attributes[key] =
|
|
1651
|
+
resolved.attributes[key] = rewrite(val);
|
|
985
1652
|
}
|
|
986
1653
|
}
|
|
987
1654
|
return resolved;
|
|
@@ -1151,9 +1818,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1151
1818
|
}
|
|
1152
1819
|
const rawChildren = node.children;
|
|
1153
1820
|
// Legacy whole-children repeater: expand the items into the node's static children.
|
|
1821
|
+
// Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
|
|
1822
|
+
// `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
|
|
1823
|
+
// Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
|
|
1824
|
+
// Definition the client needed. There is no prerendered output to preserve in that case, so the
|
|
1825
|
+
// Repeater is left in place for the client to bind (a page containing one is never zero-JS).
|
|
1154
1826
|
if (isMappedArray(rawChildren)) {
|
|
1155
1827
|
const expanded = expandMappedArrayStatic(rawChildren, scope);
|
|
1156
|
-
if (expanded) {
|
|
1828
|
+
if (expanded && expanded.length > 0) {
|
|
1157
1829
|
node.children = expanded;
|
|
1158
1830
|
return;
|
|
1159
1831
|
}
|
|
@@ -1183,10 +1855,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1183
1855
|
continue;
|
|
1184
1856
|
}
|
|
1185
1857
|
}
|
|
1186
|
-
// Array pseudo-element among siblings: expand its items in place
|
|
1858
|
+
// Array pseudo-element among siblings: expand its items in place, on the same
|
|
1859
|
+
// Produced-nothing rule as the whole-children branch above.
|
|
1187
1860
|
if (isMappedArray(child)) {
|
|
1188
1861
|
const expanded = expandMappedArrayStatic(child, scope);
|
|
1189
|
-
if (expanded) {
|
|
1862
|
+
if (expanded && expanded.length > 0) {
|
|
1190
1863
|
node.children.splice(i, 1, ...expanded);
|
|
1191
1864
|
i += expanded.length;
|
|
1192
1865
|
continue;
|
|
@@ -1198,6 +1871,19 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1198
1871
|
}
|
|
1199
1872
|
}
|
|
1200
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
|
+
|
|
1201
1887
|
/**
|
|
1202
1888
|
* Statically expand a mapped array to its resolved item nodes when `items` resolves to an array at
|
|
1203
1889
|
* build time, or null otherwise (leave the array node for client-side rendering).
|
|
@@ -1247,18 +1933,20 @@ interface SlotCssCollector {
|
|
|
1247
1933
|
* @param {JxElement | string} node
|
|
1248
1934
|
* @param {Map<string, JxElement>} componentDefs
|
|
1249
1935
|
* @param {SlotCssCollector} [slotCss]
|
|
1936
|
+
* @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
|
|
1250
1937
|
*/
|
|
1251
1938
|
function expandComponents(
|
|
1252
1939
|
node: JxElement | string,
|
|
1253
1940
|
componentDefs: Map<string, JxElement>,
|
|
1254
1941
|
slotCss?: SlotCssCollector,
|
|
1942
|
+
defaults?: ProjectConfig["defaults"],
|
|
1255
1943
|
) {
|
|
1256
1944
|
if (!node || typeof node !== "object") {
|
|
1257
1945
|
return;
|
|
1258
1946
|
}
|
|
1259
1947
|
if (Array.isArray(node)) {
|
|
1260
1948
|
for (const n of node as (JxElement | string)[]) {
|
|
1261
|
-
expandComponents(n, componentDefs, slotCss);
|
|
1949
|
+
expandComponents(n, componentDefs, slotCss, defaults);
|
|
1262
1950
|
}
|
|
1263
1951
|
return;
|
|
1264
1952
|
}
|
|
@@ -1266,7 +1954,7 @@ function expandComponents(
|
|
|
1266
1954
|
// Recurse into children first (bottom-up expansion)
|
|
1267
1955
|
if (Array.isArray(node.children)) {
|
|
1268
1956
|
for (const child of node.children) {
|
|
1269
|
-
expandComponents(child, componentDefs, slotCss);
|
|
1957
|
+
expandComponents(child, componentDefs, slotCss, defaults);
|
|
1270
1958
|
}
|
|
1271
1959
|
}
|
|
1272
1960
|
|
|
@@ -1305,10 +1993,27 @@ function expandComponents(
|
|
|
1305
1993
|
.join("\n")
|
|
1306
1994
|
: null;
|
|
1307
1995
|
|
|
1308
|
-
|
|
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);
|
|
1309
2004
|
const isStatic = isComponentFullyStatic(def);
|
|
1310
2005
|
|
|
1311
|
-
|
|
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;
|
|
1312
2017
|
delete node.children;
|
|
1313
2018
|
|
|
1314
2019
|
// Resolve template-string host styles with props (per-instance values like background-image)
|
|
@@ -1351,6 +2056,9 @@ function expandComponents(
|
|
|
1351
2056
|
* @param {string[]} allComponentTags - All compiled component tag names
|
|
1352
2057
|
* @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
|
|
1353
2058
|
* @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
|
|
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
|
|
1354
2062
|
* @returns {string}
|
|
1355
2063
|
*/
|
|
1356
2064
|
function injectComponentScripts(
|
|
@@ -1358,11 +2066,17 @@ function injectComponentScripts(
|
|
|
1358
2066
|
allComponentTags: string[],
|
|
1359
2067
|
cssMap = new Map<string, string>(),
|
|
1360
2068
|
staticTags = new Set<string>(),
|
|
2069
|
+
islandSource = "",
|
|
2070
|
+
runtimeImports: Record<string, string> = {},
|
|
2071
|
+
runtimeAssetsUsed = new Set<string>(),
|
|
1361
2072
|
) {
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
2073
|
+
// A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
|
|
2074
|
+
// From inside an island's client template — in which case the tag exists solely in the island's
|
|
2075
|
+
// Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
|
|
2076
|
+
// Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
|
|
2077
|
+
const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
|
|
2078
|
+
const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
|
|
2079
|
+
const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
|
|
1366
2080
|
if (usedTags.length === 0) {
|
|
1367
2081
|
return html;
|
|
1368
2082
|
}
|
|
@@ -1377,21 +2091,28 @@ function injectComponentScripts(
|
|
|
1377
2091
|
result = result.replace("</head>", ` ${cssLinks}\n</head>`);
|
|
1378
2092
|
}
|
|
1379
2093
|
|
|
1380
|
-
// Only inject JS for components that have non-static instances
|
|
1381
|
-
|
|
2094
|
+
// Only inject JS for components that have non-static instances. An island-rendered instance is
|
|
2095
|
+
// Never prerendered, so it needs its module even when the component itself is fully static —
|
|
2096
|
+
// That module is what fills the element in client-side. This holds when the tag ALSO appears in
|
|
2097
|
+
// The static HTML: those instances are prerendered, but the ones the island creates are not.
|
|
2098
|
+
const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
|
|
1382
2099
|
if (jsTags.length === 0) {
|
|
1383
2100
|
return result;
|
|
1384
2101
|
}
|
|
1385
2102
|
|
|
1386
2103
|
// Build import map (needed for @vue/reactivity and lit-html)
|
|
1387
|
-
const importMap =
|
|
1388
|
-
{
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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;
|
|
1392
2113
|
}
|
|
2114
|
+
runtimeAssetsUsed.add(url);
|
|
1393
2115
|
}
|
|
1394
|
-
</script>`;
|
|
1395
2116
|
|
|
1396
2117
|
const moduleScripts = jsTags
|
|
1397
2118
|
.map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
|
|
@@ -1406,19 +2127,31 @@ function injectComponentScripts(
|
|
|
1406
2127
|
|
|
1407
2128
|
/**
|
|
1408
2129
|
* Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
|
|
1409
|
-
*
|
|
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.
|
|
1410
2134
|
*
|
|
1411
2135
|
* @param {string} html
|
|
1412
2136
|
* @param {string[]} npmElements - Bare specifier strings, e.g.
|
|
1413
2137
|
* "@shoelace-style/shoelace/components/button/button.js"
|
|
1414
2138
|
* @returns {string}
|
|
1415
2139
|
*/
|
|
1416
|
-
function injectNpmElementScripts(html: string,
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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>`);
|
|
1422
2155
|
}
|
|
1423
2156
|
|
|
1424
2157
|
/**
|
|
@@ -1429,7 +2162,11 @@ function injectNpmElementScripts(html: string, npmElements: string[]) {
|
|
|
1429
2162
|
* @param {string} lang
|
|
1430
2163
|
* @returns {string}
|
|
1431
2164
|
*/
|
|
1432
|
-
function injectHead(
|
|
2165
|
+
function injectHead(
|
|
2166
|
+
html: string,
|
|
2167
|
+
headEntries: JxHeadEntry[],
|
|
2168
|
+
root: { lang: string; dir?: string },
|
|
2169
|
+
) {
|
|
1433
2170
|
const headHtml = renderHead(headEntries);
|
|
1434
2171
|
|
|
1435
2172
|
// Replace the existing <head>...</head> block, preserving compiler-generated <style> and <script> blocks
|
|
@@ -1451,12 +2188,18 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
|
|
|
1451
2188
|
result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
|
|
1452
2189
|
}
|
|
1453
2190
|
|
|
1454
|
-
// 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".
|
|
1455
2193
|
result = result.replace(/<html\s[^>]*>/i, (match: string) => {
|
|
1456
|
-
|
|
1457
|
-
|
|
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}"`);
|
|
1458
2201
|
}
|
|
1459
|
-
return
|
|
2202
|
+
return tag;
|
|
1460
2203
|
});
|
|
1461
2204
|
|
|
1462
2205
|
return result;
|
|
@@ -1497,41 +2240,113 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
|
|
|
1497
2240
|
* @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
|
|
1498
2241
|
* @returns {number} Number of files written
|
|
1499
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
|
+
*/
|
|
1500
2280
|
function generateRedirects(
|
|
1501
|
-
redirects: Record<
|
|
2281
|
+
redirects: Record<
|
|
2282
|
+
string,
|
|
2283
|
+
string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2284
|
+
>,
|
|
1502
2285
|
outDir: string,
|
|
1503
|
-
|
|
2286
|
+
opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
|
|
1504
2287
|
) {
|
|
1505
|
-
let
|
|
2288
|
+
let files = 0;
|
|
2289
|
+
const errors: string[] = [];
|
|
1506
2290
|
const redirectLines: string[] = [];
|
|
1507
2291
|
const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
|
|
1508
|
-
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";
|
|
1509
2294
|
|
|
1510
2295
|
for (const [source, target] of Object.entries(redirects)) {
|
|
1511
2296
|
const dest = typeof target === "object" ? target.destination : target;
|
|
1512
|
-
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
|
+
}
|
|
1513
2312
|
|
|
1514
|
-
|
|
2313
|
+
redirectLines.push(`${source} ${dest} ${status}`);
|
|
2314
|
+
|
|
2315
|
+
// A pattern source cannot be a file on disk, so it lives only in `_redirects`.
|
|
1515
2316
|
if (source.includes(":") || source.includes("*")) {
|
|
1516
|
-
redirectLines.push(`${source} ${dest} ${status}`);
|
|
1517
2317
|
continue;
|
|
1518
2318
|
}
|
|
1519
2319
|
|
|
1520
2320
|
if (compiled.has(normalizeUrl(source))) {
|
|
2321
|
+
const what = isRewrite ? "rewrite" : "redirect";
|
|
1521
2322
|
console.warn(
|
|
1522
|
-
`
|
|
1523
|
-
`remove
|
|
2323
|
+
`The ${what} "${source}" collides with a compiled page at the same route — ` +
|
|
2324
|
+
`remove one or the other.`,
|
|
1524
2325
|
);
|
|
1525
2326
|
}
|
|
1526
2327
|
|
|
1527
|
-
|
|
1528
|
-
|
|
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">`;
|
|
1529
2345
|
const html = `<!DOCTYPE html>
|
|
1530
2346
|
<html>
|
|
1531
2347
|
<head>
|
|
1532
2348
|
<meta charset="utf-8">
|
|
1533
|
-
<meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}"
|
|
1534
|
-
<link rel="canonical" href="${escapeAttr(dest)}">
|
|
2349
|
+
<meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">${canonical}
|
|
1535
2350
|
<title>Redirecting...</title>
|
|
1536
2351
|
</head>
|
|
1537
2352
|
<body>
|
|
@@ -1540,17 +2355,15 @@ function generateRedirects(
|
|
|
1540
2355
|
</html>`;
|
|
1541
2356
|
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
1542
2357
|
writeFileSync(htmlPath, html, "utf8");
|
|
1543
|
-
|
|
1544
|
-
redirectLines.push(`${source} ${dest} ${status}`);
|
|
2358
|
+
files += 1;
|
|
1545
2359
|
}
|
|
1546
2360
|
|
|
1547
|
-
// Write _redirects file (Netlify/Cloudflare format)
|
|
1548
2361
|
if (redirectLines.length > 0) {
|
|
1549
2362
|
writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
|
|
1550
|
-
|
|
2363
|
+
files += 1;
|
|
1551
2364
|
}
|
|
1552
2365
|
|
|
1553
|
-
return
|
|
2366
|
+
return { errors, files };
|
|
1554
2367
|
}
|
|
1555
2368
|
|
|
1556
2369
|
/**
|
|
@@ -1587,26 +2400,60 @@ function escapeXml(str: string) {
|
|
|
1587
2400
|
|
|
1588
2401
|
/**
|
|
1589
2402
|
* Write a sitemap.xml from the collected page entries (sitemaps.org urlset 0.9). Each entry emits a
|
|
1590
|
-
* `<loc>` plus a
|
|
2403
|
+
* `<loc>` plus a full RFC 3339 `<lastmod>`.
|
|
1591
2404
|
*
|
|
1592
|
-
*
|
|
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
|
|
1593
2409
|
* @param {string} outDir
|
|
1594
2410
|
* @returns {number} Number of files written
|
|
1595
2411
|
*/
|
|
1596
|
-
|
|
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) {
|
|
1597
2427
|
if (entries.length === 0) {
|
|
1598
2428
|
return 0;
|
|
1599
2429
|
}
|
|
1600
2430
|
const urls = entries
|
|
1601
|
-
.map(
|
|
1602
|
-
|
|
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 (
|
|
1603
2444
|
` <url>\n <loc>${escapeXml(e.loc)}</loc>\n` +
|
|
1604
|
-
` <lastmod>${e.lastmod
|
|
1605
|
-
|
|
2445
|
+
` <lastmod>${e.lastmod}</lastmod>\n${links === "" ? "" : `${links}\n`} </url>`
|
|
2446
|
+
);
|
|
2447
|
+
})
|
|
1606
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
|
+
: "";
|
|
1607
2454
|
const xml =
|
|
1608
2455
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
1609
|
-
`<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`;
|
|
1610
2457
|
writeFileSync(join(outDir, "sitemap.xml"), xml, "utf8");
|
|
1611
2458
|
return 1;
|
|
1612
2459
|
}
|