@jxsuite/site 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/compose.ts +57 -38
- package/src/context.ts +34 -24
- package/src/head-merger.ts +61 -19
- package/src/layout.ts +34 -36
- package/src/paths.ts +38 -30
- package/src/routes.ts +25 -7
- package/src/serve.ts +15 -4
- package/src/shell.ts +3 -2
- package/src/site-style.ts +48 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/site",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "What a Jx project's page at a route consists of — routing, layout, context, head and the document that boots it",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test:coverage": "bun test --isolate --coverage"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jxsuite/runtime": "^
|
|
35
|
-
"@jxsuite/schema": "^
|
|
34
|
+
"@jxsuite/runtime": "^5.1.0",
|
|
35
|
+
"@jxsuite/schema": "^4.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/compose.ts
CHANGED
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { compareRoutes, fileToRoute, isRoutedPageFile, matchRoute } from "./routes.ts";
|
|
26
|
-
import type {
|
|
26
|
+
import type { FileRoute } from "./routes.ts";
|
|
27
27
|
import { resolveLayout } from "./layout.ts";
|
|
28
|
-
import { mergeHead } from "./head-merger.ts";
|
|
28
|
+
import { mergeHead, withPageDescription } from "./head-merger.ts";
|
|
29
|
+
import type { HeadMergeContext } from "./head-merger.ts";
|
|
29
30
|
import { injectContext } from "./context.ts";
|
|
31
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
30
32
|
import { localeDirection, localeOfRoute, resolveI18n } from "@jxsuite/schema/locale";
|
|
31
33
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
32
34
|
import type { JxDocument, JxElement, JxHeadEntry, ProjectConfig } from "@jxsuite/schema/types";
|
|
@@ -64,7 +66,7 @@ export interface SiteIO {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/** A page file and the route it answers. */
|
|
67
|
-
export interface PageRoute extends
|
|
69
|
+
export interface PageRoute extends FileRoute {
|
|
68
70
|
/** Project path of the page source, e.g. `pages/blog/[slug].json`. */
|
|
69
71
|
sourcePath: string;
|
|
70
72
|
}
|
|
@@ -72,8 +74,11 @@ export interface PageRoute extends RouteShape {
|
|
|
72
74
|
/** The route table, in match order. Static beats dynamic beats catch-all. */
|
|
73
75
|
export function routeTable(paths: readonly string[]): PageRoute[] {
|
|
74
76
|
return paths
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
+
.flatMap((path) =>
|
|
78
|
+
path.startsWith("pages/") && isRoutedPageFile(path)
|
|
79
|
+
? [Object.assign(fileToRoute(path), { sourcePath: path })]
|
|
80
|
+
: [],
|
|
81
|
+
)
|
|
77
82
|
.toSorted(compareRoutes);
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -93,14 +98,17 @@ async function readDocument(io: SiteIO, path: string): Promise<JxDocument | null
|
|
|
93
98
|
export async function readProjectConfig(io: SiteIO): Promise<ProjectConfig> {
|
|
94
99
|
const text = await io.read("project.json");
|
|
95
100
|
if (text === null) {
|
|
96
|
-
return {}
|
|
101
|
+
return {};
|
|
97
102
|
}
|
|
98
103
|
try {
|
|
104
|
+
/* SAFETY: only the syntax is checked here, deliberately: every ProjectConfig member is
|
|
105
|
+
optional, and shape checking is `validateProject`'s job against the per-project schema,
|
|
106
|
+
which `jx build` runs and a live preview of a half-edited config must not refuse over. */
|
|
99
107
|
return JSON.parse(text) as ProjectConfig;
|
|
100
108
|
} catch {
|
|
101
109
|
/* A project whose config does not parse still has pages, and refusing to render any of them
|
|
102
110
|
would hide the one error the author can act on behind a blank origin. */
|
|
103
|
-
return {}
|
|
111
|
+
return {};
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
|
|
@@ -111,18 +119,18 @@ export async function readProjectConfig(io: SiteIO): Promise<ProjectConfig> {
|
|
|
111
119
|
* name, so a page and its layout declaring the same component register it once.
|
|
112
120
|
*/
|
|
113
121
|
export function unionElements(
|
|
114
|
-
...lists: (readonly
|
|
122
|
+
...lists: (readonly (string | JxElement)[] | undefined)[]
|
|
115
123
|
): (string | JxElement)[] | undefined {
|
|
116
124
|
const seen = new Set<string>();
|
|
117
125
|
const merged: (string | JxElement)[] = [];
|
|
118
126
|
for (const entry of lists.flatMap((list) => list ?? [])) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
/* An authored list can hold a `null` its type does not admit; it is keyed by its own JSON
|
|
128
|
+
(`"null"`) like any other entry without a `$ref`, rather than throwing. */
|
|
129
|
+
const ref = isNonNullObject(entry) ? entry.$ref : undefined;
|
|
130
|
+
const key = isString(entry) ? entry : (ref ?? JSON.stringify(entry));
|
|
123
131
|
if (!seen.has(key)) {
|
|
124
132
|
seen.add(key);
|
|
125
|
-
merged.push(entry
|
|
133
|
+
merged.push(entry);
|
|
126
134
|
}
|
|
127
135
|
}
|
|
128
136
|
return merged.length > 0 ? merged : undefined;
|
|
@@ -142,22 +150,26 @@ function refPath(ref: string): string {
|
|
|
142
150
|
return new URL(ref, "file:///").pathname;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Every custom-element tag a document tree names, root included.
|
|
155
|
+
*
|
|
156
|
+
* Text children are skipped, and so is a `null` an authored children array can still hold.
|
|
157
|
+
*/
|
|
158
|
+
function collectTagNames(node: JxElement | string, into: Set<string>): void {
|
|
159
|
+
if (!isNonNullObject(node)) {
|
|
148
160
|
return;
|
|
149
161
|
}
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
const { children, tagName } = node;
|
|
163
|
+
if (isString(tagName) && tagName.includes("-")) {
|
|
164
|
+
into.add(tagName);
|
|
165
|
+
}
|
|
166
|
+
/* A repeater filling the whole children slot names no tag of its own, and its row template
|
|
167
|
+
(`map`) is not walked, so only an array of children leads anywhere. */
|
|
168
|
+
if (Array.isArray(children)) {
|
|
169
|
+
for (const child of children) {
|
|
152
170
|
collectTagNames(child, into);
|
|
153
171
|
}
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
const { children, tagName } = node as { children?: unknown; tagName?: unknown };
|
|
157
|
-
if (typeof tagName === "string" && tagName.includes("-")) {
|
|
158
|
-
into.add(tagName);
|
|
159
172
|
}
|
|
160
|
-
collectTagNames(children, into);
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
/**
|
|
@@ -197,21 +209,21 @@ async function discoverElements(io: SiteIO, doc: JxDocument): Promise<{ $ref: st
|
|
|
197
209
|
bring components of its own — it is just not declared a second time. */
|
|
198
210
|
const declared = new Set<string>();
|
|
199
211
|
for (const entry of doc.$elements ?? []) {
|
|
200
|
-
const ref = (entry
|
|
201
|
-
if (
|
|
212
|
+
const ref = isNonNullObject(entry) ? entry.$ref : undefined;
|
|
213
|
+
if (isString(ref)) {
|
|
202
214
|
declared.add(refPath(ref));
|
|
203
215
|
}
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
const discovered: { $ref: string }[] = [];
|
|
207
219
|
const opened = new Set<string>();
|
|
208
|
-
let frontier:
|
|
220
|
+
let frontier: JxDocument[] = [doc];
|
|
209
221
|
while (frontier.length > 0) {
|
|
210
222
|
const tags = new Set<string>();
|
|
211
223
|
for (const node of frontier) {
|
|
212
224
|
collectTagNames(node, tags);
|
|
213
225
|
}
|
|
214
|
-
const next:
|
|
226
|
+
const next: JxDocument[] = [];
|
|
215
227
|
for (const tag of tags) {
|
|
216
228
|
const path = available.get(tag);
|
|
217
229
|
if (path === undefined || opened.has(tag)) {
|
|
@@ -286,7 +298,7 @@ export async function composePage(
|
|
|
286
298
|
);
|
|
287
299
|
}
|
|
288
300
|
|
|
289
|
-
const merged = await resolveLayout(pageDoc, config
|
|
301
|
+
const merged = await resolveLayout(pageDoc, config, async (ref) => {
|
|
290
302
|
const doc = await readDocument(io, ref.replace(/^\.\//, ""));
|
|
291
303
|
if (!doc) {
|
|
292
304
|
throw new ComposeError(`Layout not found: ${ref}`);
|
|
@@ -323,21 +335,28 @@ export async function composePage(
|
|
|
323
335
|
merged.$elements = withDiscovered;
|
|
324
336
|
}
|
|
325
337
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
338
|
+
/* SAFETY: `_pageHead` is no authored key; `resolveLayout` is its only writer and stores a page's
|
|
339
|
+
(or a nested layout's) `$head` array in it, which is the `JxHeadEntry[]` asserted here. */
|
|
340
|
+
const pageHead = withPageDescription(
|
|
341
|
+
(pageDoc.$head ?? merged._pageHead ?? []) as JxHeadEntry[],
|
|
342
|
+
pageDoc.description,
|
|
343
|
+
);
|
|
344
|
+
const layoutHead = merged.$head ?? [];
|
|
345
|
+
const siteHead = config.$head ?? [];
|
|
346
|
+
/* SAFETY: `pageDoc.title` and `config.name` are declared strings, and `_pageTitle` is no authored
|
|
347
|
+
key: `resolveLayout` is its only writer and stores a page's (or a nested layout's) `title`. */
|
|
329
348
|
const title = (pageDoc.title ?? merged._pageTitle ?? config.name ?? "") as string;
|
|
330
349
|
const lang =
|
|
331
|
-
(
|
|
350
|
+
(isString(pageDoc.$lang) ? pageDoc.$lang : undefined) ??
|
|
332
351
|
localeOfRoute(route.urlPattern, i18n) ??
|
|
333
352
|
config.defaults?.lang ??
|
|
334
353
|
"en";
|
|
335
354
|
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
355
|
+
const headContext: HeadMergeContext = { lang, title };
|
|
356
|
+
if (config.name !== undefined) {
|
|
357
|
+
headContext.siteName = config.name;
|
|
358
|
+
}
|
|
359
|
+
const head = mergeHead(siteHead, layoutHead, pageHead, headContext);
|
|
341
360
|
|
|
342
361
|
/* `$head` is merged out of the document and rendered into the shell, so leaving it on the
|
|
343
362
|
document would have the runtime inject every entry a second time on mount. */
|
package/src/context.ts
CHANGED
|
@@ -21,18 +21,19 @@
|
|
|
21
21
|
* @docs framework/site/i18n
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
JxElement,
|
|
27
|
-
JxStateDefinition,
|
|
28
|
-
ProjectConfig,
|
|
29
|
-
} from "@jxsuite/schema/types";
|
|
24
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
25
|
+
import type { JxDocument, JxElement, JxStateObject, ProjectConfig } from "@jxsuite/schema/types";
|
|
30
26
|
import { localeDirection, localeLabel, localeOfRoute } from "@jxsuite/schema/locale";
|
|
31
27
|
import type { ResolvedI18n } from "@jxsuite/schema/locale";
|
|
32
28
|
|
|
33
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* One route in a translation set: the locale it serves, and where it lives. The shape `<head>` and
|
|
31
|
+
* `$page.alternates` share.
|
|
32
|
+
*/
|
|
34
33
|
export interface TranslationMember {
|
|
34
|
+
/** Canonical BCP 47 tag. */
|
|
35
35
|
locale: string;
|
|
36
|
+
/** Site-absolute route pattern, e.g. `/fr-ca/about/`. */
|
|
36
37
|
urlPattern: string;
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -41,7 +42,6 @@ export interface SiteRoute {
|
|
|
41
42
|
urlPattern: string;
|
|
42
43
|
sourcePath?: string;
|
|
43
44
|
_pathParams?: Record<string, string>;
|
|
44
|
-
[key: string]: unknown;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -78,10 +78,17 @@ export function injectContext(
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// $site context — read-only project-level data
|
|
81
|
+
const localeFields: JxStateObject = {};
|
|
82
|
+
if (i18n !== null) {
|
|
83
|
+
localeFields.defaultLocale = i18n.defaultLocale;
|
|
84
|
+
localeFields.locales = i18n.locales;
|
|
85
|
+
}
|
|
86
|
+
/* Project state is spread last, so a key it shares with the fields above overrides it in place.
|
|
87
|
+
A spread, not `Object.assign`: an authored `__proto__` key stays an own data property. */
|
|
81
88
|
doc.state.$site = {
|
|
82
89
|
name: projectConfig.name ?? "Jx Site",
|
|
83
90
|
url: projectConfig.url ?? "",
|
|
84
|
-
...
|
|
91
|
+
...localeFields,
|
|
85
92
|
...projectConfig.state,
|
|
86
93
|
};
|
|
87
94
|
|
|
@@ -93,7 +100,7 @@ export function injectContext(
|
|
|
93
100
|
* `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
|
|
94
101
|
*/
|
|
95
102
|
const pageLang =
|
|
96
|
-
(
|
|
103
|
+
(isString(doc.$lang) ? doc.$lang : undefined) ??
|
|
97
104
|
localeOfRoute(route.urlPattern, i18n) ??
|
|
98
105
|
projectConfig.defaults?.lang;
|
|
99
106
|
/*
|
|
@@ -114,19 +121,25 @@ export function injectContext(
|
|
|
114
121
|
label: localeLabel(member.locale),
|
|
115
122
|
url: member.urlPattern,
|
|
116
123
|
}));
|
|
117
|
-
|
|
124
|
+
const page: JxStateObject = {
|
|
118
125
|
params: route._pathParams ?? {},
|
|
119
126
|
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
120
127
|
url: route.urlPattern,
|
|
121
|
-
...(alternates.length > 0 && { alternates }),
|
|
122
|
-
...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
|
|
123
128
|
};
|
|
129
|
+
if (alternates.length > 0) {
|
|
130
|
+
page.alternates = alternates;
|
|
131
|
+
}
|
|
132
|
+
if (pageLang !== undefined) {
|
|
133
|
+
page.dir = localeDirection(pageLang);
|
|
134
|
+
page.locale = pageLang;
|
|
135
|
+
}
|
|
136
|
+
doc.state.$page = page;
|
|
124
137
|
|
|
125
138
|
// Merge project-level state into page state (page wins on conflicts)
|
|
126
139
|
if (projectConfig.state) {
|
|
127
140
|
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
128
141
|
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
129
|
-
doc.state[key] = value
|
|
142
|
+
doc.state[key] = value;
|
|
130
143
|
}
|
|
131
144
|
}
|
|
132
145
|
}
|
|
@@ -143,12 +156,11 @@ export function injectContext(
|
|
|
143
156
|
}
|
|
144
157
|
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
145
158
|
if (!(name in doc.imports)) {
|
|
146
|
-
const src = srcPath as string;
|
|
147
159
|
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
148
160
|
doc.imports[name] =
|
|
149
|
-
rebaseImport && (
|
|
150
|
-
? rebaseImport(
|
|
151
|
-
:
|
|
161
|
+
rebaseImport && (srcPath.startsWith("./") || srcPath.startsWith("../"))
|
|
162
|
+
? rebaseImport(srcPath, route)
|
|
163
|
+
: srcPath;
|
|
152
164
|
}
|
|
153
165
|
}
|
|
154
166
|
}
|
|
@@ -160,12 +172,10 @@ export function injectContext(
|
|
|
160
172
|
} else {
|
|
161
173
|
const seen = new Set<string>();
|
|
162
174
|
const merged: (string | JxElement)[] = [];
|
|
163
|
-
for (const entry of [
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const key =
|
|
168
|
-
typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
|
|
175
|
+
for (const entry of [...projectConfig.$elements, ...doc.$elements]) {
|
|
176
|
+
/* `$elements` is authored JSON, so an entry typed as an element can still be `null`; the
|
|
177
|
+
object check keeps such an entry keyless (and dropped) instead of throwing. */
|
|
178
|
+
const key = isString(entry) ? entry : isNonNullObject(entry) ? entry.$ref : undefined;
|
|
169
179
|
if (key && !seen.has(key)) {
|
|
170
180
|
seen.add(key);
|
|
171
181
|
merged.push(entry);
|
package/src/head-merger.ts
CHANGED
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
|
|
22
|
-
import
|
|
22
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
23
|
+
import type { JxDocumentValue, JxHeadEntry } from "@jxsuite/schema/types";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A `$head` entry as authored JSON can actually hold it, which is wider than {@link JxHeadEntry}: a
|
|
27
|
+
* document may carry a raw HTML string, a hand-written element may leave out `tagName`, and a
|
|
28
|
+
* malformed entry may be `null`. The merge keys and the render tolerate all three, so one bad entry
|
|
29
|
+
* never takes the whole page down with it.
|
|
30
|
+
*/
|
|
31
|
+
export type AuthoredHeadEntry = Partial<JxHeadEntry> | string | null;
|
|
23
32
|
|
|
24
33
|
/** What a page knows about itself while its `<head>` is being merged. */
|
|
25
34
|
export interface HeadMergeContext {
|
|
@@ -34,6 +43,40 @@ export interface HeadMergeContext {
|
|
|
34
43
|
alternates?: readonly { hreflang: string; href: string }[];
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/**
|
|
47
|
+
* A page's `$head` with its own `description` property stated as the description meta.
|
|
48
|
+
*
|
|
49
|
+
* A page names its description the way it names its title: as a property of the document, which for
|
|
50
|
+
* a Markdown page is a frontmatter key (`description: …` beside `title: …`). It is the PAGE's
|
|
51
|
+
* description, so it beats the site's and the layout's, and it is written ahead of the page's own
|
|
52
|
+
* `$head` so an explicit `<meta name="description">` there still wins, like every entry stated
|
|
53
|
+
* twice at one level. A value that is not a non-empty string states nothing. The entry goes through
|
|
54
|
+
* the same template resolution as the rest of the page's head, so a dynamic page's
|
|
55
|
+
* `"${state.entry.data.summary}"` is filled per route.
|
|
56
|
+
*
|
|
57
|
+
* Before this, the build read no such property, so the museum starter's three pages and every
|
|
58
|
+
* migrated Markdown page carrying `description:` shipped no description at all (site-architecture
|
|
59
|
+
* §8.4), while Studio showed the value in a field of its own beside the head's empty one.
|
|
60
|
+
*
|
|
61
|
+
* @param {AuthoredHeadEntry[]} pageHead - The page's own `$head`
|
|
62
|
+
* @param {JxDocumentValue | undefined} description - The page document's `description` property, as
|
|
63
|
+
* authored JSON holds it
|
|
64
|
+
* @returns {AuthoredHeadEntry[]} The head the page level merges
|
|
65
|
+
*/
|
|
66
|
+
export function withPageDescription<T extends AuthoredHeadEntry>(
|
|
67
|
+
pageHead: readonly T[],
|
|
68
|
+
description: JxDocumentValue | undefined,
|
|
69
|
+
): (T | JxHeadEntry)[] {
|
|
70
|
+
if (!isString(description) || description.trim() === "") {
|
|
71
|
+
return [...pageHead];
|
|
72
|
+
}
|
|
73
|
+
const entry: JxHeadEntry = {
|
|
74
|
+
attributes: { content: description, name: "description" },
|
|
75
|
+
tagName: "meta",
|
|
76
|
+
};
|
|
77
|
+
return [entry, ...pageHead];
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/**
|
|
38
81
|
* Merge $head arrays from site, layout, and page levels.
|
|
39
82
|
*
|
|
@@ -86,8 +129,8 @@ export function mergeHead(
|
|
|
86
129
|
* canonical link must not be.
|
|
87
130
|
*/
|
|
88
131
|
const hasAuthored = [...merged.values()].some(
|
|
89
|
-
(e) =>
|
|
90
|
-
e &&
|
|
132
|
+
(e: AuthoredHeadEntry) =>
|
|
133
|
+
isNonNullObject(e) && e.tagName === "link" && e.attributes?.rel === "canonical",
|
|
91
134
|
);
|
|
92
135
|
if (!hasAuthored) {
|
|
93
136
|
const entry: JxHeadEntry = {
|
|
@@ -138,11 +181,11 @@ export function mergeHead(
|
|
|
138
181
|
* Generate a deduplication key for a <head> element. Elements with the same key are considered
|
|
139
182
|
* duplicates; the last one wins.
|
|
140
183
|
*
|
|
141
|
-
* @param {
|
|
184
|
+
* @param {AuthoredHeadEntry} entry
|
|
142
185
|
* @returns {string}
|
|
143
186
|
*/
|
|
144
|
-
function headEntryKey(entry:
|
|
145
|
-
if (!entry
|
|
187
|
+
function headEntryKey(entry: AuthoredHeadEntry) {
|
|
188
|
+
if (!isNonNullObject(entry)) {
|
|
146
189
|
return String(entry);
|
|
147
190
|
}
|
|
148
191
|
|
|
@@ -190,7 +233,7 @@ function headEntryKey(entry: JxHeadEntry) {
|
|
|
190
233
|
// <style> — unique per content hash
|
|
191
234
|
if (tag === "style") {
|
|
192
235
|
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
193
|
-
const content =
|
|
236
|
+
const content = isNonNullObject(raw) ? JSON.stringify(raw) : (raw ?? "");
|
|
194
237
|
return `style:${simpleHash(content)}`;
|
|
195
238
|
}
|
|
196
239
|
|
|
@@ -216,24 +259,24 @@ function simpleHash(str: string) {
|
|
|
216
259
|
/**
|
|
217
260
|
* Render a merged $head array to HTML string for insertion into <head>.
|
|
218
261
|
*
|
|
219
|
-
* @param {
|
|
262
|
+
* @param {readonly AuthoredHeadEntry[]} headEntries - Merged head entries
|
|
220
263
|
* @returns {string} HTML string
|
|
221
264
|
*/
|
|
222
|
-
export function renderHead(headEntries:
|
|
223
|
-
return headEntries.map((e
|
|
265
|
+
export function renderHead(headEntries: readonly AuthoredHeadEntry[]) {
|
|
266
|
+
return headEntries.map((e) => renderHeadEntry(e)).join("\n ");
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
/**
|
|
227
270
|
* Render a single $head entry to an HTML string.
|
|
228
271
|
*
|
|
229
|
-
* @param {
|
|
272
|
+
* @param {AuthoredHeadEntry} entry
|
|
230
273
|
* @returns {string}
|
|
231
274
|
*/
|
|
232
|
-
function renderHeadEntry(entry:
|
|
233
|
-
if (
|
|
275
|
+
function renderHeadEntry(entry: AuthoredHeadEntry) {
|
|
276
|
+
if (isString(entry)) {
|
|
234
277
|
return entry;
|
|
235
278
|
}
|
|
236
|
-
if (!entry
|
|
279
|
+
if (!isNonNullObject(entry)) {
|
|
237
280
|
return "";
|
|
238
281
|
}
|
|
239
282
|
|
|
@@ -247,7 +290,7 @@ function renderHeadEntry(entry: JxHeadEntry) {
|
|
|
247
290
|
|
|
248
291
|
// Void elements (no closing tag)
|
|
249
292
|
const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
|
|
250
|
-
if (VOID.has(tag)) {
|
|
293
|
+
if (tag !== undefined && VOID.has(tag)) {
|
|
251
294
|
return open;
|
|
252
295
|
}
|
|
253
296
|
|
|
@@ -257,15 +300,14 @@ function renderHeadEntry(entry: JxHeadEntry) {
|
|
|
257
300
|
* page as the string "[object Object]".
|
|
258
301
|
*/
|
|
259
302
|
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
260
|
-
const content =
|
|
261
|
-
raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
|
|
303
|
+
const content = isNonNullObject(raw) ? JSON.stringify(raw, null, 2) : (raw ?? "");
|
|
262
304
|
return `${open}${content}</${tag}>`;
|
|
263
305
|
}
|
|
264
306
|
|
|
265
307
|
/**
|
|
266
|
-
* @param {
|
|
308
|
+
* @param {string | boolean} val - An attribute value; `true` never reaches here (it renders bare)
|
|
267
309
|
* @returns {string}
|
|
268
310
|
*/
|
|
269
|
-
function escapeAttr(val:
|
|
311
|
+
function escapeAttr(val: string | boolean) {
|
|
270
312
|
return String(val).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
271
313
|
}
|
package/src/layout.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* only thing this module asks for is a {@link LayoutLoader}, and everything else is pure.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import
|
|
21
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
22
|
+
import type { JxAttributeValue, JxDocument, JxElement, ProjectConfig } from "@jxsuite/schema/types";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Read one layout document by the `$layout` reference that named it.
|
|
@@ -32,25 +33,24 @@ export type LayoutLoader = (layoutRef: string) => Promise<JxDocument> | JxDocume
|
|
|
32
33
|
* Resolve a page's layout, wrapping the page content in the layout structure.
|
|
33
34
|
*
|
|
34
35
|
* @param {JxDocument} pageDoc - The raw page JSON document
|
|
35
|
-
* @param {
|
|
36
|
+
* @param {ProjectConfig} projectConfig - Site configuration (for defaults.layout)
|
|
36
37
|
* @param {LayoutLoader} load - Reads a layout document by its `$layout` reference
|
|
37
38
|
* @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
|
|
38
39
|
*/
|
|
39
40
|
export async function resolveLayout(
|
|
40
41
|
pageDoc: JxDocument,
|
|
41
|
-
projectConfig:
|
|
42
|
+
projectConfig: ProjectConfig,
|
|
42
43
|
load: LayoutLoader,
|
|
43
44
|
): Promise<JxDocument> {
|
|
44
45
|
// Determine which layout to use
|
|
45
|
-
const layoutRef =
|
|
46
|
-
pageDoc.$layout ?? (projectConfig.defaults as Record<string, unknown>)?.layout ?? null;
|
|
46
|
+
const layoutRef = pageDoc.$layout ?? projectConfig.defaults?.layout ?? null;
|
|
47
47
|
|
|
48
48
|
if (!layoutRef) {
|
|
49
49
|
// No layout — return page as-is
|
|
50
50
|
return pageDoc;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
let layoutDoc = await load(layoutRef
|
|
53
|
+
let layoutDoc = await load(layoutRef);
|
|
54
54
|
|
|
55
55
|
// Check for nested layouts (layout inheriting from another layout)
|
|
56
56
|
if (layoutDoc.$layout) {
|
|
@@ -59,12 +59,13 @@ export async function resolveLayout(
|
|
|
59
59
|
|
|
60
60
|
// Distribute page children into layout slots
|
|
61
61
|
const rawChildren = pageDoc.children ?? [];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
/* The declared type has no string form, but an authored page can still write `"children": "Hi"`
|
|
63
|
+
and the parser does not reject it, so the bare string is kept as the one child it means. */
|
|
64
|
+
const pageChildren: (JxElement | string)[] = isString(rawChildren)
|
|
65
|
+
? [rawChildren]
|
|
66
|
+
: Array.isArray(rawChildren)
|
|
67
|
+
? rawChildren
|
|
68
|
+
: [];
|
|
68
69
|
const merged = deepClone(layoutDoc);
|
|
69
70
|
|
|
70
71
|
distributeSlots(merged, pageChildren);
|
|
@@ -119,24 +120,20 @@ export async function resolveLayout(
|
|
|
119
120
|
* @param {(JxElement | string)[]} children - Page children to distribute
|
|
120
121
|
*/
|
|
121
122
|
function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (!Array.isArray(node.children)) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Collect named and default children
|
|
130
|
-
const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
|
|
123
|
+
/* Collect named and default children. A layout with no children array has no slots, and
|
|
124
|
+
`fillSlots` returns before reading either collection, so collecting first changes nothing. */
|
|
125
|
+
const named = new Map<JxAttributeValue, (JxElement | string)[]>(); // Slot name → children[]
|
|
131
126
|
const defaults: (JxElement | string)[] = []; // Children without a slot target
|
|
132
127
|
|
|
133
128
|
for (const child of children) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
const slotName = isNonNullObject(child) ? child.attributes?.slot : undefined;
|
|
130
|
+
if (slotName) {
|
|
131
|
+
let bucket = named.get(slotName);
|
|
132
|
+
if (!bucket) {
|
|
133
|
+
bucket = [];
|
|
134
|
+
named.set(slotName, bucket);
|
|
138
135
|
}
|
|
139
|
-
|
|
136
|
+
bucket.push(child);
|
|
140
137
|
} else {
|
|
141
138
|
defaults.push(child);
|
|
142
139
|
}
|
|
@@ -149,16 +146,16 @@ function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
|
149
146
|
/**
|
|
150
147
|
* Recursively walk the tree and replace <slot> elements with distributed content.
|
|
151
148
|
*
|
|
152
|
-
* @param {JxElement} node
|
|
153
|
-
* @param {Map<
|
|
149
|
+
* @param {JxElement | string} node - A text child is passed through and ignored
|
|
150
|
+
* @param {Map<JxAttributeValue, (JxElement | string)[]>} named
|
|
154
151
|
* @param {(JxElement | string)[]} defaults
|
|
155
152
|
*/
|
|
156
153
|
function fillSlots(
|
|
157
|
-
node: JxElement,
|
|
158
|
-
named: Map<
|
|
154
|
+
node: JxElement | string,
|
|
155
|
+
named: Map<JxAttributeValue, (JxElement | string)[]>,
|
|
159
156
|
defaults: (JxElement | string)[],
|
|
160
157
|
) {
|
|
161
|
-
if (!node
|
|
158
|
+
if (!isNonNullObject(node)) {
|
|
162
159
|
return;
|
|
163
160
|
}
|
|
164
161
|
if (!Array.isArray(node.children)) {
|
|
@@ -168,13 +165,14 @@ function fillSlots(
|
|
|
168
165
|
const newChildren: (JxElement | string)[] = [];
|
|
169
166
|
|
|
170
167
|
for (const child of node.children) {
|
|
171
|
-
if (child &&
|
|
168
|
+
if (isNonNullObject(child) && child.tagName === "slot") {
|
|
172
169
|
const slotName = child.attributes?.name;
|
|
170
|
+
const slotted = slotName ? named.get(slotName) : undefined;
|
|
173
171
|
|
|
174
|
-
if (slotName &&
|
|
172
|
+
if (slotName && slotted) {
|
|
175
173
|
// Named slot — replace with matching children
|
|
176
|
-
newChildren.push(...
|
|
177
|
-
named.delete(slotName
|
|
174
|
+
newChildren.push(...slotted);
|
|
175
|
+
named.delete(slotName); // Consumed
|
|
178
176
|
} else if (!slotName && defaults.length > 0) {
|
|
179
177
|
// Default slot — replace with unassigned children
|
|
180
178
|
newChildren.push(...defaults);
|
|
@@ -188,7 +186,7 @@ function fillSlots(
|
|
|
188
186
|
}
|
|
189
187
|
} else {
|
|
190
188
|
// Not a slot — recurse into it
|
|
191
|
-
fillSlots(child
|
|
189
|
+
fillSlots(child, named, defaults);
|
|
192
190
|
newChildren.push(child);
|
|
193
191
|
}
|
|
194
192
|
}
|
package/src/paths.ts
CHANGED
|
@@ -101,35 +101,43 @@ export function candidatePaths(requestPath: string): string[] {
|
|
|
101
101
|
return candidates;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Content types a site origin serves by extension. Anything unlisted is served as a download.
|
|
106
|
+
*
|
|
107
|
+
* A `Map` rather than an object literal so a lookup only ever finds a listed extension: indexing a
|
|
108
|
+
* plain object by a path's extension also reaches `Object.prototype`, and `x.constructor` would
|
|
109
|
+
* come back as a function instead of falling through to the download type.
|
|
110
|
+
*/
|
|
111
|
+
const CONTENT_TYPES = new Map(
|
|
112
|
+
Object.entries({
|
|
113
|
+
avif: "image/avif",
|
|
114
|
+
css: "text/css; charset=utf-8",
|
|
115
|
+
csv: "text/csv; charset=utf-8",
|
|
116
|
+
gif: "image/gif",
|
|
117
|
+
htm: "text/html; charset=utf-8",
|
|
118
|
+
html: "text/html; charset=utf-8",
|
|
119
|
+
ico: "image/x-icon",
|
|
120
|
+
jpeg: "image/jpeg",
|
|
121
|
+
jpg: "image/jpeg",
|
|
122
|
+
js: "text/javascript; charset=utf-8",
|
|
123
|
+
json: "application/json; charset=utf-8",
|
|
124
|
+
map: "application/json; charset=utf-8",
|
|
125
|
+
md: "text/markdown; charset=utf-8",
|
|
126
|
+
mjs: "text/javascript; charset=utf-8",
|
|
127
|
+
mp4: "video/mp4",
|
|
128
|
+
otf: "font/otf",
|
|
129
|
+
pdf: "application/pdf",
|
|
130
|
+
png: "image/png",
|
|
131
|
+
svg: "image/svg+xml",
|
|
132
|
+
ttf: "font/ttf",
|
|
133
|
+
txt: "text/plain; charset=utf-8",
|
|
134
|
+
webm: "video/webm",
|
|
135
|
+
webp: "image/webp",
|
|
136
|
+
woff: "font/woff",
|
|
137
|
+
woff2: "font/woff2",
|
|
138
|
+
xml: "application/xml; charset=utf-8",
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
133
141
|
|
|
134
142
|
/**
|
|
135
143
|
* The Content-Type a site origin serves a path as.
|
|
@@ -142,5 +150,5 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
142
150
|
export function contentTypeFor(path: string): string {
|
|
143
151
|
const dot = path.lastIndexOf(".");
|
|
144
152
|
const extension = dot === -1 ? "" : path.slice(dot + 1).toLowerCase();
|
|
145
|
-
return CONTENT_TYPES
|
|
153
|
+
return CONTENT_TYPES.get(extension) ?? "application/octet-stream";
|
|
146
154
|
}
|
package/src/routes.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const PARAM_SEGMENT = /\[\.\.\.(\w+)\]|\[(\w+)\]/g;
|
|
19
19
|
|
|
20
20
|
/** What a page file's path says about its route, before anything is read from the file. */
|
|
21
|
-
export interface
|
|
21
|
+
export interface FileRoute {
|
|
22
22
|
/** URL pattern with parameters, e.g. `/blog/:slug` or `/docs/*`. */
|
|
23
23
|
urlPattern: string;
|
|
24
24
|
/** Parameter names in declaration order, e.g. `["slug"]`. */
|
|
@@ -52,7 +52,7 @@ export function pageRelativePath(documentPath: string): string {
|
|
|
52
52
|
* `about/index.md` → `/about`. The extension is dropped whatever it is: a page may be any format an
|
|
53
53
|
* enabled extension claims, and its route is a property of the NAME, not of the parser.
|
|
54
54
|
*/
|
|
55
|
-
export function fileToRoute(relativePath: string):
|
|
55
|
+
export function fileToRoute(relativePath: string): FileRoute {
|
|
56
56
|
let urlPath = pageRelativePath(relativePath);
|
|
57
57
|
|
|
58
58
|
/* Drop the extension, but only a real one. `.` is legal inside a route segment, and slicing at
|
|
@@ -121,10 +121,28 @@ export function dynamicRouteParams(documentPath?: string | null): string[] {
|
|
|
121
121
|
* An underscore prefix opts a file OUT — that is how a page directory holds a local component
|
|
122
122
|
* beside the pages that use it — and it opts out a whole directory the same way, so
|
|
123
123
|
* `_partials/card.json` is not a route even though `card.json` alone would be.
|
|
124
|
+
*
|
|
125
|
+
* The build routes only the files it can read as a page: `.json`, and each extension a page format
|
|
126
|
+
* claims (`discoverPages` in `@jxsuite/compiler`). A caller that knows the claimed extensions
|
|
127
|
+
* passes them, so `index.json.bak` or a stray `notes.txt` beside the pages is not listed as one. A
|
|
128
|
+
* caller that does not know them (no format registry) gets the name rule alone.
|
|
129
|
+
*
|
|
130
|
+
* @param relativePath A page path, `pages/…` or relative to `pages/`.
|
|
131
|
+
* @param pageExtensions The extensions the build routes, lower-case with their dot (`.json`,
|
|
132
|
+
* `.md`). Omitted, the extension is not judged.
|
|
124
133
|
*/
|
|
125
|
-
export function isRoutedPageFile(relativePath: string): boolean {
|
|
134
|
+
export function isRoutedPageFile(relativePath: string, pageExtensions?: Iterable<string>): boolean {
|
|
126
135
|
const segments = pageRelativePath(relativePath).split("/");
|
|
127
|
-
|
|
136
|
+
if (!segments.every((segment) => segment !== "" && segment[0] !== "_")) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (pageExtensions === undefined) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const name = segments.at(-1) ?? "";
|
|
143
|
+
const dot = name.lastIndexOf(".");
|
|
144
|
+
const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
145
|
+
return ext !== "" && new Set(pageExtensions).has(ext);
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
/**
|
|
@@ -133,7 +151,7 @@ export function isRoutedPageFile(relativePath: string): boolean {
|
|
|
133
151
|
* Sorting a route table with this is what makes `matchRoute` a first-match scan — `/about` is found
|
|
134
152
|
* before `/[slug]` can claim it, whatever order the directory walk produced.
|
|
135
153
|
*/
|
|
136
|
-
export function compareRoutes(a:
|
|
154
|
+
export function compareRoutes(a: FileRoute, b: FileRoute): number {
|
|
137
155
|
if (a.isDynamic !== b.isDynamic) {
|
|
138
156
|
return a.isDynamic ? 1 : -1;
|
|
139
157
|
}
|
|
@@ -144,7 +162,7 @@ export function compareRoutes(a: RouteShape, b: RouteShape): number {
|
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
/** One route matched against a URL, with the parameter values that matched it. */
|
|
147
|
-
export interface RouteMatch<T extends
|
|
165
|
+
export interface RouteMatch<T extends FileRoute> {
|
|
148
166
|
route: T;
|
|
149
167
|
params: Record<string, string>;
|
|
150
168
|
}
|
|
@@ -159,7 +177,7 @@ export interface RouteMatch<T extends RouteShape> {
|
|
|
159
177
|
* A trailing slash is not significant — `build.trailingSlash` decides how a route is WRITTEN, and a
|
|
160
178
|
* reader who types the other form is asking for the same page.
|
|
161
179
|
*/
|
|
162
|
-
export function matchRoute<T extends
|
|
180
|
+
export function matchRoute<T extends FileRoute>(
|
|
163
181
|
routes: readonly T[],
|
|
164
182
|
pathname: string,
|
|
165
183
|
): RouteMatch<T> | null {
|
package/src/serve.ts
CHANGED
|
@@ -22,8 +22,19 @@ import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
|
22
22
|
|
|
23
23
|
/** Reading bytes for a servable file — separate from {@link SiteIO} because assets are binary. */
|
|
24
24
|
export interface AssetIO {
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The file's bytes, or null when the tree cannot produce them.
|
|
27
|
+
*
|
|
28
|
+
* ArrayBuffer-backed because they become a `Response` body as they are: a view over a
|
|
29
|
+
* `SharedArrayBuffer` is not a `BodyInit`.
|
|
30
|
+
*/
|
|
31
|
+
bytes: (path: string) => Promise<Uint8Array<ArrayBuffer> | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One working tree, seen through both of the readers a site origin is handed. */
|
|
35
|
+
export interface SiteTree {
|
|
36
|
+
io: SiteIO;
|
|
37
|
+
assets: AssetIO;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
/** What this origin knows about a project, computed once per request. */
|
|
@@ -40,7 +51,7 @@ export interface ServeOptions {
|
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
/** Headers every response from this origin carries, whatever it is serving. */
|
|
43
|
-
export function siteHeaders(contentType: string)
|
|
54
|
+
export function siteHeaders(contentType: string) {
|
|
44
55
|
return {
|
|
45
56
|
/* Never cached. A composed page is a function of a working tree that changes under it, and it
|
|
46
57
|
is not revalidatable — there is no version of it to compare against. */
|
|
@@ -94,7 +105,7 @@ export async function serveSite(
|
|
|
94
105
|
for (const candidate of candidatePaths(requestPath)) {
|
|
95
106
|
const bytes = await assets.bytes(candidate);
|
|
96
107
|
if (bytes) {
|
|
97
|
-
return new Response(bytes
|
|
108
|
+
return new Response(bytes, {
|
|
98
109
|
headers: siteHeaders(contentTypeFor(candidate)),
|
|
99
110
|
});
|
|
100
111
|
}
|
package/src/shell.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { renderHead } from "./head-merger.ts";
|
|
29
29
|
import type { ComposedPage } from "./compose.ts";
|
|
30
|
+
import type { JxDocument } from "@jxsuite/schema/types";
|
|
30
31
|
|
|
31
32
|
/** What a host must tell the shell about its own origin. */
|
|
32
33
|
export interface ShellOptions {
|
|
@@ -63,8 +64,8 @@ function attr(value: string): string {
|
|
|
63
64
|
* `<` is escaped as `\u003c`, which JSON.parse restores and an HTML parser cannot mistake for the
|
|
64
65
|
* start of a tag — so no value inside the document, however it was authored, can close the block.
|
|
65
66
|
*/
|
|
66
|
-
function jsonPayload(
|
|
67
|
-
return JSON.stringify(
|
|
67
|
+
function jsonPayload(doc: JxDocument): string {
|
|
68
|
+
return JSON.stringify(doc).replaceAll("<", String.raw`\u003c`);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** The document for one composed page. */
|
package/src/site-style.ts
CHANGED
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
isNestedSelectorKey,
|
|
40
40
|
pureSchemeOf,
|
|
41
41
|
} from "@jxsuite/runtime/css";
|
|
42
|
+
import type { CssBuildOptions } from "@jxsuite/runtime/css";
|
|
43
|
+
import { isNonNullObject } from "@jxsuite/schema/guards";
|
|
42
44
|
import type { JxRef, JxStyle } from "@jxsuite/schema/types";
|
|
43
45
|
|
|
44
46
|
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
@@ -60,6 +62,23 @@ export type SiteStyleValueResolver = (
|
|
|
60
62
|
selector: string | null,
|
|
61
63
|
) => string | null;
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Where a site-style sheet puts what `project.json` addresses to the page itself: its custom
|
|
67
|
+
* properties and `color-scheme` on `root`, its plain properties on `body`.
|
|
68
|
+
*
|
|
69
|
+
* A host that IS the page (the canvas iframe, a preview tab, the build) uses the page's own
|
|
70
|
+
* {@link PAGE_ROOTS}. A host drawing a part of a page inside its own window has no `:root` or
|
|
71
|
+
* `body` of the page's to style: Studio's live previews render inside a shadow root, where both are
|
|
72
|
+
* `:host`, the box the preview is drawn in.
|
|
73
|
+
*/
|
|
74
|
+
export interface SiteStyleRoots {
|
|
75
|
+
root: string;
|
|
76
|
+
body: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The page's own root and body. */
|
|
80
|
+
export const PAGE_ROOTS: SiteStyleRoots = { body: "body", root: ":root" };
|
|
81
|
+
|
|
63
82
|
/**
|
|
64
83
|
* Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
|
|
65
84
|
* selector-keyed blocks (`"h1, h2"`, `a`, `.card`) as rules of their own, `&`-keyed blocks as
|
|
@@ -78,34 +97,34 @@ export type SiteStyleValueResolver = (
|
|
|
78
97
|
* live-preview tab each ARE the page, with the project's `$head` in their head and the rendered
|
|
79
98
|
* document as their body — so no host-side prefix is added, and nothing has to know one.
|
|
80
99
|
*
|
|
81
|
-
* @param {
|
|
100
|
+
* @param {JxStyle} siteStyle - `project.json`'s `style` block
|
|
82
101
|
* @param {Record<string, string>} mediaQueries
|
|
83
102
|
* @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
|
|
84
103
|
* @param {SiteStyleValueResolver} [resolveValue] - What a reactive declaration becomes; absent, it
|
|
85
104
|
* is dropped, which is what every host does
|
|
105
|
+
* @param {SiteStyleRoots} [roots] - The selectors that stand for the page's root and body; the
|
|
106
|
+
* page's own `:root` and `body` unless the sheet styles a page drawn inside something else
|
|
86
107
|
* @returns {string}
|
|
87
108
|
* @docs studio/interface/canvas
|
|
88
109
|
*/
|
|
89
110
|
export function buildSiteStyleCSS(
|
|
90
|
-
siteStyle:
|
|
111
|
+
siteStyle: JxStyle,
|
|
91
112
|
mediaQueries: Record<string, string>,
|
|
92
113
|
transpose: (value: string) => string,
|
|
93
114
|
resolveValue?: SiteStyleValueResolver,
|
|
115
|
+
roots: SiteStyleRoots = PAGE_ROOTS,
|
|
94
116
|
): string {
|
|
95
117
|
const rules: string[] = [];
|
|
96
118
|
const push = (style: JxStyle, selector: string | null) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
? { resolveValue: (property, value) => resolveValue(property, value, selector) }
|
|
107
|
-
: {}),
|
|
108
|
-
})) {
|
|
119
|
+
const options: CssBuildOptions = { mediaQueries, scope: selector, transposeValue: transpose };
|
|
120
|
+
/* The selector is closed over here rather than threaded through `buildStyleRules`, whose own
|
|
121
|
+
hook sees a rule TARGET (self / descendant / unscoped) and not the selector it hangs off;
|
|
122
|
+
the compiler's report names the selector, so this is where it is known. Set only when given:
|
|
123
|
+
`exactOptionalPropertyTypes` refuses an explicit `undefined` for an optional hook. */
|
|
124
|
+
if (resolveValue) {
|
|
125
|
+
options.resolveValue = (property, value) => resolveValue(property, value, selector);
|
|
126
|
+
}
|
|
127
|
+
for (const rule of buildStyleRules(style, options)) {
|
|
109
128
|
rules.push(rule.text);
|
|
110
129
|
}
|
|
111
130
|
};
|
|
@@ -120,9 +139,9 @@ export function buildSiteStyleCSS(
|
|
|
120
139
|
const selectorBlocks: [string, JxStyle][] = [];
|
|
121
140
|
|
|
122
141
|
for (const [key, value] of Object.entries(siteStyle)) {
|
|
123
|
-
if (value
|
|
142
|
+
if (isNonNullObject(value) && !Array.isArray(value)) {
|
|
124
143
|
if (key.startsWith("@")) {
|
|
125
|
-
condBlocks.push([key, value
|
|
144
|
+
condBlocks.push([key, value]);
|
|
126
145
|
} else if (!key.startsWith("--")) {
|
|
127
146
|
/* A selector-keyed block is a rule of the page's stylesheet, and this is where the canvas
|
|
128
147
|
used to lose it. `&`-keyed blocks were rescued first — `&[data-theme="light"]` is a STATE
|
|
@@ -134,7 +153,7 @@ export function buildSiteStyleCSS(
|
|
|
134
153
|
canvas, which showed headings in the fallback face and links underlined (#296). A block
|
|
135
154
|
under a `--name` key is not a rule at all — a custom property has no block value — and
|
|
136
155
|
is skipped the way the compiler skips it. */
|
|
137
|
-
selectorBlocks.push([key, value
|
|
156
|
+
selectorBlocks.push([key, value]);
|
|
138
157
|
}
|
|
139
158
|
continue;
|
|
140
159
|
}
|
|
@@ -147,21 +166,21 @@ export function buildSiteStyleCSS(
|
|
|
147
166
|
every token on `:root` resolving against the wrong element, and the site builder disagreeing
|
|
148
167
|
with `installTheme`, which puts the same authored block on `:root`. */
|
|
149
168
|
if (key.startsWith("--") || key === "colorScheme" || key === "color-scheme") {
|
|
150
|
-
rootProps[key] = value
|
|
169
|
+
rootProps[key] = value;
|
|
151
170
|
} else {
|
|
152
|
-
bodyProps[key] = value
|
|
171
|
+
bodyProps[key] = value;
|
|
153
172
|
}
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
// Base rules precede conditional blocks so equal-specificity overrides win by source order.
|
|
157
|
-
push(rootProps,
|
|
158
|
-
push(bodyProps,
|
|
176
|
+
push(rootProps, roots.root);
|
|
177
|
+
push(bodyProps, roots.body);
|
|
159
178
|
|
|
160
179
|
for (const [key, block] of selectorBlocks) {
|
|
161
180
|
if (key.startsWith("&")) {
|
|
162
181
|
/* `&` is spliced onto `:root`, so `&[data-theme="light"]` emits `:root[data-theme="light"]`.
|
|
163
182
|
The runtime resolves `&` against a scope and a project block has none but the root. */
|
|
164
|
-
push({ [key]: block }
|
|
183
|
+
push({ [key]: block }, roots.root);
|
|
165
184
|
} else {
|
|
166
185
|
// A top-level selector key IS the selector — `.card` styles `.card`, not `:root .card`.
|
|
167
186
|
push(block, key);
|
|
@@ -181,9 +200,9 @@ export function buildSiteStyleCSS(
|
|
|
181
200
|
const condBody: JxStyle = {};
|
|
182
201
|
const condSubs: [string, JxStyle][] = [];
|
|
183
202
|
for (const [k, v] of Object.entries(block)) {
|
|
184
|
-
if (v
|
|
203
|
+
if (isNonNullObject(v) && !Array.isArray(v)) {
|
|
185
204
|
if (!k.startsWith("@")) {
|
|
186
|
-
condSubs.push([k, v
|
|
205
|
+
condSubs.push([k, v]);
|
|
187
206
|
}
|
|
188
207
|
continue;
|
|
189
208
|
}
|
|
@@ -193,8 +212,8 @@ export function buildSiteStyleCSS(
|
|
|
193
212
|
condBody[k] = v;
|
|
194
213
|
}
|
|
195
214
|
}
|
|
196
|
-
push({ [atKey]: condRoot },
|
|
197
|
-
push({ [atKey]: condBody },
|
|
215
|
+
push({ [atKey]: condRoot }, roots.root);
|
|
216
|
+
push({ [atKey]: condBody }, roots.body);
|
|
198
217
|
for (const [sel, sub] of condSubs) {
|
|
199
218
|
push({ [atKey]: sub }, sel);
|
|
200
219
|
}
|
|
@@ -212,9 +231,9 @@ export function buildSiteStyleCSS(
|
|
|
212
231
|
!("colorScheme" in siteStyle)
|
|
213
232
|
) {
|
|
214
233
|
rules.push(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
234
|
+
`${roots.root} { color-scheme: light dark }`,
|
|
235
|
+
`${roots.root}:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
|
|
236
|
+
`${roots.root}:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
|
|
218
237
|
);
|
|
219
238
|
}
|
|
220
239
|
|