@jxsuite/site 1.0.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 +8 -26
- package/package.json +3 -3
- package/src/head-merger.ts +5 -1
- package/src/site-style.ts +119 -50
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.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": "^
|
|
35
|
-
"@jxsuite/schema": "^2.
|
|
34
|
+
"@jxsuite/runtime": "^4.0.0",
|
|
35
|
+
"@jxsuite/schema": "^2.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/head-merger.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Cloudflare Worker serving a live preview — assemble the same `<head>` the build does.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
|
|
21
22
|
import type { JxHeadEntry } from "@jxsuite/schema/types";
|
|
22
23
|
|
|
23
24
|
/** What a page knows about itself while its `<head>` is being merged. */
|
|
@@ -74,7 +75,10 @@ export function mergeHead(
|
|
|
74
75
|
|
|
75
76
|
// Add canonical URL if provided
|
|
76
77
|
if (context.pageUrl && context.siteUrl) {
|
|
77
|
-
|
|
78
|
+
/* Resolved as a RELATIVE-path reference so a `siteUrl` with a path keeps it: RFC 3986 §5.2
|
|
79
|
+
replaces the whole path for an absolute-path reference, which would point every canonical
|
|
80
|
+
link and `og:url` on a subpath deployment at a page the origin root does not serve. */
|
|
81
|
+
const canonical = siteAbsoluteUrl(context.pageUrl, context.siteUrl);
|
|
78
82
|
/*
|
|
79
83
|
* An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
|
|
80
84
|
* hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
|
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 {
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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 (
|
|
109
|
+
if (isNestedSelectorKey(key) || key.startsWith("@")) {
|
|
58
110
|
continue;
|
|
59
111
|
}
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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>][] = [];
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
164
|
+
push({ [atKey]: condRoot }, ":root");
|
|
165
|
+
push({ [atKey]: condBody }, "body");
|
|
111
166
|
for (const [sel, sub] of condSubs) {
|
|
112
|
-
|
|
167
|
+
push({ [atKey]: sub }, sel);
|
|
113
168
|
}
|
|
114
169
|
}
|
|
115
170
|
|
|
116
|
-
|
|
117
|
-
|
|
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");
|