@jxsuite/site 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/compose.ts +35 -5
- package/src/context.ts +33 -9
- package/src/serve.ts +50 -4
- package/src/shell.ts +29 -7
- package/src/xmp-containers.ts +542 -0
- package/src/xmp-packet.ts +727 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/site",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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": {
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"./routes": "./src/routes.ts",
|
|
23
23
|
"./serve": "./src/serve.ts",
|
|
24
24
|
"./shell": "./src/shell.ts",
|
|
25
|
-
"./site-style": "./src/site-style.ts"
|
|
25
|
+
"./site-style": "./src/site-style.ts",
|
|
26
|
+
"./xmp-containers": "./src/xmp-containers.ts",
|
|
27
|
+
"./xmp-packet": "./src/xmp-packet.ts"
|
|
26
28
|
},
|
|
27
29
|
"publishConfig": {
|
|
28
30
|
"provenance": true
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
"test:coverage": "bun test --isolate --coverage"
|
|
33
35
|
},
|
|
34
36
|
"dependencies": {
|
|
35
|
-
"@jxsuite/runtime": "^
|
|
36
|
-
"@jxsuite/schema": "^
|
|
37
|
+
"@jxsuite/runtime": "^7.0.0",
|
|
38
|
+
"@jxsuite/schema": "^6.0.0"
|
|
37
39
|
}
|
|
38
40
|
}
|
package/src/compose.ts
CHANGED
|
@@ -28,10 +28,18 @@ import { resolveLayout } from "./layout.ts";
|
|
|
28
28
|
import { mergeHead, withPageDescription } from "./head-merger.ts";
|
|
29
29
|
import type { HeadMergeContext } from "./head-merger.ts";
|
|
30
30
|
import { injectContext } from "./context.ts";
|
|
31
|
+
import { inheritableFormulas } from "@jxsuite/runtime/expression";
|
|
32
|
+
import { qualifyScripts } from "@jxsuite/schema/document-scripts";
|
|
31
33
|
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
32
34
|
import { localeDirection, localeOfRoute, resolveI18n } from "@jxsuite/schema/locale";
|
|
33
35
|
import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
34
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
JxDocument,
|
|
38
|
+
JxElement,
|
|
39
|
+
JxHeadEntry,
|
|
40
|
+
JxStateDefinition,
|
|
41
|
+
ProjectConfig,
|
|
42
|
+
} from "@jxsuite/schema/types";
|
|
35
43
|
import { PROJECT_FILE_NAMES, parseProjectSource } from "@jxsuite/schema/project-file";
|
|
36
44
|
|
|
37
45
|
/** Everything the composer settled about a page before a renderer sees it. */
|
|
@@ -40,6 +48,12 @@ export interface ComposedPage {
|
|
|
40
48
|
head: JxHeadEntry[];
|
|
41
49
|
lang: string;
|
|
42
50
|
dir: "ltr" | "rtl";
|
|
51
|
+
/**
|
|
52
|
+
* The project's pure named formulas a component inherits (spec.md §13.4), when it has any. The
|
|
53
|
+
* page has them in its own `state` already; a component's document is fetched as written, so the
|
|
54
|
+
* shell hands these to the runtime before it renders.
|
|
55
|
+
*/
|
|
56
|
+
formulas?: Record<string, JxStateDefinition>;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
/**
|
|
@@ -83,16 +97,23 @@ export function routeTable(paths: readonly string[]): PageRoute[] {
|
|
|
83
97
|
.toSorted(compareRoutes);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Read and parse a document, whatever format its extension claims.
|
|
102
|
+
*
|
|
103
|
+
* Every entry naming the document's own script is qualified with the document's path here, as it is
|
|
104
|
+
* read (`spec.md` §23.2): a page is about to be merged onto its layout, and after that nothing
|
|
105
|
+
* records which of the two an entry was written in.
|
|
106
|
+
*/
|
|
87
107
|
async function readDocument(io: SiteIO, path: string): Promise<JxDocument | null> {
|
|
88
108
|
const text = await io.read(path);
|
|
89
109
|
if (text === null) {
|
|
90
110
|
return null;
|
|
91
111
|
}
|
|
92
112
|
if (isNativeDocumentPath(path)) {
|
|
93
|
-
return parseNativeDocument(text, path);
|
|
113
|
+
return qualifyScripts(parseNativeDocument(text, path), path);
|
|
94
114
|
}
|
|
95
|
-
|
|
115
|
+
const parsed = io.parse ? await io.parse(path, text) : null;
|
|
116
|
+
return parsed ? qualifyScripts(parsed, path) : null;
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/**
|
|
@@ -376,6 +397,15 @@ export async function composePage(
|
|
|
376
397
|
delete merged.$head;
|
|
377
398
|
delete merged._pageHead;
|
|
378
399
|
delete merged._pageTitle;
|
|
400
|
+
/* A page merged onto a layout has one `$script` where there were two documents, so the text is
|
|
401
|
+
neither's to speak for; every entry already names its own document, and the page fetches the
|
|
402
|
+
client module from there. The server module is the other reason: a reader is never sent one. */
|
|
403
|
+
delete merged.$script;
|
|
379
404
|
|
|
380
|
-
|
|
405
|
+
const composed: ComposedPage = { dir: localeDirection(lang), doc: merged, head, lang };
|
|
406
|
+
const formulas = inheritableFormulas(config.state);
|
|
407
|
+
if (Object.keys(formulas).length > 0) {
|
|
408
|
+
composed.formulas = formulas;
|
|
409
|
+
}
|
|
410
|
+
return composed;
|
|
381
411
|
}
|
package/src/context.ts
CHANGED
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
5
|
* These are available as $site.* and $page.* in template expressions.
|
|
6
6
|
*
|
|
7
|
-
* Per site-architecture spec §10:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Per site-architecture spec §10:
|
|
8
|
+
*
|
|
9
|
+
* - `$site.name`, `$site.url`: from the project file's `name` and `url`.
|
|
10
|
+
* - `$site.<key>`: each project `state` entry as written, read-only, never replacing the four names
|
|
11
|
+
* in {@link SITE_CONTEXT_KEYS} (§10.4).
|
|
12
|
+
* - `$page.url`, `$page.title`, `$page.params`: the current page's URL path, title and dynamic route
|
|
13
|
+
* parameters.
|
|
14
|
+
* - `$page.locale`, `$page.dir`: the route's language and writing direction (§13.4).
|
|
15
|
+
* - `$page.alternates`: this page in every language it exists in (§13.5).
|
|
16
|
+
* - `$page.pagination`: where a paged route's page sits in its list (§4.3).
|
|
17
|
+
*
|
|
18
|
+
* Each project `state` entry is also copied into the page's own `state` unless the page or its
|
|
19
|
+
* layout declares the key.
|
|
12
20
|
*
|
|
13
21
|
* The site build is not the only thing that has to produce this state. A page rendered live — on
|
|
14
22
|
* the studio's canvas, or by the cloud's preview origin — binds `${$site.name}` and `${$page.url}`
|
|
@@ -56,6 +64,17 @@ export interface SiteRoute {
|
|
|
56
64
|
*/
|
|
57
65
|
export type ImportRebaser = (src: string, route: SiteRoute) => string;
|
|
58
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The names `$site` defines itself (site-architecture.md §5.5). A project `state` entry with one of
|
|
69
|
+
* these names reaches the page as bare state only (§10.4).
|
|
70
|
+
*/
|
|
71
|
+
export const SITE_CONTEXT_KEYS: ReadonlySet<string> = new Set([
|
|
72
|
+
"defaultLocale",
|
|
73
|
+
"locales",
|
|
74
|
+
"name",
|
|
75
|
+
"url",
|
|
76
|
+
]);
|
|
77
|
+
|
|
59
78
|
/**
|
|
60
79
|
* Inject $site and $page context into a page document's state.
|
|
61
80
|
*
|
|
@@ -86,13 +105,17 @@ export function injectContext(
|
|
|
86
105
|
localeFields.defaultLocale = i18n.defaultLocale;
|
|
87
106
|
localeFields.locales = i18n.locales;
|
|
88
107
|
}
|
|
89
|
-
/* Project state
|
|
90
|
-
|
|
108
|
+
/* Project state goes first and the context's own names last, so a state entry called `name` can
|
|
109
|
+
never stand in for the project's name; it is left out altogether so that a project without
|
|
110
|
+
`i18n` does not grow a `$site.locales` from its state. `Object.fromEntries` and a spread, not
|
|
111
|
+
`Object.assign`: an authored `__proto__` key stays an own data property. */
|
|
91
112
|
doc.state.$site = {
|
|
113
|
+
...Object.fromEntries(
|
|
114
|
+
Object.entries(projectConfig.state ?? {}).filter(([key]) => !SITE_CONTEXT_KEYS.has(key)),
|
|
115
|
+
),
|
|
92
116
|
name: projectConfig.name ?? "Jx Site",
|
|
93
117
|
url: projectConfig.url ?? "",
|
|
94
118
|
...localeFields,
|
|
95
|
-
...projectConfig.state,
|
|
96
119
|
};
|
|
97
120
|
|
|
98
121
|
/*
|
|
@@ -153,7 +176,8 @@ export function injectContext(
|
|
|
153
176
|
}
|
|
154
177
|
doc.state.$page = page;
|
|
155
178
|
|
|
156
|
-
// Merge project-level state into page state
|
|
179
|
+
// Merge project-level state into page state: the page, then its layout, wins
|
|
180
|
+
// (site-architecture.md §10.4)
|
|
157
181
|
if (projectConfig.state) {
|
|
158
182
|
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
159
183
|
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
package/src/serve.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* site legitimately asks for that this package cannot produce.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { withoutServerScript } from "@jxsuite/schema/document-scripts";
|
|
17
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
16
18
|
import { isJxnPath, parseJxn } from "@jxsuite/schema/jxn";
|
|
17
19
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
18
20
|
import { candidatePaths, contentTypeFor, normalizeRequestPath } from "./paths.ts";
|
|
@@ -20,7 +22,7 @@ import { ComposeError, composeRoute, readProjectConfig, routeTable } from "./com
|
|
|
20
22
|
import type { PageRoute, SiteIO } from "./compose.ts";
|
|
21
23
|
import { pageShell, problemShell } from "./shell.ts";
|
|
22
24
|
import type { ShellOptions } from "./shell.ts";
|
|
23
|
-
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
25
|
+
import type { JsonValue, JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
24
26
|
|
|
25
27
|
/** Reading bytes for a servable file — separate from {@link SiteIO} because assets are binary. */
|
|
26
28
|
export interface AssetIO {
|
|
@@ -110,7 +112,7 @@ export async function serveSite(
|
|
|
110
112
|
return jxnAsJson(bytes, candidate);
|
|
111
113
|
}
|
|
112
114
|
if (bytes) {
|
|
113
|
-
return new Response(bytes, {
|
|
115
|
+
return new Response(isJsonPath(candidate) ? jsonForReader(bytes) : bytes, {
|
|
114
116
|
headers: siteHeaders(contentTypeFor(candidate)),
|
|
115
117
|
});
|
|
116
118
|
}
|
|
@@ -138,6 +140,49 @@ export async function serveSite(
|
|
|
138
140
|
return plainNotFound();
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/** A path naming a JSON file. */
|
|
144
|
+
const JSON_PATH = /\.json$/iu;
|
|
145
|
+
|
|
146
|
+
function isJsonPath(path: string): boolean {
|
|
147
|
+
return JSON_PATH.test(path);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The key a document's own modules are under, as a JSON file spells it. */
|
|
151
|
+
const SCRIPT_KEY = '"$script"';
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A `.json` file as a reader may have it: a document's server module left out (`spec.md` §23.2),
|
|
155
|
+
* and every other file exactly the bytes it is.
|
|
156
|
+
*
|
|
157
|
+
* A file is parsed only when its text names `$script`, so an ordinary one is never read as
|
|
158
|
+
* anything, and one that does not parse is served as it is: what is wrong with it is the author's
|
|
159
|
+
* to see, and it holds no module this could have found.
|
|
160
|
+
*
|
|
161
|
+
* @param bytes The file.
|
|
162
|
+
*/
|
|
163
|
+
function jsonForReader(bytes: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> | string {
|
|
164
|
+
const text = new TextDecoder().decode(bytes);
|
|
165
|
+
if (!text.includes(SCRIPT_KEY)) {
|
|
166
|
+
return bytes;
|
|
167
|
+
}
|
|
168
|
+
let value: JsonValue;
|
|
169
|
+
try {
|
|
170
|
+
/* SAFETY: trust boundary. The file parsed as JSON, which is all the type says; it is used
|
|
171
|
+
below only after it is seen to be an object. */
|
|
172
|
+
value = JSON.parse(text) as JsonValue;
|
|
173
|
+
} catch {
|
|
174
|
+
return bytes;
|
|
175
|
+
}
|
|
176
|
+
if (!isJsonObject(value)) {
|
|
177
|
+
return bytes;
|
|
178
|
+
}
|
|
179
|
+
/* SAFETY: every member of `JxDocument` is optional and `withoutServerScript` reads only
|
|
180
|
+
`$script`, through optional access, so any JSON object is a document as far as it looks. */
|
|
181
|
+
const document = value as JxDocument;
|
|
182
|
+
const stripped = withoutServerScript(document);
|
|
183
|
+
return stripped === document ? bytes : JSON.stringify(stripped);
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
/**
|
|
142
187
|
* Compose a route into a response, turning a compose failure into a page that says so.
|
|
143
188
|
*
|
|
@@ -150,7 +195,8 @@ export async function serveSite(
|
|
|
150
195
|
* The runtime fetches a `$ref` and calls `res.json()`, on any origin, so answering JSON here keeps
|
|
151
196
|
* a KDL parser out of every page. A file that does not read answers 422 with the reason, because
|
|
152
197
|
* the runtime reports a failed fetch by its status and this one is the author's to fix. The dev
|
|
153
|
-
* server's canvas origin answers the same way, with its own headers.
|
|
198
|
+
* server's canvas origin answers the same way, with its own headers. The document's server module
|
|
199
|
+
* is left out, as it is from a `.json` document (`spec.md` §23.2).
|
|
154
200
|
*
|
|
155
201
|
* @param bytes The file.
|
|
156
202
|
* @param path Its path, which names it in an error.
|
|
@@ -162,7 +208,7 @@ export function jxnAsJson(
|
|
|
162
208
|
headers: (type: string) => HeadersInit = siteHeaders,
|
|
163
209
|
): Response {
|
|
164
210
|
try {
|
|
165
|
-
const document = parseJxn(new TextDecoder().decode(bytes), path);
|
|
211
|
+
const document = withoutServerScript(parseJxn(new TextDecoder().decode(bytes), path));
|
|
166
212
|
return Response.json(document, { headers: headers("application/json;charset=utf-8") });
|
|
167
213
|
} catch (error) {
|
|
168
214
|
return new Response(errorMessage(error), {
|
package/src/shell.ts
CHANGED
|
@@ -47,6 +47,13 @@ export interface ShellOptions {
|
|
|
47
47
|
* host that omits this should expect a collection to render as an empty list.
|
|
48
48
|
*/
|
|
49
49
|
resolveToken?: string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* URL of the body eraser (`@jxsuite/erase/browser`, built as its own file) on this origin. With
|
|
52
|
+
* it, the page fetches the eraser only when a function body is TypeScript (`spec.md` §23.1);
|
|
53
|
+
* without it, a typed body fails to compile and says so. This package never imports the eraser,
|
|
54
|
+
* so a host that cannot run it still loads this one.
|
|
55
|
+
*/
|
|
56
|
+
eraserUrl?: string | undefined;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/** Escape a string for an HTML attribute value. */
|
|
@@ -64,16 +71,31 @@ function attr(value: string): string {
|
|
|
64
71
|
* `<` is escaped as `\u003c`, which JSON.parse restores and an HTML parser cannot mistake for the
|
|
65
72
|
* start of a tag — so no value inside the document, however it was authored, can close the block.
|
|
66
73
|
*/
|
|
67
|
-
function jsonPayload(doc: JxDocument): string {
|
|
74
|
+
function jsonPayload(doc: JxDocument | NonNullable<ComposedPage["formulas"]>): string {
|
|
68
75
|
return JSON.stringify(doc).replaceAll("<", String.raw`\u003c`);
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** The document for one composed page. */
|
|
72
79
|
export function pageShell(page: ComposedPage, options: ShellOptions): string {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const names = ["Jx"];
|
|
81
|
+
let setup = "";
|
|
82
|
+
if (options.resolveToken) {
|
|
83
|
+
names.push("setResolveToken");
|
|
84
|
+
setup += `\nsetResolveToken(${JSON.stringify(options.resolveToken)});`;
|
|
85
|
+
}
|
|
86
|
+
if (options.eraserUrl) {
|
|
87
|
+
names.push("setBodyEraserLoader");
|
|
88
|
+
setup += `\nsetBodyEraserLoader(() => import(${JSON.stringify(options.eraserUrl)}));`;
|
|
89
|
+
}
|
|
90
|
+
/* The project's formulas, for the components the page instantiates (spec.md §13.4). Set before
|
|
91
|
+
the render, in a block of their own for the reason the document has one. */
|
|
92
|
+
let formulas = "";
|
|
93
|
+
if (page.formulas) {
|
|
94
|
+
names.push("setProjectFormulas");
|
|
95
|
+
formulas = `\n<script type="application/json" id="jx-project-formulas">${jsonPayload(page.formulas)}</script>`;
|
|
96
|
+
setup += `\nsetProjectFormulas(JSON.parse(document.getElementById("jx-project-formulas").textContent));`;
|
|
97
|
+
}
|
|
98
|
+
const imports = names.join(", ");
|
|
77
99
|
const style = options.styleUrl
|
|
78
100
|
? `\n <link rel="stylesheet" href="${attr(options.styleUrl)}">`
|
|
79
101
|
: "";
|
|
@@ -91,9 +113,9 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
|
|
|
91
113
|
<script>history.scrollRestoration = "manual";</script>
|
|
92
114
|
</head>
|
|
93
115
|
<body>
|
|
94
|
-
<script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script
|
|
116
|
+
<script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script>${formulas}
|
|
95
117
|
<script type="module">
|
|
96
|
-
import { ${imports} } from "${attr(options.runtimeUrl)}";${
|
|
118
|
+
import { ${imports} } from "${attr(options.runtimeUrl)}";${setup}
|
|
97
119
|
const source = document.getElementById("jx-page-document").textContent;
|
|
98
120
|
await Jx(JSON.parse(source), document.body, { base: ${JSON.stringify(options.base)} });
|
|
99
121
|
</script>${client}
|