@jxsuite/site 2.0.4 → 2.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/site",
3
- "version": "2.0.4",
3
+ "version": "2.2.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": "^4.1.0",
35
- "@jxsuite/schema": "^3.0.0"
34
+ "@jxsuite/runtime": "^5.2.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 { RouteShape } from "./routes.ts";
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 RouteShape {
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
- .filter((path) => path.startsWith("pages/") && isRoutedPageFile(path))
76
- .map((path) => Object.assign(fileToRoute(path), { sourcePath: path }))
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 {} as ProjectConfig;
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 {} as ProjectConfig;
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 unknown[] | undefined)[]
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
- const key =
120
- typeof entry === "string"
121
- ? entry
122
- : ((entry as { $ref?: string } | null)?.$ref ?? JSON.stringify(entry));
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 as string | JxElement);
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
- /** Every custom-element tag a document tree names, root included. */
146
- function collectTagNames(node: unknown, into: Set<string>): void {
147
- if (!node || typeof node !== "object") {
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
- if (Array.isArray(node)) {
151
- for (const child of node) {
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 as { $ref?: unknown } | null)?.$ref;
201
- if (typeof ref === "string") {
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: unknown[] = [doc];
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: unknown[] = [];
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 as Record<string, unknown>, async (ref) => {
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
- const pageHead = (pageDoc.$head ?? merged._pageHead ?? []) as JxHeadEntry[];
327
- const layoutHead = (merged.$head ?? []) as JxHeadEntry[];
328
- const siteHead = (config.$head ?? []) as JxHeadEntry[];
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
- (typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined) ??
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 head = mergeHead(siteHead, layoutHead, pageHead, {
337
- lang,
338
- title,
339
- ...(config.name === undefined ? {} : { siteName: config.name }),
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 type {
25
- JxDocument,
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
- /** One member of a route's translation set — the shape `<head>` and `$page.alternates` share. */
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
- ...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
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
- (typeof doc.$lang === "string" ? doc.$lang : undefined) ??
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
- doc.state.$page = {
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 as JxStateDefinition;
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 && (src.startsWith("./") || src.startsWith("../"))
150
- ? rebaseImport(src, route)
151
- : src;
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
- ...projectConfig.$elements,
165
- ...(doc.$elements as (string | JxElement)[]),
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);
@@ -19,7 +19,16 @@
19
19
  */
20
20
 
21
21
  import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
22
- import type { JxHeadEntry } from "@jxsuite/schema/types";
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 && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
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 {JxHeadEntry} entry
184
+ * @param {AuthoredHeadEntry} entry
142
185
  * @returns {string}
143
186
  */
144
- function headEntryKey(entry: JxHeadEntry) {
145
- if (!entry || typeof entry !== "object") {
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 = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
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 {JxHeadEntry[]} headEntries - Merged head entries
262
+ * @param {readonly AuthoredHeadEntry[]} headEntries - Merged head entries
220
263
  * @returns {string} HTML string
221
264
  */
222
- export function renderHead(headEntries: JxHeadEntry[]) {
223
- return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
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 {JxHeadEntry} entry
272
+ * @param {AuthoredHeadEntry} entry
230
273
  * @returns {string}
231
274
  */
232
- function renderHeadEntry(entry: JxHeadEntry) {
233
- if (typeof entry === "string") {
275
+ function renderHeadEntry(entry: AuthoredHeadEntry) {
276
+ if (isString(entry)) {
234
277
  return entry;
235
278
  }
236
- if (!entry || typeof entry !== "object") {
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 {unknown} val
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: unknown) {
311
+ function escapeAttr(val: string | boolean) {
270
312
  return String(val).replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
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 type { JxDocument, JxElement } from "@jxsuite/schema/types";
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 {Record<string, unknown>} projectConfig - Site configuration (for defaults.layout)
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: Record<string, unknown>,
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 as string);
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
- const pageChildren =
63
- typeof rawChildren === "string"
64
- ? [rawChildren]
65
- : Array.isArray(rawChildren)
66
- ? rawChildren
67
- : ([] as (JxElement | string)[]);
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
- if (!node || typeof node !== "object") {
123
- return;
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
- if (child && typeof child === "object" && child.attributes?.slot) {
135
- const slotName = child.attributes.slot as string;
136
- if (!named.has(slotName)) {
137
- named.set(slotName, []);
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
- (named.get(slotName) as (JxElement | string)[]).push(child);
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<string, (JxElement | string)[]>} named
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<string, (JxElement | string)[]>,
154
+ node: JxElement | string,
155
+ named: Map<JxAttributeValue, (JxElement | string)[]>,
159
156
  defaults: (JxElement | string)[],
160
157
  ) {
161
- if (!node || typeof node !== "object") {
158
+ if (!isNonNullObject(node)) {
162
159
  return;
163
160
  }
164
161
  if (!Array.isArray(node.children)) {
@@ -168,27 +165,26 @@ function fillSlots(
168
165
  const newChildren: (JxElement | string)[] = [];
169
166
 
170
167
  for (const child of node.children) {
171
- if (child && typeof child === "object" && child.tagName === "slot") {
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 && named.has(slotName as string)) {
172
+ if (slotName && slotted) {
175
173
  // Named slot — replace with matching children
176
- newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
177
- named.delete(slotName as string); // Consumed
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);
181
179
  // Don't clear defaults — only one default slot should exist,
182
180
  // But if there are multiple, the first one wins
183
- } else {
181
+ } else if (Array.isArray(child.children)) {
184
182
  // No matching content — keep slot's fallback children
185
- if (Array.isArray(child.children)) {
186
- newChildren.push(...child.children);
187
- }
183
+ newChildren.push(...child.children);
188
184
  }
189
185
  } else {
190
186
  // Not a slot — recurse into it
191
- fillSlots(child as JxElement, named, defaults);
187
+ fillSlots(child, named, defaults);
192
188
  newChildren.push(child);
193
189
  }
194
190
  }
package/src/paths.ts CHANGED
@@ -101,35 +101,43 @@ export function candidatePaths(requestPath: string): string[] {
101
101
  return candidates;
102
102
  }
103
103
 
104
- /** Content types a site origin serves by extension. Anything unlisted is served as a download. */
105
- const CONTENT_TYPES: Record<string, string> = {
106
- avif: "image/avif",
107
- css: "text/css; charset=utf-8",
108
- csv: "text/csv; charset=utf-8",
109
- gif: "image/gif",
110
- htm: "text/html; charset=utf-8",
111
- html: "text/html; charset=utf-8",
112
- ico: "image/x-icon",
113
- jpeg: "image/jpeg",
114
- jpg: "image/jpeg",
115
- js: "text/javascript; charset=utf-8",
116
- json: "application/json; charset=utf-8",
117
- map: "application/json; charset=utf-8",
118
- md: "text/markdown; charset=utf-8",
119
- mjs: "text/javascript; charset=utf-8",
120
- mp4: "video/mp4",
121
- otf: "font/otf",
122
- pdf: "application/pdf",
123
- png: "image/png",
124
- svg: "image/svg+xml",
125
- ttf: "font/ttf",
126
- txt: "text/plain; charset=utf-8",
127
- webm: "video/webm",
128
- webp: "image/webp",
129
- woff: "font/woff",
130
- woff2: "font/woff2",
131
- xml: "application/xml; charset=utf-8",
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[extension] ?? "application/octet-stream";
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 RouteShape {
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): RouteShape {
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
- return segments.length > 0 && segments.every((segment) => segment !== "" && segment[0] !== "_");
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: RouteShape, b: RouteShape): number {
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 RouteShape> {
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 RouteShape>(
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
- /** The file's bytes, or null when the tree cannot produce them. */
26
- bytes: (path: string) => Promise<Uint8Array | null>;
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): Record<string, 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 as BodyInit, {
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(value: unknown): string {
67
- return JSON.stringify(value).replaceAll("<", String.raw`\u003c`);
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 {Record<string, unknown>} siteStyle
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: Record<string, unknown>,
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
- for (const rule of buildStyleRules(style, {
98
- mediaQueries,
99
- scope: selector,
100
- transposeValue: transpose,
101
- /* The selector is closed over here rather than threaded through `buildStyleRules`, whose own
102
- hook sees a rule TARGET (self / descendant / unscoped) and not the selector it hangs off;
103
- the compiler's report names the selector, so this is where it is known. Conditionally
104
- spread: `exactOptionalPropertyTypes` refuses an explicit `undefined` for an optional hook. */
105
- ...(resolveValue
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 !== null && typeof value === "object" && !Array.isArray(value)) {
142
+ if (isNonNullObject(value) && !Array.isArray(value)) {
124
143
  if (key.startsWith("@")) {
125
- condBlocks.push([key, value as JxStyle]);
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 as JxStyle]);
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 as string;
169
+ rootProps[key] = value;
151
170
  } else {
152
- bodyProps[key] = value as string;
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, ":root");
158
- push(bodyProps, "body");
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 } as JxStyle, ":root");
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 !== null && typeof v === "object" && !Array.isArray(v)) {
203
+ if (isNonNullObject(v) && !Array.isArray(v)) {
185
204
  if (!k.startsWith("@")) {
186
- condSubs.push([k, v as JxStyle]);
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 }, ":root");
197
- push({ [atKey]: condBody }, "body");
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
- ":root { color-scheme: light dark }",
216
- `:root:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
217
- `:root:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
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