cms-renderer 0.5.2 → 0.6.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 CHANGED
@@ -1,43 +1,156 @@
1
- # cms-renderer
1
+ # Website
2
2
 
3
- A library for rendering CMS-authored content in Next.js websites.
3
+ **CMS-powered website built with Next.js 15 and App Router**
4
4
 
5
5
  ```
6
- CMS (authors content) ──► tRPC API ──► Website (renders blocks)
6
+ CMS (authors content) --> tRPC API --> Website (renders blocks)
7
7
  ```
8
8
 
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 |
9
+ The website app fetches structured page data via tRPC and renders blocks using the ComponentMap pattern. Article content uses WASM-powered markdown rendering for performance.
27
10
 
28
11
  ---
29
12
 
30
13
  ## Quick Start
31
14
 
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
+
32
24
  ```bash
33
- bun install # from monorepo root
34
- bun run build # or: bun run dev (watch mode)
25
+ # From monorepo root
26
+ bun install
27
+
28
+ # Start the website dev server
29
+ cd apps/renderer
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.
35
40
  ```
36
41
 
37
- **Required env vars** (in the consuming Next.js app):
42
+ Visit [http://localhost:3001/demo](http://localhost:3001/demo) to see the complete demo page with navigation, header, and article blocks.
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
+ │ └── renderer/ # 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/renderer/
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:
38
150
 
39
151
  ```env
40
- NEXT_PUBLIC_WEBSITE_ID=<uuid> # or pass websiteId prop directly
152
+ # .env.local (not required for development)
153
+ DATABASE_URL=your-database-url
41
154
  ```
42
155
 
43
156
  ---
@@ -46,193 +159,204 @@ NEXT_PUBLIC_WEBSITE_ID=<uuid> # or pass websiteId prop directly
46
159
 
47
160
  ### ComponentMap Pattern
48
161
 
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';
162
+ The website uses a registry-based pattern to map block types to React components:
56
163
 
57
- export const registry: Partial<BlockComponentRegistry> = {
58
- 'hero-block': HeroBlock,
59
- 'article': ArticleBlock,
60
- };
164
+ ```typescript
165
+ // components/blocks/index.ts
166
+ export const blockComponents: BlockComponentRegistry = {
167
+ navigation: NavigationBlock,
168
+ header: HeaderBlock,
169
+ article: ArticleBlock,
170
+ } as const;
61
171
  ```
62
172
 
63
- Each component receives `{ content, routeParams }`:
173
+ The `BlockRenderer` uses this registry to dispatch blocks:
64
174
 
65
175
  ```tsx
66
- import type { BlockComponentProps, HeroBlockContent } from 'cms-renderer/lib/types';
176
+ // Render any block by type
177
+ <BlockRenderer block={{ type: 'header', content: {...} }} />
67
178
 
68
- export function HeroBlock({ content }: BlockComponentProps<HeroBlockContent>) {
69
- return <h1>{content.headline}</h1>;
70
- }
179
+ // Render a page of blocks
180
+ {page.blocks.map((block, i) => (
181
+ <BlockRenderer key={i} block={block} />
182
+ ))}
71
183
  ```
72
184
 
73
- Custom blocks (defined via CMS schema builder) are registered by `schema_name`:
185
+ ### Block Types
74
186
 
75
- ```ts
76
- registry['my-custom-schema'] = MyCustomComponent;
77
- ```
187
+ | Block | Component | Content Fields |
188
+ |-------|-----------|----------------|
189
+ | `navigation` | `NavigationBlock` | logo, ariaLabel, links (3-level hierarchy) |
190
+ | `header` | `HeaderBlock` | headline, subheadline, backgroundImage, ctaButton, alignment |
191
+ | `article` | `ArticleBlock` | headline, author, publishedAt, body (markdown), tags |
78
192
 
79
- ---
193
+ ### tRPC Integration
80
194
 
81
- ### Path-Namespaced Registry
195
+ **Server Component** (recommended):
82
196
 
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.
197
+ ```tsx
198
+ // Direct procedure call in Server Components
199
+ import { createCaller } from '@/server/routers';
200
+ import { createContext } from '@/server/trpc';
84
201
 
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
- };
202
+ export default async function Page({ params }: PageProps) {
203
+ const { slug } = await params;
204
+ const caller = createCaller(createContext());
205
+ const page = await caller.pages.getPage({ slug });
92
206
 
93
- <BlockRenderer block={block} registry={registry} path="/en/products" />
207
+ return <PageRenderer title={page.title} blocks={page.blocks} />;
208
+ }
94
209
  ```
95
210
 
96
- This lets you mount path-specific variants without separate route files.
211
+ **Client Component** (when needed):
97
212
 
98
- ---
213
+ ```tsx
214
+ 'use client';
215
+ import { trpc } from '@/lib/trpc';
99
216
 
100
- ### ParametricRoutePage
217
+ function PageClient({ slug }: { slug: string }) {
218
+ const { data, isLoading } = trpc.pages.getPage.useQuery({ slug });
101
219
 
102
- A complete catch-all page handler. Drop it into `app/[...slug]/page.tsx`:
220
+ if (isLoading) return <div>Loading...</div>;
221
+ return <PageRenderer title={data.title} blocks={data.blocks} />;
222
+ }
223
+ ```
224
+
225
+ ### WASM Markdown Rendering
226
+
227
+ The `ArticleBlock` uses `@repo/markdown-wasm` for fast markdown-to-React conversion:
103
228
 
104
229
  ```tsx
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
- }
230
+ import { MarkdownRenderer } from '@repo/markdown-wasm';
231
+
232
+ // Inside ArticleBlock
233
+ <MarkdownRenderer content={body} className="article-block__content" />
121
234
  ```
122
235
 
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
236
+ md4w parses markdown 2.5x faster than JavaScript alternatives and outputs a traversable AST that maps directly to React components.
129
237
 
130
238
  ---
131
239
 
132
- ### Edit Mode & BlockRenderer
133
-
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.
240
+ ## API Routes Reference
135
241
 
136
- **How span injection works:**
242
+ ### tRPC Endpoints
137
243
 
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.
244
+ | Endpoint | Method | Input | Returns |
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 |
140
250
 
141
- **`walkReactNode`** is also exported for custom transforms:
251
+ ### Testing Endpoints
142
252
 
143
- ```ts
144
- import { walkReactNode } from 'cms-renderer/lib/block-renderer';
253
+ ```bash
254
+ # Start the dev server
255
+ bun run dev
145
256
 
146
- const highlighted = walkReactNode(tree, {
147
- onText: ({ value }) => value.replace(/foo/g, '**foo**'),
148
- onElement: ({ element }) => element,
149
- });
257
+ # Test endpoints (in another terminal)
258
+ curl "http://localhost:3001/api/trpc/blocks.getNavigation"
259
+ curl "http://localhost:3001/api/trpc/blocks.getHeader"
260
+ curl "http://localhost:3001/api/trpc/pages.getPage?input=%7B%22json%22%3A%7B%22slug%22%3A%22demo%22%7D%7D"
150
261
  ```
151
262
 
152
263
  ---
153
264
 
154
- ### BlockToolbar
265
+ ## Testing
155
266
 
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.
267
+ The website app does not currently have unit tests. Integration testing is done via the tRPC endpoints.
157
268
 
158
- Actions are dispatched to the parent frame via `postMessage`:
269
+ ```bash
270
+ # Type check
271
+ bun run check-types
159
272
 
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
273
+ # Lint
274
+ bun run lint
164
275
  ```
165
276
 
166
- The CMS admin panel listens for these messages to open the field editor or reorder blocks.
277
+ Test files would live at:
278
+ - `components/blocks/*.test.tsx` for block components
279
+ - `server/routers/**/*.test.ts` for tRPC procedures
167
280
 
168
281
  ---
169
282
 
170
- ### Refresher
283
+ ## Troubleshooting
171
284
 
172
- Subscribes to the CMS SSE stream and calls `router.refresh()` when content changes, keeping the page in sync during live editing:
285
+ ### Port 3001 Already in Use
173
286
 
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
- }
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
192
294
  ```
193
295
 
194
- ---
296
+ ### Cannot Find Module '@repo/cms-schema'
195
297
 
196
- ### Proxy Middleware
298
+ ```bash
299
+ # Run bun install from monorepo root
300
+ cd ../..
301
+ bun install
302
+ ```
303
+
304
+ ### TypeScript Errors on First Run
305
+
306
+ The `.next/types` directory is generated on first dev server run:
307
+
308
+ ```bash
309
+ bun run dev # Run once to generate types
310
+ bun run check-types # Now type checking works
311
+ ```
197
312
 
198
- Proxies `/admin`, `/api`, `/auth` (and optional extra paths) to the upstream CMS server. Add to `middleware.ts`:
313
+ ### WASM Module Not Found
199
314
 
200
- ```ts
201
- import { createCmsProxy } from 'cms-renderer/lib/proxy';
315
+ If you see errors about md4w:
202
316
 
203
- export const middleware = createCmsProxy({
204
- upstream: process.env.CMS_URL, // or ADMIN_UPSTREAM_ORIGIN env var
205
- additionalPaths: ['/uploads'],
206
- });
317
+ ```bash
318
+ # Rebuild the markdown-wasm package
319
+ cd ../../packages/markdown-wasm
320
+ bun install
321
+ bun run build
207
322
  ```
208
323
 
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
+
209
328
  ---
210
329
 
211
- ## File Structure
330
+ ## Related Documentation
212
331
 
213
- The recommended structure for a consuming Next.js app:
332
+ - **Tutorial**: [`_docs/cms-website-integration/`](../../_docs/cms-website-integration/00-overview.md) - Full 11-chapter tutorial
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
214
336
 
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
337
+ ---
338
+
339
+ ## Contributing
340
+
341
+ ### Code Style
342
+
343
+ This project uses [Biome](https://biomejs.dev/) for linting and formatting:
344
+
345
+ ```bash
346
+ bun run lint # Check for issues
347
+ bun run lint --fix # Auto-fix issues
226
348
  ```
227
349
 
228
- ---
350
+ ### Adding a New Block Type
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`
229
357
 
230
- ## Scripts
358
+ ### Pull Requests
231
359
 
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 |
360
+ - Run `bun run check-types` before submitting
361
+ - Run `bun run lint` to ensure code style compliance
362
+ - Test the demo page at `/demo` renders correctly
@@ -43,12 +43,8 @@ declare function walkReactNode(node: React__default.ReactNode, visitors: WalkVis
43
43
  inSvg?: boolean;
44
44
  }): React__default.ReactNode;
