@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.
@@ -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 format registry needs to run inside the compiler
5
- * (and dev server). Format classes are auto-discovered from the project-level imports map; see
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 { buildFormatRegistry } from "@jxsuite/schema/format-registry";
14
- import type { FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
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 format registry for a project from its project.json imports map. */
77
- export async function buildProjectFormatRegistry(
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<FormatRegistry> {
81
- return buildFormatRegistry(
82
- projectConfig?.imports as Record<string, string> | undefined,
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 imported for "${ext}" (${path}). ` +
92
- `Add a format class to project.json imports, e.g. ` +
93
- `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
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
  }
@@ -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()];
@@ -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
- return JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
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(projectRoot: string, cache: CacheManifest) {
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(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
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) registered from the project's
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 imported for "${ext}" (${filePath}). ` +
54
- `Add a format class to project.json imports, e.g. ` +
55
- `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
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 three $paths shapes (per spec §4.3): 1. Content type-based: { contentType: "blog",
212
- * param: "slug", field: "id" } 2. Explicit values: { values: ["en", "fr"], param: "lang" } 3. Data
213
- * file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 4. Legacy array: [{ slug:
214
- * "hello" }, { slug: "world" }]
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 {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types (from
219
- * content-loader)
220
- * @param {FormatRegistry} [registry] - Format registry for reading non-JSON dynamic pages
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
- contentTypes = new Map<string, ContentLoaderEntry[]>(),
227
- registry?: FormatRegistry,
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(raw.$paths, projectRoot, contentTypes);
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 {Map<string, ContentLoaderEntry[]>} contentTypes
283
- * @returns {Record<string, unknown>[]} Array of { paramName: value } objects
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
- contentTypes: Map<string, ContentLoaderEntry[]>,
289
- ): Record<string, unknown>[] {
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 ?? "value";
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 filePath = resolve(projectRoot, $paths.$ref);
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 "${$paths.$ref}": ${err.message}`);
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 "${$paths.$ref}" must be a JSON array`);
329
+ console.warn(`Warning: $paths.$ref "${refPath}" must be a JSON array`);
332
330
  return [];
333
331
  }
334
- const param = $paths.param ?? "id";
335
- const field = $paths.field ?? "id";
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
+ }