@jxsuite/schema 3.0.0 → 4.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.
@@ -10,7 +10,25 @@
10
10
  * (fetch + dynamic import) hosts.
11
11
  */
12
12
 
13
+ import { isFunction, isJsonObject, isString } from "./guards";
13
14
  import { mediaTypeEssence, mediaTypeProblem } from "./media-type";
15
+ import type { AssetMount } from "./asset-paths";
16
+ import type { ExtensionRegistry } from "./extension-registry";
17
+ import type {
18
+ ContentFieldValue,
19
+ ContentLoaderEntry,
20
+ ContentTypeSchema,
21
+ JsonObject,
22
+ JsonValue,
23
+ JxDocument,
24
+ JxDocumentValue,
25
+ JxElement,
26
+ JxHeadEntry,
27
+ JxPathsDef,
28
+ JxPrototypeDef,
29
+ JxStateDefinition,
30
+ ProjectConfig,
31
+ } from "../types";
14
32
 
15
33
  export type FormatCapability = "parse" | "serialize" | "rewrite" | "discover" | "load";
16
34
 
@@ -65,6 +83,18 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
65
83
  "testConnection",
66
84
  ];
67
85
 
86
+ /**
87
+ * One authored value the `rewrite` capability replaces in a document's own source text
88
+ * (specs/extensions.md §8): every place the source holds exactly `from` becomes `to`, and nothing
89
+ * else changes.
90
+ */
91
+ export interface ValueEdit {
92
+ /** The value as the format's `parse` produced it. */
93
+ from: string;
94
+ /** What to write in its place. */
95
+ to: string;
96
+ }
97
+
68
98
  export type FormatTiming = "compiler" | "server" | "client";
69
99
 
70
100
  export type FormatDocumentKind = "page" | "component" | "content";
@@ -100,7 +130,8 @@ export interface StudioHints {
100
130
  }
101
131
  >;
102
132
  };
103
- [key: string]: unknown;
133
+ /** Any further hint a format declares; `.class.json` is JSON, so every hint is a JSON value. */
134
+ [key: string]: JsonValue | undefined;
104
135
  }
105
136
 
