cms-renderer 0.4.0 → 0.5.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/README.md +154 -278
- package/dist/lib/block-renderer.js +59 -37
- package/dist/lib/block-renderer.js.map +1 -1
- package/dist/lib/block-toolbar.d.ts +3 -2
- package/dist/lib/block-toolbar.js +69 -67
- package/dist/lib/block-toolbar.js.map +1 -1
- package/dist/lib/client-editable-block.js +1 -1
- package/dist/lib/client-editable-block.js.map +1 -1
- package/dist/lib/renderer.js +59 -37
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/schema.d.ts +1 -0
- package/dist/lib/schema.js +12 -0
- package/dist/lib/schema.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,156 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cms-renderer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A library for rendering CMS-authored content in Next.js websites.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
CMS (authors content)
|
|
6
|
+
CMS (authors content) ──► tRPC API ──► Website (renders blocks)
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
| Export | Description |
|
|
12
|
+
|--------|-------------|
|
|
13
|
+
| `./lib/renderer` | `ParametricRoutePage` — drop-in catch-all page component |
|
|
14
|
+
| `./lib/block-renderer` | `BlockRenderer`, `walkReactNode` — core rendering primitives |
|
|
15
|
+
| `./lib/block-toolbar` | `BlockToolbar` — floating edit toolbar (portal to `document.body`) |
|
|
16
|
+
| `./lib/client-editable-block` | `ClientEditableBlock` — client-side span injector fallback |
|
|
17
|
+
| `./lib/cms-api` | `getCmsClient` — tRPC HTTP client for Server Components |
|
|
18
|
+
| `./lib/refresher` | `Refresher` — SSE-based live revalidation |
|
|
19
|
+
| `./lib/proxy` | `createCmsProxy` — Next.js middleware to proxy `/admin`, `/api`, `/auth` |
|
|
20
|
+
| `./lib/types` | `BlockData`, `BlockComponentRegistry`, `ResolvedRouteParams`, … |
|
|
21
|
+
| `./lib/schema` | Schema utilities |
|
|
22
|
+
| `./lib/custom-schemas` | Custom schema helpers |
|
|
23
|
+
| `./lib/markdown-utils` | Markdown helpers |
|
|
24
|
+
| `./lib/trpc` | Client-side tRPC hooks |
|
|
25
|
+
| `./lib/result` | `Result<T, E>` type utilities |
|
|
26
|
+
| `./lib/image/lazy-load` | Lazy image component |
|
|
10
27
|
|
|
11
28
|
---
|
|
12
29
|
|
|
13
30
|
## Quick Start
|
|
14
31
|
|
|
15
|
-
**Prerequisites**
|
|
16
|
-
|
|
17
|
-
| Tool | Version | Check Command |
|
|
18
|
-
|------|---------|---------------|
|
|
19
|
-
| Bun | 1.2.8+ | `bun --version` |
|
|
20
|
-
| Node.js | 18+ | `node --version` |
|
|
21
|
-
|
|
22
|
-
**Installation**
|
|
23
|
-
|
|
24
32
|
```bash
|
|
25
|
-
#
|
|
26
|
-
bun
|
|
27
|
-
|
|
28
|
-
# Start the website dev server
|
|
29
|
-
cd apps/website
|
|
30
|
-
bun run dev
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
**Verification**
|
|
34
|
-
|
|
35
|
-
Open [http://localhost:3001](http://localhost:3001). You should see:
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
Website
|
|
39
|
-
CMS-powered website built with Next.js 15 and App Router.
|
|
33
|
+
bun install # from monorepo root
|
|
34
|
+
bun run build # or: bun run dev (watch mode)
|
|
40
35
|
```
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## Architecture
|
|
47
|
-
|
|
48
|
-
### How This App Fits in the Monorepo
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
auteur/
|
|
52
|
-
├── apps/
|
|
53
|
-
│ ├── cms/ # Content authoring (Lexical editor)
|
|
54
|
-
│ └── website/ # This app - renders CMS content
|
|
55
|
-
└── packages/
|
|
56
|
-
├── cms-schema/ # Shared Zod schemas
|
|
57
|
-
└── markdown-wasm/ # WASM markdown renderer
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
The website consumes schemas from `@repo/cms-schema` and uses `@repo/markdown-wasm` for article body rendering. It does not import from `apps/cms/` directly.
|
|
61
|
-
|
|
62
|
-
### Key Directories
|
|
63
|
-
|
|
64
|
-
```
|
|
65
|
-
apps/website/
|
|
66
|
-
├── app/ # Next.js App Router pages
|
|
67
|
-
│ ├── [slug]/ # Dynamic page routes
|
|
68
|
-
│ │ ├── page.tsx # Server Component fetches via tRPC
|
|
69
|
-
│ │ ├── loading.tsx # Skeleton loading state
|
|
70
|
-
│ │ └── error.tsx # Error boundary
|
|
71
|
-
│ ├── api/trpc/[trpc]/ # tRPC API handler
|
|
72
|
-
│ ├── layout.tsx # Root layout with CSS imports
|
|
73
|
-
│ ├── globals.css # CSS reset
|
|
74
|
-
│ └── page.tsx # Homepage
|
|
75
|
-
├── components/
|
|
76
|
-
│ ├── blocks/ # Block components + registry
|
|
77
|
-
│ │ ├── navigation-block.tsx
|
|
78
|
-
│ │ ├── header-block.tsx
|
|
79
|
-
│ │ ├── article-block.tsx
|
|
80
|
-
│ │ ├── block-renderer.tsx # Dispatcher component
|
|
81
|
-
│ │ ├── types.ts # BlockData discriminated union
|
|
82
|
-
│ │ └── index.ts # Exports + blockComponents registry
|
|
83
|
-
│ ├── page-renderer.tsx # Renders array of blocks
|
|
84
|
-
│ └── trpc-provider.tsx # Client-side tRPC/React Query
|
|
85
|
-
├── server/
|
|
86
|
-
│ ├── trpc.ts # tRPC initialization
|
|
87
|
-
│ └── routers/
|
|
88
|
-
│ ├── blocks/ # Block data procedures
|
|
89
|
-
│ │ ├── get-navigation.ts
|
|
90
|
-
│ │ ├── get-header.ts
|
|
91
|
-
│ │ └── get-article.ts
|
|
92
|
-
│ └── pages/ # Page composition procedures
|
|
93
|
-
│ └── get-page.ts
|
|
94
|
-
├── seed/ # Mock data for development
|
|
95
|
-
│ └── index.ts
|
|
96
|
-
├── styles/
|
|
97
|
-
│ └── blocks.css # Vanilla CSS for blocks
|
|
98
|
-
└── lib/
|
|
99
|
-
└── trpc.ts # Client-side tRPC hooks
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### Data Flow
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
106
|
-
│ Dynamic Route │────>│ tRPC Caller │────>│ Mock Data │
|
|
107
|
-
│ [slug]/page │ │ pages.getPage │ │ seed/index.ts │
|
|
108
|
-
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
109
|
-
│
|
|
110
|
-
v
|
|
111
|
-
┌─────────────────┐ ┌─────────────────┐
|
|
112
|
-
│ PageRenderer │────>│ BlockRenderer │
|
|
113
|
-
│ (iterates) │ │ (dispatches) │
|
|
114
|
-
└─────────────────┘ └─────────────────┘
|
|
115
|
-
│
|
|
116
|
-
┌──────────────────────┼──────────────────────┐
|
|
117
|
-
v v v
|
|
118
|
-
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
|
119
|
-
│ NavigationBlock│ │ HeaderBlock │ │ ArticleBlock │
|
|
120
|
-
│ │ │ │ │ (WASM markdown)│
|
|
121
|
-
└───────────────┘ └───────────────┘ └───────────────┘
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
## Development Guide
|
|
127
|
-
|
|
128
|
-
### Available Scripts
|
|
129
|
-
|
|
130
|
-
| Script | Command | Description |
|
|
131
|
-
|--------|---------|-------------|
|
|
132
|
-
| `dev` | `bun run dev` | Start dev server on port 3001 |
|
|
133
|
-
| `build` | `bun run build` | Production build |
|
|
134
|
-
| `start` | `bun run start` | Start production server |
|
|
135
|
-
| `lint` | `bun run lint` | Run Biome linter |
|
|
136
|
-
| `check-types` | `bun run check-types` | TypeScript type checking |
|
|
137
|
-
|
|
138
|
-
### Hot Reload Behavior
|
|
139
|
-
|
|
140
|
-
- **Page changes**: Instant hot reload
|
|
141
|
-
- **tRPC procedures**: Requires server restart
|
|
142
|
-
- **CSS changes**: Instant hot reload
|
|
143
|
-
- **Block components**: Instant hot reload
|
|
144
|
-
|
|
145
|
-
### Environment Variables
|
|
146
|
-
|
|
147
|
-
No environment variables are required for development. The app uses mock data from `seed/index.ts`.
|
|
148
|
-
|
|
149
|
-
For production with a real database, you would add:
|
|
37
|
+
**Required env vars** (in the consuming Next.js app):
|
|
150
38
|
|
|
151
39
|
```env
|
|
152
|
-
#
|
|
153
|
-
DATABASE_URL=your-database-url
|
|
40
|
+
NEXT_PUBLIC_WEBSITE_ID=<uuid> # or pass websiteId prop directly
|
|
154
41
|
```
|
|
155
42
|
|
|
156
43
|
---
|
|
@@ -159,204 +46,193 @@ DATABASE_URL=your-database-url
|
|
|
159
46
|
|
|
160
47
|
### ComponentMap Pattern
|
|
161
48
|
|
|
162
|
-
|
|
49
|
+
Map block type strings to React components via a registry:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// app/registry.ts
|
|
53
|
+
import type { BlockComponentRegistry } from 'cms-renderer/lib/types';
|
|
54
|
+
import HeroBlock from '@/components/hero-block';
|
|
55
|
+
import ArticleBlock from '@/components/article-block';
|
|
163
56
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
header: HeaderBlock,
|
|
169
|
-
article: ArticleBlock,
|
|
170
|
-
} as const;
|
|
57
|
+
export const registry: Partial<BlockComponentRegistry> = {
|
|
58
|
+
'hero-block': HeroBlock,
|
|
59
|
+
'article': ArticleBlock,
|
|
60
|
+
};
|
|
171
61
|
```
|
|
172
62
|
|
|
173
|
-
|
|
63
|
+
Each component receives `{ content, routeParams }`:
|
|
174
64
|
|
|
175
65
|
```tsx
|
|
176
|
-
|
|
177
|
-
<BlockRenderer block={{ type: 'header', content: {...} }} />
|
|
66
|
+
import type { BlockComponentProps, HeroBlockContent } from 'cms-renderer/lib/types';
|
|
178
67
|
|
|
179
|
-
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
))}
|
|
68
|
+
export function HeroBlock({ content }: BlockComponentProps<HeroBlockContent>) {
|
|
69
|
+
return <h1>{content.headline}</h1>;
|
|
70
|
+
}
|
|
183
71
|
```
|
|
184
72
|
|
|
185
|
-
|
|
73
|
+
Custom blocks (defined via CMS schema builder) are registered by `schema_name`:
|
|
186
74
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
| `header` | `HeaderBlock` | headline, subheadline, backgroundImage, ctaButton, alignment |
|
|
191
|
-
| `article` | `ArticleBlock` | headline, author, publishedAt, body (markdown), tags |
|
|
75
|
+
```ts
|
|
76
|
+
registry['my-custom-schema'] = MyCustomComponent;
|
|
77
|
+
```
|
|
192
78
|
|
|
193
|
-
|
|
79
|
+
---
|
|
194
80
|
|
|
195
|
-
|
|
81
|
+
### Path-Namespaced Registry
|
|
196
82
|
|
|
197
|
-
|
|
198
|
-
// Direct procedure call in Server Components
|
|
199
|
-
import { createCaller } from '@/server/routers';
|
|
200
|
-
import { createContext } from '@/server/trpc';
|
|
83
|
+
When a page path is passed to `BlockRenderer`, registry keys of the form `"/{pattern} BlockType"` take priority over plain `"BlockType"` keys. Segments wrapped in `{…}` or `(…)` are wildcards.
|
|
201
84
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
85
|
+
```ts
|
|
86
|
+
registry = {
|
|
87
|
+
// Used only on /en/... paths
|
|
88
|
+
'/{lang}/products article': ArticleBlockLocalized,
|
|
89
|
+
// Fallback for all other paths
|
|
90
|
+
'article': ArticleBlockDefault,
|
|
91
|
+
};
|
|
206
92
|
|
|
207
|
-
|
|
208
|
-
}
|
|
93
|
+
<BlockRenderer block={block} registry={registry} path="/en/products" />
|
|
209
94
|
```
|
|
210
95
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
```tsx
|
|
214
|
-
'use client';
|
|
215
|
-
import { trpc } from '@/lib/trpc';
|
|
216
|
-
|
|
217
|
-
function PageClient({ slug }: { slug: string }) {
|
|
218
|
-
const { data, isLoading } = trpc.pages.getPage.useQuery({ slug });
|
|
96
|
+
This lets you mount path-specific variants without separate route files.
|
|
219
97
|
|
|
220
|
-
|
|
221
|
-
return <PageRenderer title={data.title} blocks={data.blocks} />;
|
|
222
|
-
}
|
|
223
|
-
```
|
|
98
|
+
---
|
|
224
99
|
|
|
225
|
-
###
|
|
100
|
+
### ParametricRoutePage
|
|
226
101
|
|
|
227
|
-
|
|
102
|
+
A complete catch-all page handler. Drop it into `app/[...slug]/page.tsx`:
|
|
228
103
|
|
|
229
104
|
```tsx
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
105
|
+
// app/[...slug]/page.tsx
|
|
106
|
+
import ParametricRoutePage, { generateMetadata } from 'cms-renderer/lib/renderer';
|
|
107
|
+
import { registry } from '@/registry';
|
|
108
|
+
|
|
109
|
+
export { generateMetadata };
|
|
110
|
+
|
|
111
|
+
export default function Page(props: any) {
|
|
112
|
+
return (
|
|
113
|
+
<ParametricRoutePage
|
|
114
|
+
{...props}
|
|
115
|
+
cmsUrl={process.env.CMS_URL!}
|
|
116
|
+
apiKey={process.env.CMS_API_KEY}
|
|
117
|
+
registry={registry}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
234
121
|
```
|
|
235
122
|
|
|
236
|
-
|
|
123
|
+
It handles:
|
|
124
|
+
- Route lookup via `client.route.getByPath`
|
|
125
|
+
- Block fetching (parallel, failures are skipped)
|
|
126
|
+
- Article publish-state filtering
|
|
127
|
+
- `?edit_mode=true` — enables editable wrappers (for iframe preview)
|
|
128
|
+
- `?ai_preview=1` — renders AI-generated content variant at index 1
|
|
237
129
|
|
|
238
130
|
---
|
|
239
131
|
|
|
240
|
-
|
|
132
|
+
### Edit Mode & BlockRenderer
|
|
241
133
|
|
|
242
|
-
|
|
134
|
+
When `disableEditable` is `false` (i.e. `edit_mode=true`), `BlockRenderer` wraps each block with `data-cms-block` and injects `data-cms-editable` spans around every text node that corresponds to a content field.
|
|
243
135
|
|
|
244
|
-
|
|
245
|
-
|----------|--------|-------|---------|
|
|
246
|
-
| `blocks.getNavigation` | Query | None | Navigation data |
|
|
247
|
-
| `blocks.getHeader` | Query | None | Header data |
|
|
248
|
-
| `blocks.getArticle` | Query | None | Article data |
|
|
249
|
-
| `pages.getPage` | Query | `{ slug: string }` | Page with blocks array |
|
|
136
|
+
**How span injection works:**
|
|
250
137
|
|
|
251
|
-
|
|
138
|
+
1. **Server-side (preferred):** `renderToWalkableTree` invokes sync function components to produce a host-element tree, then `walkReactNode` traverses it and wraps matching text values in `<span data-cms-editable … />`.
|
|
139
|
+
2. **Client-side fallback:** When a component uses hooks and can't be invoked server-side, `ClientEditableBlock` takes over — it uses `MutationObserver` to re-inject spans after every React commit, keeping overlays alive through state-driven re-renders.
|
|
252
140
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
141
|
+
**`walkReactNode`** is also exported for custom transforms:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { walkReactNode } from 'cms-renderer/lib/block-renderer';
|
|
256
145
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
146
|
+
const highlighted = walkReactNode(tree, {
|
|
147
|
+
onText: ({ value }) => value.replace(/foo/g, '**foo**'),
|
|
148
|
+
onElement: ({ element }) => element,
|
|
149
|
+
});
|
|
261
150
|
```
|
|
262
151
|
|
|
263
152
|
---
|
|
264
153
|
|
|
265
|
-
|
|
154
|
+
### BlockToolbar
|
|
266
155
|
|
|
267
|
-
|
|
156
|
+
Rendered inside every editable block. Portals to `document.body` so it never disrupts layout. Positioned by an inline script that tracks `mouseover` events on `[data-cms-block]` elements.
|
|
268
157
|
|
|
269
|
-
|
|
270
|
-
# Type check
|
|
271
|
-
bun run check-types
|
|
158
|
+
Actions are dispatched to the parent frame via `postMessage`:
|
|
272
159
|
|
|
273
|
-
|
|
274
|
-
|
|
160
|
+
```ts
|
|
161
|
+
// Messages sent to window.parent:
|
|
162
|
+
{ type: 'cms-block-action', action: 'move-up' | 'move-down' | 'add-block' | 'delete', blockId }
|
|
163
|
+
{ type: 'cms-editable-click', blockId, blockType, contentPath } // null contentPath = block-level click
|
|
275
164
|
```
|
|
276
165
|
|
|
277
|
-
|
|
278
|
-
- `components/blocks/*.test.tsx` for block components
|
|
279
|
-
- `server/routers/**/*.test.ts` for tRPC procedures
|
|
166
|
+
The CMS admin panel listens for these messages to open the field editor or reorder blocks.
|
|
280
167
|
|
|
281
168
|
---
|
|
282
169
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
### Port 3001 Already in Use
|
|
286
|
-
|
|
287
|
-
```bash
|
|
288
|
-
# Find and kill the process
|
|
289
|
-
lsof -i :3001
|
|
290
|
-
kill -9 <PID>
|
|
291
|
-
|
|
292
|
-
# Or use a different port
|
|
293
|
-
bun run dev -- --port 3002
|
|
294
|
-
```
|
|
170
|
+
### Refresher
|
|
295
171
|
|
|
296
|
-
|
|
172
|
+
Subscribes to the CMS SSE stream and calls `router.refresh()` when content changes, keeping the page in sync during live editing:
|
|
297
173
|
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
174
|
+
```tsx
|
|
175
|
+
// app/layout.tsx
|
|
176
|
+
import { Refresher } from 'cms-renderer/lib/refresher';
|
|
177
|
+
|
|
178
|
+
export default function Layout({ children }) {
|
|
179
|
+
return (
|
|
180
|
+
<html>
|
|
181
|
+
<body>
|
|
182
|
+
{children}
|
|
183
|
+
<Refresher
|
|
184
|
+
websiteId={process.env.NEXT_PUBLIC_WEBSITE_ID!}
|
|
185
|
+
cmsUrl={process.env.CMS_URL!}
|
|
186
|
+
apiKey={process.env.CMS_API_KEY}
|
|
187
|
+
/>
|
|
188
|
+
</body>
|
|
189
|
+
</html>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
302
192
|
```
|
|
303
193
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
The `.next/types` directory is generated on first dev server run:
|
|
194
|
+
---
|
|
307
195
|
|
|
308
|
-
|
|
309
|
-
bun run dev # Run once to generate types
|
|
310
|
-
bun run check-types # Now type checking works
|
|
311
|
-
```
|
|
196
|
+
### Proxy Middleware
|
|
312
197
|
|
|
313
|
-
|
|
198
|
+
Proxies `/admin`, `/api`, `/auth` (and optional extra paths) to the upstream CMS server. Add to `middleware.ts`:
|
|
314
199
|
|
|
315
|
-
|
|
200
|
+
```ts
|
|
201
|
+
import { createCmsProxy } from 'cms-renderer/lib/proxy';
|
|
316
202
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
bun run build
|
|
203
|
+
export const middleware = createCmsProxy({
|
|
204
|
+
upstream: process.env.CMS_URL, // or ADMIN_UPSTREAM_ORIGIN env var
|
|
205
|
+
additionalPaths: ['/uploads'],
|
|
206
|
+
});
|
|
322
207
|
```
|
|
323
208
|
|
|
324
|
-
### tRPC 'NOT_FOUND' Error
|
|
325
|
-
|
|
326
|
-
The mock data only includes pages with slugs: `demo`, `about`, `blog`. Requesting other slugs returns a 404.
|
|
327
|
-
|
|
328
209
|
---
|
|
329
210
|
|
|
330
|
-
##
|
|
211
|
+
## File Structure
|
|
331
212
|
|
|
332
|
-
|
|
333
|
-
- **CMS App**: [`apps/cms/README.md`](../cms/README.md) - Content authoring application
|
|
334
|
-
- **Schema Package**: [`packages/cms-schema/`](../../packages/cms-schema/) - Shared Zod schemas
|
|
335
|
-
- **Markdown Package**: [`packages/markdown-wasm/`](../../packages/markdown-wasm/) - WASM renderer
|
|
213
|
+
The recommended structure for a consuming Next.js app:
|
|
336
214
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
215
|
+
```
|
|
216
|
+
my-app/
|
|
217
|
+
app/
|
|
218
|
+
[...slug]/
|
|
219
|
+
page.tsx # ParametricRoutePage
|
|
220
|
+
layout.tsx # Refresher goes here
|
|
221
|
+
components/
|
|
222
|
+
hero-block.tsx # Block components
|
|
223
|
+
article-block.tsx
|
|
224
|
+
registry.ts # BlockComponentRegistry
|
|
225
|
+
middleware.ts # createCmsProxy
|
|
348
226
|
```
|
|
349
227
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
1. Define the schema in `apps/cms/app/schemas/`
|
|
353
|
-
2. Add mock data to `seed/index.ts`
|
|
354
|
-
3. Create the component in `components/blocks/`
|
|
355
|
-
4. Add to `blockComponents` registry in `components/blocks/index.ts`
|
|
356
|
-
5. Add the type to `BlockData` union in `components/blocks/types.ts`
|
|
228
|
+
---
|
|
357
229
|
|
|
358
|
-
|
|
230
|
+
## Scripts
|
|
359
231
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
232
|
+
| Script | Description |
|
|
233
|
+
|--------|-------------|
|
|
234
|
+
| `bun run build` | Production build (tsup) |
|
|
235
|
+
| `bun run dev` | Watch mode |
|
|
236
|
+
| `bun run check-types` | TypeScript type check |
|
|
237
|
+
| `bun run lint` | Biome lint |
|
|
238
|
+
| `bun test` | Run tests |
|
|
@@ -186,28 +186,18 @@ function BlockRenderer({
|
|
|
186
186
|
"data-cms-block": true,
|
|
187
187
|
"data-block-id": block.id,
|
|
188
188
|
"data-block-type": block.type,
|
|
189
|
-
style: {
|
|
189
|
+
style: { display: "contents" },
|
|
190
190
|
children: [
|
|
191
191
|
/* @__PURE__ */ jsx("style", { children: `
|
|
192
192
|
[data-cms-block] {
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
[data-cms-block]:hover {
|
|
196
|
-
outline: 2px solid #3b82f6;
|
|
197
|
-
outline-offset: 4px;
|
|
193
|
+
display: contents;
|
|
198
194
|
}
|
|
199
195
|
[data-cms-editable] {
|
|
196
|
+
display: contents;
|
|
200
197
|
cursor: pointer;
|
|
201
|
-
border-radius: 2px;
|
|
202
|
-
}
|
|
203
|
-
[data-cms-editable]:hover {
|
|
204
|
-
outline: 2px solid #3b82f6;
|
|
205
|
-
outline-offset: 2px;
|
|
206
198
|
}
|
|
207
199
|
.cms-block-toolbar {
|
|
208
|
-
position:
|
|
209
|
-
bottom: 8px;
|
|
210
|
-
left: 50%;
|
|
200
|
+
position: fixed;
|
|
211
201
|
transform: translateX(-50%);
|
|
212
202
|
display: flex;
|
|
213
203
|
gap: 4px;
|
|
@@ -218,11 +208,7 @@ function BlockRenderer({
|
|
|
218
208
|
opacity: 0;
|
|
219
209
|
pointer-events: none;
|
|
220
210
|
transition: opacity 0.15s ease;
|
|
221
|
-
z-index:
|
|
222
|
-
}
|
|
223
|
-
[data-cms-block]:hover .cms-block-toolbar {
|
|
224
|
-
opacity: 1;
|
|
225
|
-
pointer-events: auto;
|
|
211
|
+
z-index: 9999;
|
|
226
212
|
}
|
|
227
213
|
.cms-block-toolbar button {
|
|
228
214
|
display: flex;
|
|
@@ -266,34 +252,70 @@ function BlockRenderer({
|
|
|
266
252
|
(function() {
|
|
267
253
|
if (!window.__cmsEditableInitialized) {
|
|
268
254
|
window.__cmsEditableInitialized = true;
|
|
255
|
+
var _ab = null;
|
|
256
|
+
|
|
257
|
+
function _tb(bid) {
|
|
258
|
+
return document.querySelector('.cms-block-toolbar[data-block-id="' + bid + '"]');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _show(bid, target) {
|
|
262
|
+
var tb = _tb(bid);
|
|
263
|
+
if (tb && target) {
|
|
264
|
+
var r = target.getBoundingClientRect();
|
|
265
|
+
tb.style.left = Math.round(r.left + r.width / 2) + 'px';
|
|
266
|
+
tb.style.top = Math.round(r.bottom + 8) + 'px';
|
|
267
|
+
tb.style.opacity = '1';
|
|
268
|
+
tb.style.pointerEvents = 'auto';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _hide(bid) {
|
|
273
|
+
var tb = _tb(bid);
|
|
274
|
+
if (tb) { tb.style.opacity = '0'; tb.style.pointerEvents = 'none'; }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
document.addEventListener('mouseover', function(e) {
|
|
278
|
+
if (e.target.closest('.cms-block-toolbar')) return;
|
|
279
|
+
var bel = e.target.closest('[data-cms-block]');
|
|
280
|
+
var bid = bel ? bel.getAttribute('data-block-id') : null;
|
|
281
|
+
if (bid === _ab) return;
|
|
282
|
+
if (_ab) _hide(_ab);
|
|
283
|
+
_ab = bid;
|
|
284
|
+
if (bid) _show(bid, e.target);
|
|
285
|
+
});
|
|
269
286
|
|
|
270
287
|
document.addEventListener('click', function(e) {
|
|
271
288
|
if (e.target.closest('.cms-block-toolbar')) return;
|
|
272
289
|
|
|
273
|
-
var
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
var path = e.composedPath ? e.composedPath() : [e.target];
|
|
291
|
+
var et = null;
|
|
292
|
+
for (var i = 0; i < path.length; i++) {
|
|
293
|
+
var n = path[i];
|
|
294
|
+
if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute('data-cms-editable')) { et = n; break; }
|
|
295
|
+
}
|
|
296
|
+
if (!et) et = e.target.closest && e.target.closest('[data-cms-editable]');
|
|
297
|
+
|
|
298
|
+
if (et) {
|
|
281
299
|
if (window.parent && window.parent !== window) {
|
|
282
|
-
window.parent.postMessage(
|
|
300
|
+
window.parent.postMessage({
|
|
301
|
+
type: 'cms-editable-click',
|
|
302
|
+
blockId: et.getAttribute('data-block-id'),
|
|
303
|
+
blockType: et.getAttribute('data-block-type'),
|
|
304
|
+
contentPath: et.getAttribute('data-content-path')
|
|
305
|
+
}, '*');
|
|
283
306
|
}
|
|
284
307
|
return;
|
|
285
308
|
}
|
|
286
309
|
|
|
287
|
-
var
|
|
288
|
-
if (
|
|
289
|
-
var message = {
|
|
290
|
-
type: 'cms-editable-click',
|
|
291
|
-
blockId: blockTarget.getAttribute('data-block-id'),
|
|
292
|
-
blockType: blockTarget.getAttribute('data-block-type'),
|
|
293
|
-
contentPath: null
|
|
294
|
-
};
|
|
310
|
+
var bt = e.target.closest && e.target.closest('[data-cms-block]');
|
|
311
|
+
if (bt) {
|
|
295
312
|
if (window.parent && window.parent !== window) {
|
|
296
|
-
window.parent.postMessage(
|
|
313
|
+
window.parent.postMessage({
|
|
314
|
+
type: 'cms-editable-click',
|
|
315
|
+
blockId: bt.getAttribute('data-block-id'),
|
|
316
|
+
blockType: bt.getAttribute('data-block-type'),
|
|
317
|
+
contentPath: null
|
|
318
|
+
}, '*');
|
|
297
319
|
}
|
|
298
320
|
}
|
|
299
321
|
});
|