@shapio/schema 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
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 { a as isValidTitleTemplate, c as SEO_COMPONENT_ID, d as SEO_FIELD_IDS, f as SEO_TITLE_PLACEHOLDER, h as SEO_TWITTER_HANDLE_PATTERN, i as applyTitleTemplate, l as SEO_DESCRIPTION_SOFT_MAX, m as SEO_TITLE_TEMPLATE_PATTERN, n as resolveSeo, o as seoDefaultsForLocale, p as SEO_TITLE_SOFT_MAX, r as EMPTY_SEO_DEFAULTS, s as SEO_COMPONENT_API_KEY, t as DEFAULT_SEO_KEYS, u as SEO_EDITOR_ID } from "./resolve-viddCEYd.js";
2
3
  import { Type } from "typebox";
3
4
  import pluralize from "pluralize";
4
5
  import { Value } from "typebox/value";
@@ -103,7 +104,7 @@ const STORAGE_FAMILY = {
103
104
  * defaulted properties may be omitted. `normalizeDefinition` turns input into the normalized types below,
104
105
  * which are what Shapio stores, hashes and diffs.
105
106
  */
106
- const closed$2 = { additionalProperties: false };
107
+ const closed$3 = { additionalProperties: false };
107
108
  const MODEL_KINDS = ["collection", "singleton"];
108
109
  const DEFINITION_KINDS = [...MODEL_KINDS, "component"];
109
110
  const SORT_DIRECTIONS = ["asc", "desc"];
@@ -121,7 +122,7 @@ const EditorChoiceInputSchema = Type.Object({
121
122
  maxLength: 128
122
123
  }),
123
124
  options: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
124
- }, closed$2);
125
+ }, closed$3);
125
126
  const FieldInputSchema = Type.Object({
126
127
  id: Type.Optional(Type.String()),
127
128
  apiKey: Type.String(),
@@ -144,7 +145,7 @@ const FieldInputSchema = Type.Object({
144
145
  /** Type-specific settings, validated against SETTINGS_SCHEMAS[type]. */
145
146
  settings: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
146
147
  editor: Type.Optional(EditorChoiceInputSchema)
147
- }, closed$2);
148
+ }, closed$3);
148
149
  const FieldGroupSchema = Type.Object({
149
150
  id: Type.String({
150
151
  minLength: 1,
@@ -152,7 +153,7 @@ const FieldGroupSchema = Type.Object({
152
153
  }),
153
154
  label: Label,
154
155
  fieldIds: Type.Array(Type.String())
155
- }, closed$2);
156
+ }, closed$3);
156
157
  const ModelDisplayInputSchema = Type.Object({
157
158
  /** Field whose value labels an entry in lists, pickers and relation summaries. */
158
159
  titleFieldId: Type.Optional(Type.String()),
@@ -161,7 +162,7 @@ const ModelDisplayInputSchema = Type.Object({
161
162
  defaultSort: Type.Optional(Type.Object({
162
163
  fieldId: Type.String(),
163
164
  direction: Type.Enum(SORT_DIRECTIONS)
164
- }, closed$2)),
165
+ }, closed$3)),
165
166
  /** Editor layout: named sections of the entry form. */
166
167
  groups: Type.Optional(Type.Array(FieldGroupSchema)),
167
168
  /** Fields rendered as blocks in the entry document's canvas, in order. Default: see `effectiveLayout`. */
@@ -170,11 +171,11 @@ const ModelDisplayInputSchema = Type.Object({
170
171
  coverFieldId: Type.Optional(Type.String()),
171
172
  /** Properties shown as chips in the document's strip. Default: the first non-empty ones. */
172
173
  stripFieldIds: Type.Optional(Type.Array(Type.String()))
173
- }, closed$2);
174
+ }, closed$3);
174
175
  const ComponentDisplayInputSchema = Type.Object({
175
176
  titleFieldId: Type.Optional(Type.String()),
176
177
  groups: Type.Optional(Type.Array(FieldGroupSchema))
177
- }, closed$2);
178
+ }, closed$3);
178
179
  const definitionBase = {
179
180
  id: Type.Optional(Type.String()),
180
181
  apiKey: Type.String(),
@@ -194,7 +195,7 @@ const ModelDefinitionInputSchema = Type.Object({
194
195
  /** Entries have separate draft and published states. Default true. */
195
196
  draftAndPublish: Type.Optional(Type.Boolean()),
196
197
  display: Type.Optional(ModelDisplayInputSchema)
197
- }, closed$2);
198
+ }, closed$3);
198
199
  const ComponentDefinitionInputSchema = Type.Object({
199
200
  ...definitionBase,
200
201
  kind: Type.Literal("component"),
@@ -207,7 +208,7 @@ const ComponentDefinitionInputSchema = Type.Object({
207
208
  maxLength: 64
208
209
  })),
209
210
  display: Type.Optional(ComponentDisplayInputSchema)
210
- }, closed$2);
211
+ }, closed$3);
211
212
  const DefinitionInputSchema = Type.Union([ModelDefinitionInputSchema, ComponentDefinitionInputSchema]);
