@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/project-schemas.ts
CHANGED
|
@@ -23,20 +23,39 @@ import {
|
|
|
23
23
|
PROJECT_FIELDS_SCHEMA_ID,
|
|
24
24
|
documentPathsCoreMembers,
|
|
25
25
|
} from "../defs/field-schema.schema";
|
|
26
|
+
import type { JsonObject, JsonPrimitive, JsonValue } from "../types";
|
|
27
|
+
import { isJsonObject, isString } from "./guards";
|
|
26
28
|
|
|
27
29
|
export { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* JSON as the schema builders write it. The `defs/*.schema.ts` fragments are `as const` literals,
|
|
33
|
+
* readonly all the way down, and a readonly array is not a `JsonValue[]`. Every {@link JsonValue}
|
|
34
|
+
* is also one of these, so a function that only reads or clones a schema accepts this type.
|
|
35
|
+
*/
|
|
36
|
+
export type ReadonlyJsonValue = JsonPrimitive | readonly ReadonlyJsonValue[] | ReadonlyJsonObject;
|
|
37
|
+
|
|
38
|
+
/** The object member of {@link ReadonlyJsonValue}. */
|
|
39
|
+
export interface ReadonlyJsonObject {
|
|
40
|
+
readonly [key: string]: ReadonlyJsonValue;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/** The canonical $id of the core project fragment. */
|
|
30
44
|
export const PROJECT_CORE_SCHEMA_ID = "https://jxsuite.com/schema/project/core/v2";
|
|
31
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The conventional project-relative ref to the core project fragment: where `jx schema` points a
|
|
48
|
+
* project's entry document at the core it composes over when `@jxsuite/schema` is installed in the
|
|
49
|
+
* project's own `node_modules`.
|
|
50
|
+
*/
|
|
51
|
+
export const CONVENTIONAL_PROJECT_CORE_REF =
|
|
52
|
+
"./node_modules/@jxsuite/schema/schemas/project.core.schema.json";
|
|
53
|
+
|
|
32
54
|
export const GENERATED_SCHEMA_COMMENT =
|
|
33
55
|
"Generated by `jx schema` from project.json#/extensions — do not edit.";
|
|
34
56
|
|
|
35
57
|
export interface ProjectSchemaInputs {
|
|
36
|
-
/**
|
|
37
|
-
* Project-relative path of the core fragment, e.g.
|
|
38
|
-
* "./node_modules/@jxsuite/schema/schemas/project.core.schema.json".
|
|
39
|
-
*/
|
|
58
|
+
/** Project-relative path of the core fragment, e.g. {@link CONVENTIONAL_PROJECT_CORE_REF}. */
|
|
40
59
|
corePath: string;
|
|
41
60
|
/** Project-relative extension fragment paths, in extension declaration order. */
|
|
42
61
|
fragments: string[];
|
|
@@ -49,7 +68,7 @@ export interface ProjectSchemaInputs {
|
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
/** Emit the `project.schema.json` entry document for a project. */
|
|
52
|
-
export function emitProjectSchema(inputs: ProjectSchemaInputs):
|
|
71
|
+
export function emitProjectSchema(inputs: ProjectSchemaInputs): JsonObject {
|
|
53
72
|
const { corePath, fragments, fieldSchemaRefs = [] } = inputs;
|
|
54
73
|
return {
|
|
55
74
|
$comment: GENERATED_SCHEMA_COMMENT,
|
|
@@ -73,7 +92,7 @@ export function emitProjectSchema(inputs: ProjectSchemaInputs): Record<string, u
|
|
|
73
92
|
// ─── Generic bundler (specs/extensions.md §5.4) ──────────────────────────────
|
|
74
93
|
|
|
75
94
|
/** Injected JSON loader for {@link bundleSchema} — receives a normalized, slash-separated path. */
|
|
76
|
-
export type SchemaJsonLoader = (path: string) => Promise<
|
|
95
|
+
export type SchemaJsonLoader = (path: string) => Promise<JsonObject>;
|
|
77
96
|
|
|
78
97
|
/**
|
|
79
98
|
* Bundle an entry document into a single self-contained compound schema for hosts that cannot fetch
|
|
@@ -84,22 +103,23 @@ export type SchemaJsonLoader = (path: string) => Promise<Record<string, unknown>
|
|
|
84
103
|
* their `$id`s, refs inside an embedded absolute-`$id` resource are canonical and stay untouched,
|
|
85
104
|
* and a resource whose `$id` is already present in the compound document is not embedded twice.
|
|
86
105
|
*
|
|
87
|
-
* @param {
|
|
106
|
+
* @param {ReadonlyJsonObject} entry - The entry document (e.g. a parsed project.schema.json)
|
|
88
107
|
* @param {SchemaJsonLoader} loadJson - Loader for `$ref` target files (host-restricted)
|
|
89
108
|
* @param {string} baseDir - Directory the entry document's relative refs resolve against
|
|
90
109
|
* @param {string[]} [alsoEmbed] - Relative paths of resources to embed even though no path-`$ref`
|
|
91
110
|
* reaches them, because they are referenced only by canonical `$id` (a document fragment whose
|
|
92
111
|
* `$defs` feed the paths union). Without this they would stay dangling.
|
|
93
|
-
* @returns {Promise<
|
|
94
|
-
* mutated)
|
|
112
|
+
* @returns {Promise<JsonObject>} A new, self-contained schema (the input is not mutated)
|
|
95
113
|
*/
|
|
96
114
|
export async function bundleSchema(
|
|
97
|
-
entry:
|
|
115
|
+
entry: ReadonlyJsonObject,
|
|
98
116
|
loadJson: SchemaJsonLoader,
|
|
99
117
|
baseDir: string,
|
|
100
118
|
alsoEmbed: string[] = [],
|
|
101
|
-
): Promise<
|
|
102
|
-
|
|
119
|
+
): Promise<JsonObject> {
|
|
120
|
+
/* SAFETY: structuredClone returns a fresh deep copy of pure JSON, so the input's readonly-ness
|
|
121
|
+
protects nothing here: no other reference to this copy exists while it is rewritten. */
|
|
122
|
+
const bundled = structuredClone(entry) as JsonObject;
|
|
103
123
|
const embeddedByPath = new Map<string, string>();
|
|
104
124
|
const embeddedIds = new Set<string>();
|
|
105
125
|
collectResourceIds(bundled, embeddedIds);
|
|
@@ -112,7 +132,7 @@ export async function bundleSchema(
|
|
|
112
132
|
if (prior !== undefined) {
|
|
113
133
|
return prior;
|
|
114
134
|
}
|
|
115
|
-
let resource:
|
|
135
|
+
let resource: JsonObject;
|
|
116
136
|
try {
|
|
117
137
|
resource = structuredClone(await loadJson(normalized));
|
|
118
138
|
} catch (error) {
|
|
@@ -121,8 +141,7 @@ export async function bundleSchema(
|
|
|
121
141
|
{ cause: error },
|
|
122
142
|
);
|
|
123
143
|
}
|
|
124
|
-
const ownId =
|
|
125
|
-
typeof resource.$id === "string" && hasUriScheme(resource.$id) ? resource.$id : null;
|
|
144
|
+
const ownId = isString(resource.$id) && hasUriScheme(resource.$id) ? resource.$id : null;
|
|
126
145
|
generatedCount += ownId === null ? 1 : 0;
|
|
127
146
|
const id = ownId ?? `urn:jx:bundled:${generatedCount}`;
|
|
128
147
|
embeddedByPath.set(normalized, id);
|
|
@@ -133,7 +152,12 @@ export async function bundleSchema(
|
|
|
133
152
|
embeddedIds.add(id);
|
|
134
153
|
resource.$id = id;
|
|
135
154
|
await rewrite(resource, schemaDirOf(normalized), true);
|
|
136
|
-
|
|
155
|
+
/*
|
|
156
|
+
* SAFETY: JSON Schema 2020-12 §8.2.4 requires `$defs` to be an object, and every entry handed
|
|
157
|
+
* here is an emitted entry document (emitProjectSchema / emitDocumentSchema, or the committed
|
|
158
|
+
* file one of them wrote), whose `$defs` is always an object literal; absent, it becomes one.
|
|
159
|
+
*/
|
|
160
|
+
const defs = (bundled.$defs ??= {}) as JsonObject;
|
|
137
161
|
defs[id] = resource;
|
|
138
162
|
return id;
|
|
139
163
|
};
|
|
@@ -142,25 +166,29 @@ export async function bundleSchema(
|
|
|
142
166
|
* Walk a (sub)schema rewriting relative-path `$ref`s. `fileDir` is the directory relative refs
|
|
143
167
|
* resolve against, or null inside an embedded absolute-`$id` resource (canonical scope).
|
|
144
168
|
*/
|
|
145
|
-
const rewrite = async (
|
|
169
|
+
const rewrite = async (
|
|
170
|
+
node: JsonValue,
|
|
171
|
+
fileDir: string | null,
|
|
172
|
+
isRoot = false,
|
|
173
|
+
): Promise<void> => {
|
|
146
174
|
if (Array.isArray(node)) {
|
|
147
175
|
for (const item of node) {
|
|
148
176
|
await rewrite(item, fileDir);
|
|
149
177
|
}
|
|
150
178
|
return;
|
|
151
179
|
}
|
|
152
|
-
if (node
|
|
180
|
+
if (!isJsonObject(node)) {
|
|
153
181
|
return;
|
|
154
182
|
}
|
|
155
|
-
const obj = node
|
|
183
|
+
const obj = node;
|
|
156
184
|
let scope = fileDir;
|
|
157
|
-
if (!isRoot &&
|
|
185
|
+
if (!isRoot && isString(obj.$id) && hasUriScheme(obj.$id)) {
|
|
158
186
|
// Entering an embedded resource with a canonical $id: refs inside resolve against that
|
|
159
187
|
// $id (never the file location), so there is nothing to inline.
|
|
160
188
|
scope = null;
|
|
161
189
|
}
|
|
162
190
|
const ref = obj.$ref;
|
|
163
|
-
if (
|
|
191
|
+
if (isString(ref) && scope !== null && !ref.startsWith("#") && !hasUriScheme(ref)) {
|
|
164
192
|
const hashIndex = ref.indexOf("#");
|
|
165
193
|
const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
|
|
166
194
|
const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex);
|
|
@@ -217,11 +245,6 @@ const MAP_SUBSCHEMA_KEYWORDS = [
|
|
|
217
245
|
/** Keywords whose value is an array of subschemas. */
|
|
218
246
|
const LIST_SUBSCHEMA_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
|
|
219
247
|
|
|
220
|
-
/** True for a plain object node (a candidate subschema); booleans and arrays are not. */
|
|
221
|
-
function isSchemaObject(value: unknown): value is Record<string, unknown> {
|
|
222
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
248
|
/** Escape a `$defs` key for use as a JSON Pointer segment (RFC 6901). */
|
|
226
249
|
function escapePointerSegment(segment: string): string {
|
|
227
250
|
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
@@ -230,33 +253,32 @@ function escapePointerSegment(segment: string): string {
|
|
|
230
253
|
/**
|
|
231
254
|
* Walk the known subschema keyword positions of `node`.
|
|
232
255
|
*
|
|
233
|
-
*
|
|
256
|
+
* Only plain objects count as subschemas there; a boolean schema has nothing to walk.
|
|
257
|
+
*
|
|
258
|
+
* @param {JsonObject} node - The schema object to walk
|
|
234
259
|
* @param {string} pointer - JSON Pointer of `node` within the document
|
|
235
|
-
* @yields {[
|
|
260
|
+
* @yields {[JsonObject, string]} Each subschema paired with its own JSON Pointer
|
|
236
261
|
*/
|
|
237
|
-
function* subschemaEntries(
|
|
238
|
-
node: Record<string, unknown>,
|
|
239
|
-
pointer: string,
|
|
240
|
-
): Generator<[Record<string, unknown>, string]> {
|
|
262
|
+
function* subschemaEntries(node: JsonObject, pointer: string): Generator<[JsonObject, string]> {
|
|
241
263
|
for (const keyword of SINGLE_SUBSCHEMA_KEYWORDS) {
|
|
242
264
|
const value = node[keyword];
|
|
243
265
|
if (Array.isArray(value)) {
|
|
244
266
|
for (const [index, item] of value.entries()) {
|
|
245
|
-
if (
|
|
267
|
+
if (isJsonObject(item)) {
|
|
246
268
|
yield [item, `${pointer}/${keyword}/${index}`];
|
|
247
269
|
}
|
|
248
270
|
}
|
|
249
|
-
} else if (
|
|
271
|
+
} else if (isJsonObject(value)) {
|
|
250
272
|
yield [value, `${pointer}/${keyword}`];
|
|
251
273
|
}
|
|
252
274
|
}
|
|
253
275
|
for (const keyword of MAP_SUBSCHEMA_KEYWORDS) {
|
|
254
276
|
const value = node[keyword];
|
|
255
|
-
if (!
|
|
277
|
+
if (!isJsonObject(value)) {
|
|
256
278
|
continue;
|
|
257
279
|
}
|
|
258
280
|
for (const [name, item] of Object.entries(value)) {
|
|
259
|
-
if (
|
|
281
|
+
if (isJsonObject(item)) {
|
|
260
282
|
yield [item, `${pointer}/${keyword}/${escapePointerSegment(name)}`];
|
|
261
283
|
}
|
|
262
284
|
}
|
|
@@ -267,7 +289,7 @@ function* subschemaEntries(
|
|
|
267
289
|
continue;
|
|
268
290
|
}
|
|
269
291
|
for (const [index, item] of value.entries()) {
|
|
270
|
-
if (
|
|
292
|
+
if (isJsonObject(item)) {
|
|
271
293
|
yield [item, `${pointer}/${keyword}/${index}`];
|
|
272
294
|
}
|
|
273
295
|
}
|
|
@@ -322,19 +344,20 @@ function slugForSchemaId(id: string, used: Set<string>): string {
|
|
|
322
344
|
* `$id` shadowing (§5.3) still decides the outcome; it is simply resolved AT FLATTEN TIME, since
|
|
323
345
|
* refs to a shadowed `$id` rewrite to the pointer of the entry document's embed.
|
|
324
346
|
*
|
|
325
|
-
* @param {
|
|
326
|
-
*
|
|
327
|
-
* @returns {Record<string, unknown>} A new single-resource schema (the input is not mutated)
|
|
347
|
+
* @param {ReadonlyJsonObject} entry - A bundled compound document (output of {@link bundleSchema})
|
|
348
|
+
* @returns {JsonObject} A new single-resource schema (the input is not mutated)
|
|
328
349
|
*/
|
|
329
|
-
export function flattenSchema(entry:
|
|
330
|
-
|
|
350
|
+
export function flattenSchema(entry: ReadonlyJsonObject): JsonObject {
|
|
351
|
+
/* SAFETY: structuredClone returns a fresh deep copy of pure JSON, so the input's readonly-ness
|
|
352
|
+
protects nothing here: no other reference to this copy exists while it is rewritten. */
|
|
353
|
+
const flat = structuredClone(entry) as JsonObject;
|
|
331
354
|
|
|
332
355
|
// 1. Rekey URI-keyed `$defs` entries to readable slugs (pointers address them by key).
|
|
333
|
-
if (
|
|
356
|
+
if (isJsonObject(flat.$defs)) {
|
|
334
357
|
const used = new Set(Object.keys(flat.$defs).filter((key) => !hasUriScheme(key)));
|
|
335
|
-
const renamed:
|
|
358
|
+
const renamed: JsonObject = {};
|
|
336
359
|
for (const [key, value] of Object.entries(flat.$defs)) {
|
|
337
|
-
const id =
|
|
360
|
+
const id = isJsonObject(value) && isString(value.$id) ? value.$id : key;
|
|
338
361
|
renamed[hasUriScheme(key) ? slugForSchemaId(id, used) : key] = value;
|
|
339
362
|
}
|
|
340
363
|
flat.$defs = renamed;
|
|
@@ -342,9 +365,9 @@ export function flattenSchema(entry: Record<string, unknown>): Record<string, un
|
|
|
342
365
|
|
|
343
366
|
// 2. Map each embedded resource's `$id` to its pointer here (first wins, as the bundler embeds).
|
|
344
367
|
const idToPointer = new Map<string, string>();
|
|
345
|
-
const scan = (node:
|
|
368
|
+
const scan = (node: JsonObject, pointer: string): void => {
|
|
346
369
|
const { $id } = node;
|
|
347
|
-
if (
|
|
370
|
+
if (isString($id) && hasUriScheme($id) && !idToPointer.has($id)) {
|
|
348
371
|
idToPointer.set($id, pointer);
|
|
349
372
|
}
|
|
350
373
|
for (const [child, childPointer] of subschemaEntries(node, pointer)) {
|
|
@@ -354,10 +377,10 @@ export function flattenSchema(entry: Record<string, unknown>): Record<string, un
|
|
|
354
377
|
scan(flat, "#");
|
|
355
378
|
|
|
356
379
|
// 3. Rewrite refs against the enclosing resource, then dissolve the resource boundary.
|
|
357
|
-
const rewrite = (node:
|
|
380
|
+
const rewrite = (node: JsonObject, scope: string): void => {
|
|
358
381
|
let inner = scope;
|
|
359
382
|
const { $id } = node;
|
|
360
|
-
if (
|
|
383
|
+
if (isString($id) && hasUriScheme($id)) {
|
|
361
384
|
inner = idToPointer.get($id) ?? scope;
|
|
362
385
|
if (inner !== "#") {
|
|
363
386
|
// The root keeps its identity; nested resources must not remain ref targets.
|
|
@@ -365,7 +388,7 @@ export function flattenSchema(entry: Record<string, unknown>): Record<string, un
|
|
|
365
388
|
}
|
|
366
389
|
}
|
|
367
390
|
const ref = node.$ref;
|
|
368
|
-
if (
|
|
391
|
+
if (isString(ref)) {
|
|
369
392
|
const hashIndex = ref.indexOf("#");
|
|
370
393
|
const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
|
|
371
394
|
const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex + 1);
|
|
@@ -390,18 +413,18 @@ export function flattenSchema(entry: Record<string, unknown>): Record<string, un
|
|
|
390
413
|
}
|
|
391
414
|
|
|
392
415
|
/** Collect every absolute `$id` already present in a document (embeds like the field union). */
|
|
393
|
-
function collectResourceIds(node:
|
|
416
|
+
function collectResourceIds(node: JsonValue, out: Set<string>): void {
|
|
394
417
|
if (Array.isArray(node)) {
|
|
395
418
|
for (const item of node) {
|
|
396
419
|
collectResourceIds(item, out);
|
|
397
420
|
}
|
|
398
421
|
return;
|
|
399
422
|
}
|
|
400
|
-
if (node
|
|
423
|
+
if (!isJsonObject(node)) {
|
|
401
424
|
return;
|
|
402
425
|
}
|
|
403
|
-
const obj = node
|
|
404
|
-
if (
|
|
426
|
+
const obj = node;
|
|
427
|
+
if (isString(obj.$id) && hasUriScheme(obj.$id)) {
|
|
405
428
|
out.add(obj.$id);
|
|
406
429
|
}
|
|
407
430
|
for (const value of Object.values(obj)) {
|
|
@@ -445,8 +468,8 @@ function schemaDirOf(path: string): string {
|
|
|
445
468
|
return index === 0 ? "/" : path.slice(0, index);
|
|
446
469
|
}
|
|
447
470
|
|
|
448
|
-
function errorText(
|
|
449
|
-
return
|
|
471
|
+
function errorText(cause: unknown): string {
|
|
472
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
450
473
|
}
|
|
451
474
|
|
|
452
475
|
export interface DocumentSchemaInputs {
|
|
@@ -469,9 +492,9 @@ export interface DocumentSchemaInputs {
|
|
|
469
492
|
* pointer stops resolving. Wrapping in `allOf` keeps the merge target off the root.
|
|
470
493
|
*
|
|
471
494
|
* @param {DocumentSchemaInputs} inputs - Core path and the contributed paths-union refs
|
|
472
|
-
* @returns {
|
|
495
|
+
* @returns {ReadonlyJsonObject} Readonly because it shares the core members' `as const` objects
|
|
473
496
|
*/
|
|
474
|
-
export function emitDocumentSchema(inputs: DocumentSchemaInputs):
|
|
497
|
+
export function emitDocumentSchema(inputs: DocumentSchemaInputs): ReadonlyJsonObject {
|
|
475
498
|
const { corePath, pathsValueRefs } = inputs;
|
|
476
499
|
return {
|
|
477
500
|
$comment: GENERATED_SCHEMA_COMMENT,
|
|
@@ -516,6 +539,12 @@ export interface ComposeProjectSchemasInputs {
|
|
|
516
539
|
baseDir: string;
|
|
517
540
|
}
|
|
518
541
|
|
|
542
|
+
/** A project's two composed entry documents, as {@link composeProjectSchemas} returns them. */
|
|
543
|
+
export interface ComposedProjectSchemas {
|
|
544
|
+
project: JsonObject;
|
|
545
|
+
document: JsonObject;
|
|
546
|
+
}
|
|
547
|
+
|
|
519
548
|
/**
|
|
520
549
|
* Compose a project's two committed entry documents from the core schemas and its enabled
|
|
521
550
|
* extensions' fragments — bundled, then flattened to root JSON pointers (§5.2/§5.4).
|
|
@@ -526,13 +555,12 @@ export interface ComposeProjectSchemasInputs {
|
|
|
526
555
|
* — a project's schemas must not depend on which host generated them.
|
|
527
556
|
*
|
|
528
557
|
* @param {ComposeProjectSchemasInputs} inputs - Core refs, extension fragments, loader, base
|
|
529
|
-
* @returns {Promise<
|
|
530
|
-
*
|
|
558
|
+
* @returns {Promise<ComposedProjectSchemas>} The self-contained entry documents, ready to write or
|
|
559
|
+
* to hand an editor
|
|
531
560
|
*/
|
|
532
|
-
export async function composeProjectSchemas(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}> {
|
|
561
|
+
export async function composeProjectSchemas(
|
|
562
|
+
inputs: ComposeProjectSchemasInputs,
|
|
563
|
+
): Promise<ComposedProjectSchemas> {
|
|
536
564
|
const { baseDir, coreDocumentRef, coreProjectRef, extensions, loadJson } = inputs;
|
|
537
565
|
|
|
538
566
|
const fragments: string[] = [];
|
|
@@ -594,14 +622,14 @@ async function fragmentDefsRefs(
|
|
|
594
622
|
loadJson: SchemaJsonLoader,
|
|
595
623
|
baseDir: string,
|
|
596
624
|
): Promise<string[]> {
|
|
597
|
-
let fragment:
|
|
625
|
+
let fragment: JsonObject;
|
|
598
626
|
try {
|
|
599
627
|
fragment = await loadJson(normalizeSchemaPath(`${normalizeSchemaPath(baseDir)}/${ref}`));
|
|
600
628
|
} catch {
|
|
601
629
|
return [];
|
|
602
630
|
}
|
|
603
631
|
const { $id } = fragment;
|
|
604
|
-
if (
|
|
632
|
+
if (!isString($id) || !fragment.$defs) {
|
|
605
633
|
return [];
|
|
606
634
|
}
|
|
607
635
|
return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
|
package/src/schema.ts
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Jx-schema.ts — Jx JSON Schema 2020-12 meta-schema generator
|
|
3
|
-
* @version 2.0.0
|
|
4
|
-
* @license MIT
|
|
5
3
|
*
|
|
6
|
-
* Generates comprehensive JSON Schema 2020-12 documents that validate Jx source files.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Generates comprehensive JSON Schema 2020-12 documents that validate Jx source files. Individual
|
|
5
|
+
* schema definitions live in ../defs/ as the single source of truth. This module composes them and
|
|
6
|
+
* injects web standards data derived at generation time from:
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* - `webref/elements`: HTML element tag names
|
|
9
|
+
* - `webref/css`: CSS property names (camelCase CSSOM)
|
|
10
|
+
* - `webref/idl`: DOM EventHandler attribute names
|
|
13
11
|
*
|
|
14
12
|
* Usage:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { generateSchema } from "./schema.js";
|
|
16
|
+
* const schema = await generateSchema();
|
|
17
|
+
* fs.writeFileSync("schema.json", JSON.stringify(schema, null, 2));
|
|
18
|
+
* ```
|
|
18
19
|
*
|
|
19
20
|
* CLI:
|
|
20
|
-
*
|
|
21
|
+
*
|
|
22
|
+
* ```sh
|
|
23
|
+
* bun run schema.ts [output-path]
|
|
24
|
+
* ```
|
|
21
25
|
*
|
|
22
26
|
* @module jx-schema
|
|
27
|
+
* @license MIT
|
|
28
|
+
* @version 2.0.0
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
|
-
import { listAll
|
|
31
|
+
import { listAll } from "@webref/elements";
|
|
26
32
|
import css from "@webref/css";
|
|
27
33
|
import idl from "@webref/idl";
|
|
28
34
|
|
|
@@ -110,6 +116,8 @@ import {
|
|
|
110
116
|
} from "../defs/field-schema.schema";
|
|
111
117
|
import { imageConfigSchema } from "../defs/image-config.schema";
|
|
112
118
|
import { projectConfigSchema } from "../defs/project-config.schema";
|
|
119
|
+
import type { JsonObject, JsonValue } from "../types";
|
|
120
|
+
import type { ReadonlyJsonObject } from "./project-schemas";
|
|
113
121
|
|
|
114
122
|
// ─── Web standards data loader ────────────────────────────────────────────────
|
|
115
123
|
|
|
@@ -132,7 +140,7 @@ function loadWebData() {
|
|
|
132
140
|
|
|
133
141
|
async function computeWebData() {
|
|
134
142
|
const [elementsData, cssData, idlData] = await Promise.all([
|
|
135
|
-
|
|
143
|
+
listAll(),
|
|
136
144
|
css.listAll(),
|
|
137
145
|
idl.parseAll(),
|
|
138
146
|
]);
|
|
@@ -168,8 +176,7 @@ async function computeWebData() {
|
|
|
168
176
|
if (
|
|
169
177
|
member.type === "attribute" &&
|
|
170
178
|
member.name?.startsWith("on") &&
|
|
171
|
-
|
|
172
|
-
member.idlType.idlType === "EventHandler"
|
|
179
|
+
member.idlType?.idlType === "EventHandler"
|
|
173
180
|
) {
|
|
174
181
|
handlerSet.add(member.name);
|
|
175
182
|
}
|
|
@@ -183,8 +190,8 @@ async function computeWebData() {
|
|
|
183
190
|
|
|
184
191
|
// ─── Injection helpers ─────────────────────────────────────────────────────────
|
|
185
192
|
|
|
186
|
-
function buildEventHandlerProperties(eventHandlers: string[]) {
|
|
187
|
-
const properties:
|
|
193
|
+
function buildEventHandlerProperties(eventHandlers: string[]): JsonObject {
|
|
194
|
+
const properties: JsonObject = {};
|
|
188
195
|
for (const name of eventHandlers) {
|
|
189
196
|
properties[name] = {
|
|
190
197
|
description: `Event handler for the "${name.slice(2)}" event.`,
|
|
@@ -198,11 +205,11 @@ function buildEventHandlerProperties(eventHandlers: string[]) {
|
|
|
198
205
|
return properties;
|
|
199
206
|
}
|
|
200
207
|
|
|
201
|
-
function buildCssProperties(cssProps: string[]) {
|
|
208
|
+
function buildCssProperties(cssProps: string[]): JsonObject {
|
|
202
209
|
/* The authored `properties` come first so a `$`-prefixed metadata key survives: this function
|
|
203
210
|
REPLACES the block's properties rather than adding to them, so anything the hand-written schema
|
|
204
211
|
declared there was silently dropped — which is how `$description` validated as a number. */
|
|
205
|
-
const properties:
|
|
212
|
+
const properties: JsonObject = { ...styleObjectSchema.properties };
|
|
206
213
|
for (const name of cssProps) {
|
|
207
214
|
/* A `{ $ref }` is a reactive VALUE, which the runtime has always resolved (spec.md §9.6) and
|
|
208
215
|
only the validator refused: `style: { fontFamily: { $ref: "$map/item/face" } }` reported
|
|
@@ -718,11 +725,11 @@ export async function generateSchemaString() {
|
|
|
718
725
|
// Minimal structural types for the optional `ajv` / `ajv-formats` peer deps
|
|
719
726
|
// (no direct dependency, and shipping no types usable from here).
|
|
720
727
|
interface AjvValidateFn {
|
|
721
|
-
(doc:
|
|
728
|
+
(doc: JsonValue): boolean;
|
|
722
729
|
errors?: unknown[] | null;
|
|
723
730
|
}
|
|
724
731
|
interface AjvInstance {
|
|
725
|
-
compile: (schema:
|
|
732
|
+
compile: (schema: ReadonlyJsonObject) => AjvValidateFn;
|
|
726
733
|
}
|
|
727
734
|
type AjvCtor = new (opts: {
|
|
728
735
|
allErrors: boolean;
|
|
@@ -736,20 +743,24 @@ type AddFormatsFn = (ajv: AjvInstance) => void;
|
|
|
736
743
|
* document.schema.json from `jx schema`). The schema must be self-contained — external refs are not
|
|
737
744
|
* resolved here.
|
|
738
745
|
*
|
|
739
|
-
* @param {
|
|
740
|
-
* @param {
|
|
746
|
+
* @param {JsonValue} doc - The document to validate
|
|
747
|
+
* @param {ReadonlyJsonObject} schema - A self-contained 2020-12 schema
|
|
741
748
|
* @returns {Promise<{ errors: unknown[] | null; valid: boolean }>}
|
|
742
749
|
*/
|
|
743
|
-
export async function validateWithSchema(
|
|
744
|
-
doc: Record<string, unknown>,
|
|
745
|
-
schema: Record<string, unknown>,
|
|
746
|
-
) {
|
|
750
|
+
export async function validateWithSchema(doc: JsonValue, schema: ReadonlyJsonObject) {
|
|
747
751
|
let Ajv: AjvCtor;
|
|
748
752
|
let addFormats: AddFormatsFn;
|
|
749
753
|
try {
|
|
750
|
-
|
|
751
|
-
|
|
754
|
+
/*
|
|
755
|
+
* The generated schema is JSON Schema 2020-12, so use the matching Ajv build (the default
|
|
756
|
+
* `ajv` export is draft-07 and can't compile a 2020-12 schema).
|
|
757
|
+
* SAFETY: the default export of `ajv/dist/2020` is the Ajv2020 class. AjvCtor restates, in
|
|
758
|
+
* JSON terms, only the three constructor options, `compile` and the validate function's call
|
|
759
|
+
* and `errors` used below; ajv reports a schema value it cannot use as a thrown error.
|
|
760
|
+
*/
|
|
752
761
|
({ default: Ajv } = (await import("ajv/dist/2020")) as { default: AjvCtor });
|
|
762
|
+
/* SAFETY: ajv-formats' default export is `addFormats(ajv, options?)`, which only registers
|
|
763
|
+
format validators on the instance it is given; this call passes the Ajv2020 built above. */
|
|
753
764
|
// @ts-expect-error — optional peer dependency
|
|
754
765
|
({ default: addFormats } = (await import("ajv-formats")) as { default: AddFormatsFn });
|
|
755
766
|
} catch {
|
|
@@ -765,22 +776,22 @@ export async function validateWithSchema(
|
|
|
765
776
|
return { errors: validate.errors ?? null, valid };
|
|
766
777
|
}
|
|
767
778
|
|
|
768
|
-
export async function validateDocument(doc:
|
|
769
|
-
return validateWithSchema(doc,
|
|
779
|
+
export async function validateDocument(doc: JsonValue) {
|
|
780
|
+
return validateWithSchema(doc, await generateSchema());
|
|
770
781
|
}
|
|
771
782
|
|
|
772
783
|
/** Validate a .class.json class definition against the generated class schema. */
|
|
773
|
-
export async function validateClass(doc:
|
|
774
|
-
return validateWithSchema(doc, generateClassSchema()
|
|
784
|
+
export async function validateClass(doc: JsonValue) {
|
|
785
|
+
return validateWithSchema(doc, generateClassSchema());
|
|
775
786
|
}
|
|
776
787
|
|
|
777
788
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
778
789
|
|
|
779
|
-
async function runSchemaCli() {
|
|
790
|
+
async function runSchemaCli(scriptPath: string) {
|
|
780
791
|
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
781
792
|
const { resolve, dirname } = await import("node:path");
|
|
782
793
|
|
|
783
|
-
const schemaDir = dirname(resolve(
|
|
794
|
+
const schemaDir = dirname(resolve(scriptPath, ".."));
|
|
784
795
|
|
|
785
796
|
const componentSchema = await generateSchema();
|
|
786
797
|
const projectSchema = generateProjectSchema();
|
|
@@ -837,7 +848,7 @@ async function runSchemaCli() {
|
|
|
837
848
|
const detail =
|
|
838
849
|
fmt.stderr.toString() ||
|
|
839
850
|
fmt.stdout.toString() ||
|
|
840
|
-
`exit ${fmt.exitCode
|
|
851
|
+
`exit ${fmt.exitCode}${fmt.signalCode ? `, signal ${fmt.signalCode}` : ""}`;
|
|
841
852
|
console.error(`Warning: skipped oxfmt reformat of generated schemas (${detail})`);
|
|
842
853
|
}
|
|
843
854
|
console.error("Generated:");
|
|
@@ -854,5 +865,6 @@ async function runSchemaCli() {
|
|
|
854
865
|
// Runs when invoked as a script or driven by a test that stages argv[1]. The build lives in an async
|
|
855
866
|
// Function rather than a top-level await: Bun's test runtime drops a dynamically-imported module's
|
|
856
867
|
// Top-level-await continuation on Windows. `ready` lets tests await the same sequence.
|
|
868
|
+
const [, scriptPath] = process.argv;
|
|
857
869
|
// oxlint-disable-next-line unicorn/prefer-top-level-await
|
|
858
|
-
export const ready =
|
|
870
|
+
export const ready = scriptPath?.endsWith("schema.ts") ? runSchemaCli(scriptPath) : undefined;
|