@jxsuite/compiler 2.0.8 → 3.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/README.md +8 -8
- package/dist/cli.js +209 -202
- package/dist/compiler.js +118 -115
- package/package.json +12 -11
- package/src/shared.ts +60 -35
- package/src/site/base-path.ts +175 -0
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +51 -1
- package/src/site/context-injection.ts +30 -128
- package/src/site/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +13 -28
- package/src/site/image-optimizer.ts +31 -1
- package/src/site/image-transform.ts +141 -7
- package/src/site/layout-resolver.ts +30 -192
- package/src/site/pages-discovery.ts +9 -49
- package/src/site/service-worker.ts +22 -5
- package/src/site/site-build.ts +152 -44
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-element.ts +51 -1
- package/src/targets/compile-server.ts +14 -7
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
|
@@ -1,210 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The build's filesystem half of layout resolution.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Layouts are JSON files in the layouts/ directory
|
|
11
|
-
* - Pages reference layouts via "$layout": "./layouts/base.json"
|
|
12
|
-
* - The page's children are distributed into the layout's <slot> elements
|
|
13
|
-
* - Named slots use attributes.slot on page children
|
|
4
|
+
* The rule — which layout a page uses, how its children land in the layout's slots, which
|
|
5
|
+
* properties the page extends onto it — is `@jxsuite/site/layout`, because the studio's canvas and
|
|
6
|
+
* the cloud's live preview have to reach the same answer and neither can import this package. What
|
|
7
|
+
* is left here is the part that genuinely needs a disk: turning `./layouts/base.json` into bytes,
|
|
8
|
+
* and saying which file was wrong when it is.
|
|
14
9
|
*/
|
|
15
10
|
|
|
16
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
12
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
18
13
|
import { resolve } from "node:path";
|
|
19
|
-
import
|
|
14
|
+
import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
|
|
15
|
+
import type { LayoutLoader } from "@jxsuite/site/layout";
|
|
16
|
+
import type { JxDocument } from "@jxsuite/schema/types";
|
|
17
|
+
|
|
18
|
+
/** A {@link LayoutLoader} over the project's own directory. */
|
|
19
|
+
export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
|
|
20
|
+
return (layoutRef: string) => {
|
|
21
|
+
const layoutPath = resolve(projectRoot, layoutRef);
|
|
22
|
+
if (!existsSync(layoutPath)) {
|
|
23
|
+
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const err = error as Error;
|
|
29
|
+
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
20
33
|
|
|
21
34
|
/**
|
|
22
|
-
* Resolve a page's layout
|
|
35
|
+
* Resolve a page's layout against a project on disk.
|
|
23
36
|
*
|
|
24
|
-
* @param {JxDocument} pageDoc - The raw page
|
|
37
|
+
* @param {JxDocument} pageDoc - The raw page document
|
|
25
38
|
* @param {Record<string, unknown>} projectConfig - Site configuration (for defaults.layout)
|
|
26
39
|
* @param {string} projectRoot - Project root directory
|
|
27
|
-
* @returns {JxDocument} The merged document (layout wrapping page content)
|
|
40
|
+
* @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
|
|
28
41
|
*/
|
|
29
42
|
export function resolveLayout(
|
|
30
43
|
pageDoc: JxDocument,
|
|
31
44
|
projectConfig: Record<string, unknown>,
|
|
32
45
|
projectRoot: string,
|
|
33
|
-
) {
|
|
34
|
-
|
|
35
|
-
const layoutRef =
|
|
36
|
-
pageDoc.$layout ?? (projectConfig.defaults as Record<string, unknown>)?.layout ?? null;
|
|
37
|
-
|
|
38
|
-
if (!layoutRef) {
|
|
39
|
-
// No layout — return page as-is
|
|
40
|
-
return pageDoc;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Load the layout file
|
|
44
|
-
const layoutPath = resolve(projectRoot, layoutRef as string);
|
|
45
|
-
if (!existsSync(layoutPath)) {
|
|
46
|
-
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @type {JxDocument} */
|
|
50
|
-
let layoutDoc;
|
|
51
|
-
try {
|
|
52
|
-
layoutDoc = parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
53
|
-
} catch (error) {
|
|
54
|
-
const err = error as Error;
|
|
55
|
-
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`, { cause: error });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Check for nested layouts (layout inheriting from another layout)
|
|
59
|
-
if (layoutDoc.$layout) {
|
|
60
|
-
layoutDoc = resolveLayout(layoutDoc, projectConfig, projectRoot);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Distribute page children into layout slots
|
|
64
|
-
const rawChildren = pageDoc.children ?? [];
|
|
65
|
-
const pageChildren =
|
|
66
|
-
typeof rawChildren === "string"
|
|
67
|
-
? [rawChildren]
|
|
68
|
-
: Array.isArray(rawChildren)
|
|
69
|
-
? rawChildren
|
|
70
|
-
: ([] as (JxElement | string)[]);
|
|
71
|
-
const merged = deepClone(layoutDoc);
|
|
72
|
-
|
|
73
|
-
distributeSlots(merged, pageChildren);
|
|
74
|
-
|
|
75
|
-
// Merge page-level properties onto the resolved document
|
|
76
|
-
// Page state extends layout state
|
|
77
|
-
if (pageDoc.state) {
|
|
78
|
-
merged.state = { ...merged.state, ...pageDoc.state };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Page $media extends layout $media
|
|
82
|
-
if (pageDoc.$media) {
|
|
83
|
-
merged.$media = { ...merged.$media, ...pageDoc.$media };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Page style extends layout style
|
|
87
|
-
if (pageDoc.style) {
|
|
88
|
-
merged.style = { ...merged.style, ...pageDoc.style };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Page attributes extend layout attributes
|
|
92
|
-
if (pageDoc.attributes) {
|
|
93
|
-
merged.attributes = { ...merged.attributes, ...pageDoc.attributes };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Preserve page-level metadata
|
|
97
|
-
if (pageDoc.$head) {
|
|
98
|
-
merged._pageHead = pageDoc.$head;
|
|
99
|
-
}
|
|
100
|
-
if (pageDoc.title) {
|
|
101
|
-
merged._pageTitle = pageDoc.title;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Remove $layout from merged doc (already resolved)
|
|
105
|
-
delete merged.$layout;
|
|
106
|
-
|
|
107
|
-
return merged;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Distribute children into <slot> elements within a layout document tree. This is the compile-time
|
|
112
|
-
* equivalent of the runtime's distributeSlots().
|
|
113
|
-
*
|
|
114
|
-
* Algorithm:
|
|
115
|
-
*
|
|
116
|
-
* 1. Find all <slot> elements in the layout tree
|
|
117
|
-
* 2. For each child with attributes.slot, distribute to the matching named slot
|
|
118
|
-
* 3. Remaining children go into the default (unnamed) slot
|
|
119
|
-
* 4. Replace each <slot> element with its distributed children
|
|
120
|
-
*
|
|
121
|
-
* @param {JxElement} node - Layout document tree (mutated in place)
|
|
122
|
-
* @param {(JxElement | string)[]} children - Page children to distribute
|
|
123
|
-
*/
|
|
124
|
-
function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
125
|
-
if (!node || typeof node !== "object") {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (!Array.isArray(node.children)) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Collect named and default children
|
|
133
|
-
const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
|
|
134
|
-
const defaults: (JxElement | string)[] = []; // Children without a slot target
|
|
135
|
-
|
|
136
|
-
for (const child of children) {
|
|
137
|
-
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
138
|
-
const slotName = child.attributes.slot as string;
|
|
139
|
-
if (!named.has(slotName)) {
|
|
140
|
-
named.set(slotName, []);
|
|
141
|
-
}
|
|
142
|
-
(named.get(slotName) as (JxElement | string)[]).push(child);
|
|
143
|
-
} else {
|
|
144
|
-
defaults.push(child);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Walk the tree and replace <slot> elements
|
|
149
|
-
fillSlots(node, named, defaults);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Recursively walk the tree and replace <slot> elements with distributed content.
|
|
154
|
-
*
|
|
155
|
-
* @param {JxElement} node
|
|
156
|
-
* @param {Map<string, (JxElement | string)[]>} named
|
|
157
|
-
* @param {(JxElement | string)[]} defaults
|
|
158
|
-
*/
|
|
159
|
-
function fillSlots(
|
|
160
|
-
node: JxElement,
|
|
161
|
-
named: Map<string, (JxElement | string)[]>,
|
|
162
|
-
defaults: (JxElement | string)[],
|
|
163
|
-
) {
|
|
164
|
-
if (!node || typeof node !== "object") {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (!Array.isArray(node.children)) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const newChildren: (JxElement | string)[] = [];
|
|
172
|
-
|
|
173
|
-
for (const child of node.children) {
|
|
174
|
-
if (child && typeof child === "object" && child.tagName === "slot") {
|
|
175
|
-
const slotName = child.attributes?.name;
|
|
176
|
-
|
|
177
|
-
if (slotName && named.has(slotName as string)) {
|
|
178
|
-
// Named slot — replace with matching children
|
|
179
|
-
newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
|
|
180
|
-
named.delete(slotName as string); // Consumed
|
|
181
|
-
} else if (!slotName && defaults.length > 0) {
|
|
182
|
-
// Default slot — replace with unassigned children
|
|
183
|
-
newChildren.push(...defaults);
|
|
184
|
-
// Don't clear defaults — only one default slot should exist,
|
|
185
|
-
// But if there are multiple, the first one wins
|
|
186
|
-
} else {
|
|
187
|
-
// No matching content — keep slot's fallback children
|
|
188
|
-
if (Array.isArray(child.children)) {
|
|
189
|
-
newChildren.push(...child.children);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
// Not a slot — recurse into it
|
|
194
|
-
fillSlots(child as JxElement, named, defaults);
|
|
195
|
-
newChildren.push(child);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
node.children = newChildren;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Deep clone a JSON-serializable object.
|
|
204
|
-
*
|
|
205
|
-
* @param {JxDocument} obj
|
|
206
|
-
* @returns {JxDocument}
|
|
207
|
-
*/
|
|
208
|
-
function deepClone(obj: JxDocument) {
|
|
209
|
-
return structuredClone(obj);
|
|
46
|
+
): Promise<JxDocument> {
|
|
47
|
+
return resolveLayoutWith(pageDoc, projectConfig, nodeLayoutLoader(projectRoot));
|
|
210
48
|
}
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
|
|
10
10
|
* pages/docs/[...path].json → /docs/* (catch-all) pages/_component.json → NOT routed (underscore
|
|
11
11
|
* prefix)
|
|
12
|
+
*
|
|
13
|
+
* The conventions themselves live in `@jxsuite/site/routes`, not here. The studio and the cloud
|
|
14
|
+
* preview both have to answer "what URL is this file" and neither can import this package — its
|
|
15
|
+
* dependency graph carries `sharp` and `esbuild` — so what remains in this file is the part that
|
|
16
|
+
* genuinely needs a filesystem: the walk, the `$layout` peek, and `$paths` expansion.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import { readFileSync, readdirSync } from "node:fs";
|
|
15
20
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
21
|
+
import { compareRoutes, fileToRoute as routeShape } from "@jxsuite/site/routes";
|
|
16
22
|
import { extname, join, relative, resolve } from "node:path";
|
|
17
23
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
24
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
@@ -176,16 +182,8 @@ export async function discoverPages(pagesDir: string, registry?: FormatRegistry)
|
|
|
176
182
|
const pageExtensions = new Set([".json", ...(registry?.documentExtensions("page") ?? [])]);
|
|
177
183
|
await walkDir(pagesDir, pagesDir, routes, pageExtensions, registry);
|
|
178
184
|
|
|
179
|
-
//
|
|
180
|
-
routes.sort(
|
|
181
|
-
if (a.isDynamic !== b.isDynamic) {
|
|
182
|
-
return a.isDynamic ? 1 : -1;
|
|
183
|
-
}
|
|
184
|
-
if (a.isCatchAll !== b.isCatchAll) {
|
|
185
|
-
return a.isCatchAll ? 1 : -1;
|
|
186
|
-
}
|
|
187
|
-
return a.urlPattern.localeCompare(b.urlPattern);
|
|
188
|
-
});
|
|
185
|
+
// Static routes first, then dynamic, then catch-all (spec §4.4) — the shared ordering.
|
|
186
|
+
routes.sort(compareRoutes);
|
|
189
187
|
|
|
190
188
|
return routes;
|
|
191
189
|
}
|
|
@@ -248,45 +246,7 @@ async function walkDir(
|
|
|
248
246
|
* @returns {Promise<Route>}
|
|
249
247
|
*/
|
|
250
248
|
async function fileToRoute(relativePath: string, absolutePath: string, registry?: FormatRegistry) {
|
|
251
|
-
|
|
252
|
-
const ext = extname(relativePath);
|
|
253
|
-
let urlPath = ext ? relativePath.slice(0, -ext.length) : relativePath;
|
|
254
|
-
|
|
255
|
-
// Normalize path separators
|
|
256
|
-
urlPath = urlPath.split("\\").join("/");
|
|
257
|
-
|
|
258
|
-
// Index files map to their parent directory
|
|
259
|
-
if (urlPath.endsWith("/index")) {
|
|
260
|
-
urlPath = urlPath.slice(0, -6) || "/";
|
|
261
|
-
} else if (urlPath === "index") {
|
|
262
|
-
urlPath = "/";
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Ensure leading slash
|
|
266
|
-
if (!urlPath.startsWith("/")) {
|
|
267
|
-
urlPath = `/${urlPath}`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Extract parameters from bracket syntax
|
|
271
|
-
const params: string[] = [];
|
|
272
|
-
let isDynamic = false;
|
|
273
|
-
let isCatchAll = false;
|
|
274
|
-
|
|
275
|
-
// Convert [param] → :param and [...param] → *
|
|
276
|
-
const urlPattern = urlPath.replaceAll(
|
|
277
|
-
/\[\.\.\.(\w+)\]|\[(\w+)\]/g,
|
|
278
|
-
(_match: string, spread: string, named: string) => {
|
|
279
|
-
if (spread) {
|
|
280
|
-
isCatchAll = true;
|
|
281
|
-
isDynamic = true;
|
|
282
|
-
params.push(spread);
|
|
283
|
-
return "*";
|
|
284
|
-
}
|
|
285
|
-
isDynamic = true;
|
|
286
|
-
params.push(named);
|
|
287
|
-
return `:${named}`;
|
|
288
|
-
},
|
|
289
|
-
);
|
|
249
|
+
const { isCatchAll, isDynamic, params, urlPattern } = routeShape(relativePath);
|
|
290
250
|
|
|
291
251
|
// Peek at the page to extract $layout if present
|
|
292
252
|
let $layout: string | null = null;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @docs framework/site/deployment
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { withBase } from "@jxsuite/schema/asset-paths";
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
import { existsSync } from "node:fs";
|
|
25
26
|
import { join } from "node:path";
|
|
@@ -100,6 +101,7 @@ function configId(config: ServiceWorkerConfig): string {
|
|
|
100
101
|
export function buildServiceWorker(
|
|
101
102
|
config: ServiceWorkerConfig,
|
|
102
103
|
outDir?: string,
|
|
104
|
+
base = "",
|
|
103
105
|
): ServiceWorkerOutput {
|
|
104
106
|
const warnings: string[] = [];
|
|
105
107
|
const errors: string[] = [];
|
|
@@ -140,15 +142,26 @@ export function buildServiceWorker(
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/*
|
|
146
|
+
* The worker runs in the browser and every URL in it is a REQUEST path, so on a site served from
|
|
147
|
+
* `/m/my-site/` the base belongs on all three: a precache entry without it fetches a 404, and
|
|
148
|
+
* `CACHE_FIRST` without it never matches, so the content-addressed images it exists to serve
|
|
149
|
+
* from cache go to the network on every view. `outputFileFor` above is deliberately checked
|
|
150
|
+
* BEFORE this — those URLs are looked up in `outDir`, which has no base.
|
|
151
|
+
*/
|
|
152
|
+
const cachedPrecache = precache.map((url) => withBase(base, url));
|
|
153
|
+
const cacheFirst = withBase(base, CACHE_FIRST_PREFIX);
|
|
154
|
+
const offlineFallback = fallback === undefined ? null : withBase(base, fallback);
|
|
155
|
+
|
|
143
156
|
const cache = `jx-${configId(config)}`;
|
|
144
157
|
const source = `/*
|
|
145
158
|
* Generated by @jxsuite/compiler — configure via \`serviceWorker\` in project.json.
|
|
146
159
|
* Set \`"serviceWorker": false\` to replace this with a worker that removes itself.
|
|
147
160
|
*/
|
|
148
161
|
const CACHE = ${JSON.stringify(cache)};
|
|
149
|
-
const PRECACHE = ${JSON.stringify(
|
|
150
|
-
const CACHE_FIRST = ${JSON.stringify(
|
|
151
|
-
const OFFLINE_FALLBACK = ${JSON.stringify(
|
|
162
|
+
const PRECACHE = ${JSON.stringify(cachedPrecache)};
|
|
163
|
+
const CACHE_FIRST = ${JSON.stringify(cacheFirst)};
|
|
164
|
+
const OFFLINE_FALLBACK = ${JSON.stringify(offlineFallback)};
|
|
152
165
|
|
|
153
166
|
self.addEventListener("install", (event) => {
|
|
154
167
|
// Take over as soon as possible: a half-updated pair of workers is harder to reason about than
|
|
@@ -285,10 +298,14 @@ self.addEventListener("activate", (event) => {
|
|
|
285
298
|
* @param {string} scope
|
|
286
299
|
* @returns {string}
|
|
287
300
|
*/
|
|
288
|
-
export function registrationScript(scope: string): string {
|
|
301
|
+
export function registrationScript(scope: string, base = ""): string {
|
|
302
|
+
/* Both are request paths. A worker's scope may not exceed its own directory, so a worker served
|
|
303
|
+
from `/m/my-site/sw.js` with a scope of `/` fails to register outright — the one failure here
|
|
304
|
+
that is loud rather than silent. */
|
|
305
|
+
const worker = withBase(base, `/${SERVICE_WORKER_PATH}`);
|
|
289
306
|
return (
|
|
290
307
|
`if('serviceWorker' in navigator){addEventListener('load',function(){` +
|
|
291
|
-
`navigator.serviceWorker.register('
|
|
308
|
+
`navigator.serviceWorker.register('${worker}',{scope:${JSON.stringify(withBase(base, scope))}})` +
|
|
292
309
|
`.catch(function(){})})}`
|
|
293
310
|
);
|
|
294
311
|
}
|