rankrunners-cms 0.0.17 → 0.0.19

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,200 +1,798 @@
1
1
  # RankRunners CMS Integration Guide
2
2
 
3
- This guide provides a comprehensive overview of how to integrate `rankrunners-cms` into a TanStack React website (specifically using TanStack Start and TanStack Router).
3
+ A comprehensive guide for integrating `rankrunners-cms` into TanStack Start websites with a block-based visual editor powered by [Puck](https://puckeditor.com).
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Overview](#1-overview)
8
+ 2. [Sample Projects](#2-sample-projects)
9
+ 3. [Installation](#3-installation)
10
+ 4. [Project Structure](#4-project-structure)
11
+ 5. [Setting Up the Editor](#5-setting-up-the-editor)
12
+ 6. [Creating Component Blocks](#6-creating-component-blocks)
13
+ 7. [Creating Page Data](#7-creating-page-data)
14
+ 8. [Setting Up Routes](#8-setting-up-routes)
15
+ 9. [Sitemap & Robots.txt](#9-sitemap--robotstxt)
16
+ 10. [Best Practices](#10-best-practices)
17
+ 11. [Developer Checklist](#11-developer-checklist)
18
+ 12. [Troubleshooting](#12-troubleshooting)
19
+
20
+ ---
4
21
 
5
22
  ## 1. Overview
6
23
 
7
- `rankrunners-cms` is a specialized library designed to enhance TanStack-based websites with SEO capabilities and a modular CMS editor powered by [Puck](https://puckeditor.com). It provides utilities for SEO management, script injection, and a structured pattern for building editable components.
24
+ `rankrunners-cms` is a specialized library that provides:
25
+
26
+ - **Visual Block Editor**: Real-time content editing using Puck
27
+ - **SEO Management**: Automatic meta tags and script injection
28
+ - **Reusable Components**: Pattern for creating editable component blocks
29
+ - **Page Renderer**: Automatic page rendering from initial data
30
+
31
+ ### Key Concepts
32
+
33
+ - **Blocks**: Editable components with configurable fields
34
+ - **Initial Data**: Default page content that can be edited in the CMS
35
+ - **Root Config**: Layout wrapper (Header, Footer) applied to all pages
36
+ - **Page Renderer**: Component that renders pages based on URL path
8
37
 
9
38
  ---
10
39
 
11
- ## 2. Prerequisites
40
+ ## 2. Sample Projects
41
+
42
+ **Only TypeScript-based projects are supported. JavaScript projects are not supported.**
43
+
44
+ There are two implementations of the CMS support library as of now:
45
+ 1. **The TanStack Start implementation**: This is the recommended implementation. Use this for new projects. Full support for `HeaderScripts` and `FooterScripts`.
46
+ 2. **The Next.js implementation**: This implementation exists for legacy sites. There are outstanding issues with the SEO integration. Next.js hydration does not load the script tags in time, and often removes them after hydration. There are many issues with Next.js script tags. `HeaderScripts` and `FooterScripts` are completely impossible to implement.
47
+
48
+ The following projects use the Next.js implementation:
49
+ - [Dependable Roofing](https://github.com/rankrunners-dev/dependable-roofing)
50
+ - [Pristine Roofing](https://github.com/rankrunners-dev/pristine-roofing)
51
+
52
+ The following projects use the TanStack Start implementation:
53
+ - [A+ Handyman Services](https://github.com/rankrunners-dev/aplus-handyman-services)
54
+
55
+ ## 3. Installation
56
+
57
+ ### Add Dependencies
58
+
59
+ ```bash
60
+ # Using bun
61
+ bun add rankrunners-cms @puckeditor/core
62
+
63
+ # Using npm
64
+ npm install rankrunners-cms @puckeditor/core
65
+ ```
12
66
 
13
- Ensure your project has the following dependencies installed:
67
+ ### Required Peer Dependencies
68
+
69
+ Ensure you have TanStack Start and Router installed:
14
70
 
15
71
  ```bash
16
- bun add rankrunners-cms @puckeditor/core @tanstack/react-router @tanstack/react-start
72
+ bun add @tanstack/react-router @tanstack/react-start
73
+ ```
74
+
75
+ ### Environment Variables
76
+
77
+ Add the following to your `.env` file:
78
+
79
+ ```env
80
+ VITE_PUBLIC_SITE_ID=your-site-id
17
81
  ```
18
82
 
83
+ > **Important**: Also add `VITE_PUBLIC_SITE_ID` to your Dockerfile for production builds.
84
+
19
85
  ---
20
86
 
21
- ## 3. SEO Integration
87
+ ## 4. Project Structure
22
88
 
23
- The CMS provides a seamless way to handle SEO metadata and scripts across all routes.
89
+ Create the following structure in your `src/` directory:
24
90
 
25
- ### Root Route Configuration
26
- In your [src/routes/__root.tsx](src/routes/__root.tsx), use `headWithSEO` and `seoScripts` to initialize the global SEO state.
91
+ ```
92
+ src/
93
+ ├── editor/
94
+ │ ├── index.ts # Main config export
95
+ │ ├── root.tsx # Root layout (Header/Footer wrapper)
96
+ │ ├── types.ts # TypeScript type definitions
97
+ │ ├── blocks/
98
+ │ │ ├── main-page-blocks.tsx # Home page specific blocks
99
+ │ │ ├── page-blocks.tsx # General page blocks
100
+ │ │ └── service-page-blocks.tsx # Service-specific blocks, etc.
101
+ │ └── initial-data/
102
+ │ ├── index.ts # Exports all page data
103
+ │ ├── initial.ts # Maps paths to page data
104
+ │ ├── index-page.ts # Home page data
105
+ │ ├── contact-page.ts # Contact page data
106
+ │ └── ... # Other page data files
107
+ ├── components/
108
+ │ ├── Hero.tsx # React components
109
+ │ ├── TrustBar.tsx
110
+ │ └── ...
111
+ └── routes/
112
+ ├── __root.tsx # Root route with SEO
113
+ ├── $.ts # Catch-all route for CMS pages
114
+ └── ...
115
+ ```
27
116
 
28
- You can freely configure the head meta, links and scripts if you want.
117
+ ---
118
+
119
+ ## 5. Setting Up the Editor
120
+
121
+ ### 5.1 Root Configuration (`src/editor/root.tsx`)
122
+
123
+ The root config wraps all pages with your site's Header and Footer:
29
124
 
30
125
  ```tsx
31
- import { headWithSEO } from 'rankrunners-cms/src/tanstack/seo/head';
32
- import { seoScripts } from 'rankrunners-cms/src/tanstack/seo/scripts';
126
+ import { Footer } from '@/components/Footer'
127
+ import { Header } from '@/components/Header'
128
+ import { ContactUs } from '@/components/ContactUs'
129
+ import { ScrollToTopButton } from '@/components/ScrollToTopButton'
130
+ import type { DefaultRootProps, RootConfig } from '@puckeditor/core'
33
131
 
34
- export const Route = createRootRoute({
35
- head: headWithSEO<any>(() => ({
36
- meta: [
37
- { charSet: 'utf-8' },
38
- { name: 'viewport', content: 'width=device-width, initial-scale=1' },
39
- ],
40
- links: [
41
- { rel: 'stylesheet', href: appCss },
42
- { rel: 'icon', href: '/favicon.png' },
43
- ],
44
- })),
45
- scripts: seoScripts as any,
46
- shellComponent: RootDocument,
47
- });
132
+ export type RootProps = DefaultRootProps
133
+
134
+ export const Root: RootConfig = {
135
+ defaultProps: {
136
+ title: 'Your Site Title',
137
+ },
138
+ render: ({ children }) => {
139
+ return (
140
+ <>
141
+ <Header />
142
+ {children}
143
+ <ContactUs title="Contact Us" />
144
+ <Footer />
145
+ <ScrollToTopButton />
146
+ </>
147
+ )
148
+ },
149
+ }
150
+
151
+ export default Root
48
152
  ```
49
153
 
50
- ### Individual Route SEO
51
- For specific pages, use `seoHead` to allow the CMS to inject page-specific metadata.
154
+ ### 5.2 Type Definitions (`src/editor/types.ts`)
52
155
 
53
- ```tsx
54
- import { seoHead } from 'rankrunners-cms/src/tanstack/seo/head';
156
+ Define types for your custom components:
157
+
158
+ ```typescript
159
+ import type { CMSUserConfig, CMSUserData } from 'rankrunners-cms/src/editor/types'
160
+
161
+ import type { MainPageBlockTypes } from './blocks/main-page-blocks'
162
+ import type { PageBlockTypes } from './blocks/page-blocks'
163
+ import type { ServicePageBlockTypes } from './blocks/service-page-blocks'
164
+
165
+ // Combine all block types
166
+ export type CustomComponents = MainPageBlockTypes & PageBlockTypes & ServicePageBlockTypes
167
+
168
+ // Export config and data types
169
+ export type UserConfig = CMSUserConfig<CustomComponents, ['custom']>
170
+ export type UserData = CMSUserData<CustomComponents>
171
+ ```
55
172
 
56
- export const Route = createFileRoute('/my-page')({
57
- head: seoHead as any,
58
- component: MyPageComponent,
59
- });
173
+ ### 5.3 Main Config (`src/editor/index.ts`)
174
+
175
+ Register all blocks and create the config:
176
+
177
+ ```typescript
178
+ import Root from './root'
179
+ import type { CustomComponents } from './types'
180
+ import { createCMSConfig } from 'rankrunners-cms/src/editor/index'
181
+ import { MainPageBlocks } from './blocks/main-page-blocks'
182
+ import { PageBlocks } from './blocks/page-blocks'
183
+ import { ServicePageBlocks } from './blocks/service-page-blocks'
184
+
185
+ // Define categories for the editor sidebar
186
+ export const customCategories = {
187
+ custom: {
188
+ title: 'Custom',
189
+ components: [
190
+ 'HeroBlock',
191
+ 'TrustBarBlock',
192
+ 'IntroductionBlock',
193
+ 'PageTitleBlock',
194
+ 'ContactTodayBlock',
195
+ // ... list all block names
196
+ ],
197
+ },
198
+ }
199
+
200
+ // Combine all blocks
201
+ export const customComponents = {
202
+ ...MainPageBlocks,
203
+ ...PageBlocks,
204
+ ...ServicePageBlocks,
205
+ }
206
+
207
+ // Create and export the config
208
+ export const config = createCMSConfig<CustomComponents, ['custom']>(
209
+ Root,
210
+ customCategories,
211
+ customComponents
212
+ )
60
213
  ```
61
214
 
62
215
  ---
63
216
 
64
- ## 4. CMS Editor Setup (Puck)
217
+ ## 6. Creating Component Blocks
218
+
219
+ ### 6.1 Block Naming Convention
65
220
 
66
- The CMS editor is typically located at `/editor` and allows real-time content editing.
221
+ **Important**: All block names must end with `Block` suffix (e.g., `HeroBlock`, `TrustBarBlock`).
67
222
 
68
- ### The Editor Route
69
- Implement the editor in [src/routes/editor.tsx](src/routes/editor.tsx). It should toggle between the `Puck` editor and the `Render` component based on query parameters.
223
+ ### 6.2 Block Structure Pattern
224
+
225
+ Each block wraps a React component with Puck configuration:
70
226
 
71
227
  ```tsx
72
- import { Puck, Render } from '@puckeditor/core';
73
- import config from '@/editor';
74
- import { initialData } from '@/editor/initial-data';
75
- import '@puckeditor/core/no-external.css';
76
-
77
- export const Route = createFileRoute('/editor')({
78
- component: () => {
79
- const params = new URLSearchParams(window.location.search);
80
- const isEdit = params.get('edit') === 'true';
81
-
82
- if (isEdit) {
83
- return (
84
- <Puck
85
- config={config}
86
- data={initialData}
87
- onPublish={(data) => {
88
- // Save data to localStorage or your API
89
- localStorage.setItem('cms-data', JSON.stringify(data));
90
- }}
91
- />
92
- );
93
- }
94
-
95
- return <Render config={config} data={initialData} />;
228
+ import type { ComponentConfig } from '@puckeditor/core'
229
+ import { withLayout } from 'rankrunners-cms/src/editor/components/Layout'
230
+ import { MyComponent, type MyComponentProps } from '@/components/MyComponent'
231
+
232
+ export const MyComponentBlock: ComponentConfig<MyComponentProps> = withLayout({
233
+ label: 'My Component', // Display name in editor
234
+ fields: {
235
+ title: {
236
+ type: 'text',
237
+ label: 'Title',
238
+ contentEditable: true, // Allow inline editing
239
+ },
240
+ description: {
241
+ type: 'textarea',
242
+ label: 'Description'
243
+ },
244
+ // ... more fields
245
+ },
246
+ defaultProps: {
247
+ title: 'Default Title',
248
+ description: 'Default description text',
249
+ // ... default values for all props
96
250
  },
97
- });
251
+ render: (props) => <MyComponent {...props} />,
252
+ })
98
253
  ```
99
254
 
100
- ---
255
+ ### 6.3 Available Field Types
101
256
 
102
- ## 5. Developing Component Blocks
257
+ | Type | Description | Example |
258
+ |------|-------------|---------|
259
+ | `text` | Single-line text input | `{ type: 'text', label: 'Title' }` |
260
+ | `textarea` | Multi-line text input | `{ type: 'textarea', label: 'Description' }` |
261
+ | `number` | Numeric input | `{ type: 'number', label: 'Count' }` |
262
+ | `select` | Dropdown selection | See below |
263
+ | `array` | List of items | See below |
264
+ | `object` | Nested object | `{ type: 'object', objectFields: {...} }` |
103
265
 
104
- Each editable component (Block) should be modular and define its props and configuration.
266
+ ### 6.4 Select Field Example
105
267
 
106
- ### Example Block: [src/editor/blocks/Heading/index.tsx](src/editor/blocks/Heading/index.tsx)
107
268
  ```tsx
108
- import { ComponentConfig } from '@puckeditor/core';
269
+ category: {
270
+ type: 'select',
271
+ label: 'Category',
272
+ options: [
273
+ { label: 'Repairs', value: 'Repairs' },
274
+ { label: 'Installations', value: 'Installations' },
275
+ { label: 'Remodeling', value: 'Remodeling' },
276
+ ],
277
+ },
278
+ ```
109
279
 
110
- export type HeadingProps = {
111
- text: string;
112
- level: 'h1' | 'h2' | 'h3';
113
- };
280
+ ### 6.5 Array Field Example
114
281
 
115
- export const Heading: ComponentConfig<HeadingProps> = {
116
- fields: {
117
- text: { type: 'text' },
118
- level: {
119
- type: 'select',
120
- options: [
121
- { label: 'H1', value: 'h1' },
122
- { label: 'H2', value: 'h2' },
123
- { label: 'H3', value: 'h3' },
124
- ],
125
- },
282
+ ```tsx
283
+ services: {
284
+ type: 'array',
285
+ label: 'Services',
286
+ getItemSummary: (item) => item.name || 'Service',
287
+ arrayFields: {
288
+ name: { type: 'text', label: 'Service Name' },
289
+ description: { type: 'textarea', label: 'Description' },
290
+ imageUrl: { type: 'text', label: 'Image URL' },
126
291
  },
127
- render: ({ text, level: Tag }) => <Tag>{text}</Tag>,
128
- };
292
+ defaultItemProps: {
293
+ name: 'New Service',
294
+ description: '',
295
+ imageUrl: '/assets/placeholder.webp',
296
+ },
297
+ },
129
298
  ```
130
299
 
131
- ---
300
+ ### 6.6 The `withLayout` Wrapper
132
301
 
133
- ## 6. Sitemap & Robots.txt Support
302
+ **Critical**: Always wrap your block config with `withLayout()` to enable grid/flex layout support:
134
303
 
135
- To add support for these, you need to create the following server-side routes in your TanStack Router setup.
304
+ ```tsx
305
+ // ✅ CORRECT
306
+ export const MyBlock: ComponentConfig<MyProps> = withLayout({
307
+ label: 'My Block',
308
+ fields: { ... },
309
+ defaultProps: { ... },
310
+ render: (props) => <MyComponent {...props} />,
311
+ })
312
+
313
+ // ❌ WRONG - Will cause "Element type is invalid" error
314
+ export const MyBlock: ComponentConfig<MyProps> = {
315
+ label: 'My Block',
316
+ fields: { ... },
317
+ defaultProps: { ... },
318
+ render: withLayout((props) => <MyComponent {...props} />), // DON'T do this
319
+ }
320
+ ```
136
321
 
137
- ### Sitemap Support
138
- Create a file called `src/routes/$sitemap.xml.tsx`:
322
+ ### 6.7 Complete Block File Example
139
323
 
140
324
  ```tsx
141
- import { createFileRoute } from '@tanstack/react-router'
142
- import { downloadSitemapAsResponse } from 'rankrunners-cms/src/api/client/sitemap'
325
+ // src/editor/blocks/page-blocks.tsx
326
+ import type { ComponentConfig } from '@puckeditor/core'
327
+ import { withLayout } from 'rankrunners-cms/src/editor/components/Layout'
328
+ import { PageTitle, type PageTitleProps } from '@/components/PageTitle'
329
+ import { ContactToday, type ContactTodayProps } from '@/components/ContactToday'
330
+
331
+ // PageTitle Block
332
+ export const PageTitleBlock: ComponentConfig<PageTitleProps> = withLayout({
333
+ label: 'Page Title',
334
+ fields: {
335
+ title: { type: 'text', label: 'Title' },
336
+ backgroundImageUrl: { type: 'text', label: 'Background Image URL' },
337
+ },
338
+ defaultProps: {
339
+ title: 'Page Title',
340
+ backgroundImageUrl: '/assets/hero-bg.webp',
341
+ },
342
+ render: (props) => <PageTitle {...props} />,
343
+ })
143
344
 
144
- export const Route = createFileRoute('/$sitemap.xml')({
145
- server: {
146
- handlers: {
147
- GET: ({ params }) => downloadSitemapAsResponse(params['sitemap.xml']),
148
- },
345
+ // ContactToday Block
346
+ export const ContactTodayBlock: ComponentConfig<ContactTodayProps> = withLayout({
347
+ label: 'Contact Today',
348
+ fields: {
349
+ mainTitle: { type: 'text', label: 'Main Title' },
350
+ phoneNumber: { type: 'text', label: 'Phone Number' },
351
+ // ... more fields
352
+ },
353
+ defaultProps: {
354
+ mainTitle: 'Contact Us Today',
355
+ phoneNumber: '15551234567',
356
+ // ... more defaults
149
357
  },
358
+ render: (props) => <ContactToday {...props} />,
150
359
  })
360
+
361
+ // Export types for type.ts
362
+ export type PageBlockTypes = {
363
+ PageTitleBlock: PageTitleProps
364
+ ContactTodayBlock: ContactTodayProps
365
+ }
366
+
367
+ // Export blocks for index.ts
368
+ export const PageBlocks = {
369
+ PageTitleBlock,
370
+ ContactTodayBlock,
371
+ }
151
372
  ```
152
373
 
153
- ### Robots.txt Support
154
- Create a file called `src/routes/robots[.]txt.tsx`:
374
+ ### 6.8 Component Design Guidelines
375
+
376
+ When creating React components for use with the CMS:
377
+
378
+ 1. **Make ALL text configurable via props**
379
+ 2. **Use the same defaults in both the component AND the block**
380
+ 3. **Export the props interface for type safety**
155
381
 
156
382
  ```tsx
157
- import { createFileRoute } from '@tanstack/react-router'
158
- import { downloadSitemapAsResponse } from 'rankrunners-cms/src/api/client/sitemap'
383
+ // src/components/PageTitle.tsx
384
+ import { Link } from '@tanstack/react-router'
385
+ import React from 'react'
386
+
387
+ export interface PageTitleProps {
388
+ title?: string
389
+ backgroundImageUrl?: string
390
+ homeText?: string
391
+ }
392
+
393
+ // Default props - keep in sync with block defaultProps
394
+ const defaultProps: Required<PageTitleProps> = {
395
+ title: 'Page Title',
396
+ backgroundImageUrl: '/assets/hero-bg.webp',
397
+ homeText: 'Home',
398
+ }
399
+
400
+ export const PageTitle: React.FC<PageTitleProps> = (props) => {
401
+ const { title, backgroundImageUrl, homeText } = { ...defaultProps, ...props }
402
+
403
+ return (
404
+ <div
405
+ className="relative bg-cover bg-center min-h-[15rem] flex justify-center items-center"
406
+ style={{ backgroundImage: `url('\${backgroundImageUrl}')` }}
407
+ >
408
+ <div className="absolute inset-0 bg-black/25" />
409
+ <div className="relative text-center text-white">
410
+ <h1 className="text-4xl uppercase">{title}</h1>
411
+ <div className="mt-2">
412
+ <Link to="/">{homeText}</Link> / {title}
413
+ </div>
414
+ </div>
415
+ </div>
416
+ )
417
+ }
418
+ ```
159
419
 
160
- export const Route = createFileRoute('/robots.txt')({
161
- server: {
162
- handlers: {
163
- GET: () => downloadSitemapAsResponse('robots.txt'),
164
- },
420
+ ---
421
+
422
+ ## 7. Creating Page Data
423
+
424
+ ### 7.1 Page Data Structure
425
+
426
+ Each page has an initial data file:
427
+
428
+ ```typescript
429
+ // src/editor/initial-data/contact-page.ts
430
+ // @ts-nocheck
431
+ import type { UserData } from '../types'
432
+
433
+ export const contactPageData: UserData = {
434
+ root: {
435
+ title: 'Contact Us | Your Site Name', // Page title for SEO
165
436
  },
437
+ content: [
438
+ {
439
+ type: 'PageTitleBlock', // Must match block name exactly
440
+ props: {
441
+ id: 'page-title', // Unique ID required
442
+ title: 'Contact Us',
443
+ backgroundImageUrl: '/assets/contact-bg.webp',
444
+ homeText: 'Home',
445
+ },
446
+ },
447
+ {
448
+ type: 'ContactTodayBlock',
449
+ props: {
450
+ id: 'contact-section',
451
+ mainTitle: 'Get In Touch',
452
+ phoneNumber: '15551234567',
453
+ // ... all props for the block
454
+ },
455
+ },
456
+ ],
457
+ zones: {}, // For advanced layouts with drop zones
458
+ }
459
+ ```
460
+
461
+ ### 7.2 Registering Page Data (`src/editor/initial-data/initial.ts`)
462
+
463
+ Map URL paths to page data:
464
+
465
+ ```typescript
466
+ import { indexPageData } from './index-page'
467
+ import { contactPageData } from './contact-page'
468
+ import { testimonialsPageData } from './testimonials-page'
469
+ import { plumbingPageData } from './services/plumbing-page'
470
+
471
+ export const allPageData = {
472
+ '': indexPageData, // Home page (/)
473
+ contact: contactPageData, // /contact
474
+ testimonials: testimonialsPageData, // /testimonials
475
+ 'services/plumbing': plumbingPageData, // /services/plumbing
476
+ }
477
+
478
+ export {
479
+ indexPageData,
480
+ contactPageData,
481
+ testimonialsPageData,
482
+ plumbingPageData,
483
+ }
484
+ ```
485
+
486
+ ### 7.3 Export Initial Data (`src/editor/initial-data/index.ts`)
487
+
488
+ ```typescript
489
+ export { allPageData as initialData } from './initial'
490
+ export * from './initial'
491
+ ```
492
+
493
+ ---
494
+
495
+ ## 8. Setting Up Routes
496
+
497
+ ### 8.1 Root Route (`src/routes/__root.tsx`)
498
+
499
+ In your [src/routes/__root.tsx](src/routes/__root.tsx), use `headWithSEO` and `seoScripts` to initialize the global SEO state.
500
+
501
+ You can freely configure the head meta, links and scripts if you want.
502
+
503
+ ```tsx
504
+ import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
505
+ import { headWithSEO, seoScripts } from 'rankrunners-cms/src/tanstack'
506
+ import appCss from '../styles.css?url'
507
+
508
+ export const Route = createRootRoute({
509
+ head: headWithSEO<any>(() => ({
510
+ meta: [
511
+ { charSet: 'utf-8' },
512
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
513
+ ],
514
+ links: [
515
+ { rel: 'stylesheet', href: appCss },
516
+ { rel: 'icon', href: '/favicon.png' },
517
+ ],
518
+ })),
519
+ scripts: seoScripts as any,
520
+ shellComponent: RootDocument,
166
521
  })
522
+
523
+ function RootDocument({ children }: { children: React.ReactNode }) {
524
+ return (
525
+ <html lang="en">
526
+ <head>
527
+ <HeadContent />
528
+ </head>
529
+ <body>
530
+ {children}
531
+ <Scripts />
532
+ </body>
533
+ </html>
534
+ )
535
+ }
167
536
  ```
168
537
 
538
+ ### 8.2 Catch-All Page Route (`src/routes/$.ts`)
539
+
540
+ This single route handles ALL CMS pages:
541
+
542
+ ```typescript
543
+ import { config } from '@/editor'
544
+ import { initialData } from '@/editor/initial-data'
545
+ import { PageRendererTanstack } from 'rankrunners-cms/src/tanstack'
546
+ import { createFileRoute } from '@tanstack/react-router'
547
+
548
+ export const Route = createFileRoute('/\$')({
549
+ component: () =>
550
+ PageRendererTanstack({
551
+ config,
552
+ allPageData: initialData,
553
+ })(),
554
+ })
555
+ ```
556
+
557
+ ### 8.3 How the Page Renderer Works
558
+
559
+ 1. Extracts the pathname from the URL (e.g., `/contact` -> `contact`)
560
+ 2. Looks up the page data in `allPageData`
561
+ 3. If `?preview=token` query param exists, shows the Puck editor
562
+ 4. Otherwise, renders the page using the `Render` component
563
+
169
564
  ---
170
565
 
171
- ## 7. Developer Checklist
566
+ ## 9. Sitemap & Robots.txt
567
+
568
+ Update the `server.ts` script, or add a Tanstack Start middleware to handle `[sitemap].xml` and `robots.txt` requests:
569
+
570
+ ```typescript
571
+ // Build static routes with intelligent preloading
572
+ const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
573
+
574
+ // Import sitemap handler dynamically to help Bun bundle it
575
+ const downloadSitemapAsResponse = (
576
+ await import('rankrunners-cms/src/api/client/sitemap')
577
+ ).downloadSitemapAsResponse
578
+
579
+ // Create Bun server
580
+ const server = Bun.serve({
581
+ port: SERVER_PORT,
582
+
583
+ routes: {
584
+ // Serve static assets, robots.txt, sitemaps (*.xml), and all other routes
585
+ ...routes,
586
+ '/*': (req: Request) => {
587
+ const url = new URL(req.url)
588
+ const pathname = url.pathname
589
+
590
+ // Check if it's a sitemap file (ends with .xml)
591
+ if (pathname === 'robots.txt' || pathname.endsWith('.xml')) {
592
+ const sitemapFile = pathname.substring(1) // Remove leading slash
593
+ return downloadSitemapAsResponse(sitemapFile)
594
+ }
595
+
596
+ // Otherwise, pass to TanStack Start handler
597
+ try {
598
+ return handler.fetch(req)
599
+ } catch (error) {
600
+ log.error(`Server handler error: ${String(error)}`)
601
+ return new Response('Internal Server Error', { status: 500 })
602
+ }
603
+ },
604
+ },
172
605
 
173
- Use this checklist to ensure a complete and correct integration:
606
+ // Global error handler
607
+ error(error) {
608
+ log.error(
609
+ `Uncaught server error: ${error instanceof Error ? error.message : String(error)}`,
610
+ )
611
+ return new Response('Internal Server Error', { status: 500 })
612
+ },
613
+ })
614
+ ```
174
615
 
175
- ### Core Setup
176
- - [ ] Install `rankrunners-cms` and `@puckeditor/core`.
177
- - [ ] Add `headWithSEO` to `createRootRoute` in [src/routes/__root.tsx](src/routes/__root.tsx).
178
- - [ ] Add `seoScripts` to `createRootRoute` in [src/routes/__root.tsx](src/routes/__root.tsx).
179
- - [ ] Add `<Scripts />` after the body and `<HeadContent />` in the `<head>` to [src/routes/__root.tsx](src/routes/__root.tsx)
616
+ ---
180
617
 
181
- ### SEO & Metadata
182
- - [ ] Include `seoHead` in all page routes (`createFileRoute`).
183
- - [ ] Ensure `VITE_PUBLIC_SITE_ID` and other CMS-related environment variables are set, as well in Dockerfile.
618
+ ## 10. Best Practices
184
619
 
185
- ### Editor Infrastructure
186
- - [ ] Create `src/editor/` structure.
187
- - [ ] Define `UserConfig` and `UserData` types in `types.ts`.
188
- - [ ] Set up `src/editor/root.tsx` with Header/Footer and `DropZone`.
189
- - [ ] Create `initial-data.ts` to populate the editor initially.
620
+ ### Component Design
621
+ - Make ALL text configurable (no hardcoded strings)
622
+ - Keep default props in sync between component and block
623
+ - Export component props interface
624
+ - Use semantic HTML for accessibility
625
+
626
+ ### Block Design
627
+ - ✅ Always use `withLayout()` wrapper
628
+ - ✅ End block names with `Block` suffix
629
+ - ✅ Provide meaningful `label` for editor sidebar
630
+ - ✅ Use `contentEditable: true` for inline-editable text fields
631
+ - ✅ Use `getItemSummary` for array fields to show previews
632
+
633
+ ### Page Data
634
+ - ✅ Always include unique `id` in each block's props
635
+ - ✅ Match block `type` exactly to exported block name
636
+
637
+ ### Common Pitfalls
638
+ - ❌ Don't use `@measured/puck` - it's been renamed to `@puckeditor/core`
639
+ - ❌ Don't wrap only the render function with `withLayout()`
640
+ - ❌ Don't use `type: 'list'` for array fields (use `type: 'array'`)
641
+
642
+ ---
643
+
644
+ ## 11. Developer Checklist
645
+
646
+ ### Initial Setup
647
+ - [ ] Install `rankrunners-cms` and `@puckeditor/core`
648
+ - [ ] Set `VITE_PUBLIC_SITE_ID` environment variable
649
+ - [ ] Add `VITE_PUBLIC_SITE_ID` to Dockerfile
650
+
651
+ ### Root Route Configuration
652
+ - [ ] Add `headWithSEO` to `createRootRoute`
653
+ - [ ] Add `seoScripts` to `createRootRoute`
654
+ - [ ] Add `<HeadContent />` in `<head>`
655
+ - [ ] Add `<Scripts />` after body content
656
+
657
+ ### Editor Structure
658
+ - [ ] Create `src/editor/index.ts` with config export
659
+ - [ ] Create `src/editor/root.tsx` with Header/Footer wrapper
660
+ - [ ] Create `src/editor/types.ts` with type definitions
661
+
662
+ ### Block Implementation
663
+ - [ ] Create blocks in `src/editor/blocks/`
664
+ - [ ] Use `withLayout()` wrapper for each block
665
+ - [ ] Export block types (e.g., `PageBlockTypes`)
666
+ - [ ] Export blocks object (e.g., `PageBlocks`)
667
+ - [ ] Register blocks in `customCategories`
668
+ - [ ] Register blocks in `customComponents`
669
+
670
+ ### Page Data
671
+ - [ ] Create page data files in `src/editor/initial-data/`
672
+ - [ ] Register paths in `initial.ts` `allPageData` object
673
+
674
+ ### Routes
675
+ - [ ] Create catch-all route `src/routes/$.ts`
676
+
677
+ ### Sitemaps
678
+ - [ ] Implement sitemap serving middleware in `server.ts`
190
679
 
191
680
  ### Component Implementation
192
681
  - [ ] Migrate/Create components in `src/editor/blocks/`.
193
682
  - [ ] Register all blocks in `src/editor/index.tsx`.
194
683
  - [ ] Organize blocks into `categories` within the config.
195
684
 
196
- ### Sitemap & Robots.txt
197
- - [ ] Add `src/routes/$sitemap.xml.tsx` for dynamic sitemap support.
198
- - [ ] Add `src/routes/robots.txt.tsx` for `robots.txt` support.
685
+ ### CMS configuration
686
+ - [ ] Added the new pages to the site in the [CMS user interface](https://cms.rankrunners.net) and saved at least once.
687
+
688
+ ---
689
+
690
+ ## 12. Troubleshooting
691
+
692
+ ### "Element type is invalid: expected a string...but got: object"
693
+
694
+ **Cause**: Using `withLayout()` incorrectly.
695
+
696
+ **Fix**: Wrap the entire ComponentConfig object, not just the render function:
697
+
698
+ ```tsx
699
+ // ✅ Correct
700
+ export const MyBlock = withLayout({
701
+ label: '...',
702
+ fields: {...},
703
+ render: (props) => <MyComponent {...props} />,
704
+ })
705
+
706
+ // ❌ Wrong
707
+ export const MyBlock = {
708
+ label: '...',
709
+ fields: {...},
710
+ render: withLayout((props) => <MyComponent {...props} />),
711
+ }
712
+ ```
713
+
714
+ ### Page shows "Loading page..." forever
715
+
716
+ **Cause**: Path mismatch between URL and `allPageData` keys.
717
+
718
+ **Fix**: Ensure keys in `allPageData` match URL paths without leading slashes:
719
+ - URL `/contact` -> key `contact`
720
+ - URL `/services/plumbing` -> key `services/plumbing`
721
+ - URL `/` -> key `''` (empty string)
722
+
723
+ ### Build error: Unexpected character
724
+
725
+ **Cause**: Using special characters in strings.
726
+
727
+ **Fix**: Replace curly quotes and em-dashes:
728
+ - Curly single quotes -> straight single quote `'`
729
+ - Curly double quotes -> straight double quote `"`
730
+ - Em-dash -> hyphen `-` or double hyphen `--`
731
+
732
+ ### TypeScript error with field type
733
+
734
+ **Cause**: Using unsupported field type.
735
+
736
+ **Fix**: Use supported types: `text`, `textarea`, `number`, `select`, `array`, `object`
199
737
 
200
738
  ---
739
+
740
+ ## Quick Start Template
741
+
742
+ Create a new CMS-enabled page in 3 steps:
743
+
744
+ **1. Create the component** (`src/components/MyPage.tsx`)
745
+ ```tsx
746
+ export interface MyPageProps {
747
+ title?: string
748
+ content?: string
749
+ }
750
+
751
+ export const MyPage: React.FC<MyPageProps> = ({
752
+ title = 'Default Title',
753
+ content = 'Default content'
754
+ }) => (
755
+ <div className="max-w-4xl mx-auto py-8 px-4">
756
+ <h1>{title}</h1>
757
+ <p>{content}</p>
758
+ </div>
759
+ )
760
+ ```
761
+
762
+ **2. Create the block** (add to `src/editor/blocks/page-blocks.tsx`)
763
+ ```tsx
764
+ export const MyPageBlock: ComponentConfig<MyPageProps> = withLayout({
765
+ label: 'My Page',
766
+ fields: {
767
+ title: { type: 'text', label: 'Title' },
768
+ content: { type: 'textarea', label: 'Content' },
769
+ },
770
+ defaultProps: {
771
+ title: 'Default Title',
772
+ content: 'Default content',
773
+ },
774
+ render: (props) => <MyPage {...props} />,
775
+ })
776
+ ```
777
+
778
+ **3. Create the page data** (`src/editor/initial-data/mypage-page.ts`)
779
+ ```typescript
780
+ export const mypagePageData: UserData = {
781
+ root: { title: 'My Page | Site Name' },
782
+ content: [
783
+ {
784
+ type: 'MyPageBlock',
785
+ props: { id: 'my-page', title: 'Welcome', content: 'Hello world!' },
786
+ },
787
+ ],
788
+ zones: {},
789
+ }
790
+ ```
791
+
792
+ Then register in `initial.ts`:
793
+ ```typescript
794
+ export const allPageData = {
795
+ // ...
796
+ 'mypage': mypagePageData,
797
+ }
798
+ ```
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "rankrunners-cms",
3
3
  "type": "module",
4
- "version": "0.0.17",
4
+ "version": "0.0.19",
5
5
  "peerDependencies": {
6
- "@puckeditor/core": "^0.21.0",
6
+ "@puckeditor/core": "^0.21.1",
7
7
  "next": "^16.1.2",
8
8
  "@tanstack/router-core": "^1.150.0",
9
9
  "@tanstack/react-router": "^1.150.0",
@@ -29,15 +29,14 @@
29
29
  }
30
30
  },
31
31
  "devDependencies": {
32
- "@types/react": "^19.2.8",
33
- "bun-types": "^1.3.6",
32
+ "@types/react": "^19.2.11",
33
+ "bun-types": "^1.3.8",
34
34
  "typescript": "^5.9.3",
35
- "@tanstack/router-core": "^1.150.0",
36
- "@tanstack/react-router": "^1.150.0",
37
- "next": "^16.1.3",
35
+ "@tanstack/router-core": "^1.158.0",
36
+ "@tanstack/react-router": "^1.158.0",
37
+ "next": "^16.1.6",
38
38
  "valibot": "^1.2.0",
39
- "lucide-react": "^0.562.0",
40
- "react": "^19.2.3"
39
+ "lucide-react": "^0.563.0"
41
40
  },
42
41
  "dependencies": {
43
42
  "classnames": "^2.5.1",
@@ -0,0 +1,57 @@
1
+ import type { CMSUserData } from "../../editor";
2
+ import { fetchWithCache } from "../../libs";
3
+ import { CMS_BASE_URL, SITE_ID } from "../constants";
4
+
5
+ export const getPageContents = async (
6
+ pathname: string,
7
+ allPageData: Record<string, CMSUserData<any>>,
8
+ previewToken?: string,
9
+ ) => {
10
+ // remove trailing (left and right) slashes
11
+ pathname = pathname.replace(/^\/+|\/+$/g, "");
12
+
13
+ try {
14
+ const res = await fetchWithCache(
15
+ `${CMS_BASE_URL}/public/seo/${SITE_ID}/pages/${pathname}`,
16
+ {
17
+ headers: {
18
+ "Content-Type": "application/json",
19
+ },
20
+ },
21
+ );
22
+
23
+ if (res.ok) {
24
+ const json = (await res.json()) as { content?: string };
25
+
26
+ if (json.content) {
27
+ const content = JSON.parse(json.content) as CMSUserData<any>;
28
+
29
+ if (content.root && content.content) {
30
+ return content;
31
+ }
32
+ }
33
+ }
34
+
35
+ const actualData = allPageData[pathname as keyof typeof allPageData];
36
+
37
+ if (actualData) {
38
+ // Starting new page from existing published data
39
+ return actualData as CMSUserData<any>;
40
+ }
41
+
42
+ // Starting new blank page
43
+ } catch (error) {
44
+ console.error("Error fetching page data:", error);
45
+ }
46
+
47
+ if (previewToken) {
48
+ // New blank page in preview mode
49
+ return {
50
+ content: [],
51
+ root: { props: { title: "New Page" } },
52
+ zones: {},
53
+ };
54
+ }
55
+
56
+ return null;
57
+ };
@@ -1,56 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { CMSUserData } from "../types";
3
3
  import { Render, type Config } from "@puckeditor/core";
4
- import { CMS_BASE_URL, SITE_ID } from "../../api";
5
- import { fetchWithCache } from "../../libs";
6
-
7
- const getPageContents = async (
8
- pathname: string,
9
- allPageData: Record<string, CMSUserData<any>>,
10
- ) => {
11
- // remove trailing (left and right) slashes
12
- pathname = pathname.replace(/^\/+|\/+$/g, "");
13
-
14
- try {
15
- const res = await fetchWithCache(
16
- `${CMS_BASE_URL}/public/seo/${SITE_ID}/pages/${pathname}`,
17
- {
18
- headers: {
19
- "Content-Type": "application/json",
20
- },
21
- },
22
- );
23
-
24
- if (res.ok) {
25
- const json = (await res.json()) as { content?: string };
26
-
27
- if (json.content) {
28
- const content = JSON.parse(json.content) as CMSUserData<any>;
29
-
30
- if (content.root && content.content) {
31
- return content;
32
- }
33
- }
34
- }
35
-
36
- const actualData = allPageData[pathname as keyof typeof allPageData];
37
-
38
- if (actualData) {
39
- // Starting new page from existing published data
40
- return actualData as CMSUserData<any>;
41
- }
42
-
43
- // Starting new blank page
44
- } catch (error) {
45
- console.error("Error fetching page data:", error);
46
- }
47
-
48
- return {
49
- content: [],
50
- root: { props: { title: "New Page" } },
51
- zones: {},
52
- };
53
- };
4
+ import { getPageContents } from "../../api/client/pages";
54
5
 
55
6
  export type PageRendererContentProps = {
56
7
  pathname: string;
@@ -82,4 +33,4 @@ export const PageRendererContent = ({
82
33
  }
83
34
 
84
35
  return <Render config={config} data={data} />;
85
- };
36
+ };
@@ -0,0 +1,15 @@
1
+ import { Render, type Config } from "@puckeditor/core";
2
+ import type { CMSUserData } from "../types";
3
+
4
+ export type PageRendererSSRProps = {
5
+ config: Config;
6
+ data: CMSUserData<any>;
7
+ };
8
+
9
+ /**
10
+ * SSR-compatible PageRenderer that receives pre-fetched data from the loader.
11
+ * Use this component with TanStack Router's loader for server-side rendering.
12
+ */
13
+ export const PageRendererSSR = ({ config, data }: PageRendererSSRProps) => {
14
+ return <Render config={config} data={data} />;
15
+ };
@@ -126,7 +126,7 @@ export const BaseEditor =
126
126
  alert("Failed to upload changes to CMS");
127
127
  } else {
128
128
  alert(
129
- "Changes uploaded successfully to CMS! The page will now reflect the changes."
129
+ "Changes uploaded successfully to CMS! The page will now reflect the changes.",
130
130
  );
131
131
  }
132
132
  } catch (error) {
@@ -141,9 +141,6 @@ export const BaseEditor =
141
141
  iframe={{
142
142
  enabled: true,
143
143
  }}
144
- fieldTransforms={{
145
- userField: ({ value }) => value, // Included to check types
146
- }}
147
144
  overrides={{
148
145
  fieldTypes: {
149
146
  // Example of user field provided via overrides
@@ -1,9 +1,7 @@
1
- "use client";
2
-
3
- import { BaseEditor } from "../../editor/render/Preview";
4
- import { usePathnameTanstack, useSearchParamsTanstack } from "../hooks";
1
+ import { BaseEditor } from '../../editor/render/Preview'
2
+ import { usePathnameTanstack, useSearchParamsTanstack } from '../hooks'
5
3
 
6
4
  export const EditorTanstack = BaseEditor({
7
5
  usePathname: usePathnameTanstack,
8
6
  useSearchParams: useSearchParamsTanstack,
9
- });
7
+ })
@@ -9,6 +9,10 @@ export type PageRendererTanstackInitializerProps = {
9
9
  allPageData: Record<string, CMSUserData<any>>;
10
10
  };
11
11
 
12
+ /**
13
+ * Use PageRendererTanstackSSR for SSR support instead.
14
+ * This component fetches data on the client side.
15
+ */
12
16
  export const PageRendererTanstack =
13
17
  ({ config, allPageData }: PageRendererTanstackInitializerProps) =>
14
18
  () => {