@jxsuite/site 0.1.0 → 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Shenk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # `@jxsuite/site`
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.
5
+
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.
12
+
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.
16
+
17
+ | Module | Answers |
18
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
19
+ | `./routes` | What URL a page file has (`pages/blog/[slug].md` becomes `/blog/:slug`), and which route a request matches. Pure string math. |
20
+ | `./layout` | What a page wrapped in its layout looks like: `$layout` resolution and `<slot>` distribution. |
21
+ | `./context` | What `$site.*` and `$page.*` hold before anything renders. |
22
+ | `./head-merger` | How a site's, a layout's and a page's `$head` become one `<head>`, and how that `<head>` serializes. |
23
+ | `./site-style` | How `project.json`'s `style` becomes a stylesheet, including the dual-emitted colour-scheme selectors. |
24
+ | `./compose` | All of the above, applied: a route and a working tree become one merged document plus its `<head>`. |
25
+ | `./shell` | That document as HTML: the page that hands itself to `@jxsuite/runtime` in the reader's browser. |
26
+ | `./paths` | What an origin serving a project as a site may hand out, as an allowlist that defaults closed. |
27
+ | `./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
+
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.
31
+
32
+ ## Composing without a compiler
33
+
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.
39
+
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.
44
+
45
+ ## Where the rules are specified
46
+
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.
49
+
50
+ ## Related
51
+
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.
54
+ - `@jxsuite/runtime` renders the document this package assembles.
55
+ - `@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": "0.1.0",
3
+ "version": "1.1.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": {
@@ -13,21 +13,25 @@
13
13
  ],
14
14
  "type": "module",
15
15
  "exports": {
16
- "./routes": "./src/routes.ts",
17
- "./layout": "./src/layout.ts",
16
+ "./compose": "./src/compose.ts",
18
17
  "./context": "./src/context.ts",
19
18
  "./head-merger": "./src/head-merger.ts",
19
+ "./layout": "./src/layout.ts",
20
+ "./paths": "./src/paths.ts",
21
+ "./routes": "./src/routes.ts",
22
+ "./serve": "./src/serve.ts",
23
+ "./shell": "./src/shell.ts",
20
24
  "./site-style": "./src/site-style.ts"
21
25
  },
22
26
  "publishConfig": {
23
- "provenance": false
27
+ "provenance": true
24
28
  },
25
29
  "scripts": {
26
30
  "test": "bun test --isolate",
27
31
  "test:coverage": "bun test --isolate --coverage"
28
32
  },
29
33
  "dependencies": {
30
- "@jxsuite/runtime": "workspace:^",
31
- "@jxsuite/schema": "workspace:^"
34
+ "@jxsuite/runtime": "^3.1.0",
35
+ "@jxsuite/schema": "^2.1.0"
32
36
  }
33
37
  }
