@jxsuite/site 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **What a Jx project's page at a route consists of:** routing, layout, `<head>`, context and site style, as pure functions with their IO injected.
4
4
 
5
- Every one of these rules has to be answered identically by four things that cannot share code any other way: the compiler's static build, the Studio canvas, a Bun dev/desktop server, and a Cloudflare Worker serving a live preview. None of them can import `@jxsuite/compiler`, whose dependency graph carries `sharp` and `esbuild`, so neither a browser bundle nor a Worker can load it. And a route is exactly the kind of rule where a silent disagreement is a page that 404s in one surface and renders in another.
5
+ Every one of these rules has to be answered identically by four things that cannot share code any other way: the compiler's static build, the Studio canvas, a Bun dev/desktop server, and a Cloudflare Worker serving a live preview. None of them can import `@jxsuite/compiler`, whose dependency graph carries a native addon and `esbuild`, so neither a browser bundle nor a Worker can load it. And a route is exactly the kind of rule where a silent disagreement is a page that 404s in one surface and renders in another.
6
6
 
7
7
  So the rules live here, and this package has **no platform imports at all**: no `node:`, no DOM, no filesystem. Where a rule genuinely needs to read something, the reader is a parameter (`LayoutLoader`, `DocumentParser`) and is simply absent where there are no directories.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/site",
3
- "version": "2.6.0",
3
+ "version": "2.8.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": {
@@ -34,7 +34,7 @@
34
34
  "test:coverage": "bun test --isolate --coverage"
35
35
  },
36
36
  "dependencies": {
37
- "@jxsuite/runtime": "^6.3.0",
38
- "@jxsuite/schema": "^5.6.0"
37
+ "@jxsuite/runtime": "^7.1.0",
38
+ "@jxsuite/schema": "^6.0.2"
39
39
  }
40
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 { JxDocument, JxElement, JxHeadEntry, ProjectConfig } from "@jxsuite/schema/types";
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
- /** Read and parse a page document, whatever format its extension claims. */
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
- return io.parse ? ((await io.parse(path, text)) ?? null) : null;
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
- return { dir: localeDirection(lang), doc: merged, head, lang };
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: $site.name — from project.json name $site.url — from project.json
8
- * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
9
- * title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
10
- * language and writing direction (§13.4) $page.alternates — this page in every language it exists
11
- * in (§13.5) $page.pagination — where a paged route's page sits in its list (§4.3)
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 is spread last, so a key it shares with the fields above overrides it in place.
90
- A spread, not `Object.assign`: an authored `__proto__` key stays an own data property. */
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 (page wins on conflicts)
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/routes.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  * another.
10
10
  *
11
11
  * It lives HERE rather than in `@jxsuite/compiler` for the reason `resolveI18n` does (`i18n.ts`):
12
- * the compiler's dependency graph carries `sharp` and `esbuild`, so neither a browser bundle nor a
13
- * Cloudflare Worker can import it. This module is pure string math with no imports at all, so every
14
- * host that has to know what `/blog/:slug` means can share one answer.
12
+ * the compiler's dependency graph carries a native addon and `esbuild`, so neither a browser bundle
13
+ * nor a Cloudflare Worker can import it. This module is pure string math with no imports at all, so
14
+ * every host that has to know what `/blog/:slug` means can share one answer.
15
15
  */
16
16
 
17
17
  /** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
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
@@ -71,7 +71,7 @@ function attr(value: string): string {
71
71
  * `<` is escaped as `\u003c`, which JSON.parse restores and an HTML parser cannot mistake for the
72
72
  * start of a tag — so no value inside the document, however it was authored, can close the block.
73
73
  */
74
- function jsonPayload(doc: JxDocument): string {
74
+ function jsonPayload(doc: JxDocument | NonNullable<ComposedPage["formulas"]>): string {
75
75
  return JSON.stringify(doc).replaceAll("<", String.raw`\u003c`);
76
76
  }
77
77
 
@@ -87,6 +87,14 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
87
87
  names.push("setBodyEraserLoader");
88
88
  setup += `\nsetBodyEraserLoader(() => import(${JSON.stringify(options.eraserUrl)}));`;
89
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
+ }
90
98
  const imports = names.join(", ");
91
99
  const style = options.styleUrl
92
100
  ? `\n <link rel="stylesheet" href="${attr(options.styleUrl)}">`
@@ -105,7 +113,7 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
105
113
  <script>history.scrollRestoration = "manual";</script>
106
114
  </head>
107
115
  <body>
108
- <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}
109
117
  <script type="module">
110
118
  import { ${imports} } from "${attr(options.runtimeUrl)}";${setup}
111
119
  const source = document.getElementById("jx-page-document").textContent;
package/src/site-style.ts CHANGED
@@ -29,6 +29,10 @@
29
29
  * to differ was a nested element key, on the belief that "the document's own style pass covers page
30
30
  * content": nothing else reads `project.json#/style`, so the canvas simply dropped a site's