45
45
  /**
46
- * Renders the shared CSS and event-wiring script needed for the CMS edit
47
- * overlay. Render this **once** at page level when edit mode is active
48
- * e.g. just before the block list in your route component.
49
- *
50
- * If you use `BlockRenderer` standalone (outside of `ParametricRoutePage`)
51
- * you must render `<CmsEditableInit />` yourself somewhere on the page.
46
+ * Renders the shared CMS edit-mode styles and click-routing script.
47
+ * Place this once at the top of your page when edit_mode is active.
52
48
  */
53
49
  declare function CmsEditableInit(): React__default.JSX.Element;
54
50
  interface BlockRendererProps {
@@ -81,10 +77,13 @@ interface BlockRendererProps {
81
77
  * Uses the ComponentMap pattern: the block's `type` field determines which
82
78
  * component renders the block's `content`.
83
79
  *
84
- * Internally, it:
85
- * 1. Renders the component tree by invoking function components
86
- * 2. Extracts all string values from block.content
87
- * 3. Walks the rendered tree and wraps matching text nodes with spans
80
+ * In editable mode, wraps the block in a ClientEditableBlock that:
81
+ * - Stamps data-cms-block attributes directly on the component's root element
82
+ * - Injects data-cms-editable spans around matching text nodes
83
+ * - Portals the BlockToolbar into the component's root element
84
+ *
85
+ * Render CmsEditableInit once at the page level to include the shared styles
86
+ * and click-routing script.
88
87
  */
89
88
  declare function BlockRenderer({ block, registry, disableEditable, routeParams, path, }: BlockRendererProps): React__default.JSX.Element | null;
90
89