212
213
  const isComponentDefinition = (definition) => definition.kind === "component";
213
214
  const isModelDefinition = (definition) => definition.kind !== "component";
@@ -235,7 +236,7 @@ const LocaleInputSchema = Type.Object({
235
236
  * enum values...). Each schema is closed (`additionalProperties: false`) so a typo in a hand-edited schema
236
237
  * file is reported instead of ignored. Defaults are applied by `normalizeDefinition`.
237
238
  */
238
- const closed$1 = { additionalProperties: false };
239
+ const closed$2 = { additionalProperties: false };
239
240
  const NonNegativeInt = Type.Integer({ minimum: 0 });
240
241
  const pattern = (regex) => Type.String({ pattern: regex.source });
241
242
  const lengthRules = {
@@ -263,7 +264,7 @@ const EnumValueSchema = Type.Object({
263
264
  minLength: 1,
264
265
  maxLength: 200
265
266
  })
266
- }, closed$1);
267
+ }, closed$2);
267
268
  const SETTINGS_SCHEMAS = {
268
269
  string: Type.Object({
269
270
  ...lengthRules,
@@ -271,20 +272,20 @@ const SETTINGS_SCHEMAS = {
271
272
  minLength: 1,
272
273
  maxLength: 500
273
274
  }))
274
- }, closed$1),
275
- text: Type.Object(lengthRules, closed$1),
275
+ }, closed$2),
276
+ text: Type.Object(lengthRules, closed$2),
276
277
  richtext: Type.Object({
277
278
  formatVersion: Type.Optional(Type.Integer({ minimum: 1 })),
278
279
  maxLength: Type.Optional(Type.Integer({ minimum: 1 }))
279
- }, closed$1),
280
+ }, closed$2),
280
281
  number: Type.Object({
281
282
  min: Type.Optional(Type.Number()),
282
283
  max: Type.Optional(Type.Number())
283
- }, closed$1),
284
+ }, closed$2),
284
285
  integer: Type.Object({
285
286
  min: Type.Optional(Type.Integer()),
286
287
  max: Type.Optional(Type.Integer())
287
- }, closed$1),
288
+ }, closed$2),
288
289
  decimal: Type.Object({
289
290
  precision: Type.Optional(Type.Integer({
290
291
  minimum: 1,
@@ -296,70 +297,74 @@ const SETTINGS_SCHEMAS = {
296
297
  })),
297
298
  min: Type.Optional(pattern(DECIMAL_PATTERN)),
298
299
  max: Type.Optional(pattern(DECIMAL_PATTERN))
299
- }, closed$1),
300
+ }, closed$2),
300
301
  biginteger: Type.Object({
301
302
  min: Type.Optional(pattern(BIGINTEGER_PATTERN)),
302
303
  max: Type.Optional(pattern(BIGINTEGER_PATTERN))
303
- }, closed$1),
304
- boolean: Type.Object({}, closed$1),
304
+ }, closed$2),
305
+ boolean: Type.Object({}, closed$2),
305
306
  date: Type.Object({
306
307
  min: Type.Optional(pattern(DATE_PATTERN)),
307
308
  max: Type.Optional(pattern(DATE_PATTERN))
308
- }, closed$1),
309
+ }, closed$2),
309
310
  datetime: Type.Object({
310
311
  min: Type.Optional(pattern(DATETIME_PATTERN)),
311
312
  max: Type.Optional(pattern(DATETIME_PATTERN))
312
- }, closed$1),
313
+ }, closed$2),
313
314
  time: Type.Object({
314
315
  min: Type.Optional(pattern(TIME_PATTERN)),
315
316
  max: Type.Optional(pattern(TIME_PATTERN))
316
- }, closed$1),
317
+ }, closed$2),
317
318
  enum: Type.Object({
318
319
  values: Type.Array(EnumValueSchema, {
319
320
  minItems: 1,
320
321
  maxItems: 500
321
322
  }),
322
323
  multiple: Type.Optional(Type.Boolean())
323
- }, closed$1),
324
- json: Type.Object({}, closed$1),
324
+ }, closed$2),
325
+ json: Type.Object({}, closed$2),
325
326
  slug: Type.Object({
326
327
  ...lengthRules,
327
328
  sourceFieldId: Type.Optional(Type.String())
328
- }, closed$1),
329
- email: Type.Object({}, closed$1),
329
+ }, closed$2),
330
+ email: Type.Object({}, closed$2),
330
331
  url: Type.Object({ protocols: Type.Optional(Type.Array(Type.Enum([
331
332
  "http",
332
333
  "https",
333
334
  "mailto",
334
335
  "tel"
335
- ]), { minItems: 1 })) }, closed$1),
336
- uid: Type.Object(lengthRules, closed$1),
336
+ ]), { minItems: 1 })) }, closed$2),
337
+ uid: Type.Object(lengthRules, closed$2),
337
338
  media: Type.Object({
338
339
  multiple: Type.Optional(Type.Boolean()),
339
340
  allowedKinds: Type.Optional(Type.Array(Type.Enum(MEDIA_KINDS), { minItems: 1 })),
340
341
  ...countRules
341
- }, closed$1),
342
+ }, closed$2),
342
343
  relation: Type.Object({
343
344
  target: Type.String(),
344
345
  cardinality: Type.Enum(RELATION_CARDINALITIES),
345
346
  ...countRules
346
- }, closed$1),
347
+ }, closed$2),
347
348
  component: Type.Object({
348
349
  component: Type.String(),
349
350
  repeatable: Type.Optional(Type.Boolean()),
350
351
  ...countRules
351
- }, closed$1),
352
+ }, closed$2),
352
353
  dynamiczone: Type.Object({
353
354
  components: Type.Array(Type.String(), {
354
355
  minItems: 1,
355
356
  maxItems: 100
356
357
  }),
357
358
  ...countRules
358
- }, closed$1)
359
+ }, closed$2)
359
360
  };
