@jxsuite/schema 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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): Record<string, unknown> {
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<Record<string, unknown>>;
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 {Record<string, unknown>} entry - The entry document (e.g. a parsed project.schema.json)
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<Record<string, unknown>>} A new, self-contained schema (the input is not
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: Record<string, unknown>,
115
+ entry: ReadonlyJsonObject,
98
116
  loadJson: SchemaJsonLoader,
99
117
  baseDir: string,
100
118
  alsoEmbed: string[] = [],
101
- ): Promise<Record<string, unknown>> {
102
- const bundled = structuredClone(entry);
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: Record<string, unknown>;
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
- const defs = (bundled.$defs ??= {}) as Record<string, unknown>;
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 (node: unknown, fileDir: string | null, isRoot = false): Promise<void> => {
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 === null || typeof node !== "object") {
180
+ if (!isJsonObject(node)) {
153
181
  return;
154
182
  }
155
- const obj = node as Record<string, unknown>;
183
+ const obj = node;
156
184
  let scope = fileDir;
157
- if (!isRoot && typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
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 (typeof ref === "string" && scope !== null && !ref.startsWith("#") && !hasUriScheme(ref)) {
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
- * @param {Record<string, unknown>} node - The schema object to walk
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 {[Record<string, unknown>, string]} Each subschema paired with its own JSON Pointer
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 (isSchemaObject(item)) {
267
+ if (isJsonObject(item)) {
246
268
  yield [item, `${pointer}/${keyword}/${index}`];
247
269
  }
248
270
  }
249
- } else if (isSchemaObject(value)) {
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 (!isSchemaObject(value)) {
277
+ if (!isJsonObject(value)) {
256
278
  continue;
257
279
  }
258
280
  for (const [name, item] of Object.entries(value)) {
259
- if (isSchemaObject(item)) {
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 (isSchemaObject(item)) {
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 {Record<string, unknown>} entry - A bundled compound document (output of
326
- * {@link bundleSchema})
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: Record<string, unknown>): Record<string, unknown> {
330
- const flat = structuredClone(entry);
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 (isSchemaObject(flat.$defs)) {
356
+ if (isJsonObject(flat.$defs)) {
334
357
  const used = new Set(Object.keys(flat.$defs).filter((key) => !hasUriScheme(key)));
335
- const renamed: Record<string, unknown> = {};
358
+ const renamed: JsonObject = {};
336
359
  for (const [key, value] of Object.entries(flat.$defs)) {
337
- const id = isSchemaObject(value) && typeof value.$id === "string" ? value.$id : key;
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: Record<string, unknown>, pointer: string): void => {
368
+ const scan = (node: JsonObject, pointer: string): void => {
346
369
  const { $id } = node;
347
- if (typeof $id === "string" && hasUriScheme($id) && !idToPointer.has($id)) {
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: Record<string, unknown>, scope: string): void => {
380
+ const rewrite = (node: JsonObject, scope: string): void => {
358
381
  let inner = scope;
359
382
  const { $id } = node;
360
- if (typeof $id === "string" && hasUriScheme($id)) {
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 (typeof ref === "string") {
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: unknown, out: Set<string>): void {
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 === null || typeof node !== "object") {
423
+ if (!isJsonObject(node)) {
401
424
  return;
402
425
  }
403
- const obj = node as Record<string, unknown>;
404
- if (typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
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(error: unknown): string {
449
- return error instanceof Error ? error.message : String(error);
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 {Record<string, unknown>}
495
+ * @returns {ReadonlyJsonObject} Readonly because it shares the core members' `as const` objects
473
496
  */
474
- export function emitDocumentSchema(inputs: DocumentSchemaInputs): Record<string, unknown> {
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<{ project: Record<string, unknown>; document: Record<string, unknown> }>} The
530
- * self-contained entry documents, ready to write or to hand an editor
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(inputs: ComposeProjectSchemasInputs): Promise<{
533
- project: Record<string, unknown>;
534
- document: Record<string, unknown>;
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: { $id?: unknown; $defs?: Record<string, unknown> };
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 (typeof $id !== "string" || !fragment.$defs) {
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
- * Individual schema definitions live in ../defs/ as the single source of truth.
8
- * This module composes them and injects web standards data derived at generation time from:
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
- * webref/elements — HTML element tag names
11
- * webref/css — CSS property names (camelCase CSSOM)
12
- * webref/idl — DOM EventHandler attribute names
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
- * import { generateSchema } from './schema.js';
16
- * const schema = await generateSchema();
17
- * fs.writeFileSync('schema.json', JSON.stringify(schema, null, 2));
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
- * bun run schema.ts [output-path]
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 as listElements } from "@webref/elements";
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
- listElements(),
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
- typeof member.idlType?.idlType === "string" &&
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: Record<string, unknown> = {};
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: Record<string, unknown> = { ...styleObjectSchema.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: unknown): boolean;
728
+ (doc: JsonValue): boolean;
722
729
  errors?: unknown[] | null;
723
730
  }
724
731
  interface AjvInstance {
725
- compile: (schema: unknown) => AjvValidateFn;
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 {Record<string, unknown>} doc - The document to validate
740
- * @param {Record<string, unknown>} schema - A self-contained 2020-12 schema
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
- // The generated schema is JSON Schema 2020-12, so use the matching Ajv build
751
- // (the default `ajv` export is draft-07 and can't compile a 2020-12 schema).
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: Record<string, unknown>) {
769
- return validateWithSchema(doc, (await generateSchema()) as Record<string, unknown>);
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: Record<string, unknown>) {
774
- return validateWithSchema(doc, generateClassSchema() as Record<string, unknown>);
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(process.argv[1] as string, ".."));
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 ?? "null"}${fmt.signalCode ? `, signal ${fmt.signalCode}` : ""}`;
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 = process.argv[1]?.endsWith("schema.ts") ? runSchemaCli() : undefined;
870
+ export const ready = scriptPath?.endsWith("schema.ts") ? runSchemaCli(scriptPath) : undefined;