@jxsuite/schema 0.35.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defs/expression-node.schema.ts +118 -9
- package/defs/extension-manifest.schema.ts +47 -0
- package/defs/field-schema.schema.ts +58 -0
- package/defs/function-def.schema.ts +7 -2
- package/defs/index.ts +10 -2
- package/defs/project-config.schema.ts +20 -11
- package/defs/statement.schema.ts +65 -0
- package/extension-manifest.schema.json +54 -0
- package/package.json +12 -4
- package/project-schema.json +18 -54
- package/schema.json +323 -56
- 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/guards.ts +21 -0
- package/src/project-schemas.ts +262 -0
- package/src/schema.ts +118 -7
- package/src/validate-project.ts +144 -0
- package/types.ts +94 -11
- package/defs/content-type-def.schema.ts +0 -37
|
@@ -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
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -58,11 +58,14 @@ import {
|
|
|
58
58
|
} from "../defs/element-def.schema";
|
|
59
59
|
import { typedStateDefSchema } from "../defs/typed-state-def.schema";
|
|
60
60
|
import { functionDefSchema } from "../defs/function-def.schema";
|
|
61
|
+
import { statementListSchema, statementSchema } from "../defs/statement.schema";
|
|
61
62
|
import { externalClassDefSchema } from "../defs/external-class-def.schema";
|
|
62
63
|
import { pureTypeDefSchema } from "../defs/pure-type-def.schema";
|
|
63
64
|
import {
|
|
64
65
|
assignmentOperatorSchema,
|
|
65
66
|
binaryOperatorSchema,
|
|
67
|
+
callOperatorSchema,
|
|
68
|
+
conditionalOperatorSchema,
|
|
66
69
|
expressionEntrySchema,
|
|
67
70
|
expressionLiteralSchema,
|
|
68
71
|
expressionNodeSchema,
|
|
@@ -71,8 +74,10 @@ import {
|
|
|
71
74
|
mapFilterMethodSchema,
|
|
72
75
|
noArgMethodSchema,
|
|
73
76
|
oneArgMethodSchema,
|
|
77
|
+
pureMethodSchema,
|
|
74
78
|
reduceMethodSchema,
|
|
75
79
|
spliceMethodSchema,
|
|
80
|
+
switchOperatorSchema,
|
|
76
81
|
unaryOperatorSchema,
|
|
77
82
|
} from "../defs/expression-node.schema";
|
|
78
83
|
import {
|
|
@@ -90,7 +95,13 @@ import {
|
|
|
90
95
|
formatDefSchema,
|
|
91
96
|
studioHintsSchema,
|
|
92
97
|
} from "../defs/class-def.schema";
|
|
93
|
-
import {
|
|
98
|
+
import { extensionManifestSchema } from "../defs/extension-manifest.schema";
|
|
99
|
+
import {
|
|
100
|
+
DOCUMENT_PATHS_SCHEMA_ID,
|
|
101
|
+
PROJECT_FIELDS_SCHEMA_ID,
|
|
102
|
+
jxFieldSchemaDef,
|
|
103
|
+
relationshipRefSchema,
|
|
104
|
+
} from "../defs/field-schema.schema";
|
|
94
105
|
import { imageConfigSchema } from "../defs/image-config.schema";
|
|
95
106
|
import { projectConfigSchema } from "../defs/project-config.schema";
|
|
96
107
|
|
|
@@ -212,6 +223,10 @@ export async function generateSchema() {
|
|
|
212
223
|
ExpressionOperand: expressionOperandSchema,
|
|
213
224
|
UnaryOperator: unaryOperatorSchema,
|
|
214
225
|
BinaryOperator: binaryOperatorSchema,
|
|
226
|
+
ConditionalOperator: conditionalOperatorSchema,
|
|
227
|
+
SwitchOperator: switchOperatorSchema,
|
|
228
|
+
CallOperator: callOperatorSchema,
|
|
229
|
+
PureMethod: pureMethodSchema,
|
|
215
230
|
AssignmentOperator: assignmentOperatorSchema,
|
|
216
231
|
NoArgMethod: noArgMethodSchema,
|
|
217
232
|
OneArgMethod: oneArgMethodSchema,
|
|
@@ -220,6 +235,8 @@ export async function generateSchema() {
|
|
|
220
235
|
MapFilterMethod: mapFilterMethodSchema,
|
|
221
236
|
ExpressionNode: expressionNodeSchema,
|
|
222
237
|
ExpressionEntry: expressionEntrySchema,
|
|
238
|
+
Statement: statementSchema,
|
|
239
|
+
StatementList: statementListSchema,
|
|
223
240
|
|
|
224
241
|
// Element defs — with webref data injected
|
|
225
242
|
ElementDef: {
|
|
@@ -261,7 +278,6 @@ export async function generateSchema() {
|
|
|
261
278
|
JsonSchemaType: jsonSchemaTypeSchema,
|
|
262
279
|
HeadEntry: headEntrySchema,
|
|
263
280
|
ImageConfig: imageConfigSchema,
|
|
264
|
-
ContentTypeDef: contentTypeDefSchema,
|
|
265
281
|
},
|
|
266
282
|
$id: "https://jxsuite.com/schema/v1",
|
|
267
283
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -395,23 +411,97 @@ export async function generateSchema() {
|
|
|
395
411
|
export function generateProjectSchema() {
|
|
396
412
|
return {
|
|
397
413
|
$defs: {
|
|
398
|
-
ContentTypeDef: contentTypeDefSchema,
|
|
399
414
|
ImageConfig: imageConfigSchema,
|
|
400
415
|
},
|
|
401
416
|
$id: "https://jxsuite.com/schema/project/v1",
|
|
402
417
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
403
|
-
additionalProperties: false,
|
|
404
418
|
description:
|
|
405
419
|
"Schema for Jx project.json files. " +
|
|
406
420
|
"A project.json file is the root anchor file for a Jx project, " +
|
|
407
|
-
"declaring site metadata, default settings, global styles,
|
|
408
|
-
"and build configuration."
|
|
421
|
+
"declaring site metadata, default settings, global styles, extensions, " +
|
|
422
|
+
"and build configuration. Open by design: extension-contributed sections " +
|
|
423
|
+
"are opaque top-level keys; the generated per-project entry document " +
|
|
424
|
+
"(project.schema.json) closes the composition.",
|
|
409
425
|
properties: projectConfigSchema.properties,
|
|
410
426
|
title: "Jx Project",
|
|
411
427
|
type: "object",
|
|
412
428
|
};
|
|
413
429
|
}
|
|
414
430
|
|
|
431
|
+
// ─── Project Core Fragment Generator ─────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The core fragment of the composed per-project schema (specs/extensions.md §5.1): core
|
|
435
|
+
* project.json properties only — no extension sections — and deliberately open, because closure
|
|
436
|
+
* (`unevaluatedProperties: false`) happens in the generated entry document. Publishes the
|
|
437
|
+
* `JxFieldSchema` and `RelationshipRef` defs the entry document unions into the `jxFieldSchema`
|
|
438
|
+
* dynamic anchor.
|
|
439
|
+
*/
|
|
440
|
+
export function generateProjectCoreSchema() {
|
|
441
|
+
return {
|
|
442
|
+
$defs: {
|
|
443
|
+
ImageConfig: imageConfigSchema,
|
|
444
|
+
JxFieldSchema: jxFieldSchemaDef,
|
|
445
|
+
RelationshipRef: relationshipRefSchema,
|
|
446
|
+
},
|
|
447
|
+
$id: "https://jxsuite.com/schema/project/core/v2",
|
|
448
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
449
|
+
description:
|
|
450
|
+
"Core fragment of the Jx project.json schema. Open by design: extension fragments " +
|
|
451
|
+
"contribute their own top-level sections and the generated per-project entry document " +
|
|
452
|
+
"(project.schema.json) closes the composition. See specs/extensions.md §5.",
|
|
453
|
+
properties: projectConfigSchema.properties,
|
|
454
|
+
title: "Jx Project Core",
|
|
455
|
+
type: "object",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ─── Union Resource Generators ───────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The shipped DEFAULT field-union resource. The generated per-project entry document re-embeds a
|
|
463
|
+
* resource under the same $id with the effective union (adding extension extras); by standard
|
|
464
|
+
* compound-document $id resolution the embed shadows this default wherever both are in play.
|
|
465
|
+
*/
|
|
466
|
+
export function generateProjectFieldsSchema() {
|
|
467
|
+
return {
|
|
468
|
+
$id: PROJECT_FIELDS_SCHEMA_ID,
|
|
469
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
470
|
+
anyOf: [
|
|
471
|
+
{ $ref: "https://jxsuite.com/schema/project/core/v2#/$defs/JxFieldSchema" },
|
|
472
|
+
{ $ref: "https://jxsuite.com/schema/project/core/v2#/$defs/RelationshipRef" },
|
|
473
|
+
],
|
|
474
|
+
description:
|
|
475
|
+
"Default field-schema union for section entry schemas: the core field shape plus " +
|
|
476
|
+
"relationship references. Per-project entry documents override this resource with the " +
|
|
477
|
+
"effective union. See specs/extensions.md §5.3.",
|
|
478
|
+
title: "Jx Project Field Union",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The shipped DEFAULT $paths-value resource: permissive until extensions contribute shapes. */
|
|
483
|
+
export function generateDocumentPathsSchema() {
|
|
484
|
+
return {
|
|
485
|
+
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
486
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
487
|
+
description:
|
|
488
|
+
"Default $paths-value union for documents. Per-project entry documents override this " +
|
|
489
|
+
"resource with the union of extension-contributed paths shapes.",
|
|
490
|
+
title: "Jx Document Paths Union",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Extension Manifest Schema Generator ─────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
export function generateExtensionManifestSchema() {
|
|
497
|
+
return {
|
|
498
|
+
$id: "https://jxsuite.com/schema/extension-manifest/v1",
|
|
499
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
500
|
+
title: "Jx Extension Manifest",
|
|
501
|
+
...extensionManifestSchema,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
415
505
|
// ─── Class Schema Generator ─────────────────────────────────────────────────
|
|
416
506
|
|
|
417
507
|
export function generateClassSchema() {
|
|
@@ -545,7 +635,7 @@ export async function validateDocument(doc: Record<string, unknown>) {
|
|
|
545
635
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
546
636
|
|
|
547
637
|
async function runSchemaCli() {
|
|
548
|
-
const { writeFileSync } = await import("node:fs");
|
|
638
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
549
639
|
const { resolve, dirname } = await import("node:path");
|
|
550
640
|
|
|
551
641
|
const schemaDir = dirname(resolve(process.argv[1] as string, ".."));
|
|
@@ -553,10 +643,18 @@ async function runSchemaCli() {
|
|
|
553
643
|
const componentSchema = await generateSchema();
|
|
554
644
|
const projectSchema = generateProjectSchema();
|
|
555
645
|
const classSchema = generateClassSchema();
|
|
646
|
+
const projectCoreSchema = generateProjectCoreSchema();
|
|
647
|
+
const manifestSchema = generateExtensionManifestSchema();
|
|
648
|
+
const fieldsSchema = generateProjectFieldsSchema();
|
|
649
|
+
const pathsSchema = generateDocumentPathsSchema();
|
|
556
650
|
|
|
557
651
|
const componentStr = JSON.stringify(componentSchema, null, 2);
|
|
558
652
|
const projectStr = JSON.stringify(projectSchema, null, 2);
|
|
559
653
|
const classStr = JSON.stringify(classSchema, null, 2);
|
|
654
|
+
const projectCoreStr = JSON.stringify(projectCoreSchema, null, 2);
|
|
655
|
+
const manifestStr = JSON.stringify(manifestSchema, null, 2);
|
|
656
|
+
const fieldsStr = JSON.stringify(fieldsSchema, null, 2);
|
|
657
|
+
const pathsStr = JSON.stringify(pathsSchema, null, 2);
|
|
560
658
|
|
|
561
659
|
const [out] = process.argv.slice(2);
|
|
562
660
|
|
|
@@ -567,10 +665,23 @@ async function runSchemaCli() {
|
|
|
567
665
|
writeFileSync(resolve(schemaDir, "schema.json"), componentStr, "utf8");
|
|
568
666
|
writeFileSync(resolve(schemaDir, "project-schema.json"), projectStr, "utf8");
|
|
569
667
|
writeFileSync(resolve(schemaDir, "class-schema.json"), classStr, "utf8");
|
|
668
|
+
mkdirSync(resolve(schemaDir, "schemas"), { recursive: true });
|
|
669
|
+
writeFileSync(
|
|
670
|
+
resolve(schemaDir, "schemas", "project.core.schema.json"),
|
|
671
|
+
projectCoreStr,
|
|
672
|
+
"utf8",
|
|
673
|
+
);
|
|
674
|
+
writeFileSync(resolve(schemaDir, "schemas", "project.fields.schema.json"), fieldsStr, "utf8");
|
|
675
|
+
writeFileSync(resolve(schemaDir, "schemas", "document.paths.schema.json"), pathsStr, "utf8");
|
|
676
|
+
writeFileSync(resolve(schemaDir, "extension-manifest.schema.json"), manifestStr, "utf8");
|
|
570
677
|
console.error("Generated:");
|
|
571
678
|
console.error(" schema.json (component)");
|
|
572
679
|
console.error(" project-schema.json");
|
|
573
680
|
console.error(" class-schema.json");
|
|
681
|
+
console.error(" schemas/project.core.schema.json");
|
|
682
|
+
console.error(" schemas/project.fields.schema.json");
|
|
683
|
+
console.error(" schemas/document.paths.schema.json");
|
|
684
|
+
console.error(" extension-manifest.schema.json");
|
|
574
685
|
}
|
|
575
686
|
}
|
|
576
687
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate-project — project.json validation against the generated per-project entry schema
|
|
3
|
+
* (specs/extensions.md §5.4).
|
|
4
|
+
*
|
|
5
|
+
* Node-only by design (mirrors the dynamic-ajv-import pattern of `validateDocument`): reads
|
|
6
|
+
* `<root>/project.json` and `<root>/project.schema.json`, compiles the entry document with
|
|
7
|
+
* ajv-2020, and resolves its relative `$ref`s through a file loader restricted to the project root
|
|
8
|
+
* and its node_modules. The entry file's URL is injected as `$id` at load time so the committed,
|
|
9
|
+
* machine-path-free relative refs resolve.
|
|
10
|
+
*
|
|
11
|
+
* When a `./node_modules/...` ref does not exist on disk (workspace hoisting leaves in-repo
|
|
12
|
+
* starters without their own node_modules), the loader falls back to host resolution of the bare
|
|
13
|
+
* specifier — the same project-first-then-host order as createNodeFormatIO.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
export interface ProjectValidationResult {
|
|
22
|
+
valid: boolean;
|
|
23
|
+
errors: unknown[] | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Minimal structural types for the optional `ajv` peer dependency (no direct dependency, and it
|
|
27
|
+
// Ships no types usable from here) — same pattern as validateDocument in src/schema.ts.
|
|
28
|
+
interface AjvValidateFn {
|
|
29
|
+
(doc: unknown): boolean;
|
|
30
|
+
errors?: unknown[] | null;
|
|
31
|
+
}
|
|
32
|
+
interface AjvInstance {
|
|
33
|
+
compileAsync: (schema: unknown) => Promise<AjvValidateFn>;
|
|
34
|
+
}
|
|
35
|
+
type AjvCtor = new (opts: {
|
|
36
|
+
allErrors: boolean;
|
|
37
|
+
strict: boolean;
|
|
38
|
+
loadSchema: (uri: string) => Promise<Record<string, unknown>>;
|
|
39
|
+
}) => AjvInstance;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate `<projectRoot>/project.json` against `<projectRoot>/project.schema.json`.
|
|
43
|
+
*
|
|
44
|
+
* Validation failures are data (`{ valid: false, errors }`); infrastructure failures — missing
|
|
45
|
+
* files, unresolvable refs, refs escaping the project root — throw.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
48
|
+
* @returns {Promise<ProjectValidationResult>}
|
|
49
|
+
*/
|
|
50
|
+
export async function validateProjectFile(projectRoot: string): Promise<ProjectValidationResult> {
|
|
51
|
+
const root = resolve(projectRoot);
|
|
52
|
+
const projectPath = resolve(root, "project.json");
|
|
53
|
+
const schemaPath = resolve(root, "project.schema.json");
|
|
54
|
+
|
|
55
|
+
if (!existsSync(projectPath)) {
|
|
56
|
+
throw new Error(`project.json not found in ${root}`);
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(schemaPath)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`project.schema.json not found in ${root} — run \`jx schema\` to generate it from ` +
|
|
61
|
+
`project.json#/extensions`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const project = JSON.parse(readFileSync(projectPath, "utf8")) as Record<string, unknown>;
|
|
66
|
+
const entrySchema = JSON.parse(readFileSync(schemaPath, "utf8")) as Record<string, unknown>;
|
|
67
|
+
// The committed entry document is machine-path-free; injecting its file URL as $id makes its
|
|
68
|
+
// Relative ./node_modules/... refs resolve against the project root.
|
|
69
|
+
entrySchema.$id = pathToFileURL(schemaPath).href;
|
|
70
|
+
|
|
71
|
+
let Ajv: AjvCtor;
|
|
72
|
+
try {
|
|
73
|
+
// The entry document is JSON Schema 2020-12, so use the matching Ajv build.
|
|
74
|
+
({ default: Ajv } = (await import("ajv/dist/2020")) as { default: AjvCtor });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error("Project validation requires ajv: bun add ajv", { cause: error });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ajv = new Ajv({
|
|
80
|
+
allErrors: true,
|
|
81
|
+
loadSchema: (uri) => loadProjectSchemaRef(uri, root),
|
|
82
|
+
strict: false,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const validate = await ajv.compileAsync(entrySchema);
|
|
86
|
+
const valid = validate(project);
|
|
87
|
+
return { errors: validate.errors ?? null, valid };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Restricted schema loader: only `file:` URLs inside the project root are served. Missing files
|
|
92
|
+
* under `<root>/node_modules/` fall back to host resolution of the corresponding bare specifier
|
|
93
|
+
* (in-repo starters have no local node_modules; the fragments still ship with the host).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} uri - The `$ref` target ajv could not resolve from already-loaded resources
|
|
96
|
+
* @param {string} root - Absolute project root
|
|
97
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
98
|
+
*/
|
|
99
|
+
async function loadProjectSchemaRef(uri: string, root: string): Promise<Record<string, unknown>> {
|
|
100
|
+
if (!uri.startsWith("file:")) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Cannot resolve schema ref "${uri}": only project-relative file refs are loadable ` +
|
|
103
|
+
`(canonical URIs must resolve from already-loaded fragments)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const filePath = fileURLToPath(uri);
|
|
108
|
+
const rel = relative(root, filePath);
|
|
109
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
110
|
+
throw new Error(`Schema ref "${uri}" escapes the project root ${root}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (existsSync(filePath)) {
|
|
114
|
+
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Host fallback: <root>/node_modules/<specifier> is absent — resolve the specifier through the
|
|
118
|
+
// Project's own resolution first, then the host's (workspace symlinks resolve transparently).
|
|
119
|
+
const posixRel = rel.split("\\").join("/");
|
|
120
|
+
const marker = "node_modules/";
|
|
121
|
+
const markerIndex = posixRel.lastIndexOf(marker);
|
|
122
|
+
if (markerIndex === -1) {
|
|
123
|
+
throw new Error(`Schema ref "${uri}" does not exist`);
|
|
124
|
+
}
|
|
125
|
+
const specifier = posixRel.slice(markerIndex + marker.length);
|
|
126
|
+
|
|
127
|
+
let resolved: string;
|
|
128
|
+
try {
|
|
129
|
+
const projRequire = createRequire(resolve(root, "package.json"));
|
|
130
|
+
resolved = projRequire.resolve(specifier);
|
|
131
|
+
} catch {
|
|
132
|
+
try {
|
|
133
|
+
const selfRequire = createRequire(import.meta.url);
|
|
134
|
+
resolved = selfRequire.resolve(specifier);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
138
|
+
`project or the host`,
|
|
139
|
+
{ cause: error },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
144
|
+
}
|