@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.
@@ -56,15 +56,47 @@ export default app
56
56
  `;
57
57
  }
58
58
 
59
+ /**
60
+ * One extension server mount to emit into the generated worker (specs/extensions.md §11). The
61
+ * module is imported statically (Workers cannot dynamic-import), the options are inlined JSON
62
+ * (identifiers only — never secrets), and every mount shares one context object, called in
63
+ * ascending `order`.
64
+ */
65
+ export interface SiteMountSpec {
66
+ /** Export name of the mount class (the descriptor title, e.g. "Data"). */
67
+ className: string;
68
+ /** Bare module specifier the worker imports (`server.module`). */
69
+ module: string;
70
+ /** Route subtree the mount owns (`server.basePath`). */
71
+ basePath: string;
72
+ /** Mount order (`server.order`, ascending). */
73
+ order: number;
74
+ /** JSON-inlined mount options (project sections manifest etc.). */
75
+ options: Record<string, unknown>;
76
+ }
77
+
78
+ /** A connector provider class the worker imports and hands to mounts via `options.connectors`. */
79
+ export interface SiteConnectorSpec {
80
+ /** The `connector.provider` id (options.connectors key). */
81
+ provider: string;
82
+ /** Export name of the provider implementation (the descriptor title). */
83
+ className: string;
84
+ /** Bare module specifier (`connector.module`). */
85
+ module: string;
86
+ }
87
+
59
88
  /**
60
89
  * Generate a bundled Hono server entry from pre-collected server entries across an entire site.
61
- * Used when `build.adapter` is set in project.json. Returns null if no entries are provided.
90
+ * Used when `build.adapter` is set in project.json. Returns null if no entries or mounts are
91
+ * provided.
62
92
  *
63
93
  * @param {{ exportName: string; src: string }[]} entries - Resolved server entries
64
94
  * @param {object} [opts]
65
95
  * @param {string} [opts.baseUrl] - Base path for server endpoints. Default is `'/_jx/server'`
66
96
  * @param {string | null} [opts.adapter] - Deployment adapter: `"cloudflare-workers"`,
67
97
  * `"cloudflare-pages"`, `"node"`, `"bun"`
98
+ * @param {SiteMountSpec[]} [opts.mounts] - Extension server mounts, already sorted by order
99
+ * @param {SiteConnectorSpec[]} [opts.connectors] - Connector classes used by the mounts
68
100
  * @returns {string | null} Complete worker/server source string, or null
69
101
  */
70
102
  export function compileSiteServer(
@@ -72,10 +104,12 @@ export function compileSiteServer(
72
104
  opts: {
73
105
  baseUrl?: string;
74
106
  adapter?: string | null;
107
+ mounts?: SiteMountSpec[];
108
+ connectors?: SiteConnectorSpec[];
75
109
  } = {},
76
110
  ) {
77
- const { baseUrl = "/_jx/server", adapter = null } = opts;
78
- if (entries.length === 0 && !adapter) {
111
+ const { baseUrl = "/_jx/server", adapter = null, mounts = [], connectors = [] } = opts;
112
+ if (entries.length === 0 && mounts.length === 0 && !adapter) {
79
113
  return null;
80
114
  }
81
115
 
@@ -83,6 +117,8 @@ export function compileSiteServer(
83
117
  .map(({ exportName, src }) => `import { ${exportName} } from '${src}'`)
84
118
  .join("\n");
85
119
 
120
+ const mountBlock = buildMountBlock(mounts, connectors);
121
+
86
122
  const routes = entries.map(({ exportName }) => buildRoute(exportName, baseUrl)).join("\n");
87
123
 
88
124
  // Workers static assets and Pages advanced mode (_worker.js) both route every request
@@ -94,15 +130,57 @@ export function compileSiteServer(
94
130
 
95
131
  return `// Generated by @jxsuite/compiler — do not edit manually
96
132
  import { Hono } from 'hono'
97
- ${imports}
133
+ ${imports}${mountBlock.imports}
98
134
 
