@jxsuite/compiler 5.1.2 → 5.1.3
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 +220 -351
- package/dist/compiler.js +126 -257
- package/package.json +4 -4
- package/src/cli.ts +12 -14
- package/src/compiler.ts +45 -23
- package/src/shadow.ts +9 -8
- package/src/shared.ts +350 -296
- package/src/site/asset-mounts.ts +22 -12
- package/src/site/bundler.ts +38 -12
- package/src/site/client-runtime.ts +31 -17
- package/src/site/csp.ts +20 -11
- package/src/site/db-push.ts +38 -24
- package/src/site/dev-command.ts +3 -3
- package/src/site/format-host.ts +56 -45
- package/src/site/headers-emitter.ts +7 -4
- package/src/site/i18n.ts +20 -19
- package/src/site/image-cache.ts +16 -3
- package/src/site/image-optimizer.ts +19 -5
- package/src/site/image-transform.ts +56 -60
- package/src/site/img-loading.ts +11 -5
- package/src/site/layout-resolver.ts +7 -6
- package/src/site/link-relations.ts +10 -6
- package/src/site/locale-negotiation.ts +1 -1
- package/src/site/pages-discovery.ts +65 -47
- package/src/site/preview-server.ts +22 -19
- package/src/site/project-sections.ts +12 -3
- package/src/site/prototype-resolver.ts +94 -42
- package/src/site/schema-command.ts +26 -13
- package/src/site/service-worker.ts +2 -2
- package/src/site/site-build.ts +379 -262
- package/src/site/site-loader.ts +6 -3
- package/src/site/static-host.ts +3 -3
- package/src/site/validate-command.ts +64 -34
- package/src/site/well-known.ts +57 -12
- package/src/targets/compile-class.ts +17 -5
- package/src/targets/compile-client.ts +141 -106
- package/src/targets/compile-element.ts +157 -140
- package/src/targets/compile-server.ts +11 -5
- package/src/targets/compile-static.ts +57 -63
- package/src/types.ts +5 -6
package/src/site/site-build.ts
CHANGED
|
@@ -20,7 +20,15 @@ import {
|
|
|
20
20
|
} from "node:fs";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
isJsonObject,
|
|
25
|
+
isMappedArray,
|
|
26
|
+
isNonNullObject,
|
|
27
|
+
isRef,
|
|
28
|
+
isString,
|
|
29
|
+
isTemplateString,
|
|
30
|
+
} from "@jxsuite/schema/guards";
|
|
31
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
24
32
|
import {
|
|
25
33
|
isNpmSpecifier,
|
|
26
34
|
npmAssetPath,
|
|
@@ -55,6 +63,7 @@ import {
|
|
|
55
63
|
} from "./headers-emitter.ts";
|
|
56
64
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
57
65
|
import { loadProjectSections } from "./project-sections.ts";
|
|
66
|
+
import type { ProjectSections } from "./project-sections.ts";
|
|
58
67
|
import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
|
|
59
68
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
60
69
|
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
@@ -64,7 +73,9 @@ import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
|
|
|
64
73
|
import { unregisteredHeadRelations } from "./link-relations.ts";
|
|
65
74
|
import { injectContext } from "./context-injection.ts";
|
|
66
75
|
import { compile, compileServer, compileSiteServer } from "../compiler.ts";
|
|
76
|
+
import type { CompileOptions } from "../compiler.ts";
|
|
67
77
|
import { compileElement } from "../targets/compile-element.ts";
|
|
78
|
+
import type { CompileElementOptions } from "../targets/compile-element.ts";
|
|
68
79
|
import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
|
|
69
80
|
import {
|
|
70
81
|
buildComponentCSS,
|
|
@@ -77,7 +88,6 @@ import {
|
|
|
77
88
|
evaluateStaticTemplate,
|
|
78
89
|
isComponentFullyStatic,
|
|
79
90
|
isSingleExpression,
|
|
80
|
-
isTemplateString,
|
|
81
91
|
liftPropsAttributes,
|
|
82
92
|
preRenderComponentHtml,
|
|
83
93
|
pureSchemeOf,
|
|
@@ -131,19 +141,32 @@ import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
|
|
|
131
141
|
import type { ImageConfig } from "./image-optimizer.ts";
|
|
132
142
|
import type { ImageMetaCache } from "./image-transform.ts";
|
|
133
143
|
import type {
|
|
144
|
+
JsonObject,
|
|
134
145
|
JsonValue,
|
|
135
146
|
JxAttributeValue,
|
|
136
147
|
JxElement,
|
|
137
148
|
JxHeadEntry,
|
|
138
149
|
JxMappedArray,
|
|
139
|
-
JxMutableNode,
|
|
140
150
|
JxStateDefinition,
|
|
141
151
|
JxStyle,
|
|
142
152
|
ProjectConfig,
|
|
143
153
|
} from "@jxsuite/schema/types";
|
|
144
|
-
import type {
|
|
154
|
+
import type { JxScope } from "@jxsuite/runtime/types";
|
|
145
155
|
import type { CacheManifest } from "./image-cache.js";
|
|
146
156
|
|
|
157
|
+
declare module "@jxsuite/schema/format-registry" {
|
|
158
|
+
/**
|
|
159
|
+
* The template hooks a site build adds to an export sidecar's `serialize` options
|
|
160
|
+
* (specs/extensions.md §7). The format builds a document's scope with `buildScope` and hands that
|
|
161
|
+
* scope back to `evaluateTemplate` unread, so both are typed by the compiler's static scope.
|
|
162
|
+
*/
|
|
163
|
+
interface SerializeExportOptions {
|
|
164
|
+
buildScope?: (state: Record<string, JxStateDefinition>) => JxScope;
|
|
165
|
+
/** Evaluate one template string against that scope; `undefined` keeps it as written. */
|
|
166
|
+
evaluateTemplate?: (value: string, scope: JxScope) => JxScope[string] | undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
/**
|
|
148
171
|
* Build an entire Jx site from a project directory.
|
|
149
172
|
*
|
|
@@ -289,7 +312,7 @@ export async function buildSite(
|
|
|
289
312
|
sidecarBundles.set(assetPath, { entryPath, specifier });
|
|
290
313
|
return assetPath;
|
|
291
314
|
} catch (error) {
|
|
292
|
-
errors.push(`Sidecar "${specifier}": ${(error
|
|
315
|
+
errors.push(`Sidecar "${specifier}": ${errorMessage(error)}`);
|
|
293
316
|
return specifier;
|
|
294
317
|
}
|
|
295
318
|
};
|
|
@@ -304,7 +327,7 @@ export async function buildSite(
|
|
|
304
327
|
} catch (error) {
|
|
305
328
|
// A missing dependency is a hard error, like an unresolvable sidecar: the alternative is a
|
|
306
329
|
// Page that looks fine in dev and loses its stylesheet on deploy.
|
|
307
|
-
errors.push(`$head specifier "${specifier}": ${(error
|
|
330
|
+
errors.push(`$head specifier "${specifier}": ${errorMessage(error)}`);
|
|
308
331
|
return specifier;
|
|
309
332
|
}
|
|
310
333
|
};
|
|
@@ -328,8 +351,8 @@ export async function buildSite(
|
|
|
328
351
|
if (!entry.project) {
|
|
329
352
|
return true;
|
|
330
353
|
}
|
|
331
|
-
const value = projectConfig[entry.project.key]
|
|
332
|
-
return value
|
|
354
|
+
const value = projectConfig[entry.project.key];
|
|
355
|
+
return isNonNullObject(value) && Object.keys(value).length > 0;
|
|
333
356
|
});
|
|
334
357
|
if (activeMounts.length > 0 && !projectConfig.build.adapter) {
|
|
335
358
|
const sections = activeMounts
|
|
@@ -386,11 +409,9 @@ export async function buildSite(
|
|
|
386
409
|
const assetRefs = new Set<string>();
|
|
387
410
|
|
|
388
411
|
// ── 4. Expand dynamic routes ────────────────────────────────────────────
|
|
389
|
-
const extensionHead = registry
|
|
390
|
-
? await collectExtensionHead(registry, projectConfig, projectRoot)
|
|
391
|
-
: [];
|
|
412
|
+
const extensionHead = await collectExtensionHead(registry, projectConfig, projectRoot);
|
|
392
413
|
|
|
393
|
-
const
|
|
414
|
+
const expandedRoutes = await expandDynamicRoutes(
|
|
394
415
|
staticRoutes,
|
|
395
416
|
projectRoot,
|
|
396
417
|
sections,
|
|
@@ -404,10 +425,13 @@ export async function buildSite(
|
|
|
404
425
|
* of one entry share an id (§13.3), so a page that asked for `hello` without saying in which
|
|
405
426
|
* language got whichever translation was loaded first — the English copy under a French route,
|
|
406
427
|
* silently, on a page whose `<html lang>` said otherwise.
|
|
428
|
+
*
|
|
429
|
+
* Stamped onto each route object in place, so every holder of a route (the extension `emit`
|
|
430
|
+
* capability receives this table) sees the same `locale` member.
|
|
407
431
|
*/
|
|
408
|
-
|
|
409
|
-
(route
|
|
410
|
-
|
|
432
|
+
const routes = expandedRoutes.map((route) =>
|
|
433
|
+
Object.assign(route, { locale: localeOfRoute(route.urlPattern, i18n) }),
|
|
434
|
+
);
|
|
411
435
|
log(` ${routes.length} route(s) after expansion`);
|
|
412
436
|
|
|
413
437
|
let fileCount = 0;
|
|
@@ -443,19 +467,31 @@ export async function buildSite(
|
|
|
443
467
|
}
|
|
444
468
|
}
|
|
445
469
|
|
|
470
|
+
/*
|
|
471
|
+
* A component is a JavaScript module the page loads, and the page rewrite (`rewriteHtmlBase`) never
|
|
472
|
+
* reads it: the `/assets/` URLs the compiler writes into it for a `$src` function (a static import,
|
|
473
|
+
* or the dynamic one a `$lazy` function becomes) are re-rooted here, or they name files a site
|
|
474
|
+
* served from a path does not have.
|
|
475
|
+
*/
|
|
476
|
+
const componentBase = siteBasePath(projectConfig.url);
|
|
477
|
+
|
|
446
478
|
for (const file of componentFiles) {
|
|
447
479
|
try {
|
|
448
480
|
const componentPath = resolve(componentsDir, file);
|
|
449
|
-
const
|
|
450
|
-
...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
|
|
481
|
+
const elementOptions: CompileElementOptions = {
|
|
451
482
|
formats: formatRegistry,
|
|
452
483
|
resolveElementPath: (refPath: string, currentDir: string | null) => {
|
|
453
484
|
const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
|
|
454
485
|
const depTag = componentTagByPath.get(depPath);
|
|
455
486
|
return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
|
|
456
487
|
},
|
|
457
|
-
rewriteSrc:
|
|
458
|
-
|
|
488
|
+
rewriteSrc: (specifier: string, docDir: string | null) =>
|
|
489
|
+
withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
|
|
490
|
+
};
|
|
491
|
+
if (projectConfig.$media) {
|
|
492
|
+
elementOptions.$media = projectConfig.$media;
|
|
493
|
+
}
|
|
494
|
+
const result = await compileElement(componentPath, elementOptions);
|
|
459
495
|
for (const f of result.files) {
|
|
460
496
|
// Name the output after the tag. `injectComponentScripts` emits
|
|
461
497
|
// `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
|
|
@@ -511,9 +547,9 @@ export async function buildSite(
|
|
|
511
547
|
}
|
|
512
548
|
}
|
|
513
549
|
} catch (error) {
|
|
514
|
-
const
|
|
515
|
-
errors.push(`Error compiling component ${file}: ${
|
|
516
|
-
console.error(`Error compiling component ${file}: ${
|
|
550
|
+
const message = errorMessage(error);
|
|
551
|
+
errors.push(`Error compiling component ${file}: ${message}`);
|
|
552
|
+
console.error(`Error compiling component ${file}: ${message}`);
|
|
517
553
|
}
|
|
518
554
|
}
|
|
519
555
|
log(
|
|
@@ -579,15 +615,12 @@ export async function buildSite(
|
|
|
579
615
|
const declaredKeys =
|
|
580
616
|
i18n === null
|
|
581
617
|
? new Map<string, string>()
|
|
582
|
-
: await readTranslationKeys(
|
|
583
|
-
concreteRoutes as { sourcePath: string; urlPattern: string }[],
|
|
584
|
-
formatRegistry,
|
|
585
|
-
);
|
|
618
|
+
: await readTranslationKeys(concreteRoutes, formatRegistry);
|
|
586
619
|
const keyedRoutes = concreteRoutes.map((r) => ({
|
|
587
620
|
translationKey: declaredKeys.get(r.urlPattern),
|
|
588
621
|
urlPattern: r.urlPattern,
|
|
589
622
|
}));
|
|
590
|
-
const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl
|
|
623
|
+
const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl, trailingSlash);
|
|
591
624
|
/*
|
|
592
625
|
* The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
|
|
593
626
|
* same reason the alternates are — and separately from them because a switcher must work in a
|
|
@@ -683,7 +716,7 @@ export async function buildSite(
|
|
|
683
716
|
try {
|
|
684
717
|
log(` Compiling ${route.urlPattern} ...`);
|
|
685
718
|
const result = await compilePage(
|
|
686
|
-
route
|
|
719
|
+
route,
|
|
687
720
|
projectConfig,
|
|
688
721
|
projectRoot,
|
|
689
722
|
sections,
|
|
@@ -773,7 +806,7 @@ export async function buildSite(
|
|
|
773
806
|
* template was, which is precisely the signal `<lastmod>` exists to give.
|
|
774
807
|
*/
|
|
775
808
|
lastmod:
|
|
776
|
-
|
|
809
|
+
isString(route.sourceMtime) && route.sourceMtime !== ""
|
|
777
810
|
? route.sourceMtime
|
|
778
811
|
: toRfc3339(statSync(route.sourcePath).mtime),
|
|
779
812
|
loc: siteAbsoluteUrl(servedRoute(route.urlPattern, trailingSlash), siteUrl),
|
|
@@ -793,25 +826,26 @@ export async function buildSite(
|
|
|
793
826
|
continue;
|
|
794
827
|
}
|
|
795
828
|
try {
|
|
796
|
-
const content =
|
|
829
|
+
const content = await fmt.call("serialize", result.doc, {
|
|
797
830
|
buildScope: (state: Record<string, JxStateDefinition>) =>
|
|
798
831
|
buildInitialScope(state, null),
|
|
799
832
|
componentDefs,
|
|
800
|
-
evaluateTemplate: (value: string, scope:
|
|
833
|
+
evaluateTemplate: (value: string, scope: JxScope) => {
|
|
801
834
|
if (!isTemplateString(value)) {
|
|
802
835
|
return;
|
|
803
836
|
}
|
|
804
837
|
return evaluateStaticTemplate(value, scope) ?? value;
|
|
805
838
|
},
|
|
806
839
|
mode: "export",
|
|
807
|
-
})
|
|
840
|
+
});
|
|
808
841
|
if (content) {
|
|
809
842
|
const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]!);
|
|
810
843
|
sidecars.push({ content, path: sidecarPath, type: fmt.mediaTypeEssence });
|
|
811
844
|
}
|
|
812
845
|
} catch (error) {
|
|
813
|
-
|
|
814
|
-
|
|
846
|
+
errors.push(
|
|
847
|
+
`Error exporting ${fmt.name} for ${route.urlPattern}: ${errorMessage(error)}`,
|
|
848
|
+
);
|
|
815
849
|
}
|
|
816
850
|
}
|
|
817
851
|
for (const sidecar of sidecars) {
|
|
@@ -847,8 +881,7 @@ export async function buildSite(
|
|
|
847
881
|
fileCount += 1;
|
|
848
882
|
}
|
|
849
883
|
} catch (error) {
|
|
850
|
-
const
|
|
851
|
-
const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
|
|
884
|
+
const msg = `Error compiling ${route.urlPattern}: ${errorMessage(error)}`;
|
|
852
885
|
errors.push(msg);
|
|
853
886
|
console.error(msg);
|
|
854
887
|
}
|
|
@@ -920,7 +953,7 @@ export async function buildSite(
|
|
|
920
953
|
fileCount += 1;
|
|
921
954
|
log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
|
|
922
955
|
} catch (error) {
|
|
923
|
-
const msg = `Error bundling dist/${workerName}: ${(error
|
|
956
|
+
const msg = `Error bundling dist/${workerName}: ${errorMessage(error)}`;
|
|
924
957
|
errors.push(msg);
|
|
925
958
|
console.error(msg);
|
|
926
959
|
}
|
|
@@ -987,7 +1020,7 @@ export async function buildSite(
|
|
|
987
1020
|
fileCount += 1;
|
|
988
1021
|
log(` ${bundle.specifier} → ${assetPath}`);
|
|
989
1022
|
} catch (error) {
|
|
990
|
-
const msg = `Error bundling sidecar "${bundle.specifier}": ${(error
|
|
1023
|
+
const msg = `Error bundling sidecar "${bundle.specifier}": ${errorMessage(error)}`;
|
|
991
1024
|
errors.push(msg);
|
|
992
1025
|
console.error(msg);
|
|
993
1026
|
}
|
|
@@ -1012,9 +1045,7 @@ export async function buildSite(
|
|
|
1012
1045
|
fileCount += 1;
|
|
1013
1046
|
log(` ${specifiers.length} element(s) → ${assetPath}`);
|
|
1014
1047
|
} catch (error) {
|
|
1015
|
-
const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${
|
|
1016
|
-
(error as Error).message
|
|
1017
|
-
}`;
|
|
1048
|
+
const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${errorMessage(error)}`;
|
|
1018
1049
|
errors.push(msg);
|
|
1019
1050
|
console.error(msg);
|
|
1020
1051
|
}
|
|
@@ -1044,23 +1075,23 @@ export async function buildSite(
|
|
|
1044
1075
|
// Runs later and can shadow emitted files — same semantics as sitemap.xml.
|
|
1045
1076
|
for (const entry of registry.emitters()) {
|
|
1046
1077
|
const sectionKey = entry.project?.key;
|
|
1047
|
-
const sectionValue = sectionKey
|
|
1048
|
-
? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
|
|
1049
|
-
: undefined;
|
|
1078
|
+
const sectionValue = sectionKey ? projectConfig[sectionKey] : undefined;
|
|
1050
1079
|
if (
|
|
1051
1080
|
sectionKey &&
|
|
1052
1081
|
(sectionValue == null ||
|
|
1053
|
-
(
|
|
1082
|
+
(isNonNullObject(sectionValue) && Object.keys(sectionValue).length === 0))
|
|
1054
1083
|
) {
|
|
1055
1084
|
continue; // Section owner with nothing declared — same gating as sections/mounts
|
|
1056
1085
|
}
|
|
1057
1086
|
try {
|
|
1058
|
-
|
|
1087
|
+
/* The emit contract (extensions.md §8.4) answers outDir-relative files, or null for none; each
|
|
1088
|
+
`path` is still checked for traversal below before anything is written. */
|
|
1089
|
+
const emitted = await entry.call("emit", sectionValue ?? null, {
|
|
1059
1090
|
projectConfig,
|
|
1060
1091
|
root: projectRoot,
|
|
1061
1092
|
routes,
|
|
1062
1093
|
sections,
|
|
1063
|
-
})
|
|
1094
|
+
});
|
|
1064
1095
|
for (const file of emitted ?? []) {
|
|
1065
1096
|
const target = resolve(outDir, file.path.replace(/^\//, ""));
|
|
1066
1097
|
if (!target.startsWith(outDir + sep)) {
|
|
@@ -1072,7 +1103,7 @@ export async function buildSite(
|
|
|
1072
1103
|
log(` ${entry.name} emitted ${file.path}`);
|
|
1073
1104
|
}
|
|
1074
1105
|
} catch (error) {
|
|
1075
|
-
const msg = `Error in ${entry.name}.emit: ${(error
|
|
1106
|
+
const msg = `Error in ${entry.name}.emit: ${errorMessage(error)}`;
|
|
1076
1107
|
errors.push(msg);
|
|
1077
1108
|
console.error(msg);
|
|
1078
1109
|
}
|
|
@@ -1170,9 +1201,12 @@ export async function buildSite(
|
|
|
1170
1201
|
errors.push(error);
|
|
1171
1202
|
console.error(error);
|
|
1172
1203
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1204
|
+
// `path` is null only when there is nothing to emit; both builders always name sw.js.
|
|
1205
|
+
if (output.path !== null) {
|
|
1206
|
+
writeFileSync(join(outDir, output.path), output.source, "utf8");
|
|
1207
|
+
fileCount += 1;
|
|
1208
|
+
log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
|
|
1209
|
+
}
|
|
1176
1210
|
}
|
|
1177
1211
|
}
|
|
1178
1212
|
|
|
@@ -1229,8 +1263,11 @@ export async function buildSite(
|
|
|
1229
1263
|
if (projectConfig.copy) {
|
|
1230
1264
|
log("Copying mapped files...");
|
|
1231
1265
|
for (const [src, dest] of Object.entries(projectConfig.copy)) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1266
|
+
if (!isString(dest)) {
|
|
1267
|
+
throw new TypeError(`project.json copy["${src}"] must be a destination path string`);
|
|
1268
|
+
}
|
|
1269
|
+
const srcPath = resolve(projectRoot, src);
|
|
1270
|
+
const destPath = resolve(outDir, dest);
|
|
1234
1271
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
1235
1272
|
cpSync(srcPath, destPath);
|
|
1236
1273
|
}
|
|
@@ -1245,15 +1282,22 @@ export async function buildSite(
|
|
|
1245
1282
|
return { errors, files: fileCount, routes: routes.length };
|
|
1246
1283
|
}
|
|
1247
1284
|
|
|
1285
|
+
/** The route `compilePage` renders: a URL and the page document that produces it. */
|
|
1286
|
+
interface PageRoute {
|
|
1287
|
+
urlPattern: string;
|
|
1288
|
+
sourcePath: string;
|
|
1289
|
+
_pathParams?: Record<string, string>;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1248
1292
|
/**
|
|
1249
1293
|
* Compile a single page within the site build context.
|
|
1250
1294
|
*
|
|
1251
1295
|
* Pipeline: load JSON → resolve layout → inject context → merge head → compile
|
|
1252
1296
|
*
|
|
1253
|
-
* @param {
|
|
1297
|
+
* @param {PageRoute} route
|
|
1254
1298
|
* @param {ProjectConfig} projectConfig
|
|
1255
1299
|
* @param {string} projectRoot
|
|
1256
|
-
* @param {
|
|
1300
|
+
* @param {ProjectSections} sections - Extension-loaded project sections
|
|
1257
1301
|
* @param {import("./image-cache.js").CacheManifest | null} [imageCache]
|
|
1258
1302
|
* @param {Map<string, JxElement>} [componentDefs]
|
|
1259
1303
|
* @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
|
|
@@ -1268,16 +1312,16 @@ export async function buildSite(
|
|
|
1268
1312
|
* `rewriteNpmAsset`, was a function that could never run pretending to be a safety net.
|
|
1269
1313
|
*/
|
|
1270
1314
|
async function compilePage(
|
|
1271
|
-
route:
|
|
1315
|
+
route: PageRoute,
|
|
1272
1316
|
projectConfig: ResolvedProjectConfig,
|
|
1273
1317
|
projectRoot: string,
|
|
1274
|
-
sections:
|
|
1318
|
+
sections: ProjectSections,
|
|
1275
1319
|
imageCache: CacheManifest | null,
|
|
1276
1320
|
componentDefs: Map<string, JxElement>,
|
|
1277
1321
|
imageMetaCache: ImageMetaCache | null,
|
|
1278
|
-
formatRegistry: FormatRegistry
|
|
1279
|
-
registry: ExtensionRegistry
|
|
1280
|
-
rewriteSidecarSrc: (
|
|
1322
|
+
formatRegistry: FormatRegistry,
|
|
1323
|
+
registry: ExtensionRegistry,
|
|
1324
|
+
rewriteSidecarSrc: (specifier: string, docDir: string | null) => string,
|
|
1281
1325
|
assetMounts: readonly AssetMount[],
|
|
1282
1326
|
extensionHead: readonly JxHeadEntry[],
|
|
1283
1327
|
rewriteNpmAsset: (specifier: string) => string,
|
|
@@ -1291,14 +1335,18 @@ async function compilePage(
|
|
|
1291
1335
|
const basePath = siteBasePath(projectConfig.url);
|
|
1292
1336
|
|
|
1293
1337
|
// Load the raw page document (.json natively, other formats via the registry)
|
|
1294
|
-
const pageDoc = await readPageDocument(route.sourcePath
|
|
1338
|
+
const pageDoc = await readPageDocument(route.sourcePath, formatRegistry);
|
|
1295
1339
|
|
|
1296
1340
|
// Resolve layout (wraps page in layout with slot distribution)
|
|
1297
1341
|
const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
|
|
1298
1342
|
|
|
1299
1343
|
// Extract head arrays before they get lost in the merge
|
|
1344
|
+
/* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
|
|
1345
|
+
JxHeadEntry[]) into it when the page has one. */
|
|
1300
1346
|
const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
|
|
1301
|
-
const layoutHead =
|
|
1347
|
+
const layoutHead = layoutDoc.$head ?? [];
|
|
1348
|
+
/* SAFETY: `_pageTitle` is written only by `resolveLayout`, which copies the page's `title` (a
|
|
1349
|
+
string) into it when the page has one. */
|
|
1302
1350
|
const pageTitle = (pageDoc.title ?? layoutDoc._pageTitle ?? null) as string | null;
|
|
1303
1351
|
|
|
1304
1352
|
// Clean up internal properties
|
|
@@ -1315,32 +1363,31 @@ async function compilePage(
|
|
|
1315
1363
|
*/
|
|
1316
1364
|
const language = pageLanguage({
|
|
1317
1365
|
defaults: projectConfig.defaults,
|
|
1318
|
-
pageDir:
|
|
1319
|
-
pageLang:
|
|
1366
|
+
pageDir: isString(pageDoc.$dir) ? pageDoc.$dir : undefined,
|
|
1367
|
+
pageLang: isString(pageDoc.$lang) ? pageDoc.$lang : undefined,
|
|
1320
1368
|
routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
|
|
1321
1369
|
});
|
|
1322
1370
|
|
|
1323
1371
|
// Resolve generic $prototype entries via .class.json imports (and lower registry classes)
|
|
1324
1372
|
await resolvePrototypes(layoutDoc, route, projectRoot, {
|
|
1325
1373
|
config: projectConfig,
|
|
1374
|
+
registerBundle: (specifier: string) => {
|
|
1375
|
+
rewriteSidecarSrc(specifier, null);
|
|
1376
|
+
},
|
|
1377
|
+
registry,
|
|
1326
1378
|
sections,
|
|
1327
|
-
...(registry === undefined ? {} : { registry }),
|
|
1328
|
-
...(rewriteSidecarSrc === undefined
|
|
1329
|
-
? {}
|
|
1330
|
-
: {
|
|
1331
|
-
registerBundle: (specifier: string) => {
|
|
1332
|
-
rewriteSidecarSrc(specifier, null);
|
|
1333
|
-
},
|
|
1334
|
-
}),
|
|
1335
1379
|
});
|
|
1336
1380
|
|
|
1337
1381
|
// Build scope from resolved state so template strings in title/$head can be evaluated
|
|
1338
1382
|
const scope = buildInitialScope(layoutDoc.state ?? {});
|
|
1339
1383
|
|
|
1340
1384
|
// Determine the page title — resolve template strings against the scope
|
|
1341
|
-
let title = pageTitle ?? projectConfig.name
|
|
1342
|
-
if (
|
|
1343
|
-
|
|
1385
|
+
let title = pageTitle ?? projectConfig.name;
|
|
1386
|
+
if (isTemplateString(title)) {
|
|
1387
|
+
/* SAFETY: the result stands in for the title text it was written as, and its one consumer,
|
|
1388
|
+
`mergeHead`, places it as the `<title>` element's only child, which `renderHead` joins into
|
|
1389
|
+
the markup as text, so a non-string result renders as its string form. */
|
|
1390
|
+
title = (evaluateStaticTemplate(title, scope) as string | null) ?? title;
|
|
1344
1391
|
}
|
|
1345
1392
|
|
|
1346
1393
|
// Resolve template strings in $head entries
|
|
@@ -1393,12 +1440,7 @@ async function compilePage(
|
|
|
1393
1440
|
if (key === "$site" || key === "$page") {
|
|
1394
1441
|
continue;
|
|
1395
1442
|
}
|
|
1396
|
-
if (
|
|
1397
|
-
def &&
|
|
1398
|
-
typeof def === "object" &&
|
|
1399
|
-
!Array.isArray(def) &&
|
|
1400
|
-
(def as JxMutableNode).timing === "compiler"
|
|
1401
|
-
) {
|
|
1443
|
+
if (isJsonObject(def) && def.timing === "compiler") {
|
|
1402
1444
|
delete layoutDoc.state[key];
|
|
1403
1445
|
} else if (Array.isArray(def)) {
|
|
1404
1446
|
strippable.add(key);
|
|
@@ -1408,7 +1450,7 @@ async function compilePage(
|
|
|
1408
1450
|
let rescued = true;
|
|
1409
1451
|
while (rescued) {
|
|
1410
1452
|
rescued = false;
|
|
1411
|
-
const surviving = { ...layoutDoc.state }
|
|
1453
|
+
const surviving = { ...layoutDoc.state };
|
|
1412
1454
|
for (const key of strippable) {
|
|
1413
1455
|
delete surviving[key];
|
|
1414
1456
|
}
|
|
@@ -1444,7 +1486,7 @@ async function compilePage(
|
|
|
1444
1486
|
const resolvedSiteHead = [
|
|
1445
1487
|
// The manifest link and theme colour are first-party and unconditional once declared, so they
|
|
1446
1488
|
// Sit with the extension contributions: below the project's own $head, which still wins.
|
|
1447
|
-
...
|
|
1489
|
+
...manifestHeadEntries(projectConfig),
|
|
1448
1490
|
...swHead,
|
|
1449
1491
|
...extensionHead,
|
|
1450
1492
|
...resolveHeadBareSpecifiers(projectConfig.$head ?? [], rewriteNpmAsset),
|
|
@@ -1453,9 +1495,9 @@ async function compilePage(
|
|
|
1453
1495
|
// Merge $head from site + layout + page
|
|
1454
1496
|
const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
|
|
1455
1497
|
title,
|
|
1456
|
-
charset: projectConfig.defaults
|
|
1457
|
-
|
|
1458
|
-
|
|
1498
|
+
charset: projectConfig.defaults.charset ?? "utf8",
|
|
1499
|
+
siteName: projectConfig.name,
|
|
1500
|
+
siteUrl: projectConfig.url,
|
|
1459
1501
|
...(locale.alternates.length > 0 && { alternates: locale.alternates }),
|
|
1460
1502
|
/*
|
|
1461
1503
|
* The canonical link and og:url name the URL the host answers with the page: the route as
|
|
@@ -1484,40 +1526,47 @@ async function compilePage(
|
|
|
1484
1526
|
* it is governed by `images.lazyLoad`, and declining to generate variants for an image says
|
|
1485
1527
|
* nothing about when the browser should fetch it.
|
|
1486
1528
|
*/
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1529
|
+
/* SAFETY: `loadProjectConfig` spreads its `images` defaults (optimize, widths, formats, quality,
|
|
1530
|
+
sizes, lazyLoad) under the author's block, so every member the optimizer's config requires is
|
|
1531
|
+
present. */
|
|
1532
|
+
const imageConfig = projectConfig.images as ImageConfig;
|
|
1533
|
+
await transformImageNodes(
|
|
1534
|
+
layoutDoc,
|
|
1535
|
+
imageConfig,
|
|
1536
|
+
projectRoot,
|
|
1537
|
+
imageCache,
|
|
1538
|
+
imageMetaCache ?? undefined,
|
|
1539
|
+
assetMounts,
|
|
1540
|
+
);
|
|
1497
1541
|
|
|
1498
1542
|
// Compile the document using the existing compiler
|
|
1499
|
-
const
|
|
1543
|
+
const compileOptions: CompileOptions = {
|
|
1500
1544
|
lang: language.lang,
|
|
1501
|
-
/*
|
|
1502
|
-
* The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
|
|
1503
|
-
* that reached compile-static/compile-client kept loading its runtime from esm.sh even after
|
|
1504
|
-
* the self-hosting landed, because `injectComponentScripts` sees a map already present and
|
|
1505
|
-
* declines to add its own. Both halves have to name the same URLs.
|
|
1506
|
-
*/
|
|
1507
|
-
...(runtimeImports["@vue/reactivity"] === undefined
|
|
1508
|
-
? {}
|
|
1509
|
-
: { reactivitySrc: runtimeImports["@vue/reactivity"] }),
|
|
1510
|
-
...(runtimeImports["lit-html"] === undefined ? {} : { litHtmlSrc: runtimeImports["lit-html"] }),
|
|
1511
1545
|
prePaintScheme: false, // Injected via the merged <head> above, not the target template
|
|
1512
1546
|
projectStyle: projectConfig.style ?? null,
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1547
|
+
/*
|
|
1548
|
+
* The page's own module (`app.js`) is JavaScript the page loads, like a component: the page
|
|
1549
|
+
* rewrite does not read it, so its `/assets/` imports are re-rooted on the site base here.
|
|
1550
|
+
*/
|
|
1551
|
+
rewriteSrc: (specifier: string) =>
|
|
1552
|
+
withBase(basePath, rewriteSidecarSrc(specifier, dirname(route.sourcePath))),
|
|
1519
1553
|
title,
|
|
1520
|
-
}
|
|
1554
|
+
};
|
|
1555
|
+
/*
|
|
1556
|
+
* The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
|
|
1557
|
+
* that reached compile-static/compile-client kept loading its runtime from esm.sh even after
|
|
1558
|
+
* the self-hosting landed, because `injectComponentScripts` sees a map already present and
|
|
1559
|
+
* declines to add its own. Both halves have to name the same URLs.
|
|
1560
|
+
*/
|
|
1561
|
+
const reactivitySrc = runtimeImports["@vue/reactivity"];
|
|
1562
|
+
if (reactivitySrc !== undefined) {
|
|
1563
|
+
compileOptions.reactivitySrc = reactivitySrc;
|
|
1564
|
+
}
|
|
1565
|
+
const litHtmlSrc = runtimeImports["lit-html"];
|
|
1566
|
+
if (litHtmlSrc !== undefined) {
|
|
1567
|
+
compileOptions.litHtmlSrc = litHtmlSrc;
|
|
1568
|
+
}
|
|
1569
|
+
const result = await compile(layoutDoc, compileOptions);
|
|
1521
1570
|
|
|
1522
1571
|
// Inject CSS rules collected from component slot content
|
|
1523
1572
|
if (slotCss.rules.length > 0) {
|
|
@@ -1549,10 +1598,9 @@ async function compilePage(
|
|
|
1549
1598
|
typeof e === "string" && !e.startsWith("./") && !e.startsWith("../");
|
|
1550
1599
|
const npmElements = [
|
|
1551
1600
|
...new Set(
|
|
1552
|
-
[
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
].filter((e) => isNpmElementEntry(e)),
|
|
1601
|
+
[...(layoutDoc.$elements ?? []), ...(pageDoc.$elements ?? [])].filter((e) =>
|
|
1602
|
+
isNpmElementEntry(e),
|
|
1603
|
+
),
|
|
1556
1604
|
),
|
|
1557
1605
|
];
|
|
1558
1606
|
if (npmElements.length > 0) {
|
|
@@ -1561,9 +1609,9 @@ async function compilePage(
|
|
|
1561
1609
|
|
|
1562
1610
|
// Compile server handler if applicable (skip when provider bundles site-wide)
|
|
1563
1611
|
let serverHandler: string | null = null;
|
|
1564
|
-
if (!projectConfig.build
|
|
1612
|
+
if (!projectConfig.build.adapter) {
|
|
1565
1613
|
try {
|
|
1566
|
-
const serverResult = await compileServer(route.sourcePath
|
|
1614
|
+
const serverResult = await compileServer(route.sourcePath);
|
|
1567
1615
|
if (serverResult) {
|
|
1568
1616
|
serverHandler = serverResult;
|
|
1569
1617
|
}
|
|
@@ -1600,19 +1648,19 @@ async function compilePage(
|
|
|
1600
1648
|
* @param {FormatEntry[]} activeMounts - Server-mount classes with active sections
|
|
1601
1649
|
* @param {ExtensionRegistry} registry
|
|
1602
1650
|
* @param {ProjectConfig} projectConfig
|
|
1603
|
-
* @returns {
|
|
1651
|
+
* @returns {WorkerMountSpecs}
|
|
1604
1652
|
*/
|
|
1605
1653
|
function buildMountSpecs(
|
|
1606
1654
|
activeMounts: FormatEntry[],
|
|
1607
1655
|
registry: ExtensionRegistry,
|
|
1608
1656
|
projectConfig: ProjectConfig,
|
|
1609
|
-
):
|
|
1657
|
+
): WorkerMountSpecs {
|
|
1610
1658
|
if (activeMounts.length === 0) {
|
|
1611
1659
|
return { connectors: [], mounts: [] };
|
|
1612
1660
|
}
|
|
1613
1661
|
const base = siteBasePath(projectConfig.url);
|
|
1614
1662
|
|
|
1615
|
-
const sections: Record<string,
|
|
1663
|
+
const sections: Record<string, ProjectConfig[string]> = {};
|
|
1616
1664
|
for (const contribution of registry.projectContributions()) {
|
|
1617
1665
|
const { key } = contribution.project!;
|
|
1618
1666
|
if (key in projectConfig) {
|
|
@@ -1623,7 +1671,7 @@ function buildMountSpecs(
|
|
|
1623
1671
|
const mounts: SiteMountSpec[] = activeMounts.map((entry) => {
|
|
1624
1672
|
const server = entry.server!;
|
|
1625
1673
|
const { module } = server;
|
|
1626
|
-
if (
|
|
1674
|
+
if (!isString(module) || module === "") {
|
|
1627
1675
|
throw new Error(
|
|
1628
1676
|
`Extension class "${entry.name}": server.module (a bare import specifier) is required ` +
|
|
1629
1677
|
`to generate a deployable worker`,
|
|
@@ -1635,7 +1683,7 @@ function buildMountSpecs(
|
|
|
1635
1683
|
const mountBase = withBase(base, server.basePath);
|
|
1636
1684
|
return {
|
|
1637
1685
|
basePath: mountBase,
|
|
1638
|
-
className:
|
|
1686
|
+
className: entry.classDef.title ?? entry.name,
|
|
1639
1687
|
module,
|
|
1640
1688
|
options: { basePath: mountBase, sections },
|
|
1641
1689
|
order: server.order ?? 100,
|
|
@@ -1646,12 +1694,12 @@ function buildMountSpecs(
|
|
|
1646
1694
|
// Vocabulary, §12) marks that provider's class for import.
|
|
1647
1695
|
const usedProviders = new Set<string>();
|
|
1648
1696
|
for (const value of Object.values(sections)) {
|
|
1649
|
-
if (!
|
|
1697
|
+
if (!isJsonObject(value)) {
|
|
1650
1698
|
continue;
|
|
1651
1699
|
}
|
|
1652
|
-
for (const sectionEntry of Object.values(value
|
|
1653
|
-
const provider = (sectionEntry
|
|
1654
|
-
if (
|
|
1700
|
+
for (const sectionEntry of Object.values(value)) {
|
|
1701
|
+
const provider = isJsonObject(sectionEntry) ? sectionEntry.provider : undefined;
|
|
1702
|
+
if (isString(provider)) {
|
|
1655
1703
|
usedProviders.add(provider);
|
|
1656
1704
|
}
|
|
1657
1705
|
}
|
|
@@ -1664,14 +1712,14 @@ function buildMountSpecs(
|
|
|
1664
1712
|
continue;
|
|
1665
1713
|
}
|
|
1666
1714
|
const { module } = entry.connector!;
|
|
1667
|
-
if (
|
|
1715
|
+
if (!isString(module) || module === "") {
|
|
1668
1716
|
throw new Error(
|
|
1669
1717
|
`Connector class "${entry.name}": connector.module (a bare import specifier) is ` +
|
|
1670
1718
|
`required to generate a deployable worker`,
|
|
1671
1719
|
);
|
|
1672
1720
|
}
|
|
1673
1721
|
connectors.push({
|
|
1674
|
-
className:
|
|
1722
|
+
className: entry.classDef.title ?? entry.name,
|
|
1675
1723
|
module,
|
|
1676
1724
|
provider,
|
|
1677
1725
|
});
|
|
@@ -1680,6 +1728,12 @@ function buildMountSpecs(
|
|
|
1680
1728
|
return { connectors, mounts };
|
|
1681
1729
|
}
|
|
1682
1730
|
|
|
1731
|
+
/** What the generated worker imports and mounts, as `buildMountSpecs` derives it. */
|
|
1732
|
+
interface WorkerMountSpecs {
|
|
1733
|
+
mounts: SiteMountSpec[];
|
|
1734
|
+
connectors: SiteConnectorSpec[];
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1683
1737
|
/**
|
|
1684
1738
|
* Gather every extension's `<head>` contribution, once, before the first page is built.
|
|
1685
1739
|
*
|
|
@@ -1696,72 +1750,89 @@ async function collectExtensionHead(
|
|
|
1696
1750
|
const out: JxHeadEntry[] = [];
|
|
1697
1751
|
for (const entry of registry.headProviders()) {
|
|
1698
1752
|
const key = entry.project?.key;
|
|
1699
|
-
const sectionValue = key === undefined ? null :
|
|
1753
|
+
const sectionValue = key === undefined ? null : projectConfig[key];
|
|
1700
1754
|
if (key !== undefined && (sectionValue === undefined || sectionValue === null)) {
|
|
1701
1755
|
continue;
|
|
1702
1756
|
}
|
|
1703
1757
|
try {
|
|
1704
|
-
const contributed =
|
|
1758
|
+
const contributed = await entry.call("head", sectionValue ?? null, {
|
|
1705
1759
|
projectConfig,
|
|
1706
1760
|
root: projectRoot,
|
|
1707
|
-
})
|
|
1761
|
+
});
|
|
1708
1762
|
out.push(...(contributed ?? []));
|
|
1709
1763
|
} catch (error) {
|
|
1710
|
-
console.warn(`${entry.name} head capability failed: ${(error
|
|
1764
|
+
console.warn(`${entry.name} head capability failed: ${errorMessage(error)}`);
|
|
1711
1765
|
}
|
|
1712
1766
|
}
|
|
1713
1767
|
return out;
|
|
1714
1768
|
}
|
|
1715
1769
|
|
|
1716
1770
|
/**
|
|
1717
|
-
* Resolve template strings
|
|
1771
|
+
* Resolve template strings anywhere inside a structured data block (a JSON-LD `textContent`).
|
|
1718
1772
|
*
|
|
1719
|
-
* @param {
|
|
1720
|
-
* @param {
|
|
1721
|
-
* @returns {
|
|
1773
|
+
* @param {JsonValue} value
|
|
1774
|
+
* @param {JxScope} scope
|
|
1775
|
+
* @returns {JsonValue}
|
|
1722
1776
|
*/
|
|
1723
|
-
function resolveTemplatesDeep(value:
|
|
1724
|
-
if (typeof value === "string") {
|
|
1725
|
-
return isTemplateString(value) ? (evaluateStaticTemplate(value, scope) ?? value) : value;
|
|
1726
|
-
}
|
|
1777
|
+
function resolveTemplatesDeep(value: JsonValue, scope: JxScope): JsonValue {
|
|
1727
1778
|
if (Array.isArray(value)) {
|
|
1728
1779
|
return value.map((v) => resolveTemplatesDeep(v, scope));
|
|
1729
1780
|
}
|
|
1730
|
-
if (value
|
|
1781
|
+
if (isJsonObject(value)) {
|
|
1731
1782
|
return Object.fromEntries(
|
|
1732
1783
|
Object.entries(value).map(([k, v]) => [k, resolveTemplatesDeep(v, scope)]),
|
|
1733
1784
|
);
|
|
1734
1785
|
}
|
|
1786
|
+
if (isTemplateString(value)) {
|
|
1787
|
+
/* SAFETY: the block is JSON that `renderHead` serializes with JSON.stringify, and the result
|
|
1788
|
+
takes the place of the string it was written as, so it is serialized exactly as a value
|
|
1789
|
+
authored at that position would be. */
|
|
1790
|
+
return (evaluateStaticTemplate(value, scope) ?? value) as JsonValue;
|
|
1791
|
+
}
|
|
1735
1792
|
return value;
|
|
1736
1793
|
}
|
|
1737
1794
|
|
|
1738
|
-
|
|
1795
|
+
/**
|
|
1796
|
+
* Resolve template strings in $head entries against the compiled scope.
|
|
1797
|
+
*
|
|
1798
|
+
* @param {JxHeadEntry[]} headEntries
|
|
1799
|
+
* @param {JxScope} scope
|
|
1800
|
+
* @returns {JxHeadEntry[]}
|
|
1801
|
+
*/
|
|
1802
|
+
function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: JxScope) {
|
|
1739
1803
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
1740
|
-
|
|
1804
|
+
// A document's `$head` is authored JSON, so a member is not guaranteed to be an entry object.
|
|
1805
|
+
if (!isNonNullObject(entry)) {
|
|
1741
1806
|
return entry;
|
|
1742
1807
|
}
|
|
1743
1808
|
const resolved = { ...entry };
|
|
1744
1809
|
if (resolved.attributes) {
|
|
1745
1810
|
resolved.attributes = { ...resolved.attributes };
|
|
1746
1811
|
for (const [k, v] of Object.entries(resolved.attributes)) {
|
|
1747
|
-
if (
|
|
1812
|
+
if (isTemplateString(v)) {
|
|
1813
|
+
/* SAFETY: the result replaces the attribute value it was written as, and `renderHead`
|
|
1814
|
+
writes every attribute value other than `true` through `String()`. */
|
|
1748
1815
|
resolved.attributes[k] =
|
|
1749
|
-
(evaluateStaticTemplate(v, scope) as string | boolean | null) ??
|
|
1750
|
-
(v as string | boolean);
|
|
1816
|
+
(evaluateStaticTemplate(v, scope) as string | boolean | null) ?? v;
|
|
1751
1817
|
}
|
|
1752
1818
|
}
|
|
1753
1819
|
}
|
|
1754
|
-
if (
|
|
1820
|
+
if (isTemplateString(resolved.textContent)) {
|
|
1821
|
+
/* SAFETY: the result replaces the text it was written as, and `renderHead` interpolates a
|
|
1822
|
+
non-object `textContent` into the markup as text (an object one is serialized as JSON). */
|
|
1755
1823
|
resolved.textContent =
|
|
1756
1824
|
(evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
|
|
1757
1825
|
resolved.textContent;
|
|
1758
|
-
} else if (resolved.textContent
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1826
|
+
} else if (isNonNullObject(resolved.textContent)) {
|
|
1827
|
+
/*
|
|
1828
|
+
* A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
|
|
1829
|
+
* never reference the page it describes.
|
|
1830
|
+
*
|
|
1831
|
+
* SAFETY: `resolveTemplatesDeep` keeps a container's shape (an object resolves to an object,
|
|
1832
|
+
* an array to an array, and JSON-LD admits either at the top), and `renderHead` serializes
|
|
1833
|
+
* any object `textContent` with JSON.stringify.
|
|
1834
|
+
*/
|
|
1835
|
+
resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as JsonObject;
|
|
1765
1836
|
}
|
|
1766
1837
|
return resolved;
|
|
1767
1838
|
});
|
|
@@ -1782,13 +1853,14 @@ function resolveHeadBareSpecifiers(
|
|
|
1782
1853
|
rewrite: (specifier: string) => string,
|
|
1783
1854
|
) {
|
|
1784
1855
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
1785
|
-
|
|
1856
|
+
// Authored JSON, as in `resolveHeadTemplates`: a member is not guaranteed to be an object.
|
|
1857
|
+
if (!isNonNullObject(entry) || !entry.attributes) {
|
|
1786
1858
|
return entry;
|
|
1787
1859
|
}
|
|
1788
1860
|
const resolved = { ...entry, attributes: { ...entry.attributes } };
|
|
1789
1861
|
for (const key of ["href", "src"]) {
|
|
1790
1862
|
const val = resolved.attributes[key];
|
|
1791
|
-
if (
|
|
1863
|
+
if (isString(val) && isBareSpecifier(val)) {
|
|
1792
1864
|
resolved.attributes[key] = rewrite(val);
|
|
1793
1865
|
}
|
|
1794
1866
|
}
|
|
@@ -1843,59 +1915,70 @@ function inertAttribute(resolved: string): string {
|
|
|
1843
1915
|
* Deep-clone a map template, resolving template strings and $ref values against the given scope.
|
|
1844
1916
|
*
|
|
1845
1917
|
* @param {JxElement} template
|
|
1846
|
-
* @param {
|
|
1918
|
+
* @param {JxScope} scope
|
|
1847
1919
|
* @returns {JxElement}
|
|
1848
1920
|
*/
|
|
1849
|
-
function expandMapTemplate(template: JxElement, scope:
|
|
1850
|
-
|
|
1921
|
+
function expandMapTemplate(template: JxElement, scope: JxScope): JxElement {
|
|
1922
|
+
// A map template is authored JSON, so a child reached by recursion may not be an object.
|
|
1923
|
+
if (!isNonNullObject(template)) {
|
|
1851
1924
|
return template;
|
|
1852
1925
|
}
|
|
1853
|
-
const node = {}
|
|
1926
|
+
const node: JxElement = {};
|
|
1854
1927
|
for (const [k, v] of Object.entries(template)) {
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1928
|
+
// Each branch reads `template[k]` through its declared member, which is the same value as `v`.
|
|
1929
|
+
if (k === "children" && Array.isArray(template.children)) {
|
|
1930
|
+
node.children = template.children.map((child) => {
|
|
1931
|
+
if (isString(child)) {
|
|
1858
1932
|
return child;
|
|
1859
1933
|
}
|
|
1860
1934
|
return expandMapTemplate(child, scope);
|
|
1861
1935
|
});
|
|
1862
|
-
} else if (k === "style" &&
|
|
1863
|
-
const style: JxStyle = { ...
|
|
1936
|
+
} else if (k === "style" && isNonNullObject(template.style)) {
|
|
1937
|
+
const style: JxStyle = { ...template.style };
|
|
1864
1938
|
for (const [sk, sv] of Object.entries(style)) {
|
|
1865
1939
|
if (isTemplateString(sv)) {
|
|
1866
|
-
|
|
1940
|
+
/* SAFETY: template evaluation yields a substituted scalar for the declaration it was
|
|
1941
|
+
written as, and the style compilers interpolate declaration values into CSS text. */
|
|
1867
1942
|
style[sk] = (evaluateMapTemplate(sv, scope) as string | number | undefined) ?? sv;
|
|
1868
1943
|
}
|
|
1869
1944
|
}
|
|
1870
1945
|
node.style = style;
|
|
1871
|
-
} else if (k === "attributes" &&
|
|
1872
|
-
const attrs = { ...
|
|
1946
|
+
} else if (k === "attributes" && isNonNullObject(template.attributes)) {
|
|
1947
|
+
const attrs = { ...template.attributes };
|
|
1873
1948
|
for (const [ak, av] of Object.entries(attrs)) {
|
|
1874
1949
|
if (isTemplateString(av)) {
|
|
1950
|
+
/* SAFETY: the result replaces the attribute value it was written as, and the compile
|
|
1951
|
+
targets write attribute values into markup as their string form. */
|
|
1875
1952
|
const resolved = evaluateMapTemplate(av, scope) as JxAttributeValue | undefined;
|
|
1876
1953
|
attrs[ak] =
|
|
1877
|
-
|
|
1954
|
+
isString(resolved) && resolved.includes("${")
|
|
1878
1955
|
? inertAttribute(resolved)
|
|
1879
1956
|
: (resolved ?? av);
|
|
1880
1957
|
}
|
|
1881
1958
|
}
|
|
1882
1959
|
node.attributes = attrs;
|
|
1883
|
-
} else if (k === "$props" &&
|
|
1884
|
-
const props = { ...
|
|
1960
|
+
} else if (k === "$props" && isNonNullObject(template.$props)) {
|
|
1961
|
+
const props = { ...template.$props };
|
|
1885
1962
|
for (const [pk, pv] of Object.entries(props)) {
|
|
1886
1963
|
if (isTemplateString(pv)) {
|
|
1964
|
+
/* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
|
|
1965
|
+
`data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
|
|
1966
|
+
authored at that prop would be. */
|
|
1887
1967
|
const resolved = evaluateMapTemplate(pv, scope) as JsonValue | undefined;
|
|
1888
1968
|
// Null = evaluation error → keep template string; undefined = missing data → use null
|
|
1889
1969
|
props[pk] = resolved !== null ? (resolved ?? null) : pv;
|
|
1890
1970
|
}
|
|
1891
1971
|
}
|
|
1892
1972
|
node.$props = props;
|
|
1893
|
-
} else if (
|
|
1973
|
+
} else if (isTemplateString(v)) {
|
|
1894
1974
|
const resolved = evaluateMapTemplate(v, scope);
|
|
1895
|
-
if (k === "textContent" &&
|
|
1975
|
+
if (k === "textContent" && isString(resolved) && resolved.includes("${")) {
|
|
1896
1976
|
node.innerHTML = inertTextHtml(resolved);
|
|
1897
1977
|
} else {
|
|
1898
|
-
|
|
1978
|
+
/* SAFETY: the result takes the place of the template string at the same key, which is the
|
|
1979
|
+
substitution a map template asks for; a member the compile targets emit as text or an
|
|
1980
|
+
attribute is interpolated there as its string form. */
|
|
1981
|
+
node[k] = (resolved ?? v) as JxElement[string];
|
|
1899
1982
|
}
|
|
1900
1983
|
} else {
|
|
1901
1984
|
node[k] = v;
|
|
@@ -1909,29 +1992,21 @@ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>):
|
|
|
1909
1992
|
* `state`, and `$map` as local variables.
|
|
1910
1993
|
*
|
|
1911
1994
|
* @param {string} str
|
|
1912
|
-
* @param {
|
|
1913
|
-
* @returns {
|
|
1995
|
+
* @param {JxScope} scope
|
|
1996
|
+
* @returns {JxScope[string]} Whatever the expression yields, or null when it throws
|
|
1914
1997
|
*/
|
|
1915
|
-
function evaluateMapTemplate(str: string, scope:
|
|
1998
|
+
function evaluateMapTemplate(str: string, scope: JxScope): JxScope[string] {
|
|
1916
1999
|
try {
|
|
1917
|
-
const
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
}
|
|
1928
|
-
const fn = new Function("state", "$map", "item", "index", `return \`${str}\``) as (
|
|
1929
|
-
state: Record<string, unknown>,
|
|
1930
|
-
$map: unknown,
|
|
1931
|
-
item: unknown,
|
|
1932
|
-
index: unknown,
|
|
1933
|
-
) => unknown;
|
|
1934
|
-
return fn(scope, scope.$map, item, index);
|
|
2000
|
+
const frame = scope.$map;
|
|
2001
|
+
const item = isNonNullObject(frame) && "item" in frame ? frame.item : undefined;
|
|
2002
|
+
const index = isNonNullObject(frame) && "index" in frame ? frame.index : undefined;
|
|
2003
|
+
const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
|
|
2004
|
+
/* SAFETY: `new Function` compiles exactly the four named parameters, and its body returns one
|
|
2005
|
+
expression's value, which may be any scope value. */
|
|
2006
|
+
const fn = new Function("state", "$map", "item", "index", body) as (
|
|
2007
|
+
...args: JxScope[string][]
|
|
2008
|
+
) => JxScope[string];
|
|
2009
|
+
return fn(scope, frame, item, index);
|
|
1935
2010
|
} catch {
|
|
1936
2011
|
return null;
|
|
1937
2012
|
}
|
|
@@ -1942,14 +2017,15 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
|
|
|
1942
2017
|
* place — evaluates ${...} in innerHTML, textContent, style values, and attribute values.
|
|
1943
2018
|
*
|
|
1944
2019
|
* @param {JxElement | string} node
|
|
1945
|
-
* @param {
|
|
2020
|
+
* @param {JxScope} scope
|
|
1946
2021
|
*/
|
|
1947
|
-
function resolveDocTemplates(node: JxElement | string, scope:
|
|
1948
|
-
|
|
2022
|
+
function resolveDocTemplates(node: JxElement | string, scope: JxScope) {
|
|
2023
|
+
// Children are authored JSON, so a member reached by recursion may be neither node nor string.
|
|
2024
|
+
if (!isNonNullObject(node)) {
|
|
1949
2025
|
return;
|
|
1950
2026
|
}
|
|
1951
2027
|
|
|
1952
|
-
if (
|
|
2028
|
+
if (isTemplateString(node.innerHTML)) {
|
|
1953
2029
|
const resolved = evaluateStaticTemplate(node.innerHTML, scope);
|
|
1954
2030
|
if (resolved != null) {
|
|
1955
2031
|
// Encode any remaining `${` as HTML entities so the compile phase won't
|
|
@@ -1958,44 +2034,55 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1958
2034
|
node.innerHTML = String(resolved).replaceAll("${", "${");
|
|
1959
2035
|
}
|
|
1960
2036
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2037
|
+
const text = node.textContent;
|
|
2038
|
+
if (isTemplateString(text)) {
|
|
2039
|
+
const resolved = evaluateStaticTemplate(text, scope);
|
|
2040
|
+
if (isString(resolved) && resolved.includes("${")) {
|
|
1964
2041
|
// The resolved value is data (an excerpt, a heading, a title) that happens to contain `${`, not
|
|
1965
2042
|
// A template. Left as `textContent` the compile phase would read it as one again and bind it
|
|
1966
2043
|
// To an expression that throws or renders nothing, so it stays text as escaped `innerHTML`.
|
|
1967
2044
|
node.innerHTML = inertTextHtml(resolved);
|
|
1968
2045
|
delete node.textContent;
|
|
1969
2046
|
} else {
|
|
1970
|
-
|
|
2047
|
+
/* SAFETY: the result is the text the template stands for, and the compile targets
|
|
2048
|
+
interpolate `textContent` into the markup, so a non-string result renders as its string
|
|
2049
|
+
form. */
|
|
2050
|
+
node.textContent = (resolved as string | null) ?? text;
|
|
1971
2051
|
}
|
|
1972
2052
|
}
|
|
1973
|
-
if (node.style
|
|
2053
|
+
if (isNonNullObject(node.style)) {
|
|
1974
2054
|
for (const [k, v] of Object.entries(node.style)) {
|
|
1975
|
-
if (
|
|
2055
|
+
if (isTemplateString(v)) {
|
|
2056
|
+
/* SAFETY: the result replaces the declaration value it was written as, and the page style
|
|
2057
|
+
pass interpolates declaration values into CSS text. */
|
|
1976
2058
|
node.style[k] =
|
|
1977
|
-
(evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ??
|
|
1978
|
-
(v as string | number | JxStyle);
|
|
2059
|
+
(evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ?? v;
|
|
1979
2060
|
}
|
|
1980
2061
|
}
|
|
1981
2062
|
}
|
|
1982
|
-
if (node.attributes
|
|
2063
|
+
if (isNonNullObject(node.attributes)) {
|
|
1983
2064
|
for (const [k, v] of Object.entries(node.attributes)) {
|
|
1984
|
-
if (
|
|
2065
|
+
if (isTemplateString(v)) {
|
|
2066
|
+
/* SAFETY: the result replaces the attribute value it was written as, and the compile
|
|
2067
|
+
targets write attribute values into markup as their string form. */
|
|
1985
2068
|
const resolved = evaluateStaticTemplate(v, scope) as JxAttributeValue | null;
|
|
1986
2069
|
// A resolved value is data: a `${` left in it must not become a template a second time. An
|
|
1987
2070
|
// Entity cannot be written into an attribute, so the sequence is split with a zero-width space.
|
|
1988
2071
|
node.attributes[k] =
|
|
1989
|
-
|
|
2072
|
+
isString(resolved) && resolved.includes("${")
|
|
1990
2073
|
? inertAttribute(resolved)
|
|
1991
2074
|
: (resolved ?? v);
|
|
1992
2075
|
}
|
|
1993
2076
|
}
|
|
1994
2077
|
}
|
|
1995
|
-
if (node.$props
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2078
|
+
if (isNonNullObject(node.$props)) {
|
|
2079
|
+
const props = node.$props;
|
|
2080
|
+
for (const [k, v] of Object.entries(props)) {
|
|
2081
|
+
if (isTemplateString(v)) {
|
|
2082
|
+
/* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
|
|
2083
|
+
`data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
|
|
2084
|
+
authored at that prop would be. */
|
|
2085
|
+
props[k] = (evaluateStaticTemplate(v, scope) as JsonValue | null) ?? v;
|
|
1999
2086
|
}
|
|
2000
2087
|
}
|
|
2001
2088
|
}
|
|
@@ -2013,9 +2100,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
2013
2100
|
return;
|
|
2014
2101
|
}
|
|
2015
2102
|
}
|
|
2016
|
-
if (
|
|
2103
|
+
if (isTemplateString(rawChildren)) {
|
|
2017
2104
|
const resolved = evaluateStaticTemplate(rawChildren, scope);
|
|
2018
2105
|
if (Array.isArray(resolved)) {
|
|
2106
|
+
/* SAFETY: a children template that yields an array stands for the child list itself, and
|
|
2107
|
+
every member is walked by `resolveDocTemplates` below, which skips non-objects. */
|
|
2019
2108
|
const resolvedNodes = resolved as (JxElement | string)[];
|
|
2020
2109
|
node.children = resolvedNodes;
|
|
2021
2110
|
for (const child of resolvedNodes) {
|
|
@@ -2026,9 +2115,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
2026
2115
|
let i = 0;
|
|
2027
2116
|
while (i < node.children.length) {
|
|
2028
2117
|
const child = node.children[i]!;
|
|
2029
|
-
if (
|
|
2118
|
+
if (isTemplateString(child)) {
|
|
2030
2119
|
const resolved = evaluateStaticTemplate(child, scope);
|
|
2031
2120
|
if (Array.isArray(resolved)) {
|
|
2121
|
+
/* SAFETY: a child template that yields an array stands for the children it splices in,
|
|
2122
|
+
and every member is walked by `resolveDocTemplates` below, which skips non-objects. */
|
|
2032
2123
|
const resolvedNodes = resolved as (JxElement | string)[];
|
|
2033
2124
|
node.children.splice(i, 1, ...resolvedNodes);
|
|
2034
2125
|
for (const spliced of resolvedNodes) {
|
|
@@ -2072,26 +2163,25 @@ function referencesStateKey(haystack: string, key: string): boolean {
|
|
|
2072
2163
|
* build time, or null otherwise (leave the array node for client-side rendering).
|
|
2073
2164
|
*
|
|
2074
2165
|
* @param {JxMappedArray} arrayDef
|
|
2075
|
-
* @param {
|
|
2166
|
+
* @param {JxScope} scope
|
|
2076
2167
|
* @returns {(JxElement | string)[] | null}
|
|
2077
2168
|
*/
|
|
2078
2169
|
function expandMappedArrayStatic(
|
|
2079
2170
|
arrayDef: JxMappedArray,
|
|
2080
|
-
scope:
|
|
2171
|
+
scope: JxScope,
|
|
2081
2172
|
): (JxElement | string)[] | null {
|
|
2082
2173
|
const itemsSrc = arrayDef.items;
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
items = resolveRefValue(itemsSrc.$ref, scope);
|
|
2086
|
-
} else if (Array.isArray(itemsSrc)) {
|
|
2087
|
-
items = itemsSrc;
|
|
2088
|
-
}
|
|
2174
|
+
// A non-array, non-ref `items` falls through to the array check below and expands to nothing.
|
|
2175
|
+
const items = isRef(itemsSrc) ? resolveRefValue(itemsSrc.$ref, scope) : itemsSrc;
|
|
2089
2176
|
const mapTemplate = arrayDef.map;
|
|
2090
2177
|
if (!Array.isArray(items) || !mapTemplate) {
|
|
2091
2178
|
return null;
|
|
2092
2179
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
2180
|
+
const rows: readonly JxScope[string][] = items;
|
|
2181
|
+
return rows.map((item, index) => {
|
|
2182
|
+
/* SAFETY: `Object.create(scope)` is a new object with no own keys whose prototype is `scope`,
|
|
2183
|
+
so every key it answers is a binding of `scope` until the row's own are set below. */
|
|
2184
|
+
const childScope = Object.create(scope) as JxScope;
|
|
2095
2185
|
childScope.$map = { index, item };
|
|
2096
2186
|
childScope["$map/item"] = item;
|
|
2097
2187
|
childScope["$map/index"] = index;
|
|
@@ -2113,22 +2203,23 @@ interface SlotCssCollector {
|
|
|
2113
2203
|
* preRenderComponentHtml, marks static/prerendered. Slot children get their static styles collected
|
|
2114
2204
|
* into `slotCss` (assigning jxs-N classes) before being serialized to HTML.
|
|
2115
2205
|
*
|
|
2116
|
-
* @param {JxElement | string} node
|
|
2206
|
+
* @param {JxElement | string | (JxElement | string)[]} node - A nested array (authored JSON can
|
|
2207
|
+
* carry one among children) is walked member by member
|
|
2117
2208
|
* @param {Map<string, JxElement>} componentDefs
|
|
2118
2209
|
* @param {SlotCssCollector} [slotCss]
|
|
2119
2210
|
* @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
|
|
2120
2211
|
*/
|
|
2121
2212
|
function expandComponents(
|
|
2122
|
-
node: JxElement | string,
|
|
2213
|
+
node: JxElement | string | (JxElement | string)[],
|
|
2123
2214
|
componentDefs: Map<string, JxElement>,
|
|
2124
2215
|
slotCss?: SlotCssCollector,
|
|
2125
2216
|
defaults?: ProjectConfig["defaults"],
|
|
2126
2217
|
) {
|
|
2127
|
-
if (!node
|
|
2218
|
+
if (!isNonNullObject(node)) {
|
|
2128
2219
|
return;
|
|
2129
2220
|
}
|
|
2130
2221
|
if (Array.isArray(node)) {
|
|
2131
|
-
for (const n of node
|
|
2222
|
+
for (const n of node) {
|
|
2132
2223
|
expandComponents(n, componentDefs, slotCss, defaults);
|
|
2133
2224
|
}
|
|
2134
2225
|
return;
|
|
@@ -2141,7 +2232,12 @@ function expandComponents(
|
|
|
2141
2232
|
}
|
|
2142
2233
|
}
|
|
2143
2234
|
|
|
2144
|
-
|
|
2235
|
+
// Only a literal tag can name a registered component; a tag expression never matches one.
|
|
2236
|
+
const { tagName } = node;
|
|
2237
|
+
if (!isString(tagName)) {
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const def = componentDefs.get(tagName);
|
|
2145
2241
|
if (def) {
|
|
2146
2242
|
// Literal `props.*` attribute keys become $props (`liftPropsAttributes` says why) and leave the
|
|
2147
2243
|
// Attributes, so they do not reach the emitted HTML. Explicit $props wins on key conflicts.
|
|
@@ -2164,7 +2260,7 @@ function expandComponents(
|
|
|
2164
2260
|
Array.isArray(node.children) && node.children.length > 0
|
|
2165
2261
|
? node.children
|
|
2166
2262
|
.map((c: JxElement | string) => {
|
|
2167
|
-
if (slotCss && c
|
|
2263
|
+
if (slotCss && isNonNullObject(c)) {
|
|
2168
2264
|
collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
|
|
2169
2265
|
}
|
|
2170
2266
|
return renderStaticNode(c, {}, null);
|
|
@@ -2185,7 +2281,7 @@ function expandComponents(
|
|
|
2185
2281
|
const context: ComponentPrerenderContext = {
|
|
2186
2282
|
componentDefs,
|
|
2187
2283
|
defaults,
|
|
2188
|
-
path: [{ key: JSON.stringify(props ?? {}), tag:
|
|
2284
|
+
path: [{ key: JSON.stringify(props ?? {}), tag: tagName }],
|
|
2189
2285
|
};
|
|
2190
2286
|
const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
|
|
2191
2287
|
const isStatic = isComponentFullyStatic(def);
|
|
@@ -2198,7 +2294,7 @@ function expandComponents(
|
|
|
2198
2294
|
*/
|
|
2199
2295
|
node.innerHTML = shadow
|
|
2200
2296
|
? `<template shadowrootmode="${shadow}">` +
|
|
2201
|
-
`<link rel="stylesheet" href="/components/${
|
|
2297
|
+
`<link rel="stylesheet" href="/components/${tagName}.css">` +
|
|
2202
2298
|
`${innerHTML}</template>${slotContent ?? ""}`
|
|
2203
2299
|
: innerHTML;
|
|
2204
2300
|
delete node.children;
|
|
@@ -2216,6 +2312,9 @@ function expandComponents(
|
|
|
2216
2312
|
*/
|
|
2217
2313
|
const resolvedStyle = resolveHostStyle(def.style, buildInstanceScope(def, props));
|
|
2218
2314
|
if (Object.keys(resolvedStyle).length > 0) {
|
|
2315
|
+
/* SAFETY: every value in `resolvedStyle` replaces a template-string declaration of
|
|
2316
|
+
`def.style`, and the page style pass interpolates declaration values into CSS text, so the
|
|
2317
|
+
merged object is a style in every way the build reads one. */
|
|
2219
2318
|
node.style = { ...node.style, ...resolvedStyle } as JxStyle;
|
|
2220
2319
|
}
|
|
2221
2320
|
|
|
@@ -2293,8 +2392,10 @@ function injectComponentScripts(
|
|
|
2293
2392
|
* Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
|
|
2294
2393
|
*/
|
|
2295
2394
|
const componentCss = usedTags
|
|
2296
|
-
.
|
|
2297
|
-
|
|
2395
|
+
.flatMap((tag: string) => {
|
|
2396
|
+
const css = cssMap.get(tag);
|
|
2397
|
+
return css === undefined || css === "" ? [] : [css];
|
|
2398
|
+
})
|
|
2298
2399
|
.join("\n");
|
|
2299
2400
|
const styleBlock =
|
|
2300
2401
|
componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
|
|
@@ -2480,25 +2581,41 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
|
|
|
2480
2581
|
* An HTML meta-refresh is a _client-side_ redirect: the browser fetches the source, then navigates.
|
|
2481
2582
|
* That is a reasonable stand-in for a 301 or a 303 on a host that ignores `_redirects`, and it is
|
|
2482
2583
|
* actively wrong for everything else.
|
|
2584
|
+
*
|
|
2585
|
+
* Keyed by the status's decimal string so an authored `"status": "301"` (schema-invalid, but
|
|
2586
|
+
* project.json is not validated at build time) still matches the way an object-key lookup would.
|
|
2483
2587
|
*/
|
|
2484
|
-
const REDIRECT_HTML_POLICY
|
|
2588
|
+
const REDIRECT_HTML_POLICY = new Map<string, RedirectHtmlPolicy>([
|
|
2485
2589
|
// The permanent case: a canonical link is exactly the right signal.
|
|
2486
|
-
301
|
|
2590
|
+
["301", { canonical: true, html: true, why: "" }],
|
|
2487
2591
|
// Temporary — a canonical link would assert the permanence the status denies.
|
|
2488
|
-
302
|
|
2592
|
+
["302", { canonical: false, html: true, why: "" }],
|
|
2489
2593
|
// "See other, with GET" is what a meta-refresh already does.
|
|
2490
|
-
303
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2594
|
+
["303", { canonical: false, html: true, why: "" }],
|
|
2595
|
+
[
|
|
2596
|
+
"307",
|
|
2597
|
+
{
|
|
2598
|
+
canonical: false,
|
|
2599
|
+
html: false,
|
|
2600
|
+
why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
|
|
2601
|
+
},
|
|
2602
|
+
],
|
|
2603
|
+
[
|
|
2604
|
+
"308",
|
|
2605
|
+
{
|
|
2606
|
+
canonical: false,
|
|
2607
|
+
html: false,
|
|
2608
|
+
why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
|
|
2609
|
+
},
|
|
2610
|
+
],
|
|
2611
|
+
]);
|
|
2612
|
+
|
|
2613
|
+
/** One redirection status's HTML-fallback rule, in {@link REDIRECT_HTML_POLICY}. */
|
|
2614
|
+
interface RedirectHtmlPolicy {
|
|
2615
|
+
html: boolean;
|
|
2616
|
+
canonical: boolean;
|
|
2617
|
+
why: string;
|
|
2618
|
+
}
|
|
2502
2619
|
|
|
2503
2620
|
/**
|
|
2504
2621
|
* Emit `dist/_redirects` plus an HTML fallback for the literal sources whose status has one.
|
|
@@ -2508,7 +2625,7 @@ const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean;
|
|
|
2508
2625
|
* string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2509
2626
|
* >} redirects
|
|
2510
2627
|
* @param {string} outDir
|
|
2511
|
-
* @param {{ compiledUrls?: Set<string>; trailingSlash?: string }} [opts]
|
|
2628
|
+
* @param {{ compiledUrls?: Set<string>; trailingSlash?: string | null }} [opts]
|
|
2512
2629
|
* @returns {{ files: number; errors: string[] }}
|
|
2513
2630
|
*/
|
|
2514
2631
|
function generateRedirects(
|
|
@@ -2517,7 +2634,7 @@ function generateRedirects(
|
|
|
2517
2634
|
string | { destination: string; status?: number } | { destination: string; rewrite: true }
|
|
2518
2635
|
>,
|
|
2519
2636
|
outDir: string,
|
|
2520
|
-
opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
|
|
2637
|
+
opts: { compiledUrls?: Set<string>; trailingSlash?: string | null; basePath?: string } = {},
|
|
2521
2638
|
) {
|
|
2522
2639
|
let files = 0;
|
|
2523
2640
|
const errors: string[] = [];
|
|
@@ -2527,18 +2644,18 @@ function generateRedirects(
|
|
|
2527
2644
|
const trailingSlash = opts.trailingSlash ?? "always";
|
|
2528
2645
|
|
|
2529
2646
|
for (const [source, target] of Object.entries(redirects)) {
|
|
2530
|
-
const dest =
|
|
2531
|
-
const isRewrite =
|
|
2647
|
+
const dest = isString(target) ? target : target.destination;
|
|
2648
|
+
const isRewrite = !isString(target) && "rewrite" in target && target.rewrite;
|
|
2532
2649
|
const status = isRewrite
|
|
2533
2650
|
? 200
|
|
2534
|
-
:
|
|
2651
|
+
: !isString(target) && "status" in target
|
|
2535
2652
|
? (target.status ?? 301)
|
|
2536
2653
|
: 301;
|
|
2537
2654
|
|
|
2538
|
-
if (!isRewrite && REDIRECT_HTML_POLICY
|
|
2655
|
+
if (!isRewrite && !REDIRECT_HTML_POLICY.has(String(status))) {
|
|
2539
2656
|
errors.push(
|
|
2540
2657
|
`Redirect "${source}" has status ${status}, which is not an RFC 9110 §15.4 redirection ` +
|
|
2541
|
-
`status. Use one of ${
|
|
2658
|
+
`status. Use one of ${[...REDIRECT_HTML_POLICY.keys()].join(", ")}, or ` +
|
|
2542
2659
|
`{ destination, rewrite: true } for a proxy.`,
|
|
2543
2660
|
);
|
|
2544
2661
|
continue;
|
|
@@ -2571,7 +2688,7 @@ function generateRedirects(
|
|
|
2571
2688
|
if (isRewrite) {
|
|
2572
2689
|
continue;
|
|
2573
2690
|
}
|
|
2574
|
-
const policy = REDIRECT_HTML_POLICY
|
|
2691
|
+
const policy = REDIRECT_HTML_POLICY.get(String(status))!;
|
|
2575
2692
|
if (!policy.html) {
|
|
2576
2693
|
continue;
|
|
2577
2694
|
}
|