@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 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": "1.1.0",
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": "^3.1.0",
35
- "@jxsuite/schema": "^2.1.0"
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 { camelToKebab, pureSchemeOf, resolveAtQuery, schemeSelectors } from "@jxsuite/runtime/css";
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
- /** Serialize scalar declarations, kebab-casing property names and transposing values. */
22
- function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
23
- return Object.entries(props)
24
- .map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
25
- .join("; ");
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
- * conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
31
- * forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
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 rootProps: Record<string, unknown> = {};
45
- const bodyProps: Record<string, unknown> = {};
46
- const condBlocks: [string, Record<string, unknown>][] = [];
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 Record<string, unknown>]);
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 (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
141
+ if (isNestedSelectorKey(key) || key.startsWith("@")) {
58
142
  continue;
59
143
  }
60
- if (key.startsWith("--")) {
61
- rootProps[key] = value;
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
- const rootCSS = toDecls(rootProps, transpose);
68
- if (rootCSS) {
69
- rules.push(`:root { ${rootCSS} }`);
70
- }
71
- const bodyCSS = toDecls(bodyProps, transpose);
72
- if (bodyCSS) {
73
- rules.push(`body { ${bodyCSS} }`);
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
- const query = resolveAtQuery(atKey, mediaQueries);
78
- if (query === null) {
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 scheme = pureSchemeOf(query);
82
- const emit = (selector: string, props: string) => {
83
- if (!props) {
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 Record<string, unknown>]);
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
- emit(":root", toDecls(condRoot, transpose));
110
- emit("body", toDecls(condBody, transpose));
196
+ push({ [atKey]: condRoot }, ":root");
197
+ push({ [atKey]: condBody }, "body");
111
198
  for (const [sel, sub] of condSubs) {
112
- emit(sel, toDecls(sub, transpose));
199
+ push({ [atKey]: sub }, sel);
113
200
  }
114
201
  }
115
202
 
116
- if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
117
- rules.push(":root { color-scheme: light dark }");
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");