360
361
  //#endregion
362
+ //#region src/seo/field.ts
363
+ /** Whether a field holds the built-in SEO component (by stable ID, whatever its API ID or editor). */
364
+ const isSeoField = (field) => field.type === "component" && field.settings.component === "f3905a00-d2a3-4c62-899a-c894bfd6cb9b" && !field.settings.repeatable;
365
+ //#endregion
361
366
  //#region src/editors/catalogue.ts
362
- const closed = { additionalProperties: false };
367
+ const closed$1 = { additionalProperties: false };
363
368
  const Placeholder = Type.Optional(Type.String({ maxLength: 200 }));
364
369
  const isMultipleEnum = (field) => field.type === "enum" && field.settings.multiple;
365
370
  const entries = [
@@ -371,7 +376,7 @@ const entries = [
371
376
  "url",
372
377
  "uid"
373
378
  ],
374
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
379
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1)
375
380
  },
376
381
  {
377
382
  id: "textarea",
@@ -382,7 +387,7 @@ const entries = [
382
387
  minimum: 2,
383
388
  maximum: 40
384
389
  }))
385
- }, closed)
390
+ }, closed$1)
386
391
  },
387
392
  {
388
393
  id: "color",
@@ -390,12 +395,12 @@ const entries = [
390
395
  optionsSchema: Type.Object({
391
396
  format: Type.Optional(Type.Enum(["hex", "rgb"])),
392
397
  presets: Type.Optional(Type.Array(Type.String({ maxLength: 32 }), { maxItems: 50 }))
393
- }, closed)
398
+ }, closed$1)
394
399
  },
