@nextbridgehq/payload-block-builder 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -1,38 +1,84 @@
1
1
  # Payload Block Builder
2
2
 
3
- Developed and open-sourced by [Nextbridge](https://nextbridge.com). This plugin was built to solve a real problem we kept running into: content editors needing to manage flexible page layouts without requiring a developer for every change.
3
+ [![npm version](https://img.shields.io/npm/v/@nextbridgehq/payload-block-builder.svg)](https://www.npmjs.com/package/@nextbridgehq/payload-block-builder)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Payload CMS](https://img.shields.io/badge/Payload-v3-blue.svg)](https://payloadcms.com)
6
+
7
+ > A visual block builder plugin for Payload CMS v3. Design content blocks through a drag-and-drop UI, store schemas in your database, and let editors build pages without waiting on a developer.
8
+
9
+ Developed and open-sourced by [Nextbridge](https://nextbridge.com).
10
+
11
+ ## Screenshots
12
+
13
+ ![Block Builder canvas](https://raw.githubusercontent.com/nextbridgehq/block-builder/main/docs/screenshots/canvas.png)
14
+ ![Schema builder field](https://raw.githubusercontent.com/nextbridgehq/block-builder/main/docs/screenshots/schema-builder.png)
15
+ ![DB Layout tab on a collection](https://raw.githubusercontent.com/nextbridgehq/block-builder/main/docs/screenshots/db-layout-tab.png)
4
16
 
5
17
  ---
6
18
 
7
- A visual block builder plugin for Payload v3. Design your content blocks through a drag-and-drop UI, store the schemas in your database, and let editors build pages without waiting on a developer every time something needs to change.
19
+ ## 🎯 The Problem
8
20
 
9
- ## Use cases
21
+ Content editors need to manage flexible page layouts — but every new block type or layout change requires a developer to update code, redeploy, and migrate. This creates bottlenecks, slows down marketing teams, and turns simple content tasks into engineering tickets.
10
22
 
11
- - **Dynamic landing pages:** Let editors compose pages from a library of blocks (hero, features, testimonials, CTA) without any code changes.
12
- - **Multi-tenant platforms:** Each tenant can have its own block definitions without touching shared config or triggering redeployments.
13
- - **Marketing teams:** Give marketing full control to create, update, and reorder blocks on any page, any time.
14
- - **Evolving content schemas:** Roll out new block versions without breaking content that was built against older ones.
15
- - **Headless frontends:** Fetch structured block data from the Payload API and render it with any framework.
23
+ ## 💡 The Solution
16
24
 
17
- ## Quick start
25
+ Payload Block Builder moves block schema definitions from code into your database. Editors design blocks visually, publish them instantly, and use them across any collection — all without touching code or triggering deployments.
18
26
 
19
- ### Option A — Automatic setup (recommended)
27
+ ---
28
+
29
+ ## ✨ Features
30
+
31
+ - **Visual drag-and-drop block designer** — No code required to create new block types
32
+ - **Database-stored schemas** — Block definitions live in your DB, not your codebase
33
+ - **Version snapshots** — Every publish creates an immutable version; existing content never breaks
34
+ - **Collection integration** — Adds a "DB Layout" tab to any collection with one line of config
35
+ - **13 field types** — text, textarea, number, email, date, checkbox, select, radio, upload, relationship, json, and more
36
+ - **Multi-tenant ready** — Each tenant can have its own block definitions without shared config changes
37
+ - **Framework agnostic frontend** — Fetch structured JSON and render with React, Vue, Svelte, or anything else
38
+ - **Automatic init command** — Get up and running in under 2 minutes
39
+ - **Works with all Payload databases** — PostgreSQL, SQLite, MongoDB — no database-specific code
40
+
41
+ ---
20
42
 
21
- Install the package and run the init command from your project root:
43
+ ## 📋 Compatibility
44
+
45
+ | Requirement | Version |
46
+ |---|---|
47
+ | Payload CMS | v3.x |
48
+ | Node.js | ≥ 18 |
49
+ | Next.js | ≥ 14 |
50
+
51
+ ### Database Support
52
+
53
+ | Database | Adapter |
54
+ |---|---|
55
+ | PostgreSQL / Supabase / Neon | `@payloadcms/db-postgres` |
56
+ | SQLite / Turso / LibSQL | `@payloadcms/db-sqlite` |
57
+ | MongoDB | `@payloadcms/db-mongodb` |
58
+
59
+ ---
60
+
61
+ ## 🚀 Quick Start
62
+
63
+ ### Option A — Automatic Setup (Recommended)
22
64
 
23
65
  ```bash
66
+ # Install
24
67
  pnpm add @nextbridgehq/payload-block-builder
25
68
  # or: npm install @nextbridgehq/payload-block-builder
26
69
 
70
+ # Initialize
27
71
  npx payload-block-builder init
28
72
  ```
29
73
 
30
74
  The init command automatically:
31
75
 
32
- - Creates `src/app/block-builder/page.tsx` the builder UI page
33
- - Creates `src/app/block-builder/layout.tsx` — standalone layout with `<html>` and `<body>` tags
34
- - Updates `src/app/(payload)/custom.scss` — injects admin field styles
35
- - Updates `payload.config.ts` adds the `dynamicBlocksPlugin` import and config
76
+ | What it does | File |
77
+ |---|---|
78
+ | Creates the builder UI page | `src/app/block-builder/page.tsx` |
79
+ | Creates a standalone layout | `src/app/block-builder/layout.tsx` |
80
+ | Injects admin field styles | `src/app/(payload)/custom.scss` |
81
+ | Adds plugin config | `payload.config.ts` |
36
82
 
37
83
  Then regenerate the import map and start your dev server:
38
84
 
@@ -41,9 +87,10 @@ pnpm generate:importmap
41
87
  pnpm dev
42
88
  ```
43
89
 
44
- Visit `http://localhost:3000/block-builder` and you're ready to build.
90
+ Visit `https://your-domain.com/block-builder` and you're ready to build.
45
91
 
46
- > **PostgreSQL users:** Payload will automatically push the new schema tables on first startup in dev mode. If you are using migrations in production, run:
92
+ > **PostgreSQL users:** Payload will automatically push new schema tables on first startup in dev mode. For production migrations:
93
+ >
47
94
  > ```bash
48
95
  > pnpm payload migrate:create --name=add_block_builder
49
96
  > pnpm payload migrate
@@ -51,7 +98,8 @@ Visit `http://localhost:3000/block-builder` and you're ready to build.
51
98
 
52
99
  ---
53
100
 
54
- ### Option B — Manual setup
101
+ <details>
102
+ <summary><strong>Option B — Manual Setup</strong></summary>
55
103
 
56
104
  **1. Install:**
57
105
 
@@ -77,11 +125,19 @@ export default buildConfig({
77
125
 
78
126
  ```tsx
79
127
  import React from 'react'
128
+ import { headers } from 'next/headers'
129
+ import { redirect } from 'next/navigation'
130
+ import { getPayload } from 'payload'
131
+ import config from '@payload-config'
80
132
  import '@nextbridgehq/payload-block-builder/builder.css'
81
133
 
82
134
  export const metadata = { title: 'Block Builder' }
83
135
 
84
- export default function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
136
+ export default async function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
137
+ const payload = await getPayload({ config })
138
+ const { user } = await payload.auth({ headers: await headers() })
139
+ if (!user) redirect('/admin/login')
140
+
85
141
  return (
86
142
  <html lang="en">
87
143
  <body style={{ margin: 0, padding: 0, height: '100vh', overflow: 'hidden' }}>
@@ -118,19 +174,28 @@ pnpm generate:importmap
118
174
  pnpm dev
119
175
  ```
120
176
 
177
+ </details>
178
+
121
179
  ---
122
180
 
123
- ## Plugin options
181
+ ## ⚙️ Configuration Options
124
182
 
125
183
  ```ts
126
184
  dynamicBlocksPlugin({
127
- enabled?: boolean // Disable without removing. Default: true
128
- collections?: string[] // Collection slugs that get the DB Layout tab. Default: []
129
- fieldName?: string // Name of the layout array field. Default: 'dbLayout'
130
- tabLabel?: string // Label shown on the tab in the admin UI. Default: 'DB Layout'
185
+ enabled?: boolean, // Disable without removing. Default: true
186
+ collections?: string[], // Collection slugs that get the DB Layout tab. Default: []
187
+ fieldName?: string, // Name of the layout array field. Default: 'dbLayout'
188
+ tabLabel?: string, // Label shown on the tab in the admin UI. Default: 'DB Layout'
131
189
  })
132
190
  ```
133
191
 
192
+ | Option | Type | Default | Description |
193
+ |---|---|---|---|
194
+ | `enabled` | `boolean` | `true` | Toggle the plugin on/off without removing it from config |
195
+ | `collections` | `string[]` | `[]` | Collection slugs that receive the DB Layout tab |
196
+ | `fieldName` | `string` | `'dbLayout'` | The field name for the layout array stored on documents |
197
+ | `tabLabel` | `string` | `'DB Layout'` | Label displayed on the tab in the Payload admin UI |
198
+
134
199
  The `--collections` flag is also supported in the init command:
135
200
 
136
201
  ```bash
@@ -139,17 +204,17 @@ npx payload-block-builder init --collections=pages,posts
139
204
 
140
205
  ---
141
206
 
142
- ## Usage
207
+ ## 📖 Usage
143
208
 
144
- ### Creating a block
209
+ ### Creating a Block
145
210
 
146
211
  1. Open `/block-builder` in your browser.
147
212
  2. Click "Add Block" and give it a name and slug.
148
213
  3. Drag fields from the panel on the right onto the canvas.
149
214
  4. Configure each field (label, name, required, options, etc.).
150
- 5. Click Publish. The block schema is saved to your database and a version snapshot is created.
215
+ 5. Click Publish the block schema is saved to your database and a version snapshot is created.
151
216
 
152
- ### Using blocks in a collection
217
+ ### Using Blocks in a Collection
153
218
 
154
219
  Any collection listed in the `collections` option gets a new "DB Layout" tab in the Payload admin. Editors can:
155
220
 
@@ -159,33 +224,179 @@ Any collection listed in the `collections` option gets a new "DB Layout" tab in
159
224
  4. Reorder, hide, or add anchor IDs to individual block instances.
160
225
  5. Save the document as normal.
161
226
 
162
- ### Reading block data on the frontend
227
+ ### Reading Block Data on the Frontend
163
228
 
164
229
  ```ts
165
230
  const res = await fetch('/api/pages/my-page?depth=2')
166
231
  const page = await res.json()
167
232
 
168
233
  for (const block of page.dbLayout) {
169
- const type = block.blockDefinition.slug // e.g. "hero"
170
- const fields = block.data // { heading: '...', image: '...', ... }
234
+ const type = block.blockDefinition.slug // e.g. "hero"
235
+ const fields = block.data // { heading: '...', image: '...', ... }
171
236
  const isHidden = block.hidden
237
+ const anchor = block.anchorId
238
+ }
239
+ ```
240
+
241
+ ### Example: React Component Map
242
+
243
+ ```tsx
244
+ const blockComponents = {
245
+ hero: HeroBlock,
246
+ features: FeaturesBlock,
247
+ testimonials: TestimonialsBlock,
248
+ cta: CTABlock,
249
+ }
250
+
251
+ function PageRenderer({ blocks }) {
252
+ return (
253
+ <>
254
+ {blocks
255
+ .filter((block) => !block.hidden)
256
+ .map((block, i) => {
257
+ const Component = blockComponents[block.blockDefinition.slug]
258
+ if (!Component) return null
259
+ return (
260
+
261
+
262
+
263
+ )
264
+ })}
265
+ </>
266
+ )
267
+ }
268
+ ```
269
+
270
+ > **Note:** The inner JSX of the `return (` in the React Component Map example is intentionally left blank in this snippet — fill in with your `<section>` / `<Component>` rendering as appropriate for your app.
271
+
272
+ ---
273
+
274
+ ## 🧩 Supported Field Types
275
+
276
+ | Type | Description | Admin UI |
277
+ |---|---|---|
278
+ | `text` | Single-line text input | Standard text field |
279
+ | `textarea` | Multi-line text input | Expandable textarea |
280
+ | `number` | Numeric input | Number field with validation |
281
+ | `email` | Email address | Email field with validation |
282
+ | `date` | Date picker | Calendar date picker |
283
+ | `checkbox` | Boolean toggle | Checkbox input |
284
+ | `select` | Dropdown with custom options | Select dropdown |
285
+ | `radio` | Radio button group | Radio buttons |
286
+ | `upload` | File/image picker | Media library picker |
287
+ | `relationship` | Document picker from any collection | Relationship field |
288
+ | `json` | Raw JSON data | JSON editor |
289
+
290
+ ---
291
+
292
+ ## 🏗️ Architecture
293
+
294
+ ```
295
+ ┌─────────────────────────────────────────────────────┐
296
+ │ Block Builder UI │
297
+ │ /block-builder (drag & drop) │
298
+ └──────────────────────────┬──────────────────────────┘
299
+ │ Publish
300
+
301
+ ┌─────────────────────────────────────────────────────┐
302
+ │ block-definitions collection │
303
+ │ (name, slug, field definitions) │
304
+ └──────────────────────────┬──────────────────────────┘
305
+ │ Snapshot
306
+
307
+ ┌─────────────────────────────────────────────────────┐
308
+ │ block-definition-versions collection │
309
+ │ (immutable schema snapshots per publish) │
310
+ └──────────────────────────┬──────────────────────────┘
311
+ │ Referenced by
312
+
313
+ ┌─────────────────────────────────────────────────────┐
314
+ │ Your Collection (e.g. "pages") │
315
+ │ dbLayout: [{ blockDefinition, version, data }] │
316
+ └─────────────────────────────────────────────────────┘
317
+ ```
318
+
319
+ Key design decisions:
320
+
321
+ - Block definitions are stored in a `block-definitions` collection. Each document is a named block type with a slug and a list of field definitions.
322
+ - Versions are stored in a `block-definition-versions` collection. Every publish creates an immutable snapshot.
323
+ - Documents in opted-in collections store a reference to the exact block version they were built against — updating a block schema later does not break existing content.
324
+ - The DB Layout tab is injected automatically into each collection you list. It renders a dynamic array field where editors pick a block and version.
325
+ - Four internal API endpoints power the builder UI and admin field components. You do not need to call them directly.
326
+
327
+ ---
328
+
329
+ ## 🔧 Advanced Usage
330
+
331
+ ### Using `dbLayoutField` Directly
332
+
333
+ If you prefer not to use the plugin's `collections` option, you can add the layout tab manually to any collection:
334
+
335
+ ```ts
336
+ import { dbLayoutField } from '@nextbridgehq/payload-block-builder'
337
+
338
+ export const Pages: CollectionConfig = {
339
+ slug: 'pages',
340
+ fields: [
341
+ {
342
+ type: 'tabs',
343
+ tabs: [
344
+ { label: 'Content', fields: [/* your fields */] },
345
+ dbLayoutField(), // fieldName='dbLayout', tab label='DB Layout'
346
+ dbLayoutField('heroBlocks', 'Hero'), // custom field name and tab label
347
+ ],
348
+ },
349
+ ],
172
350
  }
173
351
  ```
174
352
 
175
- Render each block type however you like — a switch statement or a component map both work well.
353
+ ---
354
+
355
+ ## 📦 CSS Imports Reference
356
+
357
+ | Import path | Purpose |
358
+ |---|---|
359
+ | `@nextbridgehq/payload-block-builder/builder.css` | Block Builder UI page styles |
360
+ | `@nextbridgehq/payload-block-builder/block-data-field.css` | DB Layout field styles in admin |
361
+ | `@nextbridgehq/payload-block-builder/schema-builder-field.css` | Schema Builder field styles in admin |
362
+
363
+ ---
364
+
365
+ ## 🗺️ Use Cases
366
+
367
+ | Use Case | How It Helps |
368
+ |---|---|
369
+ | Dynamic landing pages | Editors compose pages from a library of blocks (hero, features, testimonials, CTA) without code changes |
370
+ | Multi-tenant platforms | Each tenant gets its own block definitions without touching shared config or triggering redeployments |
371
+ | Marketing teams | Full control to create, update, and reorder blocks on any page, any time |
372
+ | Evolving content schemas | Roll out new block versions without breaking content built against older ones |
373
+ | Headless frontends | Fetch structured block data from the Payload API and render with any framework |
176
374
 
177
375
  ---
178
376
 
179
- ## How it works
377
+ ## 🤝 Contributing
180
378
 
181
- - **Block definitions** are stored in a `block-definitions` collection. Each document is a named block type with a slug and a list of field definitions.
182
- - **Versions** are stored in a `block-definition-versions` collection. Every time you publish a block, a snapshot of its schema is saved as a new version.
183
- - **Documents** in opted-in collections store a reference to the exact block version they were built against, so updating a block schema later does not break existing content.
184
- - **The DB Layout tab** is injected automatically into each collection you list. It renders a dynamic array field where editors pick a block and version, and the field UI adjusts to match.
185
- - **Four internal API endpoints** power the builder UI and the admin field components. You do not need to call them directly.
379
+ Contributions are welcome! Please see our Contributing Guide for details.
380
+
381
+ - Fork the repository
382
+ - Create your feature branch (`git checkout -b feature/amazing-feature`)
383
+ - Commit your changes (`git commit -m 'Add amazing feature'`)
384
+ - Push to the branch (`git push origin feature/amazing-feature`)
385
+ - Open a Pull Request
186
386
 
187
387
  ---
188
388
 
189
- ## License
389
+ ## 📄 License
190
390
 
191
391
  MIT © [Nextbridge](https://nextbridge.com)
392
+
393
+ ---
394
+
395
+ ## 🔗 Links
396
+
397
+ - [npm Package](https://www.npmjs.com/package/@nextbridgehq/payload-block-builder)
398
+ - [GitHub Repository](https://github.com/nextbridgehq/block-builder)
399
+ - [Report a Bug](https://github.com/nextbridgehq/block-builder/issues)
400
+ - [Payload CMS](https://payloadcms.com)
401
+
402
+ Built with ❤️ by Nextbridge
package/dist/bin/init.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- #!/usr/bin/env node
3
2
 
4
3
  // src/bin/init.ts
5
4
  import fs from "fs";
@@ -13,13 +12,21 @@ export default function BlockBuilderPage() {
13
12
  }
14
13
  `;
15
14
  var LAYOUT_CONTENT = `import React from 'react'
15
+ import { headers } from 'next/headers'
16
+ import { redirect } from 'next/navigation'
17
+ import { getPayload } from 'payload'
18
+ import config from '@payload-config'
16
19
  import '@nextbridgehq/payload-block-builder/builder.css'
17
20
 
18
21
  export const metadata = {
19
22
  title: 'Block Builder',
20
23
  }
21
24
 
22
- export default function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
25
+ export default async function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
26
+ const payload = await getPayload({ config })
27
+ const { user } = await payload.auth({ headers: await headers() })
28
+ if (!user) redirect('/admin/login')
29
+
23
30
  return (
24
31
  <html lang="en">
25
32
  <body style={{ margin: 0, padding: 0, height: '100vh', overflow: 'hidden' }}>
@@ -76,7 +83,7 @@ function addImport(content) {
76
83
  while ((m = lastFromRegex.exec(content)) !== null) lastMatch = m;
77
84
  if (!lastMatch) return newImport + "\n" + content;
78
85
  const insertPos = lastMatch.index + lastMatch[0].length;
79
- return content.slice(0, insertPos) + "\n" + newImport + content.slice(insertPos);
86
+ return content.slice(0, insertPos) + "\n" + newImport + "\n" + content.slice(insertPos);
80
87
  }
81
88
  function insertIntoPluginsArray(content, collectionsArg) {
82
89
  const pluginsMatch = /\bplugins\s*:\s*\[/.exec(content);
@@ -84,13 +91,19 @@ function insertIntoPluginsArray(content, collectionsArg) {
84
91
  const openPos = content.indexOf("[", pluginsMatch.index);
85
92
  const closePos = findClosingBracket(content, openPos);
86
93
  if (closePos === -1) return null;
94
+ const beforePlugins = content.slice(0, pluginsMatch.index);
95
+ const pluginsLineStart = beforePlugins.lastIndexOf("\n") + 1;
96
+ const outerIndent = content.slice(pluginsLineStart, pluginsMatch.index).match(/^([ \t]*)/)?.[1] ?? " ";
97
+ const entryIndent = outerIndent + " ";
87
98
  const beforeClose = content.slice(0, closePos);
88
99
  const prevNL = beforeClose.lastIndexOf("\n");
89
- const closingIndent = beforeClose.slice(prevNL + 1).match(/^([ \t]*)/)?.[1] ?? " ";
90
- const entryIndent = closingIndent + " ";
91
- const newLine = `${entryIndent}dynamicBlocksPlugin({ collections: [${collectionsArg}] }),
92
- `;
93
- return content.slice(0, prevNL + 1) + newLine + content.slice(prevNL + 1);
100
+ const isSingleLine = prevNL < openPos;
101
+ const newEntry = `${entryIndent}dynamicBlocksPlugin({ collections: [${collectionsArg}] }),`;
102
+ if (isSingleLine) {
103
+ return content.slice(0, openPos + 1) + "\n" + newEntry + "\n" + outerIndent + content.slice(closePos);
104
+ } else {
105
+ return content.slice(0, prevNL + 1) + newEntry + "\n" + content.slice(prevNL + 1);
106
+ }
94
107
  }
95
108
  function injectPluginsBlock(content, collectionsArg) {
96
109
  const collMatch = /\bcollections\s*:\s*\[/.exec(content);
@@ -110,7 +123,13 @@ ${outerIndent}],`;
110
123
  return content.slice(0, afterCollLine) + "\n" + pluginsBlock + content.slice(afterCollLine);
111
124
  }
112
125
  function modifyPayloadConfig(configPath, collectionsArg) {
113
- let content = fs.readFileSync(configPath, "utf8");
126
+ let content;
127
+ try {
128
+ content = fs.readFileSync(configPath, "utf8");
129
+ } catch (err) {
130
+ console.error(`Error: Could not read ${configPath}: ${err.message}`);
131
+ process.exit(1);
132
+ }
114
133
  if (content.includes("dynamicBlocksPlugin")) {
115
134
  console.log(`Skipped: dynamicBlocksPlugin already present in ${configPath}`);
116
135
  return;
@@ -119,8 +138,16 @@ function modifyPayloadConfig(configPath, collectionsArg) {
119
138
  const noComments = content.replace(/\/\/[^\n]*/g, "");
120
139
  const hasPluginsArray = /\bplugins\s*:\s*\[/.test(noComments);
121
140
  const hasPluginsShorthand = /^\s*plugins\s*,/m.test(noComments);
141
+ function writeConfig(data) {
142
+ try {
143
+ fs.writeFileSync(configPath, data, "utf8");
144
+ } catch (err) {
145
+ console.error(`Error: Could not write ${configPath}: ${err.message}`);
146
+ process.exit(1);
147
+ }
148
+ }
122
149
  if (hasPluginsShorthand && !hasPluginsArray) {
123
- fs.writeFileSync(configPath, content, "utf8");
150
+ writeConfig(content);
124
151
  console.log(`Updated: ${configPath} (added import)`);
125
152
  console.log(` Note: 'plugins' is imported from another file.`);
126
153
  console.log(` Add dynamicBlocksPlugin({ collections: ['pages'] }) to that file manually.`);
@@ -133,13 +160,13 @@ function modifyPayloadConfig(configPath, collectionsArg) {
133
160
  result = injectPluginsBlock(content, collectionsArg);
134
161
  }
135
162
  if (result === null) {
136
- fs.writeFileSync(configPath, content, "utf8");
163
+ writeConfig(content);
137
164
  console.log(`Updated: ${configPath} (added import only)`);
138
165
  console.log(` Could not auto-detect plugins array. Add manually:`);
139
166
  console.log(` plugins: [ dynamicBlocksPlugin({ collections: [${collectionsArg}] }) ]`);
140
167
  return;
141
168
  }
142
- fs.writeFileSync(configPath, result, "utf8");
169
+ writeConfig(result);
143
170
  console.log(`Updated: ${configPath} (added dynamicBlocksPlugin)`);
144
171
  }
145
172
  function printNextSteps(dbAdapter) {
@@ -165,7 +192,14 @@ function printNextSteps(dbAdapter) {
165
192
  function main() {
166
193
  const args = process.argv.slice(2);
167
194
  const collectionsFlag = args.find((a) => a.startsWith("--collections="));
168
- const collectionsValue = collectionsFlag ? collectionsFlag.replace("--collections=", "").split(",").map((s) => s.trim()) : ["pages"];
195
+ const rawCollections = collectionsFlag ? collectionsFlag.replace("--collections=", "").split(",").map((s) => s.trim()) : ["pages"];
196
+ const invalidSlugs = rawCollections.filter((c) => !/^[a-z0-9_-]+$/i.test(c));
197
+ if (invalidSlugs.length > 0) {
198
+ console.error(`Error: Invalid collection slug(s): ${invalidSlugs.join(", ")}`);
199
+ console.error("Collection slugs may only contain letters, numbers, hyphens, and underscores.");
200
+ process.exit(1);
201
+ }
202
+ const collectionsValue = rawCollections;
169
203
  const collectionsArg = collectionsValue.map((c) => `'${c}'`).join(", ");
170
204
  const appDir = findAppDir();
171
205
  if (!appDir) {
@@ -173,32 +207,52 @@ function main() {
173
207
  process.exit(1);
174
208
  }
175
209
  const builderDir = path.join(appDir, "block-builder");
176
- if (!fs.existsSync(builderDir)) {
177
- fs.mkdirSync(builderDir, { recursive: true });
210
+ try {
211
+ if (!fs.existsSync(builderDir)) {
212
+ fs.mkdirSync(builderDir, { recursive: true });
213
+ }
214
+ } catch (err) {
215
+ console.error(`Error: Could not create directory ${builderDir}: ${err.message}`);
216
+ process.exit(1);
178
217
  }
179
218
  const pagePath = path.join(builderDir, "page.tsx");
180
219
  const layoutPath = path.join(builderDir, "layout.tsx");
181
220
  if (fs.existsSync(pagePath)) {
182
221
  console.log(`Skipped: ${pagePath} already exists`);
183
222
  } else {
184
- fs.writeFileSync(pagePath, PAGE_CONTENT);
185
- console.log(`Created: ${pagePath}`);
223
+ try {
224
+ fs.writeFileSync(pagePath, PAGE_CONTENT);
225
+ console.log(`Created: ${pagePath}`);
226
+ } catch (err) {
227
+ console.error(`Error: Could not write ${pagePath}: ${err.message}`);
228
+ process.exit(1);
229
+ }
186
230
  }
187
231
  if (fs.existsSync(layoutPath)) {
188
232
  console.log(`Skipped: ${layoutPath} already exists`);
189
233
  } else {
190
- fs.writeFileSync(layoutPath, LAYOUT_CONTENT);
191
- console.log(`Created: ${layoutPath}`);
234
+ try {
235
+ fs.writeFileSync(layoutPath, LAYOUT_CONTENT);
236
+ console.log(`Created: ${layoutPath}`);
237
+ } catch (err) {
238
+ console.error(`Error: Could not write ${layoutPath}: ${err.message}`);
239
+ process.exit(1);
240
+ }
192
241
  }
193
242
  const payloadRouteDir = path.join(appDir, "(payload)");
194
243
  const customScssPath = path.join(payloadRouteDir, "custom.scss");
195
244
  if (fs.existsSync(customScssPath)) {
196
- const existing = fs.readFileSync(customScssPath, "utf8");
197
- if (!existing.includes("@nextbridgehq/payload-block-builder")) {
198
- fs.appendFileSync(customScssPath, "\n" + CUSTOM_SCSS_IMPORTS);
199
- console.log(`Updated: ${customScssPath} (added admin field styles)`);
200
- } else {
201
- console.log(`Skipped: ${customScssPath} already has block-builder imports`);
245
+ try {
246
+ const existing = fs.readFileSync(customScssPath, "utf8");
247
+ if (!existing.includes("@nextbridgehq/payload-block-builder")) {
248
+ fs.appendFileSync(customScssPath, "\n" + CUSTOM_SCSS_IMPORTS);
249
+ console.log(`Updated: ${customScssPath} (added admin field styles)`);
250
+ } else {
251
+ console.log(`Skipped: ${customScssPath} already has block-builder imports`);
252
+ }
253
+ } catch (err) {
254
+ console.error(`Error: Could not update ${customScssPath}: ${err.message}`);
255
+ process.exit(1);
202
256
  }
203
257
  }
204
258
  const configPath = findPayloadConfig();