@nextbridgehq/payload-block-builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1199 @@
1
+ // src/plugin.ts
2
+ import { randomUUID } from "crypto";
3
+
4
+ // src/access/authenticated.ts
5
+ var authenticated = ({ req: { user } }) => Boolean(user);
6
+
7
+ // src/collections/BlockDefinitions.ts
8
+ var BlockDefinitions = {
9
+ slug: "block-definitions",
10
+ admin: {
11
+ useAsTitle: "name",
12
+ defaultColumns: ["name", "slug", "currentVersion", "updatedAt"],
13
+ group: "Dynamic Blocks",
14
+ hidden: true
15
+ },
16
+ access: {
17
+ create: authenticated,
18
+ read: authenticated,
19
+ update: authenticated,
20
+ delete: authenticated
21
+ },
22
+ fields: [
23
+ {
24
+ name: "name",
25
+ type: "text",
26
+ required: true,
27
+ admin: { description: 'Human-readable label, e.g. "Hero Section"' }
28
+ },
29
+ {
30
+ name: "slug",
31
+ type: "text",
32
+ required: true,
33
+ unique: true,
34
+ admin: { description: 'Machine identifier, e.g. "hero-section". Should not change after creation.' }
35
+ },
36
+ {
37
+ name: "description",
38
+ type: "textarea",
39
+ required: false
40
+ },
41
+ {
42
+ name: "currentVersion",
43
+ type: "relationship",
44
+ relationTo: "block-definition-versions",
45
+ required: false,
46
+ admin: {
47
+ description: "The active schema version used when editors add a new instance of this block."
48
+ }
49
+ },
50
+ {
51
+ name: "editInBuilderButton",
52
+ type: "ui",
53
+ admin: {
54
+ components: {
55
+ Field: "@nextbridgehq/payload-block-builder/client#EditInBuilderButton"
56
+ }
57
+ }
58
+ }
59
+ ]
60
+ };
61
+
62
+ // src/collections/BlockDefinitionVersions.ts
63
+ var BlockDefinitionVersions = {
64
+ slug: "block-definition-versions",
65
+ admin: {
66
+ useAsTitle: "label",
67
+ defaultColumns: ["blockDefinition", "versionNumber", "label", "createdAt"],
68
+ group: "Dynamic Blocks",
69
+ hidden: true
70
+ },
71
+ access: {
72
+ create: authenticated,
73
+ read: authenticated,
74
+ update: () => false,
75
+ delete: authenticated
76
+ },
77
+ fields: [
78
+ {
79
+ name: "blockDefinition",
80
+ type: "relationship",
81
+ relationTo: "block-definitions",
82
+ required: true,
83
+ admin: { description: "Which block type this version belongs to." }
84
+ },
85
+ {
86
+ name: "versionNumber",
87
+ type: "number",
88
+ required: true,
89
+ admin: { description: "Monotonically increasing integer, e.g. 1, 2, 3." }
90
+ },
91
+ {
92
+ name: "label",
93
+ type: "text",
94
+ required: false,
95
+ admin: { description: 'Optional display name, e.g. "v2 \xE2\u20AC\u201D added hero image".' }
96
+ },
97
+ {
98
+ name: "schema",
99
+ type: "json",
100
+ required: true,
101
+ admin: {
102
+ description: 'JSON object with a "fields" array of BlockFieldDefinition objects. Use the visual editor below to build the schema.',
103
+ components: {
104
+ Field: "@nextbridgehq/payload-block-builder/client#SchemaBuilderField"
105
+ }
106
+ }
107
+ },
108
+ {
109
+ name: "changelog",
110
+ type: "textarea",
111
+ required: false,
112
+ admin: { description: "Notes on what changed in this version." }
113
+ }
114
+ ]
115
+ };
116
+
117
+ // src/fields/dbLayoutField.ts
118
+ function dbLayoutField(fieldName = "dbLayout", tabLabel = "DB Layout") {
119
+ return {
120
+ label: tabLabel,
121
+ fields: [
122
+ {
123
+ name: fieldName,
124
+ type: "array",
125
+ label: "DB Layout Blocks",
126
+ admin: {
127
+ description: "Dynamic blocks rendered on this page. Add blocks, select a block definition and version, then fill in the data fields that appear.",
128
+ initCollapsed: true
129
+ },
130
+ fields: [
131
+ {
132
+ name: "blockDefinition",
133
+ type: "relationship",
134
+ relationTo: "block-definitions",
135
+ required: true,
136
+ admin: { description: "Which block type to use." }
137
+ },
138
+ {
139
+ name: "blockVersion",
140
+ type: "relationship",
141
+ relationTo: "block-definition-versions",
142
+ required: true,
143
+ admin: { description: "Which schema version to use." }
144
+ },
145
+ {
146
+ name: "instanceId",
147
+ type: "text",
148
+ admin: {
149
+ hidden: true,
150
+ readOnly: true,
151
+ description: "Auto-assigned unique ID for this block instance."
152
+ }
153
+ },
154
+ {
155
+ name: "label",
156
+ type: "text",
157
+ admin: {
158
+ description: "Optional label to identify this block in the list."
159
+ }
160
+ },
161
+ {
162
+ name: "data",
163
+ type: "json",
164
+ admin: {
165
+ description: "Block field data. Automatically populated from the selected schema.",
166
+ components: {
167
+ Field: "@nextbridgehq/payload-block-builder/client#BlockDataField"
168
+ }
169
+ }
170
+ },
171
+ {
172
+ name: "hidden",
173
+ type: "checkbox",
174
+ defaultValue: false,
175
+ admin: { description: "Hide this block on the frontend." }
176
+ },
177
+ {
178
+ name: "anchor",
179
+ type: "text",
180
+ admin: { description: "Optional HTML anchor ID for deep-linking." }
181
+ }
182
+ ]
183
+ }
184
+ ]
185
+ };
186
+ }
187
+
188
+ // src/block-builder/lib/codegen.ts
189
+ function indent(n) {
190
+ return " ".repeat(n);
191
+ }
192
+ function escStr(s) {
193
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
194
+ }
195
+ function fieldToCode(field, depth = 1) {
196
+ const pad = indent(depth);
197
+ const innerPad = indent(depth + 1);
198
+ const lines = [];
199
+ lines.push(`${pad}name: '${escStr(field.name)}'`);
200
+ lines.push(`${pad}type: '${field.type}'`);
201
+ if (field.label) lines.push(`${pad}label: '${escStr(field.label)}'`);
202
+ if (field.required) lines.push(`${pad}required: true`);
203
+ if (field.unique) lines.push(`${pad}unique: true`);
204
+ if (field.localized) lines.push(`${pad}localized: true`);
205
+ if (field.defaultValue !== void 0) {
206
+ const val = typeof field.defaultValue === "string" ? `'${escStr(String(field.defaultValue))}'` : field.defaultValue;
207
+ lines.push(`${pad}defaultValue: ${val}`);
208
+ }
209
+ if (field.type === "richText") {
210
+ lines.push(`${pad}editor: lexicalEditor({})`);
211
+ }
212
+ if (field.options && field.options.length > 0) {
213
+ const opts = field.options.map((o) => `{ label: '${escStr(o.label)}', value: '${escStr(o.value)}' }`).join(`, `);
214
+ lines.push(`${pad}options: [${opts}]`);
215
+ }
216
+ if (field.relationTo) {
217
+ lines.push(`${pad}relationTo: '${escStr(field.relationTo)}'`);
218
+ }
219
+ if (field.hasMany !== void 0) {
220
+ lines.push(`${pad}hasMany: ${field.hasMany}`);
221
+ }
222
+ if (field.minRows !== void 0) lines.push(`${pad}minRows: ${field.minRows}`);
223
+ if (field.maxRows !== void 0) lines.push(`${pad}maxRows: ${field.maxRows}`);
224
+ if (field.fields && field.fields.length > 0) {
225
+ const nested = field.fields.map((f) => `${innerPad}{
226
+ ${fieldToCode(f, depth + 2)}
227
+ ${innerPad}}`).join(",\n");
228
+ lines.push(`${pad}fields: [
229
+ ${nested}
230
+ ${innerPad}]`);
231
+ }
232
+ const adminParts = [];
233
+ if (field.admin?.description)
234
+ adminParts.push(`description: '${escStr(field.admin.description)}'`);
235
+ if (field.admin?.placeholder)
236
+ adminParts.push(`placeholder: '${escStr(field.admin.placeholder)}'`);
237
+ if (field.admin?.readOnly) adminParts.push(`readOnly: true`);
238
+ if (field.admin?.hidden) adminParts.push(`hidden: true`);
239
+ if (adminParts.length > 0) {
240
+ lines.push(`${pad}admin: { ${adminParts.join(", ")} }`);
241
+ }
242
+ return lines.join(",\n");
243
+ }
244
+ function generateBlockCode(block) {
245
+ const hasRichText = containsRichText(block.fields);
246
+ const imports = [`import type { Block } from 'payload'`];
247
+ if (hasRichText) {
248
+ imports.push(`import { lexicalEditor } from '@payloadcms/richtext-lexical'`);
249
+ }
250
+ const fieldsCode = block.fields.map((f) => ` {
251
+ ${fieldToCode(f, 2)}
252
+ }`).join(",\n");
253
+ const labelsCode = block.labels ? `
254
+ labels: {
255
+ singular: '${escStr(block.labels.singular ?? block.slug)}',
256
+ plural: '${escStr(block.labels.plural ?? block.slug + "s")}',
257
+ },` : "";
258
+ const interfaceLine = block.interfaceName ? `
259
+ interfaceName: '${escStr(block.interfaceName)}',` : "";
260
+ const exportName = block.interfaceName ?? toCamelCase(block.slug);
261
+ return [
262
+ imports.join("\n"),
263
+ "",
264
+ `export const ${exportName}: Block = {`,
265
+ ` slug: '${escStr(block.slug)}',${interfaceLine}${labelsCode}`,
266
+ ` fields: [`,
267
+ fieldsCode,
268
+ ` ],`,
269
+ `}`,
270
+ ""
271
+ ].join("\n");
272
+ }
273
+ function containsRichText(fields) {
274
+ return fields.some(
275
+ (f) => f.type === "richText" || (f.fields ? containsRichText(f.fields) : false)
276
+ );
277
+ }
278
+ function toCamelCase(slug) {
279
+ return slug.split(/[-_]/).map(
280
+ (part, i) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
281
+ ).join("");
282
+ }
283
+ function generateBlockOutput(block) {
284
+ return {
285
+ filename: `${block.slug}.ts`,
286
+ code: generateBlockCode(block),
287
+ language: "typescript"
288
+ };
289
+ }
290
+ function generateAllBlocks(blocks) {
291
+ return blocks.map(generateBlockOutput);
292
+ }
293
+ function generateIndexFile(blocks) {
294
+ const exportName = (b) => b.interfaceName ?? toCamelCase(b.slug);
295
+ const imports = blocks.map((b) => `import { ${exportName(b)} } from './${b.slug}'`).join("\n");
296
+ const exportList = blocks.map((b) => ` ${exportName(b)}`).join(",\n");
297
+ const code = [
298
+ imports,
299
+ "",
300
+ `export const blocks = [`,
301
+ exportList,
302
+ `] as const`,
303
+ ""
304
+ ].join("\n");
305
+ return { filename: "index.ts", code, language: "typescript" };
306
+ }
307
+
308
+ // src/endpoints/generate.ts
309
+ var generateEndpoint = async (req) => {
310
+ if (!req.user) {
311
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
312
+ }
313
+ let blocks;
314
+ try {
315
+ if (!req.json) return Response.json({ error: "No JSON parser available" }, { status: 500 });
316
+ const body = await req.json();
317
+ blocks = body.blocks ?? [];
318
+ } catch {
319
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
320
+ }
321
+ const blockOutputs = generateAllBlocks(blocks);
322
+ const indexOutput = generateIndexFile(blocks);
323
+ const fileMap = {};
324
+ for (const out of blockOutputs) {
325
+ fileMap[out.filename] = out.code;
326
+ }
327
+ fileMap[indexOutput.filename] = indexOutput.code;
328
+ return Response.json({ files: fileMap });
329
+ };
330
+
331
+ // src/block-builder/lib/schemaToBuilderBlock.ts
332
+ import { v4 as uuidv4 } from "uuid";
333
+ var REVERSE_TYPE_MAP = {
334
+ richtext: "richText",
335
+ image: "upload"
336
+ };
337
+ var VALID_BUILDER_TYPES = /* @__PURE__ */ new Set([
338
+ "text",
339
+ "textarea",
340
+ "number",
341
+ "email",
342
+ "checkbox",
343
+ "select",
344
+ "radio",
345
+ "date",
346
+ "richText",
347
+ "upload",
348
+ "relationship",
349
+ "array",
350
+ "group",
351
+ "tabs",
352
+ "row",
353
+ "collapsible",
354
+ "json",
355
+ "code",
356
+ "point",
357
+ "ui"
358
+ ]);
359
+ function fieldToBuilderField(raw) {
360
+ const rawType = String(raw.type ?? "text");
361
+ const mappedType = REVERSE_TYPE_MAP[rawType] ?? rawType;
362
+ const fieldType = VALID_BUILDER_TYPES.has(mappedType) ? mappedType : "text";
363
+ const field = {
364
+ id: uuidv4(),
365
+ type: fieldType,
366
+ name: String(raw.name ?? "field"),
367
+ label: raw.label ? String(raw.label) : void 0,
368
+ required: Boolean(raw.required)
369
+ };
370
+ if (raw.options && Array.isArray(raw.options)) {
371
+ field.options = raw.options.map(
372
+ (o) => typeof o === "string" ? { label: o, value: o } : { label: String(o.label), value: String(o.value) }
373
+ );
374
+ }
375
+ if (raw.hasMany !== void 0) field.hasMany = Boolean(raw.hasMany);
376
+ if (raw.collection) field.relationTo = String(raw.collection);
377
+ if (raw.relationTo) {
378
+ field.relationTo = String(raw.relationTo);
379
+ }
380
+ if (raw.minRows !== void 0) field.minRows = Number(raw.minRows);
381
+ if (raw.maxRows !== void 0) field.maxRows = Number(raw.maxRows);
382
+ if (raw.fields && Array.isArray(raw.fields)) {
383
+ field.fields = raw.fields.map(fieldToBuilderField);
384
+ }
385
+ if (raw.admin && typeof raw.admin === "object") {
386
+ const a = raw.admin;
387
+ field.admin = {
388
+ description: a.description ? String(a.description) : void 0,
389
+ placeholder: a.placeholder ? String(a.placeholder) : void 0,
390
+ readOnly: Boolean(a.readOnly),
391
+ hidden: Boolean(a.hidden)
392
+ };
393
+ }
394
+ return field;
395
+ }
396
+ function slugToInterfaceName(slug) {
397
+ return slug.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
398
+ }
399
+ function schemaToBuilderBlock(slug, name, labels, schemaFields) {
400
+ return {
401
+ id: uuidv4(),
402
+ slug,
403
+ interfaceName: slugToInterfaceName(slug),
404
+ labels,
405
+ fields: schemaFields.map(fieldToBuilderField)
406
+ };
407
+ }
408
+
409
+ // src/endpoints/load.ts
410
+ var loadEndpoint = async (req) => {
411
+ if (!req.user) {
412
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
413
+ }
414
+ const slug = req.routeParams?.slug;
415
+ if (!slug) {
416
+ return Response.json({ error: "Slug is required" }, { status: 400 });
417
+ }
418
+ const requestedVersionId = req.url ? new URL(req.url).searchParams.get("versionId") : null;
419
+ const result = await req.payload.find({
420
+ collection: "block-definitions",
421
+ where: { slug: { equals: slug } },
422
+ depth: 2,
423
+ limit: 1
424
+ });
425
+ const def = result.docs[0];
426
+ if (!def) {
427
+ return Response.json({ error: `Block definition "${slug}" not found` }, { status: 404 });
428
+ }
429
+ const name = def.name ?? slug;
430
+ const currentVersionId = def.currentVersion && typeof def.currentVersion === "object" ? String(def.currentVersion.id) : typeof def.currentVersion === "string" || typeof def.currentVersion === "number" ? String(def.currentVersion) : null;
431
+ let version = null;
432
+ if (requestedVersionId) {
433
+ try {
434
+ const v = await req.payload.findByID({
435
+ collection: "block-definition-versions",
436
+ id: requestedVersionId,
437
+ depth: 0
438
+ });
439
+ version = v;
440
+ } catch {
441
+ return Response.json({ error: `Version "${requestedVersionId}" not found` }, { status: 404 });
442
+ }
443
+ } else if (def.currentVersion && typeof def.currentVersion === "object") {
444
+ version = def.currentVersion;
445
+ } else {
446
+ const latestResult = await req.payload.find({
447
+ collection: "block-definition-versions",
448
+ where: { blockDefinition: { equals: def.id } },
449
+ sort: "-versionNumber",
450
+ depth: 0,
451
+ limit: 1
452
+ });
453
+ version = latestResult.docs[0] ?? null;
454
+ }
455
+ if (!version) {
456
+ const block2 = schemaToBuilderBlock(slug, name, {}, []);
457
+ return Response.json({ block: block2, versionId: null, versionNumber: null, isCurrent: true });
458
+ }
459
+ const versionId = String(version.id);
460
+ const schema = version.schema;
461
+ const schemaFields = schema ? Array.isArray(schema) ? schema : schema.fields ?? [] : [];
462
+ const labels = version.labels ?? {};
463
+ const versionNumber = version.versionNumber;
464
+ const block = schemaToBuilderBlock(slug, name, labels, schemaFields);
465
+ return Response.json({
466
+ block,
467
+ versionId,
468
+ versionNumber: versionNumber ?? null,
469
+ isCurrent: versionId === currentVersionId
470
+ });
471
+ };
472
+
473
+ // src/builder/normalizer.ts
474
+ var KNOWN_TYPES = /* @__PURE__ */ new Set([
475
+ "text",
476
+ "textarea",
477
+ "richtext",
478
+ "number",
479
+ "checkbox",
480
+ "select",
481
+ "multiselect",
482
+ "date",
483
+ "image",
484
+ "file",
485
+ "url",
486
+ "email",
487
+ "color",
488
+ "array",
489
+ "group",
490
+ "relationship",
491
+ "json",
492
+ "blocks"
493
+ ]);
494
+ function normaliseOption(opt) {
495
+ if (typeof opt === "string") {
496
+ return { label: opt, value: opt.toLowerCase().replace(/\s+/g, "-") };
497
+ }
498
+ if (opt && typeof opt === "object") {
499
+ const o = opt;
500
+ const value = String(o.value ?? o.label ?? "").toLowerCase().replace(/\s+/g, "-");
501
+ const label = String(o.label ?? o.value ?? value);
502
+ return { label, value };
503
+ }
504
+ return { label: String(opt), value: String(opt) };
505
+ }
506
+ function normaliseConditions(raw) {
507
+ if (!Array.isArray(raw)) return void 0;
508
+ const result = [];
509
+ for (const item of raw) {
510
+ if (item && typeof item === "object" && !Array.isArray(item)) {
511
+ const c = item;
512
+ if (typeof c.field === "string" && typeof c.operator === "string") {
513
+ result.push({ field: c.field, operator: c.operator, value: c.value });
514
+ }
515
+ }
516
+ }
517
+ return result.length > 0 ? result : void 0;
518
+ }
519
+ function normaliseValidation(raw) {
520
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
521
+ const v = raw;
522
+ const out = {};
523
+ if (typeof v.required === "boolean") out.required = v.required;
524
+ if (typeof v.minLength === "number") out.minLength = v.minLength;
525
+ if (typeof v.maxLength === "number") out.maxLength = v.maxLength;
526
+ if (typeof v.regex === "string") out.regex = v.regex;
527
+ if (typeof v.min === "number") out.min = v.min;
528
+ if (typeof v.max === "number") out.max = v.max;
529
+ if (typeof v.step === "number") out.step = v.step;
530
+ if (typeof v.integerOnly === "boolean") out.integerOnly = v.integerOnly;
531
+ if (typeof v.minRows === "number") out.minRows = v.minRows;
532
+ if (typeof v.maxRows === "number") out.maxRows = v.maxRows;
533
+ if (typeof v.uniqueItems === "boolean") out.uniqueItems = v.uniqueItems;
534
+ if (Array.isArray(v.allowedMimeTypes)) out.allowedMimeTypes = v.allowedMimeTypes;
535
+ if (typeof v.maxFileSize === "number") out.maxFileSize = v.maxFileSize;
536
+ if (typeof v.maxSelections === "number") out.maxSelections = v.maxSelections;
537
+ return Object.keys(out).length > 0 ? out : void 0;
538
+ }
539
+ function normaliseUI(raw) {
540
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
541
+ const u = raw;
542
+ const out = {};
543
+ if (typeof u.tab === "string") out.tab = u.tab;
544
+ if (typeof u.section === "string") out.section = u.section;
545
+ if (["full", "half", "third", "quarter"].includes(u.width)) {
546
+ out.width = u.width;
547
+ }
548
+ if (typeof u.collapsed === "boolean") out.collapsed = u.collapsed;
549
+ if (typeof u.order === "number") out.order = u.order;
550
+ return Object.keys(out).length > 0 ? out : void 0;
551
+ }
552
+ function normaliseField(raw) {
553
+ const type = raw.type ?? "text";
554
+ const resolvedType = KNOWN_TYPES.has(type) ? type : "text";
555
+ const base = {
556
+ name: String(raw.name ?? "").trim(),
557
+ type: resolvedType
558
+ };
559
+ if (raw.label) base.label = String(raw.label);
560
+ if (raw.required !== void 0) base.required = Boolean(raw.required);
561
+ if (raw.admin && typeof raw.admin === "object") base.admin = raw.admin;
562
+ const conditions = normaliseConditions(raw.conditions);
563
+ if (conditions) base.conditions = conditions;
564
+ if (raw.conditionMode === "AND" || raw.conditionMode === "OR") {
565
+ base.conditionMode = raw.conditionMode;
566
+ }
567
+ const validation = normaliseValidation(raw.validation);
568
+ if (validation) base.validation = validation;
569
+ const ui = normaliseUI(raw.ui);
570
+ if (ui) base.ui = ui;
571
+ switch (resolvedType) {
572
+ case "text":
573
+ case "textarea": {
574
+ if (raw.minLength !== void 0) base.minLength = Number(raw.minLength);
575
+ if (raw.maxLength !== void 0) base.maxLength = Number(raw.maxLength);
576
+ if (raw.defaultValue !== void 0) base.defaultValue = raw.defaultValue;
577
+ break;
578
+ }
579
+ case "number": {
580
+ if (raw.min !== void 0) base.min = Number(raw.min);
581
+ if (raw.max !== void 0) base.max = Number(raw.max);
582
+ if (raw.defaultValue !== void 0) base.defaultValue = raw.defaultValue;
583
+ break;
584
+ }
585
+ case "checkbox": {
586
+ if (raw.defaultValue !== void 0) base.defaultValue = Boolean(raw.defaultValue);
587
+ break;
588
+ }
589
+ case "select":
590
+ case "multiselect": {
591
+ const rawOpts = Array.isArray(raw.options) ? raw.options : [];
592
+ base.options = rawOpts.map(normaliseOption);
593
+ if (raw.defaultValue !== void 0) base.defaultValue = raw.defaultValue;
594
+ break;
595
+ }
596
+ case "date": {
597
+ if (raw.timeFormat !== void 0) base.timeFormat = Boolean(raw.timeFormat);
598
+ break;
599
+ }
600
+ case "array": {
601
+ const subFields = Array.isArray(raw.fields) ? raw.fields.map(normaliseField) : [];
602
+ base.fields = subFields;
603
+ if (raw.minRows !== void 0) base.minRows = Number(raw.minRows);
604
+ if (raw.maxRows !== void 0) base.maxRows = Number(raw.maxRows);
605
+ break;
606
+ }
607
+ case "group": {
608
+ const subFields = Array.isArray(raw.fields) ? raw.fields.map(normaliseField) : [];
609
+ base.fields = subFields;
610
+ break;
611
+ }
612
+ case "relationship": {
613
+ if (raw.collection) base.collection = String(raw.collection);
614
+ if (raw.hasMany !== void 0) base.hasMany = Boolean(raw.hasMany);
615
+ break;
616
+ }
617
+ case "file": {
618
+ if (Array.isArray(raw.allowedMimeTypes)) base.allowedMimeTypes = raw.allowedMimeTypes;
619
+ break;
620
+ }
621
+ case "blocks": {
622
+ if (Array.isArray(raw.allowedBlocks)) {
623
+ base.allowedBlocks = raw.allowedBlocks.map(String).filter(Boolean);
624
+ }
625
+ if (raw.minBlocks !== void 0) base.minBlocks = Number(raw.minBlocks);
626
+ if (raw.maxBlocks !== void 0) base.maxBlocks = Number(raw.maxBlocks);
627
+ break;
628
+ }
629
+ }
630
+ return base;
631
+ }
632
+ function normaliseSchema(raw) {
633
+ const fields = Array.isArray(raw.fields) ? raw.fields.map(normaliseField) : [];
634
+ const schema = { fields };
635
+ if (raw.layout === "sidebar" || raw.layout === "tabs") {
636
+ schema.layout = raw.layout;
637
+ } else {
638
+ schema.layout = "default";
639
+ }
640
+ return schema;
641
+ }
642
+
643
+ // src/validation/schemaValidator.ts
644
+ var VALID_FIELD_TYPES = /* @__PURE__ */ new Set([
645
+ "text",
646
+ "textarea",
647
+ "richtext",
648
+ "number",
649
+ "checkbox",
650
+ "select",
651
+ "multiselect",
652
+ "date",
653
+ "image",
654
+ "file",
655
+ "url",
656
+ "email",
657
+ "color",
658
+ "array",
659
+ "group",
660
+ "relationship",
661
+ "json",
662
+ "blocks"
663
+ ]);
664
+ var CONTAINER_TYPES = /* @__PURE__ */ new Set(["array", "group"]);
665
+ var LEAF_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
666
+ var VALID_CONDITION_OPERATORS = /* @__PURE__ */ new Set([
667
+ "equals",
668
+ "not_equals",
669
+ "contains",
670
+ "not_contains",
671
+ "greater_than",
672
+ "less_than",
673
+ "in",
674
+ "not_in",
675
+ "exists",
676
+ "empty"
677
+ ]);
678
+ function validateConditions(conditions, path, errors) {
679
+ if (!Array.isArray(conditions)) {
680
+ errors.push(`${path}: "conditions" must be an array.`);
681
+ return;
682
+ }
683
+ ;
684
+ conditions.forEach((cond, i) => {
685
+ const cp = `${path}[${i}]`;
686
+ if (!cond || typeof cond !== "object") {
687
+ errors.push(`${cp}: condition must be an object.`);
688
+ return;
689
+ }
690
+ const c = cond;
691
+ if (typeof c.field !== "string" || !c.field.trim()) {
692
+ errors.push(`${cp}: "field" must be a non-empty string.`);
693
+ }
694
+ if (typeof c.operator !== "string" || !VALID_CONDITION_OPERATORS.has(c.operator)) {
695
+ errors.push(
696
+ `${cp}: "operator" must be one of: ${[...VALID_CONDITION_OPERATORS].join(", ")}.`
697
+ );
698
+ }
699
+ });
700
+ }
701
+ function validateValidationRules(v, path, errors) {
702
+ const numericProps = [
703
+ "minLength",
704
+ "maxLength",
705
+ "min",
706
+ "max",
707
+ "step",
708
+ "minRows",
709
+ "maxRows",
710
+ "maxFileSize",
711
+ "maxSelections"
712
+ ];
713
+ for (const prop of numericProps) {
714
+ if (v[prop] !== void 0 && typeof v[prop] !== "number") {
715
+ errors.push(`${path}.${prop}: must be a number.`);
716
+ }
717
+ }
718
+ if (typeof v.minLength === "number" && typeof v.maxLength === "number" && v.minLength > v.maxLength) {
719
+ errors.push(`${path}: "minLength" (${v.minLength}) must be \xE2\u2030\xA4 "maxLength" (${v.maxLength}).`);
720
+ }
721
+ if (typeof v.min === "number" && typeof v.max === "number" && v.min > v.max) {
722
+ errors.push(`${path}: "min" (${v.min}) must be \xE2\u2030\xA4 "max" (${v.max}).`);
723
+ }
724
+ if (typeof v.minRows === "number" && typeof v.maxRows === "number" && v.minRows > v.maxRows) {
725
+ errors.push(`${path}: "minRows" (${v.minRows}) must be \xE2\u2030\xA4 "maxRows" (${v.maxRows}).`);
726
+ }
727
+ if (v.regex !== void 0) {
728
+ if (typeof v.regex !== "string") {
729
+ errors.push(`${path}.regex: must be a string.`);
730
+ } else {
731
+ try {
732
+ new RegExp(v.regex);
733
+ } catch {
734
+ errors.push(`${path}.regex: invalid regular expression "${v.regex}".`);
735
+ }
736
+ }
737
+ }
738
+ if (v.integerOnly !== void 0 && typeof v.integerOnly !== "boolean") {
739
+ errors.push(`${path}.integerOnly: must be a boolean.`);
740
+ }
741
+ if (v.uniqueItems !== void 0 && typeof v.uniqueItems !== "boolean") {
742
+ errors.push(`${path}.uniqueItems: must be a boolean.`);
743
+ }
744
+ if (v.allowedMimeTypes !== void 0 && !Array.isArray(v.allowedMimeTypes)) {
745
+ errors.push(`${path}.allowedMimeTypes: must be an array of strings.`);
746
+ }
747
+ }
748
+ function validateField(field, path, errors, warnings) {
749
+ if (!field || typeof field !== "object" || Array.isArray(field)) {
750
+ errors.push(`${path}: must be a non-array object.`);
751
+ return;
752
+ }
753
+ const f = field;
754
+ if (typeof f.name !== "string" || !f.name.trim()) {
755
+ errors.push(`${path}: "name" is required and must be a non-empty string.`);
756
+ } else if (!LEAF_NAME_RE.test(f.name)) {
757
+ errors.push(
758
+ `${path}.name "${f.name}": must start with a letter and contain only letters, digits, or underscores.`
759
+ );
760
+ }
761
+ if (typeof f.type !== "string") {
762
+ errors.push(`${path}: "type" is required.`);
763
+ return;
764
+ }
765
+ if (!VALID_FIELD_TYPES.has(f.type)) {
766
+ errors.push(`${path}: unknown field type "${f.type}".`);
767
+ return;
768
+ }
769
+ const type = f.type;
770
+ if (f.label !== void 0 && typeof f.label !== "string") {
771
+ errors.push(`${path}: "label" must be a string.`);
772
+ } else if (f.label === void 0) {
773
+ warnings.push(`${path}: no "label" defined; consider adding one for admin UX.`);
774
+ }
775
+ if (f.required !== void 0 && typeof f.required !== "boolean") {
776
+ errors.push(`${path}: "required" must be a boolean.`);
777
+ }
778
+ if (f.conditions !== void 0) {
779
+ validateConditions(f.conditions, `${path}.conditions`, errors);
780
+ }
781
+ if (f.conditionMode !== void 0 && f.conditionMode !== "AND" && f.conditionMode !== "OR") {
782
+ errors.push(`${path}: "conditionMode" must be "AND" or "OR".`);
783
+ }
784
+ if (f.validation !== void 0) {
785
+ if (!f.validation || typeof f.validation !== "object" || Array.isArray(f.validation)) {
786
+ errors.push(`${path}: "validation" must be an object.`);
787
+ } else {
788
+ validateValidationRules(
789
+ f.validation,
790
+ `${path}.validation`,
791
+ errors
792
+ );
793
+ }
794
+ }
795
+ if (f.ui !== void 0) {
796
+ if (!f.ui || typeof f.ui !== "object" || Array.isArray(f.ui)) {
797
+ errors.push(`${path}: "ui" must be an object.`);
798
+ } else {
799
+ const ui = f.ui;
800
+ if (ui.width !== void 0 && !["full", "half", "third", "quarter"].includes(ui.width)) {
801
+ errors.push(`${path}.ui.width: must be "full", "half", "third", or "quarter".`);
802
+ }
803
+ if (ui.order !== void 0 && typeof ui.order !== "number") {
804
+ errors.push(`${path}.ui.order: must be a number.`);
805
+ }
806
+ if (ui.collapsed !== void 0 && typeof ui.collapsed !== "boolean") {
807
+ errors.push(`${path}.ui.collapsed: must be a boolean.`);
808
+ }
809
+ }
810
+ }
811
+ if (type === "select" || type === "multiselect") {
812
+ if (!Array.isArray(f.options) || f.options.length === 0) {
813
+ errors.push(`${path}: "${type}" fields must have a non-empty "options" array.`);
814
+ } else {
815
+ ;
816
+ f.options.forEach((opt, i) => {
817
+ if (!opt || typeof opt !== "object") {
818
+ errors.push(`${path}.options[${i}]: must be an object.`);
819
+ return;
820
+ }
821
+ const o = opt;
822
+ if (typeof o.label !== "string" || !o.label.trim()) {
823
+ errors.push(`${path}.options[${i}]: "label" is required.`);
824
+ }
825
+ if (typeof o.value !== "string" || !o.value.trim()) {
826
+ errors.push(`${path}.options[${i}]: "value" is required.`);
827
+ }
828
+ });
829
+ }
830
+ }
831
+ if (type === "number") {
832
+ if (f.min !== void 0 && typeof f.min !== "number") {
833
+ errors.push(`${path}: "min" must be a number.`);
834
+ }
835
+ if (f.max !== void 0 && typeof f.max !== "number") {
836
+ errors.push(`${path}: "max" must be a number.`);
837
+ }
838
+ if (typeof f.min === "number" && typeof f.max === "number" && f.min > f.max) {
839
+ errors.push(`${path}: "min" (${f.min}) must be \xE2\u2030\xA4 "max" (${f.max}).`);
840
+ }
841
+ }
842
+ if (type === "text" || type === "textarea") {
843
+ if (f.minLength !== void 0 && typeof f.minLength !== "number") {
844
+ errors.push(`${path}: "minLength" must be a number.`);
845
+ }
846
+ if (f.maxLength !== void 0 && typeof f.maxLength !== "number") {
847
+ errors.push(`${path}: "maxLength" must be a number.`);
848
+ }
849
+ }
850
+ if (type === "relationship") {
851
+ if (typeof f.collection !== "string" || !f.collection.trim()) {
852
+ errors.push(`${path}: "relationship" fields require a non-empty "collection" string.`);
853
+ }
854
+ }
855
+ if (CONTAINER_TYPES.has(type)) {
856
+ if (!Array.isArray(f.fields) || f.fields.length === 0) {
857
+ errors.push(`${path}: "${type}" fields must have a non-empty "fields" array.`);
858
+ } else {
859
+ validateFields(f.fields, `${path}.fields`, errors, warnings);
860
+ }
861
+ if (type === "array") {
862
+ if (f.minRows !== void 0 && typeof f.minRows !== "number") {
863
+ errors.push(`${path}: "minRows" must be a number.`);
864
+ }
865
+ if (f.maxRows !== void 0 && typeof f.maxRows !== "number") {
866
+ errors.push(`${path}: "maxRows" must be a number.`);
867
+ }
868
+ }
869
+ }
870
+ if (type === "blocks") {
871
+ if (f.allowedBlocks !== void 0) {
872
+ if (!Array.isArray(f.allowedBlocks)) {
873
+ errors.push(`${path}: "allowedBlocks" must be an array.`);
874
+ } else {
875
+ ;
876
+ f.allowedBlocks.forEach((slug, i) => {
877
+ if (typeof slug !== "string" || !slug.trim()) {
878
+ errors.push(`${path}.allowedBlocks[${i}]: must be a non-empty string.`);
879
+ }
880
+ });
881
+ }
882
+ }
883
+ if (f.minBlocks !== void 0 && typeof f.minBlocks !== "number") {
884
+ errors.push(`${path}: "minBlocks" must be a number.`);
885
+ }
886
+ if (f.maxBlocks !== void 0 && typeof f.maxBlocks !== "number") {
887
+ errors.push(`${path}: "maxBlocks" must be a number.`);
888
+ }
889
+ if (typeof f.minBlocks === "number" && typeof f.maxBlocks === "number" && f.minBlocks > f.maxBlocks) {
890
+ errors.push(`${path}: "minBlocks" (${f.minBlocks}) must be \xE2\u2030\xA4 "maxBlocks" (${f.maxBlocks}).`);
891
+ }
892
+ }
893
+ }
894
+ function validateFields(fields, path, errors, warnings) {
895
+ const names = /* @__PURE__ */ new Set();
896
+ fields.forEach((field, index) => {
897
+ const fieldPath = `${path}[${index}]`;
898
+ validateField(field, fieldPath, errors, warnings);
899
+ const f = field;
900
+ if (typeof f.name === "string" && f.name) {
901
+ if (names.has(f.name)) {
902
+ errors.push(`${path}: duplicate field name "${f.name}".`);
903
+ }
904
+ names.add(f.name);
905
+ }
906
+ });
907
+ }
908
+ function validateBlockSchema(schema) {
909
+ const errors = [];
910
+ const warnings = [];
911
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
912
+ return {
913
+ valid: false,
914
+ errors: ['Schema must be a non-array object with a "fields" property.'],
915
+ warnings: []
916
+ };
917
+ }
918
+ const s = schema;
919
+ if (!Array.isArray(s.fields)) {
920
+ errors.push('Schema must have a "fields" array.');
921
+ } else if (s.fields.length === 0) {
922
+ warnings.push("Schema has no fields defined.");
923
+ } else {
924
+ validateFields(s.fields, "schema.fields", errors, warnings);
925
+ }
926
+ if (s.layout !== void 0 && !["default", "sidebar", "tabs"].includes(s.layout)) {
927
+ errors.push(`schema.layout must be one of: "default", "sidebar", "tabs".`);
928
+ }
929
+ return { valid: errors.length === 0, errors, warnings };
930
+ }
931
+
932
+ // src/builder/saveSchema.ts
933
+ async function saveSchemaLocally(payload, request) {
934
+ const { blockSlug, name, description, category, schema: rawSchema, changelog } = request;
935
+ const schema = normaliseSchema(rawSchema);
936
+ if (!schema.fields?.length) {
937
+ return {
938
+ success: false,
939
+ definitionId: "",
940
+ versionId: "",
941
+ versionNumber: 0,
942
+ errors: [`Block "${blockSlug}" schema has no fields. Schemas must be imported from a server-safe module (not a "use client" file).`],
943
+ warnings: []
944
+ };
945
+ }
946
+ const validation = validateBlockSchema(schema);
947
+ if (!validation.valid) {
948
+ return {
949
+ success: false,
950
+ definitionId: "",
951
+ versionId: "",
952
+ versionNumber: 0,
953
+ errors: validation.errors,
954
+ warnings: validation.warnings
955
+ };
956
+ }
957
+ try {
958
+ let definitionId;
959
+ const existing = await payload.find({
960
+ collection: "block-definitions",
961
+ where: { slug: { equals: blockSlug } },
962
+ limit: 1
963
+ });
964
+ if (existing.docs.length > 0) {
965
+ definitionId = existing.docs[0].id;
966
+ const updates = {};
967
+ if (description !== void 0) updates.description = description;
968
+ if (category !== void 0) updates.category = category;
969
+ if (name !== void 0) updates.name = name;
970
+ if (Object.keys(updates).length > 0) {
971
+ await payload.update({
972
+ collection: "block-definitions",
973
+ id: definitionId,
974
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
975
+ data: updates
976
+ });
977
+ }
978
+ } else {
979
+ if (!name) {
980
+ return {
981
+ success: false,
982
+ definitionId: "",
983
+ versionId: "",
984
+ versionNumber: 0,
985
+ errors: [`Block definition "${blockSlug}" not found. Provide a "name" to create it.`],
986
+ warnings: validation.warnings
987
+ };
988
+ }
989
+ const created = await payload.create({
990
+ collection: "block-definitions",
991
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
992
+ data: {
993
+ slug: blockSlug,
994
+ name,
995
+ description
996
+ }
997
+ });
998
+ definitionId = created.id;
999
+ }
1000
+ const existingVersions = await payload.find({
1001
+ collection: "block-definition-versions",
1002
+ where: { blockDefinition: { equals: definitionId } },
1003
+ limit: 0
1004
+ });
1005
+ const versionNumber = existingVersions.totalDocs + 1;
1006
+ const version = await payload.create({
1007
+ collection: "block-definition-versions",
1008
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1009
+ data: {
1010
+ blockDefinition: definitionId,
1011
+ versionNumber,
1012
+ label: `v${versionNumber}`,
1013
+ schema,
1014
+ changelog: changelog ?? "Created via Block Builder"
1015
+ }
1016
+ });
1017
+ await payload.update({
1018
+ collection: "block-definitions",
1019
+ id: definitionId,
1020
+ data: { currentVersion: version.id }
1021
+ });
1022
+ return {
1023
+ success: true,
1024
+ definitionId: String(definitionId),
1025
+ versionId: String(version.id),
1026
+ versionNumber: version.versionNumber ?? 0,
1027
+ warnings: validation.warnings
1028
+ };
1029
+ } catch (err) {
1030
+ const message = err instanceof Error ? err.message : String(err);
1031
+ return {
1032
+ success: false,
1033
+ definitionId: "",
1034
+ versionId: "",
1035
+ versionNumber: 0,
1036
+ errors: [`Unexpected error: ${message}`],
1037
+ warnings: validation.warnings
1038
+ };
1039
+ }
1040
+ }
1041
+
1042
+ // src/endpoints/save.ts
1043
+ var saveEndpoint = async (req) => {
1044
+ if (!req.user) {
1045
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1046
+ }
1047
+ let body;
1048
+ try {
1049
+ if (!req.json) return Response.json({ error: "No JSON parser available" }, { status: 500 });
1050
+ body = await req.json();
1051
+ } catch {
1052
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1053
+ }
1054
+ if (!body.blockSlug) {
1055
+ return Response.json({ error: "blockSlug is required" }, { status: 400 });
1056
+ }
1057
+ const result = await saveSchemaLocally(req.payload, body);
1058
+ return Response.json(result, { status: result.success ? 200 : 422 });
1059
+ };
1060
+
1061
+ // src/endpoints/versions.ts
1062
+ var versionsEndpoint = async (req) => {
1063
+ if (!req.user) {
1064
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1065
+ }
1066
+ const slug = req.routeParams?.slug;
1067
+ if (!slug) {
1068
+ return Response.json({ error: "Slug is required" }, { status: 400 });
1069
+ }
1070
+ const defResult = await req.payload.find({
1071
+ collection: "block-definitions",
1072
+ where: { slug: { equals: slug } },
1073
+ depth: 1,
1074
+ limit: 1
1075
+ });
1076
+ const def = defResult.docs[0];
1077
+ if (!def) {
1078
+ return Response.json({ error: `Block definition "${slug}" not found` }, { status: 404 });
1079
+ }
1080
+ const currentVersionId = def.currentVersion && typeof def.currentVersion === "object" ? String(def.currentVersion.id) : typeof def.currentVersion === "string" || typeof def.currentVersion === "number" ? String(def.currentVersion) : null;
1081
+ const versionsResult = await req.payload.find({
1082
+ collection: "block-definition-versions",
1083
+ where: { blockDefinition: { equals: def.id } },
1084
+ sort: "-versionNumber",
1085
+ depth: 0,
1086
+ limit: 100
1087
+ });
1088
+ const versions = versionsResult.docs.map((v) => {
1089
+ const id = String(v.id);
1090
+ return {
1091
+ id,
1092
+ versionNumber: v.versionNumber,
1093
+ label: v.label ?? `v${v.versionNumber}`,
1094
+ changelog: v.changelog ?? "",
1095
+ createdAt: v.createdAt,
1096
+ isCurrent: id === currentVersionId
1097
+ };
1098
+ });
1099
+ return Response.json({ versions });
1100
+ };
1101
+
1102
+ // src/plugin.ts
1103
+ var dynamicBlocksPlugin = (options = {}) => {
1104
+ return (incomingConfig) => {
1105
+ const {
1106
+ enabled = true,
1107
+ collections: targetCollections = [],
1108
+ fieldName = "dbLayout",
1109
+ tabLabel = "DB Layout"
1110
+ } = options;
1111
+ if (!enabled) return incomingConfig;
1112
+ const config = { ...incomingConfig };
1113
+ config.collections = [
1114
+ ...config.collections ?? [],
1115
+ BlockDefinitions,
1116
+ BlockDefinitionVersions
1117
+ ];
1118
+ config.endpoints = [
1119
+ ...config.endpoints ?? [],
1120
+ {
1121
+ path: "/block-builder/generate",
1122
+ method: "post",
1123
+ handler: generateEndpoint
1124
+ },
1125
+ {
1126
+ path: "/block-builder/load/:slug",
1127
+ method: "get",
1128
+ handler: loadEndpoint
1129
+ },
1130
+ {
1131
+ path: "/blocks/save",
1132
+ method: "post",
1133
+ handler: saveEndpoint
1134
+ },
1135
+ {
1136
+ path: "/block-builder/versions/:slug",
1137
+ method: "get",
1138
+ handler: versionsEndpoint
1139
+ }
1140
+ ];
1141
+ if (targetCollections.length > 0) {
1142
+ config.collections = config.collections.map((collection) => {
1143
+ if (!targetCollections.includes(collection.slug)) return collection;
1144
+ const layoutTab = dbLayoutField(fieldName, tabLabel);
1145
+ const existingTabsIdx = collection.fields.findIndex((f) => f.type === "tabs");
1146
+ if (existingTabsIdx !== -1) {
1147
+ const existingTabs = collection.fields[existingTabsIdx];
1148
+ return {
1149
+ ...collection,
1150
+ fields: collection.fields.map(
1151
+ (f, i) => i === existingTabsIdx ? {
1152
+ ...existingTabs,
1153
+ tabs: [...existingTabs.tabs, layoutTab]
1154
+ } : f
1155
+ )
1156
+ };
1157
+ }
1158
+ return {
1159
+ ...collection,
1160
+ fields: [
1161
+ ...collection.fields,
1162
+ {
1163
+ type: "tabs",
1164
+ tabs: [layoutTab]
1165
+ }
1166
+ ]
1167
+ };
1168
+ });
1169
+ config.collections = config.collections.map((collection) => {
1170
+ if (!targetCollections.includes(collection.slug)) return collection;
1171
+ const existingHooks = collection.hooks?.beforeChange ?? [];
1172
+ return {
1173
+ ...collection,
1174
+ hooks: {
1175
+ ...collection.hooks,
1176
+ beforeChange: [
1177
+ ...existingHooks,
1178
+ ({ data }) => {
1179
+ if (Array.isArray(data[fieldName])) {
1180
+ data[fieldName] = data[fieldName].map(
1181
+ (row) => row.instanceId ? row : { ...row, instanceId: randomUUID() }
1182
+ );
1183
+ }
1184
+ return data;
1185
+ }
1186
+ ]
1187
+ }
1188
+ };
1189
+ });
1190
+ }
1191
+ return config;
1192
+ };
1193
+ };
1194
+ export {
1195
+ BlockDefinitionVersions,
1196
+ BlockDefinitions,
1197
+ dbLayoutField,
1198
+ dynamicBlocksPlugin
1199
+ };