1435 lines
35 KiB
Markdown

# Pages Module (Landing Page Builder)
## Overview
The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (`/p/:slug`), and optionally export them to the MkDocs documentation site as Material theme overrides.
**Key Features:**
- **Dual editor modes:**
- **VISUAL** — GrapesJS drag-and-drop WYSIWYG editor with custom blocks
- **CODE** — Direct HTML editing for advanced users
- **Automatic slug generation** from titles (collision-safe)
- **MkDocs export system:**
- Exports pages to `mkdocs/overrides/` directory
- Creates `.md` stub files with front matter for MkDocs Material
- Two export modes: THEMED (Jinja2 extends main.html) or STANDALONE (full HTML document)
- Configurable nav/TOC hiding via Material theme front matter
- **Reusable block library** (hero, text, image, CTA, features, testimonials, form)
- **SEO metadata** (title, description, image)
- **Public rendering** at `/p/:slug` route
- **Sync & validation** tools for managing MkDocs exports
- **Path traversal protection** (null bytes, `..`, encoded sequences)
- **Published/draft workflow**
## File Paths
| File | Purpose |
|------|---------|
| `api/src/modules/pages/pages-admin.routes.ts` | Admin router with 7 endpoints (114 lines) |
| `api/src/modules/pages/pages-public.routes.ts` | Public router (1 endpoint, 21 lines) |
| `api/src/modules/pages/blocks.routes.ts` | Block library router (5 endpoints, 88 lines) |
| `api/src/modules/pages/pages.service.ts` | Landing page business logic + MkDocs export (637 lines) |
| `api/src/modules/pages/blocks.service.ts` | Block CRUD service (89 lines) |
| `api/src/modules/pages/pages.schemas.ts` | Zod validation schemas (83 lines) |
## Database Models
```prisma
model LandingPage {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
blocks Json // JSON from GrapesJS editor
htmlOutput String? @db.Text
cssOutput String? @db.Text
editorMode EditorMode @default(VISUAL)
mkdocsPath String? // Path in mkdocs/overrides/
mkdocsStubPath String? // Path to .md stub in mkdocs/docs/
mkdocsExportMode MkdocsExportMode @default(THEMED)
mkdocsHideNav Boolean @default(true)
mkdocsHideToc Boolean @default(true)
mkdocsSkipExport Boolean @default(false)
published Boolean @default(false)
seoTitle String?
seoDescription String? @db.Text
seoImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("landing_pages")
}
enum EditorMode {
VISUAL // GrapesJS drag-and-drop editor
CODE // Direct HTML editing
}
enum MkdocsExportMode {
THEMED // Jinja2 extends main.html (Material theme integration)
STANDALONE // Full HTML document (no Jinja2 inheritance)
}
model PageBlock {
id String @id @default(cuid())
type String // hero, text, image, cta, features, testimonials, form
label String
schema Json // Block configuration schema (GrapesJS component definition)
defaults Json // Default values for new instances
thumbnail String?
category String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("page_blocks")
}
```
**Key Fields:**
- **`blocks`** — GrapesJS JSON state (saved on Ctrl+S in editor)
- **`htmlOutput`** — Rendered HTML (generated by GrapesJS or manually entered in CODE mode)
- **`cssOutput`** — Extracted CSS (from GrapesJS styles or manual entry)
- **`mkdocsPath`** — Relative path in `mkdocs/overrides/` (e.g., `landing-page.html`)
- **`mkdocsStubPath`** — Relative path to `.md` stub (e.g., `landing-page.md`)
- **`mkdocsExportMode`** — THEMED (Jinja2) or STANDALONE (full HTML)
- **`mkdocsSkipExport`** — Skip MkDocs export (for internal pages only accessible via `/p/:slug`)
**Slug Generation:**
```typescript
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with -
.replace(/^-+|-+$/g, '') // Remove leading/trailing -
.slice(0, 80); // Max 80 chars
}
```
**Example Transformations:**
- `"Landing Page"``landing-page`
- `"About Us — Contact Info"``about-us-contact-info`
- `"Landing Page"` (duplicate) → `landing-page-2`
## API Endpoints
### Admin Endpoints (Authentication Required)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/pages` | Admin roles | List landing pages with pagination/filters |
| GET | `/api/pages/:id` | Admin roles | Get single landing page |
| POST | `/api/pages` | Admin roles | Create landing page |
| PUT | `/api/pages/:id` | Admin roles | Update landing page (triggers MkDocs export) |
| DELETE | `/api/pages/:id` | Admin roles | Delete landing page (removes MkDocs export) |
| POST | `/api/pages/sync` | Admin roles | Sync MkDocs overrides to database |
| POST | `/api/pages/validate` | Admin roles | Validate and repair MkDocs exports |
**Admin Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`
### Block Library Endpoints (Admin Only)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/page-blocks` | Admin roles | List blocks with category filter |
| GET | `/api/page-blocks/:id` | Admin roles | Get single block |
| POST | `/api/page-blocks` | Admin roles | Create block |
| PUT | `/api/page-blocks/:id` | Admin roles | Update block |
| DELETE | `/api/page-blocks/:id` | Admin roles | Delete block |
### Public Endpoints (No Authentication)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/pages/:slug/view` | None | Get published page by slug |
## Admin Endpoint Details
### GET /api/pages
List landing pages with pagination, search, and filtering.
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | number | No | 1 | Page number |
| limit | number | No | 20 | Results per page (max 100) |
| search | string | No | - | Search title, description, or slug |
| published | enum | No | - | Filter by status: `'true'`, `'false'` |
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about"
```
**Response (200 OK):**
```json
{
"pages": [
{
"id": "clx1234567890",
"slug": "about-us",
"title": "About Us",
"description": "Learn about our organization",
"editorMode": "VISUAL",
"blocks": {
"assets": [],
"pages": [/* GrapesJS page structure */],
"styles": [/* GrapesJS styles */]
},
"htmlOutput": "<div class=\"hero\">...</div>",
"cssOutput": ".hero { background: #3498db; }",
"mkdocsPath": "about-us.html",
"mkdocsStubPath": "about-us.md",
"mkdocsExportMode": "THEMED",
"mkdocsHideNav": true,
"mkdocsHideToc": true,
"mkdocsSkipExport": false,
"published": true,
"seoTitle": "About Us — Changemaker Lite",
"seoDescription": "Learn about our mission and values",
"seoImage": "https://example.com/og-image.jpg",
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T14:30:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 5,
"totalPages": 1
}
}
```
**Search Behavior:**
```typescript
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
];
}
```
---
### GET /api/pages/:id
Get single landing page with full editor state.
**Path Parameters:**
- `id` (string): Landing page ID
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages/clx1234567890"
```
**Response (200 OK):**
Returns full landing page object (same format as GET list).
**Error Responses:**
- `404 Not Found`: Page not found
---
### POST /api/pages
Create landing page with auto-generated slug.
**Request Body:**
```json
{
"title": "About Us",
"description": "Learn about our organization",
"editorMode": "VISUAL",
"blocks": {},
"htmlOutput": null,
"cssOutput": null,
"mkdocsExportMode": "THEMED",
"mkdocsHideNav": true,
"mkdocsHideToc": true,
"published": false,
"seoTitle": "About Us — Changemaker Lite",
"seoDescription": "Learn about our mission and values",
"seoImage": "https://example.com/og-image.jpg"
}
```
**Response (201 Created):**
Returns created landing page object.
**Auto-Generated Fields:**
- **`slug`** — Generated from `title` (collision-safe)
- **`mkdocsPath`** — Defaults to `${slug}.html` if not provided
**Validation:**
- `title` is required
- `mkdocsPath` must end with `.html`
- `mkdocsPath` must not contain path traversal sequences (`..`, null bytes, encoded traversal)
---
### PUT /api/pages/:id
Update landing page. Triggers MkDocs export if published.
**Request Body (Partial):**
```json
{
"htmlOutput": "<div class=\"hero\">Updated content</div>",
"cssOutput": ".hero { background: #e74c3c; }",
"published": true
}
```
**Response (200 OK):**
Returns updated landing page object.
**Side Effects:**
1. **Slug regeneration** if title changes (preserves old slug if collision):
```typescript
if (data.title && data.title !== existing.title) {
const baseSlug = generateSlug(data.title);
const newSlug = await resolveSlugCollision(baseSlug, id);
updateData.slug = newSlug;
// Update mkdocsPath if auto-generated
if (existing.mkdocsPath === `${existing.slug}.html`) {
updateData.mkdocsPath = `${newSlug}.html`;
await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
}
}
```
2. **MkDocs export** if `published === true && mkdocsSkipExport === false`:
```typescript
if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {
const stubPath = await exportToMkDocs({
mkdocsPath: page.mkdocsPath,
html: page.htmlOutput,
css: page.cssOutput,
editorMode: page.editorMode,
exportMode: page.mkdocsExportMode,
title: page.title,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
hideNav: page.mkdocsHideNav,
hideToc: page.mkdocsHideToc,
});
}
```
3. **MkDocs cleanup** if `published === false || mkdocsSkipExport === true`:
```typescript
await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
```
**Export Workflow:**
```mermaid
graph TD
A[Update Landing Page] --> B{Published?}
B -->|No| C[Remove MkDocs Export]
B -->|Yes| D{Skip Export?}
D -->|Yes| C
D -->|No| E{Has HTML Output?}
E -->|No| F[No Action]
E -->|Yes| G[Export to MkDocs]
G --> H[Write Override HTML]
H --> I[Write .md Stub]
I --> J[Update stubPath in DB]
```
---
### DELETE /api/pages/:id
Delete landing page and remove MkDocs export.
**Path Parameters:**
- `id` (string): Landing page ID
**Response (204 No Content):**
No response body.
**Side Effects:**
- Removes MkDocs override HTML file (`mkdocs/overrides/{mkdocsPath}`)
- Removes .md stub file (`mkdocs/docs/{mkdocsStubPath}`)
---
### POST /api/pages/sync
Sync MkDocs override files to database (import untracked files, update CODE pages).
**Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages/sync"
```
**Response (200 OK):**
```json
{
"imported": 2,
"updated": 1,
"stubs": 3
}
```
**Behavior:**
1. **Scan `mkdocs/overrides/` directory** for `.html` files:
```typescript
const files = await scanOverrideFiles(MKDOCS_OVERRIDES);
// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]
```
2. **Import untracked files** as CODE pages:
```typescript
if (!tracked) {
// New file not in database
const title = path.basename(file.relativePath, '.html');
const baseSlug = generateSlug(title);
const slug = await resolveSlugCollision(baseSlug);
await prisma.landingPage.create({
data: {
slug,
title,
editorMode: 'CODE',
htmlOutput: content,
mkdocsPath: file.relativePath,
published: true,
blocks: {},
},
});
imported++;
}
```
3. **Update CODE pages from disk** (disk wins):
```typescript
else if (tracked.editorMode === 'CODE') {
// Tracked CODE page — sync from disk
await prisma.landingPage.update({
where: { id: tracked.id },
data: { htmlOutput: content },
});
updated++;
}
// VISUAL pages: don't overwrite from disk (managed by GrapesJS)
```
4. **Backfill missing .md stubs** for published pages:
```typescript
for (const page of existingPages) {
if (!page.published || !page.mkdocsPath) continue;
const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);
const exists = await stubExistsOnDisk(expectedStubPath);
if (!exists) {
await writeStubFile(expectedStubPath, stubContent);
stubs++;
}
}
```
**Use Cases:**
- **Manual file creation** — Admin creates `.html` file directly in `mkdocs/overrides/`, then syncs to database
- **Git pull** — After pulling changes that add override files, sync to database
- **Stub recovery** — Re-create missing `.md` stub files
---
### POST /api/pages/validate
Validate MkDocs exports and repair missing files.
**Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages/validate"
```
**Response (200 OK):**
```json
{
"validated": 10,
"repaired": 2,
"errors": [
{
"pageId": "clx1234567890",
"slug": "broken-page",
"error": "ENOENT: no such file or directory"
}
]
}
```
**Behavior:**
1. **Query all published pages** with `mkdocsSkipExport === false`:
```typescript
const pages = await prisma.landingPage.findMany({
where: {
published: true,
mkdocsSkipExport: false,
mkdocsPath: { not: null },
htmlOutput: { not: null },
},
});
```
2. **Check override HTML exists**:
```typescript
const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);
await fs.access(overridePath); // Throws if missing
```
3. **Check .md stub exists**:
```typescript
const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);
const stubExists = await stubExistsOnDisk(expectedStubPath);
```
4. **Repair if either missing**:
```typescript
if (!overrideExists || !stubExists) {
await exportToMkDocs({
mkdocsPath: page.mkdocsPath,
html: page.htmlOutput,
css: page.cssOutput,
// ...
});
repaired++;
}
```
**Use Cases:**
- **Missing exports after deploy** — MkDocs volume lost, re-export all pages
- **Manual deletion** — Admin accidentally deleted override file, repair from database
- **Health check** — Verify all published pages have correct exports
---
## Block Library Endpoint Details
### GET /api/page-blocks
List blocks with optional category filter.
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| category | string | No | Filter by category |
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/page-blocks?category=hero"
```
**Response (200 OK):**
```json
[
{
"id": "clx1234567890",
"type": "hero",
"label": "Hero Section",
"schema": {
"type": "div",
"classes": ["hero"],
"attributes": { "data-gjs-type": "hero" },
"components": [/* ... */],
"traits": [
{ "type": "text", "name": "heading", "label": "Heading" },
{ "type": "text", "name": "subheading", "label": "Subheading" }
]
},
"defaults": {
"heading": "Welcome to our site",
"subheading": "Your journey starts here"
},
"thumbnail": "https://example.com/hero-thumb.jpg",
"category": "hero",
"sortOrder": 1,
"createdAt": "2026-01-15T12:00:00.000Z",
"updatedAt": "2026-01-20T10:00:00.000Z"
}
]
```
**Sort Order:**
Blocks are sorted by `sortOrder` ASC. Lower numbers appear first in block library panel.
---
### POST /api/page-blocks
Create block.
**Request Body:**
```json
{
"type": "hero",
"label": "Hero Section",
"schema": {
"type": "div",
"classes": ["hero"],
"attributes": { "data-gjs-type": "hero" }
},
"defaults": {
"heading": "Welcome",
"subheading": "Your journey starts here"
},
"thumbnail": "https://example.com/hero-thumb.jpg",
"category": "hero",
"sortOrder": 1
}
```
**Response (201 Created):**
Returns created block object.
---
## Public Endpoint Details
### GET /api/pages/:slug/view
Get published landing page by slug (no auth required).
**Path Parameters:**
- `slug` (string): Landing page slug
**Example Request:**
```bash
curl http://api.cmlite.org/api/pages/about-us/view
```
**Response (200 OK):**
Returns full landing page object (same format as admin GET).
**Filtering:**
- Only returns pages with `published === true`
- Throws 404 if page not found or not published
**Error Responses:**
- `404 Not Found`: Page not found or not published
---
## Service Functions
### pagesService.findAll(filters)
List landing pages with pagination, search, and filtering.
**Usage:**
```typescript
import { pagesService } from './pages.service';
const result = await pagesService.findAll({
page: 1,
limit: 20,
search: 'about',
published: 'true',
});
console.log(result.pages.length); // Array of pages
console.log(result.pagination); // { page, limit, total, totalPages }
```
---
### pagesService.create(data)
Create landing page with auto-generated slug and mkdocsPath.
**Usage:**
```typescript
const page = await pagesService.create({
title: 'About Us',
description: 'Learn about our organization',
editorMode: 'VISUAL',
blocks: {},
published: false,
});
console.log(page.slug); // 'about-us'
console.log(page.mkdocsPath); // 'about-us.html'
```
---
### pagesService.update(id, data)
Update landing page with MkDocs export/cleanup side effects.
**Usage:**
```typescript
const page = await pagesService.update('clx1234567890', {
htmlOutput: '<div class="hero">Updated</div>',
cssOutput: '.hero { background: #e74c3c; }',
published: true,
});
// Side effect: Exports to mkdocs/overrides/{mkdocsPath}
// Side effect: Creates .md stub in mkdocs/docs/{mkdocsStubPath}
```
**Export Trigger:**
- Export happens if `published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutput`
- Cleanup happens if `published === false || mkdocsSkipExport === true`
---
### pagesService.syncOverrides()
Sync MkDocs override files to database.
**Usage:**
```typescript
const result = await pagesService.syncOverrides();
console.log(`Imported: ${result.imported}`); // New CODE pages imported
console.log(`Updated: ${result.updated}`); // CODE pages synced from disk
console.log(`Stubs: ${result.stubs}`); // Missing stubs created
```
**Workflow:**
1. Scan `mkdocs/overrides/` for `.html` files
2. Import untracked files as CODE pages
3. Update tracked CODE pages from disk (disk wins)
4. Don't overwrite VISUAL pages (managed by GrapesJS)
5. Backfill missing .md stubs
---
### pagesService.validateExports()
Validate and repair MkDocs exports.
**Usage:**
```typescript
const result = await pagesService.validateExports();
console.log(`Validated: ${result.validated}`); // Pages checked
console.log(`Repaired: ${result.repaired}`); // Missing exports repaired
console.log(`Errors: ${result.errors.length}`); // Failed repairs
```
**Repair Logic:**
```typescript
// Check override HTML exists
const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);
const overrideExists = await fs.access(overridePath).then(() => true, () => false);
// Check stub exists
const stubExists = await stubExistsOnDisk(expectedStubPath);
// Repair if either missing
if (!overrideExists || !stubExists) {
await exportToMkDocs({/* ... */});
repaired++;
}
```
---
## MkDocs Export System
### Export Modes
**1. THEMED (Default)**
Wraps HTML in Jinja2 template extending MkDocs Material theme:
```jinja2
{% extends "main.html" %}
{% block content %}
<style>
{{ css }}
</style>
{{ html }}
{% endblock %}
```
**Pros:**
- Inherits Material theme navigation, footer, search
- Consistent branding with main docs
- Responsive out of the box
**Cons:**
- Limited control over layout
- Must work within Material theme constraints
**2. STANDALONE**
Full HTML document without Jinja2 inheritance:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ seoTitle || title }}</title>
<meta name="description" content="{{ seoDescription }}">
<style>
{{ css }}
</style>
</head>
<body>
{{ html }}
</body>
</html>
```
**Pros:**
- Full control over layout
- No Material theme constraints
- Custom navigation/footer
**Cons:**
- No Material theme features (search, nav, etc.)
- Must implement responsive design
- Separate branding
---
### .md Stub File Format
The `.md` stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance.
**Example:**
```markdown
---
template: about-us.html
hide:
- navigation
- toc
title: "About Us — Changemaker Lite"
description: "Learn about our mission and values"
---
```
**Front Matter Fields:**
- **`template`** — Override filename (relative to `custom_dir`/overrides)
- **`hide`** — Hide Material theme elements (`navigation`, `toc`)
- **`title`** — Page title (SEO)
- **`description`** — Page description (SEO)
**Generation:**
```typescript
function generateMdStub(opts: StubOptions): string {
const hideItems: string[] = [];
if (opts.hideNav) hideItems.push(' - navigation');
if (opts.hideToc) hideItems.push(' - toc');
const hideBlock = hideItems.length > 0 ? `hide:\n${hideItems.join('\n')}\n` : '';
const descLine = opts.description ? `description: "${opts.description.replace(/"/g, '\\"')}"\n` : '';
return `---
template: ${opts.overrideFilename}
${hideBlock}title: "${opts.title.replace(/"/g, '\\"')}"
${descLine}---
`;
}
```
---
### Path Validation
All `mkdocsPath` values are validated to prevent path traversal attacks:
```typescript
function validateMkdocsPath(mkdocsPath: string): void {
// Check for null bytes
if (mkdocsPath.includes('\0')) {
throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');
}
// Normalize and check for traversal
const normalized = path.normalize(mkdocsPath);
if (normalized.includes('..') || path.isAbsolute(normalized)) {
throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');
}
// Check for encoded traversal sequences
if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');
}
if (!mkdocsPath.endsWith('.html')) {
throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');
}
}
```
**Blocked Patterns:**
- Null bytes (`\0`)
- Path traversal (`..`)
- Absolute paths (`/etc/passwd`)
- Encoded traversal (`%2e%2e/`, `%2E%2E/`)
- Non-HTML files (must end with `.html`)
---
## Validation Schemas
### Create Landing Page Schema
```typescript
export const createLandingPageSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
editorMode: z.enum(['VISUAL', 'CODE']).optional().default('VISUAL'),
blocks: z.any().optional().default({}),
htmlOutput: z.string().optional(),
cssOutput: z.string().optional(),
mkdocsPath: z.string().optional(),
mkdocsExportMode: z.enum(['THEMED', 'STANDALONE']).optional().default('THEMED'),
mkdocsHideNav: z.boolean().optional().default(true),
mkdocsHideToc: z.boolean().optional().default(true),
mkdocsSkipExport: z.boolean().optional().default(false),
published: z.boolean().optional().default(false),
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
seoImage: z.string().optional(),
});
```
**Defaults:**
- `editorMode`: `VISUAL`
- `blocks`: `{}`
- `mkdocsExportMode`: `THEMED`
- `mkdocsHideNav`: `true`
- `mkdocsHideToc`: `true`
- `mkdocsSkipExport`: `false`
- `published`: `false`
---
### Create Page Block Schema
```typescript
export const createPageBlockSchema = z.object({
type: z.string().min(1, 'Type is required'),
label: z.string().min(1, 'Label is required'),
schema: z.any().optional().default({}),
defaults: z.any().optional().default({}),
thumbnail: z.string().optional(),
category: z.string().optional(),
sortOrder: z.number().int().optional().default(0),
});
```
**Example Valid Input:**
```json
{
"type": "hero",
"label": "Hero Section",
"schema": {
"type": "div",
"classes": ["hero"]
},
"defaults": {
"heading": "Welcome"
},
"category": "hero",
"sortOrder": 1
}
```
---
## Code Examples
### Admin: Create Landing Page
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const createPage = async () => {
try {
const { data } = await api.post('/api/pages', {
title: 'About Us',
description: 'Learn about our organization',
editorMode: 'VISUAL',
mkdocsExportMode: 'THEMED',
mkdocsHideNav: true,
mkdocsHideToc: true,
published: false,
seoTitle: 'About Us — Changemaker Lite',
seoDescription: 'Learn about our mission and values',
});
message.success(`Page created: ${data.slug}`);
return data;
} catch (error) {
message.error('Failed to create page');
throw error;
}
};
```
---
### Admin: Publish Page (Triggers MkDocs Export)
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const publishPage = async (pageId: string, htmlOutput: string, cssOutput: string) => {
try {
const { data } = await api.put(`/api/pages/${pageId}`, {
htmlOutput,
cssOutput,
published: true,
});
message.success(`Page published and exported to MkDocs!`);
return data;
} catch (error) {
message.error('Failed to publish page');
throw error;
}
};
```
---
### Admin: Sync MkDocs Overrides
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const syncOverrides = async () => {
try {
const { data } = await api.post('/api/pages/sync');
message.success(
`Sync complete: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`
);
return data;
} catch (error) {
message.error('Failed to sync overrides');
throw error;
}
};
```
---
### Admin: Validate and Repair Exports
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const validateExports = async () => {
try {
const { data } = await api.post('/api/pages/validate');
if (data.errors.length > 0) {
message.warning(`Validation complete: ${data.repaired} repaired, ${data.errors.length} errors`);
} else {
message.success(`Validation complete: ${data.validated} validated, ${data.repaired} repaired`);
}
return data;
} catch (error) {
message.error('Failed to validate exports');
throw error;
}
};
```
---
### Public: Render Landing Page
```typescript
import axios from 'axios';
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
interface LandingPage {
id: string;
slug: string;
title: string;
htmlOutput: string;
cssOutput: string | null;
seoTitle: string | null;
seoDescription: string | null;
}
const LandingPageRenderer = () => {
const { slug } = useParams<{ slug: string }>();
const [page, setPage] = useState<LandingPage | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPage = async () => {
try {
const { data } = await axios.get(`/api/pages/${slug}/view`);
setPage(data);
} catch (error) {
console.error('Page not found:', error);
} finally {
setLoading(false);
}
};
fetchPage();
}, [slug]);
if (loading) return <div>Loading...</div>;
if (!page) return <div>Page not found</div>;
return (
<>
{/* Inject CSS */}
{page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
{/* Render HTML */}
<div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />
</>
);
};
```
---
## Frontend Integration
The LandingPagesPage component (`admin/src/pages/LandingPagesPage.tsx`) provides:
- **Paginated pages table** with search and published filter
- **Create page button** (opens modal with title input)
- **Edit button** (navigates to full-screen GrapesJS editor)
- **Publish/unpublish toggle** (triggers MkDocs export)
- **Delete confirmation modal**
- **Sync button** (syncs MkDocs overrides to database)
- **Validate button** (repairs missing exports)
- **Settings modal** (configure MkDocs export options)
**State Management:**
```typescript
const [pages, setPages] = useState<LandingPage[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({ search: '', published: null });
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
```
**Page Editor:**
The PageEditorPage component (`admin/src/pages/PageEditorPage.tsx`) provides:
- **Full-screen GrapesJS editor** (no AppLayout)
- **Custom block library** (hero, text, image, CTA, features, testimonials, form)
- **Ctrl+S save** (forwardRef to GrapesJS instance)
- **Mobile warning** (GrapesJS is desktop-only)
- **Visual/Code mode toggle**
- **Auto-save on blur** (optional)
**Public Renderer:**
The LandingPage component (`admin/src/pages/public/LandingPage.tsx`) provides:
- **Public route** at `/p/:slug`
- **Renders `htmlOutput` with `cssOutput`**
- **SEO metadata** from `seoTitle`, `seoDescription`, `seoImage`
- **404 handling** for unpublished or missing pages
---
## Performance Considerations
### MkDocs Export Caching
MkDocs exports are triggered on update, not on every GET request. This avoids I/O overhead.
**Export Trigger:**
```typescript
if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {
await exportToMkDocs({/* ... */});
}
```
**No Export:**
- Draft pages (`published === false`)
- Skipped pages (`mkdocsSkipExport === true`)
- Pages without HTML output
---
### Slug Collision Handling
The slug collision resolver loops until unique slug found. To avoid infinite loops, it uses suffix counter:
```typescript
async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
let candidate = slug;
let suffix = 2;
while (true) {
const existing = await prisma.landingPage.findUnique({ where: { slug: candidate } });
if (!existing || (excludeId && existing.id === excludeId)) {
return candidate;
}
candidate = `${slug}-${suffix}`; // about-us-2, about-us-3, ...
suffix++;
}
}
```
**Worst-case:**
- O(n) queries where n = number of pages with same base slug
- In practice, n is very small (< 10)
---
## Troubleshooting
### MkDocs Override Not Appearing
**Problem:**
Page is published but doesn't appear on MkDocs site.
**Diagnosis:**
1. **Check override file exists:**
```bash
ls mkdocs/overrides/about-us.html
```
2. **Check stub file exists:**
```bash
ls mkdocs/docs/about-us.md
```
3. **Check stub front matter:**
```bash
cat mkdocs/docs/about-us.md
```
Verify `template:` points to override filename (not path):
```yaml
template: about-us.html # Correct
template: overrides/about-us.html # WRONG — causes TemplateNotFound
```
4. **Check MkDocs logs:**
```bash
docker compose logs -f mkdocs
```
**Solutions:**
- **Missing files:** Run validate endpoint to repair:
```bash
curl -X POST -H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/pages/validate
```
- **Wrong template path:** Front matter `template:` value is relative to template search paths. Use filename only.
- **MkDocs rebuild:** Restart MkDocs container:
```bash
docker compose restart mkdocs
```
---
### Path Traversal Validation Error
**Problem:**
Creating page fails with "Path traversal not allowed" error.
**Diagnosis:**
Check `mkdocsPath` value for blocked patterns:
```typescript
// Blocked:
mkdocsPath: '../etc/passwd.html' // Path traversal
mkdocsPath: '/etc/passwd.html' // Absolute path
mkdocsPath: '%2e%2e/etc/passwd.html' // Encoded traversal
mkdocsPath: 'foo\0bar.html' // Null byte
// Allowed:
mkdocsPath: 'about-us.html' // Simple filename
mkdocsPath: 'subfolder/about-us.html' // Subdirectory (no traversal)
```
**Solution:**
Use safe filenames without path traversal sequences. Subfolders are allowed but must not contain `..`.
---
### CODE Page Overwritten by Disk
**Problem:**
Manual edits to CODE page in database are lost after sync.
**Diagnosis:**
Check `editorMode`:
```sql
SELECT id, slug, "editorMode" FROM landing_pages WHERE slug = 'my-page';
```
**Behavior:**
- **CODE pages:** Disk wins. Sync overwrites database `htmlOutput` from disk.
- **VISUAL pages:** Database wins. Sync does not overwrite GrapesJS-managed pages.
**Solution:**
- **Option 1:** Edit file on disk directly:
```bash
vim mkdocs/overrides/my-page.html
# Then sync
curl -X POST -H "Authorization: Bearer <token>" http://api.cmlite.org/api/pages/sync
```
- **Option 2:** Change `editorMode` to `VISUAL` if you want database to be source of truth:
```sql
UPDATE landing_pages SET "editorMode" = 'VISUAL' WHERE slug = 'my-page';
```
---
### Stub Template Not Found
**Problem:**
MkDocs build fails with `TemplateNotFound` error.
**Diagnosis:**
Check stub front matter:
```bash
cat mkdocs/docs/about-us.md
```
**Common Mistakes:**
```yaml
# WRONG — includes directory path
template: overrides/about-us.html
# CORRECT — filename only
template: about-us.html
```
**Why:**
MkDocs Material `template:` searches in `custom_dir` (which includes `/overrides`). Using `overrides/` in the template value causes it to look for `overrides/overrides/about-us.html`.
**Solution:**
Re-export page to fix stub:
```bash
curl -X POST -H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/pages/validate
```
---
## Related Documentation
- [Frontend: LandingPagesPage](/v2/frontend/pages/admin/landing-pages-page.md) - Landing page manager UI
- [Frontend: PageEditorPage](/v2/frontend/pages/admin/page-editor-page.md) - GrapesJS editor wrapper
- [Frontend: Public Landing Page](/v2/frontend/pages/public/landing-page.md) - Public renderer
- [Features: Landing Page Builder](/v2/features/landing-pages/overview.md) - Complete feature guide
- [MkDocs Integration](/v2/deployment/mkdocs-integration.md) - MkDocs export system
- [API Reference: Pages](/v2/api-reference/pages.md) - Complete endpoint reference
- [User Guide: Content Editor](/v2/user-guides/content-editor-guide.md) - Creating landing pages
- [Troubleshooting: MkDocs Issues](/v2/troubleshooting/mkdocs-issues.md) - MkDocs debugging guide