riverbankcms 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acceptance/matrix.mjs +26 -18
- package/dist/cli/index.mjs +2026 -7310
- package/dist/cli/init-docs/content/cli-reference.md +8 -6
- package/dist/cli/init-docs/content/workflow-isr-revalidation.md +26 -24
- package/dist/eventUpsertInputSchema-BUeXa6jk.mjs +4940 -0
- package/dist/{load-config-r1s4htWu.mjs → load-config-CJUwrGHA.mjs} +1 -1
- package/dist/load-config-CcQyVy3r.mjs +5 -0
- package/dist/sdk-event-upsert-CTqsrhFT.mjs +5484 -0
- package/dist/{sdk-peer-DrblJmno.mjs → sdk-peer-D9FHJ54i.mjs} +3 -3
- package/dist/{style-Chv9Gcwq.mjs → style-lftMrun_.mjs} +29376 -34802
- package/dist/testing.mjs +3 -2
- package/package.json +2 -2
- package/dist/load-config-CRB4AHKq.mjs +0 -5
|
@@ -0,0 +1,4940 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as classifySdkEventPricing, i as classifySdkEventSchedule, o as classifySdkEventPaymentTerms, r as sdkEventUpsertOptionalFieldSchemas } from "./sdk-event-upsert-CTqsrhFT.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region ../blocks/src/lib/typeGuards.ts
|
|
6
|
+
/**
|
|
7
|
+
* Strict record guard: excludes null, primitives, and arrays.
|
|
8
|
+
*/
|
|
9
|
+
function isRecord$3(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Non-null object guard: includes arrays for behavior parity with legacy helpers.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: This intentionally narrows to UnknownRecord for compatibility with existing
|
|
16
|
+
* callsites even though arrays pass at runtime. Follow-up cleanup should replace
|
|
17
|
+
* this API with a more precise predicate type and migrate consumers.
|
|
18
|
+
*/
|
|
19
|
+
function isObjectRecord(value) {
|
|
20
|
+
return typeof value === "object" && value !== null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Coerces unknown input into a strict record, returning an empty object fallback.
|
|
24
|
+
*/
|
|
25
|
+
function asRecord(value) {
|
|
26
|
+
return isRecord$3(value) ? value : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../blocks/src/system/manifest/richTextSchema.ts
|
|
31
|
+
/**
|
|
32
|
+
* Shared Rich Text Schema
|
|
33
|
+
*
|
|
34
|
+
* This module provides a single source of truth for TipTap rich text validation
|
|
35
|
+
* across both the block-form editor and the block rendering system.
|
|
36
|
+
*
|
|
37
|
+
* Rich-text fields use one canonical root shape:
|
|
38
|
+
* `{ type: 'doc', content: [...] }`.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* TipTap document node schema (recursive structure)
|
|
42
|
+
*
|
|
43
|
+
* Supports the full TipTap document structure including:
|
|
44
|
+
* - type: Node type (e.g., 'doc', 'paragraph', 'text')
|
|
45
|
+
* - content: Nested array of child nodes
|
|
46
|
+
* - text: Text content for text nodes
|
|
47
|
+
* - attrs: Node attributes (e.g., alignment, color)
|
|
48
|
+
* - marks: Text formatting marks (e.g., bold, italic)
|
|
49
|
+
*/
|
|
50
|
+
const TipTapMarkSchema = z.object({
|
|
51
|
+
type: z.string().min(1),
|
|
52
|
+
attrs: z.record(z.string(), z.unknown()).optional()
|
|
53
|
+
}).strict();
|
|
54
|
+
const TipTapNodeSchema = z.lazy(() => z.object({
|
|
55
|
+
type: z.string().min(1),
|
|
56
|
+
content: z.array(TipTapNodeSchema).optional(),
|
|
57
|
+
text: z.string().optional(),
|
|
58
|
+
attrs: z.record(z.string(), z.unknown()).optional(),
|
|
59
|
+
marks: z.array(TipTapMarkSchema).optional()
|
|
60
|
+
}).strict().superRefine((node, ctx) => {
|
|
61
|
+
if (node.type === "doc") {
|
|
62
|
+
ctx.addIssue({
|
|
63
|
+
code: "custom",
|
|
64
|
+
path: ["type"],
|
|
65
|
+
message: "Document nodes are only valid at the rich-text root"
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (node.type === "text") {
|
|
69
|
+
if (node.text === undefined || node.text.length === 0) {
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: "custom",
|
|
72
|
+
path: ["text"],
|
|
73
|
+
message: "Text nodes require a non-empty text value"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (node.content !== undefined) {
|
|
77
|
+
ctx.addIssue({
|
|
78
|
+
code: "custom",
|
|
79
|
+
path: ["content"],
|
|
80
|
+
message: "Text nodes cannot contain child nodes"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (node.text !== undefined) {
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
path: ["text"],
|
|
89
|
+
message: "Only text nodes can contain a text value"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}));
|
|
93
|
+
/**
|
|
94
|
+
* Canonical schema for a TipTap rich-text document root.
|
|
95
|
+
*
|
|
96
|
+
* Stricter than {@link TipTapNodeSchema}: `type` is literally `'doc'` and
|
|
97
|
+
* `content` is required (possibly empty). This is the shape every producer
|
|
98
|
+
* (dummy content, editor output, migrations) must emit, and the shape every
|
|
99
|
+
* non-legacy consumer can rely on.
|
|
100
|
+
*
|
|
101
|
+
*/
|
|
102
|
+
const RichTextDocSchema = z.object({
|
|
103
|
+
type: z.literal("doc"),
|
|
104
|
+
content: z.array(TipTapNodeSchema)
|
|
105
|
+
}).strict();
|
|
106
|
+
/**
|
|
107
|
+
* Type guard for {@link RichTextDoc}.
|
|
108
|
+
*
|
|
109
|
+
* Returns `true` only for the canonical unwrapped form. The shared structural
|
|
110
|
+
* contract lives in `@riverbankcms/core`; parity tests keep its dependency-free
|
|
111
|
+
* guard aligned with this detailed Zod validator.
|
|
112
|
+
*/
|
|
113
|
+
function isRichTextDoc(value) {
|
|
114
|
+
return RichTextDocSchema.safeParse(value).success;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a canonical {@link RichTextDoc} from plain-text paragraphs.
|
|
118
|
+
*
|
|
119
|
+
* Single source of truth for constructing a TipTap doc from strings.
|
|
120
|
+
* Used by:
|
|
121
|
+
* - `@riverbankcms/dummy-content` (fixture body generators)
|
|
122
|
+
* - `@riverbankcms/content-model` (default template `cta-full` content)
|
|
123
|
+
*
|
|
124
|
+
* Empty strings produce a paragraph with no `content` array — matching
|
|
125
|
+
* the TipTap convention for empty paragraphs. The shape is exactly what
|
|
126
|
+
* {@link RichTextDocSchema} validates, so the type and runtime check
|
|
127
|
+
* never drift from the constructor.
|
|
128
|
+
*/
|
|
129
|
+
function createRichTextDoc(paragraphs) {
|
|
130
|
+
return {
|
|
131
|
+
type: "doc",
|
|
132
|
+
content: paragraphs.map((text) => text ? {
|
|
133
|
+
type: "paragraph",
|
|
134
|
+
content: [{
|
|
135
|
+
type: "text",
|
|
136
|
+
text
|
|
137
|
+
}]
|
|
138
|
+
} : { type: "paragraph" })
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function createHardBreakNode() {
|
|
142
|
+
return { type: "hardBreak" };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse a small markdown subset into TipTap inline nodes.
|
|
146
|
+
*
|
|
147
|
+
* This intentionally supports only bold (`**text**`) because the SDK/content
|
|
148
|
+
* import paths use it for lightweight default content, not full markdown.
|
|
149
|
+
*/
|
|
150
|
+
function parseRichTextInlineMarkdown(text) {
|
|
151
|
+
const out = [];
|
|
152
|
+
const pushText = (value, marks) => {
|
|
153
|
+
if (!value) return;
|
|
154
|
+
out.push(marks && marks.length > 0 ? {
|
|
155
|
+
type: "text",
|
|
156
|
+
text: value,
|
|
157
|
+
marks
|
|
158
|
+
} : {
|
|
159
|
+
type: "text",
|
|
160
|
+
text: value
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
let rest = text;
|
|
164
|
+
while (rest.length > 0) {
|
|
165
|
+
const start = rest.indexOf("**");
|
|
166
|
+
if (start === -1) {
|
|
167
|
+
pushText(rest);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
const end = rest.indexOf("**", start + 2);
|
|
171
|
+
if (end === -1) {
|
|
172
|
+
pushText(rest);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
pushText(rest.slice(0, start));
|
|
176
|
+
pushText(rest.slice(start + 2, end), [{ type: "bold" }]);
|
|
177
|
+
rest = rest.slice(end + 2);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
function parseRichTextParagraphInlineMarkdown(text) {
|
|
182
|
+
return text.split("\n").flatMap((line, index) => {
|
|
183
|
+
const lineNodes = parseRichTextInlineMarkdown(line.trim());
|
|
184
|
+
return index === 0 ? lineNodes : [createHardBreakNode(), ...lineNodes];
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Convert a plain string with a tiny markdown subset to canonical TipTap JSON.
|
|
189
|
+
*
|
|
190
|
+
* Double newlines create semantic paragraphs. Single newlines remain inside the
|
|
191
|
+
* current paragraph as TipTap `hardBreak` nodes for line-oriented content such
|
|
192
|
+
* as addresses, opening hours, and contact details.
|
|
193
|
+
*/
|
|
194
|
+
function createRichTextDocFromMarkdown(markdown) {
|
|
195
|
+
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
|
196
|
+
if (!normalized) return {
|
|
197
|
+
type: "doc",
|
|
198
|
+
content: []
|
|
199
|
+
};
|
|
200
|
+
const paragraphs = normalized.split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean);
|
|
201
|
+
return {
|
|
202
|
+
type: "doc",
|
|
203
|
+
content: paragraphs.map((paragraph) => {
|
|
204
|
+
const inline = parseRichTextParagraphInlineMarkdown(paragraph);
|
|
205
|
+
return inline.length > 0 ? {
|
|
206
|
+
type: "paragraph",
|
|
207
|
+
content: inline
|
|
208
|
+
} : { type: "paragraph" };
|
|
209
|
+
})
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function buildRichTextSchema(options) {
|
|
213
|
+
let schema = RichTextDocSchema;
|
|
214
|
+
if (options?.required) {
|
|
215
|
+
const label = options.label ?? "This field";
|
|
216
|
+
schema = schema.refine((val) => {
|
|
217
|
+
const content = val.content;
|
|
218
|
+
if (!Array.isArray(content) || content.length === 0) return false;
|
|
219
|
+
const first = content[0];
|
|
220
|
+
if (content.length === 1 && isRecord$3(first) && first.type === "paragraph" && (!("content" in first) || first.content == null)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}, { message: `${label} is required` });
|
|
225
|
+
}
|
|
226
|
+
return schema;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region ../content-model/src/title-resolution.ts
|
|
231
|
+
/**
|
|
232
|
+
* Content title resolution utilities.
|
|
233
|
+
*
|
|
234
|
+
* Provides a single source of truth for resolving display titles from content data.
|
|
235
|
+
*/
|
|
236
|
+
/**
|
|
237
|
+
* Default fields to check when titleField is not configured.
|
|
238
|
+
* Order matters - first match wins.
|
|
239
|
+
*/
|
|
240
|
+
const TITLE_FIELD_CANDIDATES = ["title", "name"];
|
|
241
|
+
/**
|
|
242
|
+
* Resolve title from content data using configured titleField with fallbacks.
|
|
243
|
+
*
|
|
244
|
+
* Resolution order:
|
|
245
|
+
* 1. Explicit titleField (if configured)
|
|
246
|
+
* 2. 'title' field
|
|
247
|
+
* 3. 'name' field
|
|
248
|
+
* 4. Fallback title
|
|
249
|
+
*
|
|
250
|
+
* @param content - Content data object
|
|
251
|
+
* @param titleField - Configured title field ID (optional)
|
|
252
|
+
* @param fallbackTitle - Fallback if no title found
|
|
253
|
+
* @returns Resolved title string
|
|
254
|
+
*/
|
|
255
|
+
function resolveContentTitle(content, titleField, fallbackTitle) {
|
|
256
|
+
if (!content) return fallbackTitle;
|
|
257
|
+
const candidates = [titleField?.trim(), ...TITLE_FIELD_CANDIDATES].filter((f) => Boolean(f?.trim()));
|
|
258
|
+
for (const field of candidates) {
|
|
259
|
+
const value = content[field];
|
|
260
|
+
if (typeof value === "string") {
|
|
261
|
+
const trimmed = value.trim();
|
|
262
|
+
if (trimmed) return trimmed;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return fallbackTitle;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region ../content-model/src/metadataConfig.ts
|
|
270
|
+
const ROUTE_METADATA_MANIFEST = {
|
|
271
|
+
name: "content-entry-metadata",
|
|
272
|
+
version: "1.0.0",
|
|
273
|
+
title: "Details",
|
|
274
|
+
description: "",
|
|
275
|
+
component: "ContentEntryMetadata",
|
|
276
|
+
fields: [
|
|
277
|
+
{
|
|
278
|
+
id: "draftTitle",
|
|
279
|
+
type: "text",
|
|
280
|
+
label: "Title",
|
|
281
|
+
required: true,
|
|
282
|
+
multiline: false
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "draftSlug",
|
|
286
|
+
type: "slug",
|
|
287
|
+
label: "Slug",
|
|
288
|
+
description: "Used in the URL.",
|
|
289
|
+
required: true,
|
|
290
|
+
sourceFieldId: "draftTitle",
|
|
291
|
+
statusFieldId: "draftStatus",
|
|
292
|
+
maxLength: 96,
|
|
293
|
+
ui: { showSlugPreview: true }
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "draftStatus",
|
|
297
|
+
type: "select",
|
|
298
|
+
label: "Draft status",
|
|
299
|
+
description: "Schedule to publish automatically, or keep as draft.",
|
|
300
|
+
required: true,
|
|
301
|
+
defaultValue: "draft",
|
|
302
|
+
options: [
|
|
303
|
+
{
|
|
304
|
+
value: "draft",
|
|
305
|
+
label: "Draft"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
value: "scheduled",
|
|
309
|
+
label: "Scheduled"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
value: "published",
|
|
313
|
+
label: "Published"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
value: "archived",
|
|
317
|
+
label: "Archived"
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
multiple: false
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "scheduledPublishAt",
|
|
324
|
+
type: "datetime",
|
|
325
|
+
label: "Publish at",
|
|
326
|
+
description: "When scheduled, the entry will publish at this time.",
|
|
327
|
+
required: false,
|
|
328
|
+
ui: { visibleWhen: {
|
|
329
|
+
field: "draftStatus",
|
|
330
|
+
equals: "scheduled"
|
|
331
|
+
} }
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: "draftMetaTitle",
|
|
335
|
+
type: "text",
|
|
336
|
+
label: "Meta title",
|
|
337
|
+
description: "Used for SEO and social previews.",
|
|
338
|
+
multiline: true,
|
|
339
|
+
required: false,
|
|
340
|
+
ui: { showCharCount: { max: 60 } }
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
id: "draftMetaDescription",
|
|
344
|
+
type: "text",
|
|
345
|
+
label: "Meta description",
|
|
346
|
+
description: "Optional description for SEO and previews.",
|
|
347
|
+
required: false,
|
|
348
|
+
multiline: true,
|
|
349
|
+
ui: { showCharCount: { max: 160 } }
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: "ogImageId",
|
|
353
|
+
type: "text",
|
|
354
|
+
label: "Social image asset ID",
|
|
355
|
+
description: "Optional media asset ID used for social sharing.",
|
|
356
|
+
required: false,
|
|
357
|
+
multiline: false
|
|
358
|
+
}
|
|
359
|
+
],
|
|
360
|
+
slots: []
|
|
361
|
+
};
|
|
362
|
+
const METADATA_WORKFLOW_FIELDS = {
|
|
363
|
+
title: "draftTitle",
|
|
364
|
+
slug: "draftSlug",
|
|
365
|
+
status: "draftStatus",
|
|
366
|
+
publishAt: "scheduledPublishAt"
|
|
367
|
+
};
|
|
368
|
+
const HOMEPAGE_PAGE_ROUTE_SLUG = { kind: "homepage" };
|
|
369
|
+
function asNonEmptyPageRouteSlug(value) {
|
|
370
|
+
return value;
|
|
371
|
+
}
|
|
372
|
+
function pageRouteSlugFromEditorValue(value) {
|
|
373
|
+
const slug = sanitizeSlug(value ?? "");
|
|
374
|
+
return slug.length > 0 ? {
|
|
375
|
+
kind: "slug",
|
|
376
|
+
value: asNonEmptyPageRouteSlug(slug)
|
|
377
|
+
} : HOMEPAGE_PAGE_ROUTE_SLUG;
|
|
378
|
+
}
|
|
379
|
+
function pageRouteSlugToEditorValue(slug) {
|
|
380
|
+
return slug.kind === "homepage" ? "" : slug.value;
|
|
381
|
+
}
|
|
382
|
+
function pageRouteSlugToStoredValue(slug) {
|
|
383
|
+
return slug.kind === "homepage" ? null : slug.value;
|
|
384
|
+
}
|
|
385
|
+
function isHomepagePageRouteSlug(slug) {
|
|
386
|
+
return slug.kind === "homepage";
|
|
387
|
+
}
|
|
388
|
+
function resolvePageRouteSlug(entry, currentSlug) {
|
|
389
|
+
if (entry.kind !== "routable") return null;
|
|
390
|
+
if (entry.draft.path === "/" || entry.published.path === "/") {
|
|
391
|
+
return HOMEPAGE_PAGE_ROUTE_SLUG;
|
|
392
|
+
}
|
|
393
|
+
if (currentSlug !== undefined) {
|
|
394
|
+
return pageRouteSlugFromEditorValue(currentSlug);
|
|
395
|
+
}
|
|
396
|
+
return pageRouteSlugFromEditorValue(entry.draft.slug);
|
|
397
|
+
}
|
|
398
|
+
const ROUTE_METADATA_MANAGED_FIELDS_BY_TYPE = {
|
|
399
|
+
event: [
|
|
400
|
+
"draftTitle",
|
|
401
|
+
"draftSlug",
|
|
402
|
+
"draftStatus",
|
|
403
|
+
"scheduledPublishAt"
|
|
404
|
+
],
|
|
405
|
+
course: [
|
|
406
|
+
"draftTitle",
|
|
407
|
+
"draftSlug",
|
|
408
|
+
"draftStatus",
|
|
409
|
+
"scheduledPublishAt"
|
|
410
|
+
],
|
|
411
|
+
product: [
|
|
412
|
+
"draftTitle",
|
|
413
|
+
"draftSlug",
|
|
414
|
+
"draftStatus",
|
|
415
|
+
"scheduledPublishAt"
|
|
416
|
+
],
|
|
417
|
+
"product-category": [
|
|
418
|
+
"draftTitle",
|
|
419
|
+
"draftSlug",
|
|
420
|
+
"draftStatus",
|
|
421
|
+
"scheduledPublishAt"
|
|
422
|
+
],
|
|
423
|
+
"team-member": [
|
|
424
|
+
"draftTitle",
|
|
425
|
+
"draftSlug",
|
|
426
|
+
"draftStatus",
|
|
427
|
+
"scheduledPublishAt"
|
|
428
|
+
]
|
|
429
|
+
};
|
|
430
|
+
const EMPTY_METADATA_FIELDS = [];
|
|
431
|
+
function normalizeContentTypeKey(typeKey) {
|
|
432
|
+
if (typeof typeKey !== "string") return null;
|
|
433
|
+
const trimmed = typeKey.trim().toLowerCase();
|
|
434
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
435
|
+
}
|
|
436
|
+
function getManagedRouteMetadataFieldIdsForType(typeKey) {
|
|
437
|
+
const normalizedTypeKey = normalizeContentTypeKey(typeKey);
|
|
438
|
+
if (!normalizedTypeKey) return EMPTY_METADATA_FIELDS;
|
|
439
|
+
return ROUTE_METADATA_MANAGED_FIELDS_BY_TYPE[normalizedTypeKey] ?? EMPTY_METADATA_FIELDS;
|
|
440
|
+
}
|
|
441
|
+
function isRouteMetadataFieldEditableForType(typeKey, fieldId) {
|
|
442
|
+
const managedFields = getManagedRouteMetadataFieldIdsForType(typeKey);
|
|
443
|
+
return !managedFields.includes(fieldId);
|
|
444
|
+
}
|
|
445
|
+
function getEditableRouteMetadataFieldIdsForType(typeKey) {
|
|
446
|
+
return ROUTE_METADATA_MANIFEST.fields.map((field) => String(field.id)).filter((fieldId) => isRouteMetadataFieldEditableForType(typeKey, fieldId));
|
|
447
|
+
}
|
|
448
|
+
function getRouteMetadataManifestForType(typeKey) {
|
|
449
|
+
return {
|
|
450
|
+
...ROUTE_METADATA_MANIFEST,
|
|
451
|
+
fields: ROUTE_METADATA_MANIFEST.fields.filter((field) => isRouteMetadataFieldEditableForType(typeKey, String(field.id)))
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function getRouteMetadataManifestForContext(context) {
|
|
455
|
+
if (context.kind === "content") {
|
|
456
|
+
return getRouteMetadataManifestForType(context.typeKey);
|
|
457
|
+
}
|
|
458
|
+
const fields = isHomepagePageRouteSlug(context.routeSlug) ? ROUTE_METADATA_MANIFEST.fields.filter((field) => String(field.id) !== "draftSlug") : ROUTE_METADATA_MANIFEST.fields;
|
|
459
|
+
return {
|
|
460
|
+
...ROUTE_METADATA_MANIFEST,
|
|
461
|
+
fields
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function deriveRouteMetadataPresentation(context, options = {}) {
|
|
465
|
+
const contextManifest = getRouteMetadataManifestForContext(context);
|
|
466
|
+
const manifest = options.omitTitle ? omitRouteTitleFieldFromManifest(contextManifest) : contextManifest;
|
|
467
|
+
if (context.kind === "content") {
|
|
468
|
+
return {
|
|
469
|
+
kind: "content",
|
|
470
|
+
manifest,
|
|
471
|
+
typeKey: normalizeContentTypeKey(context.typeKey)
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (context.routeSlug.kind === "homepage") {
|
|
475
|
+
return {
|
|
476
|
+
kind: "page-homepage",
|
|
477
|
+
manifest,
|
|
478
|
+
routeSlug: context.routeSlug
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
kind: "page-routable",
|
|
483
|
+
manifest,
|
|
484
|
+
routeSlug: context.routeSlug
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Remove the legacy route title editor from a route metadata manifest.
|
|
489
|
+
*
|
|
490
|
+
* Entry titles are now derived from entry data (single source of truth) and the route title is a denormalized copy.
|
|
491
|
+
* We still keep `draftTitle` in the underlying manifest for page creation flows, but we omit it from entry editors.
|
|
492
|
+
*/
|
|
493
|
+
function omitRouteTitleFieldFromManifest(manifest) {
|
|
494
|
+
const fields = (manifest.fields ?? []).filter((field) => String(field.id) !== "draftTitle").map((field) => {
|
|
495
|
+
if (field && typeof field === "object" && "sourceFieldId" in field) {
|
|
496
|
+
const sourceFieldId = field.sourceFieldId;
|
|
497
|
+
if (String(sourceFieldId) === "draftTitle") {
|
|
498
|
+
return {
|
|
499
|
+
...field,
|
|
500
|
+
sourceFieldId: undefined
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return field;
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
...manifest,
|
|
508
|
+
fields
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function sanitizeSlug(value) {
|
|
512
|
+
if (!value) return "";
|
|
513
|
+
return value.split("/").map((segment) => normalizeSlugSegment(segment)).filter(Boolean).join("/");
|
|
514
|
+
}
|
|
515
|
+
function normalizeSlugSegment(segment) {
|
|
516
|
+
const normalized = segment.trim().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
517
|
+
return normalized;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region ../content-model/src/referenceFields.ts
|
|
522
|
+
const BUILT_IN_REFERENCE_KINDS = [
|
|
523
|
+
"post",
|
|
524
|
+
"form",
|
|
525
|
+
"bookingForm",
|
|
526
|
+
"appointmentService",
|
|
527
|
+
"appointmentResource",
|
|
528
|
+
"page",
|
|
529
|
+
"newsletterList",
|
|
530
|
+
"mailchimpAudience"
|
|
531
|
+
];
|
|
532
|
+
const builtInReferenceKindSet = new Set(BUILT_IN_REFERENCE_KINDS);
|
|
533
|
+
function isBuiltInReferenceKind(referenceKind) {
|
|
534
|
+
return builtInReferenceKindSet.has(referenceKind);
|
|
535
|
+
}
|
|
536
|
+
function isDynamicContentReferenceKind(referenceKind) {
|
|
537
|
+
return parseReferenceKind(referenceKind).kind === "dynamicContent";
|
|
538
|
+
}
|
|
539
|
+
function isAdapterReferenceKind(referenceKind) {
|
|
540
|
+
const parsed = parseReferenceKind(referenceKind);
|
|
541
|
+
return parsed.kind === "builtIn" || parsed.kind === "dynamicContent";
|
|
542
|
+
}
|
|
543
|
+
function parseReferenceContentTypeKey(contentTypeKey) {
|
|
544
|
+
const trimmed = contentTypeKey.trim();
|
|
545
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
546
|
+
}
|
|
547
|
+
function parseReferenceKind(referenceKind) {
|
|
548
|
+
if (isBuiltInReferenceKind(referenceKind)) {
|
|
549
|
+
return {
|
|
550
|
+
kind: "builtIn",
|
|
551
|
+
referenceKind
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (referenceKind.startsWith("content:")) {
|
|
555
|
+
const contentTypeKey = parseReferenceContentTypeKey(referenceKind.slice("content:".length));
|
|
556
|
+
if (contentTypeKey) {
|
|
557
|
+
return {
|
|
558
|
+
kind: "dynamicContent",
|
|
559
|
+
referenceKind: `content:${contentTypeKey}`,
|
|
560
|
+
contentTypeKey
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
kind: "unsupported",
|
|
566
|
+
referenceKind
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region ../content-model/src/contentManifest.ts
|
|
572
|
+
const CONTENT_MODEL_ICON_PICKER_PRESETS = ["cta", "feature"];
|
|
573
|
+
function assertNever$1(value) {
|
|
574
|
+
throw new Error(`Unhandled content field mapping decision: ${JSON.stringify(value)}`);
|
|
575
|
+
}
|
|
576
|
+
function resolveContentReferenceKind(field) {
|
|
577
|
+
return field.referenceKind ?? field.reference?.type ?? null;
|
|
578
|
+
}
|
|
579
|
+
function resolveReferenceFieldMapping(referenceKind, context = {}, valueFrom = "identifier") {
|
|
580
|
+
if (referenceKind === null) {
|
|
581
|
+
return { kind: "missingReferenceKind" };
|
|
582
|
+
}
|
|
583
|
+
if (isAdapterReferenceKind(referenceKind)) {
|
|
584
|
+
return {
|
|
585
|
+
kind: "adapterReference",
|
|
586
|
+
referenceKind
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (context.referenceContentTypeKeys && !context.referenceContentTypeKeys.includes(referenceKind)) {
|
|
590
|
+
return {
|
|
591
|
+
kind: "unresolvedReference",
|
|
592
|
+
referenceKind
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
kind: "contentEntryReference",
|
|
597
|
+
contentType: referenceKind,
|
|
598
|
+
valueFrom
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const DEFAULT_BUTTON_VARIANTS = [
|
|
602
|
+
{
|
|
603
|
+
value: "primary",
|
|
604
|
+
label: "Primary"
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
value: "secondary",
|
|
608
|
+
label: "Secondary"
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
value: "outline",
|
|
612
|
+
label: "Outline"
|
|
613
|
+
}
|
|
614
|
+
];
|
|
615
|
+
const DISPLAY_MEDIA_KINDS$1 = [
|
|
616
|
+
"image",
|
|
617
|
+
"video",
|
|
618
|
+
"audio"
|
|
619
|
+
];
|
|
620
|
+
function toFieldDefinition(field, context = {}) {
|
|
621
|
+
const label = field.label ?? startCase(field.id);
|
|
622
|
+
const base = {
|
|
623
|
+
id: field.id,
|
|
624
|
+
label,
|
|
625
|
+
description: field.description ?? undefined,
|
|
626
|
+
required: field.required ?? false,
|
|
627
|
+
visibleRoles: undefined,
|
|
628
|
+
defaultValue: field.defaultValue,
|
|
629
|
+
ui: normalizeFieldUi(field.ui)
|
|
630
|
+
};
|
|
631
|
+
switch (field.type) {
|
|
632
|
+
case "text": return {
|
|
633
|
+
...base,
|
|
634
|
+
type: "text",
|
|
635
|
+
multiline: Boolean(field.multiline),
|
|
636
|
+
maxLength: field.maxLength
|
|
637
|
+
};
|
|
638
|
+
case "textarea": return {
|
|
639
|
+
...base,
|
|
640
|
+
type: "text",
|
|
641
|
+
multiline: true,
|
|
642
|
+
maxLength: field.maxLength
|
|
643
|
+
};
|
|
644
|
+
case "number": return {
|
|
645
|
+
...base,
|
|
646
|
+
type: "number",
|
|
647
|
+
min: field.min,
|
|
648
|
+
max: field.max,
|
|
649
|
+
step: field.step
|
|
650
|
+
};
|
|
651
|
+
case "richText": return {
|
|
652
|
+
...base,
|
|
653
|
+
type: "richText"
|
|
654
|
+
};
|
|
655
|
+
case "media": return {
|
|
656
|
+
...base,
|
|
657
|
+
type: "media",
|
|
658
|
+
mediaKinds: normalizeDisplayMediaKinds(field.mediaKinds)
|
|
659
|
+
};
|
|
660
|
+
case "file": return {
|
|
661
|
+
...base,
|
|
662
|
+
type: "file",
|
|
663
|
+
fileKinds: field.fileKinds && field.fileKinds.length ? field.fileKinds : [
|
|
664
|
+
"document",
|
|
665
|
+
"spreadsheet",
|
|
666
|
+
"archive",
|
|
667
|
+
"audio"
|
|
668
|
+
]
|
|
669
|
+
};
|
|
670
|
+
case "boolean": return {
|
|
671
|
+
...base,
|
|
672
|
+
type: "boolean"
|
|
673
|
+
};
|
|
674
|
+
case "date": return {
|
|
675
|
+
...base,
|
|
676
|
+
type: "date"
|
|
677
|
+
};
|
|
678
|
+
case "datetime": return {
|
|
679
|
+
...base,
|
|
680
|
+
type: "datetime"
|
|
681
|
+
};
|
|
682
|
+
case "url": return {
|
|
683
|
+
...base,
|
|
684
|
+
type: "url",
|
|
685
|
+
allowRelative: field.allowRelative ?? false
|
|
686
|
+
};
|
|
687
|
+
case "select": {
|
|
688
|
+
const options = field.options ?? [];
|
|
689
|
+
if (!options.length) return null;
|
|
690
|
+
return {
|
|
691
|
+
...base,
|
|
692
|
+
type: "select",
|
|
693
|
+
options: options.map((option) => ({
|
|
694
|
+
value: option.value,
|
|
695
|
+
label: option.label ?? option.value
|
|
696
|
+
})),
|
|
697
|
+
multiple: Boolean(field.multiple)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
case "reference": {
|
|
701
|
+
const referenceKind = resolveContentReferenceKind(field);
|
|
702
|
+
const mapping = resolveReferenceFieldMapping(referenceKind, context, field.valueFrom ?? "identifier");
|
|
703
|
+
switch (mapping.kind) {
|
|
704
|
+
case "adapterReference":
|
|
705
|
+
case "unresolvedReference": return {
|
|
706
|
+
...base,
|
|
707
|
+
type: "reference",
|
|
708
|
+
referenceKind: mapping.referenceKind,
|
|
709
|
+
allowManualEntry: false
|
|
710
|
+
};
|
|
711
|
+
case "missingReferenceKind": return null;
|
|
712
|
+
case "contentEntryReference": return {
|
|
713
|
+
...base,
|
|
714
|
+
type: "entryPicker",
|
|
715
|
+
fixedContentType: mapping.contentType,
|
|
716
|
+
valueFrom: mapping.valueFrom
|
|
717
|
+
};
|
|
718
|
+
default: return assertNever$1(mapping);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
case "link": return {
|
|
722
|
+
...base,
|
|
723
|
+
type: "link"
|
|
724
|
+
};
|
|
725
|
+
case "button": {
|
|
726
|
+
const buttonContext = { variants: context.buttonVariants };
|
|
727
|
+
return expandButtonFieldGroup(field.id, label, buttonContext);
|
|
728
|
+
}
|
|
729
|
+
case "group": {
|
|
730
|
+
const nestedFields$1 = (field.fields ?? field.schema?.fields ?? []).map((f) => toFieldDefinition(f, context)).filter((f) => f !== null);
|
|
731
|
+
if (!nestedFields$1.length) return null;
|
|
732
|
+
return {
|
|
733
|
+
id: field.id,
|
|
734
|
+
label,
|
|
735
|
+
description: field.description ?? undefined,
|
|
736
|
+
required: field.required ?? false,
|
|
737
|
+
type: "group",
|
|
738
|
+
ui: normalizeFieldUi(field.ui),
|
|
739
|
+
schema: { fields: nestedFields$1 }
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
case "repeater": {
|
|
743
|
+
const nestedFields$1 = (field.fields ?? field.schema?.fields ?? []).map((f) => toFieldDefinition(f, context)).filter((f) => f !== null);
|
|
744
|
+
if (!nestedFields$1.length) return null;
|
|
745
|
+
return {
|
|
746
|
+
id: field.id,
|
|
747
|
+
label,
|
|
748
|
+
description: field.description ?? undefined,
|
|
749
|
+
required: field.required ?? false,
|
|
750
|
+
type: "repeater",
|
|
751
|
+
ui: normalizeFieldUi(field.ui),
|
|
752
|
+
itemLabel: field.itemLabel ?? "Item",
|
|
753
|
+
schema: { fields: nestedFields$1 }
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
default: return null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function normalizeDisplayMediaKinds(mediaKinds) {
|
|
760
|
+
const displayKinds = (mediaKinds ?? []).filter(isDisplayMediaKind$1);
|
|
761
|
+
return displayKinds.length > 0 ? displayKinds : ["image"];
|
|
762
|
+
}
|
|
763
|
+
function isDisplayMediaKind$1(value) {
|
|
764
|
+
return DISPLAY_MEDIA_KINDS$1.includes(value);
|
|
765
|
+
}
|
|
766
|
+
function startCase(value) {
|
|
767
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ").replace(/\s+/g, " ").replace(/^./, (match) => match.toUpperCase());
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Maps a content type schema to form field definitions.
|
|
771
|
+
*
|
|
772
|
+
* @param schema - The content type schema
|
|
773
|
+
* @param context - Optional context with theme variants for button fields
|
|
774
|
+
* @returns Array of field definitions for form rendering
|
|
775
|
+
*/
|
|
776
|
+
function mapContentSchemaToFormFields(schema, context = {}) {
|
|
777
|
+
const fields = [];
|
|
778
|
+
for (const field of schema.fields ?? []) {
|
|
779
|
+
const definition = toFieldDefinition(field, context);
|
|
780
|
+
if (definition) {
|
|
781
|
+
fields.push(definition);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return fields;
|
|
785
|
+
}
|
|
786
|
+
function expandButtonFieldGroup(fieldId, fieldLabel, context = {}) {
|
|
787
|
+
const variants = context.variants ?? [...DEFAULT_BUTTON_VARIANTS];
|
|
788
|
+
const mainFields = [
|
|
789
|
+
{
|
|
790
|
+
id: "label",
|
|
791
|
+
type: "text",
|
|
792
|
+
label: "Label",
|
|
793
|
+
required: true,
|
|
794
|
+
multiline: false,
|
|
795
|
+
ui: { row: "primary" }
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
id: "variant",
|
|
799
|
+
type: "select",
|
|
800
|
+
label: "Style",
|
|
801
|
+
options: variants.map((variant) => ({ ...variant })),
|
|
802
|
+
required: false,
|
|
803
|
+
multiple: false,
|
|
804
|
+
ui: { row: "primary" }
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
id: "link",
|
|
808
|
+
type: "link",
|
|
809
|
+
label: "Link",
|
|
810
|
+
required: true,
|
|
811
|
+
ui: { colSpan: 2 }
|
|
812
|
+
}
|
|
813
|
+
];
|
|
814
|
+
return {
|
|
815
|
+
id: fieldId,
|
|
816
|
+
type: "group",
|
|
817
|
+
label: fieldLabel,
|
|
818
|
+
required: false,
|
|
819
|
+
ui: {
|
|
820
|
+
layout: "grid",
|
|
821
|
+
columns: 2
|
|
822
|
+
},
|
|
823
|
+
schema: { fields: [...mainFields, {
|
|
824
|
+
id: "icons",
|
|
825
|
+
type: "group",
|
|
826
|
+
label: "Icons",
|
|
827
|
+
required: false,
|
|
828
|
+
ui: {
|
|
829
|
+
preset: "disclosure",
|
|
830
|
+
colSpan: 2
|
|
831
|
+
},
|
|
832
|
+
schema: { fields: [{
|
|
833
|
+
id: "iconLeft",
|
|
834
|
+
type: "text",
|
|
835
|
+
label: "Left icon",
|
|
836
|
+
required: false,
|
|
837
|
+
multiline: false,
|
|
838
|
+
ui: {
|
|
839
|
+
widget: "iconPicker",
|
|
840
|
+
iconPickerPreset: "cta"
|
|
841
|
+
}
|
|
842
|
+
}, {
|
|
843
|
+
id: "iconRight",
|
|
844
|
+
type: "text",
|
|
845
|
+
label: "Right icon",
|
|
846
|
+
required: false,
|
|
847
|
+
multiline: false,
|
|
848
|
+
ui: {
|
|
849
|
+
widget: "iconPicker",
|
|
850
|
+
iconPickerPreset: "cta"
|
|
851
|
+
}
|
|
852
|
+
}] }
|
|
853
|
+
}] }
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function normalizeFieldUi(value) {
|
|
857
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
return value;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region ../content-model/src/routeTemplate.ts
|
|
865
|
+
const ROUTE_TEMPLATE_SLUG_SEGMENT = "{slug}";
|
|
866
|
+
var InvalidRouteTemplateError = class extends Error {
|
|
867
|
+
constructor(error) {
|
|
868
|
+
super(describeRouteTemplateParseError(error));
|
|
869
|
+
this.name = "InvalidRouteTemplateError";
|
|
870
|
+
this.error = error;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
function safeParseRouteTemplate(routePattern) {
|
|
874
|
+
if (routePattern === null || routePattern === "") {
|
|
875
|
+
return {
|
|
876
|
+
success: true,
|
|
877
|
+
routeTemplate: { kind: "fallback-slug" }
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const segments = splitRouteSegments(routePattern);
|
|
881
|
+
const slugSegmentIndices = segments.flatMap((segment, index) => segment === ROUTE_TEMPLATE_SLUG_SEGMENT ? [index] : []);
|
|
882
|
+
if (slugSegmentIndices.length > 1) {
|
|
883
|
+
return {
|
|
884
|
+
success: false,
|
|
885
|
+
error: {
|
|
886
|
+
code: "multiple-slug-segments",
|
|
887
|
+
routePattern,
|
|
888
|
+
segment: ROUTE_TEMPLATE_SLUG_SEGMENT
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const invalidSegment = segments.find((segment) => segment !== ROUTE_TEMPLATE_SLUG_SEGMENT && isUnsupportedDynamicSegment(segment));
|
|
893
|
+
if (invalidSegment) {
|
|
894
|
+
return {
|
|
895
|
+
success: false,
|
|
896
|
+
error: {
|
|
897
|
+
code: invalidSegment.startsWith(":") ? "legacy-dynamic-segment" : "unsupported-dynamic-segment",
|
|
898
|
+
routePattern,
|
|
899
|
+
segment: invalidSegment
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (slugSegmentIndices.length === 1) {
|
|
904
|
+
const [prefix = "", suffix = ""] = routePattern.split(ROUTE_TEMPLATE_SLUG_SEGMENT);
|
|
905
|
+
const slugIndex = slugSegmentIndices[0] ?? 0;
|
|
906
|
+
return {
|
|
907
|
+
success: true,
|
|
908
|
+
routeTemplate: {
|
|
909
|
+
kind: "slug-template",
|
|
910
|
+
routePattern,
|
|
911
|
+
prefix,
|
|
912
|
+
suffix,
|
|
913
|
+
prefixSegments: segments.slice(0, slugIndex),
|
|
914
|
+
suffixSegments: segments.slice(slugIndex + 1)
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
success: true,
|
|
920
|
+
routeTemplate: {
|
|
921
|
+
kind: "fixed-path",
|
|
922
|
+
routePattern,
|
|
923
|
+
path: routePattern
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function parseRouteTemplate(routePattern) {
|
|
928
|
+
const result = safeParseRouteTemplate(routePattern);
|
|
929
|
+
if (result.success) {
|
|
930
|
+
return result.routeTemplate;
|
|
931
|
+
}
|
|
932
|
+
throw new InvalidRouteTemplateError(result.error);
|
|
933
|
+
}
|
|
934
|
+
function isSlugTemplate(routeTemplate) {
|
|
935
|
+
return routeTemplate.kind === "slug-template";
|
|
936
|
+
}
|
|
937
|
+
function fixedPathOrNull(routeTemplate) {
|
|
938
|
+
return routeTemplate.kind === "fixed-path" ? routeTemplate.path : null;
|
|
939
|
+
}
|
|
940
|
+
/** Returns the first path segment, or undefined when the template has no fixed prefix segment. */
|
|
941
|
+
function getRouteTemplatePrefix(routeTemplate) {
|
|
942
|
+
switch (routeTemplate.kind) {
|
|
943
|
+
case "fallback-slug": return undefined;
|
|
944
|
+
case "fixed-path": {
|
|
945
|
+
const fixedSegments = routeTemplate.path.split("/").filter((segment) => segment.length > 0);
|
|
946
|
+
return fixedSegments[0];
|
|
947
|
+
}
|
|
948
|
+
case "slug-template": return routeTemplate.prefixSegments[0];
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function expandSlug(routeTemplate, slug) {
|
|
952
|
+
switch (routeTemplate.kind) {
|
|
953
|
+
case "fallback-slug": return `/${slug}`;
|
|
954
|
+
case "fixed-path": return routeTemplate.path;
|
|
955
|
+
case "slug-template": return `${routeTemplate.prefix}${slug}${routeTemplate.suffix}`;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function buildContentEntryRoutePath(input) {
|
|
959
|
+
return expandSlug(parseRouteTemplate(input.routePattern), input.slug);
|
|
960
|
+
}
|
|
961
|
+
function describeRouteTemplateParseError(error) {
|
|
962
|
+
switch (error.code) {
|
|
963
|
+
case "legacy-dynamic-segment": return `Route pattern "${error.routePattern}" uses unsupported dynamic segment "${error.segment ?? ":slug"}". Use "${ROUTE_TEMPLATE_SLUG_SEGMENT}" as a full path segment.`;
|
|
964
|
+
case "multiple-slug-segments": return `Route pattern "${error.routePattern}" must not contain "${ROUTE_TEMPLATE_SLUG_SEGMENT}" more than once.`;
|
|
965
|
+
case "unsupported-dynamic-segment": return `Route pattern "${error.routePattern}" contains unsupported dynamic segment "${error.segment ?? ""}". Route patterns may only use "${ROUTE_TEMPLATE_SLUG_SEGMENT}" as a full path segment.`;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function splitRouteSegments(routePattern) {
|
|
969
|
+
return routePattern.split("/").filter((segment) => segment.length > 0);
|
|
970
|
+
}
|
|
971
|
+
function isUnsupportedDynamicSegment(segment) {
|
|
972
|
+
return segment.startsWith(":") || segment.includes("{") || segment.includes("}");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region ../content-model/src/definitions/canonicalizePageRouteMetadata.ts
|
|
977
|
+
function normalizePathInput(value) {
|
|
978
|
+
if (typeof value !== "string") return null;
|
|
979
|
+
const trimmed = value.trim();
|
|
980
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
981
|
+
}
|
|
982
|
+
function canonicalizePageRoutePair(input, routing, options) {
|
|
983
|
+
const normalizedPath = normalizePathInput(input.path);
|
|
984
|
+
if (normalizedPath !== null) {
|
|
985
|
+
const slug$1 = routing.toSlug(normalizedPath);
|
|
986
|
+
return {
|
|
987
|
+
path: routing.buildRoutePath(slug$1),
|
|
988
|
+
slug: slug$1
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const trimmedInputSlug = typeof input.slug === "string" ? input.slug.trim() : "";
|
|
992
|
+
const trimmedFallbackSlug = typeof options.fallbackSlug === "string" ? options.fallbackSlug.trim() : "";
|
|
993
|
+
const rawSlug = trimmedInputSlug.length > 0 ? trimmedInputSlug : trimmedFallbackSlug;
|
|
994
|
+
const slug = routing.sanitize(rawSlug);
|
|
995
|
+
if (slug.length > 0) {
|
|
996
|
+
return {
|
|
997
|
+
path: routing.buildRoutePath(slug),
|
|
998
|
+
slug
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (options.allowMissing) {
|
|
1002
|
+
return {
|
|
1003
|
+
path: null,
|
|
1004
|
+
slug: null
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
path: routing.buildRoutePath(""),
|
|
1009
|
+
slug: ""
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Normalize routable page metadata into one editor/store-facing representation.
|
|
1014
|
+
*
|
|
1015
|
+
* Contract:
|
|
1016
|
+
* - Prefer explicit paths when present, deriving slugs from those paths so `/`, `''`, and persisted `NULL` homepage
|
|
1017
|
+
* variants collapse to the same draft/published shape.
|
|
1018
|
+
* - Preserve a missing published route as `{ path: null, slug: null }` so callers can distinguish "never published"
|
|
1019
|
+
* from "published homepage".
|
|
1020
|
+
* - Always return a concrete draft route for editor state, using `draftFallbackSlug` when both draft path and slug
|
|
1021
|
+
* are absent.
|
|
1022
|
+
*
|
|
1023
|
+
* This helper intentionally returns homepage slugs as empty strings for editor/store use. DB-facing callers that store
|
|
1024
|
+
* homepage slugs as `NULL` should map the output through `toStoredPageRouteSlug()`.
|
|
1025
|
+
*/
|
|
1026
|
+
function canonicalizePageRouteMetadata(input, routing, options = {}) {
|
|
1027
|
+
const published = canonicalizePageRoutePair({
|
|
1028
|
+
path: input.publishedPath,
|
|
1029
|
+
slug: input.publishedSlug
|
|
1030
|
+
}, routing, { allowMissing: true });
|
|
1031
|
+
const draft = canonicalizePageRoutePair({
|
|
1032
|
+
path: input.draftPath,
|
|
1033
|
+
slug: input.draftSlug ?? published.slug
|
|
1034
|
+
}, routing, {
|
|
1035
|
+
allowMissing: false,
|
|
1036
|
+
fallbackSlug: options.draftFallbackSlug
|
|
1037
|
+
});
|
|
1038
|
+
return {
|
|
1039
|
+
draft: {
|
|
1040
|
+
path: draft.path ?? routing.buildRoutePath(""),
|
|
1041
|
+
slug: draft.slug ?? ""
|
|
1042
|
+
},
|
|
1043
|
+
published
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Convert editor/store-facing page slugs back into the DB storage convention.
|
|
1048
|
+
*
|
|
1049
|
+
* Homepage slugs are stored as `NULL` in `site_routes`, while editor state uses empty strings to represent the
|
|
1050
|
+
* homepage. This helper trims incidental whitespace and returns `null` for empty homepage-equivalent values.
|
|
1051
|
+
*/
|
|
1052
|
+
function toStoredPageRouteSlug(slug) {
|
|
1053
|
+
return pageRouteSlugToStoredValue(pageRouteSlugFromEditorValue(slug));
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region ../content-model/src/definitions/shared.ts
|
|
1058
|
+
function createRoutingHelpers(schema, options = {}) {
|
|
1059
|
+
const rawPrefix = schema.routing?.prefix ?? "/";
|
|
1060
|
+
const slugPrefix = ensureSlugPrefix(rawPrefix);
|
|
1061
|
+
const normalizedPrefix = normalizeRoutePrefix(slugPrefix);
|
|
1062
|
+
const routePattern = schema.routing?.pattern ?? (schema.routing?.enabled ? "/{slug}" : undefined) ?? null;
|
|
1063
|
+
const routeTemplate = parseRouteTemplate(routePattern);
|
|
1064
|
+
const defaultSlug = options.slug ?? null;
|
|
1065
|
+
const sanitize = (value) => sanitizeSlug(value ?? "");
|
|
1066
|
+
const toSlug = (input) => {
|
|
1067
|
+
if (!input) return "";
|
|
1068
|
+
let normalized = input.trim();
|
|
1069
|
+
if (!normalized) return "";
|
|
1070
|
+
const sanitized = sanitize(normalized);
|
|
1071
|
+
let result = sanitized;
|
|
1072
|
+
if (result.startsWith(slugPrefix)) {
|
|
1073
|
+
result = result.slice(slugPrefix.length);
|
|
1074
|
+
}
|
|
1075
|
+
const prefixed = normalizedPrefix !== "/" ? `${normalizedPrefix}/` : "/";
|
|
1076
|
+
if (prefixed !== "/" && result.startsWith(prefixed)) {
|
|
1077
|
+
result = result.slice(prefixed.length);
|
|
1078
|
+
}
|
|
1079
|
+
if (normalizedPrefix !== "/" && !result.startsWith("/")) {
|
|
1080
|
+
const prefixWithoutLeadingSlash = normalizedPrefix.slice(1) + "/";
|
|
1081
|
+
if (result.startsWith(prefixWithoutLeadingSlash)) {
|
|
1082
|
+
result = result.slice(prefixWithoutLeadingSlash.length);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (prefixed === "/" && result.startsWith("/")) {
|
|
1086
|
+
result = result.slice(1);
|
|
1087
|
+
}
|
|
1088
|
+
return result;
|
|
1089
|
+
};
|
|
1090
|
+
const toPath = (slug) => {
|
|
1091
|
+
const cleaned = sanitize(slug);
|
|
1092
|
+
if (!cleaned) {
|
|
1093
|
+
return normalizedPrefix === "/" ? "/" : normalizedPrefix;
|
|
1094
|
+
}
|
|
1095
|
+
if (normalizedPrefix === "/") {
|
|
1096
|
+
return `/${cleaned}`;
|
|
1097
|
+
}
|
|
1098
|
+
return `${normalizedPrefix}/${cleaned}`;
|
|
1099
|
+
};
|
|
1100
|
+
const buildRoutePath = (slug) => {
|
|
1101
|
+
const cleaned = sanitize(slug);
|
|
1102
|
+
const effectiveSlug = cleaned || defaultSlug || "";
|
|
1103
|
+
if (routePattern) {
|
|
1104
|
+
return expandSlug(routeTemplate, effectiveSlug);
|
|
1105
|
+
}
|
|
1106
|
+
return toPath(effectiveSlug);
|
|
1107
|
+
};
|
|
1108
|
+
return {
|
|
1109
|
+
slugPrefix,
|
|
1110
|
+
normalizedPrefix,
|
|
1111
|
+
routePattern,
|
|
1112
|
+
defaultSlug,
|
|
1113
|
+
toSlug,
|
|
1114
|
+
toPath,
|
|
1115
|
+
buildRoutePath,
|
|
1116
|
+
sanitize
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
function ensureSlugPrefix(prefix) {
|
|
1120
|
+
const base = prefix?.trim();
|
|
1121
|
+
if (!base) {
|
|
1122
|
+
return "/";
|
|
1123
|
+
}
|
|
1124
|
+
const withLeading = base.startsWith("/") ? base : `/${base}`;
|
|
1125
|
+
return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
|
|
1126
|
+
}
|
|
1127
|
+
function normalizeRoutePrefix(prefix) {
|
|
1128
|
+
if (prefix === "/") return "/";
|
|
1129
|
+
return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Build snapshot for routable content (content with an associated route)
|
|
1133
|
+
*/
|
|
1134
|
+
function buildRoutableSnapshot(entry, route, routing, defaults) {
|
|
1135
|
+
const fallbackSlug = defaults.slug ?? routing.defaultSlug ?? "";
|
|
1136
|
+
const rawDraftTitle = route.draftTitle ?? route.publishedTitle ?? null;
|
|
1137
|
+
if (!rawDraftTitle || rawDraftTitle.trim().length === 0) {
|
|
1138
|
+
throw new Error(`Missing draft title in route for entry ${entry.id}. Route data: ${JSON.stringify(route)}`);
|
|
1139
|
+
}
|
|
1140
|
+
const draftTitle = rawDraftTitle.trim();
|
|
1141
|
+
const rawDraftSlug = route.draftSlug ?? fallbackSlug;
|
|
1142
|
+
const draftSlug = routing.sanitize(rawDraftSlug);
|
|
1143
|
+
const draftPath = route.draftPath ?? routing.toPath(draftSlug);
|
|
1144
|
+
const draftStatus = route.draftStatus ?? "draft";
|
|
1145
|
+
const publishAt = route.scheduledPublishAt ?? null;
|
|
1146
|
+
const rawPublishedTitle = route.publishedTitle ?? route.draftTitle;
|
|
1147
|
+
const publishedTitle = rawPublishedTitle ? rawPublishedTitle.trim() : null;
|
|
1148
|
+
const publishedPath = route.publishedPath ?? null;
|
|
1149
|
+
const publishedStatus = route.status ?? null;
|
|
1150
|
+
const publishedAt = route.publishedAt ?? null;
|
|
1151
|
+
const currentStatus = route.status ?? route.draftStatus ?? "draft";
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "routable",
|
|
1154
|
+
id: entry.id,
|
|
1155
|
+
draft: {
|
|
1156
|
+
title: draftTitle,
|
|
1157
|
+
slug: draftSlug,
|
|
1158
|
+
status: draftStatus,
|
|
1159
|
+
publishAt,
|
|
1160
|
+
path: draftPath
|
|
1161
|
+
},
|
|
1162
|
+
published: {
|
|
1163
|
+
title: publishedTitle,
|
|
1164
|
+
path: publishedPath,
|
|
1165
|
+
status: publishedStatus ?? null,
|
|
1166
|
+
publishAt: publishedAt ?? null
|
|
1167
|
+
},
|
|
1168
|
+
status: currentStatus ?? "draft",
|
|
1169
|
+
route: {
|
|
1170
|
+
draftMetaTitle: route.draftMetaTitle ?? null,
|
|
1171
|
+
draftMetaDescription: route.draftMetaDescription ?? null,
|
|
1172
|
+
publishedMetaTitle: route.publishedMetaTitle ?? null,
|
|
1173
|
+
publishedMetaDescription: route.publishedMetaDescription ?? null,
|
|
1174
|
+
ogImageId: route.ogImageId ?? null
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Build snapshot for non-routable content (content without a route, like testimonials)
|
|
1180
|
+
*/
|
|
1181
|
+
function buildNonRoutableSnapshot(entry, title) {
|
|
1182
|
+
const safeTitle = title.trim() || "Untitled Entry";
|
|
1183
|
+
const hasBeenPublished = !!entry.publishedVersionId;
|
|
1184
|
+
const hasDraftVersionChanges = !!entry.publishedVersionId && !!entry.draftVersionId;
|
|
1185
|
+
const lifecycleStatus = hasBeenPublished ? "published" : "draft";
|
|
1186
|
+
return {
|
|
1187
|
+
kind: "non-routable",
|
|
1188
|
+
id: entry.id,
|
|
1189
|
+
draft: {
|
|
1190
|
+
title: safeTitle,
|
|
1191
|
+
status: lifecycleStatus,
|
|
1192
|
+
publishAt: null
|
|
1193
|
+
},
|
|
1194
|
+
published: {
|
|
1195
|
+
title: hasBeenPublished ? safeTitle : null,
|
|
1196
|
+
status: hasBeenPublished ? lifecycleStatus : null,
|
|
1197
|
+
publishAt: null
|
|
1198
|
+
},
|
|
1199
|
+
status: lifecycleStatus,
|
|
1200
|
+
hasDraftVersionChanges
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
function buildSnapshot(entry, route, routing, defaults) {
|
|
1204
|
+
if (route !== null && route !== undefined) {
|
|
1205
|
+
return buildRoutableSnapshot(entry, route, routing, defaults);
|
|
1206
|
+
}
|
|
1207
|
+
const title = defaults.title || "Untitled Entry";
|
|
1208
|
+
return buildNonRoutableSnapshot(entry, title);
|
|
1209
|
+
}
|
|
1210
|
+
function createSnapshotFromContentEntry(data, routing, defaults, titleField) {
|
|
1211
|
+
let resolvedDefaults = defaults;
|
|
1212
|
+
if (!data.route && data.draftContent) {
|
|
1213
|
+
const extractedTitle = resolveContentTitle(data.draftContent, titleField, "");
|
|
1214
|
+
if (extractedTitle) {
|
|
1215
|
+
resolvedDefaults = {
|
|
1216
|
+
...defaults,
|
|
1217
|
+
title: extractedTitle
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return buildSnapshot(data.entry, data.route ?? null, routing, resolvedDefaults);
|
|
1222
|
+
}
|
|
1223
|
+
function createSnapshotFromRoutableContent(owner, route, routing, defaults) {
|
|
1224
|
+
return buildSnapshot(owner, route, routing, defaults);
|
|
1225
|
+
}
|
|
1226
|
+
function createSnapshotFromPageRecord(record, routing, defaults) {
|
|
1227
|
+
const canonicalRoute = canonicalizePageRouteMetadata({
|
|
1228
|
+
draftPath: record.draftPath ?? null,
|
|
1229
|
+
publishedPath: record.path ?? null
|
|
1230
|
+
}, routing, { draftFallbackSlug: defaults.slug ?? null });
|
|
1231
|
+
const routeLike = {
|
|
1232
|
+
draftTitle: record.draftTitle ?? record.title ?? null,
|
|
1233
|
+
draftPath: canonicalRoute.draft.path,
|
|
1234
|
+
draftSlug: canonicalRoute.draft.slug,
|
|
1235
|
+
draftStatus: record.draftStatus ?? null,
|
|
1236
|
+
publishedTitle: record.title ?? null,
|
|
1237
|
+
publishedPath: canonicalRoute.published.path,
|
|
1238
|
+
publishedSlug: canonicalRoute.published.slug,
|
|
1239
|
+
status: record.status ?? null,
|
|
1240
|
+
publishedAt: record.publishedAt ?? null,
|
|
1241
|
+
scheduledPublishAt: record.scheduledPublishAt ?? null,
|
|
1242
|
+
draftMetaTitle: record.draftMetaTitle ?? null,
|
|
1243
|
+
draftMetaDescription: record.draftMetaDescription ?? null,
|
|
1244
|
+
publishedMetaTitle: record.publishedMetaTitle ?? null,
|
|
1245
|
+
publishedMetaDescription: record.publishedMetaDescription ?? null,
|
|
1246
|
+
ogImageId: record.ogImageId ?? null
|
|
1247
|
+
};
|
|
1248
|
+
return buildSnapshot({ id: record.id }, routeLike, routing, defaults);
|
|
1249
|
+
}
|
|
1250
|
+
function createMetadataStateFromSnapshot(snapshot) {
|
|
1251
|
+
if (snapshot.kind === "non-routable") {
|
|
1252
|
+
const empty = {};
|
|
1253
|
+
return {
|
|
1254
|
+
values: empty,
|
|
1255
|
+
baseline: empty
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
const values = {
|
|
1259
|
+
draftTitle: snapshot.draft.title,
|
|
1260
|
+
draftSlug: snapshot.draft.slug,
|
|
1261
|
+
draftStatus: snapshot.draft.status,
|
|
1262
|
+
scheduledPublishAt: snapshot.draft.publishAt,
|
|
1263
|
+
draftMetaTitle: snapshot.route.draftMetaTitle ?? null,
|
|
1264
|
+
draftMetaDescription: snapshot.route.draftMetaDescription ?? null,
|
|
1265
|
+
ogImageId: snapshot.route.ogImageId ?? null
|
|
1266
|
+
};
|
|
1267
|
+
return {
|
|
1268
|
+
values,
|
|
1269
|
+
baseline: { ...values }
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Extract published metadata values from a snapshot.
|
|
1274
|
+
* Used to set the publishedBaseline for unpublished changes detection.
|
|
1275
|
+
*
|
|
1276
|
+
* Returns null if the entry has never been published.
|
|
1277
|
+
*/
|
|
1278
|
+
function getPublishedMetadataFromSnapshot(snapshot, routing) {
|
|
1279
|
+
if (snapshot.kind === "non-routable") {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
const hasPublishedSignal = snapshot.published.status === "published" || Boolean(snapshot.published.publishAt);
|
|
1283
|
+
if (!snapshot.published.title && !hasPublishedSignal) {
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
const publishedTitle = snapshot.published.title ?? snapshot.draft.title;
|
|
1287
|
+
if (!publishedTitle) return null;
|
|
1288
|
+
const publishedPath = snapshot.published.path ?? snapshot.draft.path ?? "";
|
|
1289
|
+
const publishedSlug = routing ? routing.toSlug(publishedPath) : publishedPath ? publishedPath.replace(/^\//, "").replace(/\/$/, "") : "";
|
|
1290
|
+
return {
|
|
1291
|
+
draftTitle: publishedTitle,
|
|
1292
|
+
draftSlug: publishedSlug,
|
|
1293
|
+
draftStatus: snapshot.published.status ?? snapshot.draft.status ?? "draft",
|
|
1294
|
+
scheduledPublishAt: null,
|
|
1295
|
+
draftMetaTitle: snapshot.route.publishedMetaTitle ?? null,
|
|
1296
|
+
draftMetaDescription: snapshot.route.publishedMetaDescription ?? null,
|
|
1297
|
+
ogImageId: snapshot.route.ogImageId ?? null
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
function buildToolbarState(snapshot, context, options) {
|
|
1301
|
+
const formatPublishMessage = ({ status, publishedAt, scheduledAt }) => {
|
|
1302
|
+
if (status === "published") {
|
|
1303
|
+
if (!publishedAt) return "Published";
|
|
1304
|
+
const date = new Date(publishedAt);
|
|
1305
|
+
if (Number.isNaN(date.getTime())) return "Published";
|
|
1306
|
+
return `Published ${date.toLocaleString()}`;
|
|
1307
|
+
}
|
|
1308
|
+
if (status === "scheduled" && scheduledAt) {
|
|
1309
|
+
const date = new Date(scheduledAt);
|
|
1310
|
+
if (!Number.isNaN(date.getTime())) {
|
|
1311
|
+
return `Scheduled for ${date.toLocaleString()}`;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return "Not yet published";
|
|
1315
|
+
};
|
|
1316
|
+
const { status: statusField } = METADATA_WORKFLOW_FIELDS;
|
|
1317
|
+
const publishedMessage = formatPublishMessage({
|
|
1318
|
+
status: snapshot.status,
|
|
1319
|
+
publishedAt: snapshot.published.publishAt,
|
|
1320
|
+
scheduledAt: snapshot.draft.publishAt
|
|
1321
|
+
});
|
|
1322
|
+
let publishLabel = `Publish ${options.entityLabel.toLowerCase()}`;
|
|
1323
|
+
if (statusField && context.values[statusField] === "scheduled") {
|
|
1324
|
+
publishLabel = "Schedule publish";
|
|
1325
|
+
}
|
|
1326
|
+
return {
|
|
1327
|
+
currentStatus: snapshot.status,
|
|
1328
|
+
publishLabel,
|
|
1329
|
+
publishedMessage,
|
|
1330
|
+
disablePublish: false,
|
|
1331
|
+
disableDiscard: false,
|
|
1332
|
+
values: context.values
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
//#endregion
|
|
1337
|
+
//#region ../content-model/src/content-types/schema.ts
|
|
1338
|
+
/**
|
|
1339
|
+
* Content field types supported in custom content types.
|
|
1340
|
+
*
|
|
1341
|
+
* - text, textarea, richText: Text-based fields
|
|
1342
|
+
* - number: Numeric input
|
|
1343
|
+
* - date, datetime: Date/time pickers
|
|
1344
|
+
* - media: Image/video uploads
|
|
1345
|
+
* - file: Public file uploads/downloads
|
|
1346
|
+
* - boolean: Toggle/checkbox
|
|
1347
|
+
* - select: Dropdown with options
|
|
1348
|
+
* - reference: Link to another content type
|
|
1349
|
+
* - link: URL/page link picker
|
|
1350
|
+
* - url: Plain URL input
|
|
1351
|
+
* - button: Expands to a group with label, variant, link, and icons
|
|
1352
|
+
* - group: Nested field container (for advanced use cases)
|
|
1353
|
+
*/
|
|
1354
|
+
const contentFieldTypes = [
|
|
1355
|
+
"text",
|
|
1356
|
+
"textarea",
|
|
1357
|
+
"richText",
|
|
1358
|
+
"number",
|
|
1359
|
+
"date",
|
|
1360
|
+
"url",
|
|
1361
|
+
"media",
|
|
1362
|
+
"file",
|
|
1363
|
+
"boolean",
|
|
1364
|
+
"select",
|
|
1365
|
+
"reference",
|
|
1366
|
+
"link",
|
|
1367
|
+
"button",
|
|
1368
|
+
"group",
|
|
1369
|
+
"repeater",
|
|
1370
|
+
"datetime"
|
|
1371
|
+
];
|
|
1372
|
+
const DISPLAY_MEDIA_KINDS = [
|
|
1373
|
+
"image",
|
|
1374
|
+
"video",
|
|
1375
|
+
"audio"
|
|
1376
|
+
];
|
|
1377
|
+
const FILE_FIELD_KINDS = [
|
|
1378
|
+
"document",
|
|
1379
|
+
"spreadsheet",
|
|
1380
|
+
"archive",
|
|
1381
|
+
"audio"
|
|
1382
|
+
];
|
|
1383
|
+
const LEGACY_MEDIA_KINDS = [
|
|
1384
|
+
"image",
|
|
1385
|
+
"video",
|
|
1386
|
+
"audio",
|
|
1387
|
+
"document",
|
|
1388
|
+
"spreadsheet",
|
|
1389
|
+
"archive"
|
|
1390
|
+
];
|
|
1391
|
+
/**
|
|
1392
|
+
* Base content field schema (non-recursive properties)
|
|
1393
|
+
*/
|
|
1394
|
+
const BaseContentFieldSchema = z.object({
|
|
1395
|
+
id: z.string(),
|
|
1396
|
+
type: z.enum(contentFieldTypes),
|
|
1397
|
+
label: z.string().optional(),
|
|
1398
|
+
description: z.string().optional(),
|
|
1399
|
+
required: z.boolean().optional(),
|
|
1400
|
+
defaultValue: z.unknown().optional(),
|
|
1401
|
+
multiline: z.boolean().optional(),
|
|
1402
|
+
rows: z.number().int().positive().optional(),
|
|
1403
|
+
maxLength: z.number().int().positive().optional(),
|
|
1404
|
+
min: z.number().optional(),
|
|
1405
|
+
max: z.number().optional(),
|
|
1406
|
+
step: z.number().positive().optional(),
|
|
1407
|
+
mediaKinds: z.array(z.enum(LEGACY_MEDIA_KINDS)).optional(),
|
|
1408
|
+
fileKinds: z.array(z.enum(FILE_FIELD_KINDS)).optional(),
|
|
1409
|
+
allowRelative: z.boolean().optional(),
|
|
1410
|
+
options: z.array(z.object({
|
|
1411
|
+
value: z.string(),
|
|
1412
|
+
label: z.string().optional()
|
|
1413
|
+
})).optional(),
|
|
1414
|
+
multiple: z.boolean().optional(),
|
|
1415
|
+
referenceKind: z.string().trim().optional(),
|
|
1416
|
+
reference: z.object({ type: z.string().trim() }).optional(),
|
|
1417
|
+
valueFrom: z.enum([
|
|
1418
|
+
"id",
|
|
1419
|
+
"identifier",
|
|
1420
|
+
"slug"
|
|
1421
|
+
]).optional(),
|
|
1422
|
+
ui: z.record(z.string(), z.unknown()).optional(),
|
|
1423
|
+
itemLabel: z.string().optional()
|
|
1424
|
+
}).superRefine((field, ctx) => {
|
|
1425
|
+
if ((field.type === "media" || field.type === "file") && field.defaultValue !== undefined && field.defaultValue !== null && (typeof field.defaultValue !== "object" || Array.isArray(field.defaultValue) || typeof field.defaultValue.identifier !== "string" || !field.defaultValue.identifier.trim())) {
|
|
1426
|
+
ctx.addIssue({
|
|
1427
|
+
code: z.ZodIssueCode.custom,
|
|
1428
|
+
path: ["defaultValue"],
|
|
1429
|
+
message: `${field.type} defaultValue must be null or an object with a non-empty identifier`
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
if (field.multiple !== undefined && field.type !== "select") {
|
|
1433
|
+
ctx.addIssue({
|
|
1434
|
+
code: z.ZodIssueCode.custom,
|
|
1435
|
+
path: ["multiple"],
|
|
1436
|
+
message: "multiple is only valid for select fields"
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
if (field.type !== "reference") {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
const referenceKind = field.referenceKind;
|
|
1443
|
+
const legacyReferenceType = field.reference?.type;
|
|
1444
|
+
const effectiveReferenceKind$1 = referenceKind ?? legacyReferenceType;
|
|
1445
|
+
if (field.referenceKind !== undefined && !referenceKind) {
|
|
1446
|
+
ctx.addIssue({
|
|
1447
|
+
code: z.ZodIssueCode.custom,
|
|
1448
|
+
path: ["referenceKind"],
|
|
1449
|
+
message: "referenceKind must not be empty"
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
if (field.reference?.type !== undefined && !legacyReferenceType) {
|
|
1453
|
+
ctx.addIssue({
|
|
1454
|
+
code: z.ZodIssueCode.custom,
|
|
1455
|
+
path: ["reference", "type"],
|
|
1456
|
+
message: "reference.type must not be empty"
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
if (referenceKind && legacyReferenceType && referenceKind !== legacyReferenceType) {
|
|
1460
|
+
ctx.addIssue({
|
|
1461
|
+
code: z.ZodIssueCode.custom,
|
|
1462
|
+
path: ["referenceKind"],
|
|
1463
|
+
message: "referenceKind must match reference.type when both are provided"
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
if (field.valueFrom !== undefined && effectiveReferenceKind$1 && isAdapterReferenceKind(effectiveReferenceKind$1)) {
|
|
1467
|
+
ctx.addIssue({
|
|
1468
|
+
code: z.ZodIssueCode.custom,
|
|
1469
|
+
path: ["valueFrom"],
|
|
1470
|
+
message: "valueFrom is only valid for content entry reference fields"
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
}).passthrough();
|
|
1474
|
+
const ContentFieldSchema = BaseContentFieldSchema.extend({
|
|
1475
|
+
fields: z.lazy(() => z.array(ContentFieldSchema)).optional(),
|
|
1476
|
+
schema: z.lazy(() => z.object({ fields: z.array(ContentFieldSchema).optional() }).passthrough()).optional()
|
|
1477
|
+
});
|
|
1478
|
+
const RoutingSchema = z.object({
|
|
1479
|
+
enabled: z.boolean().default(false),
|
|
1480
|
+
pattern: z.string().optional(),
|
|
1481
|
+
slugField: z.string().optional(),
|
|
1482
|
+
pathField: z.string().optional(),
|
|
1483
|
+
prefix: z.string().optional(),
|
|
1484
|
+
subroutes: z.array(z.object({
|
|
1485
|
+
kind: z.string().min(1),
|
|
1486
|
+
segmentType: z.enum([
|
|
1487
|
+
"uuid",
|
|
1488
|
+
"iso-date",
|
|
1489
|
+
"uuid-or-date",
|
|
1490
|
+
"slug"
|
|
1491
|
+
]),
|
|
1492
|
+
precedence: z.enum(["subroute-first", "content-first"]).optional()
|
|
1493
|
+
})).optional()
|
|
1494
|
+
}).superRefine((routing, ctx) => {
|
|
1495
|
+
if (!routing.pattern) {
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const parseResult = safeParseRouteTemplate(routing.pattern);
|
|
1499
|
+
if (parseResult.success) {
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
ctx.addIssue({
|
|
1503
|
+
code: z.ZodIssueCode.custom,
|
|
1504
|
+
path: ["pattern"],
|
|
1505
|
+
message: describeRouteTemplateParseError(parseResult.error)
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
const LabelsSchema = z.object({
|
|
1509
|
+
singular: z.string(),
|
|
1510
|
+
plural: z.string()
|
|
1511
|
+
});
|
|
1512
|
+
const OrderingSchema = z.object({ mode: z.enum(["default", "manual"]).default("default") }).default({ mode: "default" });
|
|
1513
|
+
const ContentTypeSchemaZ = z.object({
|
|
1514
|
+
version: z.number().default(1),
|
|
1515
|
+
fields: z.array(ContentFieldSchema).default([]),
|
|
1516
|
+
routing: RoutingSchema.optional(),
|
|
1517
|
+
titleField: z.string().optional(),
|
|
1518
|
+
widgets: z.record(z.string(), z.unknown()).optional(),
|
|
1519
|
+
labels: LabelsSchema.default({
|
|
1520
|
+
singular: "Entry",
|
|
1521
|
+
plural: "Entries"
|
|
1522
|
+
}),
|
|
1523
|
+
ordering: OrderingSchema.optional()
|
|
1524
|
+
}).refine((data) => {
|
|
1525
|
+
if (data.titleField) {
|
|
1526
|
+
const fieldExists = data.fields.some((f) => f.id === data.titleField);
|
|
1527
|
+
return fieldExists;
|
|
1528
|
+
}
|
|
1529
|
+
return true;
|
|
1530
|
+
}, {
|
|
1531
|
+
message: "titleField must reference an existing field ID in the fields array",
|
|
1532
|
+
path: ["titleField"]
|
|
1533
|
+
});
|
|
1534
|
+
function parseContentTypeSchema(input) {
|
|
1535
|
+
if (!input || typeof input !== "object") {
|
|
1536
|
+
throw new Error("[content-types] Missing or invalid schema payload");
|
|
1537
|
+
}
|
|
1538
|
+
const result = ContentTypeSchemaZ.safeParse(input);
|
|
1539
|
+
if (!result.success) {
|
|
1540
|
+
const message = result.error?.message ?? "Invalid content type schema";
|
|
1541
|
+
throw new Error(`[content-types] ${message}`);
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
...result.data,
|
|
1545
|
+
fields: normalizeContentFields(result.data.fields ?? [])
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
function isManualEntryOrderingEnabled(schema) {
|
|
1549
|
+
return schema?.ordering?.mode === "manual";
|
|
1550
|
+
}
|
|
1551
|
+
function normalizeContentFields(fields) {
|
|
1552
|
+
return fields.map((field) => {
|
|
1553
|
+
const schemaFields = field.schema?.fields ?? undefined;
|
|
1554
|
+
const nextFields = field.fields ?? schemaFields;
|
|
1555
|
+
const normalizedFields = nextFields ? normalizeContentFields(nextFields) : undefined;
|
|
1556
|
+
const next = normalizeLegacyFileMediaField({
|
|
1557
|
+
...field,
|
|
1558
|
+
...normalizedFields ? { fields: normalizedFields } : {}
|
|
1559
|
+
});
|
|
1560
|
+
return next;
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
function normalizeLegacyFileMediaField(field) {
|
|
1564
|
+
if (field.type !== "media") return field;
|
|
1565
|
+
const mediaKinds = field.mediaKinds ?? [];
|
|
1566
|
+
const displayKinds = mediaKinds.filter(isDisplayMediaKind);
|
|
1567
|
+
const fileKinds = mediaKinds.filter(isFileFieldKind);
|
|
1568
|
+
if (fileKinds.length === 0 || displayKinds.length > 0) {
|
|
1569
|
+
return {
|
|
1570
|
+
...field,
|
|
1571
|
+
mediaKinds: displayKinds.length > 0 ? displayKinds : field.mediaKinds
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
const { mediaKinds: _legacyMediaKinds, ...rest } = field;
|
|
1575
|
+
return {
|
|
1576
|
+
...rest,
|
|
1577
|
+
type: "file",
|
|
1578
|
+
fileKinds
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
function isDisplayMediaKind(value) {
|
|
1582
|
+
return DISPLAY_MEDIA_KINDS.includes(value);
|
|
1583
|
+
}
|
|
1584
|
+
function isFileFieldKind(value) {
|
|
1585
|
+
return FILE_FIELD_KINDS.includes(value);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region ../content-model/src/content-types/sentinels.ts
|
|
1590
|
+
/** Leaf field types that receive a placeholder sentinel (containers recurse). */
|
|
1591
|
+
const SENTINEL_LEAF_TYPES = [
|
|
1592
|
+
"text",
|
|
1593
|
+
"textarea",
|
|
1594
|
+
"richText",
|
|
1595
|
+
"number",
|
|
1596
|
+
"date",
|
|
1597
|
+
"datetime",
|
|
1598
|
+
"url",
|
|
1599
|
+
"link",
|
|
1600
|
+
"media",
|
|
1601
|
+
"file",
|
|
1602
|
+
"boolean",
|
|
1603
|
+
"select",
|
|
1604
|
+
"reference"
|
|
1605
|
+
];
|
|
1606
|
+
/** Matches the closed sentinel shape `"<REQUIRED: …>"`. */
|
|
1607
|
+
const SENTINEL_PATTERN = /^<REQUIRED:.*>$/;
|
|
1608
|
+
/**
|
|
1609
|
+
* True when a value is an unfilled placeholder sentinel. Shared by the emitter
|
|
1610
|
+
* (self-check) and the validator (rejection) so recognition can't drift.
|
|
1611
|
+
*/
|
|
1612
|
+
function isPlaceholderSentinel(value) {
|
|
1613
|
+
return typeof value === "string" && SENTINEL_PATTERN.test(value);
|
|
1614
|
+
}
|
|
1615
|
+
function effectiveReferenceKind(field) {
|
|
1616
|
+
const kind = field.referenceKind;
|
|
1617
|
+
const legacy = field.reference?.type;
|
|
1618
|
+
return kind ?? legacy;
|
|
1619
|
+
}
|
|
1620
|
+
function selectOptionValues$1(field) {
|
|
1621
|
+
const options = field.options;
|
|
1622
|
+
return options ? options.map((o) => o.value) : [];
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* The placeholder sentinel for a leaf field. Container types (group/button/
|
|
1626
|
+
* repeater) do not have a leaf sentinel — they expand structurally — and calling
|
|
1627
|
+
* this for them returns a generic marker only for completeness.
|
|
1628
|
+
*/
|
|
1629
|
+
function buildSentinel(field) {
|
|
1630
|
+
switch (field.type) {
|
|
1631
|
+
case "text":
|
|
1632
|
+
case "textarea": return "<REQUIRED: text>";
|
|
1633
|
+
case "richText": return "<REQUIRED: rich-text>";
|
|
1634
|
+
case "number": return "<REQUIRED: number>";
|
|
1635
|
+
case "date": return "<REQUIRED: date (YYYY-MM-DD)>";
|
|
1636
|
+
case "datetime": return "<REQUIRED: datetime (ISO 8601)>";
|
|
1637
|
+
case "url": return "<REQUIRED: url>";
|
|
1638
|
+
case "link": return "<REQUIRED: link>";
|
|
1639
|
+
case "media": return "<REQUIRED: media-identifier>";
|
|
1640
|
+
case "file": return "<REQUIRED: file-identifier>";
|
|
1641
|
+
case "boolean": return "<REQUIRED: boolean>";
|
|
1642
|
+
case "select": {
|
|
1643
|
+
const values = selectOptionValues$1(field);
|
|
1644
|
+
return values.length > 0 ? `<REQUIRED: select (${values.join("|")})>` : "<REQUIRED: select>";
|
|
1645
|
+
}
|
|
1646
|
+
case "reference": {
|
|
1647
|
+
const kind = effectiveReferenceKind(field);
|
|
1648
|
+
return kind ? `<REQUIRED: reference-identifier (${kind})>` : "<REQUIRED: reference-identifier>";
|
|
1649
|
+
}
|
|
1650
|
+
case "group": return "<REQUIRED: group>";
|
|
1651
|
+
case "button": return "<REQUIRED: button>";
|
|
1652
|
+
case "repeater": return "<REQUIRED: repeater>";
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Deep-scan arbitrary entry data for any unfilled placeholder sentinels. Needs no
|
|
1657
|
+
* schema, so it powers the CLI's pre-HTTP upsert pre-flight.
|
|
1658
|
+
*/
|
|
1659
|
+
function findUnfilledSentinels(data, pathPrefix = "") {
|
|
1660
|
+
const found = [];
|
|
1661
|
+
const walk = (value, path) => {
|
|
1662
|
+
if (isPlaceholderSentinel(value)) {
|
|
1663
|
+
found.push({
|
|
1664
|
+
path: path || "(root)",
|
|
1665
|
+
value
|
|
1666
|
+
});
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
if (Array.isArray(value)) {
|
|
1670
|
+
value.forEach((item, index) => walk(item, `${path}[${index}]`));
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (value !== null && typeof value === "object") {
|
|
1674
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1675
|
+
walk(child, path ? `${path}.${key}` : key);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
};
|
|
1679
|
+
walk(data, pathPrefix);
|
|
1680
|
+
return found;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
//#endregion
|
|
1684
|
+
//#region ../content-model/src/content-types/entryValidation.ts
|
|
1685
|
+
function subfieldsOf$1(field) {
|
|
1686
|
+
const direct = field.fields;
|
|
1687
|
+
if (direct && direct.length > 0) return direct;
|
|
1688
|
+
const nested = field.schema?.fields;
|
|
1689
|
+
return nested ?? [];
|
|
1690
|
+
}
|
|
1691
|
+
function repeaterVariantFields(field, item) {
|
|
1692
|
+
const itemTypes = field.itemTypes;
|
|
1693
|
+
if (!itemTypes) return subfieldsOf$1(field);
|
|
1694
|
+
const typeId = item._type;
|
|
1695
|
+
if (typeof typeId !== "string") return null;
|
|
1696
|
+
return itemTypes[typeId]?.fields ?? null;
|
|
1697
|
+
}
|
|
1698
|
+
function selectOptionValues(field) {
|
|
1699
|
+
const options = field.options;
|
|
1700
|
+
return options ? options.map((o) => o.value) : [];
|
|
1701
|
+
}
|
|
1702
|
+
function isPlainObject(value) {
|
|
1703
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1704
|
+
}
|
|
1705
|
+
const STRING_TYPES = new Set([
|
|
1706
|
+
"text",
|
|
1707
|
+
"textarea",
|
|
1708
|
+
"url",
|
|
1709
|
+
"link",
|
|
1710
|
+
"reference",
|
|
1711
|
+
"date",
|
|
1712
|
+
"datetime",
|
|
1713
|
+
"select"
|
|
1714
|
+
]);
|
|
1715
|
+
function isAllowedIdentifierBearingMedia(field, value) {
|
|
1716
|
+
if (!isPlainObject(value) || typeof value.identifier !== "string" || value.identifier.trim().length === 0) {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
if (value.type === undefined) return true;
|
|
1720
|
+
const allowedKinds = field.type === "media" ? field.mediaKinds : field.fileKinds;
|
|
1721
|
+
if (typeof value.type !== "string") return false;
|
|
1722
|
+
const effectiveAllowedKinds = allowedKinds ?? (field.type === "media" ? ["image"] : ["document"]);
|
|
1723
|
+
return effectiveAllowedKinds.some((kind) => kind === value.type);
|
|
1724
|
+
}
|
|
1725
|
+
function checkLeafType(field, value, path, errors) {
|
|
1726
|
+
const push = (code, message) => {
|
|
1727
|
+
errors.push({
|
|
1728
|
+
path,
|
|
1729
|
+
fieldType: field.type,
|
|
1730
|
+
code,
|
|
1731
|
+
message
|
|
1732
|
+
});
|
|
1733
|
+
};
|
|
1734
|
+
if (field.type === "number") {
|
|
1735
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
1736
|
+
push("wrong_type", `"${path}" must be a number.`);
|
|
1737
|
+
}
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (field.type === "boolean") {
|
|
1741
|
+
if (typeof value !== "boolean") push("wrong_type", `"${path}" must be a boolean.`);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (field.type === "richText") {
|
|
1745
|
+
if (typeof value !== "string" && !isPlainObject(value)) {
|
|
1746
|
+
push("wrong_type", `"${path}" must be rich-text (string or document object).`);
|
|
1747
|
+
}
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
if (field.type === "media" || field.type === "file") {
|
|
1751
|
+
if (isPlainObject(value) && isPlaceholderSentinel(value.identifier)) {
|
|
1752
|
+
push("unfilled_placeholder", `"${path}" still contains an unfilled placeholder (${value.identifier}).`);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (!isAllowedIdentifierBearingMedia(field, value)) {
|
|
1756
|
+
push("wrong_type", `"${path}" must be a media object with an identifier.`);
|
|
1757
|
+
}
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (field.type === "select") {
|
|
1761
|
+
if (typeof value !== "string") {
|
|
1762
|
+
push("wrong_type", `"${path}" must be a string.`);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const options = selectOptionValues(field);
|
|
1766
|
+
if (options.length > 0 && !options.includes(value)) {
|
|
1767
|
+
push("invalid_option", `"${path}" must be one of: ${options.join(", ")}.`);
|
|
1768
|
+
}
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (STRING_TYPES.has(field.type)) {
|
|
1772
|
+
if (typeof value !== "string") push("wrong_type", `"${path}" must be a string.`);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function validateField(field, value, path, errors, allowIncomplete) {
|
|
1776
|
+
const missing = value === undefined || value === null || value === "";
|
|
1777
|
+
if (missing) {
|
|
1778
|
+
if (field.required === true && !allowIncomplete) {
|
|
1779
|
+
errors.push({
|
|
1780
|
+
path,
|
|
1781
|
+
fieldType: field.type,
|
|
1782
|
+
code: "missing_required",
|
|
1783
|
+
message: `Required field "${path}" is missing.`
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (isPlaceholderSentinel(value)) {
|
|
1789
|
+
errors.push({
|
|
1790
|
+
path,
|
|
1791
|
+
fieldType: field.type,
|
|
1792
|
+
code: "unfilled_placeholder",
|
|
1793
|
+
message: `"${path}" still contains an unfilled placeholder (${value}).`
|
|
1794
|
+
});
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (field.type === "group" || field.type === "button") {
|
|
1798
|
+
if (!isPlainObject(value)) {
|
|
1799
|
+
errors.push({
|
|
1800
|
+
path,
|
|
1801
|
+
fieldType: field.type,
|
|
1802
|
+
code: "wrong_type",
|
|
1803
|
+
message: `"${path}" must be an object.`
|
|
1804
|
+
});
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
validateFields(subfieldsOf$1(field), value, path, errors, allowIncomplete);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (field.type === "repeater") {
|
|
1811
|
+
if (!Array.isArray(value)) {
|
|
1812
|
+
errors.push({
|
|
1813
|
+
path,
|
|
1814
|
+
fieldType: field.type,
|
|
1815
|
+
code: "wrong_type",
|
|
1816
|
+
message: `"${path}" must be an array.`
|
|
1817
|
+
});
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
value.forEach((item, index) => {
|
|
1821
|
+
const itemPath = `${path}[${index}]`;
|
|
1822
|
+
if (!isPlainObject(item)) {
|
|
1823
|
+
errors.push({
|
|
1824
|
+
path: itemPath,
|
|
1825
|
+
fieldType: "repeater",
|
|
1826
|
+
code: "wrong_type",
|
|
1827
|
+
message: `"${itemPath}" must be an object.`
|
|
1828
|
+
});
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
const itemFields = repeaterVariantFields(field, item);
|
|
1832
|
+
if (!itemFields) {
|
|
1833
|
+
errors.push({
|
|
1834
|
+
path: itemPath,
|
|
1835
|
+
fieldType: "repeater",
|
|
1836
|
+
code: "invalid_option",
|
|
1837
|
+
message: `"${itemPath}" has an unknown or missing repeater item type.`
|
|
1838
|
+
});
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
validateFields(itemFields, item, itemPath, errors, allowIncomplete);
|
|
1842
|
+
});
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
checkLeafType(field, value, path, errors);
|
|
1846
|
+
}
|
|
1847
|
+
function validateFields(fields, data, pathPrefix, errors, allowIncomplete) {
|
|
1848
|
+
for (const field of fields) {
|
|
1849
|
+
const path = pathPrefix ? `${pathPrefix}.${field.id}` : field.id;
|
|
1850
|
+
validateField(field, data[field.id], path, errors, allowIncomplete);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Validate entry data against a content-type schema. Returns all errors so
|
|
1855
|
+
* callers can list everything an agent must fix in one pass.
|
|
1856
|
+
*/
|
|
1857
|
+
function validateEntryInstance(input) {
|
|
1858
|
+
const errors = [];
|
|
1859
|
+
validateFields(input.schema.fields, input.data, "", errors, input.allowIncomplete ?? false);
|
|
1860
|
+
return {
|
|
1861
|
+
valid: errors.length === 0,
|
|
1862
|
+
errors
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
//#endregion
|
|
1867
|
+
//#region ../content-model/src/content-types/scaffold.ts
|
|
1868
|
+
/** Nested subfields for group/repeater/button-style fields (both DSL shapes). */
|
|
1869
|
+
function subfieldsOf(field) {
|
|
1870
|
+
const direct = field.fields;
|
|
1871
|
+
if (direct && direct.length > 0) return direct;
|
|
1872
|
+
const nested = field.schema?.fields;
|
|
1873
|
+
return nested ?? [];
|
|
1874
|
+
}
|
|
1875
|
+
function resourceKindForFieldType(fieldType) {
|
|
1876
|
+
if (fieldType === "media") return "media";
|
|
1877
|
+
if (fieldType === "file") return "file";
|
|
1878
|
+
if (fieldType === "reference") return "reference";
|
|
1879
|
+
return undefined;
|
|
1880
|
+
}
|
|
1881
|
+
const CONTAINER_TYPES = new Set([
|
|
1882
|
+
"group",
|
|
1883
|
+
"button",
|
|
1884
|
+
"repeater"
|
|
1885
|
+
]);
|
|
1886
|
+
/**
|
|
1887
|
+
* Build the placeholder value for a required field. Group/button fields recurse
|
|
1888
|
+
* into their required subfields; repeaters emit a single seed item so agents see
|
|
1889
|
+
* the item shape.
|
|
1890
|
+
*/
|
|
1891
|
+
function buildFieldValue(field) {
|
|
1892
|
+
if (field.type === "repeater") {
|
|
1893
|
+
return [buildObjectFromFields(subfieldsOf(field))];
|
|
1894
|
+
}
|
|
1895
|
+
if (CONTAINER_TYPES.has(field.type)) {
|
|
1896
|
+
const subfields = subfieldsOf(field);
|
|
1897
|
+
if (subfields.length > 0) {
|
|
1898
|
+
return buildObjectFromFields(subfields);
|
|
1899
|
+
}
|
|
1900
|
+
if (field.type === "button") {
|
|
1901
|
+
return {
|
|
1902
|
+
label: "<REQUIRED: text>",
|
|
1903
|
+
link: "<REQUIRED: link>"
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
return {};
|
|
1907
|
+
}
|
|
1908
|
+
if (field.type === "media" || field.type === "file") {
|
|
1909
|
+
return { identifier: buildSentinel(field) };
|
|
1910
|
+
}
|
|
1911
|
+
return buildSentinel(field);
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Build a template object from a list of fields, stubbing required fields and
|
|
1915
|
+
* omitting optional ones.
|
|
1916
|
+
*/
|
|
1917
|
+
function buildObjectFromFields(fields) {
|
|
1918
|
+
const template = {};
|
|
1919
|
+
for (const field of fields) {
|
|
1920
|
+
if (field.required !== true) continue;
|
|
1921
|
+
template[field.id] = buildFieldValue(field);
|
|
1922
|
+
}
|
|
1923
|
+
return template;
|
|
1924
|
+
}
|
|
1925
|
+
function diagnosticFromValidationError(error) {
|
|
1926
|
+
const needsResource = resourceKindForFieldType(error.fieldType);
|
|
1927
|
+
const message = needsResource === "media" ? `Provide an existing media identifier for "${error.path}".` : needsResource === "file" ? `Provide an existing file identifier for "${error.path}".` : needsResource === "reference" ? `Provide an existing entry identifier for "${error.path}".` : error.message;
|
|
1928
|
+
return {
|
|
1929
|
+
path: error.path,
|
|
1930
|
+
fieldType: error.fieldType,
|
|
1931
|
+
...needsResource ? { needsResource } : {},
|
|
1932
|
+
message
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Emit a placeholder template + diagnostics for a content type from its field DSL.
|
|
1937
|
+
* Diagnostics come from the entry-instance validator run against the template,
|
|
1938
|
+
* merged with a sentinel sweep so no emitted placeholder goes unreported.
|
|
1939
|
+
*/
|
|
1940
|
+
function buildEntryScaffold(input) {
|
|
1941
|
+
const template = buildObjectFromFields(input.schema.fields);
|
|
1942
|
+
const { errors } = validateEntryInstance({
|
|
1943
|
+
schema: input.schema,
|
|
1944
|
+
data: template
|
|
1945
|
+
});
|
|
1946
|
+
const diagnostics = errors.map(diagnosticFromValidationError);
|
|
1947
|
+
const seen = new Set(diagnostics.map((d) => d.path));
|
|
1948
|
+
for (const sentinel of findUnfilledSentinels(template)) {
|
|
1949
|
+
if (!seen.has(sentinel.path)) {
|
|
1950
|
+
seen.add(sentinel.path);
|
|
1951
|
+
diagnostics.push({
|
|
1952
|
+
path: sentinel.path,
|
|
1953
|
+
fieldType: "unknown",
|
|
1954
|
+
message: `Replace the placeholder at "${sentinel.path}" (${sentinel.value}).`
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
contentType: input.key,
|
|
1960
|
+
template,
|
|
1961
|
+
diagnostics,
|
|
1962
|
+
ready: diagnostics.length === 0
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region ../content-model/src/content-types/defaults.ts
|
|
1968
|
+
function applyContentTypeDefaults(schema) {
|
|
1969
|
+
return {
|
|
1970
|
+
version: schema.version ?? 1,
|
|
1971
|
+
fields: cloneFieldCollection(schema.fields ?? []),
|
|
1972
|
+
routing: schema.routing ? { ...schema.routing } : undefined,
|
|
1973
|
+
widgets: schema.widgets ? { ...schema.widgets } : undefined,
|
|
1974
|
+
labels: schema.labels ? { ...schema.labels } : {
|
|
1975
|
+
singular: "Entry",
|
|
1976
|
+
plural: "Entries"
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
function getWorkflowFieldIds(schema) {
|
|
1981
|
+
if (!schema.routing?.enabled) {
|
|
1982
|
+
return [
|
|
1983
|
+
null,
|
|
1984
|
+
null,
|
|
1985
|
+
null,
|
|
1986
|
+
null
|
|
1987
|
+
];
|
|
1988
|
+
}
|
|
1989
|
+
return [
|
|
1990
|
+
"title",
|
|
1991
|
+
"slug",
|
|
1992
|
+
"status",
|
|
1993
|
+
"publishAt"
|
|
1994
|
+
];
|
|
1995
|
+
}
|
|
1996
|
+
function serializeContentTypeSchema(schema) {
|
|
1997
|
+
return JSON.parse(JSON.stringify(applyContentTypeDefaults(schema)));
|
|
1998
|
+
}
|
|
1999
|
+
function cloneFieldCollection(collection) {
|
|
2000
|
+
return JSON.parse(JSON.stringify(collection));
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
//#endregion
|
|
2004
|
+
//#region ../content-model/src/content-types/builtin-schemas.ts
|
|
2005
|
+
const POST_CONTENT_TYPE_SCHEMA = {
|
|
2006
|
+
version: 1,
|
|
2007
|
+
fields: [
|
|
2008
|
+
{
|
|
2009
|
+
id: "image",
|
|
2010
|
+
type: "media",
|
|
2011
|
+
label: "Featured image",
|
|
2012
|
+
description: "Shown in the post hero and previews."
|
|
2013
|
+
},
|
|
2014
|
+
{
|
|
2015
|
+
id: "excerpt",
|
|
2016
|
+
type: "textarea",
|
|
2017
|
+
label: "Excerpt",
|
|
2018
|
+
description: "Short summary shown in listings.",
|
|
2019
|
+
rows: 3
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
id: "body",
|
|
2023
|
+
type: "richText",
|
|
2024
|
+
label: "Body",
|
|
2025
|
+
description: "Write the full post content. Paragraphs are separated automatically.",
|
|
2026
|
+
required: true,
|
|
2027
|
+
ui: { richTextVariant: "media" }
|
|
2028
|
+
}
|
|
2029
|
+
],
|
|
2030
|
+
routing: {
|
|
2031
|
+
enabled: true,
|
|
2032
|
+
pattern: "/blog/{slug}",
|
|
2033
|
+
slugField: "slug",
|
|
2034
|
+
prefix: "/blog/"
|
|
2035
|
+
},
|
|
2036
|
+
labels: {
|
|
2037
|
+
singular: "Blog post",
|
|
2038
|
+
plural: "Blog posts"
|
|
2039
|
+
}
|
|
2040
|
+
};
|
|
2041
|
+
const EVENT_CONTENT_TYPE_SCHEMA = {
|
|
2042
|
+
version: 1,
|
|
2043
|
+
fields: [
|
|
2044
|
+
{
|
|
2045
|
+
id: "summary",
|
|
2046
|
+
type: "textarea",
|
|
2047
|
+
label: "Summary",
|
|
2048
|
+
description: "Short teaser shown in listings.",
|
|
2049
|
+
rows: 3
|
|
2050
|
+
},
|
|
2051
|
+
{
|
|
2052
|
+
id: "image",
|
|
2053
|
+
type: "media",
|
|
2054
|
+
label: "Listing image",
|
|
2055
|
+
description: "Optional image used for event cards and previews."
|
|
2056
|
+
},
|
|
2057
|
+
{
|
|
2058
|
+
id: "body",
|
|
2059
|
+
type: "richText",
|
|
2060
|
+
label: "Details",
|
|
2061
|
+
description: "Full event details and agenda."
|
|
2062
|
+
}
|
|
2063
|
+
],
|
|
2064
|
+
routing: {
|
|
2065
|
+
enabled: true,
|
|
2066
|
+
pattern: "/events/{slug}",
|
|
2067
|
+
slugField: "slug",
|
|
2068
|
+
prefix: "/events/",
|
|
2069
|
+
subroutes: [{
|
|
2070
|
+
kind: "event-occurrence",
|
|
2071
|
+
segmentType: "uuid-or-date",
|
|
2072
|
+
precedence: "subroute-first"
|
|
2073
|
+
}]
|
|
2074
|
+
},
|
|
2075
|
+
labels: {
|
|
2076
|
+
singular: "Event",
|
|
2077
|
+
plural: "Events"
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
const COURSE_CONTENT_TYPE_SCHEMA = {
|
|
2081
|
+
version: 1,
|
|
2082
|
+
fields: [
|
|
2083
|
+
{
|
|
2084
|
+
id: "summary",
|
|
2085
|
+
type: "textarea",
|
|
2086
|
+
label: "Summary",
|
|
2087
|
+
description: "Short teaser shown in listings.",
|
|
2088
|
+
rows: 3
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
id: "image",
|
|
2092
|
+
type: "media",
|
|
2093
|
+
label: "Listing image",
|
|
2094
|
+
description: "Optional image used for course cards and previews."
|
|
2095
|
+
},
|
|
2096
|
+
{
|
|
2097
|
+
id: "body",
|
|
2098
|
+
type: "richText",
|
|
2099
|
+
label: "Details",
|
|
2100
|
+
description: "Full course details and curriculum."
|
|
2101
|
+
}
|
|
2102
|
+
],
|
|
2103
|
+
routing: {
|
|
2104
|
+
enabled: true,
|
|
2105
|
+
pattern: "/courses/{slug}",
|
|
2106
|
+
slugField: "slug",
|
|
2107
|
+
prefix: "/courses/"
|
|
2108
|
+
},
|
|
2109
|
+
labels: {
|
|
2110
|
+
singular: "Course",
|
|
2111
|
+
plural: "Courses"
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
const TEAM_MEMBER_CONTENT_TYPE_SCHEMA = {
|
|
2115
|
+
version: 1,
|
|
2116
|
+
fields: [
|
|
2117
|
+
{
|
|
2118
|
+
id: "intro",
|
|
2119
|
+
type: "textarea",
|
|
2120
|
+
label: "Profile intro",
|
|
2121
|
+
description: "A short opening paragraph for the profile page.",
|
|
2122
|
+
rows: 3,
|
|
2123
|
+
maxLength: 360
|
|
2124
|
+
},
|
|
2125
|
+
{
|
|
2126
|
+
id: "bio",
|
|
2127
|
+
type: "richText",
|
|
2128
|
+
label: "Profile body",
|
|
2129
|
+
description: "Longer profile story, approach, experience, and background."
|
|
2130
|
+
},
|
|
2131
|
+
{
|
|
2132
|
+
id: "credentials",
|
|
2133
|
+
type: "textarea",
|
|
2134
|
+
label: "Credentials and training",
|
|
2135
|
+
description: "Qualifications, training, or experience to highlight on the page.",
|
|
2136
|
+
rows: 4
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
id: "specialties",
|
|
2140
|
+
type: "textarea",
|
|
2141
|
+
label: "Specialties and focus areas",
|
|
2142
|
+
description: "Areas this team member works with most often.",
|
|
2143
|
+
rows: 4
|
|
2144
|
+
},
|
|
2145
|
+
{
|
|
2146
|
+
id: "pullQuote",
|
|
2147
|
+
type: "textarea",
|
|
2148
|
+
label: "Pull quote",
|
|
2149
|
+
description: "Optional quote or short statement to feature on the page.",
|
|
2150
|
+
rows: 2,
|
|
2151
|
+
maxLength: 240
|
|
2152
|
+
},
|
|
2153
|
+
{
|
|
2154
|
+
id: "ctaLabel",
|
|
2155
|
+
type: "text",
|
|
2156
|
+
label: "Call-to-action label",
|
|
2157
|
+
description: "Optional button label for the profile page.",
|
|
2158
|
+
maxLength: 80
|
|
2159
|
+
},
|
|
2160
|
+
{
|
|
2161
|
+
id: "ctaHref",
|
|
2162
|
+
type: "url",
|
|
2163
|
+
label: "Call-to-action link",
|
|
2164
|
+
description: "Optional internal or external link for the profile page button.",
|
|
2165
|
+
allowRelative: true
|
|
2166
|
+
}
|
|
2167
|
+
],
|
|
2168
|
+
routing: {
|
|
2169
|
+
enabled: true,
|
|
2170
|
+
pattern: "/team/{slug}",
|
|
2171
|
+
slugField: "slug",
|
|
2172
|
+
prefix: "/team/"
|
|
2173
|
+
},
|
|
2174
|
+
labels: {
|
|
2175
|
+
singular: "Team member",
|
|
2176
|
+
plural: "Team members"
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
const PRODUCT_CONTENT_TYPE_SCHEMA = {
|
|
2180
|
+
version: 1,
|
|
2181
|
+
fields: [
|
|
2182
|
+
{
|
|
2183
|
+
id: "summary",
|
|
2184
|
+
type: "textarea",
|
|
2185
|
+
label: "Summary",
|
|
2186
|
+
description: "Short teaser shown in listings.",
|
|
2187
|
+
rows: 3
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
id: "gallery",
|
|
2191
|
+
type: "media",
|
|
2192
|
+
label: "Listing image",
|
|
2193
|
+
description: "Optional image used for product cards and previews."
|
|
2194
|
+
},
|
|
2195
|
+
{
|
|
2196
|
+
id: "body",
|
|
2197
|
+
type: "richText",
|
|
2198
|
+
label: "Details",
|
|
2199
|
+
description: "Full product details and buying notes."
|
|
2200
|
+
}
|
|
2201
|
+
],
|
|
2202
|
+
routing: {
|
|
2203
|
+
enabled: true,
|
|
2204
|
+
pattern: "/products/{slug}",
|
|
2205
|
+
slugField: "slug",
|
|
2206
|
+
prefix: "/products/"
|
|
2207
|
+
},
|
|
2208
|
+
labels: {
|
|
2209
|
+
singular: "Product",
|
|
2210
|
+
plural: "Products"
|
|
2211
|
+
}
|
|
2212
|
+
};
|
|
2213
|
+
const TESTIMONIAL_CONTENT_TYPE_SCHEMA = {
|
|
2214
|
+
version: 1,
|
|
2215
|
+
fields: [
|
|
2216
|
+
{
|
|
2217
|
+
id: "name",
|
|
2218
|
+
type: "text",
|
|
2219
|
+
label: "Name",
|
|
2220
|
+
description: "Who provided this testimonial?",
|
|
2221
|
+
required: true,
|
|
2222
|
+
maxLength: 120
|
|
2223
|
+
},
|
|
2224
|
+
{
|
|
2225
|
+
id: "jobTitle",
|
|
2226
|
+
type: "text",
|
|
2227
|
+
label: "Job title",
|
|
2228
|
+
description: "Optional title or role for the person.",
|
|
2229
|
+
maxLength: 120
|
|
2230
|
+
},
|
|
2231
|
+
{
|
|
2232
|
+
id: "company",
|
|
2233
|
+
type: "text",
|
|
2234
|
+
label: "Company",
|
|
2235
|
+
description: "Optional company or organisation.",
|
|
2236
|
+
maxLength: 120
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
id: "headshot",
|
|
2240
|
+
type: "media",
|
|
2241
|
+
label: "Headshot",
|
|
2242
|
+
description: "Optional portrait or logo for this testimonial.",
|
|
2243
|
+
mediaKinds: ["image"]
|
|
2244
|
+
},
|
|
2245
|
+
{
|
|
2246
|
+
id: "body",
|
|
2247
|
+
type: "richText",
|
|
2248
|
+
label: "Testimonial",
|
|
2249
|
+
description: "Full testimonial text.",
|
|
2250
|
+
required: true,
|
|
2251
|
+
ui: { richTextVariant: "limited" }
|
|
2252
|
+
}
|
|
2253
|
+
],
|
|
2254
|
+
routing: { enabled: false },
|
|
2255
|
+
titleField: "name",
|
|
2256
|
+
labels: {
|
|
2257
|
+
singular: "Testimonial",
|
|
2258
|
+
plural: "Testimonials"
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
const PAGE_CONTENT_TYPE_SCHEMA = {
|
|
2262
|
+
version: 1,
|
|
2263
|
+
fields: [],
|
|
2264
|
+
routing: {
|
|
2265
|
+
enabled: true,
|
|
2266
|
+
pattern: "/{slug}",
|
|
2267
|
+
slugField: "slug",
|
|
2268
|
+
prefix: "/"
|
|
2269
|
+
},
|
|
2270
|
+
labels: {
|
|
2271
|
+
singular: "Page",
|
|
2272
|
+
plural: "Pages"
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
const PRODUCT_CATEGORY_CONTENT_TYPE_SCHEMA = {
|
|
2276
|
+
version: 1,
|
|
2277
|
+
fields: [
|
|
2278
|
+
{
|
|
2279
|
+
id: "summary",
|
|
2280
|
+
type: "textarea",
|
|
2281
|
+
label: "Summary",
|
|
2282
|
+
description: "Short teaser shown above category product lists.",
|
|
2283
|
+
rows: 3
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
id: "heroImage",
|
|
2287
|
+
type: "media",
|
|
2288
|
+
label: "Hero image",
|
|
2289
|
+
description: "Optional category landing page image."
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
id: "body",
|
|
2293
|
+
type: "richText",
|
|
2294
|
+
label: "Body",
|
|
2295
|
+
description: "Long-form category landing page content."
|
|
2296
|
+
}
|
|
2297
|
+
],
|
|
2298
|
+
routing: {
|
|
2299
|
+
enabled: true,
|
|
2300
|
+
pattern: "/shop/{slug}",
|
|
2301
|
+
slugField: "slug",
|
|
2302
|
+
prefix: "/shop/"
|
|
2303
|
+
},
|
|
2304
|
+
labels: {
|
|
2305
|
+
singular: "Product category",
|
|
2306
|
+
plural: "Product categories"
|
|
2307
|
+
}
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
//#endregion
|
|
2311
|
+
//#region ../content-model/src/content-types/builtins.ts
|
|
2312
|
+
/**
|
|
2313
|
+
* Built-in `ContentTypeSchema` registry. Keyed exhaustively on
|
|
2314
|
+
* `BuiltinContentTypeKey` via `satisfies` — adding a new builtin key
|
|
2315
|
+
* fails the compile here until a schema is provided. The `as const`
|
|
2316
|
+
* import preserves field literals so `BUILTIN_CONTENT_TYPE_FIELD_IDS`
|
|
2317
|
+
* below can derive an accurate per-builtin field set.
|
|
2318
|
+
*/
|
|
2319
|
+
const BUILTIN_CONTENT_TYPE_SCHEMAS = {
|
|
2320
|
+
post: POST_CONTENT_TYPE_SCHEMA,
|
|
2321
|
+
event: EVENT_CONTENT_TYPE_SCHEMA,
|
|
2322
|
+
course: COURSE_CONTENT_TYPE_SCHEMA,
|
|
2323
|
+
"team-member": TEAM_MEMBER_CONTENT_TYPE_SCHEMA,
|
|
2324
|
+
product: PRODUCT_CONTENT_TYPE_SCHEMA,
|
|
2325
|
+
testimonial: TESTIMONIAL_CONTENT_TYPE_SCHEMA,
|
|
2326
|
+
page: PAGE_CONTENT_TYPE_SCHEMA,
|
|
2327
|
+
"product-category": PRODUCT_CATEGORY_CONTENT_TYPE_SCHEMA
|
|
2328
|
+
};
|
|
2329
|
+
function deriveFieldIdSet(schema) {
|
|
2330
|
+
return new Set(schema.fields.map((f) => f.id));
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Field-id sets for every built-in content type — DERIVED from
|
|
2334
|
+
* `BUILTIN_CONTENT_TYPE_SCHEMAS`, so adding a field to a schema flows
|
|
2335
|
+
* through automatically. The previous hand-maintained registry left
|
|
2336
|
+
* drift to a snapshot test; now drift is impossible.
|
|
2337
|
+
*
|
|
2338
|
+
* Kept exported with the same shape consumers were used to.
|
|
2339
|
+
*/
|
|
2340
|
+
const BUILTIN_CONTENT_TYPE_FIELD_IDS = {
|
|
2341
|
+
post: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.post),
|
|
2342
|
+
event: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.event),
|
|
2343
|
+
course: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.course),
|
|
2344
|
+
"team-member": deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS["team-member"]),
|
|
2345
|
+
product: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.product),
|
|
2346
|
+
testimonial: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.testimonial),
|
|
2347
|
+
page: deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS.page),
|
|
2348
|
+
"product-category": deriveFieldIdSet(BUILTIN_CONTENT_TYPE_SCHEMAS["product-category"])
|
|
2349
|
+
};
|
|
2350
|
+
const DOMAIN_BACKED_LINKED_ENTRY_REASON = "Domain-backed entries need a dedicated editor workflow.";
|
|
2351
|
+
/**
|
|
2352
|
+
* Preview editing support for built-in content entries when they appear as
|
|
2353
|
+
* linked data inside a block. Generic editorial content can use the shared
|
|
2354
|
+
* content-entry form; domain-backed content stays closed until its workflow
|
|
2355
|
+
* can preserve domain invariants such as occurrences, booking, or commerce.
|
|
2356
|
+
*/
|
|
2357
|
+
const BUILTIN_PREVIEW_LINKED_ENTRY_EDITING = {
|
|
2358
|
+
post: { kind: "generic" },
|
|
2359
|
+
event: {
|
|
2360
|
+
kind: "unsupported-domain-backed",
|
|
2361
|
+
reason: DOMAIN_BACKED_LINKED_ENTRY_REASON
|
|
2362
|
+
},
|
|
2363
|
+
course: {
|
|
2364
|
+
kind: "unsupported-domain-backed",
|
|
2365
|
+
reason: DOMAIN_BACKED_LINKED_ENTRY_REASON
|
|
2366
|
+
},
|
|
2367
|
+
"team-member": { kind: "generic" },
|
|
2368
|
+
product: {
|
|
2369
|
+
kind: "unsupported-domain-backed",
|
|
2370
|
+
reason: DOMAIN_BACKED_LINKED_ENTRY_REASON
|
|
2371
|
+
},
|
|
2372
|
+
testimonial: { kind: "generic" },
|
|
2373
|
+
page: { kind: "generic" },
|
|
2374
|
+
"product-category": {
|
|
2375
|
+
kind: "unsupported-domain-backed",
|
|
2376
|
+
reason: DOMAIN_BACKED_LINKED_ENTRY_REASON
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
function getBuiltinPreviewLinkedEntryEditing(key) {
|
|
2380
|
+
return BUILTIN_PREVIEW_LINKED_ENTRY_EDITING[key] ?? null;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
//#endregion
|
|
2384
|
+
//#region ../content-model/src/content-types/labels.ts
|
|
2385
|
+
function getContentTypeLabelsFromSchema(schema, _fallbackName) {
|
|
2386
|
+
return {
|
|
2387
|
+
singular: schema.labels.singular,
|
|
2388
|
+
plural: schema.labels.plural
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
//#endregion
|
|
2393
|
+
//#region ../field-primitives/src/bindings/index.ts
|
|
2394
|
+
const BINDING_TRANSFORMS = {
|
|
2395
|
+
RICHTEXT_FIRST_PARAGRAPH: "richtext.firstParagraph",
|
|
2396
|
+
DATE_FORMAT_SHORT: "date.formatShort",
|
|
2397
|
+
DATE_FORMAT_LONG: "date.formatLong",
|
|
2398
|
+
TEXT_INLINE_TEXT: "text.inlineText"
|
|
2399
|
+
};
|
|
2400
|
+
const BINDING_TRANSFORM_VALUES = Object.values(BINDING_TRANSFORMS);
|
|
2401
|
+
|
|
2402
|
+
//#endregion
|
|
2403
|
+
//#region ../content-model/src/templates/types.ts
|
|
2404
|
+
const TEMPLATE_BINDING_TRANSFORMS = BINDING_TRANSFORMS;
|
|
2405
|
+
|
|
2406
|
+
//#endregion
|
|
2407
|
+
//#region ../content-model/src/templates/bindingBuilders.ts
|
|
2408
|
+
function fieldBinding(path) {
|
|
2409
|
+
return {
|
|
2410
|
+
source: "field",
|
|
2411
|
+
path
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
function metaBinding(path) {
|
|
2415
|
+
return {
|
|
2416
|
+
source: "meta",
|
|
2417
|
+
path
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
function literalBinding(value) {
|
|
2421
|
+
return {
|
|
2422
|
+
source: "literal",
|
|
2423
|
+
value
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
function transformBinding(transform, options = {}) {
|
|
2427
|
+
return {
|
|
2428
|
+
source: "transform",
|
|
2429
|
+
transform,
|
|
2430
|
+
...options.input === undefined ? {} : { input: options.input },
|
|
2431
|
+
...options.path === undefined ? {} : { path: options.path }
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
function fallbackBinding(...options) {
|
|
2435
|
+
return {
|
|
2436
|
+
source: "fallback",
|
|
2437
|
+
options: [...options]
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
//#endregion
|
|
2442
|
+
//#region ../content-model/src/entry-slots/types.ts
|
|
2443
|
+
function ok(value) {
|
|
2444
|
+
return {
|
|
2445
|
+
ok: true,
|
|
2446
|
+
value
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
function err(error) {
|
|
2450
|
+
return {
|
|
2451
|
+
ok: false,
|
|
2452
|
+
error
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2456
|
+
const SLOT_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
2457
|
+
function unsafeAsSlotKey(value) {
|
|
2458
|
+
return value;
|
|
2459
|
+
}
|
|
2460
|
+
function unsafeAsEntryBlockSlotId(value) {
|
|
2461
|
+
return value;
|
|
2462
|
+
}
|
|
2463
|
+
function unsafeAsEntrySlotContentEntryId(value) {
|
|
2464
|
+
return value;
|
|
2465
|
+
}
|
|
2466
|
+
function unsafeAsEntrySlotPageSurfaceId(value) {
|
|
2467
|
+
return value;
|
|
2468
|
+
}
|
|
2469
|
+
function unsafeAsPageSurfaceSlotId(value) {
|
|
2470
|
+
return value;
|
|
2471
|
+
}
|
|
2472
|
+
function unsafeAsEntrySlotPageId(value) {
|
|
2473
|
+
return value;
|
|
2474
|
+
}
|
|
2475
|
+
function unsafeAsEntrySlotBlockId(value) {
|
|
2476
|
+
return value;
|
|
2477
|
+
}
|
|
2478
|
+
function unsafeAsEntrySlotContentVersionId(value) {
|
|
2479
|
+
return value;
|
|
2480
|
+
}
|
|
2481
|
+
function unsafeAsEntrySlotBlockOrderIndex(value) {
|
|
2482
|
+
return value;
|
|
2483
|
+
}
|
|
2484
|
+
function parseSlotKey(value) {
|
|
2485
|
+
if (typeof value !== "string") return err({ kind: "not-string" });
|
|
2486
|
+
const normalized = value.trim();
|
|
2487
|
+
if (!normalized) return err({ kind: "empty" });
|
|
2488
|
+
if (!SLOT_KEY_PATTERN.test(normalized)) {
|
|
2489
|
+
return err({
|
|
2490
|
+
kind: "invalid-format",
|
|
2491
|
+
value: normalized
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
return ok(unsafeAsSlotKey(normalized));
|
|
2495
|
+
}
|
|
2496
|
+
function parseEntryBlockSlotId(value) {
|
|
2497
|
+
return parseUuidBrand(value, unsafeAsEntryBlockSlotId);
|
|
2498
|
+
}
|
|
2499
|
+
function parseEntrySlotPageSurfaceId(value) {
|
|
2500
|
+
return parseUuidBrand(value, unsafeAsEntrySlotPageSurfaceId);
|
|
2501
|
+
}
|
|
2502
|
+
function parsePageSurfaceSlotId(value) {
|
|
2503
|
+
return parseUuidBrand(value, unsafeAsPageSurfaceSlotId);
|
|
2504
|
+
}
|
|
2505
|
+
function parseUuidBrand(value, brand) {
|
|
2506
|
+
if (typeof value !== "string") return err({ kind: "not-string" });
|
|
2507
|
+
const normalized = value.trim().toLowerCase();
|
|
2508
|
+
if (!normalized) return err({ kind: "empty" });
|
|
2509
|
+
if (!UUID_PATTERN.test(normalized)) {
|
|
2510
|
+
return err({
|
|
2511
|
+
kind: "invalid-uuid",
|
|
2512
|
+
value: normalized
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
return ok(brand(normalized));
|
|
2516
|
+
}
|
|
2517
|
+
function parseNonEmptyStringBrand(value, brand) {
|
|
2518
|
+
if (typeof value !== "string") return err({ kind: "not-string" });
|
|
2519
|
+
const normalized = value.trim();
|
|
2520
|
+
if (!normalized) return err({ kind: "empty" });
|
|
2521
|
+
return ok(brand(normalized));
|
|
2522
|
+
}
|
|
2523
|
+
const SYSTEM_ENTRY_SLOT_BLOCK_KIND = "system.entry-slot";
|
|
2524
|
+
const DEFAULT_ENTRY_SLOT_ALLOWED_BLOCKS = [
|
|
2525
|
+
"block.body-text",
|
|
2526
|
+
"block.rich-text",
|
|
2527
|
+
"block.image-gallery",
|
|
2528
|
+
"block.cta-full"
|
|
2529
|
+
];
|
|
2530
|
+
const TEMPLATE_ONLY_BLOCK_KINDS = [
|
|
2531
|
+
SYSTEM_ENTRY_SLOT_BLOCK_KIND,
|
|
2532
|
+
"block.event-details",
|
|
2533
|
+
"block.event-registration"
|
|
2534
|
+
];
|
|
2535
|
+
function parseEntrySlotDefinitions(template) {
|
|
2536
|
+
const definitions = [];
|
|
2537
|
+
const seen = new Map();
|
|
2538
|
+
for (const [blockIndex, block] of template.blocks.entries()) {
|
|
2539
|
+
if (!isEntrySlotTemplateBlock(block)) continue;
|
|
2540
|
+
const parsed = parseEntrySlotDefinition(block, blockIndex);
|
|
2541
|
+
if (!parsed.ok) return parsed;
|
|
2542
|
+
const firstBlockIndex = seen.get(parsed.value.slotKey);
|
|
2543
|
+
if (firstBlockIndex !== undefined) {
|
|
2544
|
+
return err({
|
|
2545
|
+
kind: "duplicate-slot-key",
|
|
2546
|
+
slotKey: parsed.value.slotKey,
|
|
2547
|
+
firstBlockIndex,
|
|
2548
|
+
blockIndex
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
seen.set(parsed.value.slotKey, blockIndex);
|
|
2552
|
+
definitions.push(parsed.value);
|
|
2553
|
+
}
|
|
2554
|
+
return ok(definitions);
|
|
2555
|
+
}
|
|
2556
|
+
function isEntrySlotTemplateBlock(block) {
|
|
2557
|
+
return block.blockKind === SYSTEM_ENTRY_SLOT_BLOCK_KIND;
|
|
2558
|
+
}
|
|
2559
|
+
function parseEntrySlotDefinition(block, blockIndex) {
|
|
2560
|
+
if (block.scope !== "template") {
|
|
2561
|
+
return err({
|
|
2562
|
+
kind: "invalid-slot-scope",
|
|
2563
|
+
blockIndex,
|
|
2564
|
+
scope: block.scope
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
if (!isRecord$2(block.content)) {
|
|
2568
|
+
return err({
|
|
2569
|
+
kind: "invalid-slot-content",
|
|
2570
|
+
blockIndex
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
const slotKeyResult = parseSlotKey(block.content.slotKey);
|
|
2574
|
+
if (!slotKeyResult.ok) {
|
|
2575
|
+
return err({
|
|
2576
|
+
kind: "invalid-slot-key",
|
|
2577
|
+
blockIndex,
|
|
2578
|
+
error: slotKeyResult.error
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
const label = typeof block.content.label === "string" ? block.content.label.trim() : "";
|
|
2582
|
+
if (!label) {
|
|
2583
|
+
return err({
|
|
2584
|
+
kind: "missing-label",
|
|
2585
|
+
blockIndex,
|
|
2586
|
+
slotKey: slotKeyResult.value
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
const allowedBlocks = parseAllowedBlocks(block.content.allowedBlocks, slotKeyResult.value, blockIndex);
|
|
2590
|
+
if (!allowedBlocks.ok) return allowedBlocks;
|
|
2591
|
+
return ok({
|
|
2592
|
+
slotKey: slotKeyResult.value,
|
|
2593
|
+
label,
|
|
2594
|
+
allowedBlocks: allowedBlocks.value
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
function parseAllowedBlocks(value, slotKey, blockIndex) {
|
|
2598
|
+
if (value === undefined) {
|
|
2599
|
+
return ok({
|
|
2600
|
+
kind: "default",
|
|
2601
|
+
blockKinds: DEFAULT_ENTRY_SLOT_ALLOWED_BLOCKS
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
if (!Array.isArray(value)) {
|
|
2605
|
+
return err({
|
|
2606
|
+
kind: "invalid-allowed-blocks",
|
|
2607
|
+
blockIndex,
|
|
2608
|
+
slotKey
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
const allowedBlockValues = value;
|
|
2612
|
+
if (allowedBlockValues.length === 0) {
|
|
2613
|
+
return ok({
|
|
2614
|
+
kind: "none",
|
|
2615
|
+
blockKinds: []
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
const blockKinds = [];
|
|
2619
|
+
const seen = new Set();
|
|
2620
|
+
for (const blockKind of allowedBlockValues) {
|
|
2621
|
+
if (typeof blockKind !== "string" || !blockKind.trim()) {
|
|
2622
|
+
return err({
|
|
2623
|
+
kind: "invalid-allowed-block-kind",
|
|
2624
|
+
blockIndex,
|
|
2625
|
+
slotKey,
|
|
2626
|
+
value: blockKind
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
const normalized = blockKind.trim();
|
|
2630
|
+
if (isTemplateOnlyBlockKind(normalized)) {
|
|
2631
|
+
return err({
|
|
2632
|
+
kind: "template-only-block-kind",
|
|
2633
|
+
blockIndex,
|
|
2634
|
+
slotKey,
|
|
2635
|
+
blockKind: normalized
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
if (seen.has(normalized)) {
|
|
2639
|
+
return err({
|
|
2640
|
+
kind: "duplicate-allowed-block-kind",
|
|
2641
|
+
blockIndex,
|
|
2642
|
+
slotKey,
|
|
2643
|
+
blockKind: normalized
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
seen.add(normalized);
|
|
2647
|
+
blockKinds.push(normalized);
|
|
2648
|
+
}
|
|
2649
|
+
return ok({
|
|
2650
|
+
kind: "restricted",
|
|
2651
|
+
blockKinds
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
function validateBlockKindForEntrySlot(definition, blockKind) {
|
|
2655
|
+
const normalized = blockKind.trim();
|
|
2656
|
+
if (isTemplateOnlyBlockKind(normalized)) {
|
|
2657
|
+
return err({
|
|
2658
|
+
kind: "template-only-block-kind",
|
|
2659
|
+
blockKind: normalized
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
if (definition.allowedBlocks.kind === "none") {
|
|
2663
|
+
return err({
|
|
2664
|
+
kind: "block-kind-not-allowed",
|
|
2665
|
+
blockKind: normalized,
|
|
2666
|
+
slotKey: definition.slotKey
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
if (!definition.allowedBlocks.blockKinds.includes(normalized)) {
|
|
2670
|
+
return err({
|
|
2671
|
+
kind: "block-kind-not-allowed",
|
|
2672
|
+
blockKind: normalized,
|
|
2673
|
+
slotKey: definition.slotKey
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
return ok(normalized);
|
|
2677
|
+
}
|
|
2678
|
+
function isTemplateOnlyBlockKind(blockKind) {
|
|
2679
|
+
return blockKind.startsWith("system.") || TEMPLATE_ONLY_BLOCK_KINDS.includes(blockKind);
|
|
2680
|
+
}
|
|
2681
|
+
function resolveEntrySlotVisibility(input) {
|
|
2682
|
+
const storedByKey = new Map();
|
|
2683
|
+
for (const slot of input.storedSlots) {
|
|
2684
|
+
storedByKey.set(slot.slotKey, slot);
|
|
2685
|
+
}
|
|
2686
|
+
const visible = input.definitions.map((definition) => ({
|
|
2687
|
+
kind: "defined",
|
|
2688
|
+
definition,
|
|
2689
|
+
entrySlot: storedByKey.get(definition.slotKey) ?? null
|
|
2690
|
+
}));
|
|
2691
|
+
const definedKeys = new Set(input.definitions.map((definition) => definition.slotKey));
|
|
2692
|
+
for (const slot of input.storedSlots) {
|
|
2693
|
+
if (!definedKeys.has(slot.slotKey)) {
|
|
2694
|
+
visible.push({
|
|
2695
|
+
kind: "orphaned",
|
|
2696
|
+
slotKey: slot.slotKey,
|
|
2697
|
+
entrySlot: slot
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
return visible;
|
|
2702
|
+
}
|
|
2703
|
+
function resolveEntryPageCompositionState(slot) {
|
|
2704
|
+
if (slot.kind === "orphaned") {
|
|
2705
|
+
return {
|
|
2706
|
+
kind: "invalid",
|
|
2707
|
+
reason: "orphaned-slot"
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
if (!slot.entrySlot) return { kind: "absent" };
|
|
2711
|
+
const { draftCompositionVersionId, publishedCompositionVersionId } = slot.entrySlot;
|
|
2712
|
+
if (draftCompositionVersionId && publishedCompositionVersionId) {
|
|
2713
|
+
return {
|
|
2714
|
+
kind: "draft-and-published",
|
|
2715
|
+
draftCompositionVersionId,
|
|
2716
|
+
publishedCompositionVersionId
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
if (draftCompositionVersionId) return {
|
|
2720
|
+
kind: "draft-only",
|
|
2721
|
+
draftCompositionVersionId
|
|
2722
|
+
};
|
|
2723
|
+
if (publishedCompositionVersionId) return {
|
|
2724
|
+
kind: "published-only",
|
|
2725
|
+
publishedCompositionVersionId
|
|
2726
|
+
};
|
|
2727
|
+
return { kind: "absent" };
|
|
2728
|
+
}
|
|
2729
|
+
function selectBlocksForDeclaredEntrySlot(input) {
|
|
2730
|
+
return input.entrySlots.find((slot) => slot.slotKey === input.declaredSlotKey)?.blocks ?? [];
|
|
2731
|
+
}
|
|
2732
|
+
function buildDraftEntrySlotCompositionVersionData(input) {
|
|
2733
|
+
return {
|
|
2734
|
+
schemaVersion: 1,
|
|
2735
|
+
stage: "draft",
|
|
2736
|
+
...input
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
function entrySlotCompositionOwnerFromV1(versionData) {
|
|
2740
|
+
return {
|
|
2741
|
+
kind: "entrySlot",
|
|
2742
|
+
entrySlotId: versionData.entrySlotId,
|
|
2743
|
+
contentEntryId: versionData.contentEntryId
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
function buildDraftSlotCompositionVersionData(input) {
|
|
2747
|
+
return {
|
|
2748
|
+
schemaVersion: 2,
|
|
2749
|
+
stage: "draft",
|
|
2750
|
+
...input
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
function buildPublishedEntrySlotCompositionVersionData(input) {
|
|
2754
|
+
const published = filterPublishedSlotBlocks(input.blockSequence);
|
|
2755
|
+
if (!published.ok) return published;
|
|
2756
|
+
return ok({
|
|
2757
|
+
schemaVersion: 1,
|
|
2758
|
+
stage: "published",
|
|
2759
|
+
entrySlotId: input.entrySlotId,
|
|
2760
|
+
contentEntryId: input.contentEntryId,
|
|
2761
|
+
slotKey: input.slotKey,
|
|
2762
|
+
blockSequence: published.value
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
function buildPublishedSlotCompositionVersionData(input) {
|
|
2766
|
+
const published = filterPublishedSlotBlocks(input.blockSequence);
|
|
2767
|
+
if (!published.ok) return published;
|
|
2768
|
+
return ok({
|
|
2769
|
+
schemaVersion: 2,
|
|
2770
|
+
stage: "published",
|
|
2771
|
+
owner: input.owner,
|
|
2772
|
+
slotKey: input.slotKey,
|
|
2773
|
+
blockSequence: published.value
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
function filterPublishedSlotBlocks(blockSequence) {
|
|
2777
|
+
for (const block of blockSequence) {
|
|
2778
|
+
if (block.kind !== "published-block-content") {
|
|
2779
|
+
return err({
|
|
2780
|
+
kind: "published-composition-contains-unpublished-block",
|
|
2781
|
+
blockId: block.blockId,
|
|
2782
|
+
snapshotKind: block.kind
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
return ok(blockSequence.filter((block) => block.kind === "published-block-content"));
|
|
2787
|
+
}
|
|
2788
|
+
function parseEntrySlotCompositionVersionData(input) {
|
|
2789
|
+
if (!isRecord$2(input)) return err({ kind: "not-object" });
|
|
2790
|
+
if (input.schemaVersion !== 1) {
|
|
2791
|
+
return err({
|
|
2792
|
+
kind: "unsupported-schema-version",
|
|
2793
|
+
schemaVersion: input.schemaVersion
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
const stage = parseSlotCompositionStage(input.stage);
|
|
2797
|
+
if (!stage.ok) {
|
|
2798
|
+
return err({
|
|
2799
|
+
kind: "invalid-stage",
|
|
2800
|
+
stage: input.stage
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
const entrySlotId = parseEntryBlockSlotId(input.entrySlotId);
|
|
2804
|
+
if (!entrySlotId.ok) return err({
|
|
2805
|
+
kind: "invalid-entry-slot-id",
|
|
2806
|
+
error: entrySlotId.error
|
|
2807
|
+
});
|
|
2808
|
+
const contentEntryId = parseNonEmptyStringBrand(input.contentEntryId, unsafeAsEntrySlotContentEntryId);
|
|
2809
|
+
if (!contentEntryId.ok) return err({
|
|
2810
|
+
kind: "invalid-content-entry-id",
|
|
2811
|
+
error: contentEntryId.error
|
|
2812
|
+
});
|
|
2813
|
+
const slotKey = parseSlotKey(input.slotKey);
|
|
2814
|
+
if (!slotKey.ok) return err({
|
|
2815
|
+
kind: "invalid-slot-key",
|
|
2816
|
+
error: slotKey.error
|
|
2817
|
+
});
|
|
2818
|
+
const parsedBlockSequence = parseSlotCompositionBlockSequence(stage.value, input.blockSequence);
|
|
2819
|
+
if (!parsedBlockSequence.ok) return parsedBlockSequence;
|
|
2820
|
+
if (parsedBlockSequence.value.stage === "draft") {
|
|
2821
|
+
return ok(buildDraftEntrySlotCompositionVersionData({
|
|
2822
|
+
entrySlotId: entrySlotId.value,
|
|
2823
|
+
contentEntryId: contentEntryId.value,
|
|
2824
|
+
slotKey: slotKey.value,
|
|
2825
|
+
blockSequence: parsedBlockSequence.value.blockSequence
|
|
2826
|
+
}));
|
|
2827
|
+
}
|
|
2828
|
+
return ok({
|
|
2829
|
+
schemaVersion: 1,
|
|
2830
|
+
stage: "published",
|
|
2831
|
+
entrySlotId: entrySlotId.value,
|
|
2832
|
+
contentEntryId: contentEntryId.value,
|
|
2833
|
+
slotKey: slotKey.value,
|
|
2834
|
+
blockSequence: parsedBlockSequence.value.blockSequence
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
function parseSlotCompositionVersionData(input) {
|
|
2838
|
+
if (!isRecord$2(input)) return err({ kind: "not-object" });
|
|
2839
|
+
if (input.schemaVersion === 1) {
|
|
2840
|
+
return parseEntrySlotCompositionVersionData(input);
|
|
2841
|
+
}
|
|
2842
|
+
if (input.schemaVersion !== 2) {
|
|
2843
|
+
return err({
|
|
2844
|
+
kind: "unsupported-schema-version",
|
|
2845
|
+
schemaVersion: input.schemaVersion
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
const stage = parseSlotCompositionStage(input.stage);
|
|
2849
|
+
if (!stage.ok) {
|
|
2850
|
+
return err({
|
|
2851
|
+
kind: "invalid-stage",
|
|
2852
|
+
stage: input.stage
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
const owner = parseSlotCompositionOwner(input.owner);
|
|
2856
|
+
if (!owner.ok) return err({
|
|
2857
|
+
kind: "invalid-owner",
|
|
2858
|
+
error: owner.error
|
|
2859
|
+
});
|
|
2860
|
+
const slotKey = parseSlotKey(input.slotKey);
|
|
2861
|
+
if (!slotKey.ok) return err({
|
|
2862
|
+
kind: "invalid-slot-key",
|
|
2863
|
+
error: slotKey.error
|
|
2864
|
+
});
|
|
2865
|
+
const parsedBlockSequence = parseSlotCompositionBlockSequence(stage.value, input.blockSequence);
|
|
2866
|
+
if (!parsedBlockSequence.ok) return parsedBlockSequence;
|
|
2867
|
+
if (parsedBlockSequence.value.stage === "draft") {
|
|
2868
|
+
return ok(buildDraftSlotCompositionVersionData({
|
|
2869
|
+
owner: owner.value,
|
|
2870
|
+
slotKey: slotKey.value,
|
|
2871
|
+
blockSequence: parsedBlockSequence.value.blockSequence
|
|
2872
|
+
}));
|
|
2873
|
+
}
|
|
2874
|
+
return ok({
|
|
2875
|
+
schemaVersion: 2,
|
|
2876
|
+
stage: "published",
|
|
2877
|
+
owner: owner.value,
|
|
2878
|
+
slotKey: slotKey.value,
|
|
2879
|
+
blockSequence: parsedBlockSequence.value.blockSequence
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
function parseSlotCompositionStage(value) {
|
|
2883
|
+
return value === "draft" || value === "published" ? ok(value) : err("invalid");
|
|
2884
|
+
}
|
|
2885
|
+
function parseSlotCompositionBlockSequence(stage, value) {
|
|
2886
|
+
if (!Array.isArray(value)) return err({ kind: "invalid-block-sequence" });
|
|
2887
|
+
const blockSequence = [];
|
|
2888
|
+
const publishedBlockSequence = [];
|
|
2889
|
+
for (const [index, snapshot] of value.entries()) {
|
|
2890
|
+
const parsed = parseEntrySlotBlockSnapshot(snapshot);
|
|
2891
|
+
if (!parsed.ok) return err({
|
|
2892
|
+
kind: "invalid-block-snapshot",
|
|
2893
|
+
index
|
|
2894
|
+
});
|
|
2895
|
+
if (stage === "published" && parsed.value.kind !== "published-block-content") {
|
|
2896
|
+
return err({
|
|
2897
|
+
kind: "published-composition-contains-unpublished-block",
|
|
2898
|
+
index
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
if (parsed.value.kind === "published-block-content") {
|
|
2902
|
+
publishedBlockSequence.push(parsed.value);
|
|
2903
|
+
}
|
|
2904
|
+
blockSequence.push(parsed.value);
|
|
2905
|
+
}
|
|
2906
|
+
return stage === "draft" ? ok({
|
|
2907
|
+
stage,
|
|
2908
|
+
blockSequence
|
|
2909
|
+
}) : ok({
|
|
2910
|
+
stage,
|
|
2911
|
+
blockSequence: publishedBlockSequence
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
function parseSlotCompositionOwner(input) {
|
|
2915
|
+
if (!isRecord$2(input)) return err({ kind: "not-object" });
|
|
2916
|
+
if (input.kind === "entrySlot") {
|
|
2917
|
+
const entrySlotId = parseEntryBlockSlotId(input.entrySlotId);
|
|
2918
|
+
if (!entrySlotId.ok) return err({
|
|
2919
|
+
kind: "invalid-entry-slot-id",
|
|
2920
|
+
error: entrySlotId.error
|
|
2921
|
+
});
|
|
2922
|
+
const contentEntryId = parseNonEmptyStringBrand(input.contentEntryId, unsafeAsEntrySlotContentEntryId);
|
|
2923
|
+
if (!contentEntryId.ok) return err({
|
|
2924
|
+
kind: "invalid-content-entry-id",
|
|
2925
|
+
error: contentEntryId.error
|
|
2926
|
+
});
|
|
2927
|
+
return ok({
|
|
2928
|
+
kind: "entrySlot",
|
|
2929
|
+
entrySlotId: entrySlotId.value,
|
|
2930
|
+
contentEntryId: contentEntryId.value
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
if (input.kind === "surfaceSlot") {
|
|
2934
|
+
const surfaceSlotId = parsePageSurfaceSlotId(input.surfaceSlotId);
|
|
2935
|
+
if (!surfaceSlotId.ok) return err({
|
|
2936
|
+
kind: "invalid-surface-slot-id",
|
|
2937
|
+
error: surfaceSlotId.error
|
|
2938
|
+
});
|
|
2939
|
+
const pageSurfaceId = parseEntrySlotPageSurfaceId(input.pageSurfaceId);
|
|
2940
|
+
if (!pageSurfaceId.ok) return err({
|
|
2941
|
+
kind: "invalid-page-surface-id",
|
|
2942
|
+
error: pageSurfaceId.error
|
|
2943
|
+
});
|
|
2944
|
+
return ok({
|
|
2945
|
+
kind: "surfaceSlot",
|
|
2946
|
+
surfaceSlotId: surfaceSlotId.value,
|
|
2947
|
+
pageSurfaceId: pageSurfaceId.value
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
return err({
|
|
2951
|
+
kind: "unsupported-owner-kind",
|
|
2952
|
+
ownerKind: input.kind
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
function parseEntrySlotBlockSnapshot(input) {
|
|
2956
|
+
if (!isRecord$2(input)) return err("invalid");
|
|
2957
|
+
const blockId = parseNonEmptyStringBrand(input.blockId, unsafeAsEntrySlotBlockId);
|
|
2958
|
+
const orderIndex = parseBlockOrderIndex(input.orderIndex);
|
|
2959
|
+
const blockKind = typeof input.blockKind === "string" && input.blockKind.trim() ? input.blockKind.trim() : null;
|
|
2960
|
+
const purpose = input.purpose === undefined ? "content" : typeof input.purpose === "string" && input.purpose.trim() ? input.purpose.trim() : null;
|
|
2961
|
+
const identifier = input.identifier === null || input.identifier === undefined ? null : typeof input.identifier === "string" ? input.identifier : undefined;
|
|
2962
|
+
if (!blockId.ok || !orderIndex.ok || !blockKind || !purpose || identifier === undefined) return err("invalid");
|
|
2963
|
+
if (input.kind === "published-block-content") {
|
|
2964
|
+
const contentVersionId = parseNonEmptyStringBrand(input.contentVersionId, unsafeAsEntrySlotContentVersionId);
|
|
2965
|
+
if (!contentVersionId.ok) return err("invalid");
|
|
2966
|
+
return ok({
|
|
2967
|
+
kind: "published-block-content",
|
|
2968
|
+
blockId: blockId.value,
|
|
2969
|
+
contentVersionId: contentVersionId.value,
|
|
2970
|
+
identifier,
|
|
2971
|
+
blockKind,
|
|
2972
|
+
purpose,
|
|
2973
|
+
orderIndex: orderIndex.value
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
if (input.kind === "draft-block-content") {
|
|
2977
|
+
const contentVersionId = input.contentVersionId === null ? ok(null) : parseNonEmptyStringBrand(input.contentVersionId, unsafeAsEntrySlotContentVersionId);
|
|
2978
|
+
if (!contentVersionId.ok) return err("invalid");
|
|
2979
|
+
return ok({
|
|
2980
|
+
kind: "draft-block-content",
|
|
2981
|
+
blockId: blockId.value,
|
|
2982
|
+
contentVersionId: contentVersionId.value,
|
|
2983
|
+
identifier,
|
|
2984
|
+
blockKind,
|
|
2985
|
+
purpose,
|
|
2986
|
+
orderIndex: orderIndex.value
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
if (input.kind === "missing-block-content" && (input.reason === "new-block" || input.reason === "deleted-content-version" || input.reason === "unpublished-block")) {
|
|
2990
|
+
return ok({
|
|
2991
|
+
kind: "missing-block-content",
|
|
2992
|
+
blockId: blockId.value,
|
|
2993
|
+
identifier,
|
|
2994
|
+
blockKind,
|
|
2995
|
+
purpose,
|
|
2996
|
+
orderIndex: orderIndex.value,
|
|
2997
|
+
reason: input.reason
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
return err("invalid");
|
|
3001
|
+
}
|
|
3002
|
+
function parseBlockOrderIndex(value) {
|
|
3003
|
+
if (!Number.isInteger(value) || typeof value !== "number" || value < 0) return err("invalid");
|
|
3004
|
+
return ok(unsafeAsEntrySlotBlockOrderIndex(value));
|
|
3005
|
+
}
|
|
3006
|
+
function blockContentVersionIdsForEntrySlotCompositionVersionData(versionData) {
|
|
3007
|
+
return blockContentVersionIdsForSlotCompositionVersionData(versionData);
|
|
3008
|
+
}
|
|
3009
|
+
function blockContentVersionIdsForSlotCompositionVersionData(versionData) {
|
|
3010
|
+
return versionData.blockSequence.flatMap((block) => block.kind === "missing-block-content" || block.contentVersionId === null ? [] : [block.contentVersionId]);
|
|
3011
|
+
}
|
|
3012
|
+
function planEntrySlotPublish(input) {
|
|
3013
|
+
if (input.slot.kind === "orphaned") {
|
|
3014
|
+
return {
|
|
3015
|
+
kind: "warning-only",
|
|
3016
|
+
slotKey: input.slot.slotKey,
|
|
3017
|
+
warning: "orphaned-slot-hidden"
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
const missingBlocks = input.draftBlockSequence.filter((block) => block.kind === "missing-block-content").map((block) => block.blockId);
|
|
3021
|
+
if (missingBlocks.length > 0) {
|
|
3022
|
+
return {
|
|
3023
|
+
kind: "blocked",
|
|
3024
|
+
slotKey: input.slot.definition.slotKey,
|
|
3025
|
+
reason: "missing-block-content",
|
|
3026
|
+
blockIds: missingBlocks
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
return {
|
|
3030
|
+
kind: "publishable",
|
|
3031
|
+
slotKey: input.slot.definition.slotKey,
|
|
3032
|
+
needsBlockContentPublish: input.draftBlockSequence.some((block) => block.kind === "draft-block-content")
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
function planEntrySlotDiscard(slot) {
|
|
3036
|
+
return slot.kind === "orphaned" ? {
|
|
3037
|
+
kind: "skip-hidden-orphan",
|
|
3038
|
+
slotKey: slot.slotKey
|
|
3039
|
+
} : {
|
|
3040
|
+
kind: "discardable",
|
|
3041
|
+
slotKey: slot.definition.slotKey
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
function parseSdkEntryBlocksPatch(blocks) {
|
|
3045
|
+
if (blocks === undefined) return ok({ kind: "leave-unchanged" });
|
|
3046
|
+
if (!isRecord$2(blocks)) return err({ kind: "invalid-blocks-object" });
|
|
3047
|
+
const slots = [];
|
|
3048
|
+
for (const [rawSlotKey, rawBlocks] of Object.entries(blocks)) {
|
|
3049
|
+
const slotKey = parseSlotKey(rawSlotKey);
|
|
3050
|
+
if (!slotKey.ok) return err({
|
|
3051
|
+
kind: "invalid-slot-key",
|
|
3052
|
+
key: rawSlotKey,
|
|
3053
|
+
error: slotKey.error
|
|
3054
|
+
});
|
|
3055
|
+
if (!Array.isArray(rawBlocks)) return err({
|
|
3056
|
+
kind: "invalid-slot-blocks",
|
|
3057
|
+
slotKey: slotKey.value
|
|
3058
|
+
});
|
|
3059
|
+
if (rawBlocks.length === 0) {
|
|
3060
|
+
slots.push({
|
|
3061
|
+
kind: "clear-slot",
|
|
3062
|
+
slotKey: slotKey.value
|
|
3063
|
+
});
|
|
3064
|
+
continue;
|
|
3065
|
+
}
|
|
3066
|
+
const parsedBlocks = [];
|
|
3067
|
+
const blockIndexByIdentifier = new Map();
|
|
3068
|
+
for (const [blockIndex, rawBlock] of rawBlocks.entries()) {
|
|
3069
|
+
const parsed = parseSdkEntrySlotBlockInput(rawBlock, slotKey.value, blockIndex);
|
|
3070
|
+
if (!parsed.ok) return parsed;
|
|
3071
|
+
if (parsed.value.identifier) {
|
|
3072
|
+
const firstBlockIndex = blockIndexByIdentifier.get(parsed.value.identifier);
|
|
3073
|
+
if (firstBlockIndex !== undefined) {
|
|
3074
|
+
return err({
|
|
3075
|
+
kind: "duplicate-block-identifier",
|
|
3076
|
+
slotKey: slotKey.value,
|
|
3077
|
+
identifier: parsed.value.identifier,
|
|
3078
|
+
firstBlockIndex,
|
|
3079
|
+
blockIndex
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
blockIndexByIdentifier.set(parsed.value.identifier, blockIndex);
|
|
3083
|
+
}
|
|
3084
|
+
parsedBlocks.push(parsed.value);
|
|
3085
|
+
}
|
|
3086
|
+
slots.push({
|
|
3087
|
+
kind: "replace-slot",
|
|
3088
|
+
slotKey: slotKey.value,
|
|
3089
|
+
blocks: parsedBlocks
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
return ok({
|
|
3093
|
+
kind: "patch-slots",
|
|
3094
|
+
slots
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
function parseSdkEntrySlotBlockInput(input, slotKey, blockIndex) {
|
|
3098
|
+
if (!isRecord$2(input)) return err({
|
|
3099
|
+
kind: "invalid-block",
|
|
3100
|
+
slotKey,
|
|
3101
|
+
blockIndex
|
|
3102
|
+
});
|
|
3103
|
+
const blockKind = typeof input.kind === "string" ? input.kind.trim() : "";
|
|
3104
|
+
if (!blockKind || !isRecord$2(input.content)) return err({
|
|
3105
|
+
kind: "invalid-block",
|
|
3106
|
+
slotKey,
|
|
3107
|
+
blockIndex
|
|
3108
|
+
});
|
|
3109
|
+
if (isTemplateOnlyBlockKind(blockKind)) {
|
|
3110
|
+
return err({
|
|
3111
|
+
kind: "template-only-block-kind",
|
|
3112
|
+
slotKey,
|
|
3113
|
+
blockIndex,
|
|
3114
|
+
blockKind
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
const identifier = input.identifier === undefined || input.identifier === null ? null : typeof input.identifier === "string" ? input.identifier.trim() : undefined;
|
|
3118
|
+
if (identifier === undefined || identifier === "") return err({
|
|
3119
|
+
kind: "invalid-block",
|
|
3120
|
+
slotKey,
|
|
3121
|
+
blockIndex
|
|
3122
|
+
});
|
|
3123
|
+
return ok({
|
|
3124
|
+
identifier,
|
|
3125
|
+
kind: blockKind,
|
|
3126
|
+
content: input.content
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
function describeSdkEntryBlocksPatchParseError(error) {
|
|
3130
|
+
switch (error.kind) {
|
|
3131
|
+
case "invalid-blocks-object": return "Entry slot blocks payload must be an object";
|
|
3132
|
+
case "invalid-slot-key": return `Entry slot key is invalid: ${error.key}`;
|
|
3133
|
+
case "invalid-slot-blocks": return `Entry slot "${error.slotKey}" blocks must be an array`;
|
|
3134
|
+
case "invalid-block": return `Entry slot "${error.slotKey}" block at index ${error.blockIndex} is invalid`;
|
|
3135
|
+
case "duplicate-block-identifier": return `Entry slot "${error.slotKey}" has duplicate block identifier "${error.identifier}" at index ${error.blockIndex}; first used at index ${error.firstBlockIndex}`;
|
|
3136
|
+
case "template-only-block-kind": return `Entry slot "${error.slotKey}" block at index ${error.blockIndex} uses template-only block type: ${error.blockKind}`;
|
|
3137
|
+
}
|
|
3138
|
+
const exhaustive = error;
|
|
3139
|
+
return exhaustive;
|
|
3140
|
+
}
|
|
3141
|
+
function parseSlotContainerOwner(input) {
|
|
3142
|
+
if (!isRecord$2(input)) return err({ kind: "not-object" });
|
|
3143
|
+
if (input.kind === "contentEntry") {
|
|
3144
|
+
const contentEntryId = parseNonEmptyStringBrand(input.contentEntryId, unsafeAsEntrySlotContentEntryId);
|
|
3145
|
+
if (!contentEntryId.ok) {
|
|
3146
|
+
return err({
|
|
3147
|
+
kind: "invalid-content-entry-id",
|
|
3148
|
+
error: contentEntryId.error
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
return ok({
|
|
3152
|
+
kind: "contentEntry",
|
|
3153
|
+
contentEntryId: contentEntryId.value
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
if (input.kind === "pageSurface") {
|
|
3157
|
+
const pageSurfaceId = parseEntrySlotPageSurfaceId(input.pageSurfaceId);
|
|
3158
|
+
if (!pageSurfaceId.ok) {
|
|
3159
|
+
return err({
|
|
3160
|
+
kind: "invalid-page-surface-id",
|
|
3161
|
+
error: pageSurfaceId.error
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
return ok({
|
|
3165
|
+
kind: "pageSurface",
|
|
3166
|
+
pageSurfaceId: pageSurfaceId.value
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
return err({
|
|
3170
|
+
kind: "unsupported-owner-kind",
|
|
3171
|
+
ownerKind: input.kind
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
function slotContainerOwnerToContentVersionOwner(owner) {
|
|
3175
|
+
return {
|
|
3176
|
+
kind: "entry",
|
|
3177
|
+
contentEntryId: owner.contentEntryId
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
function slotContainerOwnerToEntrySlotLookup(owner) {
|
|
3181
|
+
return { contentEntryId: owner.contentEntryId };
|
|
3182
|
+
}
|
|
3183
|
+
function planSlotCompositionMutation(input) {
|
|
3184
|
+
switch (input.mutation.kind) {
|
|
3185
|
+
case "leaveUnchanged": return {
|
|
3186
|
+
kind: "noChange",
|
|
3187
|
+
owner: input.owner,
|
|
3188
|
+
slotKey: input.slotKey
|
|
3189
|
+
};
|
|
3190
|
+
case "replaceDraftComposition": return {
|
|
3191
|
+
kind: "replaceDraftComposition",
|
|
3192
|
+
owner: input.owner,
|
|
3193
|
+
slotKey: input.slotKey,
|
|
3194
|
+
blocks: input.mutation.blocks
|
|
3195
|
+
};
|
|
3196
|
+
case "publishDraftComposition": return {
|
|
3197
|
+
kind: "publishDraftComposition",
|
|
3198
|
+
owner: input.owner,
|
|
3199
|
+
slotKey: input.slotKey,
|
|
3200
|
+
draftVersionId: input.mutation.draftVersionId
|
|
3201
|
+
};
|
|
3202
|
+
default: return assertNever(input.mutation);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
function planSlotCompositionTransfer(input) {
|
|
3206
|
+
if (sameSlotCompositionOwner(input.from, input.to)) {
|
|
3207
|
+
return {
|
|
3208
|
+
kind: "noChange",
|
|
3209
|
+
owner: input.to,
|
|
3210
|
+
slotKey: input.slotKey
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
if (input.to.kind !== "surfaceSlot") {
|
|
3214
|
+
return {
|
|
3215
|
+
kind: "manualReviewRequired",
|
|
3216
|
+
reason: {
|
|
3217
|
+
kind: "target-is-not-surface-slot",
|
|
3218
|
+
targetKind: input.to.kind
|
|
3219
|
+
}
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
switch (input.from.kind) {
|
|
3223
|
+
case "entrySlot": return {
|
|
3224
|
+
kind: "copyEntrySlotToSurfaceSlot",
|
|
3225
|
+
from: input.from,
|
|
3226
|
+
to: input.to,
|
|
3227
|
+
slotKey: input.slotKey
|
|
3228
|
+
};
|
|
3229
|
+
case "surfaceSlot": return {
|
|
3230
|
+
kind: "forkSurfaceSlotComposition",
|
|
3231
|
+
from: input.from,
|
|
3232
|
+
to: input.to,
|
|
3233
|
+
slotKey: input.slotKey
|
|
3234
|
+
};
|
|
3235
|
+
default: return assertNever(input.from);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
function sameSlotCompositionOwner(left, right) {
|
|
3239
|
+
switch (left.kind) {
|
|
3240
|
+
case "entrySlot": return right.kind === "entrySlot" && right.entrySlotId === left.entrySlotId && right.contentEntryId === left.contentEntryId;
|
|
3241
|
+
case "surfaceSlot": return right.kind === "surfaceSlot" && right.surfaceSlotId === left.surfaceSlotId && right.pageSurfaceId === left.pageSurfaceId;
|
|
3242
|
+
default: return assertNever(left);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
const ENTRY_SLOT_TEMPLATE_LIFECYCLE_DECISION = {
|
|
3246
|
+
publicTemplateDefinitions: "effective-current",
|
|
3247
|
+
previewTemplateDefinitions: "draft-current",
|
|
3248
|
+
removedSlotData: "retained-hidden-until-slot-reintroduced"
|
|
3249
|
+
};
|
|
3250
|
+
function isRecord$2(value) {
|
|
3251
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3252
|
+
}
|
|
3253
|
+
function assertNever(value) {
|
|
3254
|
+
throw new Error(`Unhandled entry-slot variant: ${JSON.stringify(value)}`);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
//#endregion
|
|
3258
|
+
//#region ../content-model/src/templates/defaults.ts
|
|
3259
|
+
const POST_EXCERPT_FALLBACK = fallbackBinding(fieldBinding("excerpt"), transformBinding(TEMPLATE_BINDING_TRANSFORMS.RICHTEXT_FIRST_PARAGRAPH, { input: fieldBinding("body") }));
|
|
3260
|
+
const EVENT_SUBHEADLINE_FALLBACK = fallbackBinding(fieldBinding("summary"), transformBinding(TEMPLATE_BINDING_TRANSFORMS.RICHTEXT_FIRST_PARAGRAPH, { input: fieldBinding("body") }));
|
|
3261
|
+
const COURSE_SUMMARY_FALLBACK = fallbackBinding(fieldBinding("summary"), transformBinding(TEMPLATE_BINDING_TRANSFORMS.RICHTEXT_FIRST_PARAGRAPH, { input: fieldBinding("body") }));
|
|
3262
|
+
const ENTRY_TITLE_INLINE_TEXT = transformBinding(TEMPLATE_BINDING_TRANSFORMS.TEXT_INLINE_TEXT, { input: metaBinding("title") });
|
|
3263
|
+
const POST_BODY_TEXT_BINDINGS = {
|
|
3264
|
+
body: fieldBinding("body"),
|
|
3265
|
+
alignment: literalBinding("left")
|
|
3266
|
+
};
|
|
3267
|
+
const EVENT_BODY_TEXT_BINDINGS = {
|
|
3268
|
+
body: fieldBinding("body"),
|
|
3269
|
+
alignment: literalBinding("left")
|
|
3270
|
+
};
|
|
3271
|
+
const TEAM_MEMBER_BODY_TEXT_BINDINGS = {
|
|
3272
|
+
body: fieldBinding("bio"),
|
|
3273
|
+
alignment: literalBinding("left")
|
|
3274
|
+
};
|
|
3275
|
+
const TESTIMONIAL_BODY_TEXT_BINDINGS = {
|
|
3276
|
+
body: fieldBinding("body"),
|
|
3277
|
+
alignment: literalBinding("center")
|
|
3278
|
+
};
|
|
3279
|
+
/**
|
|
3280
|
+
* Built-in content type keys that intentionally ship without a default
|
|
3281
|
+
* template — they use the block editor directly rather than a templated
|
|
3282
|
+
* render. Single source of truth for the carve-out so callers and tests
|
|
3283
|
+
* don't drift on which keys are "missing on purpose".
|
|
3284
|
+
*/
|
|
3285
|
+
const CONTENT_TYPES_WITHOUT_DEFAULT_TEMPLATE = ["page", "product-category"];
|
|
3286
|
+
const DEFAULT_TEMPLATE_DEFINITIONS = {
|
|
3287
|
+
post: {
|
|
3288
|
+
templateKey: "default",
|
|
3289
|
+
name: "Blog post detail",
|
|
3290
|
+
description: "Default article layout for blog posts.",
|
|
3291
|
+
metadata: {},
|
|
3292
|
+
blocks: [
|
|
3293
|
+
{
|
|
3294
|
+
blockKind: "block.hero",
|
|
3295
|
+
scope: "entry",
|
|
3296
|
+
content: { _sectionStyles: { background: { type: "image" } } },
|
|
3297
|
+
bindings: {
|
|
3298
|
+
eyebrow: literalBinding("Article"),
|
|
3299
|
+
headline: metaBinding("title"),
|
|
3300
|
+
headlineInline: ENTRY_TITLE_INLINE_TEXT,
|
|
3301
|
+
subheadline: POST_EXCERPT_FALLBACK,
|
|
3302
|
+
"_sectionStyles.background.image": fieldBinding("image")
|
|
3303
|
+
}
|
|
3304
|
+
},
|
|
3305
|
+
{
|
|
3306
|
+
blockKind: "block.body-text",
|
|
3307
|
+
scope: "entry",
|
|
3308
|
+
bindings: POST_BODY_TEXT_BINDINGS
|
|
3309
|
+
},
|
|
3310
|
+
{
|
|
3311
|
+
blockKind: "block.cta-full",
|
|
3312
|
+
scope: "template",
|
|
3313
|
+
content: {
|
|
3314
|
+
eyebrow: "Stay in the loop",
|
|
3315
|
+
title: "Get the latest updates",
|
|
3316
|
+
content: createRichTextDoc(["Subscribe to our newsletter to receive new articles and resources right in your inbox."]),
|
|
3317
|
+
ctas: [{
|
|
3318
|
+
label: "Subscribe now",
|
|
3319
|
+
link: {
|
|
3320
|
+
kind: "url",
|
|
3321
|
+
href: "/contact"
|
|
3322
|
+
}
|
|
3323
|
+
}, {
|
|
3324
|
+
label: "View all posts",
|
|
3325
|
+
link: {
|
|
3326
|
+
kind: "url",
|
|
3327
|
+
href: "/blog"
|
|
3328
|
+
}
|
|
3329
|
+
}]
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
]
|
|
3333
|
+
},
|
|
3334
|
+
event: {
|
|
3335
|
+
templateKey: "default",
|
|
3336
|
+
name: "Event detail",
|
|
3337
|
+
description: "Standard detail page for events.",
|
|
3338
|
+
metadata: {},
|
|
3339
|
+
blocks: [
|
|
3340
|
+
{
|
|
3341
|
+
blockKind: "block.hero",
|
|
3342
|
+
scope: "entry",
|
|
3343
|
+
bindings: {
|
|
3344
|
+
eyebrow: literalBinding("Event"),
|
|
3345
|
+
headline: metaBinding("title"),
|
|
3346
|
+
headlineInline: ENTRY_TITLE_INLINE_TEXT,
|
|
3347
|
+
subheadline: EVENT_SUBHEADLINE_FALLBACK
|
|
3348
|
+
}
|
|
3349
|
+
},
|
|
3350
|
+
{
|
|
3351
|
+
blockKind: "block.event-details",
|
|
3352
|
+
scope: "entry",
|
|
3353
|
+
content: {
|
|
3354
|
+
heading: "Event details",
|
|
3355
|
+
showVenue: true,
|
|
3356
|
+
showMap: true,
|
|
3357
|
+
showOtherDates: true,
|
|
3358
|
+
otherDatesLimit: "5"
|
|
3359
|
+
}
|
|
3360
|
+
},
|
|
3361
|
+
{
|
|
3362
|
+
blockKind: "block.body-text",
|
|
3363
|
+
scope: "entry",
|
|
3364
|
+
bindings: EVENT_BODY_TEXT_BINDINGS
|
|
3365
|
+
},
|
|
3366
|
+
{
|
|
3367
|
+
blockKind: SYSTEM_ENTRY_SLOT_BLOCK_KIND,
|
|
3368
|
+
scope: "template",
|
|
3369
|
+
content: {
|
|
3370
|
+
slotKey: "main",
|
|
3371
|
+
label: "Page content"
|
|
3372
|
+
}
|
|
3373
|
+
},
|
|
3374
|
+
{
|
|
3375
|
+
blockKind: "block.event-registration",
|
|
3376
|
+
scope: "entry",
|
|
3377
|
+
content: {
|
|
3378
|
+
heading: "Register for this event",
|
|
3379
|
+
maxTickets: "5",
|
|
3380
|
+
showVenue: true,
|
|
3381
|
+
showCapacity: true,
|
|
3382
|
+
successMessage: "Thank you for registering! Check your email for confirmation details.",
|
|
3383
|
+
waitlistMessage: "You've been added to the waitlist. We'll email you a claim link if a spot opens up.",
|
|
3384
|
+
buttonText: "Complete Registration",
|
|
3385
|
+
buttonVariant: "primary"
|
|
3386
|
+
},
|
|
3387
|
+
bindings: {
|
|
3388
|
+
occurrenceContext: { $bind: { from: "$root.occurrenceContext" } },
|
|
3389
|
+
contentEntry: { $bind: { from: "$root.contentEntry" } }
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
]
|
|
3393
|
+
},
|
|
3394
|
+
course: {
|
|
3395
|
+
templateKey: "default",
|
|
3396
|
+
name: "Course detail",
|
|
3397
|
+
description: "Standard landing page for multi-session courses.",
|
|
3398
|
+
metadata: {},
|
|
3399
|
+
blocks: [
|
|
3400
|
+
{
|
|
3401
|
+
blockKind: "block.hero",
|
|
3402
|
+
scope: "entry",
|
|
3403
|
+
bindings: {
|
|
3404
|
+
eyebrow: literalBinding("Course"),
|
|
3405
|
+
headline: metaBinding("title"),
|
|
3406
|
+
headlineInline: ENTRY_TITLE_INLINE_TEXT,
|
|
3407
|
+
subheadline: COURSE_SUMMARY_FALLBACK
|
|
3408
|
+
}
|
|
3409
|
+
},
|
|
3410
|
+
{
|
|
3411
|
+
blockKind: "block.course-details",
|
|
3412
|
+
scope: "entry",
|
|
3413
|
+
content: {
|
|
3414
|
+
heading: "Course details",
|
|
3415
|
+
showTitle: false,
|
|
3416
|
+
showDescription: false,
|
|
3417
|
+
showSessions: true,
|
|
3418
|
+
sessionsLimit: "10",
|
|
3419
|
+
showVenue: true,
|
|
3420
|
+
showPrice: true,
|
|
3421
|
+
showCapacity: true,
|
|
3422
|
+
showEnrollmentStatus: true,
|
|
3423
|
+
showMembershipNote: true
|
|
3424
|
+
}
|
|
3425
|
+
},
|
|
3426
|
+
{
|
|
3427
|
+
blockKind: SYSTEM_ENTRY_SLOT_BLOCK_KIND,
|
|
3428
|
+
scope: "template",
|
|
3429
|
+
content: {
|
|
3430
|
+
slotKey: "main",
|
|
3431
|
+
label: "Page content"
|
|
3432
|
+
}
|
|
3433
|
+
},
|
|
3434
|
+
{
|
|
3435
|
+
blockKind: "block.course-registration",
|
|
3436
|
+
scope: "entry",
|
|
3437
|
+
content: {
|
|
3438
|
+
heading: "Enroll in this course",
|
|
3439
|
+
showSessions: true,
|
|
3440
|
+
showPrice: true,
|
|
3441
|
+
showCapacity: true,
|
|
3442
|
+
showVenue: true,
|
|
3443
|
+
successMessage: "You're enrolled! Check your email for confirmation and session details.",
|
|
3444
|
+
waitlistMessage: "You've been added to the waitlist. We'll email you a claim link if a spot opens up.",
|
|
3445
|
+
membershipMessage: "Enrolled using your membership! Check your email for session details.",
|
|
3446
|
+
buttonText: "Complete Enrollment",
|
|
3447
|
+
buttonVariant: "primary"
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
]
|
|
3451
|
+
},
|
|
3452
|
+
"team-member": {
|
|
3453
|
+
templateKey: "default",
|
|
3454
|
+
name: "Team member profile",
|
|
3455
|
+
description: "Default layout for team member profile pages.",
|
|
3456
|
+
metadata: {},
|
|
3457
|
+
blocks: [{
|
|
3458
|
+
blockKind: "block.hero",
|
|
3459
|
+
scope: "entry",
|
|
3460
|
+
bindings: {
|
|
3461
|
+
eyebrow: literalBinding("About"),
|
|
3462
|
+
headline: metaBinding("title"),
|
|
3463
|
+
headlineInline: ENTRY_TITLE_INLINE_TEXT,
|
|
3464
|
+
subheadline: fieldBinding("intro")
|
|
3465
|
+
}
|
|
3466
|
+
}, {
|
|
3467
|
+
blockKind: "block.body-text",
|
|
3468
|
+
scope: "entry",
|
|
3469
|
+
bindings: TEAM_MEMBER_BODY_TEXT_BINDINGS
|
|
3470
|
+
}]
|
|
3471
|
+
},
|
|
3472
|
+
product: {
|
|
3473
|
+
templateKey: "default",
|
|
3474
|
+
name: "Product detail",
|
|
3475
|
+
description: "Standard landing page for shop products.",
|
|
3476
|
+
metadata: {},
|
|
3477
|
+
blocks: [{
|
|
3478
|
+
blockKind: "block.product-detail",
|
|
3479
|
+
scope: "entry",
|
|
3480
|
+
content: {
|
|
3481
|
+
heading: "",
|
|
3482
|
+
showSummary: true,
|
|
3483
|
+
showBody: true,
|
|
3484
|
+
addButtonText: "Add to cart",
|
|
3485
|
+
emptyStateText: "Product unavailable."
|
|
3486
|
+
}
|
|
3487
|
+
}]
|
|
3488
|
+
},
|
|
3489
|
+
testimonial: {
|
|
3490
|
+
templateKey: "default",
|
|
3491
|
+
name: "Testimonial detail",
|
|
3492
|
+
description: "Rich layout for individual testimonials.",
|
|
3493
|
+
metadata: {},
|
|
3494
|
+
blocks: [{
|
|
3495
|
+
blockKind: "block.hero",
|
|
3496
|
+
scope: "entry",
|
|
3497
|
+
bindings: {
|
|
3498
|
+
eyebrow: literalBinding("Customer story"),
|
|
3499
|
+
headline: metaBinding("title"),
|
|
3500
|
+
headlineInline: ENTRY_TITLE_INLINE_TEXT,
|
|
3501
|
+
subheadline: fallbackBinding(fieldBinding("jobTitle"), fieldBinding("company"))
|
|
3502
|
+
}
|
|
3503
|
+
}, {
|
|
3504
|
+
blockKind: "block.body-text",
|
|
3505
|
+
scope: "entry",
|
|
3506
|
+
bindings: TESTIMONIAL_BODY_TEXT_BINDINGS
|
|
3507
|
+
}]
|
|
3508
|
+
}
|
|
3509
|
+
};
|
|
3510
|
+
function getDefaultTemplateDefinition(contentTypeKey) {
|
|
3511
|
+
return DEFAULT_TEMPLATE_DEFINITIONS[contentTypeKey] ?? null;
|
|
3512
|
+
}
|
|
3513
|
+
const DEFAULT_TEMPLATE_KEYS = Object.freeze(Object.keys(DEFAULT_TEMPLATE_DEFINITIONS));
|
|
3514
|
+
|
|
3515
|
+
//#endregion
|
|
3516
|
+
//#region ../content-model/src/templates/bindingTypeCompatibility.ts
|
|
3517
|
+
const SCALAR_BLOCK_FIELD_TYPES = [
|
|
3518
|
+
"text",
|
|
3519
|
+
"inlineText",
|
|
3520
|
+
"number",
|
|
3521
|
+
"slug",
|
|
3522
|
+
"url",
|
|
3523
|
+
"richText",
|
|
3524
|
+
"media",
|
|
3525
|
+
"file",
|
|
3526
|
+
"boolean",
|
|
3527
|
+
"select",
|
|
3528
|
+
"reference",
|
|
3529
|
+
"link",
|
|
3530
|
+
"date",
|
|
3531
|
+
"datetime"
|
|
3532
|
+
];
|
|
3533
|
+
function isScalarBlockFieldType(type) {
|
|
3534
|
+
return SCALAR_BLOCK_FIELD_TYPES.includes(type);
|
|
3535
|
+
}
|
|
3536
|
+
/**
|
|
3537
|
+
* Compatibility matrix: content field type → compatible block field types
|
|
3538
|
+
*/
|
|
3539
|
+
const CONTENT_TO_BLOCK_COMPATIBILITY = {
|
|
3540
|
+
text: [
|
|
3541
|
+
"text",
|
|
3542
|
+
"inlineText",
|
|
3543
|
+
"slug",
|
|
3544
|
+
"url"
|
|
3545
|
+
],
|
|
3546
|
+
textarea: ["text", "inlineText"],
|
|
3547
|
+
number: [
|
|
3548
|
+
"number",
|
|
3549
|
+
"text",
|
|
3550
|
+
"inlineText"
|
|
3551
|
+
],
|
|
3552
|
+
date: [
|
|
3553
|
+
"date",
|
|
3554
|
+
"datetime",
|
|
3555
|
+
"text",
|
|
3556
|
+
"inlineText"
|
|
3557
|
+
],
|
|
3558
|
+
url: [
|
|
3559
|
+
"url",
|
|
3560
|
+
"text",
|
|
3561
|
+
"inlineText"
|
|
3562
|
+
],
|
|
3563
|
+
richText: ["richText"],
|
|
3564
|
+
media: ["media"],
|
|
3565
|
+
file: ["file"],
|
|
3566
|
+
boolean: ["boolean"],
|
|
3567
|
+
select: [
|
|
3568
|
+
"text",
|
|
3569
|
+
"inlineText",
|
|
3570
|
+
"select"
|
|
3571
|
+
],
|
|
3572
|
+
reference: ["reference", "link"],
|
|
3573
|
+
link: ["link", "reference"],
|
|
3574
|
+
button: ["link"],
|
|
3575
|
+
group: [],
|
|
3576
|
+
repeater: ["repeater"],
|
|
3577
|
+
datetime: [
|
|
3578
|
+
"datetime",
|
|
3579
|
+
"date",
|
|
3580
|
+
"text",
|
|
3581
|
+
"inlineText"
|
|
3582
|
+
]
|
|
3583
|
+
};
|
|
3584
|
+
/**
|
|
3585
|
+
* Canonical meta-field registry.
|
|
3586
|
+
*
|
|
3587
|
+
* Single source of truth for "what entry meta fields exist and what content-field
|
|
3588
|
+
* type they resolve to". Other modules derive their meta-related tables from
|
|
3589
|
+
* this one to prevent the three-source-of-truth drift that previously existed
|
|
3590
|
+
* across `META_FIELD_COMPATIBILITY` (this file), `getAvailableFieldPaths`
|
|
3591
|
+
* (fieldPaths.ts), and `resolveMetaPathType` (bindingValidation.ts).
|
|
3592
|
+
*/
|
|
3593
|
+
const META_FIELDS = {
|
|
3594
|
+
title: "text",
|
|
3595
|
+
slug: "text",
|
|
3596
|
+
status: "text",
|
|
3597
|
+
publishedAt: "date",
|
|
3598
|
+
createdAt: "date",
|
|
3599
|
+
updatedAt: "date"
|
|
3600
|
+
};
|
|
3601
|
+
/** Set of all known meta-field keys, derived from {@link META_FIELDS}. */
|
|
3602
|
+
const META_FIELD_IDS = new Set(Object.keys(META_FIELDS));
|
|
3603
|
+
/**
|
|
3604
|
+
* Resolve a meta path to its content-field type. Returns `null` for unknown paths.
|
|
3605
|
+
*
|
|
3606
|
+
* Use this when validating bindings; the returned type participates in the
|
|
3607
|
+
* standard `isCompatibleBinding` matrix. For target-aware compatibility
|
|
3608
|
+
* (which adds aliases like `title → slug → url`), use
|
|
3609
|
+
* {@link isCompatibleMetaBinding}.
|
|
3610
|
+
*/
|
|
3611
|
+
function resolveMetaFieldType(path) {
|
|
3612
|
+
return isMetaFieldPath(path) ? META_FIELDS[path] : null;
|
|
3613
|
+
}
|
|
3614
|
+
/** Type guard narrowing `string` to the union of declared meta-field paths. */
|
|
3615
|
+
function isMetaFieldPath(path) {
|
|
3616
|
+
return path in META_FIELDS;
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Meta field → compatible block targets.
|
|
3620
|
+
*
|
|
3621
|
+
* Includes target-specific aliases that the generic field-type matrix
|
|
3622
|
+
* doesn't (e.g. `title` is text but is also a valid `slug`/`url` source).
|
|
3623
|
+
* Indexed by the same keys as {@link META_FIELDS} — adding a new meta field
|
|
3624
|
+
* means updating both maps.
|
|
3625
|
+
*/
|
|
3626
|
+
const META_FIELD_COMPATIBILITY = {
|
|
3627
|
+
title: [
|
|
3628
|
+
"text",
|
|
3629
|
+
"inlineText",
|
|
3630
|
+
"slug",
|
|
3631
|
+
"url"
|
|
3632
|
+
],
|
|
3633
|
+
slug: [
|
|
3634
|
+
"text",
|
|
3635
|
+
"inlineText",
|
|
3636
|
+
"slug",
|
|
3637
|
+
"url"
|
|
3638
|
+
],
|
|
3639
|
+
status: [
|
|
3640
|
+
"text",
|
|
3641
|
+
"inlineText",
|
|
3642
|
+
"select"
|
|
3643
|
+
],
|
|
3644
|
+
publishedAt: [
|
|
3645
|
+
"text",
|
|
3646
|
+
"inlineText",
|
|
3647
|
+
"date",
|
|
3648
|
+
"datetime"
|
|
3649
|
+
],
|
|
3650
|
+
createdAt: [
|
|
3651
|
+
"text",
|
|
3652
|
+
"inlineText",
|
|
3653
|
+
"date",
|
|
3654
|
+
"datetime"
|
|
3655
|
+
],
|
|
3656
|
+
updatedAt: [
|
|
3657
|
+
"text",
|
|
3658
|
+
"inlineText",
|
|
3659
|
+
"date",
|
|
3660
|
+
"datetime"
|
|
3661
|
+
]
|
|
3662
|
+
};
|
|
3663
|
+
/**
|
|
3664
|
+
* Nested path suffixes and their compatible types (for example media.url).
|
|
3665
|
+
*/
|
|
3666
|
+
const NESTED_PATH_COMPATIBILITY = {
|
|
3667
|
+
"media.url": [
|
|
3668
|
+
"text",
|
|
3669
|
+
"inlineText",
|
|
3670
|
+
"url"
|
|
3671
|
+
],
|
|
3672
|
+
"media.alt": ["text", "inlineText"],
|
|
3673
|
+
"file.url": [
|
|
3674
|
+
"text",
|
|
3675
|
+
"inlineText",
|
|
3676
|
+
"url"
|
|
3677
|
+
],
|
|
3678
|
+
"file.filename": ["text", "inlineText"]
|
|
3679
|
+
};
|
|
3680
|
+
/**
|
|
3681
|
+
* Check if a content field type is compatible with a block field type
|
|
3682
|
+
*/
|
|
3683
|
+
function isCompatibleBinding(sourceType, targetType) {
|
|
3684
|
+
if (targetType === "repeater") {
|
|
3685
|
+
return sourceType === "repeater";
|
|
3686
|
+
}
|
|
3687
|
+
const compatibleTypes = CONTENT_TO_BLOCK_COMPATIBILITY[sourceType];
|
|
3688
|
+
if (compatibleTypes) {
|
|
3689
|
+
return compatibleTypes.includes(targetType);
|
|
3690
|
+
}
|
|
3691
|
+
return false;
|
|
3692
|
+
}
|
|
3693
|
+
/**
|
|
3694
|
+
* Check if a meta field is compatible with a block field type
|
|
3695
|
+
*/
|
|
3696
|
+
function isCompatibleMetaBinding(metaFieldName, targetType) {
|
|
3697
|
+
if (targetType === "repeater") return false;
|
|
3698
|
+
if (!isMetaFieldPath(metaFieldName)) return false;
|
|
3699
|
+
const compatibleTypes = META_FIELD_COMPATIBILITY[metaFieldName];
|
|
3700
|
+
return compatibleTypes.includes(targetType);
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Check if a nested path is compatible with a block field type
|
|
3704
|
+
*/
|
|
3705
|
+
function isCompatibleNestedPath(parentType, nestedSuffix, targetType) {
|
|
3706
|
+
if (targetType === "repeater") return false;
|
|
3707
|
+
const pathKey = `${parentType}.${nestedSuffix}`;
|
|
3708
|
+
const compatibleTypes = NESTED_PATH_COMPATIBILITY[pathKey];
|
|
3709
|
+
if (compatibleTypes) {
|
|
3710
|
+
return compatibleTypes.includes(targetType);
|
|
3711
|
+
}
|
|
3712
|
+
return false;
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Get compatible block field types for a given content field type
|
|
3716
|
+
*/
|
|
3717
|
+
function getCompatibleBlockTypes(sourceType) {
|
|
3718
|
+
return CONTENT_TO_BLOCK_COMPATIBILITY[sourceType] || [];
|
|
3719
|
+
}
|
|
3720
|
+
/**
|
|
3721
|
+
* Get all compatible content field types for a given block field type (reverse lookup)
|
|
3722
|
+
*/
|
|
3723
|
+
function getCompatibleContentTypes(targetType) {
|
|
3724
|
+
const compatible = [];
|
|
3725
|
+
for (const [contentType, blockTypes] of Object.entries(CONTENT_TO_BLOCK_COMPATIBILITY)) {
|
|
3726
|
+
if (blockTypes.includes(targetType)) {
|
|
3727
|
+
compatible.push(contentType);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
return compatible;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
//#endregion
|
|
3734
|
+
//#region ../content-model/src/templates/fieldPaths.ts
|
|
3735
|
+
const META_FIELD_LABELS = {
|
|
3736
|
+
title: "Title",
|
|
3737
|
+
slug: "Slug",
|
|
3738
|
+
status: "Status",
|
|
3739
|
+
publishedAt: "Published Date",
|
|
3740
|
+
createdAt: "Created Date",
|
|
3741
|
+
updatedAt: "Updated Date"
|
|
3742
|
+
};
|
|
3743
|
+
/**
|
|
3744
|
+
* Extract all available field paths from a content type schema
|
|
3745
|
+
* for use in binding editor autocomplete.
|
|
3746
|
+
*/
|
|
3747
|
+
function getAvailableFieldPaths(schema) {
|
|
3748
|
+
const paths = [];
|
|
3749
|
+
for (const [path, type] of Object.entries(META_FIELDS)) {
|
|
3750
|
+
paths.push({
|
|
3751
|
+
path,
|
|
3752
|
+
label: META_FIELD_LABELS[path],
|
|
3753
|
+
type,
|
|
3754
|
+
category: "meta"
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
paths.push(...contentFieldPaths(schema.fields ?? []));
|
|
3758
|
+
return paths;
|
|
3759
|
+
}
|
|
3760
|
+
function contentFieldPaths(fields, parentPath = "", parentLabel = "") {
|
|
3761
|
+
const paths = [];
|
|
3762
|
+
for (const field of fields) {
|
|
3763
|
+
const basePath = parentPath ? `${parentPath}.${field.id}` : field.id;
|
|
3764
|
+
const fieldLabel = field.label ?? field.id;
|
|
3765
|
+
const baseLabel = parentLabel ? `${parentLabel} / ${fieldLabel}` : fieldLabel;
|
|
3766
|
+
if (field.type === "group") {
|
|
3767
|
+
paths.push(...contentFieldPaths(nestedFields(field), basePath, baseLabel));
|
|
3768
|
+
continue;
|
|
3769
|
+
}
|
|
3770
|
+
if (field.type === "repeater") {
|
|
3771
|
+
continue;
|
|
3772
|
+
}
|
|
3773
|
+
if (field.type === "richText") {
|
|
3774
|
+
paths.push({
|
|
3775
|
+
path: basePath,
|
|
3776
|
+
label: baseLabel,
|
|
3777
|
+
type: "richText",
|
|
3778
|
+
category: "content"
|
|
3779
|
+
});
|
|
3780
|
+
continue;
|
|
3781
|
+
}
|
|
3782
|
+
if (field.type === "media") {
|
|
3783
|
+
paths.push({
|
|
3784
|
+
path: basePath,
|
|
3785
|
+
label: baseLabel,
|
|
3786
|
+
type: "media",
|
|
3787
|
+
category: "content"
|
|
3788
|
+
}, {
|
|
3789
|
+
path: `${basePath}.url`,
|
|
3790
|
+
label: `${baseLabel} (URL)`,
|
|
3791
|
+
type: "media.url",
|
|
3792
|
+
category: "content"
|
|
3793
|
+
}, {
|
|
3794
|
+
path: `${basePath}.alt`,
|
|
3795
|
+
label: `${baseLabel} (Alt Text)`,
|
|
3796
|
+
type: "media.alt",
|
|
3797
|
+
category: "content"
|
|
3798
|
+
});
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3801
|
+
if (field.type === "file") {
|
|
3802
|
+
paths.push({
|
|
3803
|
+
path: basePath,
|
|
3804
|
+
label: baseLabel,
|
|
3805
|
+
type: "file",
|
|
3806
|
+
category: "content"
|
|
3807
|
+
}, {
|
|
3808
|
+
path: `${basePath}.url`,
|
|
3809
|
+
label: `${baseLabel} (URL)`,
|
|
3810
|
+
type: "file.url",
|
|
3811
|
+
category: "content"
|
|
3812
|
+
}, {
|
|
3813
|
+
path: `${basePath}.filename`,
|
|
3814
|
+
label: `${baseLabel} (Filename)`,
|
|
3815
|
+
type: "file.filename",
|
|
3816
|
+
category: "content"
|
|
3817
|
+
});
|
|
3818
|
+
continue;
|
|
3819
|
+
}
|
|
3820
|
+
paths.push({
|
|
3821
|
+
path: basePath,
|
|
3822
|
+
label: baseLabel,
|
|
3823
|
+
type: field.type,
|
|
3824
|
+
category: "content"
|
|
3825
|
+
});
|
|
3826
|
+
}
|
|
3827
|
+
return paths;
|
|
3828
|
+
}
|
|
3829
|
+
function nestedFields(field) {
|
|
3830
|
+
const record = field;
|
|
3831
|
+
return record.fields ?? record.schema?.fields ?? [];
|
|
3832
|
+
}
|
|
3833
|
+
/**
|
|
3834
|
+
* Get field type from path for validation
|
|
3835
|
+
*/
|
|
3836
|
+
function getFieldTypeFromPath(schema, path) {
|
|
3837
|
+
const descriptor = getFieldDescriptorFromPath(schema, path);
|
|
3838
|
+
return descriptor?.kind === "scalar" ? descriptor.type : descriptor?.kind ?? null;
|
|
3839
|
+
}
|
|
3840
|
+
function getFieldDescriptorFromPath(schema, path) {
|
|
3841
|
+
const paths = getAvailableFieldPaths(schema);
|
|
3842
|
+
const match = paths.find((p) => p.path === path);
|
|
3843
|
+
if (match) return {
|
|
3844
|
+
kind: "scalar",
|
|
3845
|
+
type: match.type
|
|
3846
|
+
};
|
|
3847
|
+
const field = findContentFieldByPath(schema.fields ?? [], path.split("."));
|
|
3848
|
+
if (!field || field.type !== "repeater") return null;
|
|
3849
|
+
return {
|
|
3850
|
+
kind: "repeater",
|
|
3851
|
+
shape: buildRepeaterFieldShape(field)
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
function findContentFieldByPath(fields, segments) {
|
|
3855
|
+
if (segments.length === 0) return null;
|
|
3856
|
+
const [head, ...tail] = segments;
|
|
3857
|
+
const field = fields.find((candidate) => candidate.id === head);
|
|
3858
|
+
if (!field) return null;
|
|
3859
|
+
if (tail.length === 0) return field;
|
|
3860
|
+
if (field.type !== "group") return null;
|
|
3861
|
+
return findContentFieldByPath(nestedFields(field), tail);
|
|
3862
|
+
}
|
|
3863
|
+
function buildRepeaterFieldShape(field) {
|
|
3864
|
+
const record = field;
|
|
3865
|
+
if (record.polymorphic || record.itemTypes) {
|
|
3866
|
+
return {
|
|
3867
|
+
kind: "unsupported",
|
|
3868
|
+
reason: "polymorphic_unsupported"
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
const fields = nestedFields(field);
|
|
3872
|
+
if (fields.length === 0) {
|
|
3873
|
+
return {
|
|
3874
|
+
kind: "unsupported",
|
|
3875
|
+
reason: "missing_item_schema"
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
const itemFields = [];
|
|
3879
|
+
for (const child of fields) {
|
|
3880
|
+
if (child.type === "repeater") {
|
|
3881
|
+
return {
|
|
3882
|
+
kind: "unsupported",
|
|
3883
|
+
reason: "nested_repeater_unsupported"
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
if (!isRepeaterItemScalarType(child.type)) {
|
|
3887
|
+
return {
|
|
3888
|
+
kind: "unsupported",
|
|
3889
|
+
reason: "unsupported_item_field"
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
itemFields.push({
|
|
3893
|
+
id: child.id,
|
|
3894
|
+
type: child.type,
|
|
3895
|
+
required: Boolean(child.required)
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
return {
|
|
3899
|
+
kind: "supported",
|
|
3900
|
+
itemFields
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
function isRepeaterItemScalarType(type) {
|
|
3904
|
+
switch (type) {
|
|
3905
|
+
case "text":
|
|
3906
|
+
case "textarea":
|
|
3907
|
+
case "richText":
|
|
3908
|
+
case "number":
|
|
3909
|
+
case "date":
|
|
3910
|
+
case "url":
|
|
3911
|
+
case "media":
|
|
3912
|
+
case "file":
|
|
3913
|
+
case "boolean":
|
|
3914
|
+
case "select":
|
|
3915
|
+
case "reference":
|
|
3916
|
+
case "link":
|
|
3917
|
+
case "button":
|
|
3918
|
+
case "datetime": return true;
|
|
3919
|
+
case "group":
|
|
3920
|
+
case "repeater": return false;
|
|
3921
|
+
default: {
|
|
3922
|
+
const _exhaustive = type;
|
|
3923
|
+
return _exhaustive;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3928
|
+
* Validate if a path exists in the schema
|
|
3929
|
+
*/
|
|
3930
|
+
function isValidFieldPath(schema, path) {
|
|
3931
|
+
const paths = getAvailableFieldPaths(schema);
|
|
3932
|
+
return paths.some((fieldPath) => fieldPath.path === path);
|
|
3933
|
+
}
|
|
3934
|
+
/**
|
|
3935
|
+
* Get field paths compatible with a specific block field type
|
|
3936
|
+
*
|
|
3937
|
+
* Filters available paths to only show those that can be safely bound
|
|
3938
|
+
* to the target field type without requiring transforms.
|
|
3939
|
+
*/
|
|
3940
|
+
function getCompatibleFieldPaths(schema, targetFieldType) {
|
|
3941
|
+
const allPaths = getAvailableFieldPaths(schema);
|
|
3942
|
+
return allPaths.filter((fieldPath) => {
|
|
3943
|
+
if (fieldPath.category === "meta") {
|
|
3944
|
+
return isCompatibleMetaBinding(fieldPath.path, targetFieldType);
|
|
3945
|
+
}
|
|
3946
|
+
if (fieldPath.type.includes(".")) {
|
|
3947
|
+
const parts = fieldPath.type.split(".");
|
|
3948
|
+
const parentType = parts[0];
|
|
3949
|
+
const nestedSuffix = parts[1];
|
|
3950
|
+
if (parentType && nestedSuffix) {
|
|
3951
|
+
return isCompatibleNestedPath(parentType, nestedSuffix, targetFieldType);
|
|
3952
|
+
}
|
|
3953
|
+
return false;
|
|
3954
|
+
}
|
|
3955
|
+
return isCompatibleBinding(fieldPath.type, targetFieldType);
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
//#endregion
|
|
3960
|
+
//#region ../content-model/src/templates/bindingValidation.ts
|
|
3961
|
+
/**
|
|
3962
|
+
* What each transform accepts and produces. The `satisfies` clause forces
|
|
3963
|
+
* every `TemplateBindingTransformName` to have an entry — adding a new
|
|
3964
|
+
* transform name without an IO declaration is a compile error rather than
|
|
3965
|
+
* a runtime "unknown transform" error.
|
|
3966
|
+
*
|
|
3967
|
+
* Source-of-truth status:
|
|
3968
|
+
* - The names live in three places (`TRANSFORMS` in blocks, `BINDING_TRANSFORMS`
|
|
3969
|
+
* in sdk, `TEMPLATE_BINDING_TRANSFORMS` here) for package-boundary
|
|
3970
|
+
* reasons — blocks doesn't depend on content-model. A cross-package
|
|
3971
|
+
* equivalence test (`bindingValidation.test.ts`) pins the three sets
|
|
3972
|
+
* so drift fails CI rather than going unnoticed.
|
|
3973
|
+
* - The IO declaration lives only here; the runtime implementations in
|
|
3974
|
+
* `blocks/bindings/index.ts:applyTransform` are checked against this
|
|
3975
|
+
* table by the same cross-package test.
|
|
3976
|
+
*/
|
|
3977
|
+
const TRANSFORM_IO = {
|
|
3978
|
+
[TEMPLATE_BINDING_TRANSFORMS.RICHTEXT_FIRST_PARAGRAPH]: {
|
|
3979
|
+
acceptsInputTypes: ["richText"],
|
|
3980
|
+
outputType: "text"
|
|
3981
|
+
},
|
|
3982
|
+
[TEMPLATE_BINDING_TRANSFORMS.DATE_FORMAT_SHORT]: {
|
|
3983
|
+
acceptsInputTypes: ["date", "datetime"],
|
|
3984
|
+
outputType: "text"
|
|
3985
|
+
},
|
|
3986
|
+
[TEMPLATE_BINDING_TRANSFORMS.DATE_FORMAT_LONG]: {
|
|
3987
|
+
acceptsInputTypes: ["date", "datetime"],
|
|
3988
|
+
outputType: "text"
|
|
3989
|
+
},
|
|
3990
|
+
[TEMPLATE_BINDING_TRANSFORMS.TEXT_INLINE_TEXT]: {
|
|
3991
|
+
acceptsInputTypes: [
|
|
3992
|
+
"text",
|
|
3993
|
+
"textarea",
|
|
3994
|
+
"select"
|
|
3995
|
+
],
|
|
3996
|
+
outputType: "text"
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
function getTransformIO(name) {
|
|
4000
|
+
return name in TRANSFORM_IO ? TRANSFORM_IO[name] : undefined;
|
|
4001
|
+
}
|
|
4002
|
+
/**
|
|
4003
|
+
* Resolve a meta path to its content-field type. Returns `null` for unknown
|
|
4004
|
+
* meta paths. Delegates entirely to the canonical {@link resolveMetaFieldType}
|
|
4005
|
+
* registry, which {@link getAvailableFieldPaths} also reads.
|
|
4006
|
+
*/
|
|
4007
|
+
function resolveMetaPathType(path) {
|
|
4008
|
+
return resolveMetaFieldType(path);
|
|
4009
|
+
}
|
|
4010
|
+
function isRecord$1(value) {
|
|
4011
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4012
|
+
}
|
|
4013
|
+
function isBindingDescriptor(value) {
|
|
4014
|
+
if (!isRecord$1(value)) return false;
|
|
4015
|
+
const source = value.source;
|
|
4016
|
+
return source === "field" || source === "meta" || source === "literal" || source === "transform" || source === "fallback";
|
|
4017
|
+
}
|
|
4018
|
+
/**
|
|
4019
|
+
* Detect a root-context (`{$bind: {from: '$root.X'}}`) binding.
|
|
4020
|
+
*
|
|
4021
|
+
* These are block-internal data-wiring bindings, not user-authored entry-field
|
|
4022
|
+
* bindings. They appear on blocks like `block.event-registration` to plumb
|
|
4023
|
+
* ambient context (occurrence, content entry root, portal token) from the
|
|
4024
|
+
* renderer's data context into the block. The validator should NOT flag them
|
|
4025
|
+
* as `invalidBindingKey` — they're never expected to live on the manifest's
|
|
4026
|
+
* additionalFields list. Phase 9 will migrate this stored shape away.
|
|
4027
|
+
*
|
|
4028
|
+
* Matches:
|
|
4029
|
+
* - `{ $bind: { from: '$root.foo' } }`
|
|
4030
|
+
* - `{ $bind: { from: '$root.foo.bar' } }`
|
|
4031
|
+
*
|
|
4032
|
+
* Does NOT match field/meta-source bindings whose path happens to start with
|
|
4033
|
+
* `$root` — those use a `source` discriminator, not the `$bind` envelope.
|
|
4034
|
+
*/
|
|
4035
|
+
function isRootContextBinding(value) {
|
|
4036
|
+
if (!isRecord$1(value)) return false;
|
|
4037
|
+
const bind = value.$bind;
|
|
4038
|
+
if (!isRecord$1(bind)) return false;
|
|
4039
|
+
return typeof bind.from === "string" && bind.from.startsWith("$root.");
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Check compatibility between a resolved source type (for example `'text'`,
|
|
4043
|
+
* `'richText'`, or `'media.url'`) and a target block field type.
|
|
4044
|
+
*
|
|
4045
|
+
* Handles three shapes of source type:
|
|
4046
|
+
* - plain content-field type (e.g. `'text'`)
|
|
4047
|
+
* - dotted nested suffix (e.g. `'media.url'`)
|
|
4048
|
+
* - meta-typed value (treated as plain content type, since meta types
|
|
4049
|
+
* already map cleanly into the matrix)
|
|
4050
|
+
*/
|
|
4051
|
+
function isSourceCompatibleWithTarget(sourceType, targetType) {
|
|
4052
|
+
if (sourceType.includes(".")) {
|
|
4053
|
+
const [parent, suffix] = sourceType.split(".", 2);
|
|
4054
|
+
if (!parent || !suffix) return false;
|
|
4055
|
+
return isCompatibleNestedPath(parent, suffix, targetType);
|
|
4056
|
+
}
|
|
4057
|
+
return isCompatibleBinding(sourceType, targetType);
|
|
4058
|
+
}
|
|
4059
|
+
function resolveFieldSource(path, context) {
|
|
4060
|
+
const descriptor = getFieldDescriptorFromPath(context.schema, path);
|
|
4061
|
+
if (descriptor === null) {
|
|
4062
|
+
return {
|
|
4063
|
+
kind: "invalidPath",
|
|
4064
|
+
error: {
|
|
4065
|
+
kind: "invalidFieldPath",
|
|
4066
|
+
blockIdentifier: context.blockIdentifier,
|
|
4067
|
+
fieldKey: context.fieldKey,
|
|
4068
|
+
path
|
|
4069
|
+
}
|
|
4070
|
+
};
|
|
4071
|
+
}
|
|
4072
|
+
if (descriptor.kind === "repeater") {
|
|
4073
|
+
return {
|
|
4074
|
+
kind: "ok",
|
|
4075
|
+
type: "repeater",
|
|
4076
|
+
path,
|
|
4077
|
+
shape: descriptor.shape
|
|
4078
|
+
};
|
|
4079
|
+
}
|
|
4080
|
+
return {
|
|
4081
|
+
kind: "ok",
|
|
4082
|
+
type: descriptor.type,
|
|
4083
|
+
path
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
4086
|
+
function resolveMetaSource(path, context) {
|
|
4087
|
+
const type = resolveMetaPathType(path);
|
|
4088
|
+
if (type === null) {
|
|
4089
|
+
return {
|
|
4090
|
+
kind: "invalidPath",
|
|
4091
|
+
error: {
|
|
4092
|
+
kind: "invalidMetaPath",
|
|
4093
|
+
blockIdentifier: context.blockIdentifier,
|
|
4094
|
+
fieldKey: context.fieldKey,
|
|
4095
|
+
path
|
|
4096
|
+
}
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
return {
|
|
4100
|
+
kind: "ok",
|
|
4101
|
+
type,
|
|
4102
|
+
path
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Check a resolved source type against the target. Meta paths get the
|
|
4107
|
+
* target-aware aliases (e.g. `title` → `slug`/`url`); plain field paths use
|
|
4108
|
+
* the generic compatibility matrix.
|
|
4109
|
+
*/
|
|
4110
|
+
function checkDirectCompatibility(resolved, context, isMetaSource) {
|
|
4111
|
+
if (isMetaSource) {
|
|
4112
|
+
if (isCompatibleMetaBinding(resolved.path, context.target.type)) return null;
|
|
4113
|
+
return {
|
|
4114
|
+
kind: "typeIncompatible",
|
|
4115
|
+
blockIdentifier: context.blockIdentifier,
|
|
4116
|
+
fieldKey: context.fieldKey,
|
|
4117
|
+
sourceType: `meta.${resolved.path}`,
|
|
4118
|
+
sourcePath: resolved.path,
|
|
4119
|
+
targetType: context.target.type,
|
|
4120
|
+
via: "direct"
|
|
4121
|
+
};
|
|
4122
|
+
}
|
|
4123
|
+
if (context.target.type === "repeater") {
|
|
4124
|
+
const mismatch = checkRepeaterCompatibility(resolved, context);
|
|
4125
|
+
return mismatch;
|
|
4126
|
+
}
|
|
4127
|
+
if (resolved.type === "repeater") {
|
|
4128
|
+
return {
|
|
4129
|
+
kind: "typeIncompatible",
|
|
4130
|
+
blockIdentifier: context.blockIdentifier,
|
|
4131
|
+
fieldKey: context.fieldKey,
|
|
4132
|
+
sourceType: describeResolvedSourceType(resolved),
|
|
4133
|
+
sourcePath: resolved.path,
|
|
4134
|
+
targetType: context.target.type,
|
|
4135
|
+
via: "direct"
|
|
4136
|
+
};
|
|
4137
|
+
}
|
|
4138
|
+
if (isSourceCompatibleWithTarget(resolved.type, context.target.type)) return null;
|
|
4139
|
+
return {
|
|
4140
|
+
kind: "typeIncompatible",
|
|
4141
|
+
blockIdentifier: context.blockIdentifier,
|
|
4142
|
+
fieldKey: context.fieldKey,
|
|
4143
|
+
sourceType: resolved.type,
|
|
4144
|
+
sourcePath: resolved.path,
|
|
4145
|
+
targetType: context.target.type,
|
|
4146
|
+
via: "direct"
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
function describeResolvedSourceType(resolved) {
|
|
4150
|
+
if (resolved.type !== "repeater") return resolved.type;
|
|
4151
|
+
return resolved.shape?.kind === "unsupported" ? `repeater(${resolved.shape.reason})` : "repeater";
|
|
4152
|
+
}
|
|
4153
|
+
function describeTargetType(target) {
|
|
4154
|
+
return target.type;
|
|
4155
|
+
}
|
|
4156
|
+
function checkRepeaterCompatibility(resolved, context) {
|
|
4157
|
+
if (context.target.type !== "repeater") return null;
|
|
4158
|
+
const incompatible = (sourceType) => ({
|
|
4159
|
+
kind: "typeIncompatible",
|
|
4160
|
+
blockIdentifier: context.blockIdentifier,
|
|
4161
|
+
fieldKey: context.fieldKey,
|
|
4162
|
+
sourceType,
|
|
4163
|
+
sourcePath: resolved.path,
|
|
4164
|
+
targetType: describeTargetType(context.target),
|
|
4165
|
+
via: "direct"
|
|
4166
|
+
});
|
|
4167
|
+
if (resolved.type !== "repeater" || !resolved.shape) {
|
|
4168
|
+
return incompatible(describeResolvedSourceType(resolved));
|
|
4169
|
+
}
|
|
4170
|
+
const sourceShape = resolved.shape;
|
|
4171
|
+
const targetShape = context.target.shape;
|
|
4172
|
+
if (sourceShape.kind === "unsupported") {
|
|
4173
|
+
return incompatible(`repeater(${sourceShape.reason})`);
|
|
4174
|
+
}
|
|
4175
|
+
if (targetShape.kind === "unsupported") {
|
|
4176
|
+
return incompatible(`target-repeater(${targetShape.reason})`);
|
|
4177
|
+
}
|
|
4178
|
+
const sourceById = new Map(sourceShape.itemFields.map((field) => [field.id, field]));
|
|
4179
|
+
for (const targetField of targetShape.itemFields) {
|
|
4180
|
+
const sourceField = sourceById.get(targetField.id);
|
|
4181
|
+
if (!sourceField) {
|
|
4182
|
+
if (targetField.required) return incompatible("repeater(missing_required_target_field)");
|
|
4183
|
+
continue;
|
|
4184
|
+
}
|
|
4185
|
+
if (targetField.required && !sourceField.required) {
|
|
4186
|
+
return incompatible("repeater(requiredness_mismatch)");
|
|
4187
|
+
}
|
|
4188
|
+
if (!isScalarBlockFieldType(targetField.type) || !isSourceCompatibleWithTarget(sourceField.type, targetField.type)) {
|
|
4189
|
+
return incompatible(`repeater(type_mismatch:${sourceField.id})`);
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
return null;
|
|
4193
|
+
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Validate one binding value against one target field.
|
|
4196
|
+
*
|
|
4197
|
+
* Handles every shape `TemplateBindingValue` can take:
|
|
4198
|
+
* - descriptor (`field`/`meta`/`literal`/`transform`/`fallback`)
|
|
4199
|
+
* - string shorthand (treated as a `field` path)
|
|
4200
|
+
* - nested object (each key maps to a nested manifest target at
|
|
4201
|
+
* `${parentKey}.${childKey}`; missing nested targets are reported as
|
|
4202
|
+
* `invalidBindingKey` rather than silently inheriting the parent's type)
|
|
4203
|
+
*/
|
|
4204
|
+
function validateBinding(binding, context) {
|
|
4205
|
+
if (typeof binding === "string") {
|
|
4206
|
+
const resolved = resolveFieldSource(binding, context);
|
|
4207
|
+
if (resolved.kind === "invalidPath") return [resolved.error];
|
|
4208
|
+
const mismatch = checkDirectCompatibility(resolved, context, false);
|
|
4209
|
+
return mismatch ? [mismatch] : [];
|
|
4210
|
+
}
|
|
4211
|
+
if (!isRecord$1(binding)) {
|
|
4212
|
+
return [{
|
|
4213
|
+
kind: "typeIncompatible",
|
|
4214
|
+
blockIdentifier: context.blockIdentifier,
|
|
4215
|
+
fieldKey: context.fieldKey,
|
|
4216
|
+
sourceType: typeof binding,
|
|
4217
|
+
targetType: context.target.type,
|
|
4218
|
+
via: "direct"
|
|
4219
|
+
}];
|
|
4220
|
+
}
|
|
4221
|
+
if (isBindingDescriptor(binding)) {
|
|
4222
|
+
return validateDescriptor(binding, context);
|
|
4223
|
+
}
|
|
4224
|
+
const errors = [];
|
|
4225
|
+
for (const [childKey, child] of Object.entries(binding)) {
|
|
4226
|
+
if (child === null || child === undefined) continue;
|
|
4227
|
+
const nestedKey = `${context.fieldKey}.${childKey}`;
|
|
4228
|
+
const nestedTarget = context.targetByKey.get(nestedKey);
|
|
4229
|
+
if (!nestedTarget) {
|
|
4230
|
+
errors.push({
|
|
4231
|
+
kind: "invalidBindingKey",
|
|
4232
|
+
blockIdentifier: context.blockIdentifier,
|
|
4233
|
+
blockKind: context.blockKind,
|
|
4234
|
+
fieldKey: nestedKey
|
|
4235
|
+
});
|
|
4236
|
+
continue;
|
|
4237
|
+
}
|
|
4238
|
+
errors.push(...validateBinding(child, {
|
|
4239
|
+
...context,
|
|
4240
|
+
fieldKey: nestedKey,
|
|
4241
|
+
target: nestedTarget
|
|
4242
|
+
}));
|
|
4243
|
+
}
|
|
4244
|
+
return errors;
|
|
4245
|
+
}
|
|
4246
|
+
function validateDescriptor(binding, context) {
|
|
4247
|
+
switch (binding.source) {
|
|
4248
|
+
case "field": {
|
|
4249
|
+
const resolved = resolveFieldSource(binding.path, context);
|
|
4250
|
+
if (resolved.kind === "invalidPath") return [resolved.error];
|
|
4251
|
+
const mismatch = checkDirectCompatibility(resolved, context, false);
|
|
4252
|
+
return mismatch ? [mismatch] : [];
|
|
4253
|
+
}
|
|
4254
|
+
case "meta": {
|
|
4255
|
+
const resolved = resolveMetaSource(binding.path, context);
|
|
4256
|
+
if (resolved.kind === "invalidPath") return [resolved.error];
|
|
4257
|
+
const mismatch = checkDirectCompatibility(resolved, context, true);
|
|
4258
|
+
return mismatch ? [mismatch] : [];
|
|
4259
|
+
}
|
|
4260
|
+
case "literal": return [];
|
|
4261
|
+
case "transform": return validateTransform(binding, context);
|
|
4262
|
+
case "fallback": return readFallbackOptions(binding).flatMap((option) => validateBinding(option, { ...context }).map((err$1) => err$1.kind === "typeIncompatible" && err$1.via === "direct" ? {
|
|
4263
|
+
...err$1,
|
|
4264
|
+
via: "fallbackOption"
|
|
4265
|
+
} : err$1));
|
|
4266
|
+
default: {
|
|
4267
|
+
const _exhaustive = binding;
|
|
4268
|
+
return _exhaustive;
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
function validateTransform(binding, context) {
|
|
4273
|
+
const io = getTransformIO(binding.transform);
|
|
4274
|
+
if (!io) {
|
|
4275
|
+
return [{
|
|
4276
|
+
kind: "unknownTransform",
|
|
4277
|
+
blockIdentifier: context.blockIdentifier,
|
|
4278
|
+
fieldKey: context.fieldKey,
|
|
4279
|
+
transform: binding.transform
|
|
4280
|
+
}];
|
|
4281
|
+
}
|
|
4282
|
+
const inputDescriptor = resolveTransformInput(binding);
|
|
4283
|
+
if (inputDescriptor === null) {
|
|
4284
|
+
return [{
|
|
4285
|
+
kind: "typeIncompatible",
|
|
4286
|
+
blockIdentifier: context.blockIdentifier,
|
|
4287
|
+
fieldKey: context.fieldKey,
|
|
4288
|
+
sourceType: "transform(no input)",
|
|
4289
|
+
targetType: context.target.type,
|
|
4290
|
+
via: "transformOutput"
|
|
4291
|
+
}];
|
|
4292
|
+
}
|
|
4293
|
+
const resolved = inputDescriptor.source === "field" ? resolveFieldSource(inputDescriptor.path, context) : inputDescriptor.source === "meta" ? resolveMetaSource(inputDescriptor.path, context) : null;
|
|
4294
|
+
if (resolved && resolved.kind === "invalidPath") {
|
|
4295
|
+
return [resolved.error];
|
|
4296
|
+
}
|
|
4297
|
+
const errors = [];
|
|
4298
|
+
if (resolved && resolved.kind === "ok") {
|
|
4299
|
+
const inputType = resolved.type;
|
|
4300
|
+
if (!io.acceptsInputTypes.includes(inputType)) {
|
|
4301
|
+
errors.push({
|
|
4302
|
+
kind: "transformInputIncompatible",
|
|
4303
|
+
blockIdentifier: context.blockIdentifier,
|
|
4304
|
+
fieldKey: context.fieldKey,
|
|
4305
|
+
transform: binding.transform,
|
|
4306
|
+
inputType,
|
|
4307
|
+
acceptedTypes: io.acceptsInputTypes
|
|
4308
|
+
});
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
if (!isCompatibleBinding(io.outputType, context.target.type)) {
|
|
4312
|
+
errors.push({
|
|
4313
|
+
kind: "typeIncompatible",
|
|
4314
|
+
blockIdentifier: context.blockIdentifier,
|
|
4315
|
+
fieldKey: context.fieldKey,
|
|
4316
|
+
sourceType: io.outputType,
|
|
4317
|
+
targetType: context.target.type,
|
|
4318
|
+
via: "transformOutput"
|
|
4319
|
+
});
|
|
4320
|
+
}
|
|
4321
|
+
return errors;
|
|
4322
|
+
}
|
|
4323
|
+
function isTemplateBindingValueArray(value) {
|
|
4324
|
+
return Array.isArray(value);
|
|
4325
|
+
}
|
|
4326
|
+
function readFallbackOptions(binding) {
|
|
4327
|
+
if (isTemplateBindingValueArray(binding.options)) return binding.options;
|
|
4328
|
+
const legacy = binding.sources;
|
|
4329
|
+
return isTemplateBindingValueArray(legacy) ? legacy : [];
|
|
4330
|
+
}
|
|
4331
|
+
function resolveTransformInput(binding) {
|
|
4332
|
+
if (binding.input !== undefined) {
|
|
4333
|
+
if (typeof binding.input === "string") {
|
|
4334
|
+
return {
|
|
4335
|
+
source: "field",
|
|
4336
|
+
path: binding.input
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
return binding.input;
|
|
4340
|
+
}
|
|
4341
|
+
if (typeof binding.path === "string") {
|
|
4342
|
+
return {
|
|
4343
|
+
source: "field",
|
|
4344
|
+
path: binding.path
|
|
4345
|
+
};
|
|
4346
|
+
}
|
|
4347
|
+
return null;
|
|
4348
|
+
}
|
|
4349
|
+
/**
|
|
4350
|
+
* Validate every binding on one block and assert that every required manifest
|
|
4351
|
+
* field has a binding present.
|
|
4352
|
+
*/
|
|
4353
|
+
function validateBlockBindings(block, schema, manifest) {
|
|
4354
|
+
const blockIdentifier = block.identifier ?? block.blockKind;
|
|
4355
|
+
if (block.scope === "template") return [];
|
|
4356
|
+
const errors = [];
|
|
4357
|
+
const bindings = block.bindings ?? {};
|
|
4358
|
+
const targetByKey = new Map(manifest.fields.map((target) => [target.id, target]));
|
|
4359
|
+
for (const [fieldKey, binding] of Object.entries(bindings)) {
|
|
4360
|
+
if (binding === null || binding === undefined) continue;
|
|
4361
|
+
if (isRootContextBinding(binding)) continue;
|
|
4362
|
+
const target = targetByKey.get(fieldKey);
|
|
4363
|
+
if (!target) {
|
|
4364
|
+
errors.push({
|
|
4365
|
+
kind: "invalidBindingKey",
|
|
4366
|
+
blockIdentifier,
|
|
4367
|
+
blockKind: block.blockKind,
|
|
4368
|
+
fieldKey
|
|
4369
|
+
});
|
|
4370
|
+
continue;
|
|
4371
|
+
}
|
|
4372
|
+
errors.push(...validateBinding(binding, {
|
|
4373
|
+
blockIdentifier,
|
|
4374
|
+
fieldKey,
|
|
4375
|
+
target,
|
|
4376
|
+
schema,
|
|
4377
|
+
targetByKey,
|
|
4378
|
+
blockKind: block.blockKind
|
|
4379
|
+
}));
|
|
4380
|
+
}
|
|
4381
|
+
for (const target of manifest.fields) {
|
|
4382
|
+
if (!target.required) continue;
|
|
4383
|
+
const value = bindings[target.id];
|
|
4384
|
+
if (value === undefined || value === null) {
|
|
4385
|
+
errors.push({
|
|
4386
|
+
kind: "requiredBindingMissing",
|
|
4387
|
+
blockIdentifier,
|
|
4388
|
+
blockKind: block.blockKind,
|
|
4389
|
+
fieldKey: target.id
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
return errors;
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Validate every block in a template definition.
|
|
4397
|
+
*
|
|
4398
|
+
* Unknown block kinds are surfaced as `unknownBlockKind` errors and that
|
|
4399
|
+
* block is skipped for further checks (the rest of the template is still
|
|
4400
|
+
* validated, so callers see all problems at once).
|
|
4401
|
+
*/
|
|
4402
|
+
function validateTemplateBindings(template, schema, getManifest) {
|
|
4403
|
+
const errors = [];
|
|
4404
|
+
for (const [index, block] of template.blocks.entries()) {
|
|
4405
|
+
const blockIdentifier = block.identifier ?? `${block.blockKind}#${index}`;
|
|
4406
|
+
if (block.scope === "template") continue;
|
|
4407
|
+
const manifest = getManifest(block.blockKind);
|
|
4408
|
+
if (!manifest) {
|
|
4409
|
+
errors.push({
|
|
4410
|
+
kind: "unknownBlockKind",
|
|
4411
|
+
blockIdentifier,
|
|
4412
|
+
blockKind: block.blockKind
|
|
4413
|
+
});
|
|
4414
|
+
continue;
|
|
4415
|
+
}
|
|
4416
|
+
errors.push(...validateBlockBindings({
|
|
4417
|
+
...block,
|
|
4418
|
+
identifier: blockIdentifier
|
|
4419
|
+
}, schema, manifest));
|
|
4420
|
+
}
|
|
4421
|
+
return errors;
|
|
4422
|
+
}
|
|
4423
|
+
function matchBindingError(error, handlers) {
|
|
4424
|
+
const handler = handlers[error.kind];
|
|
4425
|
+
return handler(error);
|
|
4426
|
+
}
|
|
4427
|
+
/**
|
|
4428
|
+
* Human-readable rendering of a validation error. Suitable for editor
|
|
4429
|
+
* inline-error display and audit reports. Server-side error mappers should
|
|
4430
|
+
* use the error's `kind` discriminator (via `matchBindingError`) instead
|
|
4431
|
+
* of string-matching the message.
|
|
4432
|
+
*/
|
|
4433
|
+
function bindingValidationErrorMessage(error) {
|
|
4434
|
+
return matchBindingError(error, {
|
|
4435
|
+
unknownBlockKind: (e) => `Unknown block kind "${e.blockKind}"`,
|
|
4436
|
+
invalidBindingKey: (e) => `Binding key "${e.fieldKey}" is not a field of block "${e.blockKind}"`,
|
|
4437
|
+
invalidFieldPath: (e) => `Field path "${e.path}" does not exist in the content type`,
|
|
4438
|
+
invalidMetaPath: (e) => `Meta path "${e.path}" is not a known entry meta field`,
|
|
4439
|
+
typeIncompatible: (e) => {
|
|
4440
|
+
const ctx = e.via === "transformOutput" ? " (transform output)" : e.via === "fallbackOption" ? " (fallback option)" : "";
|
|
4441
|
+
const pathSuffix = e.sourcePath ? ` (path "${e.sourcePath}")` : "";
|
|
4442
|
+
return `Source type "${e.sourceType}" is not compatible with target field type "${e.targetType}"${pathSuffix}${ctx}`;
|
|
4443
|
+
},
|
|
4444
|
+
unknownTransform: (e) => `Unknown transform "${e.transform}"`,
|
|
4445
|
+
transformInputIncompatible: (e) => `Transform "${e.transform}" does not accept input type "${e.inputType}" (accepts: ${e.acceptedTypes.join(", ")})`,
|
|
4446
|
+
requiredBindingMissing: (e) => `Required field "${e.fieldKey}" of block "${e.blockKind}" has no binding`
|
|
4447
|
+
});
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
//#endregion
|
|
4451
|
+
//#region ../content-model/src/templates/manifestAdapter.ts
|
|
4452
|
+
const FIELD_TYPE_CLASSIFICATION = {
|
|
4453
|
+
text: "leaf",
|
|
4454
|
+
inlineText: "leaf",
|
|
4455
|
+
number: "leaf",
|
|
4456
|
+
slug: "leaf",
|
|
4457
|
+
url: "leaf",
|
|
4458
|
+
richText: "leaf",
|
|
4459
|
+
media: "leaf",
|
|
4460
|
+
file: "leaf",
|
|
4461
|
+
boolean: "leaf",
|
|
4462
|
+
select: "leaf",
|
|
4463
|
+
reference: "leaf",
|
|
4464
|
+
link: "leaf",
|
|
4465
|
+
date: "leaf",
|
|
4466
|
+
datetime: "leaf",
|
|
4467
|
+
group: "composite",
|
|
4468
|
+
modal: "composite",
|
|
4469
|
+
tabGroup: "composite",
|
|
4470
|
+
repeater: "whole",
|
|
4471
|
+
presetOrCustom: "opaque",
|
|
4472
|
+
contentTypeSelect: "opaque",
|
|
4473
|
+
entryPicker: "opaque",
|
|
4474
|
+
time: "opaque"
|
|
4475
|
+
};
|
|
4476
|
+
function classify(type) {
|
|
4477
|
+
return FIELD_TYPE_CLASSIFICATION[type] ?? "unknown";
|
|
4478
|
+
}
|
|
4479
|
+
function leafToBlockFieldType(type) {
|
|
4480
|
+
return classify(type) === "leaf" ? type : null;
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
4483
|
+
* Recursively collect every bindable leaf in a field tree, emitting
|
|
4484
|
+
* dot-notated ids. `prefix` carries the parent path during recursion.
|
|
4485
|
+
*/
|
|
4486
|
+
function collectLeaves(fields, prefix, out) {
|
|
4487
|
+
if (!fields) return;
|
|
4488
|
+
for (const field of fields) {
|
|
4489
|
+
const id = prefix ? `${prefix}.${field.id}` : field.id;
|
|
4490
|
+
const cls = classify(field.type);
|
|
4491
|
+
if (cls === "leaf") {
|
|
4492
|
+
out.push({
|
|
4493
|
+
id,
|
|
4494
|
+
type: field.type,
|
|
4495
|
+
required: Boolean(field.required)
|
|
4496
|
+
});
|
|
4497
|
+
continue;
|
|
4498
|
+
}
|
|
4499
|
+
if (cls === "whole") {
|
|
4500
|
+
out.push({
|
|
4501
|
+
id,
|
|
4502
|
+
type: "repeater",
|
|
4503
|
+
required: Boolean(field.required),
|
|
4504
|
+
shape: buildManifestRepeaterShape(field)
|
|
4505
|
+
});
|
|
4506
|
+
continue;
|
|
4507
|
+
}
|
|
4508
|
+
if (cls === "composite") {
|
|
4509
|
+
const groupChildren = field.schema?.fields;
|
|
4510
|
+
if (groupChildren) collectLeaves(groupChildren, id, out);
|
|
4511
|
+
for (const tab of field.tabs ?? []) {
|
|
4512
|
+
if (tab.fields) collectLeaves(tab.fields, id, out);
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
4515
|
+
void leafToBlockFieldType;
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
function buildManifestRepeaterShape(field) {
|
|
4519
|
+
if (field.polymorphic || field.itemTypes) {
|
|
4520
|
+
return {
|
|
4521
|
+
kind: "unsupported",
|
|
4522
|
+
reason: "polymorphic_unsupported"
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4525
|
+
const fields = field.repeatedItem?.fields ?? field.schema?.fields;
|
|
4526
|
+
if (!fields || fields.length === 0) {
|
|
4527
|
+
return {
|
|
4528
|
+
kind: "unsupported",
|
|
4529
|
+
reason: "missing_item_schema"
|
|
4530
|
+
};
|
|
4531
|
+
}
|
|
4532
|
+
const itemFields = [];
|
|
4533
|
+
for (const child of fields) {
|
|
4534
|
+
if (child.type === "repeater") {
|
|
4535
|
+
return {
|
|
4536
|
+
kind: "unsupported",
|
|
4537
|
+
reason: "nested_repeater_unsupported"
|
|
4538
|
+
};
|
|
4539
|
+
}
|
|
4540
|
+
const type = leafToBlockFieldType(child.type);
|
|
4541
|
+
if (!type) {
|
|
4542
|
+
return {
|
|
4543
|
+
kind: "unsupported",
|
|
4544
|
+
reason: "unsupported_item_field"
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
itemFields.push({
|
|
4548
|
+
id: child.id,
|
|
4549
|
+
type,
|
|
4550
|
+
required: Boolean(child.required)
|
|
4551
|
+
});
|
|
4552
|
+
}
|
|
4553
|
+
return {
|
|
4554
|
+
kind: "supported",
|
|
4555
|
+
itemFields
|
|
4556
|
+
};
|
|
4557
|
+
}
|
|
4558
|
+
/**
|
|
4559
|
+
* Build the validator-facing shape from a block manifest. Composite fields
|
|
4560
|
+
* are walked; their leaves emerge with dot-notated ids. Opaque/unknown
|
|
4561
|
+
* fields are skipped.
|
|
4562
|
+
*
|
|
4563
|
+
* Accepts any value satisfying {@link AdaptableManifest}.
|
|
4564
|
+
*/
|
|
4565
|
+
function manifestToValidationShape(manifest) {
|
|
4566
|
+
const fields = [];
|
|
4567
|
+
collectLeaves(manifest.fields, "", fields);
|
|
4568
|
+
return {
|
|
4569
|
+
id: manifest.name,
|
|
4570
|
+
fields
|
|
4571
|
+
};
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
//#endregion
|
|
4575
|
+
//#region ../content-model/src/templates/auditBindings.ts
|
|
4576
|
+
/**
|
|
4577
|
+
* Build a zero-initialised counter map keyed on every error variant.
|
|
4578
|
+
*
|
|
4579
|
+
* The literal *is* the source of truth: adding a new variant to
|
|
4580
|
+
* `BindingValidationError` without a key here is a compile error. The
|
|
4581
|
+
* `ALL_ERROR_KINDS` derived list and the runtime exhaustiveness in
|
|
4582
|
+
* `emptyByKind` both flow from this single declaration.
|
|
4583
|
+
*/
|
|
4584
|
+
function emptyByKind() {
|
|
4585
|
+
const counters = {
|
|
4586
|
+
unknownBlockKind: 0,
|
|
4587
|
+
invalidBindingKey: 0,
|
|
4588
|
+
invalidFieldPath: 0,
|
|
4589
|
+
invalidMetaPath: 0,
|
|
4590
|
+
typeIncompatible: 0,
|
|
4591
|
+
unknownTransform: 0,
|
|
4592
|
+
transformInputIncompatible: 0,
|
|
4593
|
+
requiredBindingMissing: 0
|
|
4594
|
+
};
|
|
4595
|
+
return counters;
|
|
4596
|
+
}
|
|
4597
|
+
const ALL_ERROR_KINDS = Object.keys(emptyByKind());
|
|
4598
|
+
/**
|
|
4599
|
+
* Audit one site's templates. Returns a report with every finding from
|
|
4600
|
+
* every template, plus a per-`kind` summary. Pure function: no I/O, no side
|
|
4601
|
+
* effects.
|
|
4602
|
+
*/
|
|
4603
|
+
function auditSite(input, getManifest) {
|
|
4604
|
+
const findings = [];
|
|
4605
|
+
const byKind = emptyByKind();
|
|
4606
|
+
for (const ct of input.contentTypes) {
|
|
4607
|
+
for (const template of ct.templates) {
|
|
4608
|
+
const errors = validateTemplateBindings(template, ct.schema, getManifest);
|
|
4609
|
+
for (const error of errors) {
|
|
4610
|
+
findings.push({
|
|
4611
|
+
siteId: input.siteId,
|
|
4612
|
+
contentTypeKey: ct.contentTypeKey,
|
|
4613
|
+
templateKey: template.templateKey,
|
|
4614
|
+
templateName: template.name,
|
|
4615
|
+
error
|
|
4616
|
+
});
|
|
4617
|
+
byKind[error.kind] += 1;
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
return {
|
|
4622
|
+
siteId: input.siteId,
|
|
4623
|
+
findings,
|
|
4624
|
+
byKind
|
|
4625
|
+
};
|
|
4626
|
+
}
|
|
4627
|
+
function auditSites(inputs, getManifest) {
|
|
4628
|
+
const reports = inputs.map((input) => auditSite(input, getManifest));
|
|
4629
|
+
const totalsByKind = emptyByKind();
|
|
4630
|
+
let totalFindings = 0;
|
|
4631
|
+
for (const report of reports) {
|
|
4632
|
+
totalFindings += report.findings.length;
|
|
4633
|
+
for (const kind of ALL_ERROR_KINDS) {
|
|
4634
|
+
totalsByKind[kind] += report.byKind[kind];
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
return {
|
|
4638
|
+
reports,
|
|
4639
|
+
totalFindings,
|
|
4640
|
+
totalsByKind
|
|
4641
|
+
};
|
|
4642
|
+
}
|
|
4643
|
+
/**
|
|
4644
|
+
* Render a `MultiSiteAuditReport` as a markdown document suitable for
|
|
4645
|
+
* committing to `.project/audits/`. Stable ordering: sites by id, findings
|
|
4646
|
+
* within a site by content-type → template → block-identifier → kind.
|
|
4647
|
+
*/
|
|
4648
|
+
function formatAuditReportMarkdown(report, options) {
|
|
4649
|
+
const lines = [];
|
|
4650
|
+
const iso = options.generatedAt.toISOString();
|
|
4651
|
+
lines.push(`# Template binding audit — ${options.environment}`);
|
|
4652
|
+
lines.push("");
|
|
4653
|
+
lines.push(`Generated: ${iso}`);
|
|
4654
|
+
lines.push(`Total findings: **${report.totalFindings}**`);
|
|
4655
|
+
lines.push("");
|
|
4656
|
+
if (report.totalFindings === 0) {
|
|
4657
|
+
lines.push("No binding violations found. ✅");
|
|
4658
|
+
lines.push("");
|
|
4659
|
+
return lines.join("\n");
|
|
4660
|
+
}
|
|
4661
|
+
lines.push("## Summary by error kind");
|
|
4662
|
+
lines.push("");
|
|
4663
|
+
lines.push("| Kind | Count |");
|
|
4664
|
+
lines.push("| --- | ---: |");
|
|
4665
|
+
for (const kind of ALL_ERROR_KINDS) {
|
|
4666
|
+
const count = report.totalsByKind[kind];
|
|
4667
|
+
if (count > 0) lines.push(`| \`${kind}\` | ${count} |`);
|
|
4668
|
+
}
|
|
4669
|
+
lines.push("");
|
|
4670
|
+
for (const site of [...report.reports].sort((a, b) => a.siteId.localeCompare(b.siteId))) {
|
|
4671
|
+
if (site.findings.length === 0) continue;
|
|
4672
|
+
lines.push(`## Site \`${site.siteId}\` (${site.findings.length} finding${site.findings.length === 1 ? "" : "s"})`);
|
|
4673
|
+
lines.push("");
|
|
4674
|
+
const grouped = groupFindings(site.findings);
|
|
4675
|
+
for (const [contentTypeKey, templates] of grouped) {
|
|
4676
|
+
lines.push(`### Content type \`${contentTypeKey}\``);
|
|
4677
|
+
lines.push("");
|
|
4678
|
+
for (const [templateKey, findings] of templates) {
|
|
4679
|
+
const templateName = findings[0]?.templateName ?? "";
|
|
4680
|
+
lines.push(`**Template \`${templateKey}\`** — ${templateName}`);
|
|
4681
|
+
lines.push("");
|
|
4682
|
+
lines.push("| Block | Field | Kind | Detail |");
|
|
4683
|
+
lines.push("| --- | --- | --- | --- |");
|
|
4684
|
+
for (const finding of findings) {
|
|
4685
|
+
const err$1 = finding.error;
|
|
4686
|
+
const blockId = "blockIdentifier" in err$1 ? err$1.blockIdentifier : "-";
|
|
4687
|
+
const fieldKey = "fieldKey" in err$1 ? err$1.fieldKey : "-";
|
|
4688
|
+
const detail = describeError(err$1).replace(/\|/g, "\\|");
|
|
4689
|
+
lines.push(`| \`${blockId}\` | \`${fieldKey}\` | \`${err$1.kind}\` | ${detail} |`);
|
|
4690
|
+
}
|
|
4691
|
+
lines.push("");
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
return lines.join("\n");
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* Group findings by content type → template, preserving stable ordering
|
|
4699
|
+
* inside each group. Pure helper for the formatter; exposed for tests.
|
|
4700
|
+
*/
|
|
4701
|
+
function groupFindings(findings) {
|
|
4702
|
+
const byContentType = new Map();
|
|
4703
|
+
for (const finding of findings) {
|
|
4704
|
+
const inner = byContentType.get(finding.contentTypeKey) ?? new Map();
|
|
4705
|
+
const list = inner.get(finding.templateKey) ?? [];
|
|
4706
|
+
list.push(finding);
|
|
4707
|
+
inner.set(finding.templateKey, list);
|
|
4708
|
+
byContentType.set(finding.contentTypeKey, inner);
|
|
4709
|
+
}
|
|
4710
|
+
const ctEntries = [...byContentType.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
4711
|
+
return ctEntries.map(([ctKey, tplMap]) => {
|
|
4712
|
+
const tplEntries = [...tplMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
4713
|
+
return [ctKey, tplEntries.map(([tplKey, list]) => [tplKey, [...list].sort((a, b) => "blockIdentifier" in a.error && "blockIdentifier" in b.error ? a.error.blockIdentifier.localeCompare(b.error.blockIdentifier) || a.error.kind.localeCompare(b.error.kind) : a.error.kind.localeCompare(b.error.kind))])];
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* Audit-report rendering of a validation error.
|
|
4718
|
+
*
|
|
4719
|
+
* Delegates to the canonical `bindingValidationErrorMessage` so a single
|
|
4720
|
+
* source of truth covers both editor inline-display and audit report
|
|
4721
|
+
* surfaces — adding a new error variant updates both consumers from one
|
|
4722
|
+
* exhaustive switch.
|
|
4723
|
+
*/
|
|
4724
|
+
const describeError = bindingValidationErrorMessage;
|
|
4725
|
+
|
|
4726
|
+
//#endregion
|
|
4727
|
+
//#region ../content-model/src/templates/editorState.ts
|
|
4728
|
+
function isRecord(value) {
|
|
4729
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
4730
|
+
}
|
|
4731
|
+
function isTemplateEditorBindingDescriptor(value) {
|
|
4732
|
+
return typeof value === "string" || isRecord(value);
|
|
4733
|
+
}
|
|
4734
|
+
function normalizeTemplateEditorBindings(bindings) {
|
|
4735
|
+
const next = {};
|
|
4736
|
+
for (const [key, value] of Object.entries(bindings ?? {})) {
|
|
4737
|
+
if (!isTemplateEditorBindingDescriptor(value)) continue;
|
|
4738
|
+
next[key] = value;
|
|
4739
|
+
}
|
|
4740
|
+
return next;
|
|
4741
|
+
}
|
|
4742
|
+
function serializeTemplateEditorBindingsJson(bindings) {
|
|
4743
|
+
return JSON.stringify(bindings, null, 2);
|
|
4744
|
+
}
|
|
4745
|
+
function parseTemplateEditorBindingsJson(jsonValue) {
|
|
4746
|
+
try {
|
|
4747
|
+
const parsed = JSON.parse(jsonValue);
|
|
4748
|
+
if (!isRecord(parsed)) {
|
|
4749
|
+
return {
|
|
4750
|
+
ok: false,
|
|
4751
|
+
message: "Bindings must be an object"
|
|
4752
|
+
};
|
|
4753
|
+
}
|
|
4754
|
+
const bindings = normalizeTemplateEditorBindings(parsed);
|
|
4755
|
+
if (Object.keys(bindings).length !== Object.keys(parsed).length) {
|
|
4756
|
+
return {
|
|
4757
|
+
ok: false,
|
|
4758
|
+
message: "Binding values must be strings or objects"
|
|
4759
|
+
};
|
|
4760
|
+
}
|
|
4761
|
+
return {
|
|
4762
|
+
ok: true,
|
|
4763
|
+
bindings
|
|
4764
|
+
};
|
|
4765
|
+
} catch (error) {
|
|
4766
|
+
return {
|
|
4767
|
+
ok: false,
|
|
4768
|
+
message: error instanceof Error ? error.message : "Invalid JSON"
|
|
4769
|
+
};
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
function mergeTemplateBindingEdits(currentBindings, editedBindings) {
|
|
4773
|
+
return {
|
|
4774
|
+
...currentBindings,
|
|
4775
|
+
...editedBindings
|
|
4776
|
+
};
|
|
4777
|
+
}
|
|
4778
|
+
function setTemplateEditorBinding(bindings, propId, binding) {
|
|
4779
|
+
return {
|
|
4780
|
+
...bindings,
|
|
4781
|
+
[propId]: binding
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
function removeTemplateEditorBinding(bindings, propId) {
|
|
4785
|
+
const next = { ...bindings };
|
|
4786
|
+
delete next[propId];
|
|
4787
|
+
return next;
|
|
4788
|
+
}
|
|
4789
|
+
function createEntryTemplateBindingRecords(blocks) {
|
|
4790
|
+
const next = {};
|
|
4791
|
+
for (const block of blocks) {
|
|
4792
|
+
if (block.scope !== "entry") continue;
|
|
4793
|
+
next[block.id] = normalizeTemplateEditorBindings(block.bindings);
|
|
4794
|
+
}
|
|
4795
|
+
return next;
|
|
4796
|
+
}
|
|
4797
|
+
function setTemplateBlockBinding(allBindings, blockId, propId, binding) {
|
|
4798
|
+
return {
|
|
4799
|
+
...allBindings,
|
|
4800
|
+
[blockId]: setTemplateEditorBinding(allBindings[blockId] ?? {}, propId, binding)
|
|
4801
|
+
};
|
|
4802
|
+
}
|
|
4803
|
+
function removeTemplateBlockBinding(allBindings, blockId, propId) {
|
|
4804
|
+
return {
|
|
4805
|
+
...allBindings,
|
|
4806
|
+
[blockId]: removeTemplateEditorBinding(allBindings[blockId] ?? {}, propId)
|
|
4807
|
+
};
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4810
|
+
//#endregion
|
|
4811
|
+
//#region ../content-model/src/entry-slots/sdkBlocks.ts
|
|
4812
|
+
const defaultContentSchema = z.record(z.string(), z.unknown());
|
|
4813
|
+
function createSdkEntrySlotBlockInputSchema(contentSchema = defaultContentSchema) {
|
|
4814
|
+
return z.object({
|
|
4815
|
+
identifier: z.string().nullable().optional(),
|
|
4816
|
+
kind: z.string().min(1, "Block kind is required"),
|
|
4817
|
+
content: contentSchema
|
|
4818
|
+
});
|
|
4819
|
+
}
|
|
4820
|
+
function createSdkEntrySlotBlocksInputSchema(contentSchema) {
|
|
4821
|
+
return z.record(z.string(), z.array(createSdkEntrySlotBlockInputSchema(contentSchema)));
|
|
4822
|
+
}
|
|
4823
|
+
const sdkEntrySlotBlockInputSchema = createSdkEntrySlotBlockInputSchema();
|
|
4824
|
+
const sdkEntrySlotBlocksInputSchema = createSdkEntrySlotBlocksInputSchema();
|
|
4825
|
+
|
|
4826
|
+
//#endregion
|
|
4827
|
+
//#region src/commands/eventUpsertInputSchema.ts
|
|
4828
|
+
const eventScheduleInputSchema = z.object({
|
|
4829
|
+
recurrenceRule: z.string().nullable().optional(),
|
|
4830
|
+
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
|
4831
|
+
defaultStartTime: z.string().min(1).optional(),
|
|
4832
|
+
durationMinutes: z.number().int().positive().optional(),
|
|
4833
|
+
startsAt: z.string().min(1).optional(),
|
|
4834
|
+
endsAt: z.string().min(1).optional()
|
|
4835
|
+
}).superRefine((schedule, ctx) => {
|
|
4836
|
+
const result = classifySdkEventSchedule(schedule, { requireCanonicalFields: true });
|
|
4837
|
+
if (result.ok) return;
|
|
4838
|
+
result.issues.forEach((issue) => ctx.addIssue({
|
|
4839
|
+
code: "custom",
|
|
4840
|
+
path: [...issue.path],
|
|
4841
|
+
message: issue.message
|
|
4842
|
+
}));
|
|
4843
|
+
}).transform((schedule) => {
|
|
4844
|
+
const result = classifySdkEventSchedule(schedule, { requireCanonicalFields: true });
|
|
4845
|
+
if (!result.ok) throw new Error("Unreachable invalid event schedule");
|
|
4846
|
+
if (result.kind === "start-end") {
|
|
4847
|
+
if (typeof schedule.startsAt !== "string" || typeof schedule.endsAt !== "string") throw new Error("Unreachable invalid start/end event schedule");
|
|
4848
|
+
return {
|
|
4849
|
+
startsAt: schedule.startsAt,
|
|
4850
|
+
endsAt: schedule.endsAt
|
|
4851
|
+
};
|
|
4852
|
+
}
|
|
4853
|
+
if (result.kind === "start-duration") {
|
|
4854
|
+
if (typeof schedule.startsAt !== "string" || typeof schedule.durationMinutes !== "number") throw new Error("Unreachable invalid start/duration event schedule");
|
|
4855
|
+
return {
|
|
4856
|
+
startsAt: schedule.startsAt,
|
|
4857
|
+
durationMinutes: schedule.durationMinutes
|
|
4858
|
+
};
|
|
4859
|
+
}
|
|
4860
|
+
return result.canonical;
|
|
4861
|
+
});
|
|
4862
|
+
function classifiedSchema(classify$1) {
|
|
4863
|
+
return z.unknown().superRefine((input, ctx) => {
|
|
4864
|
+
const result = classify$1(input);
|
|
4865
|
+
if (result.ok) return;
|
|
4866
|
+
result.issues.forEach((issue) => ctx.addIssue({
|
|
4867
|
+
code: "custom",
|
|
4868
|
+
path: issue.path.map(String),
|
|
4869
|
+
message: issue.message
|
|
4870
|
+
}));
|
|
4871
|
+
}).transform((input) => {
|
|
4872
|
+
const result = classify$1(input);
|
|
4873
|
+
if (!result.ok) throw new Error("Unreachable invalid classified input");
|
|
4874
|
+
return result.value;
|
|
4875
|
+
});
|
|
4876
|
+
}
|
|
4877
|
+
const eventPricingInputSchema = classifiedSchema((input) => {
|
|
4878
|
+
const result = classifySdkEventPricing(input);
|
|
4879
|
+
return result.ok ? {
|
|
4880
|
+
ok: true,
|
|
4881
|
+
value: result.pricing
|
|
4882
|
+
} : result;
|
|
4883
|
+
});
|
|
4884
|
+
const eventPaymentTermsInputSchema = classifiedSchema((input) => {
|
|
4885
|
+
const result = classifySdkEventPaymentTerms(input);
|
|
4886
|
+
return result.ok ? {
|
|
4887
|
+
ok: true,
|
|
4888
|
+
value: result.paymentTerms
|
|
4889
|
+
} : result;
|
|
4890
|
+
});
|
|
4891
|
+
const eventRunInputSchema = z.object({
|
|
4892
|
+
identifier: z.string().min(1),
|
|
4893
|
+
eventRef: z.string().min(1),
|
|
4894
|
+
name: z.string().nullable().optional(),
|
|
4895
|
+
status: sdkEventUpsertOptionalFieldSchemas.status,
|
|
4896
|
+
visibility: sdkEventUpsertOptionalFieldSchemas.visibility,
|
|
4897
|
+
bookingScope: z.enum(["single_occurrence", "whole_series"]),
|
|
4898
|
+
occurrences: sdkEventUpsertOptionalFieldSchemas.occurrences.unwrap(),
|
|
4899
|
+
schedule: eventScheduleInputSchema.optional(),
|
|
4900
|
+
pricing: eventPricingInputSchema.optional(),
|
|
4901
|
+
paymentTerms: eventPaymentTermsInputSchema.optional(),
|
|
4902
|
+
capacity: sdkEventUpsertOptionalFieldSchemas.capacity,
|
|
4903
|
+
venueRef: z.string().nullable().optional(),
|
|
4904
|
+
categoryRef: z.string().nullable().optional(),
|
|
4905
|
+
formRef: z.string().nullable().optional(),
|
|
4906
|
+
settings: z.record(z.string(), z.unknown()).optional(),
|
|
4907
|
+
presentation: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
4908
|
+
tags: z.array(z.string()).optional()
|
|
4909
|
+
});
|
|
4910
|
+
const eventUpsertInputSchema = z.object({
|
|
4911
|
+
identifier: z.string().min(1).optional(),
|
|
4912
|
+
title: z.string().min(1, "title is required"),
|
|
4913
|
+
slug: z.string().min(1).optional(),
|
|
4914
|
+
content: z.record(z.string(), z.unknown()).optional(),
|
|
4915
|
+
blocks: sdkEntrySlotBlocksInputSchema.optional(),
|
|
4916
|
+
preset: z.enum([
|
|
4917
|
+
"event",
|
|
4918
|
+
"class",
|
|
4919
|
+
"course",
|
|
4920
|
+
"workshop",
|
|
4921
|
+
"retreat",
|
|
4922
|
+
"performance"
|
|
4923
|
+
]),
|
|
4924
|
+
runRefs: z.tuple([z.string().min(1)], z.string().min(1)),
|
|
4925
|
+
runs: z.tuple([eventRunInputSchema], eventRunInputSchema)
|
|
4926
|
+
}).superRefine((input, ctx) => input.runs.forEach((run, index) => {
|
|
4927
|
+
if (new Set(run.occurrences.map((item) => item.timeZone)).size <= 1) return;
|
|
4928
|
+
ctx.addIssue({
|
|
4929
|
+
code: "custom",
|
|
4930
|
+
path: [
|
|
4931
|
+
"runs",
|
|
4932
|
+
index,
|
|
4933
|
+
"occurrences"
|
|
4934
|
+
],
|
|
4935
|
+
message: "all occurrences must use the same timeZone"
|
|
4936
|
+
});
|
|
4937
|
+
}));
|
|
4938
|
+
|
|
4939
|
+
//#endregion
|
|
4940
|
+
export { RichTextDocSchema as a, isRecord$3 as c, findUnfilledSentinels as i, buildEntryScaffold as n, isRichTextDoc as o, validateEntryInstance as r, isObjectRecord as s, eventUpsertInputSchema as t };
|