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