@shapio/schema 0.2.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/dist/index.js ADDED
@@ -0,0 +1,2268 @@
1
+ import { _ as STABLE_ID_PATTERN, a as MAX_RICHTEXT_DEPTH, c as BIGINTEGER_PATTERN, d as DECIMAL_PATTERN, f as RICHTEXT_FORMAT, g as UID_PATTERN, h as TIME_PATTERN, i as MARK_RULES, l as DATETIME_PATTERN, m as SLUG_PATTERN, n as richTextMediaIds, o as NODE_RULES, p as RICHTEXT_FORMAT_VERSION, r as validateRichText, s as SAFE_LINK_PATTERN, t as isEmptyRichText, u as DATE_PATTERN, v as createStableId, y as isStableId } from "./validate-BQGutaBo.js";
2
+ import { Type } from "typebox";
3
+ import pluralize from "pluralize";
4
+ import { Value } from "typebox/value";
5
+ //#region src/types/dataTypes.ts
6
+ /** Every stored data type. A field's data type is separate from the editor that renders it (brief §2.5). */
7
+ const DATA_TYPES = [
8
+ "string",
9
+ "text",
10
+ "richtext",
11
+ "number",
12
+ "integer",
13
+ "decimal",
14
+ "biginteger",
15
+ "boolean",
16
+ "date",
17
+ "datetime",
18
+ "time",
19
+ "enum",
20
+ "json",
21
+ "slug",
22
+ "email",
23
+ "url",
24
+ "uid",
25
+ "media",
26
+ "relation",
27
+ "component",
28
+ "dynamiczone"
29
+ ];
30
+ const isDataType = (value) => typeof value === "string" && DATA_TYPES.includes(value);
31
+ /**
32
+ * Scalar types stored as one JSON value per field (string, number or boolean). Only these can be unique,
33
+ * filterable or sortable: their index expression is a single cast of `data ->> fieldId` (ADR 0001).
34
+ */
35
+ const SCALAR_DATA_TYPES = /* @__PURE__ */ new Set([
36
+ "string",
37
+ "text",
38
+ "number",
39
+ "integer",
40
+ "decimal",
41
+ "biginteger",
42
+ "boolean",
43
+ "date",
44
+ "datetime",
45
+ "time",
46
+ "enum",
47
+ "slug",
48
+ "email",
49
+ "url",
50
+ "uid"
51
+ ]);
52
+ /** Types that may carry `unique: true` (boolean and long text make no sense as unique keys). */
53
+ const UNIQUE_CAPABLE_DATA_TYPES = /* @__PURE__ */ new Set([
54
+ "string",
55
+ "number",
56
+ "integer",
57
+ "decimal",
58
+ "biginteger",
59
+ "date",
60
+ "datetime",
61
+ "time",
62
+ "slug",
63
+ "email",
64
+ "url",
65
+ "uid"
66
+ ]);
67
+ /** Types whose values point at other definitions; the reference checks look at these. */
68
+ const REFERENCE_DATA_TYPES = /* @__PURE__ */ new Set([
69
+ "relation",
70
+ "component",
71
+ "dynamiczone"
72
+ ]);
73
+ const STORAGE_FAMILY = {
74
+ string: "jsonString",
75
+ text: "jsonString",
76
+ slug: "jsonString",
77
+ email: "jsonString",
78
+ url: "jsonString",
79
+ uid: "jsonString",
80
+ enum: "jsonString",
81
+ date: "jsonString",
82
+ datetime: "jsonString",
83
+ time: "jsonString",
84
+ number: "jsonNumber",
85
+ integer: "jsonNumber",
86
+ decimal: "numericString",
87
+ biginteger: "numericString",
88
+ boolean: "boolean",
89
+ json: "json",
90
+ richtext: "richtext",
91
+ media: "media",
92
+ relation: "relation",
93
+ component: "component",
94
+ dynamiczone: "dynamiczone"
95
+ };
96
+ //#endregion
97
+ //#region src/types/definitions.ts
98
+ /**
99
+ * Model and component definitions. Both kinds share one table (`models`, with a `kind` discriminator) but
100
+ * have distinct TypeScript types: a component cannot be published, localised or addressed by a route.
101
+ *
102
+ * The `*InputSchema` TypeBox schemas describe what callers send and what schema files contain: IDs and
103
+ * defaulted properties may be omitted. `normalizeDefinition` turns input into the normalized types below,
104
+ * which are what Shapio stores, hashes and diffs.
105
+ */
106
+ const closed$2 = { additionalProperties: false };
107
+ const MODEL_KINDS = ["collection", "singleton"];
108
+ const DEFINITION_KINDS = [...MODEL_KINDS, "component"];
109
+ const SORT_DIRECTIONS = ["asc", "desc"];
110
+ const MAX_FIELDS_PER_DEFINITION = 500;
111
+ const MAX_LABEL_LENGTH = 200;
112
+ const MAX_DESCRIPTION_LENGTH = 2e3;
113
+ const Label = Type.String({
114
+ minLength: 1,
115
+ maxLength: 200
116
+ });
117
+ const Description = Type.Optional(Type.String({ maxLength: MAX_DESCRIPTION_LENGTH }));
118
+ const EditorChoiceInputSchema = Type.Object({
119
+ id: Type.String({
120
+ minLength: 1,
121
+ maxLength: 128
122
+ }),
123
+ options: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
124
+ }, closed$2);
125
+ const FieldInputSchema = Type.Object({
126
+ id: Type.Optional(Type.String()),
127
+ apiKey: Type.String(),
128
+ label: Label,
129
+ /** Help text shown under the control. */
130
+ description: Description,
131
+ type: Type.Enum(DATA_TYPES),
132
+ required: Type.Optional(Type.Boolean()),
133
+ /** Per-locale value. Ignored (must be false) when the model is not localized. */
134
+ localized: Type.Optional(Type.Boolean()),
135
+ /** Readable by delivery principals wherever the model is readable (ADR 0005). Default true. */
136
+ public: Type.Optional(Type.Boolean()),
137
+ unique: Type.Optional(Type.Boolean()),
138
+ filterable: Type.Optional(Type.Boolean()),
139
+ sortable: Type.Optional(Type.Boolean()),
140
+ /** Hidden from editors and the API; stored values are preserved until an explicit cleanup. */
141
+ deprecated: Type.Optional(Type.Boolean()),
142
+ /** Used for new entries and to backfill existing ones when the field becomes required. */
143
+ defaultValue: Type.Optional(Type.Unknown()),
144
+ /** Type-specific settings, validated against SETTINGS_SCHEMAS[type]. */
145
+ settings: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
146
+ editor: Type.Optional(EditorChoiceInputSchema)
147
+ }, closed$2);
148
+ const FieldGroupSchema = Type.Object({
149
+ id: Type.String({
150
+ minLength: 1,
151
+ maxLength: 64
152
+ }),
153
+ label: Label,
154
+ fieldIds: Type.Array(Type.String())
155
+ }, closed$2);
156
+ const ModelDisplayInputSchema = Type.Object({
157
+ /** Field whose value labels an entry in lists, pickers and relation summaries. */
158
+ titleFieldId: Type.Optional(Type.String()),
159
+ /** Columns of the content list, in order. */
160
+ listFieldIds: Type.Optional(Type.Array(Type.String())),
161
+ defaultSort: Type.Optional(Type.Object({
162
+ fieldId: Type.String(),
163
+ direction: Type.Enum(SORT_DIRECTIONS)
164
+ }, closed$2)),
165
+ /** Editor layout: named sections of the entry form. */
166
+ groups: Type.Optional(Type.Array(FieldGroupSchema)),
167
+ /** Fields rendered as blocks in the entry document's canvas, in order. Default: see `effectiveLayout`. */
168
+ canvasFieldIds: Type.Optional(Type.Array(Type.String())),
169
+ /** A single image `media` field shown as the cover above the title. Default: see `effectiveLayout`. */
170
+ coverFieldId: Type.Optional(Type.String()),
171
+ /** Properties shown as chips in the document's strip. Default: the first non-empty ones. */
172
+ stripFieldIds: Type.Optional(Type.Array(Type.String()))
173
+ }, closed$2);
174
+ const ComponentDisplayInputSchema = Type.Object({
175
+ titleFieldId: Type.Optional(Type.String()),
176
+ groups: Type.Optional(Type.Array(FieldGroupSchema))
177
+ }, closed$2);
178
+ const definitionBase = {
179
+ id: Type.Optional(Type.String()),
180
+ apiKey: Type.String(),
181
+ label: Label,
182
+ description: Description,
183
+ fields: Type.Array(FieldInputSchema, { maxItems: 500 })
184
+ };
185
+ const ModelDefinitionInputSchema = Type.Object({
186
+ ...definitionBase,
187
+ kind: Type.Enum(MODEL_KINDS),
188
+ /**
189
+ * Collections only: names the list route and query (`/api/content/articles`, `articles(…)`). Optional
190
+ * here so files written before plural API IDs still parse; normalizing fills it on a collection.
191
+ */
192
+ pluralApiKey: Type.Optional(Type.String()),
193
+ localized: Type.Optional(Type.Boolean()),
194
+ /** Entries have separate draft and published states. Default true. */
195
+ draftAndPublish: Type.Optional(Type.Boolean()),
196
+ display: Type.Optional(ModelDisplayInputSchema)
197
+ }, closed$2);
198
+ const ComponentDefinitionInputSchema = Type.Object({
199
+ ...definitionBase,
200
+ kind: Type.Literal("component"),
201
+ category: Type.Optional(Type.String({
202
+ minLength: 1,
203
+ maxLength: 100
204
+ })),
205
+ icon: Type.Optional(Type.String({
206
+ minLength: 1,
207
+ maxLength: 64
208
+ })),
209
+ display: Type.Optional(ComponentDisplayInputSchema)
210
+ }, closed$2);
211
+ const DefinitionInputSchema = Type.Union([ModelDefinitionInputSchema, ComponentDefinitionInputSchema]);
212
+ const isComponentDefinition = (definition) => definition.kind === "component";
213
+ const isModelDefinition = (definition) => definition.kind !== "component";
214
+ //#endregion
215
+ //#region src/types/locales.ts
216
+ /** BCP 47 subset: language (2–3 letters) plus optional subtags, e.g. `en`, `fr-CA`, `zh-Hant-TW`. */
217
+ const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
218
+ const MAX_FALLBACK_CHAIN = 10;
219
+ const LocaleInputSchema = Type.Object({
220
+ code: Type.String({
221
+ pattern: LOCALE_CODE_PATTERN.source,
222
+ maxLength: 35
223
+ }),
224
+ label: Type.String({
225
+ minLength: 1,
226
+ maxLength: 100
227
+ }),
228
+ /** Locales tried in order when content is missing in this one; the default locale is always last. */
229
+ fallbacks: Type.Optional(Type.Array(Type.String(), { maxItems: 10 }))
230
+ }, { additionalProperties: false });
231
+ //#endregion
232
+ //#region src/types/settings.ts
233
+ /**
234
+ * Per-data-type settings: validation rules and type configuration (relation target, component reference,
235
+ * enum values...). Each schema is closed (`additionalProperties: false`) so a typo in a hand-edited schema
236
+ * file is reported instead of ignored. Defaults are applied by `normalizeDefinition`.
237
+ */
238
+ const closed$1 = { additionalProperties: false };
239
+ const NonNegativeInt = Type.Integer({ minimum: 0 });
240
+ const pattern = (regex) => Type.String({ pattern: regex.source });
241
+ const lengthRules = {
242
+ minLength: Type.Optional(NonNegativeInt),
243
+ maxLength: Type.Optional(Type.Integer({ minimum: 1 }))
244
+ };
245
+ const countRules = {
246
+ min: Type.Optional(NonNegativeInt),
247
+ max: Type.Optional(Type.Integer({ minimum: 1 }))
248
+ };
249
+ const MEDIA_KINDS = [
250
+ "image",
251
+ "video",
252
+ "audio",
253
+ "document",
254
+ "other"
255
+ ];
256
+ const RELATION_CARDINALITIES = ["one", "many"];
257
+ const EnumValueSchema = Type.Object({
258
+ value: Type.String({
259
+ minLength: 1,
260
+ maxLength: 64
261
+ }),
262
+ label: Type.String({
263
+ minLength: 1,
264
+ maxLength: 200
265
+ })
266
+ }, closed$1);
267
+ const SETTINGS_SCHEMAS = {
268
+ string: Type.Object({
269
+ ...lengthRules,
270
+ pattern: Type.Optional(Type.String({
271
+ minLength: 1,
272
+ maxLength: 500
273
+ }))
274
+ }, closed$1),
275
+ text: Type.Object(lengthRules, closed$1),
276
+ richtext: Type.Object({
277
+ formatVersion: Type.Optional(Type.Integer({ minimum: 1 })),
278
+ maxLength: Type.Optional(Type.Integer({ minimum: 1 }))
279
+ }, closed$1),
280
+ number: Type.Object({
281
+ min: Type.Optional(Type.Number()),
282
+ max: Type.Optional(Type.Number())
283
+ }, closed$1),
284
+ integer: Type.Object({
285
+ min: Type.Optional(Type.Integer()),
286
+ max: Type.Optional(Type.Integer())
287
+ }, closed$1),
288
+ decimal: Type.Object({
289
+ precision: Type.Optional(Type.Integer({
290
+ minimum: 1,
291
+ maximum: 1e3
292
+ })),
293
+ scale: Type.Optional(Type.Integer({
294
+ minimum: 0,
295
+ maximum: 1e3
296
+ })),
297
+ min: Type.Optional(pattern(DECIMAL_PATTERN)),
298
+ max: Type.Optional(pattern(DECIMAL_PATTERN))
299
+ }, closed$1),
300
+ biginteger: Type.Object({
301
+ min: Type.Optional(pattern(BIGINTEGER_PATTERN)),
302
+ max: Type.Optional(pattern(BIGINTEGER_PATTERN))
303
+ }, closed$1),
304
+ boolean: Type.Object({}, closed$1),
305
+ date: Type.Object({
306
+ min: Type.Optional(pattern(DATE_PATTERN)),
307
+ max: Type.Optional(pattern(DATE_PATTERN))
308
+ }, closed$1),
309
+ datetime: Type.Object({
310
+ min: Type.Optional(pattern(DATETIME_PATTERN)),
311
+ max: Type.Optional(pattern(DATETIME_PATTERN))
312
+ }, closed$1),
313
+ time: Type.Object({
314
+ min: Type.Optional(pattern(TIME_PATTERN)),
315
+ max: Type.Optional(pattern(TIME_PATTERN))
316
+ }, closed$1),
317
+ enum: Type.Object({
318
+ values: Type.Array(EnumValueSchema, {
319
+ minItems: 1,
320
+ maxItems: 500
321
+ }),
322
+ multiple: Type.Optional(Type.Boolean())
323
+ }, closed$1),
324
+ json: Type.Object({}, closed$1),
325
+ slug: Type.Object({
326
+ ...lengthRules,
327
+ sourceFieldId: Type.Optional(Type.String())
328
+ }, closed$1),
329
+ email: Type.Object({}, closed$1),
330
+ url: Type.Object({ protocols: Type.Optional(Type.Array(Type.Enum([
331
+ "http",
332
+ "https",
333
+ "mailto",
334
+ "tel"
335
+ ]), { minItems: 1 })) }, closed$1),
336
+ uid: Type.Object(lengthRules, closed$1),
337
+ media: Type.Object({
338
+ multiple: Type.Optional(Type.Boolean()),
339
+ allowedKinds: Type.Optional(Type.Array(Type.Enum(MEDIA_KINDS), { minItems: 1 })),
340
+ ...countRules
341
+ }, closed$1),
342
+ relation: Type.Object({
343
+ target: Type.String(),
344
+ cardinality: Type.Enum(RELATION_CARDINALITIES),
345
+ ...countRules
346
+ }, closed$1),
347
+ component: Type.Object({
348
+ component: Type.String(),
349
+ repeatable: Type.Optional(Type.Boolean()),
350
+ ...countRules
351
+ }, closed$1),
352
+ dynamiczone: Type.Object({
353
+ components: Type.Array(Type.String(), {
354
+ minItems: 1,
355
+ maxItems: 100
356
+ }),
357
+ ...countRules
358
+ }, closed$1)
359
+ };
360
+ //#endregion
361
+ //#region src/editors/catalogue.ts
362
+ const closed = { additionalProperties: false };
363
+ const Placeholder = Type.Optional(Type.String({ maxLength: 200 }));
364
+ const isMultipleEnum = (field) => field.type === "enum" && field.settings.multiple;
365
+ const entries = [
366
+ {
367
+ id: "textInput",
368
+ dataTypes: [
369
+ "string",
370
+ "email",
371
+ "url",
372
+ "uid"
373
+ ],
374
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
375
+ },
376
+ {
377
+ id: "textarea",
378
+ dataTypes: ["text", "string"],
379
+ optionsSchema: Type.Object({
380
+ placeholder: Placeholder,
381
+ rows: Type.Optional(Type.Integer({
382
+ minimum: 2,
383
+ maximum: 40
384
+ }))
385
+ }, closed)
386
+ },
387
+ {
388
+ id: "color",
389
+ dataTypes: ["string"],
390
+ optionsSchema: Type.Object({
391
+ format: Type.Optional(Type.Enum(["hex", "rgb"])),
392
+ presets: Type.Optional(Type.Array(Type.String({ maxLength: 32 }), { maxItems: 50 }))
393
+ }, closed)
394
+ },
395
+ {
396
+ id: "richText",
397
+ dataTypes: ["richtext"],
398
+ optionsSchema: Type.Object({ toolbar: Type.Optional(Type.Enum(["full", "minimal"])) }, closed)
399
+ },
400
+ {
401
+ id: "numberInput",
402
+ dataTypes: [
403
+ "number",
404
+ "integer",
405
+ "decimal",
406
+ "biginteger"
407
+ ],
408
+ optionsSchema: Type.Object({
409
+ placeholder: Placeholder,
410
+ step: Type.Optional(Type.Number({ exclusiveMinimum: 0 }))
411
+ }, closed)
412
+ },
413
+ {
414
+ id: "toggle",
415
+ dataTypes: ["boolean"],
416
+ optionsSchema: Type.Object({}, closed)
417
+ },
418
+ {
419
+ id: "checkbox",
420
+ dataTypes: ["boolean"],
421
+ optionsSchema: Type.Object({}, closed)
422
+ },
423
+ {
424
+ id: "segmented",
425
+ dataTypes: ["boolean", "enum"],
426
+ optionsSchema: Type.Object({
427
+ trueLabel: Type.Optional(Type.String({ maxLength: 50 })),
428
+ falseLabel: Type.Optional(Type.String({ maxLength: 50 }))
429
+ }, closed),
430
+ supports: (field) => !isMultipleEnum(field)
431
+ },
432
+ {
433
+ id: "select",
434
+ dataTypes: ["enum"],
435
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
436
+ },
437
+ {
438
+ id: "radio",
439
+ dataTypes: ["enum"],
440
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed),
441
+ supports: (field) => !isMultipleEnum(field)
442
+ },
443
+ {
444
+ id: "checkboxGroup",
445
+ dataTypes: ["enum"],
446
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed),
447
+ supports: isMultipleEnum
448
+ },
449
+ {
450
+ id: "datePicker",
451
+ dataTypes: ["date"],
452
+ optionsSchema: Type.Object({}, closed)
453
+ },
454
+ {
455
+ id: "dateTimePicker",
456
+ dataTypes: ["datetime"],
457
+ optionsSchema: Type.Object({ display: Type.Optional(Type.Enum(["local", "utc"])) }, closed)
458
+ },
459
+ {
460
+ id: "timePicker",
461
+ dataTypes: ["time"],
462
+ optionsSchema: Type.Object({ stepMinutes: Type.Optional(Type.Integer({
463
+ minimum: 1,
464
+ maximum: 60
465
+ })) }, closed)
466
+ },
467
+ {
468
+ id: "slugInput",
469
+ dataTypes: ["slug"],
470
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
471
+ },
472
+ {
473
+ id: "jsonEditor",
474
+ dataTypes: ["json"],
475
+ optionsSchema: Type.Object({ rows: Type.Optional(Type.Integer({
476
+ minimum: 3,
477
+ maximum: 80
478
+ })) }, closed)
479
+ },
480
+ {
481
+ id: "mediaPicker",
482
+ dataTypes: ["media"],
483
+ optionsSchema: Type.Object({}, closed)
484
+ },
485
+ {
486
+ id: "relationPicker",
487
+ dataTypes: ["relation"],
488
+ optionsSchema: Type.Object({ allowInlineCreate: Type.Optional(Type.Boolean()) }, closed)
489
+ },
490
+ {
491
+ id: "componentEditor",
492
+ dataTypes: ["component"],
493
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed)
494
+ },
495
+ {
496
+ id: "dynamicZoneEditor",
497
+ dataTypes: ["dynamiczone"],
498
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed)
499
+ }
500
+ ];
501
+ const EDITOR_CATALOGUE = new Map(entries.map((entry) => [entry.id, entry]));
502
+ /** The editor a field gets when its definition names none. */
503
+ const DEFAULT_EDITORS = {
504
+ string: "textInput",
505
+ text: "textarea",
506
+ richtext: "richText",
507
+ number: "numberInput",
508
+ integer: "numberInput",
509
+ decimal: "numberInput",
510
+ biginteger: "numberInput",
511
+ boolean: "toggle",
512
+ date: "datePicker",
513
+ datetime: "dateTimePicker",
514
+ time: "timePicker",
515
+ enum: "select",
516
+ json: "jsonEditor",
517
+ slug: "slugInput",
518
+ email: "textInput",
519
+ url: "textInput",
520
+ uid: "textInput",
521
+ media: "mediaPicker",
522
+ relation: "relationPicker",
523
+ component: "componentEditor",
524
+ dynamiczone: "dynamicZoneEditor"
525
+ };
526
+ /** `vendor.name` (letters, digits, `_`, `-`), e.g. `acme.colorWheel`. Built-in IDs never contain a dot. */
527
+ const CUSTOM_EDITOR_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z][A-Za-z0-9_-]*)+$/;
528
+ const isCustomEditorId = (id) => CUSTOM_EDITOR_ID_PATTERN.test(id);
529
+ /** Built-in editors that can edit this field (for the admin's editor picker). */
530
+ const listCompatibleEditors = (field) => entries.filter((entry) => entry.dataTypes.includes(field.type) && (entry.supports?.(field) ?? true));
531
+ //#endregion
532
+ //#region src/display/titleField.ts
533
+ /** Field types that may label entries: the only types `display.titleFieldId` accepts, and the fallback's. */
534
+ const TITLE_FIELD_TYPES = /* @__PURE__ */ new Set([
535
+ "string",
536
+ "text",
537
+ "slug",
538
+ "email",
539
+ "uid",
540
+ "enum"
541
+ ]);
542
+ /**
543
+ * Field types the list API's `q` search runs on, checked on top of the title field: an enum title is not
544
+ * searchable. `url` is listed for search only; it is never a title.
545
+ */
546
+ const TEXT_TITLE_FIELD_TYPES = /* @__PURE__ */ new Set([
547
+ "string",
548
+ "text",
549
+ "slug",
550
+ "email",
551
+ "url",
552
+ "uid"
553
+ ]);
554
+ /**
555
+ * The field that labels a definition's entries in lists, pickers, relation summaries and search: the
556
+ * configured `display.titleFieldId` when it points at a live (non-deprecated) field, otherwise the first
557
+ * live field of a title type (TITLE_FIELD_TYPES) in field order, otherwise undefined. `titleFieldId` is
558
+ * optional, so a model built without one still gets a readable title.
559
+ */
560
+ const effectiveTitleField = (definition) => {
561
+ const live = definition.fields.filter((field) => !field.deprecated);
562
+ const configured = definition.display.titleFieldId;
563
+ return (configured === void 0 ? void 0 : live.find((field) => field.id === configured)) ?? live.find((field) => TITLE_FIELD_TYPES.has(field.type));
564
+ };
565
+ //#endregion
566
+ //#region src/display/layout.ts
567
+ /**
568
+ * Data types that can be blocks in the entry document's canvas. Components must also be repeatable and
569
+ * media must hold several files (`isCanvasEligible`); a single component or file is a property.
570
+ */
571
+ const CANVAS_ELIGIBLE = /* @__PURE__ */ new Set([
572
+ "richtext",
573
+ "dynamiczone",
574
+ "component",
575
+ "media"
576
+ ]);
577
+ /** Title types that the document edits inline as its H1; any other title type stays a property. */
578
+ const INLINE_TITLE_TYPES = /* @__PURE__ */ new Set(["string", "text"]);
579
+ /** How many properties the strip shows when `display.stripFieldIds` is not set. */
580
+ const STRIP_DEFAULT_COUNT = 5;
581
+ const isCanvasEligible = (field) => {
582
+ switch (field.type) {
583
+ case "richtext":
584
+ case "dynamiczone": return true;
585
+ case "component": return field.settings.repeatable;
586
+ case "media": return field.settings.multiple;
587
+ default: return false;
588
+ }
589
+ };
590
+ /** A single `media` field that may hold an image. */
591
+ const isCoverEligible = (field) => field.type === "media" && !field.settings.multiple && (field.settings.allowedKinds === void 0 || field.settings.allowedKinds.includes("image"));
592
+ const byIds = (live, ids) => (ids ?? []).map((id) => live.find((field) => field.id === id)).filter((field) => field !== void 0);
593
+ const unique = (fields) => [...new Map(fields.map((f) => [f.id, f])).values()];
594
+ const groupProperties = (model, properties) => {
595
+ const groups = (model.display.groups ?? []).map((group) => ({
596
+ id: group.id,
597
+ label: group.label,
598
+ fields: byIds(properties, group.fieldIds)
599
+ })).filter((group) => group.fields.length > 0);
600
+ const grouped = new Set(groups.flatMap((group) => group.fields.map((field) => field.id)));
601
+ const rest = properties.filter((field) => !grouped.has(field.id));
602
+ return rest.length > 0 ? [...groups, {
603
+ id: "other",
604
+ label: void 0,
605
+ fields: rest
606
+ }] : groups;
607
+ };
608
+ const coverOf = (model, live, canvas) => {
609
+ const configured = byIds(live, model.display.coverFieldId ? [model.display.coverFieldId] : [])[0];
610
+ if (configured && isCoverEligible(configured) && !canvas.has(configured.id)) return configured;
611
+ return live.find((field) => isCoverEligible(field) && !canvas.has(field.id));
612
+ };
613
+ /**
614
+ * The entry document's layout: title, cover, canvas blocks and properties. Configured `display` ids win
615
+ * when they point at live, eligible fields; anything else falls back to the defaults (canvas: every
616
+ * eligible field in field order; cover: the first single image field). Deprecated and ineligible ids are
617
+ * skipped, so a stale definition never breaks the editor. Pure: no values, no I/O.
618
+ */
619
+ const effectiveLayout = (model) => {
620
+ const live = model.fields.filter((field) => !field.deprecated);
621
+ const title = effectiveTitleField(model);
622
+ const titleInline = title !== void 0 && INLINE_TITLE_TYPES.has(title.type);
623
+ const configuredCanvas = unique(byIds(live, model.display.canvasFieldIds).filter(isCanvasEligible));
624
+ const canvasCandidates = model.display.canvasFieldIds === void 0 ? live.filter(isCanvasEligible) : configuredCanvas;
625
+ const cover = coverOf(model, live, new Set(configuredCanvas.map((field) => field.id)));
626
+ const canvas = canvasCandidates.filter((field) => field.id !== cover?.id);
627
+ const placed = /* @__PURE__ */ new Set([
628
+ ...canvas.map((field) => field.id),
629
+ ...cover ? [cover.id] : [],
630
+ ...titleInline && title ? [title.id] : []
631
+ ]);
632
+ const propertyGroups = groupProperties(model, live.filter((field) => !placed.has(field.id)));
633
+ const properties = propertyGroups.flatMap((group) => group.fields);
634
+ return {
635
+ title,
636
+ titleInline,
637
+ cover,
638
+ canvas,
639
+ properties,
640
+ propertyGroups,
641
+ strip: model.display.stripFieldIds === void 0 ? null : unique(byIds(properties, model.display.stripFieldIds))
642
+ };
643
+ };
644
+ /** The strip's properties: the configured ones, or the first `limit` that `hasValue` says are non-empty. */
645
+ const stripFieldsOf = (layout, hasValue, limit = 5) => layout.strip ?? layout.properties.filter(hasValue).slice(0, limit);
646
+ //#endregion
647
+ //#region src/validators/apiKey.ts
648
+ /**
649
+ * API keys (models, components, fields) must be valid GraphQL names from day one (CONTRIBUTING.md rule 4),
650
+ * so GraphQL can expose them without renames.
651
+ */
652
+ const API_KEY_PATTERN = /^[_A-Za-z][_0-9A-Za-z]*$/;
653
+ const MAX_API_KEY_LENGTH = 64;
654
+ const isApiKeySyntaxValid = (key) => API_KEY_PATTERN.test(key);
655
+ /**
656
+ * Syntax checks shared by every kind of API key (`noun` names it in messages, e.g. "plural API ID").
657
+ * Returns the first problem, or null.
658
+ */
659
+ const checkApiKeySyntax = (key, noun = "API ID") => {
660
+ if (!isApiKeySyntaxValid(key)) return {
661
+ code: "API_KEY_INVALID",
662
+ message: `"${key}" is not a valid ${noun}: use letters, digits and _, not starting with a digit`
663
+ };
664
+ if (key.length > 64) return {
665
+ code: "API_KEY_TOO_LONG",
666
+ message: `${noun}s are at most 64 characters`
667
+ };
668
+ if (key.startsWith("__")) return {
669
+ code: "API_KEY_RESERVED_PREFIX",
670
+ message: `${noun}s must not start with "__" (reserved by GraphQL)`
671
+ };
672
+ return null;
673
+ };
674
+ /** Case-folded form used for every collision check: `Title` and `title` collide. */
675
+ const foldApiKey = (key) => key.toLowerCase();
676
+ //#endregion
677
+ //#region src/naming/plural.ts
678
+ /** Appended when English has no distinct plural (`news` → `newsItems`, `series` → `seriesItems`). */
679
+ const PLURAL_FALLBACK_SUFFIX = "Items";
680
+ /**
681
+ * Suggests the plural API ID of a collection from its singular one (`article` → `articles`,
682
+ * `category` → `categories`). Only a suggestion for the admin and the value filled into definitions that
683
+ * predate plural API IDs: the plural is stored on the definition and nothing pluralises at request time.
684
+ * Deterministic for a given `pluralize` version, which is pinned exactly for that reason.
685
+ */
686
+ const suggestPlural = (apiKey) => {
687
+ if (apiKey === "") return "";
688
+ const plural = pluralize(apiKey);
689
+ if (plural.length <= 64 && foldApiKey(plural) !== foldApiKey(apiKey)) return plural;
690
+ return `${apiKey.slice(0, 59)}${PLURAL_FALLBACK_SUFFIX}`;
691
+ };
692
+ //#endregion
693
+ //#region src/normalize.ts
694
+ const SETTINGS_DEFAULTS = {
695
+ richtext: { formatVersion: 1 },
696
+ enum: { multiple: false },
697
+ media: { multiple: false },
698
+ component: { repeatable: false }
699
+ };
700
+ /** Optional text properties where an empty string means "not set". */
701
+ const OPTIONAL_TEXT_KEYS = /* @__PURE__ */ new Set([
702
+ "description",
703
+ "category",
704
+ "icon",
705
+ "titleFieldId",
706
+ "coverFieldId",
707
+ "pluralApiKey"
708
+ ]);
709
+ /** Optional list properties where an empty list means "not set". `fields` is never optional. */
710
+ const OPTIONAL_LIST_KEYS = /* @__PURE__ */ new Set([
711
+ "listFieldIds",
712
+ "groups",
713
+ "canvasFieldIds",
714
+ "stripFieldIds"
715
+ ]);
716
+ const withoutEmpty = (value) => {
717
+ const result = {};
718
+ for (const [key, entry] of Object.entries(value)) {
719
+ const isEmptyText = OPTIONAL_TEXT_KEYS.has(key) && entry === "";
720
+ const isEmptyList = OPTIONAL_LIST_KEYS.has(key) && Array.isArray(entry) && entry.length === 0;
721
+ if (entry === void 0 || isEmptyText || isEmptyList) continue;
722
+ result[key] = entry;
723
+ }
724
+ return result;
725
+ };
726
+ const normalizeField = (field) => {
727
+ const normalized = {
728
+ id: field.id,
729
+ apiKey: field.apiKey,
730
+ label: field.label,
731
+ description: field.description,
732
+ type: field.type,
733
+ required: field.required ?? false,
734
+ localized: field.localized ?? false,
735
+ public: field.public ?? true,
736
+ unique: field.unique ?? false,
737
+ filterable: field.filterable ?? false,
738
+ sortable: field.sortable ?? false,
739
+ deprecated: field.deprecated ?? false,
740
+ defaultValue: field.defaultValue,
741
+ settings: {
742
+ ...SETTINGS_DEFAULTS[field.type],
743
+ ...field.settings
744
+ },
745
+ editor: {
746
+ id: field.editor?.id ?? DEFAULT_EDITORS[field.type],
747
+ options: field.editor?.options ?? {}
748
+ }
749
+ };
750
+ return withoutEmpty(normalized);
751
+ };
752
+ const normalizeModelDisplay = (display) => withoutEmpty({ ...display });
753
+ const normalizeComponentDisplay = (display) => withoutEmpty({ ...display });
754
+ /**
755
+ * Applies defaults so that two definitions meaning the same thing are byte-identical once serialized
756
+ * canonically: a hand-written file that omits `public` hashes the same as one that says `public: true`.
757
+ * Empty optional strings and arrays are dropped. A collection without a plural API ID gets the suggested
758
+ * one (`suggestPlural`), so files and definitions from before plural API IDs keep working.
759
+ */
760
+ const normalizeDefinition = (input) => {
761
+ const fields = input.fields.map(normalizeField);
762
+ if (input.kind === "component") return withoutEmpty({
763
+ id: input.id,
764
+ kind: "component",
765
+ apiKey: input.apiKey,
766
+ label: input.label,
767
+ description: input.description,
768
+ category: input.category,
769
+ icon: input.icon,
770
+ fields,
771
+ display: normalizeComponentDisplay(input.display)
772
+ });
773
+ return withoutEmpty({
774
+ id: input.id,
775
+ kind: input.kind,
776
+ apiKey: input.apiKey,
777
+ pluralApiKey: input.kind === "collection" ? input.pluralApiKey || suggestPlural(input.apiKey) : void 0,
778
+ label: input.label,
779
+ description: input.description,
780
+ localized: input.localized ?? false,
781
+ draftAndPublish: input.draftAndPublish ?? true,
782
+ fields,
783
+ display: normalizeModelDisplay(input.display)
784
+ });
785
+ };
786
+ /** Normalizes a definition that is already normalized-shaped (e.g. read back from the database). */
787
+ const renormalizeDefinition = (definition) => normalizeDefinition(definition);
788
+ //#endregion
789
+ //#region src/validators/defaultValue.ts
790
+ /**
791
+ * Checks that a field's `defaultValue` has the right JSON shape for its type. Full content rules
792
+ * (lengths, ranges) are applied by the content validator when the default is used.
793
+ * Returns a message, or null when the default is acceptable.
794
+ */
795
+ const checkDefaultValue = (field) => {
796
+ const value = field.defaultValue;
797
+ if (value === void 0) return null;
798
+ const isString = typeof value === "string";
799
+ const matches = (pattern) => isString && pattern.test(value);
800
+ switch (field.type) {
801
+ case "string":
802
+ case "text":
803
+ case "email":
804
+ case "url": return isString ? null : "must be a string";
805
+ case "slug": return matches(SLUG_PATTERN) ? null : "must be a lower-case slug";
806
+ case "uid": return matches(UID_PATTERN) ? null : "must contain only URL-safe characters";
807
+ case "number": return typeof value === "number" && Number.isFinite(value) ? null : "must be a number";
808
+ case "integer": return Number.isSafeInteger(value) ? null : "must be an integer";
809
+ case "decimal": return matches(DECIMAL_PATTERN) ? null : "must be a decimal string, e.g. \"12.50\"";
810
+ case "biginteger": return matches(BIGINTEGER_PATTERN) ? null : "must be an integer string, e.g. \"9007199254740993\"";
811
+ case "boolean": return typeof value === "boolean" ? null : "must be true or false";
812
+ case "date": return matches(DATE_PATTERN) ? null : "must be a date, YYYY-MM-DD";
813
+ case "datetime": return matches(DATETIME_PATTERN) ? null : "must be a UTC date-time, YYYY-MM-DDTHH:mm:ssZ";
814
+ case "time": return matches(TIME_PATTERN) ? null : "must be a time, HH:mm[:ss]";
815
+ case "enum": {
816
+ const allowed = new Set(field.settings.values.map((entry) => entry.value));
817
+ if (field.settings.multiple) return Array.isArray(value) && value.every((item) => typeof item === "string" && allowed.has(item)) ? null : "must be a list of the enum values";
818
+ return isString && allowed.has(value) ? null : "must be one of the enum values";
819
+ }
820
+ case "json": return null;
821
+ default: return `${field.type} fields cannot have a default value`;
822
+ }
823
+ };
824
+ //#endregion
825
+ //#region src/validators/issues.ts
826
+ var SchemaValidationError = class extends Error {
827
+ issues;
828
+ constructor(issues) {
829
+ super(`Schema definition is invalid: ${issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`);
830
+ this.name = "SchemaValidationError";
831
+ this.issues = issues;
832
+ }
833
+ };
834
+ const issue = (path, code, message) => ({
835
+ path,
836
+ code,
837
+ message
838
+ });
839
+ //#endregion
840
+ //#region src/validators/naming.ts
841
+ /**
842
+ * The names GraphQL (package J) and other generated contracts derive from API keys. Defined here, with the
843
+ * validator, so a name that would collide in GraphQL is rejected when the model is saved, never later.
844
+ * Package J must generate exactly these names.
845
+ */
846
+ const toTypeName = (apiKey) => apiKey.charAt(0).toUpperCase() + apiKey.slice(1);
847
+ /** Suffixes of the types generated for each model (`Page`, `PageFilter`, `PageInput`...). */
848
+ const MODEL_TYPE_SUFFIXES = [
849
+ "",
850
+ "Filter",
851
+ "Input",
852
+ "Connection",
853
+ "Edge",
854
+ "Localizations",
855
+ "Sort"
856
+ ];
857
+ const COMPONENT_TYPE_SUFFIXES = ["", "Input"];
858
+ const MUTATION_PREFIXES = [
859
+ "create",
860
+ "update",
861
+ "delete",
862
+ "publish",
863
+ "unpublish"
864
+ ];
865
+ /**
866
+ * The key a model is addressed by in the delivery API (`/api/content/:routeKey`, preview routes): the
867
+ * plural API ID of a collection, the API ID of a singleton. A collection stored before plural API IDs
868
+ * existed gets the same derived value normalizing would fill, so this never returns undefined.
869
+ */
870
+ const routeKeyOf = (definition) => definition.kind === "collection" ? definition.pluralApiKey || suggestPlural(definition.apiKey) : definition.apiKey;
871
+ /** GraphQL list query of a collection (`articles(…)`): its plural API ID. */
872
+ const collectionQueryName = (definition) => routeKeyOf(definition);
873
+ /**
874
+ * A stored definition with the plural API ID a collection gets when it predates them (the value
875
+ * normalizing fills), and whether anything was filled. Callers that hash definitions re-hash when `filled`.
876
+ */
877
+ const withDerivedPlural = (definition) => {
878
+ if (definition.kind !== "collection" || definition.pluralApiKey) return {
879
+ definition,
880
+ filled: false
881
+ };
882
+ const { id, kind, apiKey, ...rest } = definition;
883
+ return {
884
+ definition: {
885
+ id,
886
+ kind,
887
+ apiKey,
888
+ pluralApiKey: suggestPlural(apiKey),
889
+ ...rest
890
+ },
891
+ filled: true
892
+ };
893
+ };
894
+ /**
895
+ * GraphQL built-ins and the fixed types Shapio's schema always defines. Compared case-folded, so a model
896
+ * called `string` (type `String`) or `pageInfo` is rejected.
897
+ */
898
+ const RESERVED_TYPE_NAMES = [
899
+ "Query",
900
+ "Mutation",
901
+ "Subscription",
902
+ "String",
903
+ "Int",
904
+ "Float",
905
+ "Boolean",
906
+ "ID",
907
+ "JSON",
908
+ "Date",
909
+ "DateTime",
910
+ "Time",
911
+ "Decimal",
912
+ "BigInt",
913
+ "RichText",
914
+ "Media",
915
+ "MediaAsset",
916
+ "MediaVariant",
917
+ "FocalPoint",
918
+ "MediaKind",
919
+ "Int53",
920
+ "PageInfo",
921
+ "Locale",
922
+ "PublicationState",
923
+ "SortDirection",
924
+ "Node",
925
+ "Entry",
926
+ "Upload",
927
+ "StringFilter",
928
+ "IntFilter",
929
+ "FloatFilter",
930
+ "BooleanFilter",
931
+ "IDFilter",
932
+ "DateFilter",
933
+ "DateTimeFilter",
934
+ "TimeFilter",
935
+ "DecimalFilter",
936
+ "BigIntFilter",
937
+ "JSONFilter",
938
+ "SnapshotChange",
939
+ "SnapshotChangeKind",
940
+ "SnapshotChangeLocale",
941
+ "SnapshotChangePage",
942
+ "SnapshotInfo"
943
+ ];
944
+ /**
945
+ * Model and component API keys the admin uses as static URL segments (`/content/new`,
946
+ * `/develop/components`), so a type with one of these keys could not be opened. Compared case-folded.
947
+ */
948
+ const RESERVED_DEFINITION_API_KEYS = ["new", "components"];
949
+ /**
950
+ * Field API keys that collide with the system fields every entry exposes in REST and GraphQL.
951
+ * Compared case-folded.
952
+ */
953
+ const RESERVED_FIELD_API_KEYS = [
954
+ "id",
955
+ "locale",
956
+ "localizations",
957
+ "status",
958
+ "version",
959
+ "createdAt",
960
+ "updatedAt",
961
+ "publishedAt",
962
+ "createdBy",
963
+ "updatedBy",
964
+ "publicationState",
965
+ "and",
966
+ "or",
967
+ "not"
968
+ ];
969
+ /**
970
+ * Root query fields every GraphQL schema has, whatever models exist. Compared case-folded. The first is the
971
+ * schema version field (schemaBuilder.ts reads it by position).
972
+ */
973
+ const RESERVED_QUERY_NAMES = [
974
+ "_schemaVersion",
975
+ "_changes",
976
+ "_snapshot"
977
+ ];
978
+ /** Every name a definition contributes to the GraphQL schema. */
979
+ const generatedNames = (definition) => {
980
+ const typeName = toTypeName(definition.apiKey);
981
+ const definitionId = definition.id;
982
+ const names = (definition.kind === "component" ? COMPONENT_TYPE_SUFFIXES : MODEL_TYPE_SUFFIXES).map((suffix) => ({
983
+ namespace: "type",
984
+ name: `${typeName}${suffix}`,
985
+ definitionId
986
+ }));
987
+ for (const field of definition.fields) {
988
+ const fieldType = `${typeName}${toTypeName(field.apiKey)}`;
989
+ if (field.type === "enum") names.push({
990
+ namespace: "type",
991
+ name: `${fieldType}Enum`,
992
+ definitionId,
993
+ fieldId: field.id
994
+ });
995
+ if (field.type === "dynamiczone") {
996
+ names.push({
997
+ namespace: "type",
998
+ name: `${fieldType}Zone`,
999
+ definitionId,
1000
+ fieldId: field.id
1001
+ });
1002
+ names.push({
1003
+ namespace: "type",
1004
+ name: `${fieldType}ZoneInput`,
1005
+ definitionId,
1006
+ fieldId: field.id
1007
+ });
1008
+ }
1009
+ }
1010
+ if (definition.kind !== "component") {
1011
+ names.push({
1012
+ namespace: "query",
1013
+ name: definition.apiKey,
1014
+ definitionId
1015
+ });
1016
+ if (definition.kind === "collection") names.push({
1017
+ namespace: "query",
1018
+ name: collectionQueryName(definition),
1019
+ definitionId,
1020
+ property: "pluralApiKey"
1021
+ });
1022
+ for (const prefix of MUTATION_PREFIXES) names.push({
1023
+ namespace: "mutation",
1024
+ name: `${prefix}${typeName}`,
1025
+ definitionId
1026
+ });
1027
+ }
1028
+ return names;
1029
+ };
1030
+ //#endregion
1031
+ //#region src/validators/typeboxIssues.ts
1032
+ /**
1033
+ * TypeBox errors as (JSON pointer, message) pairs. The `schema is false` echo that accompanies every
1034
+ * `additionalProperties` error is dropped, and unknown properties are named in the message.
1035
+ */
1036
+ const collectSchemaErrors = (schema, value, basePath = "") => [...Value.Errors(schema, value)].filter((error) => error.keyword !== "boolean").map((error) => {
1037
+ const extra = error.params.additionalProperties;
1038
+ const message = error.keyword === "additionalProperties" && Array.isArray(extra) ? `unknown propert${extra.length === 1 ? "y" : "ies"}: ${extra.join(", ")}` : error.message;
1039
+ return {
1040
+ path: `${basePath}${error.instancePath}`,
1041
+ message
1042
+ };
1043
+ });
1044
+ //#endregion
1045
+ //#region src/validators/definition.ts
1046
+ /**
1047
+ * Checks one normalized definition on its own: IDs, API keys, per-type settings, flags, editor choice and
1048
+ * display references. References to other definitions are checked by `validateSchema`.
1049
+ */
1050
+ const RESERVED_FIELD_KEYS = new Set(RESERVED_FIELD_API_KEYS.map(foldApiKey));
1051
+ const RESERVED_DEFINITION_KEYS = new Set(RESERVED_DEFINITION_API_KEYS.map(foldApiKey));
1052
+ /** GraphQL enum values must be names and cannot be `true`, `false` or `null`. */
1053
+ const RESERVED_ENUM_VALUES = /* @__PURE__ */ new Set([
1054
+ "true",
1055
+ "false",
1056
+ "null"
1057
+ ]);
1058
+ const compareNumbers = (a, b) => a - b;
1059
+ const compareBigInts = (a, b) => {
1060
+ const diff = BigInt(a) - BigInt(b);
1061
+ return diff === 0n ? 0 : diff > 0n ? 1 : -1;
1062
+ };
1063
+ const compareDecimals = (a, b) => Number(a) - Number(b);
1064
+ const compareText = (a, b) => a === b ? 0 : a > b ? 1 : -1;
1065
+ const checkRange = ({ min, max, compare, path }) => min !== void 0 && max !== void 0 && compare(min, max) > 0 ? [issue(path, "INVALID_RANGE", "minimum is greater than maximum")] : [];
1066
+ const settingsRanges = (field, path) => {
1067
+ const s = field.settings;
1068
+ const issues = [];
1069
+ issues.push(...checkRange({
1070
+ min: s.minLength,
1071
+ max: s.maxLength,
1072
+ compare: compareNumbers,
1073
+ path: `${path}/minLength`
1074
+ }));
1075
+ switch (field.type) {
1076
+ case "number":
1077
+ case "integer":
1078
+ case "media":
1079
+ case "relation":
1080
+ case "component":
1081
+ case "dynamiczone":
1082
+ issues.push(...checkRange({
1083
+ min: s.min,
1084
+ max: s.max,
1085
+ compare: compareNumbers,
1086
+ path: `${path}/min`
1087
+ }));
1088
+ break;
1089
+ case "biginteger":
1090
+ issues.push(...checkRange({
1091
+ min: s.min,
1092
+ max: s.max,
1093
+ compare: compareBigInts,
1094
+ path: `${path}/min`
1095
+ }));
1096
+ break;
1097
+ case "decimal":
1098
+ issues.push(...checkRange({
1099
+ min: s.min,
1100
+ max: s.max,
1101
+ compare: compareDecimals,
1102
+ path: `${path}/min`
1103
+ }));
1104
+ if (field.settings.scale !== void 0 && field.settings.precision !== void 0) issues.push(...checkRange({
1105
+ min: field.settings.scale,
1106
+ max: field.settings.precision,
1107
+ compare: compareNumbers,
1108
+ path: `${path}/scale`
1109
+ }));
1110
+ break;
1111
+ case "date":
1112
+ case "datetime":
1113
+ case "time": issues.push(...checkRange({
1114
+ min: s.min,
1115
+ max: s.max,
1116
+ compare: compareText,
1117
+ path: `${path}/min`
1118
+ }));
1119
+ }
1120
+ return issues;
1121
+ };
1122
+ const checkSettings = (field, path, definition) => {
1123
+ const issues = settingsRanges(field, `${path}/settings`);
1124
+ if (field.type === "string" && field.settings.pattern !== void 0) try {
1125
+ new RegExp(field.settings.pattern, "u");
1126
+ } catch {
1127
+ issues.push(issue(`${path}/settings/pattern`, "INVALID_PATTERN", "is not a valid regular expression"));
1128
+ }
1129
+ if (field.type === "enum") {
1130
+ const seen = /* @__PURE__ */ new Set();
1131
+ field.settings.values.forEach((entry, index) => {
1132
+ const entryPath = `${path}/settings/values/${index}/value`;
1133
+ if (!isApiKeySyntaxValid(entry.value) || entry.value.startsWith("__") || RESERVED_ENUM_VALUES.has(entry.value)) issues.push(issue(entryPath, "INVALID_ENUM_VALUE", "enum values must be GraphQL names (letters, digits, _), not true/false/null"));
1134
+ if (seen.has(entry.value)) issues.push(issue(entryPath, "DUPLICATE_ENUM_VALUE", `"${entry.value}" is listed twice`));
1135
+ seen.add(entry.value);
1136
+ });
1137
+ }
1138
+ if (field.type === "slug" && field.settings.sourceFieldId !== void 0) {
1139
+ const source = definition.fields.find((candidate) => candidate.id === field.settings.sourceFieldId);
1140
+ if (!source) issues.push(issue(`${path}/settings/sourceFieldId`, "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition"));
1141
+ else if (source.type !== "string" && source.type !== "text") issues.push(issue(`${path}/settings/sourceFieldId`, "INVALID_FIELD_REFERENCE", "must refer to a string or text field"));
1142
+ }
1143
+ if (field.type === "richtext" && field.settings.formatVersion !== 1) issues.push(issue(`${path}/settings/formatVersion`, "INVALID_SETTINGS", "unsupported rich-text format version"));
1144
+ if (field.type === "relation" && !isStableId(field.settings.target)) issues.push(issue(`${path}/settings/target`, "INVALID_ID", "must be the ID of a model"));
1145
+ if (field.type === "component" && !isStableId(field.settings.component)) issues.push(issue(`${path}/settings/component`, "INVALID_ID", "must be the ID of a component"));
1146
+ if (field.type === "dynamiczone") field.settings.components.forEach((id, index) => {
1147
+ if (!isStableId(id)) issues.push(issue(`${path}/settings/components/${index}`, "INVALID_ID", "must be the ID of a component"));
1148
+ });
1149
+ return issues;
1150
+ };
1151
+ const checkFlags = (field, path, isComponent) => {
1152
+ const issues = [];
1153
+ const reject = (flag, why) => issues.push(issue(`${path}/${flag}`, "UNSUPPORTED_FLAG", why));
1154
+ if (field.unique && !UNIQUE_CAPABLE_DATA_TYPES.has(field.type)) reject("unique", `${field.type} fields cannot be unique`);
1155
+ for (const flag of ["filterable", "sortable"]) {
1156
+ if (field[flag] && !SCALAR_DATA_TYPES.has(field.type)) reject(flag, `${field.type} fields cannot be ${flag}`);
1157
+ if (field[flag] && field.type === "enum" && field.settings.multiple) reject(flag, `multi-value enum fields cannot be ${flag}`);
1158
+ }
1159
+ if (isComponent) {
1160
+ for (const flag of [
1161
+ "unique",
1162
+ "filterable",
1163
+ "sortable"
1164
+ ]) if (field[flag]) reject(flag, `fields of a component cannot be ${flag}`);
1165
+ }
1166
+ if (field.deprecated && field.required) reject("required", "a deprecated field cannot be required");
1167
+ return issues;
1168
+ };
1169
+ const checkEditor = (field, path) => {
1170
+ const { id, options } = field.editor;
1171
+ if (isCustomEditorId(id)) return [];
1172
+ const entry = EDITOR_CATALOGUE.get(id);
1173
+ if (!entry) return [issue(`${path}/editor/id`, "UNKNOWN_EDITOR", `"${id}" is not a built-in editor (custom editors use a vendor.name ID)`)];
1174
+ if (!entry.dataTypes.includes(field.type) || !(entry.supports?.(field) ?? true)) return [issue(`${path}/editor/id`, "INCOMPATIBLE_EDITOR", `the ${id} editor cannot edit this ${field.type} field`)];
1175
+ return collectSchemaErrors(entry.optionsSchema, options, `${path}/editor/options`).map((error) => issue(error.path, "INVALID_EDITOR_OPTIONS", error.message));
1176
+ };
1177
+ const checkField = (field, index, definition) => {
1178
+ const path = `/fields/${index}`;
1179
+ const issues = [];
1180
+ if (!isStableId(field.id)) issues.push(issue(`${path}/id`, "INVALID_ID", "must be a lower-case UUID"));
1181
+ const syntax = checkApiKeySyntax(field.apiKey);
1182
+ if (syntax) issues.push(issue(`${path}/apiKey`, syntax.code, syntax.message));
1183
+ else if (RESERVED_FIELD_KEYS.has(foldApiKey(field.apiKey))) issues.push(issue(`${path}/apiKey`, "API_KEY_RESERVED", `"${field.apiKey}" is a system field name`));
1184
+ issues.push(...checkSettings(field, path, definition));
1185
+ issues.push(...checkFlags(field, path, definition.kind === "component"));
1186
+ const defaultProblem = checkDefaultValue(field);
1187
+ if (defaultProblem) issues.push(issue(`${path}/defaultValue`, "INVALID_DEFAULT_VALUE", defaultProblem));
1188
+ issues.push(...checkEditor(field, path));
1189
+ return issues;
1190
+ };
1191
+ const checkFieldUniqueness = (definition) => {
1192
+ const issues = [];
1193
+ const ids = /* @__PURE__ */ new Set();
1194
+ const keys = /* @__PURE__ */ new Map();
1195
+ definition.fields.forEach((field, index) => {
1196
+ if (ids.has(field.id)) issues.push(issue(`/fields/${index}/id`, "DUPLICATE_ID", "another field has the same ID"));
1197
+ ids.add(field.id);
1198
+ const folded = foldApiKey(field.apiKey);
1199
+ const clash = keys.get(folded);
1200
+ if (clash !== void 0) issues.push(issue(`/fields/${index}/apiKey`, "API_KEY_COLLISION", `"${field.apiKey}" collides with field "${clash}" (keys are case-insensitive)`));
1201
+ keys.set(folded, field.apiKey);
1202
+ });
1203
+ return issues;
1204
+ };
1205
+ const checkGroups = (groups, fieldIds) => {
1206
+ const issues = [];
1207
+ const groupIds = /* @__PURE__ */ new Set();
1208
+ const placed = /* @__PURE__ */ new Set();
1209
+ (groups ?? []).forEach((group, groupIndex) => {
1210
+ if (groupIds.has(group.id)) issues.push(issue(`/display/groups/${groupIndex}/id`, "DUPLICATE_GROUP", "another group has the same ID"));
1211
+ groupIds.add(group.id);
1212
+ group.fieldIds.forEach((fieldId, index) => {
1213
+ const path = `/display/groups/${groupIndex}/fieldIds/${index}`;
1214
+ if (!fieldIds.has(fieldId)) issues.push(issue(path, "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition"));
1215
+ else if (placed.has(fieldId)) issues.push(issue(path, "INVALID_FIELD_REFERENCE", "a field can be in one group only"));
1216
+ placed.add(fieldId);
1217
+ });
1218
+ });
1219
+ return issues;
1220
+ };
1221
+ /** A list of field IDs: each must exist, appear once and pass `reject`. */
1222
+ const checkFieldList = (ids, check) => {
1223
+ const seen = /* @__PURE__ */ new Set();
1224
+ return (ids ?? []).flatMap((fieldId, index) => {
1225
+ const path = `${check.path}/${index}`;
1226
+ const field = check.fields.get(fieldId);
1227
+ if (!field) return [issue(path, "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition")];
1228
+ if (seen.has(fieldId)) return [issue(path, "INVALID_FIELD_REFERENCE", "the field is listed more than once")];
1229
+ seen.add(fieldId);
1230
+ const reason = check.reject(field);
1231
+ return reason ? [issue(path, "INVALID_FIELD_REFERENCE", reason)] : [];
1232
+ });
1233
+ };
1234
+ /**
1235
+ * The entry document's layout (`effectiveLayout`): canvas blocks, cover and strip. Title, cover and canvas
1236
+ * are disjoint (title types are never canvas or cover types, so eligibility covers the title); strip
1237
+ * entries are properties, so never the cover or a canvas block.
1238
+ */
1239
+ const checkDocumentLayout = (model, fields) => {
1240
+ const { canvasFieldIds, coverFieldId, stripFieldIds } = model.display;
1241
+ const canvas = new Set(canvasFieldIds ?? []);
1242
+ const issues = checkFieldList(canvasFieldIds, {
1243
+ path: "/display/canvasFieldIds",
1244
+ fields,
1245
+ reject: (field) => isCanvasEligible(field) ? void 0 : canvasRejection(field)
1246
+ });
1247
+ if (coverFieldId !== void 0) issues.push(...checkFieldList([coverFieldId], {
1248
+ path: "/display/coverFieldId",
1249
+ fields,
1250
+ reject: (field) => !isCoverEligible(field) ? "the cover must be a single media field that allows images" : canvas.has(field.id) ? "the cover cannot also be in the canvas" : void 0
1251
+ }).map((found) => ({
1252
+ ...found,
1253
+ path: "/display/coverFieldId"
1254
+ })));
1255
+ issues.push(...checkFieldList(stripFieldIds, {
1256
+ path: "/display/stripFieldIds",
1257
+ fields,
1258
+ reject: (field) => canvas.has(field.id) || field.id === coverFieldId ? "the strip shows properties, not the cover or canvas blocks" : void 0
1259
+ }));
1260
+ return issues;
1261
+ };
1262
+ const canvasRejection = (field) => {
1263
+ if (field.type === "component") return "only a repeatable component can be in the canvas";
1264
+ if (field.type === "media") return "only a media field with several files can be in the canvas";
1265
+ return `a ${field.type} field cannot be in the canvas`;
1266
+ };
1267
+ const checkDisplay = (definition) => {
1268
+ const fields = new Map(definition.fields.map((field) => [field.id, field]));
1269
+ const fieldIds = new Set(fields.keys());
1270
+ const issues = [];
1271
+ const { titleFieldId } = definition.display;
1272
+ if (titleFieldId !== void 0) {
1273
+ const title = fields.get(titleFieldId);
1274
+ if (!title) issues.push(issue("/display/titleFieldId", "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition"));
1275
+ else if (!TITLE_FIELD_TYPES.has(title.type)) issues.push(issue("/display/titleFieldId", "INVALID_FIELD_REFERENCE", `a ${title.type} field cannot be the title`));
1276
+ }
1277
+ if (definition.kind !== "component") {
1278
+ definition.display.listFieldIds?.forEach((fieldId, index) => {
1279
+ if (!fieldIds.has(fieldId)) issues.push(issue(`/display/listFieldIds/${index}`, "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition"));
1280
+ });
1281
+ const sort = definition.display.defaultSort;
1282
+ if (sort) {
1283
+ const field = fields.get(sort.fieldId);
1284
+ if (!field) issues.push(issue("/display/defaultSort/fieldId", "UNKNOWN_FIELD_REFERENCE", "refers to no field of this definition"));
1285
+ else if (!field.sortable) issues.push(issue("/display/defaultSort/fieldId", "INVALID_FIELD_REFERENCE", "the default sort field must be sortable"));
1286
+ }
1287
+ issues.push(...checkDocumentLayout(definition, fields));
1288
+ }
1289
+ issues.push(...checkGroups(definition.display.groups, fieldIds));
1290
+ return issues;
1291
+ };
1292
+ /**
1293
+ * A collection's plural API ID follows the API ID rules and must differ from its singular. Singletons and
1294
+ * components have none (normalizing drops it).
1295
+ */
1296
+ const checkPluralApiKey = (definition) => {
1297
+ if (definition.kind !== "collection" || definition.pluralApiKey === void 0) return [];
1298
+ const syntax = checkApiKeySyntax(definition.pluralApiKey, "plural API ID");
1299
+ if (syntax) return [issue("/pluralApiKey", syntax.code, syntax.message)];
1300
+ if (foldApiKey(definition.pluralApiKey) === foldApiKey(definition.apiKey)) return [issue("/pluralApiKey", "PLURAL_API_KEY_SAME_AS_SINGULAR", "the plural API ID must differ from the API ID (case-insensitively)")];
1301
+ return [];
1302
+ };
1303
+ /** All problems with one definition, in document order. Empty means valid on its own. */
1304
+ const validateDefinition = (definition) => {
1305
+ const issues = [];
1306
+ if (!isStableId(definition.id)) issues.push(issue("/id", "INVALID_ID", "must be a lower-case UUID"));
1307
+ const syntax = checkApiKeySyntax(definition.apiKey);
1308
+ if (syntax) issues.push(issue("/apiKey", syntax.code, syntax.message));
1309
+ else if (RESERVED_DEFINITION_KEYS.has(foldApiKey(definition.apiKey))) issues.push(issue("/apiKey", "API_KEY_RESERVED", `"${definition.apiKey}" is reserved by the admin`));
1310
+ if (!syntax) issues.push(...checkPluralApiKey(definition));
1311
+ definition.fields.forEach((field, index) => issues.push(...checkField(field, index, definition)));
1312
+ issues.push(...checkFieldUniqueness(definition));
1313
+ issues.push(...checkDisplay(definition));
1314
+ return issues;
1315
+ };
1316
+ //#endregion
1317
+ //#region src/validators/parse.ts
1318
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1319
+ const structuralIssues = (raw) => {
1320
+ const schema = raw.kind === "component" ? ComponentDefinitionInputSchema : ModelDefinitionInputSchema;
1321
+ const issues = collectSchemaErrors(schema, raw).map((error) => issue(error.path, "INVALID_STRUCTURE", error.message));
1322
+ if (issues.length > 0) return issues;
1323
+ raw.fields.forEach((field, index) => {
1324
+ const settingsSchema = SETTINGS_SCHEMAS[field.type];
1325
+ for (const error of collectSchemaErrors(settingsSchema, field.settings ?? {}, `/fields/${index}/settings`)) issues.push(issue(error.path, "INVALID_SETTINGS", error.message));
1326
+ });
1327
+ return issues;
1328
+ };
1329
+ /** Fills in missing definition and field IDs (see ParseDefinitionOptions.previous). */
1330
+ const assignIds = (input, { previous, createId = createStableId } = {}) => {
1331
+ const explicitIds = new Set(input.fields.flatMap((field) => field.id ? [field.id] : []));
1332
+ const previousByKey = new Map((previous?.fields ?? []).filter((field) => !explicitIds.has(field.id)).map((field) => [foldApiKey(field.apiKey), field.id]));
1333
+ const fields = input.fields.map((field) => {
1334
+ if (field.id) return {
1335
+ ...field,
1336
+ id: field.id
1337
+ };
1338
+ const matched = previousByKey.get(foldApiKey(field.apiKey));
1339
+ previousByKey.delete(foldApiKey(field.apiKey));
1340
+ return {
1341
+ ...field,
1342
+ id: matched ?? createId()
1343
+ };
1344
+ });
1345
+ return {
1346
+ ...input,
1347
+ id: input.id ?? previous?.id ?? createId(),
1348
+ fields
1349
+ };
1350
+ };
1351
+ /**
1352
+ * The single entry point from untrusted input (request bodies, schema files) to a normalized definition:
1353
+ * structure, settings per data type, ID assignment, defaults, then the per-definition semantic checks.
1354
+ * Cross-definition rules (references, cycles, GraphQL name collisions) are `validateSchema`'s job.
1355
+ */
1356
+ const parseDefinition = (raw, options = {}) => {
1357
+ if (!isObject(raw)) return {
1358
+ ok: false,
1359
+ issues: [issue("", "INVALID_STRUCTURE", "a definition must be a JSON object")]
1360
+ };
1361
+ const structural = structuralIssues(raw);
1362
+ if (structural.length > 0) return {
1363
+ ok: false,
1364
+ issues: structural
1365
+ };
1366
+ const definition = normalizeDefinition(assignIds(raw, options));
1367
+ const issues = validateDefinition(definition);
1368
+ return issues.length > 0 ? {
1369
+ ok: false,
1370
+ issues
1371
+ } : {
1372
+ ok: true,
1373
+ definition
1374
+ };
1375
+ };
1376
+ //#endregion
1377
+ //#region src/validators/schema.ts
1378
+ /** Deepest allowed nesting of components (a component inside a component inside ... ) below an entry. */
1379
+ const MAX_COMPONENT_DEPTH = 5;
1380
+ const RESERVED_TYPES = new Set(RESERVED_TYPE_NAMES.map(foldApiKey));
1381
+ const RESERVED_QUERIES = new Set(RESERVED_QUERY_NAMES.map(foldApiKey));
1382
+ const withDefinition = (definition, found) => ({
1383
+ ...found,
1384
+ definitionId: definition.id
1385
+ });
1386
+ const describeOrigin = (name, byId) => {
1387
+ const definition = byId.get(name.definitionId);
1388
+ const field = name.fieldId ? definition?.fields.find((candidate) => candidate.id === name.fieldId) : void 0;
1389
+ return field ? `${definition?.apiKey ?? "?"}.${field.apiKey}` : definition?.apiKey ?? "?";
1390
+ };
1391
+ /** The API IDs a definition claims in the shared case-insensitive namespace, with their issue paths. */
1392
+ const claimedKeys = (definition) => {
1393
+ const keys = [{
1394
+ key: definition.apiKey,
1395
+ path: "/apiKey",
1396
+ plural: false
1397
+ }];
1398
+ const plural = definition.kind === "collection" ? routeKeyOf(definition) : "";
1399
+ if (plural) keys.push({
1400
+ key: plural,
1401
+ path: "/pluralApiKey",
1402
+ plural: true
1403
+ });
1404
+ return keys;
1405
+ };
1406
+ const describeKey = ({ definition, key }) => key === definition.apiKey ? `"${key}"` : `the plural API ID "${key}" of "${definition.apiKey}"`;
1407
+ const checkIdentity = (definitions) => {
1408
+ const issues = [];
1409
+ const ids = /* @__PURE__ */ new Set();
1410
+ const keys = /* @__PURE__ */ new Map();
1411
+ const fieldOwners = /* @__PURE__ */ new Map();
1412
+ for (const definition of definitions) {
1413
+ if (ids.has(definition.id)) issues.push(withDefinition(definition, issue("/id", "DUPLICATE_ID", "another definition has the same ID")));
1414
+ ids.add(definition.id);
1415
+ for (const { key, path, plural } of claimedKeys(definition)) {
1416
+ const folded = foldApiKey(key);
1417
+ const clash = keys.get(folded);
1418
+ if (clash && clash.definition.id !== definition.id) issues.push(withDefinition(definition, issue(path, "API_KEY_COLLISION", `${plural ? "the plural API ID " : ""}"${key}" collides with ${describeKey(clash)} (models and components share one case-insensitive namespace)`)));
1419
+ if (!clash) keys.set(folded, {
1420
+ definition,
1421
+ key
1422
+ });
1423
+ }
1424
+ definition.fields.forEach((field, index) => {
1425
+ const owner = fieldOwners.get(field.id);
1426
+ if (owner && owner.id !== definition.id) issues.push(withDefinition(definition, issue(`/fields/${index}/id`, "DUPLICATE_ID", `field ID is already used by "${owner.apiKey}"; field IDs are global`)));
1427
+ fieldOwners.set(field.id, definition);
1428
+ });
1429
+ }
1430
+ return issues;
1431
+ };
1432
+ const checkGeneratedNames = (definitions) => {
1433
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]));
1434
+ const issues = [];
1435
+ const seen = /* @__PURE__ */ new Map();
1436
+ for (const definition of definitions) for (const name of generatedNames(definition)) {
1437
+ const folded = `${name.namespace}:${foldApiKey(name.name)}`;
1438
+ const path = name.fieldId ? `/fields/${definition.fields.findIndex((field) => field.id === name.fieldId)}/apiKey` : `/${name.property ?? "apiKey"}`;
1439
+ if (name.namespace === "type" && RESERVED_TYPES.has(foldApiKey(name.name))) {
1440
+ issues.push(withDefinition(definition, issue(path, "API_KEY_RESERVED", `generates the GraphQL type "${name.name}", which is reserved`)));
1441
+ continue;
1442
+ }
1443
+ if (name.namespace === "query" && RESERVED_QUERIES.has(foldApiKey(name.name))) {
1444
+ issues.push(withDefinition(definition, issue(path, "API_KEY_RESERVED", `generates the GraphQL query "${name.name}", which is reserved`)));
1445
+ continue;
1446
+ }
1447
+ const clash = seen.get(folded);
1448
+ if (clash && (clash.definitionId !== name.definitionId || clash.fieldId !== name.fieldId)) {
1449
+ issues.push(withDefinition(definition, issue(path, "GENERATED_NAME_COLLISION", `generates the GraphQL ${name.namespace} name "${name.name}", which "${describeOrigin(clash, byId)}" already generates`)));
1450
+ continue;
1451
+ }
1452
+ seen.set(folded, name);
1453
+ }
1454
+ return issues;
1455
+ };
1456
+ /** Component IDs a definition embeds directly (component and dynamic-zone fields). */
1457
+ const embeddedComponentIds = (definition) => definition.fields.flatMap((field) => {
1458
+ if (field.type === "component") return [field.settings.component];
1459
+ if (field.type === "dynamiczone") return field.settings.components;
1460
+ return [];
1461
+ });
1462
+ const checkReferences = (definitions) => {
1463
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]));
1464
+ const issues = [];
1465
+ for (const definition of definitions) definition.fields.forEach((field, index) => {
1466
+ const path = `/fields/${index}/settings`;
1467
+ const expectComponent = (id, at) => {
1468
+ const target = byId.get(id);
1469
+ if (!target) issues.push(withDefinition(definition, issue(at, "UNKNOWN_REFERENCE", "refers to no component")));
1470
+ else if (target.kind !== "component") issues.push(withDefinition(definition, issue(at, "INVALID_REFERENCE_TARGET", `"${target.apiKey}" is a model, not a component`)));
1471
+ };
1472
+ if (field.type === "relation") {
1473
+ const target = byId.get(field.settings.target);
1474
+ if (!target) issues.push(withDefinition(definition, issue(`${path}/target`, "UNKNOWN_REFERENCE", "refers to no model")));
1475
+ else if (target.kind !== "collection") issues.push(withDefinition(definition, issue(`${path}/target`, "INVALID_REFERENCE_TARGET", `relations must target a collection model; "${target.apiKey}" is a ${target.kind}`)));
1476
+ }
1477
+ if (field.type === "component") expectComponent(field.settings.component, `${path}/component`);
1478
+ if (field.type === "dynamiczone") {
1479
+ const seen = /* @__PURE__ */ new Set();
1480
+ field.settings.components.forEach((id, componentIndex) => {
1481
+ const at = `${path}/components/${componentIndex}`;
1482
+ if (seen.has(id)) issues.push(withDefinition(definition, issue(at, "DUPLICATE_ID", "component listed twice")));
1483
+ seen.add(id);
1484
+ expectComponent(id, at);
1485
+ });
1486
+ }
1487
+ });
1488
+ return issues;
1489
+ };
1490
+ /**
1491
+ * Longest chain of nested components below each definition, or a cycle. Depth 1 means the definition
1492
+ * embeds components that embed nothing.
1493
+ */
1494
+ const checkNesting = (definitions) => {
1495
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]));
1496
+ const depthOf = /* @__PURE__ */ new Map();
1497
+ const issues = [];
1498
+ const reported = /* @__PURE__ */ new Set();
1499
+ const visit = (id, stack) => {
1500
+ const known = depthOf.get(id);
1501
+ if (known !== void 0) return known;
1502
+ if (stack.includes(id)) {
1503
+ const cycle = [...stack.slice(stack.indexOf(id)), id].map((member) => byId.get(member)?.apiKey ?? member);
1504
+ const definition = byId.get(id);
1505
+ if (definition && !reported.has(id)) {
1506
+ reported.add(id);
1507
+ issues.push(withDefinition(definition, issue("/fields", "COMPONENT_CYCLE", `components nest in a cycle: ${cycle.join(" → ")}`)));
1508
+ }
1509
+ return Number.POSITIVE_INFINITY;
1510
+ }
1511
+ const definition = byId.get(id);
1512
+ if (!definition) return 0;
1513
+ const children = embeddedComponentIds(definition).filter((child) => byId.get(child)?.kind === "component");
1514
+ const depth = children.length === 0 ? 0 : 1 + Math.max(...children.map((child) => visit(child, [...stack, id])));
1515
+ depthOf.set(id, depth);
1516
+ return depth;
1517
+ };
1518
+ for (const definition of definitions) {
1519
+ const depth = visit(definition.id, []);
1520
+ if (Number.isFinite(depth) && depth > 5 && definition.kind !== "component") issues.push(withDefinition(definition, issue("/fields", "COMPONENT_TOO_DEEP", `components nest ${depth} levels deep; the limit is 5`)));
1521
+ }
1522
+ return issues;
1523
+ };
1524
+ /**
1525
+ * Cross-definition checks over a whole schema (every active definition with the proposed change applied):
1526
+ * unique IDs and API keys, GraphQL name collisions, relation and component targets, component cycles and
1527
+ * nesting depth. Each definition must already pass `validateDefinition`.
1528
+ */
1529
+ const validateSchema = (definitions) => [
1530
+ ...checkIdentity(definitions),
1531
+ ...checkGeneratedNames(definitions),
1532
+ ...checkReferences(definitions),
1533
+ ...checkNesting(definitions)
1534
+ ];
1535
+ /** Definitions that reference `id` (relations, components, dynamic zones). Used to block deletions. */
1536
+ const findReferencingDefinitions = (definitions, id) => definitions.filter((definition) => definition.id !== id && definition.fields.some((field) => field.type === "relation" && field.settings.target === id || field.type === "component" && field.settings.component === id || field.type === "dynamiczone" && field.settings.components.includes(id)));
1537
+ /**
1538
+ * Models that embed a component directly or through other components, i.e. whose entries change shape
1539
+ * when the component does (brief §5: "editing a shared component must account for all dependent models").
1540
+ */
1541
+ const findDependentModels = (definitions, componentId) => {
1542
+ const embeddedBy = /* @__PURE__ */ new Map();
1543
+ for (const definition of definitions) for (const child of embeddedComponentIds(definition)) embeddedBy.set(child, [...embeddedBy.get(child) ?? [], definition]);
1544
+ const models = /* @__PURE__ */ new Map();
1545
+ const visited = /* @__PURE__ */ new Set();
1546
+ const queue = [componentId];
1547
+ while (queue.length > 0) {
1548
+ const current = queue.shift();
1549
+ if (visited.has(current)) continue;
1550
+ visited.add(current);
1551
+ for (const parent of embeddedBy.get(current) ?? []) if (parent.kind === "component") queue.push(parent.id);
1552
+ else models.set(parent.id, parent);
1553
+ }
1554
+ return [...models.values()];
1555
+ };
1556
+ //#endregion
1557
+ //#region src/diff/types.ts
1558
+ const CHANGE_KINDS = [
1559
+ "definition.added",
1560
+ "definition.removed",
1561
+ "definition.apiKey",
1562
+ "definition.pluralApiKey",
1563
+ "definition.metadata",
1564
+ "model.kind",
1565
+ "model.localized",
1566
+ "model.draftAndPublish",
1567
+ "field.added",
1568
+ "field.removed",
1569
+ "field.order",
1570
+ "field.apiKey",
1571
+ "field.metadata",
1572
+ "field.editor",
1573
+ "field.type",
1574
+ "field.required",
1575
+ "field.localized",
1576
+ "field.public",
1577
+ "field.deprecated",
1578
+ "field.unique",
1579
+ "field.filterable",
1580
+ "field.sortable",
1581
+ "field.settings"
1582
+ ];
1583
+ //#endregion
1584
+ //#region src/fileFormat/canonical.ts
1585
+ /**
1586
+ * Canonical JSON: object keys sorted by code point at every level, arrays in their given order (field
1587
+ * order is meaningful: it is the form order), two-space indentation and a trailing newline. Two equal
1588
+ * definitions always serialize to the same bytes, so files diff cleanly in git and hashes are stable.
1589
+ */
1590
+ const sortKeys = (value) => {
1591
+ if (Array.isArray(value)) return value.map(sortKeys);
1592
+ if (typeof value === "object" && value !== null) {
1593
+ const sorted = {};
1594
+ for (const key of Object.keys(value).sort()) {
1595
+ const entry = value[key];
1596
+ if (entry !== void 0) sorted[key] = sortKeys(entry);
1597
+ }
1598
+ return sorted;
1599
+ }
1600
+ return value;
1601
+ };
1602
+ const canonicalJson = (value) => `${JSON.stringify(sortKeys(value), null, 2)}\n`;
1603
+ /**
1604
+ * The bytes Shapio hashes and writes for a definition. The definition must be normalized
1605
+ * (`normalizeDefinition`), so defaults are explicit and the same meaning has one spelling.
1606
+ */
1607
+ const serializeDefinition = (definition) => canonicalJson(definition);
1608
+ //#endregion
1609
+ //#region src/diff/diffDefinitions.ts
1610
+ const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
1611
+ const asJson = (value) => value;
1612
+ const DEFINITION_METADATA = [
1613
+ "label",
1614
+ "description",
1615
+ "category",
1616
+ "icon",
1617
+ "display"
1618
+ ];
1619
+ const FIELD_METADATA = [
1620
+ "label",
1621
+ "description",
1622
+ "defaultValue"
1623
+ ];
1624
+ const FIELD_FLAGS = [
1625
+ ["required", "field.required"],
1626
+ ["localized", "field.localized"],
1627
+ ["public", "field.public"],
1628
+ ["deprecated", "field.deprecated"],
1629
+ ["unique", "field.unique"],
1630
+ ["filterable", "field.filterable"],
1631
+ ["sortable", "field.sortable"]
1632
+ ];
1633
+ const diffFieldSettings = (definitionId, before, after) => {
1634
+ const from = before.settings;
1635
+ const to = after.settings;
1636
+ return [.../* @__PURE__ */ new Set([...Object.keys(from), ...Object.keys(to)])].sort().filter((key) => !same(from[key], to[key])).map((key) => ({
1637
+ kind: "field.settings",
1638
+ definitionId,
1639
+ fieldId: after.id,
1640
+ property: key,
1641
+ from: asJson(from[key]),
1642
+ to: asJson(to[key])
1643
+ }));
1644
+ };
1645
+ const diffField = (definitionId, before, after) => {
1646
+ const changes = [];
1647
+ const base = {
1648
+ definitionId,
1649
+ fieldId: after.id
1650
+ };
1651
+ if (before.apiKey !== after.apiKey) changes.push({
1652
+ ...base,
1653
+ kind: "field.apiKey",
1654
+ from: before.apiKey,
1655
+ to: after.apiKey
1656
+ });
1657
+ for (const property of FIELD_METADATA) if (!same(before[property], after[property])) changes.push({
1658
+ ...base,
1659
+ kind: "field.metadata",
1660
+ property,
1661
+ from: asJson(before[property]),
1662
+ to: asJson(after[property])
1663
+ });
1664
+ if (!same(before.editor, after.editor)) changes.push({
1665
+ ...base,
1666
+ kind: "field.editor",
1667
+ from: before.editor,
1668
+ to: after.editor
1669
+ });
1670
+ if (before.type !== after.type) changes.push({
1671
+ ...base,
1672
+ kind: "field.type",
1673
+ from: before.type,
1674
+ to: after.type
1675
+ });
1676
+ else changes.push(...diffFieldSettings(definitionId, before, after));
1677
+ for (const [flag, kind] of FIELD_FLAGS) if (before[flag] !== after[flag]) changes.push({
1678
+ ...base,
1679
+ kind,
1680
+ from: before[flag],
1681
+ to: after[flag]
1682
+ });
1683
+ return changes;
1684
+ };
1685
+ /**
1686
+ * Every difference between two versions of a definition, matched by stable IDs (so a renamed field is a
1687
+ * rename, not a removal plus an addition). Pass `null` for a side that does not exist. The order is
1688
+ * deterministic: definition-level changes, then fields in the new order, then removed fields.
1689
+ */
1690
+ const diffDefinitions = (before, after) => {
1691
+ if (!before && !after) return [];
1692
+ if (!before) return [{
1693
+ kind: "definition.added",
1694
+ definitionId: after.id,
1695
+ to: after.apiKey
1696
+ }];
1697
+ if (!after) return [{
1698
+ kind: "definition.removed",
1699
+ definitionId: before.id,
1700
+ from: before.apiKey
1701
+ }];
1702
+ const definitionId = after.id;
1703
+ const changes = [];
1704
+ if (before.apiKey !== after.apiKey) changes.push({
1705
+ kind: "definition.apiKey",
1706
+ definitionId,
1707
+ from: before.apiKey,
1708
+ to: after.apiKey
1709
+ });
1710
+ const beforeRecord = before;
1711
+ const afterRecord = after;
1712
+ for (const property of DEFINITION_METADATA) if (!same(beforeRecord[property], afterRecord[property])) changes.push({
1713
+ kind: "definition.metadata",
1714
+ definitionId,
1715
+ property,
1716
+ from: asJson(beforeRecord[property]),
1717
+ to: asJson(afterRecord[property])
1718
+ });
1719
+ if (before.kind !== after.kind) changes.push({
1720
+ kind: "model.kind",
1721
+ definitionId,
1722
+ from: before.kind,
1723
+ to: after.kind
1724
+ });
1725
+ if (before.kind === "collection" && after.kind === "collection") {
1726
+ const [from, to] = [routeKeyOf(before), routeKeyOf(after)];
1727
+ if (from !== to) changes.push({
1728
+ kind: "definition.pluralApiKey",
1729
+ definitionId,
1730
+ from,
1731
+ to
1732
+ });
1733
+ }
1734
+ if (before.kind !== "component" && after.kind !== "component") {
1735
+ if (before.localized !== after.localized) changes.push({
1736
+ kind: "model.localized",
1737
+ definitionId,
1738
+ from: before.localized,
1739
+ to: after.localized
1740
+ });
1741
+ if (before.draftAndPublish !== after.draftAndPublish) changes.push({
1742
+ kind: "model.draftAndPublish",
1743
+ definitionId,
1744
+ from: before.draftAndPublish,
1745
+ to: after.draftAndPublish
1746
+ });
1747
+ }
1748
+ const beforeFields = new Map(before.fields.map((field) => [field.id, field]));
1749
+ const afterIds = new Set(after.fields.map((field) => field.id));
1750
+ for (const field of after.fields) {
1751
+ const previous = beforeFields.get(field.id);
1752
+ if (!previous) changes.push({
1753
+ kind: "field.added",
1754
+ definitionId,
1755
+ fieldId: field.id,
1756
+ to: field
1757
+ });
1758
+ else changes.push(...diffField(definitionId, previous, field));
1759
+ }
1760
+ for (const field of before.fields) if (!afterIds.has(field.id)) changes.push({
1761
+ kind: "field.removed",
1762
+ definitionId,
1763
+ fieldId: field.id,
1764
+ from: field
1765
+ });
1766
+ const keptBefore = before.fields.filter((field) => afterIds.has(field.id)).map((field) => field.id);
1767
+ const keptAfter = after.fields.filter((field) => beforeFields.has(field.id)).map((field) => field.id);
1768
+ if (!same(keptBefore, keptAfter)) changes.push({
1769
+ kind: "field.order",
1770
+ definitionId,
1771
+ from: keptBefore,
1772
+ to: keptAfter
1773
+ });
1774
+ return changes;
1775
+ };
1776
+ //#endregion
1777
+ //#region src/diff/classify.ts
1778
+ const result = (category, extra = {}) => ({
1779
+ category,
1780
+ breaking: false,
1781
+ destructive: false,
1782
+ supported: true,
1783
+ prerequisites: [],
1784
+ cleanup: [],
1785
+ ...extra
1786
+ });
1787
+ const findField = (definition, fieldId) => definition?.fields.find((field) => field.id === fieldId);
1788
+ const hasIndex = (field) => Boolean(field && (field.filterable || field.sortable));
1789
+ /** Names delivery APIs use for a type; a change of name is a contract change. */
1790
+ const API_SCALAR = {
1791
+ string: "String",
1792
+ text: "String",
1793
+ slug: "String",
1794
+ email: "String",
1795
+ url: "String",
1796
+ uid: "String",
1797
+ enum: "Enum",
1798
+ date: "Date",
1799
+ datetime: "DateTime",
1800
+ time: "Time",
1801
+ number: "Float",
1802
+ integer: "Int",
1803
+ decimal: "Decimal",
1804
+ biginteger: "BigInt",
1805
+ boolean: "Boolean",
1806
+ json: "JSON",
1807
+ richtext: "RichText",
1808
+ media: "Media",
1809
+ relation: "Relation",
1810
+ component: "Component",
1811
+ dynamiczone: "DynamicZone"
1812
+ };
1813
+ const PLAIN_TEXT_TYPES = /* @__PURE__ */ new Set(["string", "text"]);
1814
+ const SCALAR_FAMILIES = /* @__PURE__ */ new Set([
1815
+ "jsonString",
1816
+ "jsonNumber",
1817
+ "numericString",
1818
+ "boolean"
1819
+ ]);
1820
+ const hasTextConstraints = (field) => {
1821
+ const settings = field?.settings ?? {};
1822
+ return settings.minLength !== void 0 || settings.maxLength !== void 0 || settings.pattern !== void 0;
1823
+ };
1824
+ /**
1825
+ * A type change. Within one storage family the stored JSON stays as it is: widening is live, anything else
1826
+ * re-validates existing values. Across families Shapio converts values with a job, for the pairs it knows.
1827
+ */
1828
+ const classifyTypeChange = (from, to, after) => {
1829
+ const breaking = API_SCALAR[from] !== API_SCALAR[to];
1830
+ const fromFamily = STORAGE_FAMILY[from];
1831
+ const toFamily = STORAGE_FAMILY[to];
1832
+ if (fromFamily === toFamily) return fromFamily === "jsonString" && PLAIN_TEXT_TYPES.has(to) && !hasTextConstraints(after) || from === "integer" && to === "number" || from === "biginteger" && to === "decimal" && after?.type === "decimal" && after.settings.precision === void 0 ? result("additive", { breaking }) : result("validation", {
1833
+ breaking,
1834
+ prerequisites: ["validateValues"]
1835
+ });
1836
+ const numericPair = fromFamily === "jsonNumber" && toFamily === "numericString" || fromFamily === "numericString" && toFamily === "jsonNumber";
1837
+ const toText = SCALAR_FAMILIES.has(fromFamily) && PLAIN_TEXT_TYPES.has(to);
1838
+ const textToRichtext = fromFamily === "jsonString" && to === "richtext";
1839
+ const richtextToText = from === "richtext" && PLAIN_TEXT_TYPES.has(to);
1840
+ const supported = numericPair || toText || textToRichtext || richtextToText;
1841
+ return result("conversion", {
1842
+ breaking,
1843
+ supported,
1844
+ destructive: richtextToText,
1845
+ prerequisites: supported ? ["convert", "validateValues"] : []
1846
+ });
1847
+ };
1848
+ const compareValues = (a, b) => {
1849
+ if (typeof a === "number" && typeof b === "number") return a - b;
1850
+ try {
1851
+ const diff = BigInt(String(a)) - BigInt(String(b));
1852
+ return diff === 0n ? 0 : diff > 0n ? 1 : -1;
1853
+ } catch {
1854
+ const numeric = Number(a) - Number(b);
1855
+ return Number.isNaN(numeric) ? String(a).localeCompare(String(b)) : numeric;
1856
+ }
1857
+ };
1858
+ /** A lower bound (min, minLength) that appears or rises, or an upper bound that appears or falls. */
1859
+ const isTightened = (bound, from, to) => {
1860
+ if (to === void 0 || to === null) return false;
1861
+ if (from === void 0 || from === null) return true;
1862
+ const order = compareValues(from, to);
1863
+ return bound === "lower" ? order < 0 : order > 0;
1864
+ };
1865
+ const asStrings = (value) => Array.isArray(value) ? value.map((entry) => typeof entry === "object" && entry !== null ? String(entry.value) : String(entry)) : [];
1866
+ const removesAny = (from, to) => {
1867
+ const kept = new Set(asStrings(to));
1868
+ return asStrings(from).some((entry) => !kept.has(entry));
1869
+ };
1870
+ const validation = (tightened) => tightened ? result("validation", { prerequisites: ["validateValues"] }) : result("additive");
1871
+ const classifySettingsChange = (change) => {
1872
+ const { property, from, to } = change;
1873
+ switch (property) {
1874
+ case "minLength":
1875
+ case "min": return validation(isTightened("lower", from, to));
1876
+ case "maxLength":
1877
+ case "max":
1878
+ case "precision":
1879
+ case "scale": return validation(isTightened("upper", from, to));
1880
+ case "pattern": return validation(to !== void 0);
1881
+ case "values": return validation(removesAny(from, to));
1882
+ case "components":
1883
+ case "allowedKinds":
1884
+ case "protocols": return validation(to !== void 0 && (from === void 0 || removesAny(from, to)));
1885
+ case "sourceFieldId": return result("metadata");
1886
+ case "multiple":
1887
+ case "repeatable":
1888
+ case "cardinality": return result("conversion", {
1889
+ breaking: true,
1890
+ destructive: to === false || to === "one",
1891
+ prerequisites: ["convert", "validateValues"]
1892
+ });
1893
+ case "formatVersion": return result("conversion", { prerequisites: ["convert", "validateValues"] });
1894
+ case "target":
1895
+ case "component": return result("conversion", {
1896
+ breaking: true,
1897
+ destructive: true,
1898
+ prerequisites: ["convert"]
1899
+ });
1900
+ default: return result("validation", { prerequisites: ["validateValues"] });
1901
+ }
1902
+ };
1903
+ const classifyFieldChange = (change, { before, after }) => {
1904
+ const previous = findField(before, change.fieldId);
1905
+ const field = findField(after, change.fieldId);
1906
+ switch (change.kind) {
1907
+ case "field.added": {
1908
+ const prerequisites = [];
1909
+ if (field?.required) prerequisites.push(field.defaultValue !== void 0 ? "backfill" : "validateRequired");
1910
+ if (field?.unique) prerequisites.push("checkUnique");
1911
+ if (hasIndex(field)) prerequisites.push("buildIndex");
1912
+ return result(field?.required ? "requiredField" : "additive", { prerequisites });
1913
+ }
1914
+ case "field.removed": return result("removal", {
1915
+ breaking: true,
1916
+ cleanup: [...hasIndex(previous) ? ["dropIndex"] : [], ...previous?.unique ? ["releaseUnique"] : []]
1917
+ });
1918
+ case "field.order":
1919
+ case "field.metadata": return result("metadata");
1920
+ case "field.editor": return result("editorSwap");
1921
+ case "field.apiKey": return result("contract", { breaking: true });
1922
+ case "field.type": {
1923
+ const classification = classifyTypeChange(change.from, change.to, field);
1924
+ return field?.unique && previous?.unique && !classification.prerequisites.includes("checkUnique") ? {
1925
+ ...classification,
1926
+ prerequisites: [...classification.prerequisites, "checkUnique"]
1927
+ } : classification;
1928
+ }
1929
+ case "field.required":
1930
+ if (change.to !== true) return result("additive");
1931
+ return result("requiredField", { prerequisites: [field?.defaultValue !== void 0 ? "backfill" : "validateRequired"] });
1932
+ case "field.localized":
1933
+ if (!(after?.kind !== "component" && after?.localized === true)) return result("metadata");
1934
+ return change.to === true ? result("additive") : result("conversion", {
1935
+ destructive: true,
1936
+ prerequisites: ["convert"]
1937
+ });
1938
+ case "field.public":
1939
+ case "field.deprecated": {
1940
+ const hides = change.kind === "field.public" ? change.to === false : change.to === true;
1941
+ return result("metadata", { breaking: hides });
1942
+ }
1943
+ case "field.unique": return change.to === true ? result("constraint", { prerequisites: ["checkUnique"] }) : result("additive", { cleanup: ["releaseUnique"] });
1944
+ case "field.filterable":
1945
+ case "field.sortable":
1946
+ if (hasIndex(field) && !hasIndex(previous)) return result("index", { prerequisites: ["buildIndex"] });
1947
+ if (!hasIndex(field) && hasIndex(previous)) return result("index", { cleanup: ["dropIndex"] });
1948
+ return result("metadata");
1949
+ case "field.settings": return classifySettingsChange(change);
1950
+ default: return result("metadata");
1951
+ }
1952
+ };
1953
+ /** Classifies one change in the context of the definitions it was diffed from. */
1954
+ const classifyChange = (change, context) => {
1955
+ let classification;
1956
+ switch (change.kind) {
1957
+ case "definition.added":
1958
+ classification = result("additive");
1959
+ break;
1960
+ case "definition.removed":
1961
+ classification = result("removal", { breaking: true });
1962
+ break;
1963
+ case "definition.apiKey":
1964
+ case "definition.pluralApiKey":
1965
+ classification = result("contract", { breaking: true });
1966
+ break;
1967
+ case "definition.metadata":
1968
+ classification = result("metadata");
1969
+ break;
1970
+ case "model.kind":
1971
+ classification = change.to === "singleton" ? result("validation", {
1972
+ breaking: true,
1973
+ prerequisites: ["validateValues"]
1974
+ }) : result("contract", { breaking: true });
1975
+ break;
1976
+ case "model.localized":
1977
+ classification = change.to === true ? result("additive") : result("conversion", {
1978
+ destructive: true,
1979
+ prerequisites: ["convert"]
1980
+ });
1981
+ break;
1982
+ case "model.draftAndPublish":
1983
+ classification = change.to === true ? result("additive") : result("conversion", {
1984
+ destructive: true,
1985
+ prerequisites: ["convert"]
1986
+ });
1987
+ break;
1988
+ default: classification = classifyFieldChange(change, context);
1989
+ }
1990
+ return {
1991
+ ...change,
1992
+ ...classification
1993
+ };
1994
+ };
1995
+ const classifyChanges = (changes, context) => changes.map((change) => classifyChange(change, context));
1996
+ const PREREQUISITE_ORDER = [
1997
+ "convert",
1998
+ "backfill",
1999
+ "validateRequired",
2000
+ "validateValues",
2001
+ "checkUnique",
2002
+ "buildIndex"
2003
+ ];
2004
+ const summarizeChanges = (changes) => {
2005
+ const prerequisites = new Set(changes.flatMap((change) => change.prerequisites));
2006
+ return {
2007
+ breaking: changes.some((change) => change.breaking),
2008
+ destructive: changes.some((change) => change.destructive),
2009
+ supported: changes.every((change) => change.supported),
2010
+ prerequisites: PREREQUISITE_ORDER.filter((kind) => prerequisites.has(kind)),
2011
+ cleanup: [...new Set(changes.flatMap((change) => change.cleanup))],
2012
+ metadataOnly: changes.every((change) => change.category === "metadata" || change.category === "editorSwap")
2013
+ };
2014
+ };
2015
+ /**
2016
+ * - added: live (entries gain the locale as editors fill it in);
2017
+ * - removed: destroys that locale's content (purged by a follow-up job), so it needs explicit consent;
2018
+ * - default changed: delivery falls back to a different locale, a contract change for API consumers;
2019
+ * - label or fallback chain: metadata.
2020
+ */
2021
+ const classifyLocaleChange = (kind, code) => {
2022
+ switch (kind) {
2023
+ case "locale.added": return {
2024
+ kind,
2025
+ code,
2026
+ category: "additive",
2027
+ breaking: false,
2028
+ destructive: false
2029
+ };
2030
+ case "locale.removed": return {
2031
+ kind,
2032
+ code,
2033
+ category: "removal",
2034
+ breaking: true,
2035
+ destructive: true
2036
+ };
2037
+ case "locale.defaultChanged": return {
2038
+ kind,
2039
+ code,
2040
+ category: "contract",
2041
+ breaking: true,
2042
+ destructive: false
2043
+ };
2044
+ default: return {
2045
+ kind,
2046
+ code,
2047
+ category: "metadata",
2048
+ breaking: false,
2049
+ destructive: false
2050
+ };
2051
+ }
2052
+ };
2053
+ //#endregion
2054
+ //#region src/fileFormat/hash.ts
2055
+ const toHex = (buffer) => Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
2056
+ /** `sha256:<hex>` of a UTF-8 string, via Web Crypto (Node and browsers alike). */
2057
+ const sha256 = async (text) => `sha256:${toHex(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)))}`;
2058
+ /** Hash of a normalized definition's canonical serialization; recorded per model in the lock file. */
2059
+ const hashDefinition = (definition) => sha256(serializeDefinition(definition));
2060
+ //#endregion
2061
+ //#region src/fileFormat/lockFile.ts
2062
+ /** Where `shapio schema pull` keeps its record of what it pulled, relative to the project root. */
2063
+ const LOCK_FILE_PATH = ".shapio/schema-lock.json";
2064
+ const LOCK_FILE_FORMAT_VERSION = 1;
2065
+ const LockEntrySchema = Type.Object({
2066
+ kind: Type.Enum(DEFINITION_KINDS),
2067
+ apiKey: Type.String(),
2068
+ /** The definition's per-model version on the instance when it was pulled. */
2069
+ version: Type.Integer({ minimum: 1 }),
2070
+ /** `hashDefinition` of the pulled definition; a local file with another hash has been edited. */
2071
+ hash: Type.String()
2072
+ }, { additionalProperties: false });
2073
+ const LockFileSchema = Type.Object({
2074
+ formatVersion: Type.Literal(1),
2075
+ /** The instance's global schema version at pull time (informational; the guard is per model). */
2076
+ schemaVersion: Type.Integer({ minimum: 0 }),
2077
+ /** Keyed by definition ID. */
2078
+ definitions: Type.Record(Type.String(), LockEntrySchema)
2079
+ }, { additionalProperties: false });
2080
+ var LockFileError = class extends Error {
2081
+ constructor(message) {
2082
+ super(message);
2083
+ this.name = "LockFileError";
2084
+ }
2085
+ };
2086
+ const parseLockFile = (text) => {
2087
+ let parsed;
2088
+ try {
2089
+ parsed = JSON.parse(text);
2090
+ } catch {
2091
+ throw new LockFileError(`${LOCK_FILE_PATH} is not valid JSON`);
2092
+ }
2093
+ const errors = collectSchemaErrors(LockFileSchema, parsed);
2094
+ if (errors.length > 0) throw new LockFileError(`${LOCK_FILE_PATH} is invalid: ${errors.map((error) => `${error.path || "/"} ${error.message}`).join("; ")}`);
2095
+ return parsed;
2096
+ };
2097
+ const serializeLockFile = (lock) => canonicalJson(lock);
2098
+ //#endregion
2099
+ //#region src/sync/threeWay.ts
2100
+ const unchangedSinceBase = (remote, base) => remote.version === base.version || remote.hash === base.hash;
2101
+ const decideForLocal = (local, base, remote) => {
2102
+ if (base && local.hash === base.hash) return {
2103
+ action: "skip",
2104
+ reason: "unchangedLocally"
2105
+ };
2106
+ if (!remote) return base ? {
2107
+ action: "conflict",
2108
+ reason: "deletedOnTarget"
2109
+ } : { action: "create" };
2110
+ if (remote.hash === local.hash) return {
2111
+ action: "skip",
2112
+ reason: "alreadyApplied"
2113
+ };
2114
+ if (!base) return {
2115
+ action: "conflict",
2116
+ reason: "existsOnTarget"
2117
+ };
2118
+ return unchangedSinceBase(remote, base) ? {
2119
+ action: "update",
2120
+ expectedVersion: remote.version
2121
+ } : {
2122
+ action: "conflict",
2123
+ reason: "changedOnBoth"
2124
+ };
2125
+ };
2126
+ const decideSync = ({ local, base, remote, prune }) => {
2127
+ if (local) return decideForLocal(local, base, remote);
2128
+ if (!base) return {
2129
+ action: "skip",
2130
+ reason: "remoteOnly"
2131
+ };
2132
+ if (!remote) return {
2133
+ action: "skip",
2134
+ reason: "alreadyDeleted"
2135
+ };
2136
+ if (!prune) return {
2137
+ action: "skip",
2138
+ reason: "deletedLocallyWithoutPrune"
2139
+ };
2140
+ return unchangedSinceBase(remote, base) ? {
2141
+ action: "delete",
2142
+ expectedVersion: remote.version
2143
+ } : {
2144
+ action: "conflict",
2145
+ reason: "changedOnTargetBeforeDelete"
2146
+ };
2147
+ };
2148
+ //#endregion
2149
+ //#region src/richtext/render.ts
2150
+ const ESCAPES = {
2151
+ "&": "&amp;",
2152
+ "<": "&lt;",
2153
+ ">": "&gt;",
2154
+ "\"": "&quot;",
2155
+ "'": "&#39;"
2156
+ };
2157
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
2158
+ /** Attribute values come from validated documents: strings and numbers only. */
2159
+ const textOf = (value) => typeof value === "string" || typeof value === "number" ? String(value) : "";
2160
+ const attribute = (name, value) => value === null || value === void 0 ? "" : ` ${name}="${escapeHtml(textOf(value))}"`;
2161
+ const BLOCK_TAGS = {
2162
+ paragraph: "p",
2163
+ blockquote: "blockquote",
2164
+ bulletList: "ul",
2165
+ listItem: "li",
2166
+ table: "table",
2167
+ tableRow: "tr",
2168
+ tableHeader: "th",
2169
+ tableCell: "td"
2170
+ };
2171
+ const renderMarks = (text, marks) => (marks ?? []).reduceRight((inner, mark) => {
2172
+ switch (mark.type) {
2173
+ case "bold": return `<strong>${inner}</strong>`;
2174
+ case "italic": return `<em>${inner}</em>`;
2175
+ case "code": return `<code>${inner}</code>`;
2176
+ case "link": {
2177
+ const href = textOf(mark.attrs?.href).trim();
2178
+ if (!SAFE_LINK_PATTERN.test(href)) return inner;
2179
+ const blank = mark.attrs?.target === "_blank";
2180
+ return `<a${attribute("href", href)}${blank ? " target=\"_blank\" rel=\"noopener noreferrer nofollow\"" : ""}>${inner}</a>`;
2181
+ }
2182
+ default: return inner;
2183
+ }
2184
+ }, escapeHtml(text));
2185
+ const cellAttributes = (node) => {
2186
+ const colspan = Number(node.attrs?.colspan ?? 1);
2187
+ const rowspan = Number(node.attrs?.rowspan ?? 1);
2188
+ return `${colspan > 1 ? attribute("colspan", colspan) : ""}${rowspan > 1 ? attribute("rowspan", rowspan) : ""}`;
2189
+ };
2190
+ const renderNode = (node, resolveMedia) => {
2191
+ const children = () => (node.content ?? []).map((child) => renderNode(child, resolveMedia)).join("");
2192
+ switch (node.type) {
2193
+ case "doc": return children();
2194
+ case "text": return renderMarks(node.text ?? "", node.marks);
2195
+ case "hardBreak": return "<br>";
2196
+ case "horizontalRule": return "<hr>";
2197
+ case "heading": {
2198
+ const level = Math.min(6, Math.max(1, Number(node.attrs?.level ?? 1)));
2199
+ return `<h${level}>${children()}</h${level}>`;
2200
+ }
2201
+ case "orderedList": {
2202
+ const start = Number(node.attrs?.start ?? 1);
2203
+ return `<ol${start !== 1 ? attribute("start", start) : ""}${attribute("type", node.attrs?.type)}>${children()}</ol>`;
2204
+ }
2205
+ case "codeBlock": {
2206
+ const language = node.attrs?.language;
2207
+ const text = (node.content ?? []).map((child) => escapeHtml(child.text ?? "")).join("");
2208
+ return `<pre><code${language ? attribute("class", `language-${textOf(language)}`) : ""}>${text}</code></pre>`;
2209
+ }
2210
+ case "image": {
2211
+ const mediaId = textOf(node.attrs?.mediaId);
2212
+ const src = resolveMedia?.(mediaId);
2213
+ if (!src) return "";
2214
+ return `<img${attribute("src", src)}${attribute("alt", node.attrs?.alt ?? "")}${attribute("title", node.attrs?.title)}>`;
2215
+ }
2216
+ case "table": return `<table><tbody>${children()}</tbody></table>`;
2217
+ case "tableHeader":
2218
+ case "tableCell": {
2219
+ const tag = BLOCK_TAGS[node.type];
2220
+ return `<${tag}${cellAttributes(node)}>${children()}</${tag}>`;
2221
+ }
2222
+ default: {
2223
+ const tag = BLOCK_TAGS[node.type];
2224
+ return tag ? `<${tag}>${children()}</${tag}>` : "";
2225
+ }
2226
+ }
2227
+ };
2228
+ /** Renders a validated document to sanitized HTML. */
2229
+ const renderRichTextHtml = (document, resolveMedia) => renderNode(document.doc, resolveMedia);
2230
+ /** The document's text, blocks separated by blank lines (rich text → plain text conversions, search). */
2231
+ const richTextToPlainText = (document) => {
2232
+ const blocks = [];
2233
+ const inline = (node) => node.type === "text" ? node.text ?? "" : node.type === "hardBreak" ? "\n" : (node.content ?? []).map(inline).join("");
2234
+ const walk = (node) => {
2235
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "codeBlock") {
2236
+ blocks.push(inline(node));
2237
+ return;
2238
+ }
2239
+ (node.content ?? []).forEach(walk);
2240
+ };
2241
+ walk(document.doc);
2242
+ return blocks.join("\n\n");
2243
+ };
2244
+ /** Plain text → a document of paragraphs (blank lines split paragraphs; single newlines are hard breaks). */
2245
+ const plainTextToRichText = (text) => {
2246
+ const paragraphs = text.replace(/\r\n?/g, "\n").split(/\n{2,}/).filter((paragraph) => paragraph.length > 0).map((paragraph) => {
2247
+ const content = paragraph.split("\n").flatMap((line, index) => [...index > 0 ? [{ type: "hardBreak" }] : [], ...line.length > 0 ? [{
2248
+ type: "text",
2249
+ text: line
2250
+ }] : []]);
2251
+ return content.length > 0 ? {
2252
+ type: "paragraph",
2253
+ content
2254
+ } : { type: "paragraph" };
2255
+ });
2256
+ return {
2257
+ format: RICHTEXT_FORMAT,
2258
+ version: 1,
2259
+ doc: {
2260
+ type: "doc",
2261
+ content: paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }]
2262
+ }
2263
+ };
2264
+ };
2265
+ //#endregion
2266
+ export { API_KEY_PATTERN, BIGINTEGER_PATTERN, CANVAS_ELIGIBLE, CHANGE_KINDS, COMPONENT_TYPE_SUFFIXES, CUSTOM_EDITOR_ID_PATTERN, ComponentDefinitionInputSchema, ComponentDisplayInputSchema, DATA_TYPES, DATETIME_PATTERN, DATE_PATTERN, DECIMAL_PATTERN, DEFAULT_EDITORS, DEFINITION_KINDS, DefinitionInputSchema, EDITOR_CATALOGUE, EditorChoiceInputSchema, EnumValueSchema, FieldInputSchema, INLINE_TITLE_TYPES, LOCALE_CODE_PATTERN, LOCK_FILE_FORMAT_VERSION, LOCK_FILE_PATH, LocaleInputSchema, LockEntrySchema, LockFileError, LockFileSchema, MARK_RULES, MAX_API_KEY_LENGTH, MAX_COMPONENT_DEPTH, MAX_DESCRIPTION_LENGTH, MAX_FALLBACK_CHAIN, MAX_FIELDS_PER_DEFINITION, MAX_LABEL_LENGTH, MAX_RICHTEXT_DEPTH, MEDIA_KINDS, MODEL_KINDS, MODEL_TYPE_SUFFIXES, MUTATION_PREFIXES, ModelDefinitionInputSchema, ModelDisplayInputSchema, NODE_RULES, PLURAL_FALLBACK_SUFFIX, REFERENCE_DATA_TYPES, RELATION_CARDINALITIES, RESERVED_DEFINITION_API_KEYS, RESERVED_FIELD_API_KEYS, RESERVED_QUERY_NAMES, RESERVED_TYPE_NAMES, RICHTEXT_FORMAT, RICHTEXT_FORMAT_VERSION, SAFE_LINK_PATTERN, SCALAR_DATA_TYPES, SETTINGS_SCHEMAS, SLUG_PATTERN, SORT_DIRECTIONS, STABLE_ID_PATTERN, STORAGE_FAMILY, STRIP_DEFAULT_COUNT, SchemaValidationError, TEXT_TITLE_FIELD_TYPES, TIME_PATTERN, TITLE_FIELD_TYPES, UID_PATTERN, UNIQUE_CAPABLE_DATA_TYPES, assignIds, canonicalJson, checkApiKeySyntax, checkDefaultValue, classifyChange, classifyChanges, classifyLocaleChange, collectionQueryName, createStableId, decideSync, diffDefinitions, effectiveLayout, effectiveTitleField, embeddedComponentIds, escapeHtml, findDependentModels, findReferencingDefinitions, foldApiKey, generatedNames, hashDefinition, isApiKeySyntaxValid, isCanvasEligible, isComponentDefinition, isCoverEligible, isCustomEditorId, isDataType, isEmptyRichText, isModelDefinition, isStableId, issue, listCompatibleEditors, normalizeDefinition, parseDefinition, parseLockFile, plainTextToRichText, renderRichTextHtml, renormalizeDefinition, richTextMediaIds, richTextToPlainText, routeKeyOf, serializeDefinition, serializeLockFile, sha256, stripFieldsOf, suggestPlural, summarizeChanges, toTypeName, validateDefinition, validateRichText, validateSchema, withDerivedPlural };
2267
+
2268
+ //# sourceMappingURL=index.js.map