@nextlyhq/plugin-page-builder 0.0.2-alpha.31

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 ADDED
@@ -0,0 +1,240 @@
1
+ # @nextlyhq/plugin-page-builder
2
+
3
+ A visual, block-based **page builder** for [Nextly](https://nextlyhq.com) — a minimal,
4
+ extensible foundation in the spirit of Gutenberg/Elementor. Drag-and-drop editing in an
5
+ iframe canvas, per-block styling with responsive overrides, a data-driven Query Loop, and
6
+ a server-first renderer that ships zero client JS by default.
7
+
8
+ > **Status:** alpha. The block model, registries, and render pipeline are stable; the
9
+ > editor interactions are evolving. MIT-licensed.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm add @nextlyhq/plugin-page-builder
15
+ ```
16
+
17
+ Peers (provided by a Nextly app): `nextly`, `@nextlyhq/admin`, `@nextlyhq/ui`,
18
+ `@nextlyhq/plugin-sdk`, `react`, `react-dom`, `next`, `@tanstack/react-query`,
19
+ `react-hook-form`, `lucide-react`.
20
+
21
+ ## Quick start
22
+
23
+ ### 1. Register the plugin
24
+
25
+ ```ts
26
+ // nextly.config.ts
27
+ import { pageBuilder } from "@nextlyhq/plugin-page-builder";
28
+ import { defineConfig } from "nextly/config";
29
+
30
+ export default defineConfig({
31
+ plugins: [pageBuilder()], // adds a `pages` collection with the full editor
32
+ });
33
+ ```
34
+
35
+ This contributes a `pages` collection (title, slug, `content` block tree, `customCss`,
36
+ draft/publish status) whose Edit view is the page builder.
37
+
38
+ ### 2. Add the public render route
39
+
40
+ A plugin can't inject Next.js routes, so the consuming app declares **one** catch-all
41
+ route and hands the stored block tree to `PageRenderer`. The renderer never imports the
42
+ CMS runtime — you inject a small `dataProvider` backed by `getNextly()`:
43
+
44
+ ```tsx
45
+ // app/(site)/[...slug]/page.tsx
46
+ import {
47
+ PageRenderer,
48
+ type DataProvider,
49
+ } from "@nextlyhq/plugin-page-builder/render";
50
+ import { notFound } from "next/navigation";
51
+ import { getNextly } from "nextly";
52
+ import nextlyConfig from "../../../../nextly.config";
53
+
54
+ function dataProvider(nx: Awaited<ReturnType<typeof getNextly>>): DataProvider {
55
+ return {
56
+ find: async args => ({ items: (await nx.find(args as never)).items ?? [] }),
57
+ findOne: async ({ collection, id }) =>
58
+ (await nx.findByID({ collection, id })) ?? null,
59
+ resolveMedia: async () => null,
60
+ };
61
+ }
62
+
63
+ export default async function SitePage({
64
+ params,
65
+ }: {
66
+ params: Promise<{ slug: string[] }>;
67
+ }) {
68
+ const { slug } = await params;
69
+ const nx = await getNextly({ config: nextlyConfig });
70
+ const { items } = await nx.find({
71
+ collection: "pages",
72
+ where: {
73
+ slug: { equals: slug.join("/") },
74
+ status: { equals: "published" },
75
+ },
76
+ limit: 1,
77
+ });
78
+ const page = items[0] as
79
+ | { content?: unknown; customCss?: string }
80
+ | undefined;
81
+ if (!page?.content) notFound();
82
+ return (
83
+ <PageRenderer
84
+ document={page.content as never}
85
+ customCss={page.customCss}
86
+ dataProvider={dataProvider(nx)}
87
+ />
88
+ );
89
+ }
90
+ ```
91
+
92
+ ## Field mount (collections **and** singles)
93
+
94
+ Use the builder as a **field** alongside other fields — in any collection or single:
95
+
96
+ ```ts
97
+ import { pageBuilderField } from "@nextlyhq/plugin-page-builder";
98
+ import { defineSingle, text } from "nextly/config";
99
+
100
+ export const Homepage = defineSingle({
101
+ slug: "homepage",
102
+ label: { singular: "Homepage" },
103
+ fields: [
104
+ text({ name: "title" }),
105
+ pageBuilderField("layout", { label: "Layout" }),
106
+ ],
107
+ });
108
+ ```
109
+
110
+ `pageBuilderField` stores the block tree as JSON and mounts the editor via the field's
111
+ `admin.component`. The **host form** persists it (no separate save button). Render it the
112
+ same way — hand the field's value to `PageRenderer` (for a single, fetch via
113
+ `nx.findSingle({ slug })`).
114
+
115
+ ## Per-entry editor choice (Default vs Page Builder)
116
+
117
+ Let **each entry** of a collection or single choose between the normal Nextly fields and the
118
+ visual Page Builder canvas — Elementor/WordPress-style.
119
+
120
+ **Code-first:** wrap the config with `withPageBuilder()`. It adds an `editorMode` select + the
121
+ reserved `content` page-builder field, and sets `admin.pageBuilder.enabled`:
122
+
123
+ ```ts
124
+ import { withPageBuilder } from "@nextlyhq/plugin-page-builder";
125
+ import { defineCollection, text, textarea } from "nextly/config";
126
+
127
+ export const Articles = defineCollection(
128
+ withPageBuilder({
129
+ slug: "articles",
130
+ labels: { singular: "Article", plural: "Articles" },
131
+ status: true,
132
+ fields: [
133
+ text({ name: "title", required: true }),
134
+ text({ name: "slug", required: true, unique: true }),
135
+ textarea({ name: "excerpt" }),
136
+ ],
137
+ })
138
+ );
139
+ ```
140
+
141
+ **UI-created collections/singles:** open the schema builder → toggle **"Use Page Builder"**
142
+ (shown only when this plugin is installed). It adds/removes the same two fields.
143
+
144
+ When an entry picks **Page Builder**, the edit screen shows the canvas plus the essentials
145
+ (title, slug, status); the other fields are hidden. Picking **Default** shows the normal form.
146
+
147
+ **Front-end** — render whichever the entry chose:
148
+
149
+ ```tsx
150
+ import { PageRenderer } from "@nextlyhq/plugin-page-builder/render";
151
+
152
+ const article = await nx.findOne({ collection: "articles", where: { slug } });
153
+ export default function Article() {
154
+ return article.editormode === "builder" ? (
155
+ <PageRenderer
156
+ document={article.content}
157
+ registry={registry}
158
+ dataProvider={dp}
159
+ />
160
+ ) : (
161
+ <NormalArticle entry={article} />
162
+ ); // your normal-fields template
163
+ }
164
+ ```
165
+
166
+ ## Built-in blocks
167
+
168
+ `core/heading`, `core/paragraph`, `core/image`, `core/button`, `core/video`,
169
+ `core/container`, `core/grid`, and the dynamic `core/query-loop`. Each declares content
170
+ fields (Content tab) and style controls (Style/Responsive tabs) that drive the inspector.
171
+
172
+ ## Query Loop
173
+
174
+ Drop a **Query Loop**, set its collection/sort/limit, and place a template inside it. At
175
+ render the loop fetches entries through your `dataProvider` and renders the template once
176
+ per item. Bind any content field on a nested block to an item field (Content tab → **Bind**
177
+ → path, e.g. `title` or `author.name`) — bindings resolve at any depth. Empty / error /
178
+ config states are first-class, and a per-page query budget bounds nested loops.
179
+
180
+ ## Styling, tokens & responsive
181
+
182
+ Style values are **typed** (spacing as box-sides, colors, dimensions, …) and compiled to a
183
+ single scoped `<style>` per page via a real CSS parser (never string concatenation).
184
+ Colors may be raw values or design-token references (`{ token: "color.primary" }` →
185
+ `var(--nx-color-primary)`). Breakpoints are desktop-first; per-breakpoint overrides are
186
+ edited in the **Responsive** tab and visible at real device widths in the iframe canvas.
187
+
188
+ Page-level **custom CSS** is parsed, allow-listed, and scoped under the page root — no
189
+ `@import`, no `javascript:` urls, no `</style>` breakout.
190
+
191
+ ## Extending — add your own block
192
+
193
+ The block registry is the single extensibility seam. One `defineBlock` call wires the
194
+ validator, renderer, and inspector — no core edit:
195
+
196
+ ```tsx
197
+ import { defineBlock } from "@nextlyhq/plugin-page-builder";
198
+
199
+ defineBlock({
200
+ type: "acme/pricing-table", // must be namespaced
201
+ version: 1,
202
+ label: "Pricing Table",
203
+ icon: "Table",
204
+ category: "basic",
205
+ defaultProps: { plan: "Pro" },
206
+ contentFields: [{ name: "plan", type: "text", label: "Plan" }],
207
+ styleControls: [
208
+ { control: "color", styleKey: "backgroundColor", label: "Background" },
209
+ ],
210
+ render: ({ props, className }) => (
211
+ <div className={className}>{String(props.plan)}</div>
212
+ ),
213
+ });
214
+ ```
215
+
216
+ Blocks can bump `version` and ship a pure `migrate(old, fromVersion)` — stored documents
217
+ upgrade on read, and unknown blocks are preserved (never dropped).
218
+
219
+ ## Security
220
+
221
+ - Text is escaped; image/link/video URLs are scheme-validated (rejects `javascript:` /
222
+ `vbscript:` / `data:`, including control-char-obfuscated variants).
223
+ - Custom CSS is parser-validated, scoped, and allow-listed.
224
+ - Structural limits: max depth, max node count, unique ids, no move-into-descendant,
225
+ namespaced types, slot allow-lists.
226
+ - The `./render` entry imports no CMS runtime and no admin code (enforced by a test).
227
+
228
+ ## Package entries
229
+
230
+ - `.` — isomorphic core (registries, tree/validate/migrate, `pageBuilder`, `pageBuilderField`).
231
+ - `./render` — server-first `PageRenderer` (+ `DataProvider`).
232
+ - `./admin` — the React editor (registers its components on import).
233
+ - `./styles/editor.css` — editor styles.
234
+
235
+ ## Environment note
236
+
237
+ Two steps require a real terminal (not a headless CI sandbox): applying the plugin's DB
238
+ table (drizzle push needs a TTY) and the `@nextlyhq/plugin-sdk` default/CJS export used by
239
+ the dev auto-seed. Everything else — build, type-check, unit tests — runs anywhere. See
240
+ the `e2e/` suite for the browser interaction tests (run against a live playground).
@@ -0,0 +1,56 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { Control } from 'react-hook-form';
3
+
4
+ /**
5
+ * Props the Nextly admin passes to a custom collection Edit-view component
6
+ * (verified: packages/admin/src/lib/plugins/component-registry.ts). There is no save
7
+ * function — the view self-persists (see adminFetch).
8
+ */
9
+ interface CustomEditViewProps {
10
+ collectionSlug: string;
11
+ entryId?: string;
12
+ isCreating: boolean;
13
+ initialData?: Record<string, unknown>;
14
+ onSuccess?: (entry?: Record<string, unknown>) => void;
15
+ onDelete?: () => void;
16
+ onCancel?: () => void;
17
+ onDuplicate?: () => void;
18
+ }
19
+
20
+ declare function PageBuilderEditView(props: CustomEditViewProps): react_jsx_runtime.JSX.Element;
21
+
22
+ interface PageBuilderFieldProps {
23
+ /** Form path (react-hook-form) for this field. */
24
+ name: string;
25
+ /** react-hook-form control, injected by the host FieldRenderer. */
26
+ control: Control;
27
+ field?: {
28
+ label?: string;
29
+ };
30
+ }
31
+ declare function PageBuilderField({ name, control }: PageBuilderFieldProps): react_jsx_runtime.JSX.Element;
32
+
33
+ interface Props {
34
+ controllerField?: string;
35
+ value?: unknown;
36
+ onChange?: (next: unknown) => void;
37
+ }
38
+ declare function PageBuilderModeToggle({ controllerField, value, onChange, }: Props): react_jsx_runtime.JSX.Element | null;
39
+
40
+ interface BuilderField {
41
+ id?: string;
42
+ name?: string;
43
+ type?: string;
44
+ admin?: {
45
+ component?: string;
46
+ } | null;
47
+ }
48
+ interface PageBuilderToggleProps<T extends BuilderField> {
49
+ fields: T[];
50
+ setFields: (fields: T[]) => void;
51
+ disabled?: boolean;
52
+ context?: "collection" | "single";
53
+ }
54
+ declare function PageBuilderToggle<T extends BuilderField>({ fields, setFields, disabled, }: PageBuilderToggleProps<T>): react_jsx_runtime.JSX.Element;
55
+
56
+ export { type CustomEditViewProps, PageBuilderEditView, PageBuilderField, PageBuilderModeToggle, PageBuilderToggle };