99
135
  const app = new Hono()
100
- ${routes}
136
+ ${mountBlock.routes}${routes}
101
137
  ${adapterBlock}
102
138
  export default app
103
139
  `;
104
140
  }
105
141
 
142
+ /**
143
+ * Emit the extension-mount section: static imports, one shared ctx, ordered `mount()` calls at
144
+ * module init (top-level await), and `app.all('<basePath>/*', …)` wrappers registered BEFORE the
145
+ * /_jx/server routes and the ASSETS fallthrough.
146
+ *
147
+ * @param {SiteMountSpec[]} mounts
148
+ * @param {SiteConnectorSpec[]} connectors
149
+ * @returns {{ imports: string; routes: string }}
150
+ */
151
+ function buildMountBlock(mounts: SiteMountSpec[], connectors: SiteConnectorSpec[]) {
152
+ if (mounts.length === 0) {
153
+ return { imports: "", routes: "" };
154
+ }
155
+ const ordered = mounts.toSorted((a, b) => a.order - b.order);
156
+
157
+ const importLines: string[] = [];
158
+ for (const mount of ordered) {
159
+ importLines.push(`import { ${mount.className} } from '${mount.module}'`);
160
+ }
161
+ for (const connector of connectors) {
162
+ importLines.push(`import { ${connector.className} } from '${connector.module}'`);
163
+ }
164
+
165
+ const lines: string[] = [
166
+ "// Extension server mounts (specs/extensions.md §11): one shared ctx, mounted in order.",
167
+ "const jxCtx = {}",
168
+ `const jxConnectors = { ${connectors
169
+ .map((c) => `${JSON.stringify(c.provider)}: ${c.className}`)
170
+ .join(", ")} }`,
171
+ ];
172
+ for (const [index, mount] of ordered.entries()) {
173
+ const optionsJson = JSON.stringify(mount.options);
174
+ lines.push(
175
+ `const jxMount${index} = await ${mount.className}.mount({ ...${optionsJson}, connectors: jxConnectors }, jxCtx)`,
176
+ `app.all('${mount.basePath}/*', (c) => jxMount${index}(c.req.raw, c.env))`,
177
+ `app.all('${mount.basePath}', (c) => jxMount${index}(c.req.raw, c.env))`,
178
+ );
179
+ }
180
+
181
+ return { imports: `\n${importLines.join("\n")}`, routes: `${lines.join("\n")}\n` };
182
+ }
183
+
106
184
  /** @param {string} exportName @param {string} baseUrl */
107
185
  function buildRoute(exportName: string, baseUrl: string) {
108
186
  return `
