@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 +240 -0
- package/dist/admin/index.d.ts +56 -0
- package/dist/admin/index.js +2463 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/chunk-7VN7UNLK.js +45 -0
- package/dist/chunk-7VN7UNLK.js.map +1 -0
- package/dist/chunk-ABMSYCSD.js +355 -0
- package/dist/chunk-ABMSYCSD.js.map +1 -0
- package/dist/chunk-E4G7NNXW.js +21 -0
- package/dist/chunk-E4G7NNXW.js.map +1 -0
- package/dist/chunk-EGU6QGUC.js +119 -0
- package/dist/chunk-EGU6QGUC.js.map +1 -0
- package/dist/chunk-R7YHOSL2.js +483 -0
- package/dist/chunk-R7YHOSL2.js.map +1 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.js +190 -0
- package/dist/index.js.map +1 -0
- package/dist/render/ErrorBoundary.d.ts +23 -0
- package/dist/render/ErrorBoundary.js +8 -0
- package/dist/render/ErrorBoundary.js.map +1 -0
- package/dist/render/index.d.ts +151 -0
- package/dist/render/index.js +64 -0
- package/dist/render/index.js.map +1 -0
- package/dist/style-compiler-hnWIDfko.d.ts +212 -0
- package/dist/styles/editor.css +412 -0
- package/package.json +109 -0
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 };
|