@jxsuite/site 1.1.0 → 2.0.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 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.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": "^3.1.0",
35
- "@jxsuite/schema": "^2.1.0"
34
+ "@jxsuite/runtime": "^4.0.0",
35
+ "@jxsuite/schema": "^2.2.0"
36
36
  }
37
37
  }
package/src/site-style.ts CHANGED
@@ -11,29 +11,56 @@
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 this builder emits the same rules in the
21
+ * same order for the same block — `site-style.test.ts` holds the two byte-for-byte — because a host
22
+ * that shows a page is showing what the build will ship. The one place the two were allowed to
23
+ * differ was a nested element key, on the belief that "the document's own style pass covers page
24
+ * content": nothing else reads `project.json#/style`, so the canvas simply dropped a site's
25
+ * typography and link rules while the build kept them (#296).
14
26
  */
15
27
 
16
- import { camelToKebab, pureSchemeOf, resolveAtQuery, schemeSelectors } from "@jxsuite/runtime/css";
28
+ import {
29
+ COLOR_SCHEME_ATTR,
30
+ buildStyleRules,
31
+ isDeclarationAtRule,
32
+ isKeyframesAtRule,
33
+ isNestedSelectorKey,
34
+ pureSchemeOf,
35
+ } from "@jxsuite/runtime/css";
36
+ import type { JxStyle } from "@jxsuite/schema/types";
17
37
 
18
38
  /** Id of the injected site-style tag (replace-in-place, never accumulate). */
19
39
  export const SITE_STYLE_ID = "jx-site-style";
20
40
 
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
- }
27
-
28
41
  /**
29
42
  * 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.
43
+ * selector-keyed blocks (`"h1, h2"`, `a`, `.card`) as rules of their own, `&`-keyed blocks as
44
+ * states of `:root`, conditional `@`-blocks resolved against `mediaQueries` (scheme queries
45
+ * dual-emitted per the forced-scheme contract), and the `color-scheme` hint triplet declared when a
46
+ * scheme query exists and the author has not set `colorScheme` (spec.md §9.5).
47
+ *
48
+ * The `:root` / `body` split is the one decision this builder owns; everything after it is handed
49
+ * to `buildStyleRules`, which is the single definition of what a Jx style object means as CSS. That
50
+ * is what makes a nested selector inside a conditional block compose rather than flatten to one
51
+ * level, and what makes a host and the compiled page agree about a `@supports` block.
52
+ *
53
+ * A selector-keyed block is emitted UNSCOPED, exactly as the build emits it into a page: the
54
+ * project's `style` is the page's stylesheet, so `"h1, h2"` styles every `h1` and `h2` the page
55
+ * holds, not `:root h1`. That is the right answer for both hosts too — the canvas iframe and the
56
+ * live-preview tab each ARE the page, with the project's `$head` in their head and the rendered
57
+ * document as their body — so no host-side prefix is added, and nothing has to know one.
32
58
  *
33
59
  * @param {Record<string, unknown>} siteStyle
34
60
  * @param {Record<string, string>} mediaQueries
35
61
  * @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
36
62
  * @returns {string}
63
+ * @docs studio/interface/canvas
37
64
  */
