@narrative.io/jsonforms-provider-protocols 3.0.0-beta.19 → 3.0.0-beta.20

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.
@@ -0,0 +1,381 @@
1
+ import {
2
+ Validator,
3
+ type OutputUnit,
4
+ type Schema,
5
+ type SchemaDraft,
6
+ } from "@cfworker/json-schema";
7
+
8
+ /**
9
+ * CSP-safe JSON Schema validation backed by `@cfworker/json-schema`.
10
+ *
11
+ * Cloudflare Pages and similar strict-CSP environments forbid `new Function`,
12
+ * which is how AJV compiles validators. This factory returns an
13
+ * AJV-shaped facade whose `compile()` produces a synchronous validator
14
+ * function `(data) => boolean` with a mutable `errors` property — enough
15
+ * for `<JsonForms :ajv="..." />` to drop in without any other changes.
16
+ *
17
+ * Only `compile()` is functional; the rest of the surface (`addSchema`,
18
+ * `getSchema`, `removeSchema`, `addFormat`, `addKeyword`, `opts`) exists
19
+ * as no-ops for forward compatibility with plugins that probe the AJV
20
+ * interface but never reach those methods at runtime.
21
+ */
22
+
23
+ // AJV's ErrorObject shape. We don't import `ajv` here to keep the lib's
24
+ // zero-runtime-deps philosophy intact; the consumer's transitive AJV types
25
+ // are structurally compatible.
26
+ export interface NoEvalErrorObject {
27
+ instancePath: string;
28
+ schemaPath: string;
29
+ keyword: string;
30
+ params: Record<string, unknown>;
31
+ message?: string;
32
+ }
33
+
34
+ export interface NoEvalValidateFunction {
35
+ (data: unknown): boolean;
36
+ errors: NoEvalErrorObject[] | null;
37
+ }
38
+
39
+ // Minimal subset of AJV's surface that JsonForms touches. The factory
40
+ // returns this as `unknown as Ajv` at the call site; consumers cast on
41
+ // import or pass directly to `<JsonForms :ajv="..." />`.
42
+ export interface NoEvalAjv {
43
+ compile: (schema: unknown) => NoEvalValidateFunction;
44
+ addSchema: (schema: unknown, key?: string) => NoEvalAjv;
45
+ getSchema: (key: string) => NoEvalValidateFunction | undefined;
46
+ removeSchema: (schemaKeyRef?: unknown) => NoEvalAjv;
47
+ addFormat: (name: string, format: unknown) => NoEvalAjv;
48
+ addKeyword: (definition: unknown) => NoEvalAjv;
49
+ opts: Readonly<Record<string, unknown>>;
50
+ }
51
+
52
+ export interface CreateNoEvalAjvOptions {
53
+ /**
54
+ * JSON Schema draft to validate against. Defaults to `'2020-12'` to match
55
+ * the spec; schemas authored against draft-07 should pass `'7'`.
56
+ */
57
+ draft?: SchemaDraft;
58
+ }
59
+
60
+ const KEYWORD_WRAPPERS = new Set([
61
+ "properties",
62
+ "items",
63
+ "prefixItems",
64
+ "$ref",
65
+ ]);
66
+
67
+ const QUOTED_RE = /(['"])((?:\\.|(?!\1).)*?)\1/g;
68
+
69
+ function findQuoted(s: string): string[] {
70
+ const out: string[] = [];
71
+ let m: RegExpExecArray | null;
72
+ QUOTED_RE.lastIndex = 0;
73
+ while ((m = QUOTED_RE.exec(s)) !== null) {
74
+ out.push(m[2]!);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function firstQuoted(s: string): string | undefined {
80
+ const all = findQuoted(s);
81
+ return all[0];
82
+ }
83
+
84
+ function allQuoted(s: string): string[] {
85
+ return findQuoted(s);
86
+ }
87
+
88
+ const NUMBER_RE = /-?\d+(?:\.\d+)?/g;
89
+
90
+ function extractNumbers(s: string): number[] {
91
+ const out: number[] = [];
92
+ let m: RegExpExecArray | null;
93
+ NUMBER_RE.lastIndex = 0;
94
+ while ((m = NUMBER_RE.exec(s)) !== null) {
95
+ out.push(Number(m[0]));
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function firstNumber(s: string): number | undefined {
101
+ const nums = extractNumbers(s);
102
+ return nums[0];
103
+ }
104
+
105
+ function stripPointerPrefix(p: string): string {
106
+ return p.startsWith("#") ? p.slice(1) : p;
107
+ }
108
+
109
+ interface KeywordTransform {
110
+ params: Record<string, unknown>;
111
+ message: string | undefined;
112
+ }
113
+
114
+ function transformByKeyword(unit: OutputUnit): KeywordTransform {
115
+ const { keyword, error } = unit;
116
+
117
+ switch (keyword) {
118
+ case "required": {
119
+ const prop = firstQuoted(error);
120
+ return {
121
+ params: { missingProperty: prop ?? "" },
122
+ message: prop
123
+ ? `must have required property '${prop}'`
124
+ : "must have required property",
125
+ };
126
+ }
127
+
128
+ case "type": {
129
+ const quoted = allQuoted(error);
130
+ // cfworker emits `Instance type "X" is invalid. Expected "Y".` (single)
131
+ // or `... Expected "Y" or "Z".` (union). Per AJV: `params.type` is the
132
+ // expected type as a string; for unions it's comma-separated.
133
+ const expected = quoted.slice(1).join(",") || quoted[0];
134
+ const message = expected ? `must be ${expected}` : "must be of expected type";
135
+ return {
136
+ params: expected ? { type: expected } : {},
137
+ message,
138
+ };
139
+ }
140
+
141
+ case "enum": {
142
+ // cfworker error: `Instance does not match any of [...].`
143
+ const match = error.match(/\[.*\]/);
144
+ let allowedValues: unknown[] = [];
145
+ if (match) {
146
+ try {
147
+ allowedValues = JSON.parse(match[0]);
148
+ } catch {
149
+ allowedValues = [];
150
+ }
151
+ }
152
+ return {
153
+ params: { allowedValues },
154
+ message: "must be equal to one of the allowed values",
155
+ };
156
+ }
157
+
158
+ case "const":
159
+ return {
160
+ params: {},
161
+ message: "must be equal to constant",
162
+ };
163
+
164
+ case "minimum": {
165
+ const limit = extractNumbers(error)[1];
166
+ return {
167
+ params: { comparison: ">=", limit: limit ?? 0 },
168
+ message: `must be >= ${limit ?? 0}`,
169
+ };
170
+ }
171
+
172
+ case "exclusiveMinimum": {
173
+ const limit = extractNumbers(error)[1];
174
+ return {
175
+ params: { comparison: ">", limit: limit ?? 0 },
176
+ message: `must be > ${limit ?? 0}`,
177
+ };
178
+ }
179
+
180
+ case "maximum": {
181
+ const limit = extractNumbers(error)[1];
182
+ return {
183
+ params: { comparison: "<=", limit: limit ?? 0 },
184
+ message: `must be <= ${limit ?? 0}`,
185
+ };
186
+ }
187
+
188
+ case "exclusiveMaximum": {
189
+ const limit = extractNumbers(error)[1];
190
+ return {
191
+ params: { comparison: "<", limit: limit ?? 0 },
192
+ message: `must be < ${limit ?? 0}`,
193
+ };
194
+ }
195
+
196
+ case "minLength": {
197
+ const limit = firstNumber(error) ?? 0;
198
+ return {
199
+ params: { limit },
200
+ message: `must NOT have fewer than ${limit} characters`,
201
+ };
202
+ }
203
+
204
+ case "maxLength": {
205
+ const limit = firstNumber(error) ?? 0;
206
+ return {
207
+ params: { limit },
208
+ message: `must NOT have more than ${limit} characters`,
209
+ };
210
+ }
211
+
212
+ case "minItems": {
213
+ const limit = firstNumber(error) ?? 0;
214
+ return {
215
+ params: { limit },
216
+ message: `must NOT have fewer than ${limit} items`,
217
+ };
218
+ }
219
+
220
+ case "maxItems": {
221
+ const limit = firstNumber(error) ?? 0;
222
+ return {
223
+ params: { limit },
224
+ message: `must NOT have more than ${limit} items`,
225
+ };
226
+ }
227
+
228
+ case "pattern": {
229
+ const pattern = firstQuoted(error) ?? "";
230
+ return {
231
+ params: { pattern },
232
+ message: `must match pattern "${pattern}"`,
233
+ };
234
+ }
235
+
236
+ case "format": {
237
+ const format = firstQuoted(error) ?? "";
238
+ return {
239
+ params: { format },
240
+ message: `must match format "${format}"`,
241
+ };
242
+ }
243
+
244
+ case "multipleOf": {
245
+ // cfworker emits two numbers: the value and the divisor.
246
+ const nums = extractNumbers(error);
247
+ const multipleOf = nums[1] ?? nums[0] ?? 1;
248
+ return {
249
+ params: { multipleOf },
250
+ message: `must be multiple of ${multipleOf}`,
251
+ };
252
+ }
253
+
254
+ case "uniqueItems":
255
+ return {
256
+ params: {},
257
+ message: "must NOT have duplicate items",
258
+ };
259
+
260
+ case "additionalProperties": {
261
+ const additionalProperty = firstQuoted(error) ?? "";
262
+ return {
263
+ params: { additionalProperty },
264
+ message: `must NOT have additional property '${additionalProperty}'`,
265
+ };
266
+ }
267
+
268
+ case "anyOf":
269
+ return { params: {}, message: "must match a schema in anyOf" };
270
+
271
+ case "oneOf":
272
+ return {
273
+ params: { passingSchemas: null },
274
+ message: "must match exactly one schema in oneOf",
275
+ };
276
+
277
+ case "allOf":
278
+ return { params: {}, message: "must match all schemas in allOf" };
279
+
280
+ case "not":
281
+ return { params: {}, message: "must NOT be valid against schema" };
282
+
283
+ default:
284
+ return { params: {}, message: undefined };
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Transform a single cfworker `OutputUnit` into an AJV-compatible
290
+ * `ErrorObject`, or `null` if the unit is a structural wrapper that should
291
+ * be filtered out (`properties` / `items` / `prefixItems` / `$ref`).
292
+ *
293
+ * Exported for unit testing.
294
+ */
295
+ export function transformUnit(unit: OutputUnit): NoEvalErrorObject | null {
296
+ if (KEYWORD_WRAPPERS.has(unit.keyword)) return null;
297
+
298
+ const { params, message } = transformByKeyword(unit);
299
+
300
+ return {
301
+ instancePath: stripPointerPrefix(unit.instanceLocation),
302
+ schemaPath: unit.keywordLocation,
303
+ keyword: unit.keyword,
304
+ params,
305
+ message: message ?? unit.error,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Transform a cfworker `OutputUnit[]` into an AJV-compatible
311
+ * `ErrorObject[]`, with structural wrappers filtered out.
312
+ *
313
+ * Exported for unit testing.
314
+ */
315
+ export function transformErrors(units: OutputUnit[]): NoEvalErrorObject[] {
316
+ const out: NoEvalErrorObject[] = [];
317
+ for (const unit of units) {
318
+ const transformed = transformUnit(unit);
319
+ if (transformed !== null) out.push(transformed);
320
+ }
321
+ return out;
322
+ }
323
+
324
+ const FROZEN_OPTS: Readonly<Record<string, unknown>> = Object.freeze({
325
+ allErrors: true,
326
+ verbose: true,
327
+ errorDataPath: "",
328
+ strict: false,
329
+ });
330
+
331
+ /**
332
+ * Build a CSP-safe AJV-shaped validator backed by `@cfworker/json-schema`.
333
+ *
334
+ * Each `compile(schema)` call constructs a `Validator` and returns a
335
+ * synchronous validator function. The validator function's `errors`
336
+ * property is reassigned on every call, matching AJV's contract.
337
+ *
338
+ * Validators are memoized per-schema via a `WeakMap`, so JsonForms's
339
+ * repeated `compile()` calls on prop changes don't re-parse the schema
340
+ * graph each time.
341
+ */
342
+ export function createNoEvalAjv(
343
+ options: CreateNoEvalAjvOptions = {},
344
+ ): NoEvalAjv {
345
+ const draft: SchemaDraft = options.draft ?? "2020-12";
346
+ const cache = new WeakMap<object, NoEvalValidateFunction>();
347
+
348
+ const compile = (schema: unknown): NoEvalValidateFunction => {
349
+ if (schema && typeof schema === "object") {
350
+ const cached = cache.get(schema as object);
351
+ if (cached) return cached;
352
+ }
353
+
354
+ const validator = new Validator(schema as Schema | boolean, draft, false);
355
+
356
+ const fn: NoEvalValidateFunction = ((data: unknown) => {
357
+ const result = validator.validate(data);
358
+ fn.errors = result.valid ? null : transformErrors(result.errors);
359
+ return result.valid;
360
+ }) as NoEvalValidateFunction;
361
+
362
+ fn.errors = null;
363
+
364
+ if (schema && typeof schema === "object") {
365
+ cache.set(schema as object, fn);
366
+ }
367
+ return fn;
368
+ };
369
+
370
+ const facade: NoEvalAjv = {
371
+ compile,
372
+ addSchema: () => facade,
373
+ getSchema: () => undefined,
374
+ removeSchema: () => facade,
375
+ addFormat: () => facade,
376
+ addKeyword: () => facade,
377
+ opts: FROZEN_OPTS,
378
+ };
379
+
380
+ return facade;
381
+ }
@@ -5,6 +5,7 @@ import {
5
5
  getProjectedSchema,
6
6
  parseProjectionPath,
7
7
  } from "../../core/projection";
8
+ import { deref } from "../../core/refs";
8
9
 
9
10
  interface ProjectionControl {
10
11
  data: unknown;
@@ -48,27 +49,34 @@ function resolveLabel(
48
49
  * Determine whether the leaf of a projection path is listed in its parent
49
50
  * schema's `required` array. Numeric leaf segments (array indices) are not
50
51
  * considered "required properties".
52
+ *
53
+ * Dereferences `$ref` at every step against `root` (defaulting to `schema`)
54
+ * so that schemas like `items: { $ref: '#/$defs/Foo' }` resolve correctly.
51
55
  */
52
56
  function isProjectedFieldRequired(
53
57
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
58
  schema: Record<string, any>,
55
59
  path: string,
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ root?: Record<string, any>,
56
62
  ): boolean {
57
63
  const segments = parseProjectionPath(path);
58
64
  if (segments.length === 0) return false;
65
+ const rootSchema = root ?? schema;
59
66
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- let current: Record<string, any> = schema;
67
+ let current: Record<string, any> | undefined = deref(schema, rootSchema);
61
68
  for (let i = 0; i < segments.length - 1; i++) {
62
69
  const seg = segments[i]!;
63
70
  if (typeof seg === "number") {
64
- current = current?.items;
71
+ current = deref(current?.items, rootSchema);
65
72
  } else {
66
- current = current?.properties?.[seg];
73
+ current = deref(current?.properties?.[seg], rootSchema);
67
74
  }
68
75
  if (!current) return false;
69
76
  }
70
77
  const last = segments[segments.length - 1];
71
78
  if (typeof last !== "string") return false;
79
+ current = deref(current, rootSchema);
72
80
  return Array.isArray(current?.required) && current.required.includes(last);
73
81
  }
74
82
 
@@ -162,13 +170,16 @@ export function useProjection(
162
170
  };
163
171
  }
164
172
 
165
- // Inject JSONForms state to access raw AJV errors for projected sub-paths.
166
- // control.errors only contains errors at the exact control path (e.g. "data_rates"),
167
- // but projected fields need errors at the full path (e.g. "data_rates.0.video_rate_usd").
168
- const jsonforms = inject<{ core?: { errors?: ErrorLike[] } } | null>(
169
- "jsonforms",
170
- null,
171
- );
173
+ // Inject JSONForms state to access raw AJV errors for projected sub-paths,
174
+ // and the root schema so that `$ref` resolution inside the projected slice
175
+ // can find `$defs` (which live at the root, not on the control's schema).
176
+ const jsonforms = inject<{
177
+ core?: {
178
+ errors?: ErrorLike[];
179
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
+ schema?: Record<string, any>;
181
+ };
182
+ } | null>("jsonforms", null);
172
183
 
173
184
  const fullProjectedPath = control.value.path + "." + projection;
174
185
 
@@ -181,7 +192,11 @@ export function useProjection(
181
192
  );
182
193
 
183
194
  const projectedRequired = computed(() =>
184
- isProjectedFieldRequired(control.value.schema, projection),
195
+ isProjectedFieldRequired(
196
+ control.value.schema,
197
+ projection,
198
+ jsonforms?.core?.schema ?? control.value.schema,
199
+ ),
185
200
  );
186
201
 
187
202
  const label = computed(() =>
@@ -195,7 +210,7 @@ export function useProjection(
195
210
  const projectedErrors = computed(() => {
196
211
  const baseErrors = normalizeErrors(control.value.errors || "");
197
212
 
198
- const rawErrors = jsonforms?.core?.errors ?? [];
213
+ const rawErrors = jsonforms?.core?.errors ?? ([] as ErrorLike[]);
199
214
  const matching = rawErrors.filter(
200
215
  (err) => getErrorPath(err) === fullProjectedPath,
201
216
  );
@@ -1,18 +0,0 @@
1
- /**
2
- * In-DOM debug overlay for `@narrative.io/jsonforms-provider-protocols`.
3
- *
4
- * Renders a fixed-position panel with a scrollable list of structured log
5
- * entries. Used in place of `console.*` because some host apps strip console
6
- * output in production. No-op unless explicitly enabled at runtime via
7
- * either `window.__PP_DEBUG__ = true` or `localStorage.PP_DEBUG = "1"`.
8
- *
9
- * Internal API. Not part of the package's public surface.
10
- */
11
- /**
12
- * Append a structured debug entry to the in-DOM overlay. No-op unless the
13
- * runtime gate is on (`window.__PP_DEBUG__ === true` or
14
- * `localStorage.PP_DEBUG === "1"`). Stringification is lazy: the payload is
15
- * not serialized when the gate is off, so this is safe to leave in hot paths.
16
- */
17
- export declare function debugLog(label: string, payload: unknown): void;
18
- //# sourceMappingURL=overlay.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../src/debug/overlay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AA0TH;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CA4B9D"}