@jxsuite/compiler 0.35.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +191 -164
- package/dist/compiler.js +121 -114
- package/package.json +11 -7
- package/src/cli.ts +132 -9
- package/src/shared.ts +53 -4
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/dev-command.ts +84 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/head-merger.ts +13 -0
- package/src/site/image-cache.ts +57 -6
- package/src/site/image-transform.ts +21 -0
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/preview-server.ts +93 -0
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +178 -28
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-client.ts +36 -5
- package/src/targets/compile-element.ts +41 -4
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
package/src/site/format-host.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Format-host — node-side FormatHostIO and project registry construction
|
|
3
3
|
*
|
|
4
|
-
* Provides the filesystem/module I/O the shared
|
|
5
|
-
* (and dev server).
|
|
6
|
-
* specs/extensions.md.
|
|
4
|
+
* Provides the filesystem/module I/O the shared extension registry needs to run inside the compiler
|
|
5
|
+
* (and dev server). Extensions are declared explicitly in project.json's `extensions` array and
|
|
6
|
+
* discovered through their jx-extension.json manifests; see specs/extensions.md.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
13
|
+
import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
14
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
15
|
+
import type { FormatHostIO, FormatRegistry, ProjectBlock } from "@jxsuite/schema/format-registry";
|
|
15
16
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
16
17
|
|
|
18
|
+
export type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
17
19
|
export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -73,23 +75,136 @@ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
|
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/** Build the
|
|
77
|
-
export async function
|
|
78
|
+
/** Build the extension registry for a project from its project.json `extensions` array. */
|
|
79
|
+
export async function buildProjectExtensionRegistry(
|
|
78
80
|
projectRoot: string,
|
|
79
81
|
projectConfig?: ProjectConfig,
|
|
80
|
-
): Promise<
|
|
81
|
-
return
|
|
82
|
-
projectConfig?.
|
|
82
|
+
): Promise<ExtensionRegistry> {
|
|
83
|
+
return buildExtensionRegistry(
|
|
84
|
+
projectConfig?.extensions,
|
|
83
85
|
createNodeFormatIO(projectRoot),
|
|
84
86
|
resolve(projectRoot, "project.json"),
|
|
85
87
|
);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Format-dispatch view of the project's extension registry.
|
|
92
|
+
*
|
|
93
|
+
* @deprecated Part-3 cleanup: the desktop app still imports this; use
|
|
94
|
+
* `buildProjectExtensionRegistry(...).then((r) => r.formats)` instead.
|
|
95
|
+
*/
|
|
96
|
+
export async function buildProjectFormatRegistry(
|
|
97
|
+
projectRoot: string,
|
|
98
|
+
projectConfig?: ProjectConfig,
|
|
99
|
+
): Promise<FormatRegistry> {
|
|
100
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
|
|
101
|
+
return registry.formats;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Studio extensions payload ────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** One project-section contribution on the studio wire (specs/extensions.md §9/§9.1). */
|
|
107
|
+
export interface ExtensionsPayloadContribution {
|
|
108
|
+
/** The $prototype-visible class name declaring the `project` block. */
|
|
109
|
+
className: string;
|
|
110
|
+
/** The class descriptor's `project` admission block. */
|
|
111
|
+
project: ProjectBlock;
|
|
112
|
+
/** The class descriptor's `$studio` block (settings section vocabulary), when declared. */
|
|
113
|
+
studio: Record<string, unknown> | null;
|
|
114
|
+
/**
|
|
115
|
+
* The section's value schema — `properties[<key>]` of the extension's project fragment — or null
|
|
116
|
+
* when the extension ships no project fragment (or it lacks the key).
|
|
117
|
+
*/
|
|
118
|
+
entrySchema: Record<string, unknown> | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One extension package on the studio wire (the formats route's `extensions` sibling array). */
|
|
122
|
+
export interface ExtensionsPayloadEntry {
|
|
123
|
+
specifier: string;
|
|
124
|
+
name: string;
|
|
125
|
+
title?: string;
|
|
126
|
+
description?: string;
|
|
127
|
+
contributions: ExtensionsPayloadContribution[];
|
|
128
|
+
/**
|
|
129
|
+
* Every manifest class of the extension with its resolved descriptor path, in declaration order
|
|
130
|
+
* (additive; lets the studio address a class by `$src` without hardcoding its package layout).
|
|
131
|
+
* Plain state classes (no admission blocks — `$prototype` targets like TableQuery or Session)
|
|
132
|
+
* carry `state: true` plus their `$studio.stateDefaults` hint (specs/extensions.md §10), so the
|
|
133
|
+
* studio can offer them in its add-state picker and seed new defs (e.g. `timing: "client"`).
|
|
134
|
+
*/
|
|
135
|
+
classes: {
|
|
136
|
+
name: string;
|
|
137
|
+
path: string;
|
|
138
|
+
state?: boolean;
|
|
139
|
+
stateDefaults?: Record<string, unknown>;
|
|
140
|
+
}[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the studio-facing extensions payload from a project's extension registry: per extension,
|
|
145
|
+
* its manifest identity plus every project-section contribution paired with the entry schema read
|
|
146
|
+
* from the extension's shipped project fragment. Unreadable fragments degrade to a null entrySchema
|
|
147
|
+
* (the studio renders the section without field metadata).
|
|
148
|
+
*
|
|
149
|
+
* @param {ExtensionRegistry} registry
|
|
150
|
+
* @returns {ExtensionsPayloadEntry[]}
|
|
151
|
+
*/
|
|
152
|
+
export function buildExtensionsPayload(registry: ExtensionRegistry): ExtensionsPayloadEntry[] {
|
|
153
|
+
const payload: ExtensionsPayloadEntry[] = [];
|
|
154
|
+
for (const ext of registry.extensions) {
|
|
155
|
+
let fragmentProperties: Record<string, Record<string, unknown>> = {};
|
|
156
|
+
if (ext.schemas.project) {
|
|
157
|
+
try {
|
|
158
|
+
const fragment = JSON.parse(readFileSync(ext.schemas.project, "utf8")) as {
|
|
159
|
+
properties?: Record<string, Record<string, unknown>>;
|
|
160
|
+
};
|
|
161
|
+
fragmentProperties = fragment.properties ?? {};
|
|
162
|
+
} catch {
|
|
163
|
+
fragmentProperties = {};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const contributions: ExtensionsPayloadContribution[] = [];
|
|
167
|
+
for (const cls of ext.classes) {
|
|
168
|
+
if (!cls.project) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
contributions.push({
|
|
172
|
+
className: cls.name,
|
|
173
|
+
entrySchema: fragmentProperties[cls.project.key] ?? null,
|
|
174
|
+
project: cls.project,
|
|
175
|
+
studio: cls.studio,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
payload.push({
|
|
179
|
+
classes: ext.classes.map((cls) => {
|
|
180
|
+
const stateDefaults = cls.studio?.stateDefaults as Record<string, unknown> | undefined;
|
|
181
|
+
const isState =
|
|
182
|
+
cls.project === null &&
|
|
183
|
+
cls.server === null &&
|
|
184
|
+
cls.connector === null &&
|
|
185
|
+
cls.extensions.length === 0;
|
|
186
|
+
return {
|
|
187
|
+
name: cls.name,
|
|
188
|
+
path: cls.classPath,
|
|
189
|
+
...(isState ? { state: true } : {}),
|
|
190
|
+
...(stateDefaults && typeof stateDefaults === "object" ? { stateDefaults } : {}),
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
contributions,
|
|
194
|
+
name: ext.manifest.name,
|
|
195
|
+
specifier: ext.specifier,
|
|
196
|
+
...(ext.manifest.title === undefined ? {} : { title: ext.manifest.title }),
|
|
197
|
+
...(ext.manifest.description === undefined ? {} : { description: ext.manifest.description }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return payload;
|
|
201
|
+
}
|
|
202
|
+
|
|
88
203
|
/** Error message for an unregistered non-JSON extension, naming the fix. */
|
|
89
204
|
export function unknownFormatError(path: string, ext: string): Error {
|
|
90
205
|
return new Error(
|
|
91
|
-
`No format class
|
|
92
|
-
`
|
|
93
|
-
`
|
|
206
|
+
`No format class registered for "${ext}" (${path}). ` +
|
|
207
|
+
`Enable an extension providing this format in project.json "extensions", ` +
|
|
208
|
+
`e.g. "@jxsuite/parser".`,
|
|
94
209
|
);
|
|
95
210
|
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -63,6 +63,19 @@ export function mergeHead(
|
|
|
63
63
|
attributes: { href: canonical, rel: "canonical" },
|
|
64
64
|
tagName: "link",
|
|
65
65
|
});
|
|
66
|
+
// Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
|
|
67
|
+
if (!merged.has("meta:og:url")) {
|
|
68
|
+
merged.set("meta:og:url", {
|
|
69
|
+
attributes: { content: canonical, property: "og:url" },
|
|
70
|
+
tagName: "meta",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (context.siteName && !merged.has("meta:og:site_name")) {
|
|
75
|
+
merged.set("meta:og:site_name", {
|
|
76
|
+
attributes: { content: context.siteName, property: "og:site_name" },
|
|
77
|
+
tagName: "meta",
|
|
78
|
+
});
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
return [...merged.values()];
|
package/src/site/image-cache.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
@@ -23,6 +23,7 @@ interface CacheEntry {
|
|
|
23
23
|
export interface CacheManifest {
|
|
24
24
|
version: number; // Cache format version
|
|
25
25
|
entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
|
|
26
|
+
touched?: Set<string>; // Keys resolved during this build — runtime only, never serialized
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// ─── Cache directory resolution ──────────────────────────────────────────────
|
|
@@ -97,25 +98,73 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
|
|
|
97
98
|
export function loadCache(projectRoot: string): CacheManifest {
|
|
98
99
|
const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
|
|
99
100
|
if (!existsSync(manifestPath)) {
|
|
100
|
-
return { entries: {}, version: 1 };
|
|
101
|
+
return { entries: {}, touched: new Set(), version: 1 };
|
|
101
102
|
}
|
|
102
103
|
try {
|
|
103
|
-
|
|
104
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
|
|
105
|
+
parsed.touched = new Set();
|
|
106
|
+
return parsed;
|
|
104
107
|
} catch {
|
|
105
|
-
return { entries: {}, version: 1 };
|
|
108
|
+
return { entries: {}, touched: new Set(), version: 1 };
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
113
|
* Save the cache manifest to disk.
|
|
111
114
|
*
|
|
115
|
+
* With `prune: true`, entries whose key was never touched during this build (deleted/replaced
|
|
116
|
+
* sources, superseded configs) are dropped and their variant files deleted first, so a persisted
|
|
117
|
+
* cache — and the dist copy made from it — stays bounded to images the build actually uses.
|
|
118
|
+
*
|
|
112
119
|
* @param {string} projectRoot
|
|
113
120
|
* @param {CacheManifest} cache
|
|
121
|
+
* @param {{ prune?: boolean }} [options]
|
|
114
122
|
*/
|
|
115
|
-
export function saveCache(
|
|
123
|
+
export function saveCache(
|
|
124
|
+
projectRoot: string,
|
|
125
|
+
cache: CacheManifest,
|
|
126
|
+
options: { prune?: boolean } = {},
|
|
127
|
+
) {
|
|
128
|
+
if (options.prune && cache.touched) {
|
|
129
|
+
pruneStale(cache, cache.touched);
|
|
130
|
+
}
|
|
116
131
|
const cacheDir = getImageCacheDir(projectRoot);
|
|
117
132
|
mkdirSync(cacheDir, { recursive: true });
|
|
118
|
-
writeFileSync(
|
|
133
|
+
writeFileSync(
|
|
134
|
+
resolve(cacheDir, "manifest.json"),
|
|
135
|
+
JSON.stringify({ entries: cache.entries, version: cache.version }, null, 2),
|
|
136
|
+
"utf8",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Remove entries (and their variant files) that were not touched during this build. A file shared
|
|
142
|
+
* with a surviving entry is kept — the same source under an older config produces overlapping
|
|
143
|
+
* width/format filenames, so deleting blindly would remove files a live entry still references.
|
|
144
|
+
*
|
|
145
|
+
* @param {CacheManifest} cache
|
|
146
|
+
* @param {Set<string>} touched
|
|
147
|
+
*/
|
|
148
|
+
function pruneStale(cache: CacheManifest, touched: Set<string>) {
|
|
149
|
+
const keep = new Set<string>();
|
|
150
|
+
for (const [key, entry] of Object.entries(cache.entries)) {
|
|
151
|
+
if (touched.has(key)) {
|
|
152
|
+
for (const variant of entry.manifest.variants ?? []) {
|
|
153
|
+
keep.add(variant.absolutePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [key, entry] of Object.entries(cache.entries)) {
|
|
158
|
+
if (touched.has(key)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const variant of entry.manifest.variants ?? []) {
|
|
162
|
+
if (!keep.has(variant.absolutePath)) {
|
|
163
|
+
rmSync(variant.absolutePath, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
delete cache.entries[key];
|
|
167
|
+
}
|
|
119
168
|
}
|
|
120
169
|
|
|
121
170
|
// ─── Entry access ─────────────────────────────────────────────────────────────
|
|
@@ -128,6 +177,7 @@ export function saveCache(projectRoot: string, cache: CacheManifest) {
|
|
|
128
177
|
* @returns {ImageManifest | null}
|
|
129
178
|
*/
|
|
130
179
|
export function getCached(cache: CacheManifest, key: string) {
|
|
180
|
+
cache.touched?.add(key);
|
|
131
181
|
const entry = cache.entries[key];
|
|
132
182
|
if (!entry) {
|
|
133
183
|
return null;
|
|
@@ -149,6 +199,7 @@ export function setCached(
|
|
|
149
199
|
sourcePath: string,
|
|
150
200
|
manifest: ImageManifest,
|
|
151
201
|
) {
|
|
202
|
+
cache.touched?.add(key);
|
|
152
203
|
cache.entries[key] = {
|
|
153
204
|
manifest,
|
|
154
205
|
source: sourcePath,
|
|
@@ -318,6 +318,7 @@ async function transformImgNode(
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
let srcset;
|
|
321
|
+
let dims: { width: number; height: number } | null = null;
|
|
321
322
|
|
|
322
323
|
if (remote) {
|
|
323
324
|
// Original dimensions are unknown without fetching — emit every configured width and let
|
|
@@ -332,6 +333,7 @@ async function transformImgNode(
|
|
|
332
333
|
if (config.service === "cloudflare") {
|
|
333
334
|
const meta = await resolveCfMeta(absoluteSrc, metaCache);
|
|
334
335
|
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
336
|
+
dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
|
|
335
337
|
} else {
|
|
336
338
|
let manifest = imageRefs.get(absoluteSrc);
|
|
337
339
|
|
|
@@ -342,6 +344,10 @@ async function transformImgNode(
|
|
|
342
344
|
|
|
343
345
|
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
|
|
344
346
|
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
347
|
+
dims =
|
|
348
|
+
manifest.original?.width && manifest.original.height
|
|
349
|
+
? { height: manifest.original.height, width: manifest.original.width }
|
|
350
|
+
: null;
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
353
|
|
|
@@ -350,6 +356,12 @@ async function transformImgNode(
|
|
|
350
356
|
node.attributes.sizes ??= config.sizes;
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
// Intrinsic dimensions prevent layout shift; author-supplied values win.
|
|
360
|
+
if (dims && node.attributes.width === undefined && node.attributes.height === undefined) {
|
|
361
|
+
node.attributes.width = String(dims.width);
|
|
362
|
+
node.attributes.height = String(dims.height);
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
if (config.lazyLoad && node.attributes.loading !== "eager") {
|
|
354
366
|
node.attributes.loading = "lazy";
|
|
355
367
|
node.attributes.decoding = "async";
|
|
@@ -412,12 +424,14 @@ async function transformInnerHtmlImages(
|
|
|
412
424
|
}
|
|
413
425
|
|
|
414
426
|
let srcset;
|
|
427
|
+
let dims: { width: number; height: number } | null = null;
|
|
415
428
|
|
|
416
429
|
if (remote) {
|
|
417
430
|
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
418
431
|
} else if (config.service === "cloudflare") {
|
|
419
432
|
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
420
433
|
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
434
|
+
dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
|
|
421
435
|
} else {
|
|
422
436
|
let manifest = imageRefs.get(absoluteSrc as string);
|
|
423
437
|
if (!manifest) {
|
|
@@ -427,12 +441,19 @@ async function transformInnerHtmlImages(
|
|
|
427
441
|
|
|
428
442
|
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
|
|
429
443
|
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
444
|
+
dims =
|
|
445
|
+
manifest.original?.width && manifest.original.height
|
|
446
|
+
? { height: manifest.original.height, width: manifest.original.width }
|
|
447
|
+
: null;
|
|
430
448
|
}
|
|
431
449
|
if (!srcset) {
|
|
432
450
|
continue;
|
|
433
451
|
}
|
|
434
452
|
|
|
435
453
|
let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
|
|
454
|
+
if (dims && !/\bwidth=/.test(attrs) && !/\bheight=/.test(attrs)) {
|
|
455
|
+
extra += ` width="${dims.width}" height="${dims.height}"`;
|
|
456
|
+
}
|
|
436
457
|
if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
|
|
437
458
|
if (!/\bloading=/.test(attrs)) {
|
|
438
459
|
extra += ` loading="lazy"`;
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Scans the pages/ directory and builds a route table mapping URL paths to their source files,
|
|
5
5
|
* layouts, and metadata. `.json` pages are native; any other extension must be claimed by a format
|
|
6
|
-
* class (with the `parse` capability and a "page" document kind)
|
|
7
|
-
* imports map.
|
|
6
|
+
* class (with the `parse` capability and a "page" document kind) provided by an enabled extension.
|
|
8
7
|
*
|
|
9
8
|
* Conventions (per site-architecture spec §4): pages/index.json → / pages/about.json → /about
|
|
10
9
|
* pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
|
|
@@ -15,9 +14,9 @@
|
|
|
15
14
|
import { readFileSync, readdirSync } from "node:fs";
|
|
16
15
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
17
16
|
import { extname, join, relative, resolve } from "node:path";
|
|
17
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
18
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
19
|
-
import type { JxDocument, JxPathsDef } from "@jxsuite/schema/types";
|
|
20
|
-
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
19
|
+
import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
21
20
|
|
|
22
21
|
interface Route {
|
|
23
22
|
urlPattern: string; // URL pattern (e.g. "/blog/:slug")
|
|
@@ -50,9 +49,9 @@ export async function readPageDocument(
|
|
|
50
49
|
const entry = registry?.byExtension(ext, "parse");
|
|
51
50
|
if (!entry) {
|
|
52
51
|
throw new Error(
|
|
53
|
-
`No format class
|
|
54
|
-
`
|
|
55
|
-
`
|
|
52
|
+
`No format class registered for "${ext}" (${filePath}). ` +
|
|
53
|
+
`Enable an extension providing this format in project.json "extensions", ` +
|
|
54
|
+
`e.g. "@jxsuite/parser".`,
|
|
56
55
|
);
|
|
57
56
|
}
|
|
58
57
|
return (await entry.call("parse", source)) as JxDocument;
|
|
@@ -208,23 +207,25 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
|
|
|
208
207
|
/**
|
|
209
208
|
* Expand dynamic routes by resolving $paths from each dynamic page.
|
|
210
209
|
*
|
|
211
|
-
* Supports
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
210
|
+
* Supports these $paths shapes (per spec §4.3): 1. Explicit values: { values: ["en", "fr"], param:
|
|
211
|
+
* "lang" } 2. Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 3.
|
|
212
|
+
* Legacy array: [{ slug: "hello" }, { slug: "world" }] 4. Extension-discriminated: any object
|
|
213
|
+
* carrying a key an enabled extension registered as its `resolvePaths` discriminator (e.g. the
|
|
214
|
+
* parser's { contentType: "blog", param: "slug" }).
|
|
215
215
|
*
|
|
216
216
|
* @param {Route[]} routes - Discovered route table
|
|
217
217
|
* @param {string} projectRoot - Project root for resolving $ref paths
|
|
218
|
-
* @param {
|
|
219
|
-
*
|
|
220
|
-
* @param {
|
|
218
|
+
* @param {Record<string, unknown>} [sections] - Loaded project sections (projectData results)
|
|
219
|
+
* @param {ExtensionRegistry} [registry] - Extension registry (formats view reads non-JSON pages)
|
|
220
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
221
221
|
* @returns {Promise<Route[]>} Expanded routes with concrete paths
|
|
222
222
|
*/
|
|
223
223
|
export async function expandDynamicRoutes(
|
|
224
224
|
routes: Route[],
|
|
225
225
|
projectRoot: string,
|
|
226
|
-
|
|
227
|
-
registry?:
|
|
226
|
+
sections: Record<string, unknown> = {},
|
|
227
|
+
registry?: ExtensionRegistry,
|
|
228
|
+
projectConfig?: ProjectConfig,
|
|
228
229
|
) {
|
|
229
230
|
const expanded: Route[] = [];
|
|
230
231
|
|
|
@@ -238,7 +239,7 @@ export async function expandDynamicRoutes(
|
|
|
238
239
|
/** @type {Record<string, unknown>} */
|
|
239
240
|
let raw;
|
|
240
241
|
try {
|
|
241
|
-
raw = await readPageDocument(route.sourcePath, registry);
|
|
242
|
+
raw = await readPageDocument(route.sourcePath, registry?.formats);
|
|
242
243
|
} catch {
|
|
243
244
|
expanded.push(route);
|
|
244
245
|
continue;
|
|
@@ -249,7 +250,13 @@ export async function expandDynamicRoutes(
|
|
|
249
250
|
continue;
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
const pathEntries = resolvePathEntries(
|
|
253
|
+
const pathEntries = await resolvePathEntries(
|
|
254
|
+
raw.$paths,
|
|
255
|
+
projectRoot,
|
|
256
|
+
sections,
|
|
257
|
+
registry,
|
|
258
|
+
projectConfig,
|
|
259
|
+
);
|
|
253
260
|
|
|
254
261
|
for (const pathEntry of pathEntries) {
|
|
255
262
|
let concreteUrl = route.urlPattern;
|
|
@@ -277,67 +284,72 @@ export async function expandDynamicRoutes(
|
|
|
277
284
|
/**
|
|
278
285
|
* Resolve $paths into an array of param objects.
|
|
279
286
|
*
|
|
287
|
+
* Core shapes (array / `values` / `$ref`) are handled inline; any other object dispatches through
|
|
288
|
+
* the extension registry — the first key with a registered `resolvePaths` discriminator routes the
|
|
289
|
+
* whole $paths value to the owning class, with its section's loaded data as context.
|
|
290
|
+
*
|
|
280
291
|
* @param {import("@jxsuite/schema/types").JxPathsDef} $paths - The $paths declaration
|
|
281
292
|
* @param {string} projectRoot
|
|
282
|
-
* @param {
|
|
283
|
-
* @
|
|
293
|
+
* @param {Record<string, unknown>} sections - Loaded project sections keyed by section key
|
|
294
|
+
* @param {ExtensionRegistry} [registry]
|
|
295
|
+
* @param {ProjectConfig} [projectConfig]
|
|
296
|
+
* @returns {Promise<Record<string, unknown>[]>} Array of { paramName: value } objects
|
|
284
297
|
*/
|
|
285
|
-
function resolvePathEntries(
|
|
298
|
+
async function resolvePathEntries(
|
|
286
299
|
$paths: JxPathsDef,
|
|
287
300
|
projectRoot: string,
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
sections: Record<string, unknown>,
|
|
302
|
+
registry?: ExtensionRegistry,
|
|
303
|
+
projectConfig?: ProjectConfig,
|
|
304
|
+
): Promise<Record<string, unknown>[]> {
|
|
290
305
|
// Legacy: array of param objects
|
|
291
306
|
if (Array.isArray($paths)) {
|
|
292
307
|
return $paths;
|
|
293
308
|
}
|
|
294
309
|
|
|
295
|
-
// Content type-based: { contentType: "blog", param: "slug", field: "id" }
|
|
296
|
-
if ("contentType" in $paths && $paths.contentType) {
|
|
297
|
-
const entries = contentTypes.get($paths.contentType);
|
|
298
|
-
if (!entries || entries.length === 0) {
|
|
299
|
-
console.warn(
|
|
300
|
-
`Warning: $paths references content type "${$paths.contentType}" but it has no entries`,
|
|
301
|
-
);
|
|
302
|
-
return [];
|
|
303
|
-
}
|
|
304
|
-
const param = $paths.param ?? "slug";
|
|
305
|
-
const field = $paths.field ?? "id";
|
|
306
|
-
return entries
|
|
307
|
-
.map((entry: ContentLoaderEntry) => ({
|
|
308
|
-
[param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
|
|
309
|
-
}))
|
|
310
|
-
.filter((p: Record<string, unknown>) => p[param]);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
310
|
// Explicit values: { values: ["en", "fr"], param: "lang" }
|
|
314
311
|
if ("values" in $paths && Array.isArray($paths.values)) {
|
|
315
|
-
const param = $paths.param
|
|
316
|
-
return $paths.values.map((v) => ({ [param]: v }));
|
|
312
|
+
const param = typeof $paths.param === "string" ? $paths.param : "value";
|
|
313
|
+
return ($paths.values as unknown[]).map((v) => ({ [param]: v }));
|
|
317
314
|
}
|
|
318
315
|
|
|
319
316
|
// Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
|
|
320
|
-
if ("$ref" in $paths && $paths.$ref) {
|
|
321
|
-
const
|
|
317
|
+
if ("$ref" in $paths && typeof $paths.$ref === "string" && $paths.$ref) {
|
|
318
|
+
const refPath = $paths.$ref;
|
|
319
|
+
const filePath = resolve(projectRoot, refPath);
|
|
322
320
|
let data: unknown;
|
|
323
321
|
try {
|
|
324
322
|
data = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
325
323
|
} catch (error) {
|
|
326
324
|
const err = error as Error;
|
|
327
|
-
console.warn(`Warning: $paths.$ref could not load "${
|
|
325
|
+
console.warn(`Warning: $paths.$ref could not load "${refPath}": ${err.message}`);
|
|
328
326
|
return [];
|
|
329
327
|
}
|
|
330
328
|
if (!Array.isArray(data)) {
|
|
331
|
-
console.warn(`Warning: $paths.$ref "${
|
|
329
|
+
console.warn(`Warning: $paths.$ref "${refPath}" must be a JSON array`);
|
|
332
330
|
return [];
|
|
333
331
|
}
|
|
334
|
-
const param = $paths.param
|
|
335
|
-
const field = $paths.field
|
|
332
|
+
const param = typeof $paths.param === "string" ? $paths.param : "id";
|
|
333
|
+
const field = typeof $paths.field === "string" ? $paths.field : "id";
|
|
336
334
|
return (data as Record<string, unknown>[]).map((item: Record<string, unknown>) => ({
|
|
337
335
|
[param]: item[field] ?? item.id ?? String(item),
|
|
338
336
|
}));
|
|
339
337
|
}
|
|
340
338
|
|
|
339
|
+
// Extension-discriminated: the first key with a registered resolvePaths discriminator routes
|
|
340
|
+
// The whole value to the owning class (specs/extensions.md §8).
|
|
341
|
+
for (const key of Object.keys($paths)) {
|
|
342
|
+
const entry = registry?.byPathsDiscriminator(key);
|
|
343
|
+
if (!entry?.project) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
return (await entry.call("resolvePaths", $paths, {
|
|
347
|
+
data: sections[entry.project.key],
|
|
348
|
+
projectConfig,
|
|
349
|
+
root: projectRoot,
|
|
350
|
+
})) as Record<string, unknown>[];
|
|
351
|
+
}
|
|
352
|
+
|
|
341
353
|
console.warn(`Warning: unrecognized $paths shape — skipping`);
|
|
342
354
|
return [];
|
|
343
355
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview server — `jx preview` serves an already-built dist/ directory.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free node:http static server, so the Node-run `jx` bin needs neither Bun nor the
|
|
5
|
+
* dev-server package. Maps directory URLs to index.html (the build's trailingSlash output) and
|
|
6
|
+
* falls back to <dist>/404.html when present.
|
|
7
|
+
*
|
|
8
|
+
* @module preview-server
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import type { Server } from "node:http";
|
|
13
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
const MIME_TYPES: Record<string, string> = {
|
|
17
|
+
".avif": "image/avif",
|
|
18
|
+
".css": "text/css; charset=utf-8",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".js": "text/javascript; charset=utf-8",
|
|
25
|
+
".json": "application/json; charset=utf-8",
|
|
26
|
+
".md": "text/markdown; charset=utf-8",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
".txt": "text/plain; charset=utf-8",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".woff2": "font/woff2",
|
|
32
|
+
".xml": "application/xml; charset=utf-8",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a request pathname to a file inside distDir, or null. Directory URLs (with or without a
|
|
37
|
+
* trailing slash) map to their index.html; traversal outside distDir is rejected.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} distDir - Absolute dist directory
|
|
40
|
+
* @param {string} pathname - Decoded URL pathname
|
|
41
|
+
* @returns {string | null} Absolute file path to serve, or null
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePreviewFile(distDir: string, pathname: string): string | null {
|
|
44
|
+
const safe = normalize(pathname).replaceAll("\\", "/");
|
|
45
|
+
const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
|
|
46
|
+
if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (existsSync(candidate)) {
|
|
50
|
+
if (statSync(candidate).isDirectory()) {
|
|
51
|
+
const index = join(candidate, "index.html");
|
|
52
|
+
return existsSync(index) ? index : null;
|
|
53
|
+
}
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
// Extensionless route without trailing slash → its directory index
|
|
57
|
+
const index = join(candidate, "index.html");
|
|
58
|
+
return existsSync(index) ? index : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start the preview server on the given port (0 picks a free port).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} distDir - Absolute path to the built dist directory
|
|
65
|
+
* @param {number} port - Port to listen on
|
|
66
|
+
* @returns {Server} The listening node:http server
|
|
67
|
+
*/
|
|
68
|
+
export function startPreviewServer(distDir: string, port: number): Server {
|
|
69
|
+
const dist = resolve(distDir);
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
72
|
+
const filePath = resolvePreviewFile(dist, decodeURIComponent(url.pathname));
|
|
73
|
+
|
|
74
|
+
if (filePath) {
|
|
75
|
+
res.writeHead(200, {
|
|
76
|
+
"Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream",
|
|
77
|
+
});
|
|
78
|
+
res.end(readFileSync(filePath));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const notFound = join(dist, "404.html");
|
|
83
|
+
if (existsSync(notFound)) {
|
|
84
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
85
|
+
res.end(readFileSync(notFound));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
89
|
+
res.end("Not found");
|
|
90
|
+
});
|
|
91
|
+
server.listen(port);
|
|
92
|
+
return server;
|
|
93
|
+
}
|