@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/LICENSE +201 -0
- package/dist/html.d.ts +26 -0
- package/dist/html.js +424 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1082 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/dist/validate-BQGutaBo.js +381 -0
- package/dist/validate-BQGutaBo.js.map +1 -0
- package/dist/validate-Oyo0GJ9j.d.ts +41 -0
- package/package.json +52 -0
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
|
+
"&": "&",
|
|
2152
|
+
"<": "<",
|
|
2153
|
+
">": ">",
|
|
2154
|
+
"\"": """,
|
|
2155
|
+
"'": "'"
|
|
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
|