@estation/create-cms-site 1.0.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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/list-sections.d.ts +19 -0
- package/dist/tools/list-sections.d.ts.map +1 -0
- package/dist/tools/list-sections.js +152 -0
- package/dist/tools/list-sections.js.map +1 -0
- package/dist/tools/scaffold-project.d.ts +11 -0
- package/dist/tools/scaffold-project.d.ts.map +1 -0
- package/dist/tools/scaffold-project.js +90 -0
- package/dist/tools/scaffold-project.js.map +1 -0
- package/dist/tools/validate-config.d.ts +9 -0
- package/dist/tools/validate-config.d.ts.map +1 -0
- package/dist/tools/validate-config.js +72 -0
- package/dist/tools/validate-config.js.map +1 -0
- package/dist/utils/helpers.d.ts +2 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +5 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +36 -0
- package/template/.env.example +9 -0
- package/template/LIVE-PREVIEW.md +267 -0
- package/template/README.md +210 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +24 -0
- package/template/postcss.config.mjs +8 -0
- package/template/src/app/[slug]/page.tsx +38 -0
- package/template/src/app/api/revalidate/route.ts +34 -0
- package/template/src/app/blog/[slug]/page.tsx +60 -0
- package/template/src/app/blog/page.tsx +68 -0
- package/template/src/app/collections/[uuid]/page.tsx +28 -0
- package/template/src/app/events/[slug]/page.tsx +62 -0
- package/template/src/app/events/page.tsx +70 -0
- package/template/src/app/layout.tsx +27 -0
- package/template/src/app/news/[slug]/page.tsx +66 -0
- package/template/src/app/news/page.tsx +68 -0
- package/template/src/app/page.tsx +40 -0
- package/template/src/app/search/page.tsx +136 -0
- package/template/src/app/sitemap.ts +48 -0
- package/template/src/components/Footer.tsx +47 -0
- package/template/src/components/Navigation.tsx +65 -0
- package/template/src/components/SectionRenderer.tsx +43 -0
- package/template/src/components/cms-preview-listener.tsx +114 -0
- package/template/src/components/sections/CTASection.tsx +32 -0
- package/template/src/components/sections/ContactSection.tsx +74 -0
- package/template/src/components/sections/FAQSection.tsx +48 -0
- package/template/src/components/sections/FeaturesSection.tsx +42 -0
- package/template/src/components/sections/GallerySection.tsx +44 -0
- package/template/src/components/sections/GenericSection.tsx +63 -0
- package/template/src/components/sections/HeroSection.tsx +27 -0
- package/template/src/components/sections/SliderSection.tsx +66 -0
- package/template/src/components/sections/TestimonialsSection.tsx +52 -0
- package/template/src/components/sections/TextSection.tsx +31 -0
- package/template/src/lib/cms-api.ts +103 -0
- package/template/src/lib/content-helpers.ts +18 -0
- package/template/src/lib/types.ts +109 -0
- package/template/src/styles/globals.css +1 -0
- package/template/tsconfig.json +23 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estation/create-cms-site",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server to scaffold Next.js sites powered by eSTATION CMS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-cms-site": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"template"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"inspect": "npm run build && npx @modelcontextprotocol/inspector node dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"cms",
|
|
22
|
+
"nextjs",
|
|
23
|
+
"scaffold",
|
|
24
|
+
"ctrla",
|
|
25
|
+
"estation"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
30
|
+
"zod": "^3.23.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# CMS API
|
|
2
|
+
CMS_API_URL=https://cms-gateway.estation.io/api/v1
|
|
3
|
+
CMS_API_TOKEN=your-tenant-api-token
|
|
4
|
+
|
|
5
|
+
# On-demand ISR revalidation
|
|
6
|
+
REVALIDATE_SECRET=your-revalidation-secret
|
|
7
|
+
|
|
8
|
+
# Public site URL (used for sitemap generation)
|
|
9
|
+
NEXT_PUBLIC_SITE_URL=https://your-site.com
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# CMS Live Preview — Developer Guide
|
|
2
|
+
|
|
3
|
+
Real-time preview lets content editors see field changes instantly in the website template while editing in the CMS admin. This guide explains how the system works and how to implement it in your own components.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
CMS Admin Website Template (iframe)
|
|
9
|
+
+--------------------------+ +----------------------------+
|
|
10
|
+
| AdminPageCompositionEditor| | CMSPreviewListener |
|
|
11
|
+
| | | |
|
|
12
|
+
| User types in a field | postMessage | Listens for "message" |
|
|
13
|
+
| ───────────────────────> | ─────────────> | events on window |
|
|
14
|
+
| | | |
|
|
15
|
+
| Resolves: | | Finds element by: |
|
|
16
|
+
| - blockTag (first tag) | | [data-cms-block="{tag}"] |
|
|
17
|
+
| - fieldName | | └─ [data-cms-field="…"] |
|
|
18
|
+
| - fieldType | | |
|
|
19
|
+
| - value (string) | | Updates DOM directly: |
|
|
20
|
+
| | | text → .textContent |
|
|
21
|
+
| | | richtext → .innerHTML |
|
|
22
|
+
| | | image → .src |
|
|
23
|
+
| | | list → <li> elements |
|
|
24
|
+
+--------------------------+ +----------------------------+
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The admin embeds your website in an `<iframe>`. When a content editor changes any field, the admin sends a `postMessage` to the iframe. The `CMSPreviewListener` component (already installed in the root layout) receives the message, finds the matching DOM element via data attributes, and updates it in place — no page reload required.
|
|
28
|
+
|
|
29
|
+
## Message Protocol
|
|
30
|
+
|
|
31
|
+
### `cms-preview-update`
|
|
32
|
+
|
|
33
|
+
Sent when a field value changes.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
interface PreviewUpdateMessage {
|
|
37
|
+
type: "cms-preview-update";
|
|
38
|
+
blockTag: string; // First tag of the content block (e.g., "hero", "text", "faq")
|
|
39
|
+
fieldName: string; // Field key (e.g., "title", "description", "body")
|
|
40
|
+
fieldType: string; // "text" | "richtext" | "image" | "list"
|
|
41
|
+
value: string; // New value (string or JSON-stringified for lists)
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `cms-preview-highlight`
|
|
46
|
+
|
|
47
|
+
Sent when the editor hovers over or focuses a field in the admin UI.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
interface PreviewHighlightMessage {
|
|
51
|
+
type: "cms-preview-highlight";
|
|
52
|
+
blockTag: string; // First tag of the content block
|
|
53
|
+
fieldName: string; // Field key
|
|
54
|
+
active: boolean; // true = show highlight, false = remove highlight
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
When active, the target element gets a blue outline and is scrolled into view.
|
|
59
|
+
|
|
60
|
+
## Data Attributes
|
|
61
|
+
|
|
62
|
+
The preview system relies on two HTML data attributes to locate elements:
|
|
63
|
+
|
|
64
|
+
| Attribute | Purpose | Applied to |
|
|
65
|
+
|-----------|---------|------------|
|
|
66
|
+
| `data-cms-block="{tag}"` | Identifies the content block | The wrapping `<section>` element |
|
|
67
|
+
| `data-cms-field="{fieldName}"` | Identifies the field within the block | The element rendering the field value |
|
|
68
|
+
|
|
69
|
+
The listener finds elements using this selector chain:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
document.querySelector(`[data-cms-block="${blockTag}"]`)
|
|
73
|
+
.querySelector(`[data-cms-field="${fieldName}"]`)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `data-cms-block`
|
|
77
|
+
|
|
78
|
+
Applied automatically by `SectionRenderer.tsx` — you don't need to add this yourself:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// SectionRenderer.tsx — already handled
|
|
82
|
+
<section key={block.uuid} data-cms-block={tag}>
|
|
83
|
+
<Component block={block} />
|
|
84
|
+
</section>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The `tag` value is `block.tags[0]` (the first tag on the content block).
|
|
88
|
+
|
|
89
|
+
### `data-cms-field`
|
|
90
|
+
|
|
91
|
+
You must add this to every element in your component whose content comes from a CMS field. The attribute value must match the field key exactly.
|
|
92
|
+
|
|
93
|
+
## How to Build a Live-Preview-Ready Component
|
|
94
|
+
|
|
95
|
+
### Step 1: Create the component
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// src/components/sections/MySection.tsx
|
|
99
|
+
import type { SectionProps } from "@/lib/types";
|
|
100
|
+
import { str } from "@/lib/types";
|
|
101
|
+
|
|
102
|
+
export function MySection({ block }: SectionProps) {
|
|
103
|
+
const c = block.content;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="py-16 px-6 max-w-3xl mx-auto">
|
|
107
|
+
{/* data-cms-field must match the field key in the CMS */}
|
|
108
|
+
<h2 data-cms-field="title" className="text-3xl font-bold mb-4">
|
|
109
|
+
{str(c.title, "Default Title")}
|
|
110
|
+
</h2>
|
|
111
|
+
|
|
112
|
+
<p data-cms-field="subtitle" className="text-gray-600 mb-6">
|
|
113
|
+
{str(c.subtitle)}
|
|
114
|
+
</p>
|
|
115
|
+
|
|
116
|
+
{/* Richtext: use dangerouslySetInnerHTML */}
|
|
117
|
+
<div
|
|
118
|
+
data-cms-field="body"
|
|
119
|
+
className="prose prose-gray max-w-none"
|
|
120
|
+
dangerouslySetInnerHTML={{ __html: str(c.body) }}
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
{/* Image: must be on an <img> element */}
|
|
124
|
+
<img
|
|
125
|
+
data-cms-field="heroImage"
|
|
126
|
+
src={str(c.heroImage)}
|
|
127
|
+
alt={str(c.title)}
|
|
128
|
+
className="w-full rounded-lg mt-8"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Step 2: Register in SectionRenderer
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
// src/components/SectionRenderer.tsx
|
|
139
|
+
import { MySection } from "./sections/MySection";
|
|
140
|
+
|
|
141
|
+
const SECTION_MAP: Record<string, React.FC<SectionProps>> = {
|
|
142
|
+
// ... existing entries
|
|
143
|
+
"my-section": MySection,
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The key in `SECTION_MAP` must match the first tag you'll assign to this block type in the CMS.
|
|
148
|
+
|
|
149
|
+
### Step 3: Create content in the CMS
|
|
150
|
+
|
|
151
|
+
1. Create a content block with tag `my-section`
|
|
152
|
+
2. Add fields: `title` (text), `subtitle` (text), `body` (richtext), `heroImage` (image)
|
|
153
|
+
3. Add the block to a page composition
|
|
154
|
+
4. Open the page editor — the preview iframe shows your component with live updates
|
|
155
|
+
|
|
156
|
+
## Field Type Handling
|
|
157
|
+
|
|
158
|
+
The preview listener updates DOM elements differently based on `fieldType`:
|
|
159
|
+
|
|
160
|
+
| `fieldType` | Update method | Element requirement |
|
|
161
|
+
|-------------|--------------|---------------------|
|
|
162
|
+
| `text` | `el.textContent = value` | Any element (`<h1>`, `<p>`, `<span>`, etc.) |
|
|
163
|
+
| `richtext` | `el.innerHTML = value` | Any element (typically a `<div>`) |
|
|
164
|
+
| `image` | `el.src = value` | Must be an `<img>` element |
|
|
165
|
+
| `list` | Parses JSON, renders `<li>` elements | Typically a `<ul>` or `<div>` |
|
|
166
|
+
| *(default)* | `el.textContent = value` | Any element |
|
|
167
|
+
|
|
168
|
+
### Text fields
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
<h2 data-cms-field="title">{str(c.title)}</h2>
|
|
172
|
+
<p data-cms-field="description">{str(c.description)}</p>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Richtext fields
|
|
176
|
+
|
|
177
|
+
Use `dangerouslySetInnerHTML` so the preview listener can update via `innerHTML`:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<div
|
|
181
|
+
data-cms-field="body"
|
|
182
|
+
className="prose max-w-none"
|
|
183
|
+
dangerouslySetInnerHTML={{ __html: str(c.body) }}
|
|
184
|
+
/>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Image fields
|
|
188
|
+
|
|
189
|
+
Must use an `<img>` tag — the listener checks `el instanceof HTMLImageElement` before setting `.src`:
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
<img data-cms-field="featuredImage" src={str(c.featuredImage)} alt="..." />
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### List fields
|
|
196
|
+
|
|
197
|
+
The listener receives a JSON string, parses it, and renders `<li>` elements. For basic lists, wrap in a `<ul>`:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
<ul data-cms-field="items" className="list-disc pl-6">
|
|
201
|
+
{items.map((item, i) => (
|
|
202
|
+
<li key={i}>{item.title}</li>
|
|
203
|
+
))}
|
|
204
|
+
</ul>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
> **Note:** List preview updates replace the entire inner HTML with simple `<li>` elements. For complex list rendering (cards, grids), the live preview will show a simplified version — the full rendering appears after save + page reload.
|
|
208
|
+
|
|
209
|
+
## Existing Components Reference
|
|
210
|
+
|
|
211
|
+
These built-in components are already wired for live preview:
|
|
212
|
+
|
|
213
|
+
| Component | Tag | Fields with `data-cms-field` |
|
|
214
|
+
|-----------|-----|------------------------------|
|
|
215
|
+
| HeroSection | `hero` | `header`, `description`, `buttonText` |
|
|
216
|
+
| TextSection | `text` | `title`, `subtitle`, `body` |
|
|
217
|
+
| FeaturesSection | `features` | `title`, `description`, `items` |
|
|
218
|
+
| FAQSection | `faq` | `title`, `items` |
|
|
219
|
+
| TestimonialsSection | `testimonials` | `title`, `items` |
|
|
220
|
+
| CTASection | `cta` | `title`, `description`, `buttonText` |
|
|
221
|
+
| SliderSection | `slider` | `items` |
|
|
222
|
+
| GallerySection | `gallery` | `title`, `items` |
|
|
223
|
+
| ContactSection | `contact` | `title`, `description`, `email`, `phone`, `address` |
|
|
224
|
+
|
|
225
|
+
## Highlight Behavior
|
|
226
|
+
|
|
227
|
+
When the content editor hovers over a field in the admin panel, the preview listener:
|
|
228
|
+
|
|
229
|
+
1. Injects a CSS style (once) for `.cms-preview-highlight` — a 2px blue outline with 2px offset
|
|
230
|
+
2. Finds the matching element via `data-cms-block` + `data-cms-field`
|
|
231
|
+
3. Adds the `cms-preview-highlight` class and scrolls the element into view
|
|
232
|
+
4. Removes the class when the editor moves away
|
|
233
|
+
|
|
234
|
+
No extra markup is needed in your components — this works automatically if you add `data-cms-field` attributes.
|
|
235
|
+
|
|
236
|
+
## Checklist
|
|
237
|
+
|
|
238
|
+
When building a new section component, verify:
|
|
239
|
+
|
|
240
|
+
- [ ] Every visible CMS field has `data-cms-field="{fieldKey}"` on the element that displays it
|
|
241
|
+
- [ ] The `fieldKey` in the attribute exactly matches the field name in the CMS content block
|
|
242
|
+
- [ ] Richtext fields use `dangerouslySetInnerHTML`
|
|
243
|
+
- [ ] Image fields are on `<img>` elements
|
|
244
|
+
- [ ] The component is registered in `SectionRenderer.tsx` with the block tag as key
|
|
245
|
+
- [ ] The block tag in the CMS matches the key in `SECTION_MAP`
|
|
246
|
+
|
|
247
|
+
## Debugging Preview
|
|
248
|
+
|
|
249
|
+
If live preview isn't working:
|
|
250
|
+
|
|
251
|
+
1. **Check the iframe loads** — open your site URL directly, it should render
|
|
252
|
+
2. **Inspect data attributes** — in DevTools, verify `data-cms-block` and `data-cms-field` exist on the right elements
|
|
253
|
+
3. **Monitor messages** — add to your browser console in the iframe:
|
|
254
|
+
```js
|
|
255
|
+
window.addEventListener("message", (e) => console.log("CMS message:", e.data));
|
|
256
|
+
```
|
|
257
|
+
4. **Verify the tag** — the `blockTag` in the message must match the `data-cms-block` value. The admin uses `block.tags[0]` as the tag
|
|
258
|
+
5. **Check the field name** — the `fieldName` must exactly match the `data-cms-field` value
|
|
259
|
+
|
|
260
|
+
## Architecture Notes
|
|
261
|
+
|
|
262
|
+
- **No backend required** — preview is purely client-side via `window.postMessage`
|
|
263
|
+
- **No WebSocket/SSE** — the iframe and parent communicate directly
|
|
264
|
+
- **Changes are not persisted** — preview updates are DOM-only. The actual save happens when the editor clicks Save in the admin
|
|
265
|
+
- **`CMSPreviewListener` is already in the root layout** — you don't need to add it to individual pages
|
|
266
|
+
- **Server components work fine** — the preview listener is a client component that runs alongside server-rendered content. It patches the DOM after hydration
|
|
267
|
+
- **Sandbox restrictions** — the iframe uses `sandbox="allow-same-origin allow-scripts"` which is sufficient for postMessage communication
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# CMS Website Template
|
|
2
|
+
|
|
3
|
+
A full-featured Next.js 15 website template that renders content from the eSTATION CMS. Includes SSR + ISR, real-time live preview, blog, news, events, search, collections, dynamic navigation/footer, SEO metadata, and sitemap generation.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cp .env.example .env.local
|
|
9
|
+
# Edit .env.local with your CMS credentials
|
|
10
|
+
|
|
11
|
+
npm install
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Open [http://localhost:3000](http://localhost:3000).
|
|
16
|
+
|
|
17
|
+
## Environment Variables
|
|
18
|
+
|
|
19
|
+
| Variable | Description |
|
|
20
|
+
|----------|-------------|
|
|
21
|
+
| `CMS_API_URL` | CMS API base URL (e.g., `https://cms-gateway.estation.io/api/v1`) |
|
|
22
|
+
| `CMS_API_TOKEN` | Your tenant's API token |
|
|
23
|
+
| `REVALIDATE_SECRET` | Secret for on-demand ISR revalidation webhook |
|
|
24
|
+
| `NEXT_PUBLIC_SITE_URL` | Public site URL for sitemap generation (e.g., `https://your-site.com`) |
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
src/
|
|
30
|
+
├── app/
|
|
31
|
+
│ ├── page.tsx # Homepage (slug: "index")
|
|
32
|
+
│ ├── [slug]/page.tsx # Dynamic CMS pages
|
|
33
|
+
│ ├── blog/
|
|
34
|
+
│ │ ├── page.tsx # Blog listing
|
|
35
|
+
│ │ └── [slug]/page.tsx # Blog detail
|
|
36
|
+
│ ├── news/
|
|
37
|
+
│ │ ├── page.tsx # News listing
|
|
38
|
+
│ │ └── [slug]/page.tsx # News detail
|
|
39
|
+
│ ├── events/
|
|
40
|
+
│ │ ├── page.tsx # Events listing
|
|
41
|
+
│ │ └── [slug]/page.tsx # Event detail
|
|
42
|
+
│ ├── search/page.tsx # Search page
|
|
43
|
+
│ ├── collections/[uuid]/page.tsx # Collection results
|
|
44
|
+
│ ├── sitemap.ts # Dynamic sitemap
|
|
45
|
+
│ ├── layout.tsx # Root layout (nav + footer)
|
|
46
|
+
│ └── api/revalidate/route.ts # ISR revalidation endpoint
|
|
47
|
+
├── components/
|
|
48
|
+
│ ├── Navigation.tsx # Dynamic navigation
|
|
49
|
+
│ ├── Footer.tsx # Dynamic footer
|
|
50
|
+
│ ├── SectionRenderer.tsx # Block → component mapper
|
|
51
|
+
│ ├── cms-preview-listener.tsx # Live preview handler
|
|
52
|
+
│ └── sections/ # Section components
|
|
53
|
+
│ ├── HeroSection.tsx
|
|
54
|
+
│ ├── TextSection.tsx
|
|
55
|
+
│ ├── FeaturesSection.tsx
|
|
56
|
+
│ ├── FAQSection.tsx
|
|
57
|
+
│ ├── TestimonialsSection.tsx
|
|
58
|
+
│ ├── CTASection.tsx
|
|
59
|
+
│ ├── SliderSection.tsx
|
|
60
|
+
│ ├── GallerySection.tsx
|
|
61
|
+
│ ├── ContactSection.tsx
|
|
62
|
+
│ └── GenericSection.tsx
|
|
63
|
+
└── lib/
|
|
64
|
+
├── types.ts # TypeScript interfaces
|
|
65
|
+
├── cms-api.ts # CMS API client
|
|
66
|
+
└── content-helpers.ts # Utility functions
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Section Components
|
|
70
|
+
|
|
71
|
+
| Component | Block Tag(s) | Fields |
|
|
72
|
+
|-----------|-------------|--------|
|
|
73
|
+
| HeroSection | `hero` | `header`, `description`, `buttonText`, `buttonLink` |
|
|
74
|
+
| TextSection | `text` | `title`, `subtitle`, `body` (richtext) |
|
|
75
|
+
| FeaturesSection | `features`, `feature` | `title`, `description`, `items` (list) |
|
|
76
|
+
| FAQSection | `faq` | `title`, `items` (list: `question`, `answer`) |
|
|
77
|
+
| TestimonialsSection | `testimonials`, `testimonial` | `title`, `items` (list: `name`, `quote`, `role`) |
|
|
78
|
+
| CTASection | `cta` | `title`, `description`, `buttonText`, `buttonLink` |
|
|
79
|
+
| SliderSection | `slider`, `sliders` | `items` (list: `title`, `subtitle`, `image`, `linkUrl`, `linkText`) |
|
|
80
|
+
| GallerySection | `gallery` | `title`, `items` (list: `image`, `caption`) |
|
|
81
|
+
| ContactSection | `contact`, `form` | `title`, `description`, `email`, `phone`, `address` |
|
|
82
|
+
| GenericSection | (fallback) | Renders all fields dynamically |
|
|
83
|
+
|
|
84
|
+
## Content Types
|
|
85
|
+
|
|
86
|
+
### Blog Posts
|
|
87
|
+
|
|
88
|
+
- **CMS tag:** `blogs`
|
|
89
|
+
- **Fields:** `slug`, `title`, `author`, `publishDate`, `featuredImage`, `excerpt`, `content` (richtext)
|
|
90
|
+
- **Routes:** `/blog` (listing), `/blog/[slug]` (detail)
|
|
91
|
+
|
|
92
|
+
### News Articles
|
|
93
|
+
|
|
94
|
+
- **CMS tag:** `news`
|
|
95
|
+
- **Fields:** `slug`, `title`, `author`, `category`, `publishDate`, `featuredImage`, `excerpt`, `content` (richtext)
|
|
96
|
+
- **Routes:** `/news` (listing), `/news/[slug]` (detail)
|
|
97
|
+
|
|
98
|
+
### Events
|
|
99
|
+
|
|
100
|
+
- **CMS tag:** `events`
|
|
101
|
+
- **Fields:** `slug`, `title`, `description`, `location`, `startDate`, `endDate`, `organizer`, `content` (richtext)
|
|
102
|
+
- **Routes:** `/events` (listing), `/events/[slug]` (detail)
|
|
103
|
+
|
|
104
|
+
## Routing
|
|
105
|
+
|
|
106
|
+
| Route | Source |
|
|
107
|
+
|-------|--------|
|
|
108
|
+
| `/` | Homepage — CMS page with slug `index` |
|
|
109
|
+
| `/[slug]` | Dynamic CMS pages |
|
|
110
|
+
| `/blog` | Blog listing |
|
|
111
|
+
| `/blog/[slug]` | Blog post detail |
|
|
112
|
+
| `/news` | News listing |
|
|
113
|
+
| `/news/[slug]` | News article detail |
|
|
114
|
+
| `/events` | Events listing |
|
|
115
|
+
| `/events/[slug]` | Event detail |
|
|
116
|
+
| `/search?q=...` | Search results |
|
|
117
|
+
| `/collections/[uuid]` | Collection execution results |
|
|
118
|
+
| `/sitemap.xml` | Auto-generated sitemap |
|
|
119
|
+
|
|
120
|
+
## Navigation
|
|
121
|
+
|
|
122
|
+
The `Navigation` component fetches blocks tagged `navigation` from the CMS. Expected field structure:
|
|
123
|
+
|
|
124
|
+
- `items` (list): each item has `label` and `href`
|
|
125
|
+
|
|
126
|
+
If no `navigation` block exists, it auto-generates links from all published CMS pages.
|
|
127
|
+
|
|
128
|
+
## Footer
|
|
129
|
+
|
|
130
|
+
The `Footer` component fetches blocks tagged `footer`. Expected field structure:
|
|
131
|
+
|
|
132
|
+
- `copyright` (text)
|
|
133
|
+
- `links` (list): each item has `label` and `href`
|
|
134
|
+
|
|
135
|
+
Falls back to a default copyright notice if no footer block exists.
|
|
136
|
+
|
|
137
|
+
## SEO
|
|
138
|
+
|
|
139
|
+
Every page generates `<title>` and `<meta name="description">` from CMS content:
|
|
140
|
+
|
|
141
|
+
- Homepage and dynamic pages use the page composition's `title` and `description`
|
|
142
|
+
- Blog, news, and event detail pages use the block's `title` and `excerpt`/`description`
|
|
143
|
+
|
|
144
|
+
## Sitemap
|
|
145
|
+
|
|
146
|
+
`/sitemap.xml` is dynamically generated from:
|
|
147
|
+
|
|
148
|
+
- All published CMS pages
|
|
149
|
+
- All blog posts, news articles, and events with slugs
|
|
150
|
+
|
|
151
|
+
Set `NEXT_PUBLIC_SITE_URL` to your production URL for correct sitemap URLs.
|
|
152
|
+
|
|
153
|
+
## Search
|
|
154
|
+
|
|
155
|
+
`/search` provides server-side search. Query parameters:
|
|
156
|
+
|
|
157
|
+
- `q` — search query (required)
|
|
158
|
+
- `type` — filter by `block` or `page` (optional)
|
|
159
|
+
- `page` — pagination (optional)
|
|
160
|
+
|
|
161
|
+
Results link to the appropriate content type page.
|
|
162
|
+
|
|
163
|
+
## Collections
|
|
164
|
+
|
|
165
|
+
`/collections/[uuid]` executes a CMS collection query and renders the resulting blocks through `SectionRenderer`.
|
|
166
|
+
|
|
167
|
+
## On-Demand Revalidation
|
|
168
|
+
|
|
169
|
+
`POST /api/revalidate` with JSON body:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{ "secret": "your-secret", "slug": "about" }
|
|
173
|
+
{ "secret": "your-secret", "tag": "tag-hero" }
|
|
174
|
+
{ "secret": "your-secret", "type": "blogs" }
|
|
175
|
+
{ "secret": "your-secret" }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `slug` — revalidate a specific page
|
|
179
|
+
- `tag` — revalidate a specific cache tag
|
|
180
|
+
- `type` — revalidate a content type (`blogs`, `news`, `events`)
|
|
181
|
+
- No target — revalidates the entire site
|
|
182
|
+
|
|
183
|
+
## CMS Preview Protocol
|
|
184
|
+
|
|
185
|
+
When embedded in the CMS admin iframe, the template supports real-time preview via `postMessage`:
|
|
186
|
+
|
|
187
|
+
- **`cms-preview-update`** — updates field content in-place (text, richtext, image, list)
|
|
188
|
+
- **`cms-preview-highlight`** — highlights the targeted field with a blue outline
|
|
189
|
+
|
|
190
|
+
Elements are targeted via `data-cms-block="{tag}"` and `data-cms-field="{fieldName}"` attributes.
|
|
191
|
+
|
|
192
|
+
## Customization
|
|
193
|
+
|
|
194
|
+
1. Add new section components in `src/components/sections/`
|
|
195
|
+
2. Register them in `SectionRenderer.tsx`'s `SECTION_MAP` with the block tag as key
|
|
196
|
+
3. Each component receives `block: ContentBlock` with `block.content` containing field data
|
|
197
|
+
4. Use `str(block.content.fieldName)` to safely extract string field values
|
|
198
|
+
|
|
199
|
+
## API Reference (`src/lib/cms-api.ts`)
|
|
200
|
+
|
|
201
|
+
| Function | Description |
|
|
202
|
+
|----------|-------------|
|
|
203
|
+
| `getPageBySlug(slug)` | Get page composition with resolved blocks |
|
|
204
|
+
| `getBlocksByTags(tags)` | Get all blocks matching any of the given tags |
|
|
205
|
+
| `getBlocksByTagPaginated(tag, page, size)` | Paginated blocks by tag |
|
|
206
|
+
| `getBlockByUUID(uuid)` | Get a single block by UUID |
|
|
207
|
+
| `getAllPages()` | Get all published page compositions |
|
|
208
|
+
| `executeCollection(uuid)` | Execute a collection query and get results |
|
|
209
|
+
| `getCollections()` | List all collections |
|
|
210
|
+
| `searchContent(query, type, page, size)` | Search across content |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cms-website-template",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --turbopack",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"next": "^15.3.2",
|
|
13
|
+
"react": "^19.0.0",
|
|
14
|
+
"react-dom": "^19.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@tailwindcss/postcss": "^4.1.0",
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/react": "^19.0.0",
|
|
20
|
+
"@types/react-dom": "^19.0.0",
|
|
21
|
+
"tailwindcss": "^4.1.0",
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getPageBySlug } from "@/lib/cms-api";
|
|
2
|
+
import { SectionRenderer } from "@/components/SectionRenderer";
|
|
3
|
+
import type { ContentBlock } from "@/lib/types";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
|
|
6
|
+
interface PageProps {
|
|
7
|
+
params: Promise<{ slug: string }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
11
|
+
const { slug } = await params;
|
|
12
|
+
try {
|
|
13
|
+
const data = await getPageBySlug(slug);
|
|
14
|
+
return {
|
|
15
|
+
title: data.page.title || slug,
|
|
16
|
+
description: data.page.description || undefined,
|
|
17
|
+
};
|
|
18
|
+
} catch {
|
|
19
|
+
return { title: slug };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function DynamicPage({ params }: PageProps) {
|
|
24
|
+
const { slug } = await params;
|
|
25
|
+
const data = await getPageBySlug(slug);
|
|
26
|
+
const orderedBlocks = getOrderedBlocks(data.page.blocks, data.blocks);
|
|
27
|
+
|
|
28
|
+
return <SectionRenderer blocks={orderedBlocks} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getOrderedBlocks(
|
|
32
|
+
blockUuids: string[],
|
|
33
|
+
blocksMap: Record<string, ContentBlock>
|
|
34
|
+
): ContentBlock[] {
|
|
35
|
+
return blockUuids
|
|
36
|
+
.map((uuid) => blocksMap[uuid])
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { revalidateTag, revalidatePath } from "next/cache";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: NextRequest) {
|
|
5
|
+
const body = await request.json().catch(() => null);
|
|
6
|
+
if (!body) {
|
|
7
|
+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { secret, slug, tag, type } = body;
|
|
11
|
+
|
|
12
|
+
if (secret !== process.env.REVALIDATE_SECRET) {
|
|
13
|
+
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (tag) {
|
|
17
|
+
revalidateTag(tag);
|
|
18
|
+
return NextResponse.json({ revalidated: true, tag });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (type) {
|
|
22
|
+
revalidateTag(`tag-${type}`);
|
|
23
|
+
return NextResponse.json({ revalidated: true, type });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (slug) {
|
|
27
|
+
revalidateTag(`page-${slug}`);
|
|
28
|
+
return NextResponse.json({ revalidated: true, slug });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// No specific target — revalidate everything
|
|
32
|
+
revalidatePath("/", "layout");
|
|
33
|
+
return NextResponse.json({ revalidated: true, all: true });
|
|
34
|
+
}
|