106
137
  export interface CapabilityInfo {
@@ -114,6 +145,7 @@ export interface CapabilityInfo {
114
145
 
115
146
  /** A class owns a project.json section iff its descriptor carries a `project` block. */
116
147
  export interface ProjectBlock {
148
+ /** The project.json top-level property the class owns. */
117
149
  key: string;
118
150
  title?: string;
119
151
  description?: string;
@@ -133,14 +165,368 @@ export interface ConnectorBlock {
133
165
  kind: "sqlite" | "postgres";
134
166
  local?: string;
135
167
  serve?: string;
136
- [key: string]: unknown;
168
+ /** Provider-specific settings; the block is open by design and, like its descriptor, JSON. */
169
+ [key: string]: JsonValue | undefined;
170
+ }
171
+
172
+ /**
173
+ * Result shape of the connector `deploySchema` capability (specs/extensions.md §8): the compiled
174
+ * additive DDL, whether it ran, and any drift warnings. Declared here, beside the capability's
175
+ * name, because both the connector that answers it and the hosts that call it read it.
176
+ */
177
+ export interface DeploySchemaResult {
178
+ statements: string[];
179
+ applied: boolean;
180
+ warnings: string[];
181
+ }
182
+
183
+ /** Result shape of the connector `testConnection` capability (specs/extensions.md §8). */
184
+ export interface TestConnectionResult {
185
+ ok: boolean;
186
+ error?: string;
187
+ }
188
+
189
+ /**
190
+ * What a schema push over a project's connections is asked to do: `jx db push` and the Studio
191
+ * data-push route take the same two choices.
192
+ */
193
+ export interface DataPushRequest {
194
+ /** Restrict the push to one connection; omitted = every declared connection. */
195
+ connection?: string;
196
+ /** Compile the plan without executing it. */
197
+ dryRun?: boolean;
198
+ }
199
+
200
+ // ─── Capability contracts (specs/extensions.md §8) ───────────────────────────
201
+
202
+ /**
203
+ * The environment a dispatching host resolves connections and mounts against: env vars and secrets
204
+ * by name (process.env merged under `.dev.vars` on the dev server and the CLI, specs/extensions.md
205
+ * §13). Connections carry identifiers only; every secret is read from here.
206
+ */
207
+ export type HostEnvironment = Readonly<Record<string, string | undefined>>;
208
+
209
+ /**
210
+ * Options a host passes a format capability by name. They are JSON, as the method declares them in
211
+ * its `parameters` (specs/extensions.md §8.2).
212
+ */
213
+ export type FormatOptions = Readonly<Record<string, JsonValue | undefined>>;
214
+
215
+ /**
216
+ * What a format's `parse` answers (specs/extensions.md §8): a Jx document, or, from a format that
217
+ * declares only the `content` document kind, the entries it loaded.
218
+ */
219
+ export type ParseResult = JxDocument | ContentLoaderEntry[];
220
+
221
+ /**
222
+ * The export-mode options a site build hands `serialize` for an `exportTarget` sidecar
223
+ * (specs/extensions.md §7): clean, lossy output that inlines the custom elements `componentDefs`
224
+ * defines. The template hooks a site build adds are host code over the host's own scope, so the
225
+ * host that supplies them declares them by merging into this interface.
226
+ */
227
+ export interface SerializeExportOptions {
228
+ mode: "export";
229
+ componentDefs?: Map<string, JxElement>;
230
+ }
231
+
232
+ /** The options a format's `discover` receives from the content section's host. */
233
+ export interface FormatDiscoverOptions {
234
+ /** The project root a relative `source` resolves against. */
235
+ baseDir: string;
236
+ /** The content type's `exclude` globs, passed only when it declares some. */
237
+ exclude?: readonly string[] | undefined;
238
+ }
239
+
240
+ /** The options a format's `load` receives from the content section's host. */
241
+ export interface FormatLoadOptions {
242
+ /** The content type's schema, which drives per-field coercion. */
243
+ schema?: ContentTypeSchema | undefined;
244
+ /** Directive options derived from the content type's `$elements`: the tags a directive may name. */
245
+ directiveOptions?: { allowedNames: string[] } | undefined;
246
+ /** The field an entry's id is read from. */
247
+ idField?: string;
248
+ /** A directory source's resolved root, from which nested entries take path-based ids. */
249
+ sourceRoot?: string;
250
+ /** The content type's GitHub-alert options: alert type to element, or `false` for blockquotes. */
251
+ alerts?: JsonValue;
252
+ /** Told of each alert marker whose type is not enabled (it stays a blockquote). */
253
+ onUnknownAlert?: (type: string) => void;
254
+ }
255
+
256
+ /** Host context passed to a section owner's `projectData`. */
257
+ export interface ProjectDataContext {
258
+ projectConfig?: ProjectConfig | undefined;
259
+ /** Absolute project root directory. */
260
+ root: string;
261
+ /** The project's extension registry; format dispatch goes through its `formats` view. */
262
+ registry: ExtensionRegistry;
263
+ /** The host's injected I/O. */
264
+ io?: FormatHostIO;
265
+ /**
266
+ * The host serves one page at a time and wants the section loaded on demand: an owner may answer
267
+ * data that loads each part the first time a class asks for it, rather than all of it up front.
268
+ * Absent means everything now, which is what a build needs (specs/extensions.md §8).
269
+ */
270
+ lazy?: boolean;
271
+ }
272
+
273
+ /**
274
+ * A project section's loaded data: what its owner's `projectData` answers. Hosts keep it as
275
+ * `_project[<key>]` and hand it, unread, to the owner's `resolvePaths` and to every `emit`. It is
276
+ * document data, or a map of collection name to loaded entries (the `content` section's).
277
+ */
278
+ export type ProjectSectionData = JxDocumentValue | Map<string, ContentLoaderEntry[]>;
279
+
280
+ /** What a `$paths` result carries about the entry its route was generated from. */
281
+ export type PathEntryMeta = NonNullable<ContentLoaderEntry["_meta"]>;
282
+
283
+ /**
284
+ * One route a `resolvePaths` expands a page into: route parameter to value, plus the reserved
285
+ * `_meta`, which the host strips before substituting the parameters.
286
+ */
287
+ export interface PathParams {
288
+ _meta?: PathEntryMeta;
289
+ [param: string]: ContentFieldValue | PathEntryMeta | undefined;
290
+ }
291
+
292
+ /** Host context passed to a section owner's `resolvePaths`: the page being expanded, and its data. */
293
+ export interface ResolvePathsContext {
294
+ /** The section's loaded data, as its owner's `projectData` answered it. */
295
+ data: ProjectSectionData | undefined;
296
+ /** The locale of the route being expanded, when the project has any. */
297
+ locale: string | null | undefined;
298
+ projectConfig: ProjectConfig | undefined;
299
+ /** Absolute project root directory. */
300
+ root: string;
301
+ /** The dynamic page's parameter names, in order. */
302
+ params?: readonly string[];
303
+ /** The dynamic page's URL pattern (`/kb/:category/:slug`). */
304
+ urlPattern?: string;
305
+ /** The URL pattern of every dynamic page in the project. */
306
+ patterns?: readonly string[];
307
+ }
308
+
309
+ /** Host context passed to a `lower` (specs/extensions.md §8.3). */
310
+ export interface LowerContext {
311
+ projectConfig?: ProjectConfig | undefined;
312
+ /** Absolute project root directory. */
313
+ root: string;
314
+ /** The route being compiled: its source file and resolved path parameters. */
315
+ route: { sourcePath?: string; _pathParams?: Record<string, string> };
316
+ }
317
+
318
+ /** Host context passed to a section owner's `head` and `assets` (specs/extensions.md §8.5, §8.6). */
319
+ export interface SectionContext {
320
+ projectConfig: ProjectConfig;
321
+ /** Absolute project root directory. */
322
+ root: string;
323
+ }
324
+
325
+ /** A route of the expanded route table, as an `emit` receives it. */
326
+ export interface EmitRoute {
327
+ /** The route's URL, concrete once expanded (`/blog/hello`). */
328
+ urlPattern: string;
329
+ /** The page source the route renders. */
330
+ sourcePath: string;
331
+ }
332
+
333
+ /** Host context passed to a section owner's `emit` (specs/extensions.md §8.4). */
334
+ export interface EmitHostContext extends SectionContext {
335
+ /** Every loaded project section, by section key. */
336
+ sections: Readonly<Record<string, ProjectSectionData>>;
337
+ /** The expanded route table. */
338
+ routes: readonly EmitRoute[];
339
+ }
340
+
341
+ /** One file an `emit` contributes to the build output; the host writes it (§8.4). */
342
+ export interface EmitFile {
343
+ /** Output-directory-relative path; a leading `/` is tolerated. */
344
+ path: string;
345
+ content: string | Uint8Array;
346
+ }
347
+
348
+ /** What a host hands a mount (specs/extensions.md §11). */
349
+ export interface MountOptions {
350
+ /** The route subtree the mount owns. */
351
+ basePath: string;
352
+ /** Run additive schema syncs on first touch (the dev server's local databases). */
353
+ autoSync?: boolean;
354
+ /**
355
+ * Connector provider implementations by provider id, with the dev server's `local` stand-ins
356
+ * applied (§12). The host relays them unread; the mount that receives them calls them.
357
+ */
358
+ connectors: Readonly<Record<string, ImplementationExport | null | undefined>>;
359
+ /** Every extension-contributed project section the project declares, by section key. */
360
+ sections: Readonly<Record<string, JxDocumentValue | undefined>>;
361
+ }
362
+
363
+ /** The fetch-style handler a mount answers with; the host dispatches its route subtree to it. */
364
+ export type MountHandler = (request: Request, env: HostEnvironment) => Promise<Response>;
365
+
366
+ /** The options a connector's `deploySchema` receives. */
367
+ export interface ConnectorDeployOptions {
368
+ env: HostEnvironment;
369
+ /** Compile the additive DDL without executing it. */
370
+ dryRun?: boolean;
371
+ }
372
+
373
+ /**
374
+ * A `data` section table definition, as a host hands it to a connector (§12). The connector owns
375
+ * the full definition; a host reads only which connection the table lives on.
376
+ */
377
+ export interface DataTableDefinition {
378
+ connection?: string;
379
+ }
380
+
381
+ /** The options a section owner's `deploySchema` receives (specs/extensions.md §11.1). */
382
+ export interface SectionDeployOptions {
383
+ env: HostEnvironment;
384
+ /** Compile the steps without applying them. */
385
+ dryRun?: boolean;
386
+ /** The one connection a push is filtered to, when it is. */
387
+ connection?: string;
388
+ /** The connector providers the mounts receive, so a dev push reaches the same stand-ins. */
389
+ connectors?: Readonly<Record<string, ImplementationExport | null | undefined>>;
390
+ }
391
+
392
+ /** One step a section owner contributes to a schema push (specs/extensions.md §11.1). */
393
+ export interface SectionPushStep {
394
+ /** The step's kind; hosts default an empty one to the contributing section key. */
395
+ kind: string;
396
+ table?: string;
397
+ /** One-line human description of the step. */
398
+ summary: string;
399
+ sql?: string;
400
+ connection?: string;
401
+ }
402
+
403
+ /** What a section owner's `deploySchema` answers (specs/extensions.md §11.1). */
404
+ export interface SectionDeployResult {
405
+ steps: SectionPushStep[];
406
+ applied: boolean;
407
+ warnings: string[];
408
+ /** The connection the section lives on. */
409
+ connection?: string;
410
+ }
411
+
412
+ /**
413
+ * What a host passes each capability role and what the role answers: the contract a class signs
414
+ * when it declares the role on a static method in `$defs.methods` (specs/extensions.md §8).
415
+ * {@link FormatEntry.call} takes and returns exactly these, so no host restates a capability's
416
+ * answer.
417
+ *
418
+ * `dialect` is absent on purpose. Its answer is the dialect of the host's own query builder, a type
419
+ * this package cannot name, so the host that opens connections declares that role by merging into
420
+ * this interface (the dev server's data API does, for Kysely).
421
+ */
422
+ export interface CapabilityContracts {
423
+ parse: { args: [source: string, options?: FormatOptions | undefined]; result: ParseResult };
424
+ serialize: {
425
+ args: [doc: JxDocumentValue, options?: FormatOptions | SerializeExportOptions | undefined];
426
+ result: string;
427
+ };
428
+ rewrite: { args: [source: string, edits: readonly ValueEdit[]]; result: string };
429
+ discover: { args: [source: string, options: FormatDiscoverOptions]; result: string[] };
430
+ load: { args: [path: string, options: FormatLoadOptions]; result: ContentLoaderEntry[] };
431
+ projectData: {
432
+ args: [sectionValue: JxDocumentValue | undefined, context: ProjectDataContext];
433
+ result: ProjectSectionData;
434
+ };
435
+ resolvePaths: {
436
+ args: [pathsDef: JxPathsDef, context: ResolvePathsContext];
437
+ result: PathParams[];
438
+ };
439
+ lower: { args: [def: JxPrototypeDef, context: LowerContext]; result: JxStateDefinition };
440
+ /* `emit`, `head` and `assets`: a section owner with nothing to contribute may answer null, which
441
+ contributes no files, no `<head>` entries and no mounts. */
442
+ emit: {
443
+ args: [sectionValue: JxDocumentValue | null, context: EmitHostContext];
444
+ result: EmitFile[] | null;
445
+ };
446
+ head: {
447
+ args: [sectionValue: JxDocumentValue | null, context: SectionContext];
448
+ result: JxHeadEntry[] | null;
449
+ };
450
+ assets: {
451
+ args: [sectionValue: JxDocumentValue | null, context: SectionContext];
452
+ result: AssetMount[] | null;
453
+ };
454
+ /** `context` is the one shared server context per isolate; its members belong to the mounts. */
455
+ mount: { args: [options: MountOptions, context: object]; result: MountHandler };
456
+ deploySchema: {
457
+ args: [
458
+ tables: Readonly<Record<string, DataTableDefinition>>,
459
+ connection: JsonObject,
460
+ options: ConnectorDeployOptions,
461
+ ];
462
+ result: DeploySchemaResult;
463
+ };
464
+ /** Answers the wrangler config fragment the connection provisions. */
465
+ bindings: { args: [connection: JsonObject]; result: JsonObject };
466
+ testConnection: {
467
+ args: [connection: JsonObject, env: HostEnvironment];
468
+ result: TestConnectionResult;
469
+ };
470
+ }
471
+
472
+ /**
473
+ * The section-owner form of `deploySchema` (specs/extensions.md §11.1). A `project` class with no
474
+ * `connector` block has no single connection to hand over, so it takes its section value and the
475
+ * project config instead, and answers push steps rather than DDL.
476
+ */
477
+ export interface SectionDeployContract {
478
+ args: [
479
+ sectionValue: JxDocumentValue,
480
+ projectConfig: ProjectConfig,
481
+ options: SectionDeployOptions,
482
+ ];
483
+ result: SectionDeployResult;
484
+ }
485
+
486
+ /** A capability role some contract describes. */
487
+ export type ContractedCapability = ExtensionCapability & keyof CapabilityContracts;
488
+
489
+ /** Every argument list a capability may be called with. */
490
+ type CapabilityArgs =
491
+ | CapabilityContracts[ContractedCapability]["args"]
492
+ | SectionDeployContract["args"];
493
+
494
+ /** Every answer a capability may give. */
495
+ type CapabilityAnswer =
496
+ | CapabilityContracts[ContractedCapability]["result"]
497
+ | SectionDeployContract["result"];
498
+
499
+ /**
500
+ * A capability's static method, as dispatch calls it: a member of an implementation export (see
501
+ * {@link ImplementationExport}) that takes the contracts' arguments and gives their answers. The
502
+ * arguments are a list rather than the contracts' tuples so that a member checked only as callable
503
+ * can be claimed as one; the overloads of {@link FormatEntry.call} keep each contract's exact
504
+ * form.
505
+ */
506
+ type CapabilityMethod = ImplementationExport &
507
+ ((...args: CapabilityArgs[number][]) => CapabilityAnswer | PromiseLike<CapabilityAnswer>);
508
+
509
+ /**
510
+ * A loaded implementation module's namespace object, as a host's loader resolves it.
511
+ * {@link FormatEntry.implementation} reads it by export name, as an {@link ImplementationExport}.
512
+ */
513
+ export type ImplementationModule = object;
514
+
515
+ /**
516
+ * An implementation module's export, or a member read off one, before dispatch has checked it.
517
+ * Reading a member is defined for every value but `null` and `undefined` (a function reads its own
518
+ * properties, a primitive its wrapper's), and reading members is all this view admits: whatever
519
+ * dispatch calls, it first checks is callable.
520
+ */
521
+ export interface ImplementationExport {
522
+ readonly [member: string]: ImplementationExport | null | undefined;
137
523
  }
138
524
 
139
525
  export interface FormatHostIO {
140
526
  /** Read and parse a JSON file or URL. */
141
- loadJson: (pathOrUrl: string) => Promise<Record<string, unknown>>;
142
- /** Import a JS module by path or URL. */
143
- importModule: (pathOrUrl: string) => Promise<Record<string, unknown>>;
527
+ loadJson: (pathOrUrl: string) => Promise<JsonValue>;
528
+ /** Import a JS module by path or URL, resolving to its namespace object. */
529
+ importModule: (pathOrUrl: string) => Promise<ImplementationModule>;
144
530
  /** Resolve a relative reference against a base (node: path.resolve; browser: new URL). */
145
531
  resolvePath: (base: string, ref: string) => string;
146
532
  }
@@ -149,11 +535,17 @@ interface ClassMethodLike {
149
535
  role?: string;
150
536
  identifier?: string;
151
537
  scope?: string;
152
- timing?: string[];
538
+ timing?: FormatTiming[];
153
539
  discriminator?: string;
154
540
  }
155
541
 
156
- interface ClassDefLike {
542
+ /** The `$defs` block of a class descriptor, as far as capability discovery reads it. */
543
+ interface ClassDefsLike {
544
+ methods?: Record<string, ClassMethodLike>;
545
+ }
546
+
547
+ /** A `.class.json` descriptor, as far as dispatch reads it. */
548
+ export interface ClassDefLike {
157
549
  title?: string;
158
550
  $implementation?: string;
159
551
  format?: FormatBlock;
@@ -161,8 +553,34 @@ interface ClassDefLike {
161
553
  server?: ServerBlock;
162
554
  connector?: ConnectorBlock;
163
555
  $studio?: StudioHints;
164
- $defs?: { methods?: Record<string, ClassMethodLike> };
165
- [key: string]: unknown;
556
+ $defs?: ClassDefsLike;
557
+ /** Every other descriptor key, read by hosts as JSON (`description`, …). */
558
+ [key: string]:
559
+ | JsonValue
560
+ | FormatBlock
561
+ | ProjectBlock
562
+ | ServerBlock
563
+ | ConnectorBlock
564
+ | StudioHints
565
+ | ClassDefsLike
566
+ | undefined;
567
+ }
568
+
569
+ /**
570
+ * Read a loaded `.class.json` as a class descriptor.
571
+ *
572
+ * A non-object file declares nothing, so it reads as an empty descriptor, except `null`, which is
573
+ * refused the way the descriptor's first read has always refused it.
574
+ */
575
+ export function classDefFrom(value: JsonValue): ClassDefLike {
576
+ if (value === null) {
577
+ throw new TypeError("A class descriptor cannot be null");
578
+ }
579
+ /* SAFETY: a `.class.json` is a ClassDef document (class-schema.json, which `jx validate` and the
580
+ editor enforce), and the blocks dispatch depends on are re-checked where they are read:
581
+ `format.extensions` by `buildFormatRegistry`, `mediaType` by the FormatRegistry constructor,
582
+ method roles and `discriminator` by `extractCapabilities`. */
583
+ return isJsonObject(value) ? (value as ClassDefLike) : {};
166
584
  }
167
585
 
168
586
  const DEFAULT_TIMING: FormatTiming[] = ["compiler", "server"];
@@ -192,7 +610,7 @@ export class FormatEntry {
192
610
  readonly capabilities: Partial<Record<ExtensionCapability, CapabilityInfo>>;
193
611
 
194
612
  #io: FormatHostIO;
195
- #implementation: Promise<Record<string, unknown>> | null = null;
613
+ #implementation: Promise<ImplementationExport> | null = null;
196
614
 
197
615
  constructor(name: string, classPath: string, classDef: ClassDefLike, io: FormatHostIO) {
198
616
  this.name = name;
@@ -216,8 +634,8 @@ export class FormatEntry {
216
634
  this.capabilities = extractCapabilities(classDef);
217
635
  }
218
636
 
219
- /** Import (and cache) the class's $implementation module. */
220
- implementation(): Promise<Record<string, unknown>> {
637
+ /** Import (and cache) the class's $implementation module, read by export name. */
638
+ implementation(): Promise<ImplementationExport> {
221
639
  if (!this.#implementation) {
222
640
  const impl = this.classDef.$implementation;
223
641
  if (!impl) {
@@ -225,29 +643,76 @@ export class FormatEntry {
225
643
  new Error(`Format class "${this.name}" has no $implementation to import`),
226
644
  );
227
645
  }
228
- this.#implementation = this.#io.importModule(this.#io.resolvePath(this.classPath, impl));
646
+ /* SAFETY: a module namespace is an object, and ImplementationExport claims nothing about it
647
+ but that its members can be read, which holds for it and for every non-nullish value read
648
+ off it; dispatch calls nothing it has not checked. */
649
+ this.#implementation = this.#io.importModule(
650
+ this.#io.resolvePath(this.classPath, impl),
651
+ ) as Promise<ImplementationExport>;
229
652
  }
230
653
  return this.#implementation;
231
654
  }
232
655
 
233
- /** Invoke a capability's static method on the implementation class. */
234
- async call(capability: ExtensionCapability, ...args: unknown[]): Promise<unknown> {
656
+ /**
657
+ * The export capabilities are called on (specs/extensions.md §6.1, step 4): the one the
658
+ * descriptor's `title` names, else that name on the default export, else the default export
659
+ * itself. Nullish when the module exports none of the three.
660
+ */
661
+ async implementationExport(): Promise<ImplementationExport | null | undefined> {
662
+ return this.#exportOf(await this.implementation());
663
+ }
664
+
665
+ /** {@link implementationExport}, read off an already imported module. */
666
+ #exportOf(exported: ImplementationExport): ImplementationExport | null | undefined {
667
+ const title = this.classDef.title ?? this.name;
668
+ return exported[title] ?? exported.default?.[title] ?? exported.default;
669
+ }
670
+
671
+ /**
672
+ * Invoke a capability's static method on the implementation export, with the arguments and the
673
+ * answer its contract fixes ({@link CapabilityContracts}).
674
+ */
675
+ call<C extends ContractedCapability>(
676
+ capability: C,
677
+ ...args: CapabilityContracts[C]["args"]
678
+ ): Promise<CapabilityContracts[C]["result"]>;
679
+ /** Invoke a section owner's `deploySchema` ({@link SectionDeployContract}). */
680
+ call(
681
+ capability: "deploySchema",
682
+ ...args: SectionDeployContract["args"]
683
+ ): Promise<SectionDeployContract["result"]>;
684
+ async call(capability: ContractedCapability, ...args: CapabilityArgs): Promise<CapabilityAnswer> {
235
685
  const cap = this.capabilities[capability];
236
686
  if (!cap) {
237
687
  throw new Error(`Format class "${this.name}" does not declare a "${capability}" capability`);
238
688
  }
239
- const mod = await this.implementation();
240
- const title = this.classDef.title ?? this.name;
241
- const Cls = (mod[title] ?? (mod.default as Record<string, unknown>)?.[title] ?? mod.default) as
242
- | Record<string, unknown>
243
- | undefined;
244
- const fn = Cls?.[cap.identifier];
245
- if (typeof fn !== "function") {
689
+ const owner = this.#exportOf(await this.implementation());
690
+ const member = owner?.[cap.identifier];
691
+ if (!isFunction(member)) {
692
+ const title = this.classDef.title ?? this.name;
246
693
  throw new TypeError(
247
694
  `Format class "${this.name}": implementation export "${title}" has no static "${cap.identifier}" method`,
248
695
  );
249
696
  }
250
- return await fn.call(Cls, ...args);
697
+ /* SAFETY: dispatch can check only that the member is callable. What it accepts and answers is
698
+ the contract its class signed by declaring this role (specs/extensions.md §8), which hosts
699
+ rely on rather than re-validate, so that signature is trusted here, in this one place. */
700
+ const method = member as CapabilityMethod;
701
+ return await method.call(owner, ...args);
702
+ }
703
+
704
+ /**
705
+ * Parse a page or component source. What `parse` answers is decided by the format's
706
+ * `documentKinds` (specs/extensions.md §8): a format declaring `page` or `component` answers the
707
+ * Jx document it built from the text.
708
+ */
709
+ async parseDocument(source: string, options?: FormatOptions): Promise<JxDocument> {
710
+ const parsed = await this.call("parse", source, options);
711
+ /* SAFETY: hosts call this to read a page or a component, and specs/extensions.md §8 has every
712
+ format that declares the `page` or `component` document kind answer a JxDocument; only a
713
+ format declaring `content` alone may answer entries. A content-only format read as a page
714
+ breaks that rule, and its answer passes through unchecked, exactly as it always has. */
715
+ return parsed as JxDocument;
251
716
  }
252
717
  }
253
718
 
@@ -349,20 +814,21 @@ export async function buildFormatRegistry(
349
814
  ): Promise<FormatRegistry> {
350
815
  const entries: FormatEntry[] = [];
351
816
  for (const [name, ref] of Object.entries(imports ?? {})) {
352
- if (typeof ref !== "string" || !ref.endsWith(".class.json")) {
817
+ if (!isString(ref) || !ref.endsWith(".class.json")) {
353
818
  continue;
354
819
  }
355
820
  let classPath: string;
356
- let classDef: ClassDefLike;
821
+ let loaded: JsonValue;
357
822
  try {
358
823
  classPath = base ? io.resolvePath(base, ref) : ref;
359
- classDef = (await io.loadJson(classPath)) as ClassDefLike;
824
+ loaded = await io.loadJson(classPath);
360
825
  } catch {
361
826
  continue; // Unresolvable or unreadable imports are not format classes
362
827
  }
363
- if (!classDef || typeof classDef !== "object") {
828
+ if (!isJsonObject(loaded)) {
364
829
  continue;
365
830
  }
831
+ const classDef = classDefFrom(loaded);
366
832
  if (!classDef.format || !Array.isArray(classDef.format.extensions)) {
367
833
  continue;
368
834
  }
@@ -377,15 +843,15 @@ function extractCapabilities(
377
843
  const out: Partial<Record<ExtensionCapability, CapabilityInfo>> = {};
378
844
  const methods = classDef.$defs?.methods ?? {};
379
845
  for (const [key, method] of Object.entries(methods)) {
380
- const role = method.role as ExtensionCapability | undefined;
381
- if (!role || !EXTENSION_CAPABILITIES.includes(role)) {
846
+ const { role } = method;
847
+ if (!isExtensionCapability(role)) {
382
848
  continue;
383
849
  }
384
850
  const info: CapabilityInfo = {
385
851
  identifier: method.identifier ?? key,
386
- timing: (method.timing as FormatTiming[] | undefined) ?? DEFAULT_TIMING,
852
+ timing: method.timing ?? DEFAULT_TIMING,
387
853
  };
388
- if (typeof method.discriminator === "string") {
854
+ if (isString(method.discriminator)) {
389
855
  info.discriminator = method.discriminator;
390
856
  }
391
857
  out[role] = info;
@@ -393,6 +859,13 @@ function extractCapabilities(
393
859
  return out;
394
860
  }
395
861
 
862
+ const CAPABILITY_ROLES: ReadonlySet<string> = new Set(EXTENSION_CAPABILITIES);
863
+
864
+ /** Whether a declared method `role` is one of the capability roles dispatch knows. */
865
+ function isExtensionCapability(role: string | undefined): role is ExtensionCapability {
866
+ return role !== undefined && CAPABILITY_ROLES.has(role);
867
+ }
868
+
396
869
  function normalizeExt(ext: string): string {
397
870
  const lower = ext.toLowerCase();
398
871
  return lower.startsWith(".") ? lower : `.${lower}`;