395
400
  {
396
401
  id: "richText",
397
402
  dataTypes: ["richtext"],
398
- optionsSchema: Type.Object({ toolbar: Type.Optional(Type.Enum(["full", "minimal"])) }, closed)
403
+ optionsSchema: Type.Object({ toolbar: Type.Optional(Type.Enum(["full", "minimal"])) }, closed$1)
399
404
  },
400
405
  {
401
406
  id: "numberInput",
@@ -408,17 +413,17 @@ const entries = [
408
413
  optionsSchema: Type.Object({
409
414
  placeholder: Placeholder,
410
415
  step: Type.Optional(Type.Number({ exclusiveMinimum: 0 }))
411
- }, closed)
416
+ }, closed$1)
412
417
  },
413
418
  {
414
419
  id: "toggle",
415
420
  dataTypes: ["boolean"],
416
- optionsSchema: Type.Object({}, closed)
421
+ optionsSchema: Type.Object({}, closed$1)
417
422
  },
418
423
  {
419
424
  id: "checkbox",
420
425
  dataTypes: ["boolean"],
421
- optionsSchema: Type.Object({}, closed)
426
+ optionsSchema: Type.Object({}, closed$1)
422
427
  },
423
428
  {
424
429
  id: "segmented",
@@ -426,35 +431,35 @@ const entries = [
426
431
  optionsSchema: Type.Object({
427
432
  trueLabel: Type.Optional(Type.String({ maxLength: 50 })),
428
433
  falseLabel: Type.Optional(Type.String({ maxLength: 50 }))
429
- }, closed),
434
+ }, closed$1),
430
435
  supports: (field) => !isMultipleEnum(field)
431
436
  },
432
437
  {
433
438
  id: "select",
434
439
  dataTypes: ["enum"],
435
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
440
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1)
436
441
  },
437
442
  {
438
443
  id: "radio",
439
444
  dataTypes: ["enum"],
440
- optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed),
445
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$1),
441
446
  supports: (field) => !isMultipleEnum(field)
442
447
  },
443
448
  {
444
449
  id: "checkboxGroup",
445
450
  dataTypes: ["enum"],
446
- optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed),
451
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$1),
447
452
  supports: isMultipleEnum
448
453
  },
449
454
  {
450
455
  id: "datePicker",
451
456
  dataTypes: ["date"],
452
- optionsSchema: Type.Object({}, closed)
457
+ optionsSchema: Type.Object({}, closed$1)
453
458
  },
454
459
  {
455
460
  id: "dateTimePicker",
456
461
  dataTypes: ["datetime"],
457
- optionsSchema: Type.Object({ display: Type.Optional(Type.Enum(["local", "utc"])) }, closed)
462
+ optionsSchema: Type.Object({ display: Type.Optional(Type.Enum(["local", "utc"])) }, closed$1)
458
463
  },
459
464
  {
460
465
  id: "timePicker",
@@ -462,12 +467,12 @@ const entries = [
462
467
  optionsSchema: Type.Object({ stepMinutes: Type.Optional(Type.Integer({
463
468
  minimum: 1,
464
469
  maximum: 60
465
- })) }, closed)
470
+ })) }, closed$1)
466
471
  },
467
472
  {
468
473
  id: "slugInput",
469
474
  dataTypes: ["slug"],
470
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed)
475
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1)
471
476
  },
472
477
  {
473
478
  id: "jsonEditor",
@@ -475,27 +480,33 @@ const entries = [
475
480
  optionsSchema: Type.Object({ rows: Type.Optional(Type.Integer({
476
481
  minimum: 3,
477
482
  maximum: 80
478
- })) }, closed)
483
+ })) }, closed$1)
479
484
  },
480
485
  {
481
486
  id: "mediaPicker",
482
487
  dataTypes: ["media"],
483
- optionsSchema: Type.Object({}, closed)
488
+ optionsSchema: Type.Object({}, closed$1)
484
489
  },
485
490
  {
486
491
  id: "relationPicker",
487
492
  dataTypes: ["relation"],
488
- optionsSchema: Type.Object({ allowInlineCreate: Type.Optional(Type.Boolean()) }, closed)
493
+ optionsSchema: Type.Object({ allowInlineCreate: Type.Optional(Type.Boolean()) }, closed$1)
489
494
  },
