@jxsuite/site 2.6.0 → 2.8.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/README.md +1 -1
- package/package.json +3 -3
- package/src/compose.ts +35 -5
- package/src/context.ts +33 -9
- package/src/routes.ts +3 -3
- package/src/serve.ts +50 -4
- package/src/shell.ts +10 -2
- package/src/site-style.ts +40 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**What a Jx project's page at a route consists of:** routing, layout, `<head>`, context and site style, as pure functions with their IO injected.
|
|
4
4
|
|
|
5
|
-
Every one of these rules has to be answered identically by four things that cannot share code any other way: the compiler's static build, the Studio canvas, a Bun dev/desktop server, and a Cloudflare Worker serving a live preview. None of them can import `@jxsuite/compiler`, whose dependency graph carries
|
|
5
|
+
Every one of these rules has to be answered identically by four things that cannot share code any other way: the compiler's static build, the Studio canvas, a Bun dev/desktop server, and a Cloudflare Worker serving a live preview. None of them can import `@jxsuite/compiler`, whose dependency graph carries a native addon and `esbuild`, so neither a browser bundle nor a Worker can load it. And a route is exactly the kind of rule where a silent disagreement is a page that 404s in one surface and renders in another.
|
|
6
6
|
|
|
7
7
|
So the rules live here, and this package has **no platform imports at all**: no `node:`, no DOM, no filesystem. Where a rule genuinely needs to read something, the reader is a parameter (`LayoutLoader`, `DocumentParser`) and is simply absent where there are no directories.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/site",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test:coverage": "bun test --isolate --coverage"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@jxsuite/runtime": "^
|
|
38
|
-
"@jxsuite/schema": "^
|
|
37
|
+
"@jxsuite/runtime": "^7.1.0",
|
|
38
|
+
"@jxsuite/schema": "^6.0.2"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/compose.ts
CHANGED
|
@@ -28,10 +28,18 @@ import { resolveLayout } from "./layout.ts";
|
|
|
28
28
|
import { mergeHead, withPageDescription } from "./head-merger.ts";
|
|
29
29
|
import type { HeadMergeContext } from "./head-merger.ts";
|
|
30
30
|
import { injectContext } from "./context.ts";
|
|
31
|
+
import { inheritableFormulas } from "@jxsuite/runtime/expression";
|
|
32
|
+
import { qualifyScripts } from "@jxsuite/schema/document-scripts";
|
|
31
33
|
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
32
34
|
import { localeDirection, localeOfRoute, resolveI18n } from "@jxsuite/schema/locale";
|
|
33
35
|
import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
34
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
JxDocument,
|
|
38
|
+
JxElement,
|
|
39
|
+
JxHeadEntry,
|
|
40
|
+
JxStateDefinition,
|
|
41
|
+
ProjectConfig,
|
|
42
|
+
} from "@jxsuite/schema/types";
|
|
35
43
|
import { PROJECT_FILE_NAMES, parseProjectSource } from "@jxsuite/schema/project-file";
|
|
36
44
|
|
|
37
45
|
/** Everything the composer settled about a page before a renderer sees it. */
|
|
@@ -40,6 +48,12 @@ export interface ComposedPage {
|
|
|
40
48
|
head: JxHeadEntry[];
|
|
41
49
|
lang: string;
|
|
42
50
|
dir: "ltr" | "rtl";
|
|
51
|
+
/**
|
|
52
|
+
* The project's pure named formulas a component inherits (spec.md §13.4), when it has any. The
|
|
53
|
+
* page has them in its own `state` already; a component's document is fetched as written, so the
|
|
54
|
+
* shell hands these to the runtime before it renders.
|
|
55
|
+
*/
|
|
56
|
+
formulas?: Record<string, JxStateDefinition>;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
/**
|
|
@@ -83,16 +97,23 @@ export function routeTable(paths: readonly string[]): PageRoute[] {
|
|
|
83
97
|
.toSorted(compareRoutes);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Read and parse a document, whatever format its extension claims.
|
|
102
|
+
*
|
|
103
|
+
* Every entry naming the document's own script is qualified with the document's path here, as it is
|
|
104
|
+
* read (`spec.md` §23.2): a page is about to be merged onto its layout, and after that nothing
|
|
105
|
+
* records which of the two an entry was written in.
|
|
106
|
+
*/
|
|
87
107
|
async function readDocument(io: SiteIO, path: string): Promise<JxDocument | null> {
|
|
88
108
|
const text = await io.read(path);
|
|
89
109
|
if (text === null) {
|
|
90
110
|
return null;
|
|
91
111
|
}
|
|
92
112
|
if (isNativeDocumentPath(path)) {
|
|
93
|
-
return parseNativeDocument(text, path);
|
|
113
|
+
return qualifyScripts(parseNativeDocument(text, path), path);
|
|
94
114
|
}
|
|
95
|
-
|
|
115
|
+
const parsed = io.parse ? await io.parse(path, text) : null;
|
|
116
|
+
return parsed ? qualifyScripts(parsed, path) : null;
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/**
|
|
@@ -376,6 +397,15 @@ export async function composePage(
|
|
|
376
397
|
delete merged.$head;
|
|
377
398
|
delete merged._pageHead;
|
|
378
399
|
delete merged._pageTitle;
|
|
400
|
+
/* A page merged onto a layout has one `$script` where there were two documents, so the text is
|
|
401
|
+
neither's to speak for; every entry already names its own document, and the page fetches the
|
|
402
|
+
client module from there. The server module is the other reason: a reader is never sent one. */
|
|
403
|
+
delete merged.$script;
|
|
379
404
|
|
|
380
|
-
|
|
405
|
+
const composed: ComposedPage = { dir: localeDirection(lang), doc: merged, head, lang };
|
|
406
|
+
const formulas = inheritableFormulas(config.state);
|
|
407
|
+
if (Object.keys(formulas).length > 0) {
|
|
408
|
+
composed.formulas = formulas;
|
|
409
|
+
}
|
|
410
|
+
return composed;
|
|
381
411
|
}
|
package/src/context.ts
CHANGED
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
5
|
* These are available as $site.* and $page.* in template expressions.
|
|
6
6
|
*
|
|
7
|
-
* Per site-architecture spec §10:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Per site-architecture spec §10:
|
|
8
|
+
*
|
|
9
|
+
* - `$site.name`, `$site.url`: from the project file's `name` and `url`.
|
|
10
|
+
* - `$site.<key>`: each project `state` entry as written, read-only, never replacing the four names
|
|
11
|
+
* in {@link SITE_CONTEXT_KEYS} (§10.4).
|
|
12
|
+
* - `$page.url`, `$page.title`, `$page.params`: the current page's URL path, title and dynamic route
|
|
13
|
+
* parameters.
|
|
14
|
+
* - `$page.locale`, `$page.dir`: the route's language and writing direction (§13.4).
|
|
15
|
+
* - `$page.alternates`: this page in every language it exists in (§13.5).
|
|
16
|
+
* - `$page.pagination`: where a paged route's page sits in its list (§4.3).
|
|
17
|
+
*
|
|
18
|
+
* Each project `state` entry is also copied into the page's own `state` unless the page or its
|
|
19
|
+
* layout declares the key.
|
|
12
20
|
*
|
|
13
21
|
* The site build is not the only thing that has to produce this state. A page rendered live — on
|
|
14
22
|
* the studio's canvas, or by the cloud's preview origin — binds `${$site.name}` and `${$page.url}`
|
|
@@ -56,6 +64,17 @@ export interface SiteRoute {
|
|
|
56
64
|
*/
|
|
57
65
|
export type ImportRebaser = (src: string, route: SiteRoute) => string;
|
|
58
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The names `$site` defines itself (site-architecture.md §5.5). A project `state` entry with one of
|
|
69
|
+
* these names reaches the page as bare state only (§10.4).
|
|
70
|
+
*/
|
|
71
|
+
export const SITE_CONTEXT_KEYS: ReadonlySet<string> = new Set([
|
|
72
|
+
"defaultLocale",
|
|
73
|
+
"locales",
|
|
74
|
+
"name",
|
|
75
|
+
"url",
|
|
76
|
+
]);
|
|
77
|
+
|
|
59
78
|
/**
|
|
60
79
|
* Inject $site and $page context into a page document's state.
|
|
61
80
|
*
|
|
@@ -86,13 +105,17 @@ export function injectContext(
|
|
|
86
105
|
localeFields.defaultLocale = i18n.defaultLocale;
|
|
87
106
|
localeFields.locales = i18n.locales;
|
|
88
107
|
}
|
|
89
|
-
/* Project state
|
|
90
|
-
|
|
108
|
+
/* Project state goes first and the context's own names last, so a state entry called `name` can
|
|
109
|
+
never stand in for the project's name; it is left out altogether so that a project without
|
|
110
|
+
`i18n` does not grow a `$site.locales` from its state. `Object.fromEntries` and a spread, not
|
|
111
|
+
`Object.assign`: an authored `__proto__` key stays an own data property. */
|
|
91
112
|
doc.state.$site = {
|
|
113
|
+
...Object.fromEntries(
|
|
114
|
+
Object.entries(projectConfig.state ?? {}).filter(([key]) => !SITE_CONTEXT_KEYS.has(key)),
|
|
115
|
+
),
|
|
92
116
|
name: projectConfig.name ?? "Jx Site",
|
|
93
117
|
url: projectConfig.url ?? "",
|
|
94
118
|
...localeFields,
|
|
95
|
-
...projectConfig.state,
|
|
96
119
|
};
|
|
97
120
|
|
|
98
121
|
/*
|
|
@@ -153,7 +176,8 @@ export function injectContext(
|
|
|
153
176
|
}
|
|
154
177
|
doc.state.$page = page;
|
|
155
178
|
|
|
156
|
-
// Merge project-level state into page state
|
|
179
|
+
// Merge project-level state into page state: the page, then its layout, wins
|
|
180
|
+
// (site-architecture.md §10.4)
|
|
157
181
|
if (projectConfig.state) {
|
|
158
182
|
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
159
183
|
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
package/src/routes.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* another.
|
|
10
10
|
*
|
|
11
11
|
* It lives HERE rather than in `@jxsuite/compiler` for the reason `resolveI18n` does (`i18n.ts`):
|
|
12
|
-
* the compiler's dependency graph carries
|
|
13
|
-
* Cloudflare Worker can import it. This module is pure string math with no imports at all, so
|
|
14
|
-
* host that has to know what `/blog/:slug` means can share one answer.
|
|
12
|
+
* the compiler's dependency graph carries a native addon and `esbuild`, so neither a browser bundle
|
|
13
|
+
* nor a Cloudflare Worker can import it. This module is pure string math with no imports at all, so
|
|
14
|
+
* every host that has to know what `/blog/:slug` means can share one answer.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
|
package/src/serve.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* site legitimately asks for that this package cannot produce.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { withoutServerScript } from "@jxsuite/schema/document-scripts";
|
|
17
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
16
18
|
import { isJxnPath, parseJxn } from "@jxsuite/schema/jxn";
|
|
17
19
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
18
20
|
import { candidatePaths, contentTypeFor, normalizeRequestPath } from "./paths.ts";
|
|
@@ -20,7 +22,7 @@ import { ComposeError, composeRoute, readProjectConfig, routeTable } from "./com
|
|
|
20
22
|
import type { PageRoute, SiteIO } from "./compose.ts";
|
|
21
23
|
import { pageShell, problemShell } from "./shell.ts";
|
|
22
24
|
import type { ShellOptions } from "./shell.ts";
|
|
23
|
-
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
25
|
+
import type { JsonValue, JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
24
26
|
|
|
25
27
|
/** Reading bytes for a servable file — separate from {@link SiteIO} because assets are binary. */
|
|
26
28
|
export interface AssetIO {
|
|
@@ -110,7 +112,7 @@ export async function serveSite(
|
|
|
110
112
|
return jxnAsJson(bytes, candidate);
|
|
111
113
|
}
|
|
112
114
|
if (bytes) {
|
|
113
|
-
return new Response(bytes, {
|
|
115
|
+
return new Response(isJsonPath(candidate) ? jsonForReader(bytes) : bytes, {
|
|
114
116
|
headers: siteHeaders(contentTypeFor(candidate)),
|
|
115
117
|
});
|
|
116
118
|
}
|
|
@@ -138,6 +140,49 @@ export async function serveSite(
|
|
|
138
140
|
return plainNotFound();
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/** A path naming a JSON file. */
|
|
144
|
+
const JSON_PATH = /\.json$/iu;
|
|
145
|
+
|
|
146
|
+
function isJsonPath(path: string): boolean {
|
|
147
|
+
return JSON_PATH.test(path);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The key a document's own modules are under, as a JSON file spells it. */
|
|
151
|
+
const SCRIPT_KEY = '"$script"';
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A `.json` file as a reader may have it: a document's server module left out (`spec.md` §23.2),
|
|
155
|
+
* and every other file exactly the bytes it is.
|
|
156
|
+
*
|
|
157
|
+
* A file is parsed only when its text names `$script`, so an ordinary one is never read as
|
|
158
|
+
* anything, and one that does not parse is served as it is: what is wrong with it is the author's
|
|
159
|
+
* to see, and it holds no module this could have found.
|
|
160
|
+
*
|
|
161
|
+
* @param bytes The file.
|
|
162
|
+
*/
|
|
163
|
+
function jsonForReader(bytes: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> | string {
|
|
164
|
+
const text = new TextDecoder().decode(bytes);
|
|
165
|
+
if (!text.includes(SCRIPT_KEY)) {
|
|
166
|
+
return bytes;
|
|
167
|
+
}
|
|
168
|
+
let value: JsonValue;
|
|
169
|
+
try {
|
|
170
|
+
/* SAFETY: trust boundary. The file parsed as JSON, which is all the type says; it is used
|
|
171
|
+
below only after it is seen to be an object. */
|
|
172
|
+
value = JSON.parse(text) as JsonValue;
|
|
173
|
+
} catch {
|
|
174
|
+
return bytes;
|
|
175
|
+
}
|
|
176
|
+
if (!isJsonObject(value)) {
|
|
177
|
+
return bytes;
|
|
178
|
+
}
|
|
179
|
+
/* SAFETY: every member of `JxDocument` is optional and `withoutServerScript` reads only
|
|
180
|
+
`$script`, through optional access, so any JSON object is a document as far as it looks. */
|
|
181
|
+
const document = value as JxDocument;
|
|
182
|
+
const stripped = withoutServerScript(document);
|
|
183
|
+
return stripped === document ? bytes : JSON.stringify(stripped);
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
/**
|
|
142
187
|
* Compose a route into a response, turning a compose failure into a page that says so.
|
|
143
188
|
*
|
|
@@ -150,7 +195,8 @@ export async function serveSite(
|
|
|
150
195
|
* The runtime fetches a `$ref` and calls `res.json()`, on any origin, so answering JSON here keeps
|
|
151
196
|
* a KDL parser out of every page. A file that does not read answers 422 with the reason, because
|
|
152
197
|
* the runtime reports a failed fetch by its status and this one is the author's to fix. The dev
|
|
153
|
-
* server's canvas origin answers the same way, with its own headers.
|
|
198
|
+
* server's canvas origin answers the same way, with its own headers. The document's server module
|
|
199
|
+
* is left out, as it is from a `.json` document (`spec.md` §23.2).
|
|
154
200
|
*
|
|
155
201
|
* @param bytes The file.
|
|
156
202
|
* @param path Its path, which names it in an error.
|
|
@@ -162,7 +208,7 @@ export function jxnAsJson(
|
|
|
162
208
|
headers: (type: string) => HeadersInit = siteHeaders,
|
|
163
209
|
): Response {
|
|
164
210
|
try {
|
|
165
|
-
const document = parseJxn(new TextDecoder().decode(bytes), path);
|
|
211
|
+
const document = withoutServerScript(parseJxn(new TextDecoder().decode(bytes), path));
|
|
166
212
|
return Response.json(document, { headers: headers("application/json;charset=utf-8") });
|
|
167
213
|
} catch (error) {
|
|
168
214
|
return new Response(errorMessage(error), {
|
package/src/shell.ts
CHANGED
|
@@ -71,7 +71,7 @@ function attr(value: string): string {
|
|
|
71
71
|
* `<` is escaped as `\u003c`, which JSON.parse restores and an HTML parser cannot mistake for the
|
|
72
72
|
* start of a tag — so no value inside the document, however it was authored, can close the block.
|
|
73
73
|
*/
|
|
74
|
-
function jsonPayload(doc: JxDocument): string {
|
|
74
|
+
function jsonPayload(doc: JxDocument | NonNullable<ComposedPage["formulas"]>): string {
|
|
75
75
|
return JSON.stringify(doc).replaceAll("<", String.raw`\u003c`);
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -87,6 +87,14 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
|
|
|
87
87
|
names.push("setBodyEraserLoader");
|
|
88
88
|
setup += `\nsetBodyEraserLoader(() => import(${JSON.stringify(options.eraserUrl)}));`;
|
|
89
89
|
}
|
|
90
|
+
/* The project's formulas, for the components the page instantiates (spec.md §13.4). Set before
|
|
91
|
+
the render, in a block of their own for the reason the document has one. */
|
|
92
|
+
let formulas = "";
|
|
93
|
+
if (page.formulas) {
|
|
94
|
+
names.push("setProjectFormulas");
|
|
95
|
+
formulas = `\n<script type="application/json" id="jx-project-formulas">${jsonPayload(page.formulas)}</script>`;
|
|
96
|
+
setup += `\nsetProjectFormulas(JSON.parse(document.getElementById("jx-project-formulas").textContent));`;
|
|
97
|
+
}
|
|
90
98
|
const imports = names.join(", ");
|
|
91
99
|
const style = options.styleUrl
|
|
92
100
|
? `\n <link rel="stylesheet" href="${attr(options.styleUrl)}">`
|
|
@@ -105,7 +113,7 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
|
|
|
105
113
|
<script>history.scrollRestoration = "manual";</script>
|
|
106
114
|
</head>
|
|
107
115
|
<body>
|
|
108
|
-
<script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script
|
|
116
|
+
<script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script>${formulas}
|
|
109
117
|
<script type="module">
|
|
110
118
|
import { ${imports} } from "${attr(options.runtimeUrl)}";${setup}
|
|
111
119
|
const source = document.getElementById("jx-page-document").textContent;
|
package/src/site-style.ts
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* to differ was a nested element key, on the belief that "the document's own style pass covers page
|
|
30
30
|
* content": nothing else reads `project.json#/style`, so the canvas simply dropped a site's
|
|
31
31
|
* typography and link rules while the build kept them (#296).
|
|
32
|
+
*
|
|
33
|
+
* A block's `$description` is the one thing only the build renders: a static build writes it as a
|
|
34
|
+
* comment above the rule (`{ comments: true }`, spec.md §9.2), and a host never does, because every
|
|
35
|
+
* consumer but a static build ignores the description.
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
import {
|
|
@@ -36,11 +40,12 @@ import {
|
|
|
36
40
|
buildStyleRules,
|
|
37
41
|
isDeclarationAtRule,
|
|
38
42
|
isKeyframesAtRule,
|
|
43
|
+
commentedRuleText,
|
|
39
44
|
isNestedSelectorKey,
|
|
40
45
|
pureSchemeOf,
|
|
41
46
|
} from "@jxsuite/runtime/css";
|
|
42
47
|
import type { CssBuildOptions } from "@jxsuite/runtime/css";
|
|
43
|
-
import { isNonNullObject } from "@jxsuite/schema/guards";
|
|
48
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
44
49
|
import type { JxRef, JxStyle } from "@jxsuite/schema/types";
|
|
45
50
|
|
|
46
51
|
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
@@ -76,9 +81,29 @@ export interface SiteStyleRoots {
|
|
|
76
81
|
body: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
/** What a caller may ask of the sheet beyond its rules. */
|
|
85
|
+
export interface SiteStyleOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Write each described rule's `$description` as a comment on the line above it. The static
|
|
88
|
+
* build's switch: the canvas and the live preview leave it off and emit the rule text alone.
|
|
89
|
+
*/
|
|
90
|
+
comments?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
/** The page's own root and body. */
|
|
80
94
|
export const PAGE_ROOTS: SiteStyleRoots = { body: "body", root: ":root" };
|
|
81
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Hand a block's own `$description` to the first rule its split writes: the root's when it holds a
|
|
98
|
+
* declaration, the body's otherwise. A block that writes neither rule carries no comment.
|
|
99
|
+
*/
|
|
100
|
+
function describeFirstRule(block: JxStyle, root: JxStyle, body: JxStyle): void {
|
|
101
|
+
const description = block["$description"];
|
|
102
|
+
if (isString(description)) {
|
|
103
|
+
(Object.keys(root).length > 0 ? root : body)["$description"] = description;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
/**
|
|
83
108
|
* Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
|
|
84
109
|
* selector-keyed blocks (`"h1, h2"`, `a`, `.card`) as rules of their own, `&`-keyed blocks as
|
|
@@ -104,6 +129,7 @@ export const PAGE_ROOTS: SiteStyleRoots = { body: "body", root: ":root" };
|
|
|
104
129
|
* is dropped, which is what every host does
|
|
105
130
|
* @param {SiteStyleRoots} [roots] - The selectors that stand for the page's root and body; the
|
|
106
131
|
* page's own `:root` and `body` unless the sheet styles a page drawn inside something else
|
|
132
|
+
* @param {SiteStyleOptions} [options] - `comments: true` is the static build's form
|
|
107
133
|
* @returns {string}
|
|
108
134
|
* @docs studio/interface/canvas
|
|
109
135
|
*/
|
|
@@ -113,19 +139,20 @@ export function buildSiteStyleCSS(
|
|
|
113
139
|
transpose: (value: string) => string,
|
|
114
140
|
resolveValue?: SiteStyleValueResolver,
|
|
115
141
|
roots: SiteStyleRoots = PAGE_ROOTS,
|
|
142
|
+
options: SiteStyleOptions = {},
|
|
116
143
|
): string {
|
|
117
144
|
const rules: string[] = [];
|
|
118
145
|
const push = (style: JxStyle, selector: string | null) => {
|
|
119
|
-
const
|
|
146
|
+
const build: CssBuildOptions = { mediaQueries, scope: selector, transposeValue: transpose };
|
|
120
147
|
/* The selector is closed over here rather than threaded through `buildStyleRules`, whose own
|
|
121
148
|
hook sees a rule TARGET (self / descendant / unscoped) and not the selector it hangs off;
|
|
122
149
|
the compiler's report names the selector, so this is where it is known. Set only when given:
|
|
123
150
|
`exactOptionalPropertyTypes` refuses an explicit `undefined` for an optional hook. */
|
|
124
151
|
if (resolveValue) {
|
|
125
|
-
|
|
152
|
+
build.resolveValue = (property, value) => resolveValue(property, value, selector);
|
|
126
153
|
}
|
|
127
|
-
for (const rule of buildStyleRules(style,
|
|
128
|
-
rules.push(rule.text);
|
|
154
|
+
for (const rule of buildStyleRules(style, build)) {
|
|
155
|
+
rules.push(options.comments === true ? commentedRuleText(rule) : rule.text);
|
|
129
156
|
}
|
|
130
157
|
};
|
|
131
158
|
|
|
@@ -157,7 +184,8 @@ export function buildSiteStyleCSS(
|
|
|
157
184
|
}
|
|
158
185
|
continue;
|
|
159
186
|
}
|
|
160
|
-
|
|
187
|
+
// Metadata is no declaration; the block's own `$description` is placed after the split.
|
|
188
|
+
if (isNestedSelectorKey(key) || key.startsWith("@") || key.startsWith("$")) {
|
|
161
189
|
continue;
|
|
162
190
|
}
|
|
163
191
|
/* Custom properties go to `:root`; so does `color-scheme`, which is the one non-custom
|
|
@@ -172,6 +200,8 @@ export function buildSiteStyleCSS(
|
|
|
172
200
|
}
|
|
173
201
|
}
|
|
174
202
|
|
|
203
|
+
describeFirstRule(siteStyle, rootProps, bodyProps);
|
|
204
|
+
|
|
175
205
|
// Base rules precede conditional blocks so equal-specificity overrides win by source order.
|
|
176
206
|
push(rootProps, roots.root);
|
|
177
207
|
push(bodyProps, roots.body);
|
|
@@ -206,12 +236,16 @@ export function buildSiteStyleCSS(
|
|
|
206
236
|
}
|
|
207
237
|
continue;
|
|
208
238
|
}
|
|
239
|
+
if (k.startsWith("$")) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
209
242
|
if (k.startsWith("--")) {
|
|
210
243
|
condRoot[k] = v;
|
|
211
244
|
} else {
|
|
212
245
|
condBody[k] = v;
|
|
213
246
|
}
|
|
214
247
|
}
|
|
248
|
+
describeFirstRule(block, condRoot, condBody);
|
|
215
249
|
push({ [atKey]: condRoot }, roots.root);
|
|
216
250
|
push({ [atKey]: condBody }, roots.body);
|
|
217
251
|
for (const [sel, sub] of condSubs) {
|