@jxsuite/compiler 0.34.1 → 1.0.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.
@@ -23,13 +23,16 @@ import { basename, dirname, join, resolve } from "node:path";
23
23
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
24
24
  import { loadProjectConfig } from "./site-loader.ts";
25
25
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
26
- import { buildProjectFormatRegistry } from "./format-host.ts";
27
- import type { FormatRegistry } from "@jxsuite/schema/format-registry";
26
+ import { buildProjectExtensionRegistry } from "./format-host.ts";
27
+ import { loadProjectSections } from "./project-sections.ts";
28
+ import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
29
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
28
30
  import { resolveLayout } from "./layout-resolver.ts";
29
31
  import { mergeHead, renderHead } from "./head-merger.ts";
30
32
  import { injectContext } from "./context-injection.ts";
31
33
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
32
34
  import { compileElement } from "../targets/compile-element.ts";
35
+ import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
33
36
  import {
34
37
  DEFAULT_LIT_HTML_SRC,
35
38
  DEFAULT_REACTIVITY_SRC,
@@ -45,7 +48,6 @@ import {
45
48
  resolveRefValue,
46
49
  resolveStaticValue,
47
50
  } from "../shared.ts";
48
- import { loadContentConfig, loadContentTypes, resolveContentTypeRefs } from "./content-loader.ts";
49
51
  import { resolvePrototypes } from "./prototype-resolver.ts";
50
52
  import { transformImageNodes } from "./image-transform.ts";
51
53
  import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
@@ -62,7 +64,6 @@ import type {
62
64
  JxStyle,
63
65
  ProjectConfig,
64
66
  } from "@jxsuite/schema/types";
65
- import type { ContentLoaderEntry } from "@jxsuite/parser/types";
66
67
  import type { SiteRoute } from "../types.ts";
67
68
  import type { CacheManifest } from "./image-cache.js";
68
69
 
@@ -90,13 +91,24 @@ export async function buildSite(
90
91
  log("Loading project.json...");
91
92
  const { config: projectConfig } = loadProjectConfig(projectRoot);
92
93
 
94
+ // The legacy imports-based content model is gone (specs/extensions.md v2) — fail loudly with
95
+ // The migration path instead of silently ignoring the section.
96
+ if ("contentTypes" in projectConfig) {
97
+ throw new Error(
98
+ `project.json declares "contentTypes", which is no longer supported. ` +
99
+ `Run \`bun scripts/migrate-project-extensions.ts <project>\` to migrate to ` +
100
+ `"extensions" + "content", then \`jx schema\`.`,
101
+ );
102
+ }
103
+
93
104
  const outDir = resolve(projectRoot, projectConfig.build.outDir);
94
105
  const pagesDir = resolve(projectRoot, "pages");
95
106
  const publicDir = resolve(projectRoot, "public");
96
107
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
97
108
 
98
- // ── 1b. Build the format registry from project imports ─────────────────
99
- const formatRegistry = await buildProjectFormatRegistry(projectRoot, projectConfig);
109
+ // ── 1b. Build the extension registry from project.json#/extensions ─────
110
+ const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
111
+ const formatRegistry = registry.formats;
100
112
  if (formatRegistry.entries.length > 0) {
101
113
  log(
102
114
  ` Registered ${formatRegistry.entries.length} format(s): ${formatRegistry.entries
@@ -105,6 +117,28 @@ export async function buildSite(
105
117
  );
106
118
  }
107
119
 
120
+ // ── 1c. Active extension server mounts (specs/extensions.md §11) ────────
121
+ // A mount is active when its class owns no project section, or the project declares a
122
+ // Non-empty value for its section key. Sites with active mounts always get a worker; a
123
+ // Static site cannot serve dynamic sections, so that combination is a build error.
124
+ const activeMounts = registry.serverMounts().filter((entry) => {
125
+ if (!entry.project) {
126
+ return true;
127
+ }
128
+ const value = projectConfig[entry.project.key] as Record<string, unknown> | undefined;
129
+ return value !== undefined && typeof value === "object" && Object.keys(value).length > 0;
130
+ });
131
+ if (activeMounts.length > 0 && !projectConfig.build.adapter) {
132
+ const sections = activeMounts
133
+ .map((entry) => (entry.project ? `"${entry.project.key}"` : `"${entry.server!.basePath}"`))
134
+ .join(", ");
135
+ throw new Error(
136
+ `project.json declares dynamic section(s) ${sections} served by extension mounts, but ` +
137
+ `build.adapter is "static". Dynamic tables need a server-capable adapter — set ` +
138
+ `build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".`,
139
+ );
140
+ }
141
+
108
142
  // ── 2. Clean output directory ───────────────────────────────────────────
109
143
  if (clean && existsSync(outDir)) {
110
144
  rmSync(outDir, { force: true, recursive: true });
@@ -120,20 +154,22 @@ export async function buildSite(
120
154
  const staticRoutes = await discoverPages(pagesDir, formatRegistry);
121
155
  log(` Found ${staticRoutes.length} page(s)`);
122
156
 
123
- // ── 3b. Load content types ─────────────────────────────────────────────
124
- log("Loading content types...");
125
- const contentTypes = await loadContentTypes(projectRoot, projectConfig, formatRegistry);
126
- if (contentTypes.size > 0) {
127
- log(` Loaded ${contentTypes.size} content type(s): ${[...contentTypes.keys()].join(", ")}`);
128
- // Resolve cross-content-type $ref references
129
- const contentConfig = loadContentConfig(projectRoot, projectConfig);
130
- if (contentConfig) {
131
- resolveContentTypeRefs(contentTypes, contentConfig.config);
132
- }
157
+ // ── 3b. Load extension-contributed project sections ────────────────────
158
+ log("Loading project sections...");
159
+ const sections = await loadProjectSections(projectRoot, projectConfig, registry);
160
+ const sectionKeys = Object.keys(sections);
161
+ if (sectionKeys.length > 0) {
162
+ log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
133
163
  }
134
164
 
135
165
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
136
- const routes = await expandDynamicRoutes(staticRoutes, projectRoot, contentTypes, formatRegistry);
166
+ const routes = await expandDynamicRoutes(
167
+ staticRoutes,
168
+ projectRoot,
169
+ sections,
170
+ registry,
171
+ projectConfig,
172
+ );
137
173
  log(` ${routes.length} route(s) after expansion`);
138
174
 
139
175
  let fileCount = 0;
@@ -235,11 +271,12 @@ export async function buildSite(
235
271
  route as unknown as SiteRoute,
236
272
  projectConfig,
237
273
  projectRoot,
238
- contentTypes,
274
+ sections,
239
275
  imageCache,
240
276
  componentDefs,
241
277
  imageMetaCache,
242
278
  formatRegistry,
279
+ registry,
243
280
  );
244
281
 
245
282
  // Determine which component tags are fully static (for script omission)
@@ -355,11 +392,22 @@ export async function buildSite(
355
392
  }
356
393
  }
357
394
 
395
+ // Extension server mounts: static imports of the mount modules and used connector classes,
396
+ // Inlined JSON options (the project's section manifest — identifiers only, never secrets).
397
+ const { mounts, connectors } = buildMountSpecs(activeMounts, registry, projectConfig);
398
+ if (mounts.length > 0) {
399
+ log(
400
+ ` Mounting ${mounts.length} extension route(s): ${mounts.map((m) => m.basePath).join(", ")}`,
401
+ );
402
+ }
403
+
358
404
  // Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
359
405
  // Functions/ directory convention only works from the project root, not from dist/.
360
- // A static-only Pages site needs no worker at all.
361
- const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0;
362
- const workerSource = skipWorker ? null : compileSiteServer([...deduped.values()], { adapter });
406
+ // A static-only Pages site needs no worker at all; sites with mounts always get one.
407
+ const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
408
+ const workerSource = skipWorker
409
+ ? null
410
+ : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
363
411
 
364
412
  if (workerSource) {
365
413
  const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
@@ -445,7 +493,7 @@ export async function buildSite(
445
493
  * @param {SiteRoute} route
446
494
  * @param {ProjectConfig} projectConfig
447
495
  * @param {string} projectRoot
448
- * @param {Map<string, ContentLoaderEntry[]>} [contentTypes]
496
+ * @param {Record<string, unknown>} [sections] - Extension-loaded project sections
449
497
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
450
498
  * @param {Map<string, JxElement>} [componentDefs]
451
499
  * @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
@@ -460,11 +508,12 @@ async function compilePage(
460
508
  route: SiteRoute,
461
509
  projectConfig: ProjectConfig,
462
510
  projectRoot: string,
463
- contentTypes = new Map<string, ContentLoaderEntry[]>(),
511
+ sections: Record<string, unknown> = {},
464
512
  imageCache: CacheManifest | null = null,
465
513
  componentDefs = new Map<string, JxElement>(),
466
514
  imageMetaCache: ImageMetaCache | null = null,
467
515
  formatRegistry?: FormatRegistry,
516
+ registry?: ExtensionRegistry,
468
517
  ) {
469
518
  // Load the raw page document (.json natively, other formats via the registry)
470
519
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -482,12 +531,13 @@ async function compilePage(
482
531
  delete layoutDoc._pageTitle;
483
532
 
484
533
  // Inject $site and $page context
485
- injectContext(layoutDoc, projectConfig, route, contentTypes, projectRoot);
534
+ injectContext(layoutDoc, projectConfig, route, projectRoot);
486
535
 
487
- // Resolve generic $prototype entries via .class.json imports
536
+ // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
488
537
  await resolvePrototypes(layoutDoc, route, projectRoot, {
489
538
  config: projectConfig,
490
- contentTypes,
539
+ sections,
540
+ ...(registry === undefined ? {} : { registry }),
491
541
  });
492
542
 
493
543
  // Build scope from resolved state so template strings in title/$head can be evaluated
@@ -617,6 +667,92 @@ async function compilePage(
617
667
  };
618
668
  }
619
669
 
670
+ /**
671
+ * Build the mount + connector specs for the generated worker (specs/extensions.md §11–§12).
672
+ *
673
+ * Mount options inline the extension-contributed sections from project.json — identifiers only,
674
+ * never secrets (§13). Connector classes are included when any section entry names their
675
+ * `connector.provider`; the import specifier comes from the descriptor's `connector.module` (bare
676
+ * specifier — Workers cannot import filesystem paths).
677
+ *
678
+ * @param {FormatEntry[]} activeMounts - Server-mount classes with active sections
679
+ * @param {ExtensionRegistry} registry
680
+ * @param {ProjectConfig} projectConfig
681
+ * @returns {{ mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] }}
682
+ */
683
+ function buildMountSpecs(
684
+ activeMounts: FormatEntry[],
685
+ registry: ExtensionRegistry,
686
+ projectConfig: ProjectConfig,
687
+ ): { mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] } {
688
+ if (activeMounts.length === 0) {
689
+ return { connectors: [], mounts: [] };
690
+ }
691
+
692
+ const sections: Record<string, unknown> = {};
693
+ for (const contribution of registry.projectContributions()) {
694
+ const { key } = contribution.project!;
695
+ if (key in projectConfig) {
696
+ sections[key] = projectConfig[key];
697
+ }
698
+ }
699
+
700
+ const mounts: SiteMountSpec[] = activeMounts.map((entry) => {
701
+ const server = entry.server!;
702
+ const { module } = server;
703
+ if (typeof module !== "string" || module === "") {
704
+ throw new Error(
705
+ `Extension class "${entry.name}": server.module (a bare import specifier) is required ` +
706
+ `to generate a deployable worker`,
707
+ );
708
+ }
709
+ return {
710
+ basePath: server.basePath,
711
+ className: (entry.classDef.title as string | undefined) ?? entry.name,
712
+ module,
713
+ options: { basePath: server.basePath, sections },
714
+ order: server.order ?? 100,
715
+ };
716
+ });
717
+
718
+ // Used providers: any section entry object carrying a string `provider` field (the connector
719
+ // Vocabulary, §12) marks that provider's class for import.
720
+ const usedProviders = new Set<string>();
721
+ for (const value of Object.values(sections)) {
722
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
723
+ continue;
724
+ }
725
+ for (const sectionEntry of Object.values(value as Record<string, unknown>)) {
726
+ const provider = (sectionEntry as { provider?: unknown } | null)?.provider;
727
+ if (typeof provider === "string") {
728
+ usedProviders.add(provider);
729
+ }
730
+ }
731
+ }
732
+
733
+ const connectors: SiteConnectorSpec[] = [];
734
+ for (const entry of registry.connectors()) {
735
+ const provider = String(entry.connector!.provider);
736
+ if (!usedProviders.has(provider)) {
737
+ continue;
738
+ }
739
+ const { module } = entry.connector!;
740
+ if (typeof module !== "string" || module === "") {
741
+ throw new Error(
742
+ `Connector class "${entry.name}": connector.module (a bare import specifier) is ` +
743
+ `required to generate a deployable worker`,
744
+ );
745
+ }
746
+ connectors.push({
747
+ className: (entry.classDef.title as string | undefined) ?? entry.name,
748
+ module,
749
+ provider,
750
+ });
751
+ }
752
+
753
+ return { connectors, mounts };
754
+ }
755
+
620
756
  /**
621
757
  * Resolve template strings in $head entries against the compiled scope.
622
758
  *
@@ -39,7 +39,6 @@ const DEFAULTS = {
39
39
  provider: null,
40
40
  trailingSlash: "always",
41
41
  },
42
- contentTypes: {},
43
42
  defaults: {
44
43
  charset: "utf8",
45
44
  lang: "en",
@@ -83,7 +82,9 @@ export function loadProjectConfig(projectRoot: string) {
83
82
  throw new Error(`Invalid project.json: ${errorMessage(error)}`, { cause: error });
84
83
  }
85
84
 
86
- // Deep merge with defaults
85
+ // Deep merge with defaults. The `...raw` spread carries every author-declared key through
86
+ // Untouched — including `extensions` and extension-contributed sections (`content`, ...), which
87
+ // Are opaque to the loader (specs/extensions.md §3.1).
87
88
  const config: ResolvedProjectConfig = {
88
89
  ...DEFAULTS,
89
90
  ...raw,
@@ -111,12 +112,13 @@ export function loadProjectConfig(projectRoot: string) {
111
112
  if (raw.imports) {
112
113
  config.imports = raw.imports;
113
114
  }
114
- if (raw.contentTypes) {
115
- config.contentTypes = raw.contentTypes;
116
- }
117
115
 
118
116
  // Validate adapter
119
117
  const VALID_ADAPTERS = ["cloudflare-workers", "cloudflare-pages", "node", "bun"];
118
+ // "static" is the Settings UI's explicit no-adapter choice; normalize it away.
119
+ if (config.build.adapter === "static") {
120
+ delete config.build.adapter;
121
+ }
120
122
  if (config.build.adapter && !VALID_ADAPTERS.includes(config.build.adapter)) {
121
123
  throw new Error(
122
124
  `Unknown build adapter "${config.build.adapter}" in project.json. ` +
@@ -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 `