490
495
  {
491
496
  id: "componentEditor",
492
497
  dataTypes: ["component"],
493
- optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed)
498
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$1)
499
+ },
500
+ {
501
+ id: SEO_EDITOR_ID,
502
+ dataTypes: ["component"],
503
+ optionsSchema: Type.Object({}, closed$1),
504
+ supports: isSeoField
494
505
  },
495
506
  {
496
507
  id: "dynamicZoneEditor",
497
508
  dataTypes: ["dynamiczone"],
498
- optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed)
509
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$1)
499
510
  }
500
511
  ];
501
512
  const EDITOR_CATALOGUE = new Map(entries.map((entry) => [entry.id, entry]));
@@ -565,8 +576,9 @@ const effectiveTitleField = (definition) => {
565
576
  //#endregion
566
577
  //#region src/display/layout.ts
567
578
  /**
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.
579
+ * Data types that are blocks of the entry document's canvas by default. Components must also be repeatable
580
+ * and media must hold several files (`isCanvasEligible`); a single component or file is a property unless
581
+ * `display.canvasFieldIds` places it in the document.
570
582
  */
571
583
  const CANVAS_ELIGIBLE = /* @__PURE__ */ new Set([
572
584
  "richtext",
@@ -578,6 +590,11 @@ const CANVAS_ELIGIBLE = /* @__PURE__ */ new Set([
578
590
  const INLINE_TITLE_TYPES = /* @__PURE__ */ new Set(["string", "text"]);
579
591
  /** How many properties the strip shows when `display.stripFieldIds` is not set. */
580
592
  const STRIP_DEFAULT_COUNT = 5;
593
+ /**
594
+ * The automatic rule: the fields written in the document while `display.canvasFieldIds` is unset (rich
595
+ * text, dynamic zones, repeatable components, media with several files). Also what a canvas field can take
596
+ * between blocks: only these hold blocks.
597
+ */
581
598
  const isCanvasEligible = (field) => {
582
599
  switch (field.type) {
583
600
  case "richtext":
@@ -587,6 +604,12 @@ const isCanvasEligible = (field) => {
587
604
  default: return false;
588
605
  }
589
606
  };
607
+ /**
608
+ * Whether `display.canvasFieldIds` may list the field: any field but the entry's title (`effectiveTitleField`),
609
+ * which the document shows as its heading. A configured cover is refused by the validator; an automatically
610
+ * chosen one placed in the document simply stops being the cover.
611
+ */
612
+ const isDocumentPlaceable = (field, title) => field.id !== title?.id;
590
613
  /** A single `media` field that may hold an image. */
591
614
  const isCoverEligible = (field) => field.type === "media" && !field.settings.multiple && (field.settings.allowedKinds === void 0 || field.settings.allowedKinds.includes("image"));
592
615
  const byIds = (live, ids) => (ids ?? []).map((id) => live.find((field) => field.id === id)).filter((field) => field !== void 0);
@@ -611,16 +634,16 @@ const coverOf = (model, live, canvas) => {
611
634
  return live.find((field) => isCoverEligible(field) && !canvas.has(field.id));
612
635
  };
613
636
  /**
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.
637
+ * The entry document's layout: title, cover, document fields and properties. Configured `display` ids win
638
+ * when they point at live, eligible fields; anything else falls back to the defaults (canvas: every block
639
+ * field in field order; cover: the first single image field not in the canvas). Deprecated, unknown ids and
640
+ * the title are skipped, so a stale definition never breaks the editor. Pure: no values, no I/O.
618
641
  */
619
642
  const effectiveLayout = (model) => {
620
643
  const live = model.fields.filter((field) => !field.deprecated);
621
644
  const title = effectiveTitleField(model);
622
645
  const titleInline = title !== void 0 && INLINE_TITLE_TYPES.has(title.type);
623
- const configuredCanvas = unique(byIds(live, model.display.canvasFieldIds).filter(isCanvasEligible));
646
+ const configuredCanvas = unique(byIds(live, model.display.canvasFieldIds).filter((field) => isDocumentPlaceable(field, title)));
624
647
  const canvasCandidates = model.display.canvasFieldIds === void 0 ? live.filter(isCanvasEligible) : configuredCanvas;
625
648
  const cover = coverOf(model, live, new Set(configuredCanvas.map((field) => field.id)));
626
649
  const canvas = canvasCandidates.filter((field) => field.id !== cover?.id);
@@ -939,7 +962,10 @@ const RESERVED_TYPE_NAMES = [
939
962
  "SnapshotChangeKind",
940
963
  "SnapshotChangeLocale",
941
964
  "SnapshotChangePage",
942
- "SnapshotInfo"
965
+ "SnapshotInfo",
966
+ "SiteInfo",
967
+ "SiteSeoDefaults",
968
+ "SiteSeoLocale"
943
969
  ];
944
970
  /**
945
971
  * Model and component API keys the admin uses as static URL segments (`/content/new`,
@@ -973,7 +999,8 @@ const RESERVED_FIELD_API_KEYS = [
973
999
  const RESERVED_QUERY_NAMES = [
974
1000
  "_schemaVersion",
975
1001
  "_changes",
976
- "_snapshot"
1002
+ "_snapshot",
1003
+ "_site"
977
1004
  ];
978
1005
  /** Every name a definition contributes to the GraphQL schema. */
979
1006
  const generatedNames = (definition) => {
@@ -1232,17 +1259,18 @@ const checkFieldList = (ids, check) => {
1232
1259
  });
1233
1260
  };
1234
1261
  /**
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.
1262
+ * The entry document's layout (`effectiveLayout`): document fields, cover and strip. Title, cover and
1263
+ * canvas are disjoint: any field but the title (configured or automatic) may be in the canvas, the
1264
+ * configured cover may not; strip entries are properties, so never the cover or a document field.
1238
1265
  */
1239
1266
  const checkDocumentLayout = (model, fields) => {
1240
1267
  const { canvasFieldIds, coverFieldId, stripFieldIds } = model.display;
1241
1268
  const canvas = new Set(canvasFieldIds ?? []);
1269
+ const title = effectiveTitleField(model);
1242
1270
  const issues = checkFieldList(canvasFieldIds, {
1243
1271
  path: "/display/canvasFieldIds",
1244
1272
  fields,
1245
- reject: (field) => isCanvasEligible(field) ? void 0 : canvasRejection(field)
1273
+ reject: (field) => isDocumentPlaceable(field, title) ? void 0 : "the title is the heading of the document, not a field in it"
1246
1274
  });
1247
1275
  if (coverFieldId !== void 0) issues.push(...checkFieldList([coverFieldId], {
1248
1276
  path: "/display/coverFieldId",
@@ -1259,11 +1287,6 @@ const checkDocumentLayout = (model, fields) => {
1259
1287
  }));
1260
1288
  return issues;
1261
1289
  };
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
1290
  const checkDisplay = (definition) => {
1268
1291
  const fields = new Map(definition.fields.map((field) => [field.id, field]));
1269
1292
  const fieldIds = new Set(fields.keys());
@@ -2412,7 +2435,9 @@ const BUILT_IN_THEME_KEYS = [
2412
2435
  "shapio",
2413
2436
  "classic",
2414
2437
  "murdered-out",
2415
- "snowed"
2438
+ "forest",
2439
+ "snowed",
2440
+ "butter"
2416
2441
  ];
2417
2442
  /**
2418
2443
  * A token value an extension may use: a hex colour or a colour function whose arguments are numbers, units
@@ -2525,6 +2550,89 @@ const checkThemeContrast = (tokens, variant) => {
2525
2550
  return [...THEME_TEXT_PAIRS.map((pair) => measure(pair, 4.5)), ...uiPairs.map((pair) => measure(pair, 3))];
2526
2551
  };
2527
2552
  //#endregion
2528
- export { API_KEY_PATTERN, BIGINTEGER_PATTERN, BUILT_IN_THEME_KEYS, 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, SCHEMA_KIND_DIRECTORIES, SETTINGS_SCHEMAS, SITES_DIRECTORY, SLUG_PATTERN, SORT_DIRECTIONS, STABLE_ID_PATTERN, STORAGE_FAMILY, STRIP_DEFAULT_COUNT, SchemaValidationError, TEXT_TITLE_FIELD_TYPES, THEME_BRAND_TOKENS, THEME_COLOUR_PATTERN, THEME_DARK_UI_PAIRS, THEME_KEY_PATTERN, THEME_SEMANTIC_TOKENS, THEME_TEXT_PAIRS, THEME_TOKENS, THEME_UI_PAIRS, THEME_VARIANTS, TIME_PATTERN, TITLE_FIELD_TYPES, UID_PATTERN, UNIQUE_CAPABLE_DATA_TYPES, assignIds, canonicalJson, checkApiKeySyntax, checkDefaultValue, checkThemeContrast, classifyChange, classifyChanges, classifyLocaleChange, collectionQueryName, contrastRatio, createStableId, decideSync, diffDefinitions, effectiveLayout, effectiveTitleField, embeddedComponentIds, escapeHtml, findDependentModels, findReferencingDefinitions, foldApiKey, generatedNames, hashDefinition, isApiKeySyntaxValid, isCanvasEligible, isComponentDefinition, isCoverEligible, isCustomEditorId, isDataType, isEmptyRichText, isModelDefinition, isStableId, issue, listCompatibleEditors, lockCoversSite, lockEntriesForSite, mergePulledLock, normalizeDefinition, parseDefinition, parseLockFile, plainTextToRichText, renderRichTextHtml, renormalizeDefinition, richTextMediaIds, richTextToPlainText, routeKeyOf, schemaFilePath, scopeDirectory, scopeOfSchemaFilePath, serializeDefinition, serializeLockFile, sha256, stripFieldsOf, suggestPlural, summarizeChanges, toTypeName, upgradeLockFile, validateDefinition, validateRichText, validateSchema, withDerivedPlural, withSiteCovered };
2553
+ //#region src/seo/definition.ts
2554
+ /**
2555
+ * The built-in SEO component as first created. All fields are public and none has a length limit: the 70 and
2556
+ * 160 character marks are admin-only counters (`SEO_TITLE_SOFT_MAX`), never validation.
2557
+ */
2558
+ const seoComponentDefinition = () => normalizeDefinition({
2559
+ id: SEO_COMPONENT_ID,
2560
+ kind: "component",
2561
+ apiKey: "seo",
2562
+ label: "SEO",
2563
+ description: "Search and social metadata: title, description, image, canonical URL and indexing.",
2564
+ category: "Built-in",
2565
+ icon: "search",
2566
+ fields: [
2567
+ {
2568
+ id: SEO_FIELD_IDS.title,
2569
+ apiKey: "title",
2570
+ label: "Title",
2571
+ type: "string"
2572
+ },
2573
+ {
2574
+ id: SEO_FIELD_IDS.description,
2575
+ apiKey: "description",
2576
+ label: "Description",
2577
+ type: "text"
2578
+ },
2579
+ {
2580
+ id: SEO_FIELD_IDS.image,
2581
+ apiKey: "image",
2582
+ label: "Social image",
2583
+ type: "media",
2584
+ settings: {
2585
+ multiple: false,
2586
+ allowedKinds: ["image"]
2587
+ }
2588
+ },
2589
+ {
2590
+ id: SEO_FIELD_IDS.canonical,
2591
+ apiKey: "canonical",
2592
+ label: "Canonical URL",
2593
+ type: "url",
2594
+ settings: { protocols: ["https", "http"] }
2595
+ },
2596
+ {
2597
+ id: SEO_FIELD_IDS.noindex,
2598
+ apiKey: "noindex",
2599
+ label: "Hide from search engines",
2600
+ type: "boolean"
2601
+ }
2602
+ ],
2603
+ display: { titleFieldId: SEO_FIELD_IDS.title }
2604
+ });
2605
+ //#endregion
2606
+ //#region src/seo/defaults.ts
2607
+ const closed = { additionalProperties: false };
2608
+ /** Media asset IDs are UUIDs. */
2609
+ const ASSET_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
2610
+ /** One locale's default texts. Every key is optional: a missing one falls back along the locale chain. */
2611
+ const SeoLocaleDefaultsSchema = Type.Object({
2612
+ siteName: Type.Optional(Type.String({
2613
+ minLength: 1,
2614
+ maxLength: 200
2615
+ })),
2616
+ titleTemplate: Type.Optional(Type.String({
2617
+ minLength: 2,
2618
+ maxLength: 200,
2619
+ pattern: SEO_TITLE_TEMPLATE_PATTERN.source
2620
+ })),
2621
+ description: Type.Optional(Type.String({
2622
+ minLength: 1,
2623
+ maxLength: 1e3
2624
+ }))
2625
+ }, closed);
2626
+ /**
2627
+ * A site's SEO defaults (`sites.seo_defaults`): texts per locale code, the default social image (a public
2628
+ * image asset of the same site) and the Twitter handle for the whole site.
2629
+ */
2630
+ const SeoDefaultsSchema = Type.Object({
2631
+ locales: Type.Record(Type.String({ pattern: LOCALE_CODE_PATTERN.source }), SeoLocaleDefaultsSchema, { additionalProperties: false }),
2632
+ imageId: Type.Union([Type.String({ pattern: ASSET_ID_PATTERN.source }), Type.Null()]),
2633
+ twitterHandle: Type.Union([Type.String({ pattern: SEO_TWITTER_HANDLE_PATTERN.source }), Type.Null()])
2634
+ }, closed);
2635
+ //#endregion
2636
+ export { API_KEY_PATTERN, BIGINTEGER_PATTERN, BUILT_IN_THEME_KEYS, CANVAS_ELIGIBLE, CHANGE_KINDS, COMPONENT_TYPE_SUFFIXES, CUSTOM_EDITOR_ID_PATTERN, ComponentDefinitionInputSchema, ComponentDisplayInputSchema, DATA_TYPES, DATETIME_PATTERN, DATE_PATTERN, DECIMAL_PATTERN, DEFAULT_EDITORS, DEFAULT_SEO_KEYS, DEFINITION_KINDS, DefinitionInputSchema, EDITOR_CATALOGUE, EMPTY_SEO_DEFAULTS, 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, SCHEMA_KIND_DIRECTORIES, SEO_COMPONENT_API_KEY, SEO_COMPONENT_ID, SEO_DESCRIPTION_SOFT_MAX, SEO_EDITOR_ID, SEO_FIELD_IDS, SEO_TITLE_PLACEHOLDER, SEO_TITLE_SOFT_MAX, SEO_TITLE_TEMPLATE_PATTERN, SEO_TWITTER_HANDLE_PATTERN, SETTINGS_SCHEMAS, SITES_DIRECTORY, SLUG_PATTERN, SORT_DIRECTIONS, STABLE_ID_PATTERN, STORAGE_FAMILY, STRIP_DEFAULT_COUNT, SchemaValidationError, SeoDefaultsSchema, SeoLocaleDefaultsSchema, TEXT_TITLE_FIELD_TYPES, THEME_BRAND_TOKENS, THEME_COLOUR_PATTERN, THEME_DARK_UI_PAIRS, THEME_KEY_PATTERN, THEME_SEMANTIC_TOKENS, THEME_TEXT_PAIRS, THEME_TOKENS, THEME_UI_PAIRS, THEME_VARIANTS, TIME_PATTERN, TITLE_FIELD_TYPES, UID_PATTERN, UNIQUE_CAPABLE_DATA_TYPES, applyTitleTemplate, assignIds, canonicalJson, checkApiKeySyntax, checkDefaultValue, checkThemeContrast, classifyChange, classifyChanges, classifyLocaleChange, collectionQueryName, contrastRatio, createStableId, decideSync, diffDefinitions, effectiveLayout, effectiveTitleField, embeddedComponentIds, escapeHtml, findDependentModels, findReferencingDefinitions, foldApiKey, generatedNames, hashDefinition, isApiKeySyntaxValid, isCanvasEligible, isComponentDefinition, isCoverEligible, isCustomEditorId, isDataType, isDocumentPlaceable, isEmptyRichText, isModelDefinition, isSeoField, isStableId, isValidTitleTemplate, issue, listCompatibleEditors, lockCoversSite, lockEntriesForSite, mergePulledLock, normalizeDefinition, parseDefinition, parseLockFile, plainTextToRichText, renderRichTextHtml, renormalizeDefinition, resolveSeo, richTextMediaIds, richTextToPlainText, routeKeyOf, schemaFilePath, scopeDirectory, scopeOfSchemaFilePath, seoComponentDefinition, seoDefaultsForLocale, serializeDefinition, serializeLockFile, sha256, stripFieldsOf, suggestPlural, summarizeChanges, toTypeName, upgradeLockFile, validateDefinition, validateRichText, validateSchema, withDerivedPlural, withSiteCovered };
2529
2637
 
2530
2638
  //# sourceMappingURL=index.js.map