@jxsuite/site 0.1.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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@jxsuite/site",
3
+ "version": "0.1.0",
4
+ "description": "What a Jx project's page at a route consists of — routing, layout, context, head and the document that boots it",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jxsuite/jx.git",
9
+ "directory": "packages/site"
10
+ },
11
+ "files": [
12
+ "src/"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ "./routes": "./src/routes.ts",
17
+ "./layout": "./src/layout.ts",
18
+ "./context": "./src/context.ts",
19
+ "./head-merger": "./src/head-merger.ts",
20
+ "./site-style": "./src/site-style.ts"
21
+ },
22
+ "publishConfig": {
23
+ "provenance": false
24
+ },
25
+ "scripts": {
26
+ "test": "bun test --isolate",
27
+ "test:coverage": "bun test --isolate --coverage"
28
+ },
29
+ "dependencies": {
30
+ "@jxsuite/runtime": "workspace:^",
31
+ "@jxsuite/schema": "workspace:^"
32
+ }
33
+ }
package/src/context.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * $page and $site context injection
3
+ *
4
+ * Injects project-level and page-level context variables into a page's state before compilation.
5
+ * These are available as $site.* and $page.* in template expressions.
6
+ *
7
+ * Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
8
+ * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
9
+ * title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
10
+ * language and writing direction (§13.4) $page.alternates — this page in every language it exists
11
+ * in (§13.5)
12
+ *
13
+ * The site build is not the only thing that has to produce this state. A page rendered live — on
14
+ * the studio's canvas, or by the cloud's preview origin — binds `${$site.name}` and `${$page.url}`
15
+ * exactly as a built page does, and a host that improvised its own subset would differ from the
16
+ * build in whichever field it forgot. So the injection lives here, with no platform imports; the
17
+ * one thing that genuinely needed a filesystem, rebasing relative `imports` onto the page's own
18
+ * directory, is injected as {@link ImportRebaser} and simply absent where there are no
19
+ * directories.
20
+ *
21
+ * @docs framework/site/i18n
22
+ */
23
+
24
+ import type { JxDocument, JxElement, JxStateDefinition, ProjectConfig } from "@jxsuite/schema/types";
25
+ import { localeDirection, localeLabel, localeOfRoute } from "@jxsuite/schema/locale";
26
+ import type { ResolvedI18n } from "@jxsuite/schema/locale";
27
+
28
+ /** One member of a route's translation set — the shape `<head>` and `$page.alternates` share. */
29
+ export interface TranslationMember {
30
+ locale: string;
31
+ urlPattern: string;
32
+ }
33
+
34
+ /** The route a page is being injected for. */
35
+ export interface SiteRoute {
36
+ urlPattern: string;
37
+ sourcePath?: string;
38
+ _pathParams?: Record<string, string>;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ /**
43
+ * Rewrite one project-level relative `imports` entry so it resolves from the PAGE's directory.
44
+ *
45
+ * Only a host with real directories can answer this — and only one needs to. A live renderer serves
46
+ * the project at a root where the authored path already means what it says, so it passes nothing
47
+ * and the entry is used as written.
48
+ */
49
+ export type ImportRebaser = (src: string, route: SiteRoute) => string;
50
+
51
+ /**
52
+ * Inject $site and $page context into a page document's state.
53
+ *
54
+ * @param {JxDocument} doc - The page document (mutated)
55
+ * @param {ProjectConfig} projectConfig - Loaded project configuration
56
+ * @param {SiteRoute} route - The resolved route for this page
57
+ * @param {ImportRebaser | null} [rebaseImport] - Rebases relative `imports` onto the page directory
58
+ * @param {ResolvedI18n | null} [i18n] - Validated locale config, when the project declares one
59
+ * @param {readonly TranslationMember[]} [translations] - This route's translation set, itself
60
+ * included
61
+ * @returns {JxDocument} The mutated document
62
+ */
63
+ export function injectContext(
64
+ doc: JxDocument,
65
+ projectConfig: ProjectConfig,
66
+ route: SiteRoute,
67
+ rebaseImport: ImportRebaser | null = null,
68
+ i18n: ResolvedI18n | null = null,
69
+ translations: readonly TranslationMember[] = [],
70
+ ) {
71
+ if (!doc.state) {
72
+ doc.state = {};
73
+ }
74
+
75
+ // $site context — read-only project-level data
76
+ doc.state.$site = {
77
+ name: projectConfig.name ?? "Jx Site",
78
+ url: projectConfig.url ?? "",
79
+ ...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
80
+ ...projectConfig.state,
81
+ };
82
+
83
+ /*
84
+ * $page context — read-only page-level data.
85
+ *
86
+ * The locale lives here rather than as a top-level `$locale` because that is what it is: a
87
+ * property of the route, not a third ambient namespace beside $site and $page. A document's own
88
+ * `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
89
+ */
90
+ const pageLang =
91
+ (typeof doc.$lang === "string" ? doc.$lang : undefined) ??
92
+ localeOfRoute(route.urlPattern, i18n) ??
93
+ projectConfig.defaults?.lang;
94
+ /*
95
+ * The same translation set `<head>` advertises, in the shape a template can render: a language
96
+ * switcher is the one part of a multilingual site the framework cannot write for the author, and
97
+ * without this it could only be hand-maintained — a hardcoded list of URLs that goes stale the
98
+ * moment a page gains or loses a translation, silently, in the one place a reader would use it.
99
+ *
100
+ * Site-absolute URLs, not the absolute hrefs `<head>` needs: a switcher is an internal link, and
101
+ * it has to work before `url` is configured. `current` marks the member this route *is*, taken
102
+ * from the route rather than from `$page.locale`, because a document whose `$lang` disagrees with
103
+ * its directory is still served from that directory.
104
+ */
105
+ const alternates = translations.map((member) => ({
106
+ code: member.locale,
107
+ current: member.urlPattern === route.urlPattern,
108
+ dir: localeDirection(member.locale),
109
+ label: localeLabel(member.locale),
110
+ url: member.urlPattern,
111
+ }));
112
+ doc.state.$page = {
113
+ params: route._pathParams ?? {},
114
+ title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
115
+ url: route.urlPattern,
116
+ ...(alternates.length > 0 && { alternates }),
117
+ ...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
118
+ };
119
+
120
+ // Merge project-level state into page state (page wins on conflicts)
121
+ if (projectConfig.state) {
122
+ for (const [key, value] of Object.entries(projectConfig.state)) {
123
+ if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
124
+ doc.state[key] = value as JxStateDefinition;
125
+ }
126
+ }
127
+ }
128
+
129
+ // Merge project-level $media into page $media
130
+ if (projectConfig.$media) {
131
+ doc.$media = { ...projectConfig.$media, ...doc.$media };
132
+ }
133
+
134
+ // Merge project-level imports into page imports (page wins on collision)
135
+ if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
136
+ if (!doc.imports) {
137
+ doc.imports = {};
138
+ }
139
+ for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
140
+ if (!(name in doc.imports)) {
141
+ const src = srcPath as string;
142
+ // Only rebase relative paths — bare/npm specifiers pass through unmodified
143
+ doc.imports[name] =
144
+ rebaseImport && (src.startsWith("./") || src.startsWith("../"))
145
+ ? rebaseImport(src, route)
146
+ : src;
147
+ }
148
+ }
149
+ }
150
+
151
+ // Merge project-level $elements into page $elements (union, dedup)
152
+ if (projectConfig.$elements?.length) {
153
+ if (!doc.$elements?.length) {
154
+ doc.$elements = [...projectConfig.$elements];
155
+ } else {
156
+ const seen = new Set<string>();
157
+ const merged: (string | JxElement)[] = [];
158
+ for (const entry of [
159
+ ...projectConfig.$elements,
160
+ ...(doc.$elements as (string | JxElement)[]),
161
+ ]) {
162
+ const key =
163
+ typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
164
+ if (key && !seen.has(key)) {
165
+ seen.add(key);
166
+ merged.push(entry);
167
+ }
168
+ }
169
+ doc.$elements = merged;
170
+ }
171
+ }
172
+
173
+ return doc;
174
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * $head merge pipeline — the three levels a page's `<head>` is assembled from.
3
+ *
4
+ * Merges <head> element arrays from three levels:
5
+ *
6
+ * 1. Site.json.$head — global (e.g., favicon, global stylesheet)
7
+ * 2. Layout.$head — layout-level (e.g., shared nav scripts)
8
+ * 3. Page.$head — page-specific (e.g., per-page meta tags)
9
+ *
10
+ * Per site-architecture spec §8:
11
+ *
12
+ * - Later levels override earlier levels for the same element
13
+ * - Deduplication by tagName + key attribute (name, property, rel+href)
14
+ * - Charset and viewport are auto-injected if missing
15
+ *
16
+ * Pure tree and string work with no platform imports, and it lives here rather than in
17
+ * `@jxsuite/compiler` so that hosts which cannot import that package — a browser bundle, a
18
+ * Cloudflare Worker serving a live preview — assemble the same `<head>` the build does.
19
+ */
20
+
21
+ import type { JxHeadEntry } from "@jxsuite/schema/types";
22
+
23
+ /** What a page knows about itself while its `<head>` is being merged. */
24
+ export interface HeadMergeContext {
25
+ title?: string;
26
+ siteName?: string;
27
+ lang?: string;
28
+ charset?: string;
29
+ url?: string;
30
+ siteUrl?: string;
31
+ pageUrl?: string;
32
+ /** `rel="alternate"` links for this page's translations (site-architecture.md §13.5). */
33
+ alternates?: readonly { hreflang: string; href: string }[];
34
+ }
35
+
36
+ /**
37
+ * Merge $head arrays from site, layout, and page levels.
38
+ *
39
+ * @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
40
+ * @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
41
+ * @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
42
+ * @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
43
+ * @returns {JxHeadEntry[]} Merged, deduplicated $head array
44
+ */
45
+ export function mergeHead(
46
+ siteHead: JxHeadEntry[] = [],
47
+ layoutHead: JxHeadEntry[] = [],
48
+ pageHead: JxHeadEntry[] = [],
49
+ context: HeadMergeContext = {},
50
+ ) {
51
+ // Start with auto-injected defaults
52
+ const defaults: JxHeadEntry[] = [
53
+ { attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
54
+ {
55
+ attributes: {
56
+ content: "width=device-width, initial-scale=1",
57
+ name: "viewport",
58
+ },
59
+ tagName: "meta",
60
+ },
61
+ ];
62
+
63
+ // Merge layers: site → layout → page (later wins)
64
+ const merged = new Map<string, JxHeadEntry>();
65
+
66
+ for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
67
+ const key = headEntryKey(entry);
68
+ merged.set(key, entry);
69
+ }
70
+
71
+ // Insert <title> if present
72
+ const title = context.title ?? context.siteName ?? "Jx Site";
73
+ merged.set("title", { children: [title], tagName: "title" });
74
+
75
+ // Add canonical URL if provided
76
+ if (context.pageUrl && context.siteUrl) {
77
+ const canonical = new URL(context.pageUrl, context.siteUrl).href;
78
+ /*
79
+ * An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
80
+ * hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
81
+ * and this one landed under different keys and the page got BOTH — which is the one thing a
82
+ * canonical link must not be.
83
+ */
84
+ const hasAuthored = [...merged.values()].some(
85
+ (e) =>
86
+ e && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
87
+ );
88
+ if (!hasAuthored) {
89
+ const entry: JxHeadEntry = {
90
+ attributes: { href: canonical, rel: "canonical" },
91
+ tagName: "link",
92
+ };
93
+ merged.set(headEntryKey(entry), entry);
94
+ }
95
+ // Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
96
+ if (!merged.has("meta:og:url")) {
97
+ merged.set("meta:og:url", {
98
+ attributes: { content: canonical, property: "og:url" },
99
+ tagName: "meta",
100
+ });
101
+ }
102
+ }
103
+ if (context.siteName && !merged.has("meta:og:site_name")) {
104
+ merged.set("meta:og:site_name", {
105
+ attributes: { content: context.siteName, property: "og:site_name" },
106
+ tagName: "meta",
107
+ });
108
+ }
109
+
110
+ /*
111
+ * Locale alternates (site-architecture.md §13.5). Keyed through `headEntryKey` like everything
112
+ * else, which is what keeps a set of them from collapsing: they share `rel="alternate"` and
113
+ * differ only in `hreflang`, and `x-default` conventionally shares its `href` with the default
114
+ * locale's entry. Before the key accounted for the qualifying attribute this whole feature was
115
+ * impossible — the set became one link.
116
+ *
117
+ * An author-supplied alternate for the same `hreflang` wins, like every other auto entry.
118
+ */
119
+ for (const alternate of context.alternates ?? []) {
120
+ const entry: JxHeadEntry = {
121
+ attributes: { href: alternate.href, hreflang: alternate.hreflang, rel: "alternate" },
122
+ tagName: "link",
123
+ };
124
+ const key = headEntryKey(entry);
125
+ if (!merged.has(key)) {
126
+ merged.set(key, entry);
127
+ }
128
+ }
129
+
130
+ return [...merged.values()];
131
+ }
132
+
133
+ /**
134
+ * Generate a deduplication key for a <head> element. Elements with the same key are considered
135
+ * duplicates; the last one wins.
136
+ *
137
+ * @param {JxHeadEntry} entry
138
+ * @returns {string}
139
+ */
140
+ function headEntryKey(entry: JxHeadEntry) {
141
+ if (!entry || typeof entry !== "object") {
142
+ return String(entry);
143
+ }
144
+
145
+ const tag = entry.tagName ?? "unknown";
146
+ const attrs = entry.attributes ?? {};
147
+
148
+ // <title> — singleton
149
+ if (tag === "title") {
150
+ return "title";
151
+ }
152
+
153
+ // <meta charset> — singleton
154
+ if (attrs.charset) {
155
+ return "meta:charset";
156
+ }
157
+
158
+ // <meta name="...""> — keyed by name
159
+ if (tag === "meta" && attrs.name) {
160
+ return `meta:${attrs.name}`;
161
+ }
162
+
163
+ // <meta property="..."> — keyed by property (Open Graph)
164
+ if (tag === "meta" && attrs.property) {
165
+ return `meta:${attrs.property}`;
166
+ }
167
+
168
+ /*
169
+ * <link> — keyed by rel + href + the attribute that distinguishes two links sharing both.
170
+ *
171
+ * `rel` + `href` alone is not identity, and the cases where it fails are the ones a site actually
172
+ * needs: `hreflang="x-default"` conventionally points at the SAME href as the default locale's
173
+ * alternate, and an RSS and an Atom feed are both `rel="alternate"` differing only in `type`.
174
+ * `icon` differs by `sizes` and `stylesheet` by `media` for the same reason.
175
+ */
176
+ if (tag === "link" && attrs.rel) {
177
+ const qualifier = attrs.hreflang ?? attrs.type ?? attrs.media ?? attrs.sizes ?? "";
178
+ return `link:${attrs.rel}:${attrs.href ?? ""}:${String(qualifier)}`;
179
+ }
180
+
181
+ // <script src="..."> — keyed by src
182
+ if (tag === "script" && attrs.src) {
183
+ return `script:${attrs.src}`;
184
+ }
185
+
186
+ // <style> — unique per content hash
187
+ if (tag === "style") {
188
+ const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
189
+ const content = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
190
+ return `style:${simpleHash(content)}`;
191
+ }
192
+
193
+ // Fallback — use full JSON serialization
194
+ return `${tag}:${JSON.stringify(entry)}`;
195
+ }
196
+
197
+ /**
198
+ * Simple string hash for deduplication (not cryptographic).
199
+ *
200
+ * @param {string} str
201
+ * @returns {string}
202
+ */
203
+ function simpleHash(str: string) {
204
+ let hash = 0;
205
+ for (let i = 0; i < str.length; i++) {
206
+ // oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
207
+ hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
208
+ }
209
+ return hash.toString(36);
210
+ }
211
+
212
+ /**
213
+ * Render a merged $head array to HTML string for insertion into <head>.
214
+ *
215
+ * @param {JxHeadEntry[]} headEntries - Merged head entries
216
+ * @returns {string} HTML string
217
+ */
218
+ export function renderHead(headEntries: JxHeadEntry[]) {
219
+ return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
220
+ }
221
+
222
+ /**
223
+ * Render a single $head entry to an HTML string.
224
+ *
225
+ * @param {JxHeadEntry} entry
226
+ * @returns {string}
227
+ */
228
+ function renderHeadEntry(entry: JxHeadEntry) {
229
+ if (typeof entry === "string") {
230
+ return entry;
231
+ }
232
+ if (!entry || typeof entry !== "object") {
233
+ return "";
234
+ }
235
+
236
+ const tag = entry.tagName;
237
+ const attrs = entry.attributes ?? {};
238
+ const attrStr = Object.entries(attrs)
239
+ .map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
240
+ .join(" ");
241
+
242
+ const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
243
+
244
+ // Void elements (no closing tag)
245
+ const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
246
+ if (VOID.has(tag)) {
247
+ return open;
248
+ }
249
+
250
+ /*
251
+ * Elements with content. `textContent` may be an OBJECT: that is how a JSON-LD block is authored
252
+ * (§8.5), and serializing it here is what the spec has always promised. Left as-is it reached the
253
+ * page as the string "[object Object]".
254
+ */
255
+ const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
256
+ const content =
257
+ raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
258
+ return `${open}${content}</${tag}>`;
259
+ }
260
+
261
+ /**
262
+ * @param {unknown} val
263
+ * @returns {string}
264
+ */
265
+ function escapeAttr(val: unknown) {
266
+ return String(val).replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
267
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Layout resolution — $layout loading and slot distribution.
3
+ *
4
+ * Resolves $layout references, loads layout documents, and distributes page content into layout
5
+ * <slot> elements. This is the compile-time equivalent of the runtime's distributeSlots()
6
+ * algorithm.
7
+ *
8
+ * Per site-architecture spec §5:
9
+ *
10
+ * - Layouts are JSON files in the layouts/ directory
11
+ * - Pages reference layouts via "$layout": "./layouts/base.json"
12
+ * - The page's children are distributed into the layout's <slot> elements
13
+ * - Named slots use attributes.slot on page children
14
+ *
15
+ * Reading is injected rather than imported. The rule "a page wrapped in its layout looks like THIS"
16
+ * is the same rule whether the layout comes off a disk, out of a Durable Object's SQLite table, or
17
+ * over `fetch` — and the two hosts that are not the compiler cannot import `node:fs` at all. So the
18
+ * only thing this module asks for is a {@link LayoutLoader}, and everything else is pure.
19
+ */
20
+
21
+ import type { JxDocument, JxElement } from "@jxsuite/schema/types";
22
+
23
+ /**
24
+ * Read one layout document by the `$layout` reference that named it.
25
+ *
26
+ * The reference is project-relative as authored (`./layouts/base.json`); resolving it against
27
+ * wherever the project actually lives is the host's job, because only the host knows.
28
+ */
29
+ export type LayoutLoader = (layoutRef: string) => Promise<JxDocument> | JxDocument;
30
+
31
+ /**
32
+ * Resolve a page's layout, wrapping the page content in the layout structure.
33
+ *
34
+ * @param {JxDocument} pageDoc - The raw page JSON document
35
+ * @param {Record<string, unknown>} projectConfig - Site configuration (for defaults.layout)
36
+ * @param {LayoutLoader} load - Reads a layout document by its `$layout` reference
37
+ * @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
38
+ */
39
+ export async function resolveLayout(
40
+ pageDoc: JxDocument,
41
+ projectConfig: Record<string, unknown>,
42
+ load: LayoutLoader,
43
+ ): Promise<JxDocument> {
44
+ // Determine which layout to use
45
+ const layoutRef =
46
+ pageDoc.$layout ?? (projectConfig.defaults as Record<string, unknown>)?.layout ?? null;
47
+
48
+ if (!layoutRef) {
49
+ // No layout — return page as-is
50
+ return pageDoc;
51
+ }
52
+
53
+ let layoutDoc = await load(layoutRef as string);
54
+
55
+ // Check for nested layouts (layout inheriting from another layout)
56
+ if (layoutDoc.$layout) {
57
+ layoutDoc = await resolveLayout(layoutDoc, projectConfig, load);
58
+ }
59
+
60
+ // Distribute page children into layout slots
61
+ const rawChildren = pageDoc.children ?? [];
62
+ const pageChildren =
63
+ typeof rawChildren === "string"
64
+ ? [rawChildren]
65
+ : Array.isArray(rawChildren)
66
+ ? rawChildren
67
+ : ([] as (JxElement | string)[]);
68
+ const merged = deepClone(layoutDoc);
69
+
70
+ distributeSlots(merged, pageChildren);
71
+
72
+ // Merge page-level properties onto the resolved document
73
+ // Page state extends layout state
74
+ if (pageDoc.state) {
75
+ merged.state = { ...merged.state, ...pageDoc.state };
76
+ }
77
+
78
+ // Page $media extends layout $media
79
+ if (pageDoc.$media) {
80
+ merged.$media = { ...merged.$media, ...pageDoc.$media };
81
+ }
82
+
83
+ // Page style extends layout style
84
+ if (pageDoc.style) {
85
+ merged.style = { ...merged.style, ...pageDoc.style };
86
+ }
87
+
88
+ // Page attributes extend layout attributes
89
+ if (pageDoc.attributes) {
90
+ merged.attributes = { ...merged.attributes, ...pageDoc.attributes };
91
+ }
92
+
93
+ // Preserve page-level metadata
94
+ if (pageDoc.$head) {
95
+ merged._pageHead = pageDoc.$head;
96
+ }
97
+ if (pageDoc.title) {
98
+ merged._pageTitle = pageDoc.title;
99
+ }
100
+
101
+ // Remove $layout from merged doc (already resolved)
102
+ delete merged.$layout;
103
+
104
+ return merged;
105
+ }
106
+
107
+ /**
108
+ * Distribute children into <slot> elements within a layout document tree. This is the compile-time
109
+ * equivalent of the runtime's distributeSlots().
110
+ *
111
+ * Algorithm:
112
+ *
113
+ * 1. Find all <slot> elements in the layout tree
114
+ * 2. For each child with attributes.slot, distribute to the matching named slot
115
+ * 3. Remaining children go into the default (unnamed) slot
116
+ * 4. Replace each <slot> element with its distributed children
117
+ *
118
+ * @param {JxElement} node - Layout document tree (mutated in place)
119
+ * @param {(JxElement | string)[]} children - Page children to distribute
120
+ */
121
+ function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
122
+ if (!node || typeof node !== "object") {
123
+ return;
124
+ }
125
+ if (!Array.isArray(node.children)) {
126
+ return;
127
+ }
128
+
129
+ // Collect named and default children
130
+ const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
131
+ const defaults: (JxElement | string)[] = []; // Children without a slot target
132
+
133
+ for (const child of children) {
134
+ if (child && typeof child === "object" && child.attributes?.slot) {
135
+ const slotName = child.attributes.slot as string;
136
+ if (!named.has(slotName)) {
137
+ named.set(slotName, []);
138
+ }
139
+ (named.get(slotName) as (JxElement | string)[]).push(child);
140
+ } else {
141
+ defaults.push(child);
142
+ }
143
+ }
144
+
145
+ // Walk the tree and replace <slot> elements
146
+ fillSlots(node, named, defaults);
147
+ }
148
+
149
+ /**
150
+ * Recursively walk the tree and replace <slot> elements with distributed content.
151
+ *
152
+ * @param {JxElement} node
153
+ * @param {Map<string, (JxElement | string)[]>} named
154
+ * @param {(JxElement | string)[]} defaults
155
+ */
156
+ function fillSlots(
157
+ node: JxElement,
158
+ named: Map<string, (JxElement | string)[]>,
159
+ defaults: (JxElement | string)[],
160
+ ) {
161
+ if (!node || typeof node !== "object") {
162
+ return;
163
+ }
164
+ if (!Array.isArray(node.children)) {
165
+ return;
166
+ }
167
+
168
+ const newChildren: (JxElement | string)[] = [];
169
+
170
+ for (const child of node.children) {
171
+ if (child && typeof child === "object" && child.tagName === "slot") {
172
+ const slotName = child.attributes?.name;
173
+
174
+ if (slotName && named.has(slotName as string)) {
175
+ // Named slot — replace with matching children
176
+ newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
177
+ named.delete(slotName as string); // Consumed
178
+ } else if (!slotName && defaults.length > 0) {
179
+ // Default slot — replace with unassigned children
180
+ newChildren.push(...defaults);
181
+ // Don't clear defaults — only one default slot should exist,
182
+ // But if there are multiple, the first one wins
183
+ } else {
184
+ // No matching content — keep slot's fallback children
185
+ if (Array.isArray(child.children)) {
186
+ newChildren.push(...child.children);
187
+ }
188
+ }
189
+ } else {
190
+ // Not a slot — recurse into it
191
+ fillSlots(child as JxElement, named, defaults);
192
+ newChildren.push(child);
193
+ }
194
+ }
195
+
196
+ node.children = newChildren;
197
+ }
198
+
199
+ /**
200
+ * Deep clone a JSON-serializable object.
201
+ *
202
+ * @param {JxDocument} obj
203
+ * @returns {JxDocument}
204
+ */
205
+ function deepClone(obj: JxDocument) {
206
+ return structuredClone(obj);
207
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * File-based routing — the one definition of what URL a page file has.
3
+ *
4
+ * The rules in this file (spec `site-architecture.md` §4) had been written down three times: the
5
+ * compiler's `fileToRoute` inside `pages-discovery.ts`, a mirror in the studio's `page-params.ts`
6
+ * whose own docblock admitted it was a mirror, and — the moment a Worker needed to answer the same
7
+ * question — very nearly a third. Nothing kept the copies in agreement, and a route is exactly the
8
+ * kind of rule where a silent disagreement is a page that 404s in one surface and renders in
9
+ * another.
10
+ *
11
+ * It lives HERE rather than in `@jxsuite/compiler` for the reason `resolveI18n` does (`i18n.ts`):
12
+ * the compiler's dependency graph carries `sharp` and `esbuild`, so neither a browser bundle nor a
13
+ * Cloudflare Worker can import it. This module is pure string math with no imports at all, so every
14
+ * host that has to know what `/blog/:slug` means can share one answer.
15
+ */
16
+
17
+ /** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
18
+ const PARAM_SEGMENT = /\[\.\.\.(\w+)\]|\[(\w+)\]/g;
19
+
20
+ /** What a page file's path says about its route, before anything is read from the file. */
21
+ export interface RouteShape {
22
+ /** URL pattern with parameters, e.g. `/blog/:slug` or `/docs/*`. */
23
+ urlPattern: string;
24
+ /** Parameter names in declaration order, e.g. `["slug"]`. */
25
+ params: string[];
26
+ /** True when the pattern carries any parameter. */
27
+ isDynamic: boolean;
28
+ /** True when the pattern ends in a `[...rest]` spread. */
29
+ isCatchAll: boolean;
30
+ }
31
+
32
+ /**
33
+ * A page path with any addressing prefix removed, ready for {@link fileToRoute}.
34
+ *
35
+ * Callers hold these paths in two spaces — the compiler walks `pages/` and holds paths relative to
36
+ * it, while the studio holds a document path relative to the PROJECT (`pages/blog/[slug].json`).
37
+ * Normalising here means neither has to remember which space it is in.
38
+ */
39
+ export function pageRelativePath(documentPath: string): string {
40
+ return documentPath
41
+ .split("\\")
42
+ .join("/")
43
+ .replace(/^\.\//, "")
44
+ .replace(/^\//, "")
45
+ .replace(/^pages\//, "");
46
+ }
47
+
48
+ /**
49
+ * The route a page file's path describes.
50
+ *
51
+ * `blog/[slug].json` → `/blog/:slug`, `docs/[...rest].json` → `/docs/*`, `index.json` → `/`,
52
+ * `about/index.md` → `/about`. The extension is dropped whatever it is: a page may be any format an
53
+ * enabled extension claims, and its route is a property of the NAME, not of the parser.
54
+ */
55
+ export function fileToRoute(relativePath: string): RouteShape {
56
+ let urlPath = pageRelativePath(relativePath);
57
+
58
+ /* Drop the extension, but only a real one. `.` is legal inside a route segment, and slicing at
59
+ the last dot regardless would turn `pages/v1.2/index.json` into `/v1`. */
60
+ const lastSegment = urlPath.slice(urlPath.lastIndexOf("/") + 1);
61
+ const dot = lastSegment.lastIndexOf(".");
62
+ if (dot > 0) {
63
+ urlPath = urlPath.slice(0, urlPath.length - (lastSegment.length - dot));
64
+ }
65
+
66
+ // Index files map to their parent directory.
67
+ if (urlPath.endsWith("/index")) {
68
+ urlPath = urlPath.slice(0, -6) || "/";
69
+ } else if (urlPath === "index") {
70
+ urlPath = "/";
71
+ }
72
+
73
+ if (!urlPath.startsWith("/")) {
74
+ urlPath = `/${urlPath}`;
75
+ }
76
+
77
+ const params: string[] = [];
78
+ let isDynamic = false;
79
+ let isCatchAll = false;
80
+ const urlPattern = urlPath.replaceAll(
81
+ PARAM_SEGMENT,
82
+ (_match: string, spread: string | undefined, named: string | undefined) => {
83
+ isDynamic = true;
84
+ if (spread) {
85
+ isCatchAll = true;
86
+ params.push(spread);
87
+ return "*";
88
+ }
89
+ params.push(named!);
90
+ return `:${named}`;
91
+ },
92
+ );
93
+
94
+ return { isCatchAll, isDynamic, params, urlPattern };
95
+ }
96
+
97
+ /**
98
+ * URL pattern for a page document path — {@link fileToRoute} when only the pattern is wanted.
99
+ *
100
+ * A null or empty path answers `/` rather than throwing: the studio asks this of whatever document
101
+ * happens to be open, including none.
102
+ */
103
+ export function documentUrlPattern(documentPath?: string | null): string {
104
+ if (!documentPath) {
105
+ return "/";
106
+ }
107
+ return fileToRoute(documentPath).urlPattern;
108
+ }
109
+
110
+ /** Param names declared by a document path's bracket segments, e.g. `["sku"]`. */
111
+ export function dynamicRouteParams(documentPath?: string | null): string[] {
112
+ if (!documentPath) {
113
+ return [];
114
+ }
115
+ return fileToRoute(documentPath).params;
116
+ }
117
+
118
+ /**
119
+ * Whether a path under `pages/` becomes a route at all.
120
+ *
121
+ * An underscore prefix opts a file OUT — that is how a page directory holds a local component
122
+ * beside the pages that use it — and it opts out a whole directory the same way, so
123
+ * `_partials/card.json` is not a route even though `card.json` alone would be.
124
+ */
125
+ export function isRoutedPageFile(relativePath: string): boolean {
126
+ const segments = pageRelativePath(relativePath).split("/");
127
+ return segments.length > 0 && segments.every((segment) => segment !== "" && segment[0] !== "_");
128
+ }
129
+
130
+ /**
131
+ * Route priority (spec §4.4, Astro's rules): static beats dynamic, dynamic beats catch-all.
132
+ *
133
+ * Sorting a route table with this is what makes `matchRoute` a first-match scan — `/about` is found
134
+ * before `/[slug]` can claim it, whatever order the directory walk produced.
135
+ */
136
+ export function compareRoutes(a: RouteShape, b: RouteShape): number {
137
+ if (a.isDynamic !== b.isDynamic) {
138
+ return a.isDynamic ? 1 : -1;
139
+ }
140
+ if (a.isCatchAll !== b.isCatchAll) {
141
+ return a.isCatchAll ? 1 : -1;
142
+ }
143
+ return a.urlPattern.localeCompare(b.urlPattern);
144
+ }
145
+
146
+ /** One route matched against a URL, with the parameter values that matched it. */
147
+ export interface RouteMatch<T extends RouteShape> {
148
+ route: T;
149
+ params: Record<string, string>;
150
+ }
151
+
152
+ /**
153
+ * The first route in `routes` that matches `pathname`, or null.
154
+ *
155
+ * `routes` is expected to be in {@link compareRoutes} order; the scan takes the first hit rather
156
+ * than the best one, because specificity is a property of the TABLE and re-deciding it per request
157
+ * is how two surfaces end up disagreeing about which page a URL is.
158
+ *
159
+ * A trailing slash is not significant — `build.trailingSlash` decides how a route is WRITTEN, and a
160
+ * reader who types the other form is asking for the same page.
161
+ */
162
+ export function matchRoute<T extends RouteShape>(
163
+ routes: readonly T[],
164
+ pathname: string,
165
+ ): RouteMatch<T> | null {
166
+ const parts = splitPath(pathname);
167
+ for (const route of routes) {
168
+ const params = matchPattern(route.urlPattern, parts);
169
+ if (params) {
170
+ return { params, route };
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** Path segments, with the empty leading/trailing entries a slash produces removed. */
177
+ function splitPath(pathname: string): string[] {
178
+ return pathname.split("/").filter((segment) => segment !== "");
179
+ }
180
+
181
+ /** Parameter values if `pattern` matches `parts`, else null. */
182
+ function matchPattern(pattern: string, parts: string[]): Record<string, string> | null {
183
+ const patternParts = splitPath(pattern);
184
+ const params: Record<string, string> = {};
185
+
186
+ for (const [index, expected] of patternParts.entries()) {
187
+ if (expected === "*") {
188
+ /* A catch-all consumes the rest, including nothing at all: `/docs/*` serves `/docs`. Its
189
+ parameter is the remainder as one path, which is what the page's `$params` receives. */
190
+ params["*"] = parts.slice(index).join("/");
191
+ return params;
192
+ }
193
+ const actual = parts[index];
194
+ if (actual === undefined) {
195
+ return null;
196
+ }
197
+ if (expected.startsWith(":")) {
198
+ params[expected.slice(1)] = decodeURIComponent(actual);
199
+ continue;
200
+ }
201
+ if (expected !== actual) {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ return patternParts.length === parts.length ? params : null;
207
+ }
208
+
209
+ /** How a site writes its URLs — `project.json`'s `build.trailingSlash`. */
210
+ export type TrailingSlash = "always" | "never";
211
+
212
+ /**
213
+ * The address a route's page will have when it is published.
214
+ *
215
+ * This is the URL the page's OWN markup already points at, which is why Open in Browser opens it
216
+ * rather than the compiler's output path: a built page links to `/blog/hello/` and pulls
217
+ * `/components/demo.css`, both root-absolute, and handed its output path instead a browser resolves
218
+ * those against the server root and every one of them misses.
219
+ */
220
+ export function routeHref(
221
+ urlPattern: string,
222
+ params: Record<string, string> = {},
223
+ trailingSlash: TrailingSlash = "always",
224
+ ): string {
225
+ const filled = urlPattern
226
+ .replaceAll(/:(\w+)/g, (match, name: string) => {
227
+ const value = params[name];
228
+ return value === undefined ? match : encodeURIComponent(value);
229
+ })
230
+ .replace(/\*$/, (match) => {
231
+ const rest = params["*"];
232
+ return rest === undefined
233
+ ? match
234
+ : rest
235
+ .split("/")
236
+ .map((segment) => encodeURIComponent(segment))
237
+ .join("/");
238
+ });
239
+ if (filled === "/" || filled === "") {
240
+ return "/";
241
+ }
242
+ const bare = filled.endsWith("/") ? filled.slice(0, -1) : filled;
243
+ return trailingSlash === "always" ? `${bare}/` : bare;
244
+ }
@@ -0,0 +1,117 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Site-style stylesheet builder — pure functions extracted for testability.
4
+ *
5
+ * The canvas iframe injects the project's `style` as a real stylesheet instead of inline properties
6
+ * on the root element: inline custom properties would defeat the forced-scheme override selectors
7
+ * (`:root[data-color-scheme]`, spec §9.5), and object-valued `@--name` blocks used to be dropped
8
+ * entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so the canvas
9
+ * honors both the OS preference and the preview toggle.
10
+ */
11
+
12
+ import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
13
+
14
+ /** Id of the injected site-style tag (replace-in-place, never accumulate). */
15
+ export const SITE_STYLE_ID = "jx-site-style";
16
+
17
+ /** Serialize scalar declarations, kebab-casing property names and transposing values. */
18
+ function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
19
+ return Object.entries(props)
20
+ .map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
21
+ .join("; ");
22
+ }
23
+
24
+ /**
25
+ * Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
26
+ * conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
27
+ * forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
28
+ *
29
+ * @param {Record<string, unknown>} siteStyle
30
+ * @param {Record<string, string>} mediaQueries
31
+ * @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
32
+ * @returns {string}
33
+ */
34
+ export function buildSiteStyleCSS(
35
+ siteStyle: Record<string, unknown>,
36
+ mediaQueries: Record<string, string>,
37
+ transpose: (value: string) => string,
38
+ ): string {
39
+ const rules: string[] = [];
40
+ const rootProps: Record<string, unknown> = {};
41
+ const bodyProps: Record<string, unknown> = {};
42
+ const condBlocks: [string, Record<string, unknown>][] = [];
43
+
44
+ for (const [key, value] of Object.entries(siteStyle)) {
45
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
46
+ if (key.startsWith("@")) {
47
+ condBlocks.push([key, value as Record<string, unknown>]);
48
+ }
49
+ // Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
50
+ // Own style pass covers those; the site sheet handles tokens + conditional overrides.
51
+ continue;
52
+ }
53
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
54
+ continue;
55
+ }
56
+ if (key.startsWith("--")) {
57
+ rootProps[key] = value;
58
+ } else {
59
+ bodyProps[key] = value;
60
+ }
61
+ }
62
+
63
+ const rootCSS = toDecls(rootProps, transpose);
64
+ if (rootCSS) {
65
+ rules.push(`:root { ${rootCSS} }`);
66
+ }
67
+ const bodyCSS = toDecls(bodyProps, transpose);
68
+ if (bodyCSS) {
69
+ rules.push(`body { ${bodyCSS} }`);
70
+ }
71
+
72
+ for (const [atKey, block] of condBlocks) {
73
+ const query = resolveAtQuery(atKey, mediaQueries);
74
+ if (query === null) {
75
+ continue;
76
+ }
77
+ const scheme = pureSchemeOf(query);
78
+ const emit = (selector: string, props: string) => {
79
+ if (!props) {
80
+ return;
81
+ }
82
+ if (scheme) {
83
+ const { auto, forced } = schemeSelectors(selector, scheme);
84
+ rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
85
+ } else {
86
+ rules.push(`@media ${query} { ${selector} { ${props} } }`);
87
+ }
88
+ };
89
+ const condRoot: Record<string, unknown> = {};
90
+ const condBody: Record<string, unknown> = {};
91
+ const condSubs: [string, Record<string, unknown>][] = [];
92
+ for (const [k, v] of Object.entries(block)) {
93
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
94
+ if (!k.startsWith("@")) {
95
+ condSubs.push([k, v as Record<string, unknown>]);
96
+ }
97
+ continue;
98
+ }
99
+ if (k.startsWith("--")) {
100
+ condRoot[k] = v;
101
+ } else {
102
+ condBody[k] = v;
103
+ }
104
+ }
105
+ emit(":root", toDecls(condRoot, transpose));
106
+ emit("body", toDecls(condBody, transpose));
107
+ for (const [sel, sub] of condSubs) {
108
+ emit(sel, toDecls(sub, transpose));
109
+ }
110
+ }
111
+
112
+ if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
113
+ rules.push(":root { color-scheme: light dark }");
114
+ }
115
+
116
+ return rules.join("\n");
117
+ }