@jxsuite/schema 0.35.0 → 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.
- package/defs/extension-manifest.schema.ts +47 -0
- package/defs/field-schema.schema.ts +58 -0
- package/defs/index.ts +5 -2
- package/defs/project-config.schema.ts +18 -10
- package/extension-manifest.schema.json +54 -0
- package/package.json +12 -4
- package/project-schema.json +17 -53
- package/schema.json +0 -44
- package/schemas/document.paths.schema.json +6 -0
- package/schemas/project.core.schema.json +441 -0
- package/schemas/project.fields.schema.json +14 -0
- package/src/extension-registry.ts +270 -0
- package/src/format-registry.ts +83 -13
- package/src/project-schemas.ts +262 -0
- package/src/schema.ts +107 -7
- package/src/validate-project.ts +144 -0
- package/types.ts +41 -8
- package/defs/content-type-def.schema.ts +0 -37
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension registry — manifest-driven discovery of extension packages (specs/extensions.md).
|
|
3
|
+
*
|
|
4
|
+
* A project declares extensions in project.json (`"extensions": ["@jxsuite/parser", ...]`); each
|
|
5
|
+
* extension package ships a `jx-extension.json` manifest enumerating its classes and schema
|
|
6
|
+
* fragments. Hosts resolve `<specifier>/jx-extension.json` (bare specifiers through the package's
|
|
7
|
+
* exports map, project-first), read each listed `.class.json`, and dispatch through the declared
|
|
8
|
+
* admission blocks (`format` | `project` | `server` | `connector`) and capability roles.
|
|
9
|
+
*
|
|
10
|
+
* All I/O is injected via FormatHostIO so the same module serves node and browser hosts. The
|
|
11
|
+
* format-dispatch view reuses FormatRegistry (including its extension/capability conflict checks).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { FormatEntry, FormatRegistry } from "./format-registry";
|
|
15
|
+
import type { FormatHostIO } from "./format-registry";
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
CapabilityInfo,
|
|
19
|
+
ConnectorBlock,
|
|
20
|
+
ExtensionCapability,
|
|
21
|
+
FormatHostIO,
|
|
22
|
+
ProjectBlock,
|
|
23
|
+
ServerBlock,
|
|
24
|
+
} from "./format-registry";
|
|
25
|
+
export { EXTENSION_CAPABILITIES, FormatEntry, FormatRegistry } from "./format-registry";
|
|
26
|
+
|
|
27
|
+
/** The well-known manifest filename every extension package exports. */
|
|
28
|
+
export const EXTENSION_MANIFEST = "jx-extension.json";
|
|
29
|
+
|
|
30
|
+
export interface ExtensionManifest {
|
|
31
|
+
name: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
classes?: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Schema fragments the package contributes: `project` (project.json sections), `document` ($paths
|
|
37
|
+
* shapes), and `fields` — a fragment whose `$defs` members are unioned into the per-project
|
|
38
|
+
* field-schema resource (extension field extras, specs/extensions.md §5.3).
|
|
39
|
+
*/
|
|
40
|
+
schemas?: { project?: string; document?: string; fields?: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExtensionInfo {
|
|
44
|
+
/** The project.json `extensions` entry that produced this extension. */
|
|
45
|
+
specifier: string;
|
|
46
|
+
/** Resolved absolute path of the jx-extension.json manifest. */
|
|
47
|
+
manifestPath: string;
|
|
48
|
+
manifest: ExtensionManifest;
|
|
49
|
+
/** Class entries in manifest order. */
|
|
50
|
+
classes: FormatEntry[];
|
|
51
|
+
/** Resolved absolute fragment paths, when the manifest declares them. */
|
|
52
|
+
schemas: { project?: string; document?: string; fields?: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SchemaFragmentKind = "project" | "document" | "fields";
|
|
56
|
+
|
|
57
|
+
export class ExtensionRegistry {
|
|
58
|
+
#extensions: ExtensionInfo[];
|
|
59
|
+
#classes: FormatEntry[];
|
|
60
|
+
#formats: FormatRegistry;
|
|
61
|
+
|
|
62
|
+
constructor(extensions: ExtensionInfo[]) {
|
|
63
|
+
this.#extensions = extensions;
|
|
64
|
+
this.#classes = extensions.flatMap((e) => e.classes);
|
|
65
|
+
|
|
66
|
+
// Class names are the $prototype-visible namespace: duplicates across extensions are a
|
|
67
|
+
// Configuration error (a project-local `imports` entry may still shadow a manifest class —
|
|
68
|
+
// That overlay happens at the host, not here).
|
|
69
|
+
const byName = new Map<string, string>();
|
|
70
|
+
for (const ext of extensions) {
|
|
71
|
+
for (const cls of ext.classes) {
|
|
72
|
+
const prior = byName.get(cls.name);
|
|
73
|
+
if (prior && prior !== ext.specifier) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Extension conflict: "${prior}" and "${ext.specifier}" both provide a class named "${cls.name}"`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
byName.set(cls.name, ext.specifier);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Section keys are exclusive (specs/extensions.md §3.1).
|
|
83
|
+
const byKey = new Map<string, FormatEntry>();
|
|
84
|
+
for (const entry of this.#classes) {
|
|
85
|
+
if (!entry.project) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const prior = byKey.get(entry.project.key);
|
|
89
|
+
if (prior) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Extension conflict: "${prior.name}" and "${entry.name}" both claim the project section "${entry.project.key}"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
byKey.set(entry.project.key, entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Server mounts own disjoint /_jx/ subtrees (specs/extensions.md §11).
|
|
98
|
+
const byBasePath = new Map<string, FormatEntry>();
|
|
99
|
+
for (const entry of this.#classes) {
|
|
100
|
+
if (!entry.server) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const { basePath } = entry.server;
|
|
104
|
+
if (!basePath.startsWith("/_jx/")) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Extension "${entry.name}": server basePath "${basePath}" must be under /_jx/`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const prior = byBasePath.get(basePath);
|
|
110
|
+
if (prior) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Extension conflict: "${prior.name}" and "${entry.name}" both mount "${basePath}"`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
byBasePath.set(basePath, entry);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The format-dispatch view; its constructor enforces (extension, capability) exclusivity.
|
|
119
|
+
this.#formats = new FormatRegistry(this.#classes.filter((e) => e.extensions.length > 0));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get extensions(): readonly ExtensionInfo[] {
|
|
123
|
+
return this.#extensions;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every class entry across all extensions, in declaration order. */
|
|
127
|
+
get classes(): readonly FormatEntry[] {
|
|
128
|
+
return this.#classes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** File-extension dispatch view (parse/serialize/discover/load lookups). */
|
|
132
|
+
get formats(): FormatRegistry {
|
|
133
|
+
return this.#formats;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
byName(name: string): FormatEntry | undefined {
|
|
137
|
+
return this.#classes.find((e) => e.name === name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Classes owning a project.json section, in declaration order. */
|
|
141
|
+
projectContributions(): FormatEntry[] {
|
|
142
|
+
return this.#classes.filter((e) => e.project !== null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
byProjectKey(key: string): FormatEntry | undefined {
|
|
146
|
+
return this.#classes.find((e) => e.project?.key === key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The class whose `resolvePaths` capability declares this `$paths` discriminator key. */
|
|
150
|
+
byPathsDiscriminator(key: string): FormatEntry | undefined {
|
|
151
|
+
return this.#classes.find((e) => e.capabilities.resolvePaths?.discriminator === key);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
|
|
155
|
+
serverMounts(): FormatEntry[] {
|
|
156
|
+
return this.#classes
|
|
157
|
+
.filter((e) => e.server !== null)
|
|
158
|
+
.toSorted(
|
|
159
|
+
(a, b) =>
|
|
160
|
+
(a.server?.order ?? 100) - (b.server?.order ?? 100) || a.name.localeCompare(b.name),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Connection-provider classes (connector block). */
|
|
165
|
+
connectors(): FormatEntry[] {
|
|
166
|
+
return this.#classes.filter((e) => e.connector !== null);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolved fragment paths of the given kind, in extension declaration order. */
|
|
170
|
+
schemaFragments(kind: SchemaFragmentKind): string[] {
|
|
171
|
+
return this.#extensions
|
|
172
|
+
.map((e) => e.schemas[kind])
|
|
173
|
+
.filter((p): p is string => typeof p === "string");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build a registry from a project's `extensions` list.
|
|
179
|
+
*
|
|
180
|
+
* Specifiers are bare package names (resolved project-first through the package's exports map) or
|
|
181
|
+
* relative paths. Unlike the legacy imports scan, everything here is explicit — an unresolvable
|
|
182
|
+
* manifest or class file is an error, not a silent skip.
|
|
183
|
+
*
|
|
184
|
+
* @param {string[] | undefined} extensions - The project.json `extensions` array
|
|
185
|
+
* @param {FormatHostIO} io - Injected host I/O
|
|
186
|
+
* @param {string} base - Reference file for relative resolution (convention: `<root>/project.json`)
|
|
187
|
+
*/
|
|
188
|
+
export async function buildExtensionRegistry(
|
|
189
|
+
extensions: string[] | undefined,
|
|
190
|
+
io: FormatHostIO,
|
|
191
|
+
base: string,
|
|
192
|
+
): Promise<ExtensionRegistry> {
|
|
193
|
+
const infos: ExtensionInfo[] = [];
|
|
194
|
+
for (const specifier of extensions ?? []) {
|
|
195
|
+
infos.push(await loadExtension(specifier, io, base));
|
|
196
|
+
}
|
|
197
|
+
return new ExtensionRegistry(infos);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function loadExtension(
|
|
201
|
+
specifier: string,
|
|
202
|
+
io: FormatHostIO,
|
|
203
|
+
base: string,
|
|
204
|
+
): Promise<ExtensionInfo> {
|
|
205
|
+
let manifestPath: string;
|
|
206
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
207
|
+
const dir = io.resolvePath(base, specifier);
|
|
208
|
+
manifestPath = io.resolvePath(`${dir}/package.json`, `./${EXTENSION_MANIFEST}`);
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
manifestPath = io.resolvePath(base, `${specifier}/${EXTENSION_MANIFEST}`);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Extension "${specifier}" is not resolvable: it must export ${EXTENSION_MANIFEST} ` +
|
|
215
|
+
`(is the package installed, and does its exports map include it?): ${errorMessage(error)}`,
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let manifest: ExtensionManifest;
|
|
222
|
+
try {
|
|
223
|
+
manifest = (await io.loadJson(manifestPath)) as unknown as ExtensionManifest;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Extension "${specifier}": cannot read manifest ${manifestPath}: ${errorMessage(error)}`,
|
|
227
|
+
{ cause: error },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!manifest || typeof manifest !== "object" || typeof manifest.name !== "string") {
|
|
231
|
+
throw new Error(`Extension "${specifier}": ${EXTENSION_MANIFEST} must declare a "name"`);
|
|
232
|
+
}
|
|
233
|
+
if (!specifier.startsWith(".") && manifest.name !== specifier) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Extension "${specifier}": manifest name "${manifest.name}" does not match the specifier`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const classes: FormatEntry[] = [];
|
|
240
|
+
for (const [name, ref] of Object.entries(manifest.classes ?? {})) {
|
|
241
|
+
const classPath = io.resolvePath(manifestPath, ref);
|
|
242
|
+
let classDef: Record<string, unknown>;
|
|
243
|
+
try {
|
|
244
|
+
classDef = await io.loadJson(classPath);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Extension "${specifier}": cannot read class "${name}" at ${classPath}: ${errorMessage(error)}`,
|
|
248
|
+
{ cause: error },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
classes.push(new FormatEntry(name, classPath, classDef, io));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const schemas: ExtensionInfo["schemas"] = {};
|
|
255
|
+
if (manifest.schemas?.project) {
|
|
256
|
+
schemas.project = io.resolvePath(manifestPath, manifest.schemas.project);
|
|
257
|
+
}
|
|
258
|
+
if (manifest.schemas?.document) {
|
|
259
|
+
schemas.document = io.resolvePath(manifestPath, manifest.schemas.document);
|
|
260
|
+
}
|
|
261
|
+
if (manifest.schemas?.fields) {
|
|
262
|
+
schemas.fields = io.resolvePath(manifestPath, manifest.schemas.fields);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { specifier, manifestPath, manifest, classes, schemas };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function errorMessage(error: unknown): string {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|
package/src/format-registry.ts
CHANGED
|
@@ -19,6 +19,34 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
19
19
|
"load",
|
|
20
20
|
];
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* All well-known static capability roles across admission blocks (specs/extensions.md §8): format
|
|
24
|
+
* roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), server-mount
|
|
25
|
+
* (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
|
|
26
|
+
*/
|
|
27
|
+
export type ExtensionCapability =
|
|
28
|
+
| FormatCapability
|
|
29
|
+
| "projectData"
|
|
30
|
+
| "resolvePaths"
|
|
31
|
+
| "lower"
|
|
32
|
+
| "mount"
|
|
33
|
+
| "dialect"
|
|
34
|
+
| "deploySchema"
|
|
35
|
+
| "bindings"
|
|
36
|
+
| "testConnection";
|
|
37
|
+
|
|
38
|
+
export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
|
|
39
|
+
...FORMAT_CAPABILITIES,
|
|
40
|
+
"projectData",
|
|
41
|
+
"resolvePaths",
|
|
42
|
+
"lower",
|
|
43
|
+
"mount",
|
|
44
|
+
"dialect",
|
|
45
|
+
"deploySchema",
|
|
46
|
+
"bindings",
|
|
47
|
+
"testConnection",
|
|
48
|
+
];
|
|
49
|
+
|
|
22
50
|
export type FormatTiming = "compiler" | "server" | "client";
|
|
23
51
|
|
|
24
52
|
export type FormatDocumentKind = "page" | "component" | "content";
|
|
@@ -62,6 +90,32 @@ export interface CapabilityInfo {
|
|
|
62
90
|
identifier: string;
|
|
63
91
|
/** Environments allowed to call directly; others round-trip via the dev server. */
|
|
64
92
|
timing: FormatTiming[];
|
|
93
|
+
/** For `resolvePaths`: the `$paths` key that routes to this class. */
|
|
94
|
+
discriminator?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A class owns a project.json section iff its descriptor carries a `project` block. */
|
|
98
|
+
export interface ProjectBlock {
|
|
99
|
+
key: string;
|
|
100
|
+
title?: string;
|
|
101
|
+
description?: string;
|
|
102
|
+
referenceable?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A class mounts routes in the site worker/dev server iff it carries a `server` block. */
|
|
106
|
+
export interface ServerBlock {
|
|
107
|
+
basePath: string;
|
|
108
|
+
order?: number;
|
|
109
|
+
module?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A class provides database connections iff it carries a `connector` block. */
|
|
113
|
+
export interface ConnectorBlock {
|
|
114
|
+
provider: string;
|
|
115
|
+
kind: "sqlite" | "postgres";
|
|
116
|
+
local?: string;
|
|
117
|
+
serve?: string;
|
|
118
|
+
[key: string]: unknown;
|
|
65
119
|
}
|
|
66
120
|
|
|
67
121
|
export interface FormatHostIO {
|
|
@@ -78,12 +132,16 @@ interface ClassMethodLike {
|
|
|
78
132
|
identifier?: string;
|
|
79
133
|
scope?: string;
|
|
80
134
|
timing?: string[];
|
|
135
|
+
discriminator?: string;
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
interface ClassDefLike {
|
|
84
139
|
title?: string;
|
|
85
140
|
$implementation?: string;
|
|
86
141
|
format?: FormatBlock;
|
|
142
|
+
project?: ProjectBlock;
|
|
143
|
+
server?: ServerBlock;
|
|
144
|
+
connector?: ConnectorBlock;
|
|
87
145
|
$studio?: StudioHints;
|
|
88
146
|
$defs?: { methods?: Record<string, ClassMethodLike> };
|
|
89
147
|
[key: string]: unknown;
|
|
@@ -101,7 +159,10 @@ export class FormatEntry {
|
|
|
101
159
|
readonly exportTarget: boolean;
|
|
102
160
|
readonly remote: boolean;
|
|
103
161
|
readonly studio: StudioHints | null;
|
|
104
|
-
readonly
|
|
162
|
+
readonly project: ProjectBlock | null;
|
|
163
|
+
readonly server: ServerBlock | null;
|
|
164
|
+
readonly connector: ConnectorBlock | null;
|
|
165
|
+
readonly capabilities: Partial<Record<ExtensionCapability, CapabilityInfo>>;
|
|
105
166
|
|
|
106
167
|
#io: FormatHostIO;
|
|
107
168
|
#implementation: Promise<Record<string, unknown>> | null = null;
|
|
@@ -112,13 +173,18 @@ export class FormatEntry {
|
|
|
112
173
|
this.classDef = classDef;
|
|
113
174
|
this.#io = io;
|
|
114
175
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
176
|
+
// The format block is optional: entries also represent project/server/connector classes
|
|
177
|
+
// (specs/extensions.md §6). Absent block → no extensions claimed.
|
|
178
|
+
const { format } = classDef;
|
|
179
|
+
this.extensions = (format?.extensions ?? []).map((e) => e.toLowerCase());
|
|
180
|
+
this.mediaType = format?.mediaType ?? null;
|
|
181
|
+
this.documentKinds = format?.documentKinds ?? [];
|
|
182
|
+
this.exportTarget = format?.exportTarget === true;
|
|
183
|
+
this.remote = format?.remote === true;
|
|
121
184
|
this.studio = classDef.$studio ?? null;
|
|
185
|
+
this.project = classDef.project ?? null;
|
|
186
|
+
this.server = classDef.server ?? null;
|
|
187
|
+
this.connector = classDef.connector ?? null;
|
|
122
188
|
this.capabilities = extractCapabilities(classDef);
|
|
123
189
|
}
|
|
124
190
|
|
|
@@ -137,7 +203,7 @@ export class FormatEntry {
|
|
|
137
203
|
}
|
|
138
204
|
|
|
139
205
|
/** Invoke a capability's static method on the implementation class. */
|
|
140
|
-
async call(capability:
|
|
206
|
+
async call(capability: ExtensionCapability, ...args: unknown[]): Promise<unknown> {
|
|
141
207
|
const cap = this.capabilities[capability];
|
|
142
208
|
if (!cap) {
|
|
143
209
|
throw new Error(`Format class "${this.name}" does not declare a "${capability}" capability`);
|
|
@@ -264,18 +330,22 @@ export async function buildFormatRegistry(
|
|
|
264
330
|
|
|
265
331
|
function extractCapabilities(
|
|
266
332
|
classDef: ClassDefLike,
|
|
267
|
-
): Partial<Record<
|
|
268
|
-
const out: Partial<Record<
|
|
333
|
+
): Partial<Record<ExtensionCapability, CapabilityInfo>> {
|
|
334
|
+
const out: Partial<Record<ExtensionCapability, CapabilityInfo>> = {};
|
|
269
335
|
const methods = classDef.$defs?.methods ?? {};
|
|
270
336
|
for (const [key, method] of Object.entries(methods)) {
|
|
271
|
-
const role = method.role as
|
|
272
|
-
if (!role || !
|
|
337
|
+
const role = method.role as ExtensionCapability | undefined;
|
|
338
|
+
if (!role || !EXTENSION_CAPABILITIES.includes(role)) {
|
|
273
339
|
continue;
|
|
274
340
|
}
|
|
275
|
-
|
|
341
|
+
const info: CapabilityInfo = {
|
|
276
342
|
identifier: method.identifier ?? key,
|
|
277
343
|
timing: (method.timing as FormatTiming[] | undefined) ?? DEFAULT_TIMING,
|
|
278
344
|
};
|
|
345
|
+
if (typeof method.discriminator === "string") {
|
|
346
|
+
info.discriminator = method.discriminator;
|
|
347
|
+
}
|
|
348
|
+
out[role] = info;
|
|
279
349
|
}
|
|
280
350
|
return out;
|
|
281
351
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project schema emitters (specs/extensions.md §5.2).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: they emit the generated entry documents (project.schema.json and
|
|
5
|
+
* document.schema.json) from already-resolved, project-relative fragment paths. All composition is
|
|
6
|
+
* standard JSON Schema 2020-12:
|
|
7
|
+
*
|
|
8
|
+
* - A plain `allOf` over fragment `$ref`s, closed with `unevaluatedProperties: false`;
|
|
9
|
+
* - The two well-known union resources (`PROJECT_FIELDS_SCHEMA_ID`, `DOCUMENT_PATHS_SCHEMA_ID`)
|
|
10
|
+
* re-embedded under `$defs` with their canonical `$id`s, carrying the _effective_ unions.
|
|
11
|
+
* Compound-document `$id` resolution makes every fragment's `$ref` to those URIs land on the
|
|
12
|
+
* entry document's embed instead of the shipped defaults — the override mechanism.
|
|
13
|
+
*
|
|
14
|
+
* Inside the embeds all refs are canonical URIs (never relative paths): a `$ref` inside a resource
|
|
15
|
+
* with an absolute `$id` resolves against that `$id`. Relative `./node_modules/...` paths appear
|
|
16
|
+
* only in the entry document's own `allOf`/`$ref`, resolved against the file location. Hosts
|
|
17
|
+
* validating with ajv should inject an `$id` set to the entry file's URL at load time so those
|
|
18
|
+
* relative refs resolve (the committed files stay machine-path-free).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
|
|
22
|
+
|
|
23
|
+
export { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
|
|
24
|
+
|
|
25
|
+
/** The canonical $id of the core project fragment. */
|
|
26
|
+
export const PROJECT_CORE_SCHEMA_ID = "https://jxsuite.com/schema/project/core/v2";
|
|
27
|
+
|
|
28
|
+
export const GENERATED_SCHEMA_COMMENT =
|
|
29
|
+
"Generated by `jx schema` from project.json#/extensions — do not edit.";
|
|
30
|
+
|
|
31
|
+
export interface ProjectSchemaInputs {
|
|
32
|
+
/**
|
|
33
|
+
* Project-relative path of the core fragment, e.g.
|
|
34
|
+
* "./node_modules/@jxsuite/schema/schemas/project.core.schema.json".
|
|
35
|
+
*/
|
|
36
|
+
corePath: string;
|
|
37
|
+
/** Project-relative extension fragment paths, in extension declaration order. */
|
|
38
|
+
fragments: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Additional field-union members beyond the core field shape and RelationshipRef, as canonical
|
|
41
|
+
* "uri#/pointer" refs (e.g. a connector's column extras). Never relative paths — they resolve
|
|
42
|
+
* against the embed's $id.
|
|
43
|
+
*/
|
|
44
|
+
fieldSchemaRefs?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Emit the `project.schema.json` entry document for a project. */
|
|
48
|
+
export function emitProjectSchema(inputs: ProjectSchemaInputs): Record<string, unknown> {
|
|
49
|
+
const { corePath, fragments, fieldSchemaRefs = [] } = inputs;
|
|
50
|
+
return {
|
|
51
|
+
$comment: GENERATED_SCHEMA_COMMENT,
|
|
52
|
+
$defs: {
|
|
53
|
+
Fields: {
|
|
54
|
+
$id: PROJECT_FIELDS_SCHEMA_ID,
|
|
55
|
+
anyOf: [
|
|
56
|
+
{ $ref: `${PROJECT_CORE_SCHEMA_ID}#/$defs/JxFieldSchema` },
|
|
57
|
+
{ $ref: `${PROJECT_CORE_SCHEMA_ID}#/$defs/RelationshipRef` },
|
|
58
|
+
...fieldSchemaRefs.map((ref) => ({ $ref: ref })),
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
63
|
+
allOf: [{ $ref: corePath }, ...fragments.map((ref) => ({ $ref: ref }))],
|
|
64
|
+
type: "object",
|
|
65
|
+
unevaluatedProperties: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Generic bundler (specs/extensions.md §5.4) ──────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Injected JSON loader for {@link bundleSchema} — receives a normalized, slash-separated path. */
|
|
72
|
+
export type SchemaJsonLoader = (path: string) => Promise<Record<string, unknown>>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bundle an entry document into a single self-contained compound schema for hosts that cannot fetch
|
|
76
|
+
* files (editor schema registration, cloud studio). Every relative-path `$ref` target is loaded
|
|
77
|
+
* through the injected loader and embedded under the entry's `$defs`, keyed by its `$id` (or a
|
|
78
|
+
* generated `urn:jx:bundled:*` one when the file declares none); the referencing `$ref`s are
|
|
79
|
+
* rewritten to those canonical URIs. Resource boundaries are preserved: embedded resources keep
|
|
80
|
+
* their `$id`s, refs inside an embedded absolute-`$id` resource are canonical and stay untouched,
|
|
81
|
+
* and a resource whose `$id` is already present in the compound document is not embedded twice.
|
|
82
|
+
*
|
|
83
|
+
* @param {Record<string, unknown>} entry - The entry document (e.g. a parsed project.schema.json)
|
|
84
|
+
* @param {SchemaJsonLoader} loadJson - Loader for `$ref` target files (host-restricted)
|
|
85
|
+
* @param {string} baseDir - Directory the entry document's relative refs resolve against
|
|
86
|
+
* @returns {Promise<Record<string, unknown>>} A new, self-contained schema (the input is not
|
|
87
|
+
* mutated)
|
|
88
|
+
*/
|
|
89
|
+
export async function bundleSchema(
|
|
90
|
+
entry: Record<string, unknown>,
|
|
91
|
+
loadJson: SchemaJsonLoader,
|
|
92
|
+
baseDir: string,
|
|
93
|
+
): Promise<Record<string, unknown>> {
|
|
94
|
+
const bundled = structuredClone(entry);
|
|
95
|
+
const embeddedByPath = new Map<string, string>();
|
|
96
|
+
const embeddedIds = new Set<string>();
|
|
97
|
+
collectResourceIds(bundled, embeddedIds);
|
|
98
|
+
let generatedCount = 0;
|
|
99
|
+
|
|
100
|
+
/** Ordered queue of embeds so $defs insertion happens after the entry walk (stable output). */
|
|
101
|
+
const embed = async (path: string): Promise<string> => {
|
|
102
|
+
const normalized = normalizeSchemaPath(path);
|
|
103
|
+
const prior = embeddedByPath.get(normalized);
|
|
104
|
+
if (prior !== undefined) {
|
|
105
|
+
return prior;
|
|
106
|
+
}
|
|
107
|
+
let resource: Record<string, unknown>;
|
|
108
|
+
try {
|
|
109
|
+
resource = structuredClone(await loadJson(normalized));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`bundleSchema: cannot load $ref target "${normalized}": ${errorText(error)}`,
|
|
113
|
+
{ cause: error },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const ownId =
|
|
117
|
+
typeof resource.$id === "string" && hasUriScheme(resource.$id) ? resource.$id : null;
|
|
118
|
+
generatedCount += ownId === null ? 1 : 0;
|
|
119
|
+
const id = ownId ?? `urn:jx:bundled:${generatedCount}`;
|
|
120
|
+
embeddedByPath.set(normalized, id);
|
|
121
|
+
if (embeddedIds.has(id)) {
|
|
122
|
+
// The canonical resource is already part of the compound document — refs land on it.
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
embeddedIds.add(id);
|
|
126
|
+
resource.$id = id;
|
|
127
|
+
await rewrite(resource, schemaDirOf(normalized), true);
|
|
128
|
+
const defs = (bundled.$defs ??= {}) as Record<string, unknown>;
|
|
129
|
+
defs[id] = resource;
|
|
130
|
+
return id;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Walk a (sub)schema rewriting relative-path `$ref`s. `fileDir` is the directory relative refs
|
|
135
|
+
* resolve against, or null inside an embedded absolute-`$id` resource (canonical scope).
|
|
136
|
+
*/
|
|
137
|
+
const rewrite = async (node: unknown, fileDir: string | null, isRoot = false): Promise<void> => {
|
|
138
|
+
if (Array.isArray(node)) {
|
|
139
|
+
for (const item of node) {
|
|
140
|
+
await rewrite(item, fileDir);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (node === null || typeof node !== "object") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const obj = node as Record<string, unknown>;
|
|
148
|
+
let scope = fileDir;
|
|
149
|
+
if (!isRoot && typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
|
|
150
|
+
// Entering an embedded resource with a canonical $id: refs inside resolve against that
|
|
151
|
+
// $id (never the file location), so there is nothing to inline.
|
|
152
|
+
scope = null;
|
|
153
|
+
}
|
|
154
|
+
const ref = obj.$ref;
|
|
155
|
+
if (typeof ref === "string" && scope !== null && !ref.startsWith("#") && !hasUriScheme(ref)) {
|
|
156
|
+
const hashIndex = ref.indexOf("#");
|
|
157
|
+
const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
|
|
158
|
+
const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex);
|
|
159
|
+
if (target) {
|
|
160
|
+
const id = await embed(`${scope}/${target}`);
|
|
161
|
+
obj.$ref = `${id}${fragment}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const value of Object.values(obj)) {
|
|
165
|
+
await rewrite(value, scope);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await rewrite(bundled, normalizeSchemaPath(baseDir), true);
|
|
170
|
+
return bundled;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Collect every absolute `$id` already present in a document (embeds like the field union). */
|
|
174
|
+
function collectResourceIds(node: unknown, out: Set<string>): void {
|
|
175
|
+
if (Array.isArray(node)) {
|
|
176
|
+
for (const item of node) {
|
|
177
|
+
collectResourceIds(item, out);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (node === null || typeof node !== "object") {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const obj = node as Record<string, unknown>;
|
|
185
|
+
if (typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
|
|
186
|
+
out.add(obj.$id);
|
|
187
|
+
}
|
|
188
|
+
for (const value of Object.values(obj)) {
|
|
189
|
+
collectResourceIds(value, out);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when a ref/id carries a URI scheme (https:, urn:, file:) — i.e. is not a relative path. */
|
|
194
|
+
function hasUriScheme(ref: string): boolean {
|
|
195
|
+
return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Normalize a path to forward slashes, resolving "." and ".." segments (environment-agnostic). */
|
|
199
|
+
function normalizeSchemaPath(path: string): string {
|
|
200
|
+
const slashed = path.replaceAll("\\", "/");
|
|
201
|
+
const absolute = slashed.startsWith("/");
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
for (const segment of slashed.split("/")) {
|
|
204
|
+
if (segment === "" || segment === ".") {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (segment === "..") {
|
|
208
|
+
if (parts.length > 0 && parts.at(-1) !== "..") {
|
|
209
|
+
parts.pop();
|
|
210
|
+
} else if (!absolute) {
|
|
211
|
+
parts.push("..");
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
parts.push(segment);
|
|
216
|
+
}
|
|
217
|
+
return (absolute ? "/" : "") + parts.join("/");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The directory portion of a normalized schema path ("." when there is none). */
|
|
221
|
+
function schemaDirOf(path: string): string {
|
|
222
|
+
const index = path.lastIndexOf("/");
|
|
223
|
+
if (index === -1) {
|
|
224
|
+
return ".";
|
|
225
|
+
}
|
|
226
|
+
return index === 0 ? "/" : path.slice(0, index);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function errorText(error: unknown): string {
|
|
230
|
+
return error instanceof Error ? error.message : String(error);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface DocumentSchemaInputs {
|
|
234
|
+
/**
|
|
235
|
+
* Project-relative path of the core document schema, e.g.
|
|
236
|
+
* "./node_modules/@jxsuite/schema/schema.json".
|
|
237
|
+
*/
|
|
238
|
+
corePath: string;
|
|
239
|
+
/** Canonical "uri#/pointer" refs unioned into the paths resource, in declaration order. */
|
|
240
|
+
pathsValueRefs: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Emit the `document.schema.json` entry document for a project. */
|
|
244
|
+
export function emitDocumentSchema(inputs: DocumentSchemaInputs): Record<string, unknown> {
|
|
245
|
+
const { corePath, pathsValueRefs } = inputs;
|
|
246
|
+
return {
|
|
247
|
+
$comment: GENERATED_SCHEMA_COMMENT,
|
|
248
|
+
$defs: {
|
|
249
|
+
// With no contributed paths shapes the embed stays permissive — core `$paths` forms
|
|
250
|
+
// (arrays, values, $ref) are validated by the core schema itself.
|
|
251
|
+
PathsValue:
|
|
252
|
+
pathsValueRefs.length > 0
|
|
253
|
+
? {
|
|
254
|
+
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
255
|
+
anyOf: pathsValueRefs.map((ref) => ({ $ref: ref })),
|
|
256
|
+
}
|
|
257
|
+
: { $id: DOCUMENT_PATHS_SCHEMA_ID },
|
|
258
|
+
},
|
|
259
|
+
$ref: corePath,
|
|
260
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
261
|
+
};
|
|
262
|
+
}
|