rankrunners-cms 0.0.16 → 0.0.18
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,35 +1,509 @@
|
|
|
1
1
|
# RankRunners CMS Integration Guide
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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.
|
|
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
|
|
12
58
|
|
|
13
|
-
|
|
59
|
+
```bash
|
|
60
|
+
# Using bun
|
|
61
|
+
bun add rankrunners-cms @puckeditor/core
|
|
62
|
+
|
|
63
|
+
# Using npm
|
|
64
|
+
npm install rankrunners-cms @puckeditor/core
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Required Peer Dependencies
|
|
68
|
+
|
|
69
|
+
Ensure you have TanStack Start and Router installed:
|
|
14
70
|
|
|
15
71
|
```bash
|
|
16
|
-
bun add
|
|
72
|
+
bun add @tanstack/react-router @tanstack/react-start
|
|
17
73
|
```
|
|
18
74
|
|
|
75
|
+
### Environment Variables
|
|
76
|
+
|
|
77
|
+
Add the following to your `.env` file:
|
|
78
|
+
|
|
79
|
+
```env
|
|
80
|
+
VITE_PUBLIC_SITE_ID=your-site-id
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> **Important**: Also add `VITE_PUBLIC_SITE_ID` to your Dockerfile for production builds.
|
|
84
|
+
|
|
19
85
|
---
|
|
20
86
|
|
|
21
|
-
##
|
|
87
|
+
## 4. Project Structure
|
|
22
88
|
|
|
23
|
-
|
|
89
|
+
Create the following structure in your `src/` directory:
|
|
90
|
+
|
|
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
|
+
```
|
|
116
|
+
|
|
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:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
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'
|
|
131
|
+
|
|
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
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 5.2 Type Definitions (`src/editor/types.ts`)
|
|
155
|
+
|
|
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
|
+
```
|
|
172
|
+
|
|
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
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 6. Creating Component Blocks
|
|
218
|
+
|
|
219
|
+
### 6.1 Block Naming Convention
|
|
220
|
+
|
|
221
|
+
**Important**: All block names must end with `Block` suffix (e.g., `HeroBlock`, `TrustBarBlock`).
|
|
222
|
+
|
|
223
|
+
### 6.2 Block Structure Pattern
|
|
224
|
+
|
|
225
|
+
Each block wraps a React component with Puck configuration:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
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
|
|
250
|
+
},
|
|
251
|
+
render: (props) => <MyComponent {...props} />,
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 6.3 Available Field Types
|
|
256
|
+
|
|
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: {...} }` |
|
|
265
|
+
|
|
266
|
+
### 6.4 Select Field Example
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
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
|
+
```
|
|
279
|
+
|
|
280
|
+
### 6.5 Array Field Example
|
|
281
|
+
|
|
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' },
|
|
291
|
+
},
|
|
292
|
+
defaultItemProps: {
|
|
293
|
+
name: 'New Service',
|
|
294
|
+
description: '',
|
|
295
|
+
imageUrl: '/assets/placeholder.webp',
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### 6.6 The `withLayout` Wrapper
|
|
301
|
+
|
|
302
|
+
**Critical**: Always wrap your block config with `withLayout()` to enable grid/flex layout support:
|
|
303
|
+
|
|
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
|
+
```
|
|
321
|
+
|
|
322
|
+
### 6.7 Complete Block File Example
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
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
|
+
})
|
|
344
|
+
|
|
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
|
|
357
|
+
},
|
|
358
|
+
render: (props) => <ContactToday {...props} />,
|
|
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
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
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**
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
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
|
+
```
|
|
419
|
+
|
|
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
|
|
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`)
|
|
24
498
|
|
|
25
|
-
### Root Route Configuration
|
|
26
499
|
In your [src/routes/__root.tsx](src/routes/__root.tsx), use `headWithSEO` and `seoScripts` to initialize the global SEO state.
|
|
27
500
|
|
|
28
501
|
You can freely configure the head meta, links and scripts if you want.
|
|
29
502
|
|
|
30
503
|
```tsx
|
|
31
|
-
import {
|
|
32
|
-
import { seoScripts } from 'rankrunners-cms/src/tanstack
|
|
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'
|
|
33
507
|
|
|
34
508
|
export const Route = createRootRoute({
|
|
35
509
|
head: headWithSEO<any>(() => ({
|
|
@@ -44,120 +518,260 @@ export const Route = createRootRoute({
|
|
|
44
518
|
})),
|
|
45
519
|
scripts: seoScripts as any,
|
|
46
520
|
shellComponent: RootDocument,
|
|
47
|
-
})
|
|
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
|
+
}
|
|
48
536
|
```
|
|
49
537
|
|
|
50
|
-
###
|
|
51
|
-
For specific pages, use `seoHead` to allow the CMS to inject page-specific metadata.
|
|
538
|
+
### 8.2 Catch-All Page Route (`src/routes/$.ts`)
|
|
52
539
|
|
|
53
|
-
|
|
54
|
-
import { seoHead } from 'rankrunners-cms/src/tanstack/seo/head';
|
|
540
|
+
This single route handles ALL CMS pages:
|
|
55
541
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
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
|
+
})
|
|
60
555
|
```
|
|
61
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
|
+
|
|
62
564
|
---
|
|
63
565
|
|
|
64
|
-
##
|
|
566
|
+
## 9. Sitemap & Robots.txt
|
|
65
567
|
|
|
66
|
-
|
|
568
|
+
Update the `server.ts` script, or add a Tanstack Start middleware to handle `[sitemap].xml` and `robots.txt` requests:
|
|
67
569
|
|
|
68
|
-
|
|
69
|
-
|
|
570
|
+
```typescript
|
|
571
|
+
// Build static routes with intelligent preloading
|
|
572
|
+
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
|
|
70
573
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
);
|
|
574
|
+
// Add sitemap routes
|
|
575
|
+
routes['/*'] = (req: Request) => {
|
|
576
|
+
const url = new URL(req.url)
|
|
577
|
+
const pathname = url.pathname
|
|
578
|
+
|
|
579
|
+
// Serve the sitemap if we are asking for it
|
|
580
|
+
if (pathname === 'robots.txt' || pathname.endsWith('.xml')) {
|
|
581
|
+
const sitemapFile = pathname.substring(1)
|
|
582
|
+
return downloadSitemapAsResponse(sitemapFile)
|
|
93
583
|
}
|
|
94
584
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
585
|
+
// Otherwise, pass to TanStack Start handler
|
|
586
|
+
try {
|
|
587
|
+
return handler.fetch(req)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
log.error(`Server handler error: ${String(error)}`)
|
|
590
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
591
|
+
}
|
|
592
|
+
}
|
|
98
593
|
```
|
|
99
594
|
|
|
100
595
|
---
|
|
101
596
|
|
|
102
|
-
##
|
|
597
|
+
## 10. Best Practices
|
|
103
598
|
|
|
104
|
-
|
|
599
|
+
### Component Design
|
|
600
|
+
- ✅ Make ALL text configurable (no hardcoded strings)
|
|
601
|
+
- ✅ Keep default props in sync between component and block
|
|
602
|
+
- ✅ Export component props interface
|
|
603
|
+
- ✅ Use semantic HTML for accessibility
|
|
105
604
|
|
|
106
|
-
###
|
|
107
|
-
|
|
108
|
-
|
|
605
|
+
### Block Design
|
|
606
|
+
- ✅ Always use `withLayout()` wrapper
|
|
607
|
+
- ✅ End block names with `Block` suffix
|
|
608
|
+
- ✅ Provide meaningful `label` for editor sidebar
|
|
609
|
+
- ✅ Use `contentEditable: true` for inline-editable text fields
|
|
610
|
+
- ✅ Use `getItemSummary` for array fields to show previews
|
|
109
611
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
};
|
|
612
|
+
### Page Data
|
|
613
|
+
- ✅ Always include unique `id` in each block's props
|
|
614
|
+
- ✅ Match block `type` exactly to exported block name
|
|
114
615
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
type: 'select',
|
|
120
|
-
options: [
|
|
121
|
-
{ label: 'H1', value: 'h1' },
|
|
122
|
-
{ label: 'H2', value: 'h2' },
|
|
123
|
-
{ label: 'H3', value: 'h3' },
|
|
124
|
-
],
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
render: ({ text, level: Tag }) => <Tag>{text}</Tag>,
|
|
128
|
-
};
|
|
129
|
-
```
|
|
616
|
+
### Common Pitfalls
|
|
617
|
+
- ❌ Don't use `@measured/puck` - it's been renamed to `@puckeditor/core`
|
|
618
|
+
- ❌ Don't wrap only the render function with `withLayout()`
|
|
619
|
+
- ❌ Don't use `type: 'list'` for array fields (use `type: 'array'`)
|
|
130
620
|
|
|
131
621
|
---
|
|
132
622
|
|
|
133
|
-
##
|
|
134
|
-
|
|
135
|
-
Use this checklist to ensure a complete and correct integration:
|
|
623
|
+
## 11. Developer Checklist
|
|
136
624
|
|
|
137
|
-
###
|
|
138
|
-
- [ ] Install `rankrunners-cms` and `@puckeditor/core
|
|
139
|
-
- [ ]
|
|
140
|
-
- [ ] Add `
|
|
141
|
-
- [ ] Add `<Scripts />` after the body and `<HeadContent />` in the `<head>` to [src/routes/__root.tsx](src/routes/__root.tsx)
|
|
625
|
+
### Initial Setup
|
|
626
|
+
- [ ] Install `rankrunners-cms` and `@puckeditor/core`
|
|
627
|
+
- [ ] Set `VITE_PUBLIC_SITE_ID` environment variable
|
|
628
|
+
- [ ] Add `VITE_PUBLIC_SITE_ID` to Dockerfile
|
|
142
629
|
|
|
143
|
-
###
|
|
144
|
-
- [ ]
|
|
145
|
-
- [ ]
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
- [ ]
|
|
151
|
-
- [ ] Create `
|
|
630
|
+
### Root Route Configuration
|
|
631
|
+
- [ ] Add `headWithSEO` to `createRootRoute`
|
|
632
|
+
- [ ] Add `seoScripts` to `createRootRoute`
|
|
633
|
+
- [ ] Add `<HeadContent />` in `<head>`
|
|
634
|
+
- [ ] Add `<Scripts />` after body content
|
|
635
|
+
|
|
636
|
+
### Editor Structure
|
|
637
|
+
- [ ] Create `src/editor/index.ts` with config export
|
|
638
|
+
- [ ] Create `src/editor/root.tsx` with Header/Footer wrapper
|
|
639
|
+
- [ ] Create `src/editor/types.ts` with type definitions
|
|
640
|
+
|
|
641
|
+
### Block Implementation
|
|
642
|
+
- [ ] Create blocks in `src/editor/blocks/`
|
|
643
|
+
- [ ] Use `withLayout()` wrapper for each block
|
|
644
|
+
- [ ] Export block types (e.g., `PageBlockTypes`)
|
|
645
|
+
- [ ] Export blocks object (e.g., `PageBlocks`)
|
|
646
|
+
- [ ] Register blocks in `customCategories`
|
|
647
|
+
- [ ] Register blocks in `customComponents`
|
|
648
|
+
|
|
649
|
+
### Page Data
|
|
650
|
+
- [ ] Create page data files in `src/editor/initial-data/`
|
|
651
|
+
- [ ] Register paths in `initial.ts` `allPageData` object
|
|
652
|
+
|
|
653
|
+
### Routes
|
|
654
|
+
- [ ] Create catch-all route `src/routes/$.ts`
|
|
655
|
+
|
|
656
|
+
### Sitemaps
|
|
657
|
+
- [ ] Implement sitemap serving middleware in `server.ts`
|
|
152
658
|
|
|
153
659
|
### Component Implementation
|
|
154
660
|
- [ ] Migrate/Create components in `src/editor/blocks/`.
|
|
155
661
|
- [ ] Register all blocks in `src/editor/index.tsx`.
|
|
156
662
|
- [ ] Organize blocks into `categories` within the config.
|
|
157
663
|
|
|
158
|
-
###
|
|
159
|
-
- [ ]
|
|
160
|
-
|
|
161
|
-
|
|
664
|
+
### CMS configuration
|
|
665
|
+
- [ ] Added the new pages to the site in the [CMS user interface](https://cms.rankrunners.net) and saved at least once.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## 12. Troubleshooting
|
|
670
|
+
|
|
671
|
+
### "Element type is invalid: expected a string...but got: object"
|
|
672
|
+
|
|
673
|
+
**Cause**: Using `withLayout()` incorrectly.
|
|
674
|
+
|
|
675
|
+
**Fix**: Wrap the entire ComponentConfig object, not just the render function:
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
// ✅ Correct
|
|
679
|
+
export const MyBlock = withLayout({
|
|
680
|
+
label: '...',
|
|
681
|
+
fields: {...},
|
|
682
|
+
render: (props) => <MyComponent {...props} />,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
// ❌ Wrong
|
|
686
|
+
export const MyBlock = {
|
|
687
|
+
label: '...',
|
|
688
|
+
fields: {...},
|
|
689
|
+
render: withLayout((props) => <MyComponent {...props} />),
|
|
690
|
+
}
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### Page shows "Loading page..." forever
|
|
694
|
+
|
|
695
|
+
**Cause**: Path mismatch between URL and `allPageData` keys.
|
|
696
|
+
|
|
697
|
+
**Fix**: Ensure keys in `allPageData` match URL paths without leading slashes:
|
|
698
|
+
- URL `/contact` -> key `contact`
|
|
699
|
+
- URL `/services/plumbing` -> key `services/plumbing`
|
|
700
|
+
- URL `/` -> key `''` (empty string)
|
|
701
|
+
|
|
702
|
+
### Build error: Unexpected character
|
|
703
|
+
|
|
704
|
+
**Cause**: Using special characters in strings.
|
|
705
|
+
|
|
706
|
+
**Fix**: Replace curly quotes and em-dashes:
|
|
707
|
+
- Curly single quotes -> straight single quote `'`
|
|
708
|
+
- Curly double quotes -> straight double quote `"`
|
|
709
|
+
- Em-dash -> hyphen `-` or double hyphen `--`
|
|
710
|
+
|
|
711
|
+
### TypeScript error with field type
|
|
712
|
+
|
|
713
|
+
**Cause**: Using unsupported field type.
|
|
714
|
+
|
|
715
|
+
**Fix**: Use supported types: `text`, `textarea`, `number`, `select`, `array`, `object`
|
|
162
716
|
|
|
163
717
|
---
|
|
718
|
+
|
|
719
|
+
## Quick Start Template
|
|
720
|
+
|
|
721
|
+
Create a new CMS-enabled page in 3 steps:
|
|
722
|
+
|
|
723
|
+
**1. Create the component** (`src/components/MyPage.tsx`)
|
|
724
|
+
```tsx
|
|
725
|
+
export interface MyPageProps {
|
|
726
|
+
title?: string
|
|
727
|
+
content?: string
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export const MyPage: React.FC<MyPageProps> = ({
|
|
731
|
+
title = 'Default Title',
|
|
732
|
+
content = 'Default content'
|
|
733
|
+
}) => (
|
|
734
|
+
<div className="max-w-4xl mx-auto py-8 px-4">
|
|
735
|
+
<h1>{title}</h1>
|
|
736
|
+
<p>{content}</p>
|
|
737
|
+
</div>
|
|
738
|
+
)
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
**2. Create the block** (add to `src/editor/blocks/page-blocks.tsx`)
|
|
742
|
+
```tsx
|
|
743
|
+
export const MyPageBlock: ComponentConfig<MyPageProps> = withLayout({
|
|
744
|
+
label: 'My Page',
|
|
745
|
+
fields: {
|
|
746
|
+
title: { type: 'text', label: 'Title' },
|
|
747
|
+
content: { type: 'textarea', label: 'Content' },
|
|
748
|
+
},
|
|
749
|
+
defaultProps: {
|
|
750
|
+
title: 'Default Title',
|
|
751
|
+
content: 'Default content',
|
|
752
|
+
},
|
|
753
|
+
render: (props) => <MyPage {...props} />,
|
|
754
|
+
})
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
**3. Create the page data** (`src/editor/initial-data/mypage-page.ts`)
|
|
758
|
+
```typescript
|
|
759
|
+
export const mypagePageData: UserData = {
|
|
760
|
+
root: { title: 'My Page | Site Name' },
|
|
761
|
+
content: [
|
|
762
|
+
{
|
|
763
|
+
type: 'MyPageBlock',
|
|
764
|
+
props: { id: 'my-page', title: 'Welcome', content: 'Hello world!' },
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
zones: {},
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Then register in `initial.ts`:
|
|
772
|
+
```typescript
|
|
773
|
+
export const allPageData = {
|
|
774
|
+
// ...
|
|
775
|
+
'mypage': mypagePageData,
|
|
776
|
+
}
|
|
777
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rankrunners-cms",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.18",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@puckeditor/core": "^0.21.0",
|
|
7
7
|
"next": "^16.1.2",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"typescript": "^5.9.3",
|
|
35
35
|
"@tanstack/router-core": "^1.150.0",
|
|
36
36
|
"@tanstack/react-router": "^1.150.0",
|
|
37
|
-
"next": "^16.1.
|
|
37
|
+
"next": "^16.1.3",
|
|
38
38
|
"valibot": "^1.2.0",
|
|
39
39
|
"lucide-react": "^0.562.0",
|
|
40
40
|
"react": "^19.2.3"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import type { CMSUserData } from "../types";
|
|
3
3
|
import { Render, type Config } from "@puckeditor/core";
|
|
4
4
|
import { CMS_BASE_URL, SITE_ID } from "../../api";
|
|
@@ -6,7 +6,7 @@ import { fetchWithCache } from "../../libs";
|
|
|
6
6
|
|
|
7
7
|
const getPageContents = async (
|
|
8
8
|
pathname: string,
|
|
9
|
-
allPageData: Record<string, CMSUserData<any
|
|
9
|
+
allPageData: Record<string, CMSUserData<any>>,
|
|
10
10
|
) => {
|
|
11
11
|
// remove trailing (left and right) slashes
|
|
12
12
|
pathname = pathname.replace(/^\/+|\/+$/g, "");
|
|
@@ -18,13 +18,10 @@ const getPageContents = async (
|
|
|
18
18
|
headers: {
|
|
19
19
|
"Content-Type": "application/json",
|
|
20
20
|
},
|
|
21
|
-
}
|
|
21
|
+
},
|
|
22
22
|
);
|
|
23
23
|
|
|
24
24
|
if (res.ok) {
|
|
25
|
-
// console.error("Failed to fetch page data from CMS");
|
|
26
|
-
// throw new Error("Failed to fetch page data from CMS");
|
|
27
|
-
//}
|
|
28
25
|
const json = (await res.json()) as { content?: string };
|
|
29
26
|
|
|
30
27
|
if (json.content) {
|
|
@@ -68,13 +65,13 @@ export const PageRendererContent = ({
|
|
|
68
65
|
}: PageRendererContentProps) => {
|
|
69
66
|
const [data, setData] = useState<CMSUserData<any> | null>(null);
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
useEffect(() => {
|
|
72
69
|
const fetchData = async () => {
|
|
73
70
|
const pageData = await getPageContents(pathname, allPageData);
|
|
74
71
|
setData(pageData);
|
|
75
72
|
};
|
|
76
73
|
fetchData();
|
|
77
|
-
});
|
|
74
|
+
}, [pathname]);
|
|
78
75
|
|
|
79
76
|
if (!data) {
|
|
80
77
|
return (
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Config } from "@puckeditor/core";
|
|
2
|
+
import { PageRendererContent } from "../../editor";
|
|
3
|
+
import { EditorTanstack } from ".";
|
|
4
|
+
import { usePathnameTanstack, useSearchParamsTanstack } from "../hooks";
|
|
5
|
+
import type { CMSUserData } from "../../editor";
|
|
6
|
+
|
|
7
|
+
export type PageRendererTanstackInitializerProps = {
|
|
8
|
+
config: Config;
|
|
9
|
+
allPageData: Record<string, CMSUserData<any>>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const PageRendererTanstack =
|
|
13
|
+
({ config, allPageData }: PageRendererTanstackInitializerProps) =>
|
|
14
|
+
() => {
|
|
15
|
+
const pathname = usePathnameTanstack();
|
|
16
|
+
const searchParams = useSearchParamsTanstack();
|
|
17
|
+
const previewToken = searchParams.get("preview");
|
|
18
|
+
|
|
19
|
+
if (previewToken) {
|
|
20
|
+
return <EditorTanstack config={config} allPageData={allPageData} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<PageRendererContent
|
|
25
|
+
config={config}
|
|
26
|
+
allPageData={allPageData}
|
|
27
|
+
pathname={pathname}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { downloadSitemap } from "../../api/client/sitemap";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Handler for sitemap routes in TanStack Router
|
|
5
|
-
* Use this in your route file with a loader
|
|
6
|
-
*/
|
|
7
|
-
export const createSitemapRoute = async (sitemap: string) => {
|
|
8
|
-
const sitemapData = await downloadSitemap(`${sitemap}.xml`);
|
|
9
|
-
|
|
10
|
-
return new Response(sitemapData, {
|
|
11
|
-
status: 200,
|
|
12
|
-
headers: {
|
|
13
|
-
"Content-Type": "application/xml",
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Handler for robots.txt route in TanStack Router
|
|
20
|
-
* Use this in your route file with a loader
|
|
21
|
-
*/
|
|
22
|
-
export const createRobotsTxtRoute = async () => {
|
|
23
|
-
const robotsTxt = await downloadSitemap("robots.txt");
|
|
24
|
-
|
|
25
|
-
return new Response(robotsTxt, {
|
|
26
|
-
status: 200,
|
|
27
|
-
headers: {
|
|
28
|
-
"Content-Type": "text/plain",
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
};
|