31
31
  * typography and link rules while the build kept them (#296).
32
+ *
33
+ * A block's `$description` is the one thing only the build renders: a static build writes it as a
34
+ * comment above the rule (`{ comments: true }`, spec.md §9.2), and a host never does, because every
35
+ * consumer but a static build ignores the description.
32
36
  */
33
37
 
34
38
  import {
@@ -36,11 +40,12 @@ import {
36
40
  buildStyleRules,
37
41
  isDeclarationAtRule,
38
42
  isKeyframesAtRule,
43
+ commentedRuleText,
39
44
  isNestedSelectorKey,
40
45
  pureSchemeOf,
41
46
  } from "@jxsuite/runtime/css";
42
47
  import type { CssBuildOptions } from "@jxsuite/runtime/css";
43
- import { isNonNullObject } from "@jxsuite/schema/guards";
48
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
44
49
  import type { JxRef, JxStyle } from "@jxsuite/schema/types";
45
50
 
46
51
  /** Id of the injected site-style tag (replace-in-place, never accumulate). */
@@ -76,9 +81,29 @@ export interface SiteStyleRoots {
76
81
  body: string;
77
82
  }
78
83
 
84
+ /** What a caller may ask of the sheet beyond its rules. */
85
+ export interface SiteStyleOptions {
86
+ /**
87
+ * Write each described rule's `$description` as a comment on the line above it. The static
88
+ * build's switch: the canvas and the live preview leave it off and emit the rule text alone.
89
+ */
90
+ comments?: boolean;
91
+ }
92
+
79
93
  /** The page's own root and body. */
80
94
  export const PAGE_ROOTS: SiteStyleRoots = { body: "body", root: ":root" };
81
95
 
96
+ /**
97
+ * Hand a block's own `$description` to the first rule its split writes: the root's when it holds a
98
+ * declaration, the body's otherwise. A block that writes neither rule carries no comment.
99
+ */
100
+ function describeFirstRule(block: JxStyle, root: JxStyle, body: JxStyle): void {
101
+ const description = block["$description"];
102
+ if (isString(description)) {
103
+ (Object.keys(root).length > 0 ? root : body)["$description"] = description;
104
+ }
105
+ }
106
+
82
107
  /**
83
108
  * Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
84
109
  * selector-keyed blocks (`"h1, h2"`, `a`, `.card`) as rules of their own, `&`-keyed blocks as
@@ -104,6 +129,7 @@ export const PAGE_ROOTS: SiteStyleRoots = { body: "body", root: ":root" };
104
129
  * is dropped, which is what every host does
105
130
  * @param {SiteStyleRoots} [roots] - The selectors that stand for the page's root and body; the
106
131
  * page's own `:root` and `body` unless the sheet styles a page drawn inside something else
132
+ * @param {SiteStyleOptions} [options] - `comments: true` is the static build's form
107
133
  * @returns {string}
108
134
  * @docs studio/interface/canvas
109
135
  */
@@ -113,19 +139,20 @@ export function buildSiteStyleCSS(
113
139
  transpose: (value: string) => string,
114
140
  resolveValue?: SiteStyleValueResolver,
115
141
  roots: SiteStyleRoots = PAGE_ROOTS,
142
+ options: SiteStyleOptions = {},
116
143
  ): string {
117
144
  const rules: string[] = [];
118
145
  const push = (style: JxStyle, selector: string | null) => {
119
- const options: CssBuildOptions = { mediaQueries, scope: selector, transposeValue: transpose };
146
+ const build: CssBuildOptions = { mediaQueries, scope: selector, transposeValue: transpose };
120
147
  /* The selector is closed over here rather than threaded through `buildStyleRules`, whose own
121
148
  hook sees a rule TARGET (self / descendant / unscoped) and not the selector it hangs off;
122
149
  the compiler's report names the selector, so this is where it is known. Set only when given:
123
150
  `exactOptionalPropertyTypes` refuses an explicit `undefined` for an optional hook. */
124
151
  if (resolveValue) {
125
- options.resolveValue = (property, value) => resolveValue(property, value, selector);
152
+ build.resolveValue = (property, value) => resolveValue(property, value, selector);
126
153
  }
127
- for (const rule of buildStyleRules(style, options)) {
128
- rules.push(rule.text);
154
+ for (const rule of buildStyleRules(style, build)) {
155
+ rules.push(options.comments === true ? commentedRuleText(rule) : rule.text);
129
156
  }
130
157
  };
131
158
 
@@ -157,7 +184,8 @@ export function buildSiteStyleCSS(
157
184
  }
158
185
  continue;
159
186
  }
160
- if (isNestedSelectorKey(key) || key.startsWith("@")) {
187
+ // Metadata is no declaration; the block's own `$description` is placed after the split.
188
+ if (isNestedSelectorKey(key) || key.startsWith("@") || key.startsWith("$")) {
161
189
  continue;
162
190
  }
163
191
  /* Custom properties go to `:root`; so does `color-scheme`, which is the one non-custom
@@ -172,6 +200,8 @@ export function buildSiteStyleCSS(
172
200
  }
173
201
  }
174
202
 
203
+ describeFirstRule(siteStyle, rootProps, bodyProps);
204
+
175
205
  // Base rules precede conditional blocks so equal-specificity overrides win by source order.
176
206
  push(rootProps, roots.root);
177
207
  push(bodyProps, roots.body);
@@ -206,12 +236,16 @@ export function buildSiteStyleCSS(
206
236
  }
207
237
  continue;
208
238
  }
239
+ if (k.startsWith("$")) {
240
+ continue;
241
+ }
209
242
  if (k.startsWith("--")) {
210
243
  condRoot[k] = v;
211
244
  } else {
212
245
  condBody[k] = v;
213
246
  }
214
247
  }
248
+ describeFirstRule(block, condRoot, condBody);
215
249
  push({ [atKey]: condRoot }, roots.root);
216
250
  push({ [atKey]: condBody }, roots.body);
217
251
  for (const [sel, sub] of condSubs) {