@jxsuite/site 0.1.0 → 1.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/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +10 -6
- package/src/compose.ts +349 -0
- package/src/context.ts +6 -1
- package/src/paths.ts +146 -0
- package/src/serve.ts +155 -0
- package/src/shell.ts +126 -0
- package/src/site-style.ts +9 -5
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": "
|
|
3
|
+
"version": "1.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": {
|
|
@@ -13,21 +13,25 @@
|
|
|
13
13
|
],
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
|
-
"./
|
|
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":
|
|
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": "
|
|
31
|
-
"@jxsuite/schema": "
|
|
34
|
+
"@jxsuite/runtime": "^3.0.0",
|
|
35
|
+
"@jxsuite/schema": "^2.0.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 {
|
|
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
|
|
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("&", "&")
|
|
55
|
+
.replaceAll("<", "<")
|
|
56
|
+
.replaceAll(">", ">")
|
|
57
|
+
.replaceAll('"', """);
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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,12 +1,16 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
/**
|
|
3
|
-
* Site-style stylesheet builder —
|
|
3
|
+
* Site-style stylesheet builder — `project.json`'s `style` as a real stylesheet.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
16
|
import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
|