@jxsuite/schema 3.0.0 → 4.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.
- package/package.json +4 -4
- package/schema.json +52 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +23 -1
- package/src/component-meta.ts +123 -55
- package/src/dialogs.ts +20 -19
- package/src/doc-ops.ts +59 -35
- package/src/extension-registry.ts +15 -8
- package/src/format-registry.ts +504 -31
- package/src/guards.ts +89 -14
- package/src/ijson.ts +8 -8
- package/src/json-layout.ts +64 -25
- package/src/locale.ts +23 -18
- package/src/media-type.ts +26 -12
- package/src/overlays.ts +50 -45
- package/src/parse.ts +42 -24
- package/src/project-schemas.ts +94 -66
- package/src/schema.ts +50 -38
- package/src/validate-project.ts +34 -11
- package/types.ts +116 -47
package/src/format-registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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<
|
|
142
|
-
/** Import a JS module by path or URL. */
|
|
143
|
-
importModule: (pathOrUrl: string) => Promise<
|
|
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?:
|
|
538
|
+
timing?: FormatTiming[];
|
|
153
539
|
discriminator?: string;
|
|
154
540
|
}
|
|
155
541
|
|
|
156
|
-
|
|
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?:
|
|
165
|
-
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
/**
|
|
234
|
-
|
|
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
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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 (
|
|
817
|
+
if (!isString(ref) || !ref.endsWith(".class.json")) {
|
|
353
818
|
continue;
|
|
354
819
|
}
|
|
355
820
|
let classPath: string;
|
|
356
|
-
let
|
|
821
|
+
let loaded: JsonValue;
|
|
357
822
|
try {
|
|
358
823
|
classPath = base ? io.resolvePath(base, ref) : ref;
|
|
359
|
-
|
|
824
|
+
loaded = await io.loadJson(classPath);
|
|
360
825
|
} catch {
|
|
361
826
|
continue; // Unresolvable or unreadable imports are not format classes
|
|
362
827
|
}
|
|
363
|
-
if (!
|
|
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
|
|
381
|
-
if (!
|
|
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:
|
|
852
|
+
timing: method.timing ?? DEFAULT_TIMING,
|
|
387
853
|
};
|
|
388
|
-
if (
|
|
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}`;
|