@@ -1,385 +0,0 @@
1
- /**
2
- * Content-loader.js — Format-agnostic content type loader
3
- *
4
- * Loads content types defined in project.json's `contentTypes` key. JSON sources are handled
5
- * natively (Jx IS JSON); every other format dispatches through the format registry built from the
6
- * project's imports map — the class's `discover` capability lists entry files and its `load`
7
- * capability parses each into ContentLoaderEntry[].
8
- *
9
- * There are no implicit format defaults: a content type either names an imported format class via
10
- * its `format` key, or its source extension must match a registered format. Remote http(s) sources
11
- * require an explicit `format` whose class declares `format.remote: true`.
12
- *
13
- * @module content-loader
14
- */
15
-
16
- import { existsSync, readFileSync, readdirSync } from "node:fs";
17
- import { errorMessage } from "@jxsuite/schema/parse";
18
- import { basename, extname, resolve } from "node:path";
19
- import { buildProjectFormatRegistry, unknownFormatError } from "./format-host.ts";
20
- import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
21
- import type {
22
- ContentTypeDef,
23
- ContentTypeSchema,
24
- ContentTypeSchemaField,
25
- JxMutableNode,
26
- ProjectConfig,
27
- } from "@jxsuite/schema/types";
28
- import type { ContentLoaderEntry } from "@jxsuite/parser/types";
29
-
30
- // ─── JSON loader (the single native built-in format) ─────────────────────────
31
-
32
- /**
33
- * Load a JSON file into ContentEntry(s). If the file is an array, each element is an entry. If it's
34
- * an object with an `id` field, it's a single entry.
35
- *
36
- * @param {string} filePath - Absolute path to .json file
37
- * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
38
- */
39
- function loadJSONEntries(filePath: string) {
40
- const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
41
- if (Array.isArray(raw)) {
42
- return (raw as Record<string, unknown>[]).map((item: Record<string, unknown>, i: number) => ({
43
- body: null,
44
- data: item,
45
- id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
46
- }));
47
- }
48
- // Single object file — filename is the id
49
- const rawObj = raw as Record<string, unknown>;
50
- return [
51
- {
52
- body: null,
53
- data: rawObj,
54
- id: (rawObj.id as string) ?? basename(filePath, ".json"),
55
- },
56
- ];
57
- }
58
-
59
- /** Discover .json entry files for a source (single file or directory). */
60
- function discoverJSONFiles(resolvedSource: string): string[] {
61
- if (extname(resolvedSource)) {
62
- return existsSync(resolvedSource) ? [resolvedSource] : [];
63
- }
64
- try {
65
- return readdirSync(resolvedSource, { recursive: true })
66
- .filter((f) => String(f).endsWith(".json"))
67
- .map((f) => resolve(resolvedSource, String(f)));
68
- } catch {
69
- return [];
70
- }
71
- }
72
-
73
- // ─── Content Config ───────────────────────────────────────────────────────────
74
-
75
- /**
76
- * Load and parse content types config from project.json.
77
- *
78
- * @param {string} projectRoot - Project root directory
79
- * @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
80
- * @returns {{
81
- * config: { contentTypes: Record<string, ContentTypeDef> };
82
- * contentDir: string;
83
- * } | null}
84
- * Parsed config or null if no content dir
85
- */
86
- export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
87
- const contentDir = resolve(projectRoot, "content");
88
-
89
- const config = { contentTypes: projectConfig?.contentTypes ?? {} };
90
-
91
- return { config, contentDir };
92
- }
93
-
94
- // ─── Content Type Loading ────────────────────────────────────────────────────
95
-
96
- /**
97
- * Load all content types defined in project.json.
98
- *
99
- * @param {string} projectRoot - Project root directory
100
- * @param {ProjectConfig} [projectConfig] - Already-loaded project config
101
- * @param {FormatRegistry} [registry] - Pre-built format registry (built from imports if omitted)
102
- * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
103
- * ContentEntry
104
- */
105
- export async function loadContentTypes(
106
- projectRoot: string,
107
- projectConfig?: ProjectConfig,
108
- registry?: FormatRegistry,
109
- ) {
110
- const result = loadContentConfig(projectRoot, projectConfig);
111
- if (!result) {
112
- return new Map<string, ContentLoaderEntry[]>();
113
- }
114
-
115
- const { config } = result;
116
- const contentTypes = new Map<string, ContentLoaderEntry[]>();
117
- if (Object.keys(config.contentTypes).length === 0) {
118
- return contentTypes;
119
- }
120
-
121
- const formats = registry ?? (await buildProjectFormatRegistry(projectRoot, projectConfig));
122
-
123
- for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
124
- const entries = await loadContentType(name, contentTypeDef, projectRoot, formats);
125
- contentTypes.set(name, entries);
126
- }
127
-
128
- return contentTypes;
129
- }
130
-
131
- /**
132
- * Get the $elements array for a specific content type, if defined in project.json contentTypes.
133
- *
134
- * @param {string} projectRoot - Project root directory
135
- * @param {string} contentTypeName - Name of the content type
136
- * @param {ProjectConfig} [projectConfig] - Already-loaded project config
137
- * @returns {(string | JxElement)[] | undefined}
138
- */
139
- export function getContentTypeElements(
140
- projectRoot: string,
141
- contentTypeName: string,
142
- projectConfig?: ProjectConfig,
143
- ) {
144
- const result = loadContentConfig(projectRoot, projectConfig);
145
- if (!result) {
146
- return;
147
- }
148
- const def = result.config.contentTypes?.[contentTypeName];
149
- return def?.$elements;
150
- }
151
-
152
- /**
153
- * Load a single content type by its definition, dispatching through the format registry.
154
- *
155
- * @param {string} name - Content type name
156
- * @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
157
- * @param {string} projectRoot - Absolute path to project root directory
158
- * @param {FormatRegistry} registry - Format registry built from project imports
159
- * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
160
- */
161
- async function loadContentType(
162
- name: string,
163
- contentTypeDef: ContentTypeDef,
164
- projectRoot: string,
165
- registry: FormatRegistry,
166
- ) {
167
- const { source } = contentTypeDef;
168
- if (!source) {
169
- return [];
170
- }
171
- const { schema } = contentTypeDef;
172
-
173
- // Derive directive allowedNames from content type $elements (tag names from npm packages)
174
- const directiveOptions = contentTypeDef.$elements?.length
175
- ? {
176
- allowedNames: contentTypeDef.$elements
177
- .filter(
178
- (e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
179
- )
180
- .map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
181
- }
182
- : undefined;
183
-
184
- // Resolve the format class: explicit `format` (an import name) wins; "json" is native
185
- const formatName = contentTypeDef.format;
186
- let entry: FormatEntry | undefined;
187
- if (formatName && formatName !== "json") {
188
- entry = registry.byName(formatName);
189
- if (!entry) {
190
- throw new Error(
191
- `Content type "${name}": format "${formatName}" is not an imported format class. ` +
192
- `Add it to project.json imports, e.g. "${formatName}": "@jxsuite/parser/${formatName}.class.json".`,
193
- );
194
- }
195
- }
196
-
197
- // Remote URL source — requires an explicit remote-capable format (no implicit fallback)
198
- if (source.startsWith("http://") || source.startsWith("https://")) {
199
- if (!entry) {
200
- throw new Error(
201
- `Content type "${name}": remote sources require an explicit "format" naming an ` +
202
- `imported format class with "remote": true (e.g. "Csv").`,
203
- );
204
- }
205
- if (!entry.remote) {
206
- throw new Error(
207
- `Content type "${name}": format "${entry.name}" does not support remote sources ` +
208
- `(its format block lacks "remote": true).`,
209
- );
210
- }
211
- try {
212
- const entries = (await entry.call("load", source, {
213
- directiveOptions,
214
- schema,
215
- })) as ContentLoaderEntry[];
216
- if (schema) {
217
- validateEntries(entries, schema, name);
218
- }
219
- return entries;
220
- } catch (error) {
221
- console.warn(`Content type "${name}": ${errorMessage(error)}`);
222
- return [];
223
- }
224
- }
225
-
226
- const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
227
- const ext = extname(source).toLowerCase();
228
-
229
- // JSON — the native built-in
230
- if (formatName === "json" || (!entry && ext === ".json")) {
231
- const files = discoverJSONFiles(resolvedSource);
232
- const entries: ContentLoaderEntry[] = [];
233
- for (const filePath of files) {
234
- entries.push(...loadJSONEntries(filePath));
235
- }
236
- if (schema) {
237
- validateEntries(entries, schema, name);
238
- }
239
- return entries;
240
- }
241
-
242
- // Derive the format from the source extension when no explicit format is named
243
- if (!entry && ext) {
244
- entry = registry.byExtension(ext, "load");
245
- }
246
- if (!entry) {
247
- throw unknownFormatError(
248
- source,
249
- ext || `content type "${name}" (directory sources need an explicit "format")`,
250
- );
251
- }
252
-
253
- // Discover entry files via the class, then load each
254
- const files = entry.capabilities.discover
255
- ? ((await entry.call("discover", source, {
256
- baseDir: projectRoot,
257
- })) as string[])
258
- : [resolvedSource];
259
-
260
- const entries: ContentLoaderEntry[] = [];
261
- for (const filePath of files) {
262
- entries.push(
263
- ...((await entry.call("load", filePath, {
264
- directiveOptions,
265
- schema,
266
- })) as ContentLoaderEntry[]),
267
- );
268
- }
269
-
270
- // Validate entries against schema if present
271
- if (schema) {
272
- validateEntries(entries, schema, name);
273
- }
274
-
275
- return entries;
276
- }
277
-
278
- // ─── Schema Validation ────────────────────────────────────────────────────────
279
-
280
- /**
281
- * Validate content entries against their content type schema. Logs warnings for missing required
282
- * fields and type mismatches.
283
- *
284
- * @param {ContentLoaderEntry[]} entries - Array of ContentEntry
285
- * @param {ContentTypeSchema} schema - JSON Schema for the content type
286
- * @param {string} contentTypeName - For error messages
287
- */
288
- function validateEntries(
289
- entries: ContentLoaderEntry[],
290
- schema: ContentTypeSchema,
291
- contentTypeName: string,
292
- ) {
293
- const required = schema.required ?? [];
294
- const properties = schema.properties ?? {};
295
-
296
- for (const entry of entries) {
297
- // Check required fields
298
- for (const field of required) {
299
- if (!(field in entry.data) || entry.data[field] == null) {
300
- console.warn(
301
- `Content validation: "${contentTypeName}/${entry.id}" missing required field "${field}"`,
302
- );
303
- }
304
- }
305
-
306
- // Check types
307
- for (const [field, def] of Object.entries(properties)) {
308
- const value = entry.data[field];
309
- if (value == null) {
310
- continue;
311
- }
312
-
313
- if (def.type === "string" && typeof value !== "string") {
314
- console.warn(
315
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
316
- );
317
- } else if (def.type === "number" && typeof value !== "number") {
318
- console.warn(
319
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
320
- );
321
- } else if (def.type === "boolean" && typeof value !== "boolean") {
322
- console.warn(
323
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
324
- );
325
- } else if (def.type === "array" && !Array.isArray(value)) {
326
- console.warn(
327
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
328
- );
329
- }
330
- }
331
- }
332
- }
333
-
334
- // ─── Content Type Querying (re-exported from @jxsuite/parser/content) ────────
335
-
336
- export { queryContentType, findEntry } from "@jxsuite/parser/content";
337
-
338
- // ─── Content Type Reference Resolution ──────────────────────────────────────
339
-
340
- /**
341
- * Resolve cross-content-type $ref references in entry data. For example, a blog post's `author:
342
- * "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
343
- * entry.
344
- *
345
- * @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
346
- * contentTypes: Record<string, ContentTypeDef> }} config - Content config
347
- */
348
- export function resolveContentTypeRefs(
349
- contentTypes: Map<string, ContentLoaderEntry[]>,
350
- config: { contentTypes: Record<string, ContentTypeDef> },
351
- ) {
352
- for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
353
- const { schema } = contentTypeDef;
354
- if (!schema?.properties) {
355
- continue;
356
- }
357
-
358
- const entries = contentTypes.get(name);
359
- if (!entries) {
360
- continue;
361
- }
362
-
363
- for (const [field, rawDef] of Object.entries(schema.properties)) {
364
- const def = rawDef as ContentTypeSchemaField;
365
- if (!def.$ref?.startsWith("#/contentTypes/")) {
366
- continue;
367
- }
368
- const refContentType = def.$ref.replace("#/contentTypes/", "");
369
- const refEntries = contentTypes.get(refContentType);
370
- if (!refEntries) {
371
- continue;
372
- }
373
-
374
- for (const entry of entries) {
375
- const refId = entry.data[field];
376
- if (typeof refId === "string") {
377
- const resolved = refEntries.find((e) => e.id === refId);
378
- if (resolved) {
379
- entry.data[field] = resolved;
380
- }
381
- }
382
- }
383
- }
384
- }
385
- }