38
65
  export function buildSiteStyleCSS(
39
66
  siteStyle: Record<string, unknown>,
@@ -41,62 +68,90 @@ export function buildSiteStyleCSS(
41
68
  transpose: (value: string) => string,
42
69
  ): string {
43
70
  const rules: string[] = [];
44
- const rootProps: Record<string, unknown> = {};
45
- const bodyProps: Record<string, unknown> = {};
46
- const condBlocks: [string, Record<string, unknown>][] = [];
71
+ const push = (style: JxStyle, selector: string | null) => {
72
+ for (const rule of buildStyleRules(style, {
73
+ mediaQueries,
74
+ scope: selector,
75
+ transposeValue: transpose,
76
+ })) {
77
+ rules.push(rule.text);
78
+ }
79
+ };
80
+
81
+ const rootProps: JxStyle = {};
82
+ const bodyProps: JxStyle = {};
83
+ const condBlocks: [string, JxStyle][] = [];
84
+ /* Every block keyed by a selector, in authored order — `&`-keyed root states and element, class
85
+ and attribute selectors alike. ONE list rather than one per kind because the build keeps them
86
+ in one list, and source order is what decides between two rules of equal specificity: a site
87
+ that writes `a { … }` and then `&[data-theme="dark"] a { … }` has said which wins. */
88
+ const selectorBlocks: [string, JxStyle][] = [];
47
89
 
48
90
  for (const [key, value] of Object.entries(siteStyle)) {
49
91
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
50
92
  if (key.startsWith("@")) {
51
- condBlocks.push([key, value as Record<string, unknown>]);
93
+ condBlocks.push([key, value as JxStyle]);
94
+ } else if (!key.startsWith("--")) {
95
+ /* A selector-keyed block is a rule of the page's stylesheet, and this is where the canvas
96
+ used to lose it. `&`-keyed blocks were rescued first — `&[data-theme="light"]` is a STATE
97
+ OF THE ROOT, how a project forces a scheme, and it was dropped in silence — while a bare
98
+ element or class key was still skipped on the stated belief that the resolved document's
99
+ own style pass covered page content. It never did: nothing but this builder and the
100
+ compiler reads `project.json#/style`, and the compiler emits these rules into every
101
+ page. So a site's `"h1, h2"` and `a` rules were in the built output and missing from the
102
+ canvas, which showed headings in the fallback face and links underlined (#296). A block
103
+ under a `--name` key is not a rule at all — a custom property has no block value — and
104
+ is skipped the way the compiler skips it. */
105
+ selectorBlocks.push([key, value as JxStyle]);
52
106
  }
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
107
  continue;
56
108
  }
57
- if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
109
+ if (isNestedSelectorKey(key) || key.startsWith("@")) {
58
110
  continue;
59
111
  }
60
- if (key.startsWith("--")) {
61
- rootProps[key] = value;
112
+ /* Custom properties go to `:root`; so does `color-scheme`, which is the one non-custom
113
+ property that has to. Every semantic token is a `light-dark()` pair, and `light-dark()`
114
+ resolves against the element carrying `color-scheme` — so a scheme declared on `body` leaves
115
+ every token on `:root` resolving against the wrong element, and the site builder disagreeing
116
+ with `installTheme`, which puts the same authored block on `:root`. */
117
+ if (key.startsWith("--") || key === "colorScheme" || key === "color-scheme") {
118
+ rootProps[key] = value as string;
62
119
  } else {
63
- bodyProps[key] = value;
120
+ bodyProps[key] = value as string;
64
121
  }
65
122
  }
66
123
 
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} }`);
124
+ // Base rules precede conditional blocks so equal-specificity overrides win by source order.
125
+ push(rootProps, ":root");
126
+ push(bodyProps, "body");
127
+
128
+ for (const [key, block] of selectorBlocks) {
129
+ if (key.startsWith("&")) {
130
+ /* `&` is spliced onto `:root`, so `&[data-theme="light"]` emits `:root[data-theme="light"]`.
131
+ The runtime resolves `&` against a scope and a project block has none but the root. */
132
+ push({ [key]: block } as JxStyle, ":root");
133
+ } else {
134
+ // A top-level selector key IS the selector — `.card` styles `.card`, not `:root .card`.
135
+ push(block, key);
136
+ }
74
137
  }
75
138
 
76
139
  for (const [atKey, block] of condBlocks) {
77
- const query = resolveAtQuery(atKey, mediaQueries);
78
- if (query === null) {
140
+ /* An unscoped at-rule has no selector to split across, and the name it declares is
141
+ document-global — one block, not one per target. A split `@keyframes` is the costly case:
142
+ each half is valid CSS, and the last definition of a name replaces every earlier one, so the
143
+ animation silently keeps only the stop that was emitted last. */
144
+ if (isDeclarationAtRule(atKey) || isKeyframesAtRule(atKey)) {
145
+ push({ [atKey]: block }, null);
79
146
  continue;
80
147
  }
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>][] = [];
148
+ const condRoot: JxStyle = {};
149
+ const condBody: JxStyle = {};
150
+ const condSubs: [string, JxStyle][] = [];
96
151
  for (const [k, v] of Object.entries(block)) {
97
152
  if (v !== null && typeof v === "object" && !Array.isArray(v)) {
98
153
  if (!k.startsWith("@")) {
99
- condSubs.push([k, v as Record<string, unknown>]);
154
+ condSubs.push([k, v as JxStyle]);
100
155
  }
101
156
  continue;
102
157
  }
@@ -106,15 +161,29 @@ export function buildSiteStyleCSS(
106
161
  condBody[k] = v;
107
162
  }
108
163
  }
109
- emit(":root", toDecls(condRoot, transpose));
110
- emit("body", toDecls(condBody, transpose));
164
+ push({ [atKey]: condRoot }, ":root");
165
+ push({ [atKey]: condBody }, "body");
111
166
  for (const [sel, sub] of condSubs) {
112
- emit(sel, toDecls(sub, transpose));
167
+ push({ [atKey]: sub }, sel);
113
168
  }
114
169
  }
115
170
 
116
- if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
117
- rules.push(":root { color-scheme: light dark }");
171
+ /* The forced-scheme UA hint, and the same three lines the compiler writes: native widgets,
172
+ scrollbars and form controls follow `color-scheme`, not the author's `light-dark()` tokens, so
173
+ the root attribute has to re-point it as well. An authored `colorScheme` suppresses all three
174
+ (spec.md §9.5). This builder used to emit the first line unconditionally, AFTER the `:root`
175
+ rule that carried the author's own value — same selector, same specificity, later in source —
176
+ so a project declaring `colorScheme: "light"` under a scheme query got `light dark` in every
177
+ host and its own value in the build. */
178
+ if (
179
+ Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null) &&
180
+ !("colorScheme" in siteStyle)
181
+ ) {
182
+ rules.push(
183
+ ":root { color-scheme: light dark }",
184
+ `:root:where([${COLOR_SCHEME_ATTR}="light"]) { color-scheme: light }`,
185
+ `:root:where([${COLOR_SCHEME_ATTR}="dark"]) { color-scheme: dark }`,
186
+ );
118
187
  }
119
188
 
120
189
  return rules.join("\n");