@jxsuite/site 1.1.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -26
- package/package.json +3 -3
- package/src/site-style.ts +150 -49
package/README.md
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
# `@jxsuite/site`
|
|
2
2
|
|
|
3
|
-
**What a Jx project's page at a route consists of:** routing, layout, `<head>`, context and site
|
|
4
|
-
style, as pure functions with their IO injected.
|
|
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.
|
|
5
4
|
|
|
6
|
-
Every one of these rules has to be answered identically by four things that cannot share code any
|
|
7
|
-
other way: the compiler's static build, the Studio canvas, a Bun dev/desktop server, and a
|
|
8
|
-
Cloudflare Worker serving a live preview. None of them can import `@jxsuite/compiler`, whose
|
|
9
|
-
dependency graph carries `sharp` and `esbuild`, so neither a browser bundle nor a Worker can load
|
|
10
|
-
it. And a route is exactly the kind of rule where a silent disagreement is a page that 404s in one
|
|
11
|
-
surface and renders in another.
|
|
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 `sharp` 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.
|
|
12
6
|
|
|
13
|
-
So the rules live here, and this package has **no platform imports at all**: no `node:`, no DOM, no
|
|
14
|
-
filesystem. Where a rule genuinely needs to read something, the reader is a parameter
|
|
15
|
-
(`LayoutLoader`, `DocumentParser`) and is simply absent where there are no directories.
|
|
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.
|
|
16
8
|
|
|
17
9
|
| Module | Answers |
|
|
18
10
|
| --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -26,30 +18,20 @@ filesystem. Where a rule genuinely needs to read something, the reader is a para
|
|
|
26
18
|
| `./paths` | What an origin serving a project as a site may hand out, as an allowlist that defaults closed. |
|
|
27
19
|
| `./serve` | The decision order a published site answers with: file, then route, then the host's own lane, then the project's own 404. |
|
|
28
20
|
|
|
29
|
-
**Per-file exports, never a barrel.** Studio imports `./routes` into a browser bundle; a barrel
|
|
30
|
-
would drag every other module in behind it.
|
|
21
|
+
**Per-file exports, never a barrel.** Studio imports `./routes` into a browser bundle; a barrel would drag every other module in behind it.
|
|
31
22
|
|
|
32
23
|
## Composing without a compiler
|
|
33
24
|
|
|
34
|
-
`compose` and `shell` are what let a page render at its real URL with no build on the path. The
|
|
35
|
-
server settles what it can (routing, layout, `<head>`, `$site`/`$page`) and the runtime assembles
|
|
36
|
-
the DOM. Two things the build does are deliberately skipped, and both say so in the source: `$paths`
|
|
37
|
-
is not expanded, because a route matches on demand and takes its parameters from the URL; and
|
|
38
|
-
`imports` are not rebased, because the source tree is the served tree.
|
|
25
|
+
`compose` and `shell` are what let a page render at its real URL with no build on the path. The server settles what it can (routing, layout, `<head>`, `$site`/`$page`) and the runtime assembles the DOM. Two things the build does are deliberately skipped, and both say so in the source: `$paths` is not expanded, because a route matches on demand and takes its parameters from the URL; and `imports` are not rebased, because the source tree is the served tree.
|
|
39
26
|
|
|
40
|
-
All IO is injected (`SiteIO`, `AssetIO`, `LayoutLoader`, `DocumentParser`), so the same code path
|
|
41
|
-
answers off a disk, out of a Durable Object's SQLite table, or over `fetch`. A host that has the
|
|
42
|
-
project's format registry passes a `DocumentParser` and `.md` pages render; one that does not omits
|
|
43
|
-
it and gets a named error rather than a blank page.
|
|
27
|
+
All IO is injected (`SiteIO`, `AssetIO`, `LayoutLoader`, `DocumentParser`), so the same code path answers off a disk, out of a Durable Object's SQLite table, or over `fetch`. A host that has the project's format registry passes a `DocumentParser` and `.md` pages render; one that does not omits it and gets a named error rather than a blank page.
|
|
44
28
|
|
|
45
29
|
## Where the rules are specified
|
|
46
30
|
|
|
47
|
-
`specs/site-architecture.md`: routing §4, layouts §5, `$head` §8, context §10, i18n §13. The spec is
|
|
48
|
-
the source of truth; this package is its one implementation.
|
|
31
|
+
`specs/site-architecture.md`: routing §4, layouts §5, `$head` §8, context §10, i18n §13. The spec is the source of truth; this package is its one implementation.
|
|
49
32
|
|
|
50
33
|
## Related
|
|
51
34
|
|
|
52
|
-
- `@jxsuite/schema` holds the document types, `parse` and `locale`. This package depends on it, and
|
|
53
|
-
the edge is one-way and must stay that way.
|
|
35
|
+
- `@jxsuite/schema` holds the document types, `parse` and `locale`. This package depends on it, and the edge is one-way and must stay that way.
|
|
54
36
|
- `@jxsuite/runtime` renders the document this package assembles.
|
|
55
37
|
- `@jxsuite/compiler` is the static build, which is one consumer rather than the owner.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/site",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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": "^2.
|
|
34
|
+
"@jxsuite/runtime": "^4.0.1",
|
|
35
|
+
"@jxsuite/schema": "^2.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/site-style.ts
CHANGED
|
@@ -11,92 +11,179 @@
|
|
|
11
11
|
* Two hosts share it, which is why it is here rather than in either of them: the studio canvas,
|
|
12
12
|
* which passes a transposer rewriting viewport units to container units, and the live preview
|
|
13
13
|
* origin, which passes identity because a browser tab IS the viewport.
|
|
14
|
+
*
|
|
15
|
+
* The nesting inside a block is NOT this module's own recursion any more: it delegates to
|
|
16
|
+
* `buildStyleRules`, which the runtime and the compiler also use, so all three agree about what an
|
|
17
|
+
* `@media` inside a selector inside an `@supports` means.
|
|
18
|
+
*
|
|
19
|
+
* The sheet is the build's. `compileStyles` (`@jxsuite/compiler`, `src/shared.ts`) writes the same
|
|
20
|
+
* project block into every built page's `<style>`, and it does so BY CALLING THIS BUILDER — the
|
|
21
|
+
* compiler depends on the site package, so its project half is this function with an identity
|
|
22
|
+
* transposer and a resolver that records what a static build drops. A host that shows a page is
|
|
23
|
+
* showing what the build will ship because the two are one emitter, not because a test says so;
|
|
24
|
+
* `site-style.test.ts` still holds them byte-for-byte, as the guard that keeps the delegation from
|
|
25
|
+
* being undone. It used to be a second copy of the `:root` / `body` / selector / `@`-block split,
|
|
26
|
+
* and the copies disagreed twice: the compiler passed a top-level `&[...]` key through as a raw `&`
|
|
27
|
+
* selector, which no engine matches, and routed a top-level `colorScheme` to `body`, where
|
|
28
|
+
* `light-dark()` on the root cannot see it (#329). The one place the two were DELIBERATELY allowed
|
|
29
|
+
* to differ was a nested element key, on the belief that "the document's own style pass covers page
|
|
30
|
+
* content": nothing else reads `project.json#/style`, so the canvas simply dropped a site's
|
|
31
|
+
* typography and link rules while the build kept them (#296).
|
|
14
32
|
*/
|
|
15
33
|
|
|
16
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
COLOR_SCHEME_ATTR,
|
|
36
|
+
buildStyleRules,
|
|
37
|
+
isDeclarationAtRule,
|
|
38
|
+
isKeyframesAtRule,
|
|
39
|
+
isNestedSelectorKey,
|
|
40
|
+
pureSchemeOf,
|
|
41
|
+
} from "@jxsuite/runtime/css";
|
|
42
|
+
import type { JxRef, JxStyle } from "@jxsuite/schema/types";
|
|
17
43
|
|
|
18
44
|
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
19
45
|
export const SITE_STYLE_ID = "jx-site-style";
|
|
20
46
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a reactive declaration — a `${…}` template or a `{ $ref }` — to what the sheet should
|
|
49
|
+
* say, or `null` to emit no declaration. `selector` is the rule it would have landed on (`:root`,
|
|
50
|
+
* `body`, a selector key, or `null` for an unscoped at-rule), which is what a report of a dropped
|
|
51
|
+
* declaration needs to name.
|
|
52
|
+
*
|
|
53
|
+
* The hosts omit it: a canvas or a preview tab has no scope to evaluate a project-level template
|
|
54
|
+
* against, so a reactive project declaration is dropped. The compiler passes a recorder so the same
|
|
55
|
+
* drop is REPORTED by the build (`takeDroppedReactiveStyles`) rather than silent.
|
|
56
|
+
*/
|
|
57
|
+
export type SiteStyleValueResolver = (
|
|
58
|
+
property: string,
|
|
59
|
+
value: string | JxRef,
|
|
60
|
+
selector: string | null,
|
|
61
|
+
) => string | null;
|
|
27
62
|
|
|
28
63
|
/**
|
|
29
64
|
* Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
65
|
+
* selector-keyed blocks (`"h1, h2"`, `a`, `.card`) as rules of their own, `&`-keyed blocks as
|
|
66
|
+
* states of `:root`, conditional `@`-blocks resolved against `mediaQueries` (scheme queries
|
|
67
|
+
* dual-emitted per the forced-scheme contract), and the `color-scheme` hint triplet declared when a
|
|
68
|
+
* scheme query exists and the author has not set `colorScheme` (spec.md §9.5).
|
|
69
|
+
*
|
|
70
|
+
* The `:root` / `body` split is the one decision this builder owns; everything after it is handed
|
|
71
|
+
* to `buildStyleRules`, which is the single definition of what a Jx style object means as CSS. That
|
|
72
|
+
* is what makes a nested selector inside a conditional block compose rather than flatten to one
|
|
73
|
+
* level, and what makes a host and the compiled page agree about a `@supports` block.
|
|
74
|
+
*
|
|
75
|
+
* A selector-keyed block is emitted UNSCOPED, exactly as the build emits it into a page: the
|
|
76
|
+
* project's `style` is the page's stylesheet, so `"h1, h2"` styles every `h1` and `h2` the page
|
|
77
|
+
* holds, not `:root h1`. That is the right answer for both hosts too — the canvas iframe and the
|
|
78
|
+
* live-preview tab each ARE the page, with the project's `$head` in their head and the rendered
|
|
79
|
+
* document as their body — so no host-side prefix is added, and nothing has to know one.
|
|
32
80
|
*
|
|
33
81
|
* @param {Record<string, unknown>} siteStyle
|
|
34
82
|
* @param {Record<string, string>} mediaQueries
|
|
35
83
|
* @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
|
|
84
|
+
* @param {SiteStyleValueResolver} [resolveValue] - What a reactive declaration becomes; absent, it
|
|
85
|
+
* is dropped, which is what every host does
|
|
36
86
|
* @returns {string}
|
|
87
|
+
* @docs studio/interface/canvas
|
|
37
88
|
*/
|
|
38
89
|
export function buildSiteStyleCSS(
|
|
39
90
|
siteStyle: Record<string, unknown>,
|
|
40
91
|
mediaQueries: Record<string, string>,
|
|
41
92
|
transpose: (value: string) => string,
|
|
93
|
+
resolveValue?: SiteStyleValueResolver,
|
|
42
94
|
): string {
|
|
43
95
|
const rules: string[] = [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
96
|
+
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
|
+
})) {
|
|
109
|
+
rules.push(rule.text);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const rootProps: JxStyle = {};
|
|
114
|
+
const bodyProps: JxStyle = {};
|
|
115
|
+
const condBlocks: [string, JxStyle][] = [];
|
|
116
|
+
/* Every block keyed by a selector, in authored order — `&`-keyed root states and element, class
|
|
117
|
+
and attribute selectors alike. ONE list rather than one per kind because the build keeps them
|
|
118
|
+
in one list, and source order is what decides between two rules of equal specificity: a site
|
|
119
|
+
that writes `a { … }` and then `&[data-theme="dark"] a { … }` has said which wins. */
|
|
120
|
+
const selectorBlocks: [string, JxStyle][] = [];
|
|
47
121
|
|
|
48
122
|
for (const [key, value] of Object.entries(siteStyle)) {
|
|
49
123
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
50
124
|
if (key.startsWith("@")) {
|
|
51
|
-
condBlocks.push([key, value as
|
|
125
|
+
condBlocks.push([key, value as JxStyle]);
|
|
126
|
+
} else if (!key.startsWith("--")) {
|
|
127
|
+
/* A selector-keyed block is a rule of the page's stylesheet, and this is where the canvas
|
|
128
|
+
used to lose it. `&`-keyed blocks were rescued first — `&[data-theme="light"]` is a STATE
|
|
129
|
+
OF THE ROOT, how a project forces a scheme, and it was dropped in silence — while a bare
|
|
130
|
+
element or class key was still skipped on the stated belief that the resolved document's
|
|
131
|
+
own style pass covered page content. It never did: nothing but this builder and the
|
|
132
|
+
compiler reads `project.json#/style`, and the compiler emits these rules into every
|
|
133
|
+
page. So a site's `"h1, h2"` and `a` rules were in the built output and missing from the
|
|
134
|
+
canvas, which showed headings in the fallback face and links underlined (#296). A block
|
|
135
|
+
under a `--name` key is not a rule at all — a custom property has no block value — and
|
|
136
|
+
is skipped the way the compiler skips it. */
|
|
137
|
+
selectorBlocks.push([key, value as JxStyle]);
|
|
52
138
|
}
|
|
53
|
-
// Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
|
|
54
|
-
// Own style pass covers those; the site sheet handles tokens + conditional overrides.
|
|
55
139
|
continue;
|
|
56
140
|
}
|
|
57
|
-
if (
|
|
141
|
+
if (isNestedSelectorKey(key) || key.startsWith("@")) {
|
|
58
142
|
continue;
|
|
59
143
|
}
|
|
60
|
-
|
|
61
|
-
|
|
144
|
+
/* Custom properties go to `:root`; so does `color-scheme`, which is the one non-custom
|
|
145
|
+
property that has to. Every semantic token is a `light-dark()` pair, and `light-dark()`
|
|
146
|
+
resolves against the element carrying `color-scheme` — so a scheme declared on `body` leaves
|
|
147
|
+
every token on `:root` resolving against the wrong element, and the site builder disagreeing
|
|
148
|
+
with `installTheme`, which puts the same authored block on `:root`. */
|
|
149
|
+
if (key.startsWith("--") || key === "colorScheme" || key === "color-scheme") {
|
|
150
|
+
rootProps[key] = value as string;
|
|
62
151
|
} else {
|
|
63
|
-
bodyProps[key] = value;
|
|
152
|
+
bodyProps[key] = value as string;
|
|
64
153
|
}
|
|
65
154
|
}
|
|
66
155
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
// Base rules precede conditional blocks so equal-specificity overrides win by source order.
|
|
157
|
+
push(rootProps, ":root");
|
|
158
|
+
push(bodyProps, "body");
|
|
159
|
+
|
|
160
|
+
for (const [key, block] of selectorBlocks) {
|
|
161
|
+
if (key.startsWith("&")) {
|
|
162
|
+
/* `&` is spliced onto `:root`, so `&[data-theme="light"]` emits `:root[data-theme="light"]`.
|
|
163
|
+
The runtime resolves `&` against a scope and a project block has none but the root. */
|
|
164
|
+
push({ [key]: block } as JxStyle, ":root");
|
|
165
|
+
} else {
|
|
166
|
+
// A top-level selector key IS the selector — `.card` styles `.card`, not `:root .card`.
|
|
167
|
+
push(block, key);
|
|
168
|
+
}
|
|
74
169
|
}
|
|
75
170
|
|
|
76
171
|
for (const [atKey, block] of condBlocks) {
|
|
77
|
-
|
|
78
|
-
|
|
172
|
+
/* An unscoped at-rule has no selector to split across, and the name it declares is
|
|
173
|
+
document-global — one block, not one per target. A split `@keyframes` is the costly case:
|
|
174
|
+
each half is valid CSS, and the last definition of a name replaces every earlier one, so the
|
|
175
|
+
animation silently keeps only the stop that was emitted last. */
|
|
176
|
+
if (isDeclarationAtRule(atKey) || isKeyframesAtRule(atKey)) {
|
|
177
|
+
push({ [atKey]: block }, null);
|
|
79
178
|
continue;
|
|
80
179
|
}
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (scheme) {
|
|
87
|
-
const { auto, forced } = schemeSelectors(selector, scheme);
|
|
88
|
-
rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
|
|
89
|
-
} else {
|
|
90
|
-
rules.push(`@media ${query} { ${selector} { ${props} } }`);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
const condRoot: Record<string, unknown> = {};
|
|
94
|
-
const condBody: Record<string, unknown> = {};
|
|
95
|
-
const condSubs: [string, Record<string, unknown>][] = [];
|
|
180
|
+
const condRoot: JxStyle = {};
|
|
181
|
+
const condBody: JxStyle = {};
|
|
182
|
+
const condSubs: [string, JxStyle][] = [];
|
|
96
183
|
for (const [k, v] of Object.entries(block)) {
|
|
97
184
|
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
98
185
|
if (!k.startsWith("@")) {
|
|
99
|
-
condSubs.push([k, v as
|
|
186
|
+
condSubs.push([k, v as JxStyle]);
|
|
100
187
|
}
|
|
101
188
|
continue;
|
|
102
189
|
}
|
|
@@ -106,15 +193,29 @@ export function buildSiteStyleCSS(
|
|
|
106
193
|
condBody[k] = v;
|
|
107
194
|
}
|
|
108
195
|
}
|
|
109
|
-
|
|
110
|
-
|
|
196
|
+
push({ [atKey]: condRoot }, ":root");
|
|
197
|
+
push({ [atKey]: condBody }, "body");
|
|
111
198
|
for (const [sel, sub] of condSubs) {
|
|
112
|
-
|
|
199
|
+
push({ [atKey]: sub }, sel);
|
|
113
200
|
}
|
|
114
201
|
}
|
|
115
202
|
|
|
116
|
-
|
|
117
|
-
|
|
203
|
+
/* The forced-scheme UA hint, and the same three lines the compiler writes: native widgets,
|
|
204
|
+
scrollbars and form controls follow `color-scheme`, not the author's `light-dark()` tokens, so
|
|
205
|
+
the root attribute has to re-point it as well. An authored `colorScheme` suppresses all three
|
|
206
|
+
(spec.md §9.5). This builder used to emit the first line unconditionally, AFTER the `:root`
|
|
207
|
+
rule that carried the author's own value — same selector, same specificity, later in source —
|
|
208
|
+
so a project declaring `colorScheme: "light"` under a scheme query got `light dark` in every
|
|
209
|
+
host and its own value in the build. */
|
|
210
|
+
if (
|
|
211
|
+
Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null) &&
|
|
212
|
+
!("colorScheme" in siteStyle)
|
|
213
|
+
) {
|
|
214
|
+
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 }`,
|
|
218
|
+
);
|
|
118
219
|
}
|
|
119
220
|
|
|
120
221
|
return rules.join("\n");
|