package/src/compose.ts ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Composing one page — routing, layout, `<head>` and context — over a project's working tree.
3
+ *
4
+ * Every rule here belongs to a sibling module in this package, not to this one: a page's URL
5
+ * ({@link ./routes.ts}), the layout it wraps in ({@link ./layout.ts}), the `<head>` those merge
6
+ * into ({@link ./head-merger.ts}) and the `$site`/`$page` state it binds against
7
+ * ({@link ./context.ts}) are each decided in exactly one place, so a live render and `jx build`
8
+ * cannot quietly disagree about what a route is. What is written here is only the assembly, and the
9
+ * IO is injected — the same function reads off a disk, out of a Durable Object's SQLite table, or
10
+ * over `fetch`.
11
+ *
12
+ * Two things the build does are deliberately NOT done:
13
+ *
14
+ * **`$paths` is not expanded.** Expanding a dynamic route means resolving a content collection or
15
+ * running a module. It is also not needed: this serves on demand, so `/blog/hello` MATCHES
16
+ * `pages/blog/[slug].json` and takes `slug` from the URL. What is lost is knowing which concrete
17
+ * pages exist — `/blog/anything` renders — and that is the right trade for a surface whose job is
18
+ * "show me this page".
19
+ *
20
+ * **`imports` are not rebased.** The build rewrites a project-level relative import onto the page's
21
+ * own directory because the output tree has a different shape from the source tree. This serves the
22
+ * source tree at the origin root, where the authored path already means what it says.
23
+ */
24
+
25
+ import { compareRoutes, fileToRoute, isRoutedPageFile, matchRoute } from "./routes.ts";
26
+ import type { RouteShape } from "./routes.ts";
27
+ import { resolveLayout } from "./layout.ts";
28
+ import { mergeHead } from "./head-merger.ts";
29
+ import { injectContext } from "./context.ts";
30
+ import { localeDirection, localeOfRoute, resolveI18n } from "@jxsuite/schema/locale";
31
+ import { parseJxDocument } from "@jxsuite/schema/parse";
32
+ import type { JxDocument, JxElement, JxHeadEntry, ProjectConfig } from "@jxsuite/schema/types";
33
+
34
+ /** Everything the composer settled about a page before a renderer sees it. */
35
+ export interface ComposedPage {
36
+ doc: JxDocument;
37
+ head: JxHeadEntry[];
38
+ lang: string;
39
+ dir: "ltr" | "rtl";
40
+ }
41
+
42
+ /**
43
+ * Turning a non-JSON document's bytes into a document, or null when this host has no parser for it.
44
+ *
45
+ * Named for the same reason {@link ./layout.ts}'s `LayoutLoader` is: it is the seam a host fills,
46
+ * and what it decides is whether non-JSON pages render at all. A host with the project's format
47
+ * registry (a Bun server, the desktop app) supplies one and `pages/index.md` composes; a host
48
+ * without one (a Worker, where a format's parser is not reachable) omits it, and the caller gets a
49
+ * named error instead of a blank page.
50
+ */
51
+ export type DocumentParser = (
52
+ path: string,
53
+ text: string,
54
+ ) => Promise<JxDocument | null> | JxDocument | null;
55
+
56
+ /** Reading the working tree — the one thing this module cannot do for itself. */
57
+ export interface SiteIO {
58
+ /** A project file's text, or null when the tree has no such file. */
59
+ read: (path: string) => Promise<string | null>;
60
+ /** Every non-deleted path in the tree. */
61
+ paths: () => readonly string[];
62
+ /** Parse a non-JSON document through its format. Absent where the host has no format registry. */
63
+ parse?: DocumentParser;
64
+ }
65
+
66
+ /** A page file and the route it answers. */
67
+ export interface PageRoute extends RouteShape {
68
+ /** Project path of the page source, e.g. `pages/blog/[slug].json`. */
69
+ sourcePath: string;
70
+ }
71
+
72
+ /** The route table, in match order. Static beats dynamic beats catch-all. */
73
+ export function routeTable(paths: readonly string[]): PageRoute[] {
74
+ return paths
75
+ .filter((path) => path.startsWith("pages/") && isRoutedPageFile(path))
76
+ .map((path) => Object.assign(fileToRoute(path), { sourcePath: path }))
77
+ .toSorted(compareRoutes);
78
+ }
79
+
80
+ /** Read and parse a page document, whatever format its extension claims. */
81
+ async function readDocument(io: SiteIO, path: string): Promise<JxDocument | null> {
82
+ const text = await io.read(path);
83
+ if (text === null) {
84
+ return null;
85
+ }
86
+ if (path.endsWith(".json")) {
87
+ return parseJxDocument(text, path);
88
+ }
89
+ return io.parse ? ((await io.parse(path, text)) ?? null) : null;
90
+ }
91
+
92
+ /** The project's configuration, or an empty one when it has none this host can read. */
93
+ export async function readProjectConfig(io: SiteIO): Promise<ProjectConfig> {
94
+ const text = await io.read("project.json");
95
+ if (text === null) {
96
+ return {} as ProjectConfig;
97
+ }
98
+ try {
99
+ return JSON.parse(text) as ProjectConfig;
100
+ } catch {
101
+ /* A project whose config does not parse still has pages, and refusing to render any of them
102
+ would hide the one error the author can act on behind a blank origin. */
103
+ return {} as ProjectConfig;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Union two `$elements` lists, keeping declaration order and dropping repeats.
109
+ *
110
+ * An entry is either a bare specifier (a string) or a `{ $ref }`; both are keyed by the thing they
111
+ * name, so a page and its layout declaring the same component register it once.
112
+ */
113
+ export function unionElements(
114
+ ...lists: (readonly unknown[] | undefined)[]
115
+ ): (string | JxElement)[] | undefined {
116
+ const seen = new Set<string>();
117
+ const merged: (string | JxElement)[] = [];
118
+ for (const entry of lists.flatMap((list) => list ?? [])) {
119
+ const key =
120
+ typeof entry === "string"
121
+ ? entry
122
+ : ((entry as { $ref?: string } | null)?.$ref ?? JSON.stringify(entry));
123
+ if (!seen.has(key)) {
124
+ seen.add(key);
125
+ merged.push(entry as string | JxElement);
126
+ }
127
+ }
128
+ return merged.length > 0 ? merged : undefined;
129
+ }
130
+
131
+ /** A component file in the project's own library, keyed by the tag it defines. */
132
+ const COMPONENT_FILE = /^components\/([^/]+-[^/]*)\.json$/;
133
+
134
+ /**
135
+ * A `$ref`'s project path, so the same file written from two places is one key.
136
+ *
137
+ * A layout says `../components/nav.json` and a page says `./components/nav.json` for one file. Both
138
+ * resolve against the shell's base rather than against the document that wrote them, so both name
139
+ * `/components/nav.json` — and comparing the raw strings would register it twice.
140
+ */
141
+ function refPath(ref: string): string {
142
+ return new URL(ref, "file:///").pathname;
143
+ }
144
+
145
+ /** Every custom-element tag a document tree names, root included. */
146
+ function collectTagNames(node: unknown, into: Set<string>): void {
147
+ if (!node || typeof node !== "object") {
148
+ return;
149
+ }
150
+ if (Array.isArray(node)) {
151
+ for (const child of node) {
152
+ collectTagNames(child, into);
153
+ }
154
+ return;
155
+ }
156
+ const { children, tagName } = node as { children?: unknown; tagName?: unknown };
157
+ if (typeof tagName === "string" && tagName.includes("-")) {
158
+ into.add(tagName);
159
+ }
160
+ collectTagNames(children, into);
161
+ }
162
+
163
+ /**
164
+ * The project components a document USES, as `$elements` entries it never declared.
165
+ *
166
+ * `$elements` is a declaration, and almost nothing declares one: across the shipped starters a
167
+ * layout names the chrome it uses and no page names anything, because in a BUILD it does not have
168
+ * to — the compiler scans the rendered HTML for tags it compiled and emits a module script per tag
169
+ * (`site-build.ts`, `injectComponentScripts`). The studio canvas reaches the same answer a third
170
+ * way, walking the document against its component registry. Only this composer took `$elements`
171
+ * literally, so a page rendered its own components as inert unknown tags while the canvas beside it
172
+ * rendered them correctly — and, as everywhere else in this area, with nothing reported anywhere.
173
+ *
174
+ * The walk is transitive, which the other two auto-discoveries are not: a component's own tags are
175
+ * found by reading it, so `sa-pricing` bringing `sa-check-item` registers both. The build gets that
176
+ * for free by scanning HTML it has already rendered; here the definition has to be opened. It is
177
+ * bounded by the components a page actually reaches, and each is opened once.
178
+ *
179
+ * Only `.json` components are discoverable, because a `$ref` is resolved by the browser: the
180
+ * runtime fetches the file and parses it as a document, so a component in a format needing an
181
+ * extension's parser cannot be registered this way whoever discovers it.
182
+ */
183
+ async function discoverElements(io: SiteIO, doc: JxDocument): Promise<{ $ref: string }[]> {
184
+ const available = new Map<string, string>();
185
+ for (const path of io.paths()) {
186
+ const tag = COMPONENT_FILE.exec(path)?.[1];
187
+ if (tag !== undefined) {
188
+ available.set(tag, path);
189
+ }
190
+ }
191
+ if (available.size === 0) {
192
+ return [];
193
+ }
194
+
195
+ /* What the document already declares, at any level: the union above has run, so this is the
196
+ page's, the layout's and the project's together. A declared entry is still WALKED — it may
197
+ bring components of its own — it is just not declared a second time. */
198
+ const declared = new Set<string>();
199
+ for (const entry of doc.$elements ?? []) {
200
+ const ref = (entry as { $ref?: unknown } | null)?.$ref;
201
+ if (typeof ref === "string") {
202
+ declared.add(refPath(ref));
203
+ }
204
+ }
205
+
206
+ const discovered: { $ref: string }[] = [];
207
+ const opened = new Set<string>();
208
+ let frontier: unknown[] = [doc];
209
+ while (frontier.length > 0) {
210
+ const tags = new Set<string>();
211
+ for (const node of frontier) {
212
+ collectTagNames(node, tags);
213
+ }
214
+ const next: unknown[] = [];
215
+ for (const tag of tags) {
216
+ const path = available.get(tag);
217
+ if (path === undefined || opened.has(tag)) {
218
+ continue;
219
+ }
220
+ opened.add(tag);
221
+ const ref = `./${path}`;
222
+ if (!declared.has(refPath(ref))) {
223
+ discovered.push({ $ref: ref });
224
+ }
225
+ const definition = await readDocument(io, path);
226
+ if (definition) {
227
+ next.push(definition);
228
+ }
229
+ }
230
+ frontier = next;
231
+ }
232
+ return discovered;
233
+ }
234
+
235
+ /**
236
+ * Why a route could not be composed — a sentence for the reader, not a stack trace.
237
+ *
238
+ * The constructor is explicit rather than a `name` field initializer, and that is a coverage fact
239
+ * rather than a style one: a class field on a subclass synthesizes a constructor, Bun counts that
240
+ * synthesized function, and `new ComposeError(...)` never marks it entered — V8 attributes the call
241
+ * to `Error`. The file then sits at 14/15 functions forever with every line covered, which reads
242
+ * like an untested branch and is not one. Writing the constructor out is what makes the count
243
+ * true.
244
+ */
245
+ export class ComposeError extends Error {
246
+ override readonly name: string;
247
+
248
+ constructor(message: string) {
249
+ super(message);
250
+ this.name = "ComposeError";
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Compose the page a URL asks for, or null when no route claims it.
256
+ *
257
+ * @param {SiteIO} io - Reads the working tree
258
+ * @param {readonly PageRoute[]} routes - The table, in match order
259
+ * @param {ProjectConfig} config - The project's configuration
260
+ * @param {string} pathname - The request path, e.g. `/blog/hello/`
261
+ */
262
+ export async function composeRoute(
263
+ io: SiteIO,
264
+ routes: readonly PageRoute[],
265
+ config: ProjectConfig,
266
+ pathname: string,
267
+ ): Promise<ComposedPage | null> {
268
+ const match = matchRoute(routes, pathname);
269
+ if (!match) {
270
+ return null;
271
+ }
272
+ return composePage(io, match.route, config, match.params);
273
+ }
274
+
275
+ /** Compose one known route with known parameters. */
276
+ export async function composePage(
277
+ io: SiteIO,
278
+ route: PageRoute,
279
+ config: ProjectConfig,
280
+ params: Record<string, string>,
281
+ ): Promise<ComposedPage> {
282
+ const pageDoc = await readDocument(io, route.sourcePath);
283
+ if (!pageDoc) {
284
+ throw new ComposeError(
285
+ `${route.sourcePath} could not be read as a page. Only .json pages render here; other formats need their extension's parser, which this host does not run.`,
286
+ );
287
+ }
288
+
289
+ const merged = await resolveLayout(pageDoc, config as Record<string, unknown>, async (ref) => {
290
+ const doc = await readDocument(io, ref.replace(/^\.\//, ""));
291
+ if (!doc) {
292
+ throw new ComposeError(`Layout not found: ${ref}`);
293
+ }
294
+ return doc;
295
+ });
296
+
297
+ /*
298
+ * `$elements` is legal on the page AND on the layout, and `resolveLayout` merges neither into the
299
+ * other — so the composed document has only the LAYOUT's, and every custom element a page
300
+ * declared for itself goes unregistered. The compiler reads both documents for exactly this
301
+ * reason (`site-build.ts`, "a page of inert unknown elements with no error anywhere"), and the
302
+ * failure is just as quiet here: the runtime renders `<site-card>` into the DOM, nothing defines
303
+ * it, and the browser reports nothing at all.
304
+ */
305
+ const elements = unionElements(merged.$elements, pageDoc.$elements);
306
+ if (elements) {
307
+ merged.$elements = elements;
308
+ }
309
+
310
+ const { i18n } = resolveI18n(config);
311
+ const siteRoute = {
312
+ _pathParams: params,
313
+ sourcePath: route.sourcePath,
314
+ urlPattern: route.urlPattern,
315
+ };
316
+ injectContext(merged, config, siteRoute, null, i18n);
317
+
318
+ /* After `injectContext`, because that is what merges the PROJECT's `$elements` in — discovering
319
+ before it would re-declare every component the project already names for every page. */
320
+ const discovered = await discoverElements(io, merged);
321
+ const withDiscovered = unionElements(merged.$elements, discovered);
322
+ if (withDiscovered) {
323
+ merged.$elements = withDiscovered;
324
+ }
325
+
326
+ const pageHead = (pageDoc.$head ?? merged._pageHead ?? []) as JxHeadEntry[];
327
+ const layoutHead = (merged.$head ?? []) as JxHeadEntry[];
328
+ const siteHead = (config.$head ?? []) as JxHeadEntry[];
329
+ const title = (pageDoc.title ?? merged._pageTitle ?? config.name ?? "") as string;
330
+ const lang =
331
+ (typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined) ??
332
+ localeOfRoute(route.urlPattern, i18n) ??
333
+ config.defaults?.lang ??
334
+ "en";
335
+
336
+ const head = mergeHead(siteHead, layoutHead, pageHead, {
337
+ lang,
338
+ title,
339
+ ...(config.name === undefined ? {} : { siteName: config.name }),
340
+ });
341
+
342
+ /* `$head` is merged out of the document and rendered into the shell, so leaving it on the
343
+ document would have the runtime inject every entry a second time on mount. */
344
+ delete merged.$head;
345
+ delete merged._pageHead;
346
+ delete merged._pageTitle;
347
+
348
+ return { dir: localeDirection(lang), doc: merged, head, lang };
349
+ }
package/src/context.ts CHANGED
@@ -21,7 +21,12 @@
21
21
  * @docs framework/site/i18n
22
22
  */
23
23
 
24
- import type { JxDocument, JxElement, JxStateDefinition, ProjectConfig } from "@jxsuite/schema/types";
24
+ import type {
25
+ JxDocument,
26
+ JxElement,
27
+ JxStateDefinition,
28
+ ProjectConfig,
29
+ } from "@jxsuite/schema/types";
25
30
  import { localeDirection, localeLabel, localeOfRoute } from "@jxsuite/schema/locale";
26
31
  import type { ResolvedI18n } from "@jxsuite/schema/locale";
27
32
 
@@ -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
- const canonical = new URL(context.pageUrl, context.siteUrl).href;
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/paths.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a site origin serves, and — the part that matters — what it refuses.
3
+ *
4
+ * A previewed page runs the project's own JavaScript, which routinely includes third-party script,
5
+ * and it runs it on an origin that also answers for the project tree. The rendered site gives away
6
+ * its own content by design; a project's `.dev.vars` does not, and neither does the `project.json`
7
+ * that names the user's deployment. So this list is an ALLOWLIST and it defaults closed. A denylist
8
+ * would be the wrong shape: it has to be complete to be correct, and the cost of forgetting an
9
+ * entry is a secret readable by whatever the page loaded.
10
+ *
11
+ * This is deliberately NOT the rule an editing server uses. `@jxsuite/server`'s `serveProjectFile`
12
+ * serves the whole project root, which is Studio's protocol working — the editor addresses files it
13
+ * already holds paths for. The same latitude on an origin running project script is a hole, so the
14
+ * two rules stay apart even though they answer some of the same URLs.
15
+ *
16
+ * The mapping mirrors what `jx build` writes into `dist/`:
17
+ *
18
+ * - `public/**` is copied to the site root, so `/favicon.svg` is `public/favicon.svg`.
19
+ * - Everything else keeps its project path, so `/components/card.json` is that file — which is what
20
+ * makes the runtime's own `$ref` and `$elements` resolution work with no rewriting at all.
21
+ */
22
+
23
+ /** Project directories a site origin may read from. Everything else is not servable. */
24
+ export const SERVABLE_ROOTS: readonly string[] = [
25
+ "public/",
26
+ "components/",
27
+ "layouts/",
28
+ "pages/",
29
+ "assets/",
30
+ "media/",
31
+ "content/",
32
+ "data/",
33
+ "styles/",
34
+ ];
35
+
36
+ /**
37
+ * Files that are never servable however they are addressed.
38
+ *
39
+ * Belt and braces over {@link SERVABLE_ROOTS} — these all sit at the project root and so are
40
+ * already excluded, but they are the exact files whose exposure would matter and the cost of naming
41
+ * them twice is nothing.
42
+ */
43
+ export const NEVER_SERVABLE: ReadonlySet<string> = new Set([
44
+ "project.json",
45
+ "package.json",
46
+ "package-lock.json",
47
+ "bun.lock",
48
+ "bun.lockb",
49
+ "wrangler.jsonc",
50
+ "wrangler.toml",
51
+ ".dev.vars",
52
+ ]);
53
+
54
+ /** A path segment that must never appear: dotfiles, traversal, and empty interior segments. */
55
+ function segmentIsSafe(segment: string): boolean {
56
+ return segment !== "" && segment !== "." && segment !== ".." && !segment.startsWith(".");
57
+ }
58
+
59
+ /**
60
+ * Normalise a request path into a project-relative path, or null when it is not one.
61
+ *
62
+ * Traversal is rejected rather than resolved: a `..` that a decode produced is not a path a site
63
+ * ever legitimately asks for, and answering it — even correctly — means the check has to be right
64
+ * about what it collapsed to.
65
+ */
66
+ export function normalizeRequestPath(pathname: string): string | null {
67
+ let decoded: string;
68
+ try {
69
+ decoded = decodeURIComponent(pathname);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (decoded.includes("\0") || decoded.includes("\\")) {
74
+ return null;
75
+ }
76
+ /* A TRAILING slash is stripped rather than rejected. `build.trailingSlash` defaults to `always`,
77
+ so `/blog/hello/` is the ordinary form of a page URL and the one Open in Browser hands over —
78
+ treating its empty last segment as a malformed path 404'd every page on the site. Leading and
79
+ interior empty segments are still refused; only the final one is a writing convention. */
80
+ const clean = decoded.replace(/^\/+/, "").replace(/\/+$/, "");
81
+ if (clean === "") {
82
+ return "";
83
+ }
84
+ return clean.split("/").every((segment) => segmentIsSafe(segment)) ? clean : null;
85
+ }
86
+
87
+ /**
88
+ * The project paths a request path could be asking for, in the order a built site would answer.
89
+ *
90
+ * `public/` first, because that is where the build copies from and an author who put `robots.txt`
91
+ * in both meant the one they publish.
92
+ */
93
+ export function candidatePaths(requestPath: string): string[] {
94
+ if (requestPath === "" || NEVER_SERVABLE.has(requestPath)) {
95
+ return [];
96
+ }
97
+ const candidates = [`public/${requestPath}`];
98
+ if (SERVABLE_ROOTS.some((root) => requestPath.startsWith(root))) {
99
+ candidates.push(requestPath);
100
+ }
101
+ return candidates;
102
+ }
103
+
104
+ /** Content types a site origin serves by extension. Anything unlisted is served as a download. */
105
+ const CONTENT_TYPES: Record<string, string> = {
106
+ avif: "image/avif",
107
+ css: "text/css; charset=utf-8",
108
+ csv: "text/csv; charset=utf-8",
109
+ gif: "image/gif",
110
+ htm: "text/html; charset=utf-8",
111
+ html: "text/html; charset=utf-8",
112
+ ico: "image/x-icon",
113
+ jpeg: "image/jpeg",
114
+ jpg: "image/jpeg",
115
+ js: "text/javascript; charset=utf-8",
116
+ json: "application/json; charset=utf-8",
117
+ map: "application/json; charset=utf-8",
118
+ md: "text/markdown; charset=utf-8",
119
+ mjs: "text/javascript; charset=utf-8",
120
+ mp4: "video/mp4",
121
+ otf: "font/otf",
122
+ pdf: "application/pdf",
123
+ png: "image/png",
124
+ svg: "image/svg+xml",
125
+ ttf: "font/ttf",
126
+ txt: "text/plain; charset=utf-8",
127
+ webm: "video/webm",
128
+ webp: "image/webp",
129
+ woff: "font/woff",
130
+ woff2: "font/woff2",
131
+ xml: "application/xml; charset=utf-8",
132
+ };
133
+
134
+ /**
135
+ * The Content-Type a site origin serves a path as.
136
+ *
137
+ * Unlike an editor's raw-file mount — which never says `text/html` and attaches everything, because
138
+ * it answers on an origin that holds a session — this serves real types. That is the whole point of
139
+ * giving a preview an origin of its own: an HTML file executing here is the site working, not a
140
+ * privilege escalation, because there is nothing on this origin to escalate to.
141
+ */
142
+ export function contentTypeFor(path: string): string {
143
+ const dot = path.lastIndexOf(".");
144
+ const extension = dot === -1 ? "" : path.slice(dot + 1).toLowerCase();
145
+ return CONTENT_TYPES[extension] ?? "application/octet-stream";
146
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Turning one request into a response, on an origin that serves a project as a site.
3
+ *
4
+ * The decision order mirrors what a published site does with the same URL, which is the whole
5
+ * contract of serving on an origin of its own: a path is a FILE if the tree has one there, and a
6
+ * ROUTE otherwise. Getting that order wrong in either direction is visible — a `pages/` document
7
+ * served as JSON instead of rendered, or a `/favicon.svg` answered with someone's index page.
8
+ *
9
+ * A host's own surfaces — a runtime bundle, a reload stream — are NOT a lane here. They are
10
+ * dispatched before this function is reached, because a namespace only that host knows about is not
11
+ * a fact about the site. What a host may add is a {@link ServeOptions.fallback}, tried after routes
12
+ * and before the 404, which is where a lane like on-demand npm bundling belongs: it answers paths a
13
+ * site legitimately asks for that this package cannot produce.
14
+ */
15
+
16
+ import { candidatePaths, contentTypeFor, normalizeRequestPath } from "./paths.ts";
17
+ import { ComposeError, composeRoute, readProjectConfig, routeTable } from "./compose.ts";
18
+ import type { PageRoute, SiteIO } from "./compose.ts";
19
+ import { pageShell, problemShell } from "./shell.ts";
20
+ import type { ShellOptions } from "./shell.ts";
21
+ import type { ProjectConfig } from "@jxsuite/schema/types";
22
+
23
+ /** Reading bytes for a servable file — separate from {@link SiteIO} because assets are binary. */
24
+ export interface AssetIO {
25
+ /** The file's bytes, or null when the tree cannot produce them. */
26
+ bytes: (path: string) => Promise<Uint8Array | null>;
27
+ }
28
+
29
+ /** What this origin knows about a project, computed once per request. */
30
+ export interface SiteContext {
31
+ config: ProjectConfig;
32
+ routes: PageRoute[];
33
+ }
34
+
35
+ /** How a host wants pages shelled, and what it can answer that this package cannot. */
36
+ export interface ServeOptions {
37
+ shell: ShellOptions;
38
+ /** Tried after routes and before the 404 — a host's own lane for site-legitimate paths. */
39
+ fallback?: (requestPath: string) => Promise<Response | null>;
40
+ }
41
+
42
+ /** Headers every response from this origin carries, whatever it is serving. */
43
+ export function siteHeaders(contentType: string): Record<string, string> {
44
+ return {
45
+ /* Never cached. A composed page is a function of a working tree that changes under it, and it
46
+ is not revalidatable — there is no version of it to compare against. */
47
+ "Cache-Control": "private, no-store",
48
+ "Content-Type": contentType,
49
+ /* A live render is a working copy, and an indexed one would compete with the site the author
50
+ actually publishes. */
51
+ "X-Robots-Tag": "noindex, nofollow",
52
+ "X-Content-Type-Options": "nosniff",
53
+ /* The origin this was reached from is nobody else's business — and on a desktop machine the
54
+ referring origin is an editor holding a token. */
55
+ "Referrer-Policy": "same-origin",
56
+ };
57
+ }
58
+
59
+ /** Read the project's configuration and route table out of the tree. */
60
+ export async function siteContext(io: SiteIO): Promise<SiteContext> {
61
+ return { config: await readProjectConfig(io), routes: routeTable(io.paths()) };
62
+ }
63
+
64
+ /** A plain 404, for when neither the tree nor the project has anything to say. */
65
+ function plainNotFound(): Response {
66
+ return new Response("Not found", {
67
+ headers: siteHeaders("text/plain; charset=utf-8"),
68
+ status: 404,
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Serve one path.
74
+ *
75
+ * @param {string} pathname - The request path, still URL-encoded
76
+ * @param {SiteIO} io - Reads the working tree's text
77
+ * @param {AssetIO} assets - Reads a servable file's bytes
78
+ * @param {SiteContext} context - The project's configuration and route table
79
+ * @param {ServeOptions} options - The shell contract, and the host's own fallback lane
80
+ */
81
+ export async function serveSite(
82
+ pathname: string,
83
+ io: SiteIO,
84
+ assets: AssetIO,
85
+ context: SiteContext,
86
+ options: ServeOptions,
87
+ ): Promise<Response> {
88
+ const requestPath = normalizeRequestPath(pathname);
89
+ if (requestPath === null) {
90
+ return plainNotFound();
91
+ }
92
+
93
+ // A file the tree really has, at the path the published site would serve it from.
94
+ for (const candidate of candidatePaths(requestPath)) {
95
+ const bytes = await assets.bytes(candidate);
96
+ if (bytes) {
97
+ return new Response(bytes as BodyInit, {
98
+ headers: siteHeaders(contentTypeFor(candidate)),
99
+ });
100
+ }
101
+ }
102
+
103
+ const page = await composeSafely(io, context, pathname, options.shell);
104
+ if (page) {
105
+ return page;
106
+ }
107
+
108
+ const fallback = await options.fallback?.(requestPath);
109
+ if (fallback) {
110
+ return fallback;
111
+ }
112
+
113
+ /* The project's own 404, at 404 — the same thing a built site serves, because a reader who
114
+ mistypes a URL should see the site's answer and not ours. */
115
+ const notFound = await composeSafely(io, context, "/404", options.shell);
116
+ if (notFound) {
117
+ return new Response(await notFound.text(), {
118
+ headers: siteHeaders("text/html; charset=utf-8"),
119
+ status: 404,
120
+ });
121
+ }
122
+ return plainNotFound();
123
+ }
124
+
125
+ /**
126
+ * Compose a route into a response, turning a compose failure into a page that says so.
127
+ *
128
+ * A render that 500s tells the author nothing they can act on. A page naming the file and the
129
+ * reason is the difference between "the preview is broken" and "this page needs a `.json` layout".
130
+ */
131
+ async function composeSafely(
132
+ io: SiteIO,
133
+ context: SiteContext,
134
+ pathname: string,
135
+ shell: ShellOptions,
136
+ ): Promise<Response | null> {
137
+ let page;
138
+ try {
139
+ page = await composeRoute(io, context.routes, context.config, pathname);
140
+ } catch (error) {
141
+ if (error instanceof ComposeError) {
142
+ return new Response(problemShell(error.message), {
143
+ headers: siteHeaders("text/html; charset=utf-8"),
144
+ status: 500,
145
+ });
146
+ }
147
+ throw error;
148
+ }
149
+ if (!page) {
150
+ return null;
151
+ }
152
+ return new Response(pageShell(page, shell), {
153
+ headers: siteHeaders("text/html; charset=utf-8"),
154
+ });
155
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The HTML a composed page is served as.
3
+ *
4
+ * A built page is prerendered; a live one cannot be, because prerendering is the compiler's job and
5
+ * the compiler is not on this path. So the shell carries what the SERVER can settle — the merged
6
+ * `<head>`, the page's language and writing direction — and hands the composed document to
7
+ * `@jxsuite/runtime`, which builds the DOM in the reader's browser exactly as the studio's canvas
8
+ * does.
9
+ *
10
+ * Two details are load-bearing:
11
+ *
12
+ * **`base`.** Handed a document object rather than a URL, the runtime has no base to resolve
13
+ * `$ref`, `$elements` and asset paths against and falls back to `location.href` — which is right
14
+ * only at the site root. At `/blog/hello/` every relative reference would resolve two directories
15
+ * too deep. A host serving the project tree AT its origin root passes `"/"`, and saying so is what
16
+ * makes an authored `./components/card.json` find its file.
17
+ *
18
+ * **The document travels in a `<script type="application/json">`, not in a JS literal.** The only
19
+ * sequence that can end such a block is `</script`, so escaping `<` in the payload closes the one
20
+ * hole; a JS string literal would have to be right about backslashes, line separators and
21
+ * `</script` all at once.
22
+ *
23
+ * Everything else is a parameter rather than a constant, because the two hosts that serve this
24
+ * disagree about all of it: where the runtime bundle lives, whether there is a site stylesheet,
25
+ * whether a reload client is injected, and whether the runtime may reach a resolver.
26
+ */
27
+
28
+ import { renderHead } from "./head-merger.ts";
29
+ import type { ComposedPage } from "./compose.ts";
30
+
31
+ /** What a host must tell the shell about its own origin. */
32
+ export interface ShellOptions {
33
+ /** URL of the `@jxsuite/runtime` browser bundle on this origin. */
34
+ runtimeUrl: string;
35
+ /** What relative references resolve against — `"/"` for a tree served at the origin root. */
36
+ base: string;
37
+ /** Stylesheet carrying `project.json`'s `style`, when the host emits one. */
38
+ styleUrl?: string | undefined;
39
+ /** A module the host appends after the render — a live-reload client, typically. */
40
+ clientScriptUrl?: string | undefined;
41
+ /**
42
+ * Token for the runtime's `/__jx_resolve__` and `/__jx_server__` proxies.
43
+ *
44
+ * Present only where the host can actually answer them. Without it the runtime still renders;
45
+ * what it cannot do is resolve `$src` classes, content collections or `timing: "server"`, so a
46
+ * host that omits this should expect a collection to render as an empty list.
47
+ */
48
+ resolveToken?: string | undefined;
49
+ }
50
+
51
+ /** Escape a string for an HTML attribute value. */
52
+ function attr(value: string): string {
53
+ return value
54
+ .replaceAll("&", "&amp;")
55
+ .replaceAll("<", "&lt;")
56
+ .replaceAll(">", "&gt;")
57
+ .replaceAll('"', "&quot;");
58
+ }
59
+
60
+ /**
61
+ * Serialize a document for a `<script type="application/json">` block.
62
+ *
63
+ * `<` is escaped as `\u003c`, which JSON.parse restores and an HTML parser cannot mistake for the
64
+ * start of a tag — so no value inside the document, however it was authored, can close the block.
65
+ */
66
+ function jsonPayload(value: unknown): string {
67
+ return JSON.stringify(value).replaceAll("<", String.raw`\u003c`);
68
+ }
69
+
70
+ /** The document for one composed page. */
71
+ export function pageShell(page: ComposedPage, options: ShellOptions): string {
72
+ const imports = options.resolveToken ? "Jx, setResolveToken" : "Jx";
73
+ const setToken = options.resolveToken
74
+ ? `\nsetResolveToken(${JSON.stringify(options.resolveToken)});`
75
+ : "";
76
+ const style = options.styleUrl
77
+ ? `\n <link rel="stylesheet" href="${attr(options.styleUrl)}">`
78
+ : "";
79
+ /* Scroll restoration is turned off before first paint, and it has to be an inline classic script
80
+ to land there: a module script is deferred, so the browser would have restored against an empty
81
+ body — a client-rendered page has no height yet — and left the reader at the top. Whoever
82
+ restores the position does it after the render instead. */
83
+ const client = options.clientScriptUrl
84
+ ? `\n<script type="module" src="${attr(options.clientScriptUrl)}"></script>`
85
+ : "";
86
+ return `<!doctype html>
87
+ <html lang="${attr(page.lang)}" dir="${attr(page.dir)}">
88
+ <head>
89
+ ${renderHead(page.head)}${style}
90
+ <script>history.scrollRestoration = "manual";</script>
91
+ </head>
92
+ <body>
93
+ <script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script>
94
+ <script type="module">
95
+ import { ${imports} } from "${attr(options.runtimeUrl)}";${setToken}
96
+ const source = document.getElementById("jx-page-document").textContent;
97
+ await Jx(JSON.parse(source), document.body, { base: ${JSON.stringify(options.base)} });
98
+ </script>${client}
99
+ </body>
100
+ </html>
101
+ `;
102
+ }
103
+
104
+ /**
105
+ * A page that names what stopped a route from rendering.
106
+ *
107
+ * A preview that 500s tells the author nothing they can act on. A page naming the file and the
108
+ * reason — a layout that is not there, a format whose parser does not run here — is the difference
109
+ * between "the preview is broken" and "this page needs a `.json` layout".
110
+ */
111
+ export function problemShell(message: string): string {
112
+ const escaped = message.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
113
+ return `<!doctype html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1">
118
+ <title>This page could not be rendered</title>
119
+ </head>
120
+ <body>
121
+ <h1>This page could not be rendered</h1>
122
+ <p>${escaped}</p>
123
+ </body>
124
+ </html>
125
+ `;
126
+ }
package/src/site-style.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Site-style stylesheet builder — pure functions extracted for testability.
3
+ * Site-style stylesheet builder — `project.json`'s `style` as a real stylesheet.
4
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
5
+ * A host emits the project's `style` as a stylesheet rather than as inline properties on the root
6
+ * element: inline custom properties would defeat the forced-scheme override selectors
7
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.
8
+ * entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so a host honors
9
+ * both the OS preference and its own forced-scheme toggle.
10
+ *
11
+ * Two hosts share it, which is why it is here rather than in either of them: the studio canvas,
12
+ * which passes a transposer rewriting viewport units to container units, and the live preview
13
+ * origin, which passes identity because a browser tab IS the viewport.
10
14
  */
11
15
 
12
- import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
16
+ import { camelToKebab, pureSchemeOf, resolveAtQuery, schemeSelectors } from "@jxsuite/runtime/css";
13
17
 
14
18
  /** Id of the injected site-style tag (replace-in-place, never accumulate). */
15
19
  export const SITE_STYLE_ID = "jx-site-style";