35 KiB
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
.mdstub 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
- Exports pages to
- Reusable block library (hero, text, image, CTA, features, testimonials, form)
- SEO metadata (title, description, image)
- Public rendering at
/p/:slugroute - 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
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 inmkdocs/overrides/(e.g.,landing-page.html)mkdocsStubPath— Relative path to.mdstub (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:
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:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about"
Response (200 OK):
{
"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:
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:
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:
{
"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 fromtitle(collision-safe)mkdocsPath— Defaults to${slug}.htmlif not provided
Validation:
titleis requiredmkdocsPathmust end with.htmlmkdocsPathmust not contain path traversal sequences (.., null bytes, encoded traversal)
PUT /api/pages/:id
Update landing page. Triggers MkDocs export if published.
Request Body (Partial):
{
"htmlOutput": "<div class=\"hero\">Updated content</div>",
"cssOutput": ".hero { background: #e74c3c; }",
"published": true
}
Response (200 OK):
Returns updated landing page object.
Side Effects:
-
Slug regeneration if title changes (preserves old slug if collision):
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); } } -
MkDocs export if
published === true && mkdocsSkipExport === false: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, }); } -
MkDocs cleanup if
published === false || mkdocsSkipExport === true:await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
Export Workflow:
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:
curl -X POST \
-H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages/sync"
Response (200 OK):
{
"imported": 2,
"updated": 1,
"stubs": 3
}
Behavior:
-
Scan
mkdocs/overrides/directory for.htmlfiles:const files = await scanOverrideFiles(MKDOCS_OVERRIDES); // Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...] -
Import untracked files as CODE pages:
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++; } -
Update CODE pages from disk (disk wins):
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) -
Backfill missing .md stubs for published pages:
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
.htmlfile directly inmkdocs/overrides/, then syncs to database - Git pull — After pulling changes that add override files, sync to database
- Stub recovery — Re-create missing
.mdstub files
POST /api/pages/validate
Validate MkDocs exports and repair missing files.
Example Request:
curl -X POST \
-H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/pages/validate"
Response (200 OK):
{
"validated": 10,
"repaired": 2,
"errors": [
{
"pageId": "clx1234567890",
"slug": "broken-page",
"error": "ENOENT: no such file or directory"
}
]
}
Behavior:
-
Query all published pages with
mkdocsSkipExport === false:const pages = await prisma.landingPage.findMany({ where: { published: true, mkdocsSkipExport: false, mkdocsPath: { not: null }, htmlOutput: { not: null }, }, }); -
Check override HTML exists:
const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath); await fs.access(overridePath); // Throws if missing -
Check .md stub exists:
const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath); const stubExists = await stubExistsOnDisk(expectedStubPath); -
Repair if either missing:
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:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/page-blocks?category=hero"
Response (200 OK):
[
{
"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:
{
"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:
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:
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:
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:
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:
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:
- Scan
mkdocs/overrides/for.htmlfiles - Import untracked files as CODE pages
- Update tracked CODE pages from disk (disk wins)
- Don't overwrite VISUAL pages (managed by GrapesJS)
- Backfill missing .md stubs
pagesService.validateExports()
Validate and repair MkDocs exports.
Usage:
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:
// 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:
{% 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:
<!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:
---
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 tocustom_dir/overrides)hide— Hide Material theme elements (navigation,toc)title— Page title (SEO)description— Page description (SEO)
Generation:
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:
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
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:VISUALblocks:{}mkdocsExportMode:THEMEDmkdocsHideNav:truemkdocsHideToc:truemkdocsSkipExport:falsepublished:false
Create Page Block Schema
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:
{
"type": "hero",
"label": "Hero Section",
"schema": {
"type": "div",
"classes": ["hero"]
},
"defaults": {
"heading": "Welcome"
},
"category": "hero",
"sortOrder": 1
}
Code Examples
Admin: Create Landing Page
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)
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
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
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
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:
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
htmlOutputwithcssOutput - 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:
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:
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:
-
Check override file exists:
ls mkdocs/overrides/about-us.html -
Check stub file exists:
ls mkdocs/docs/about-us.md -
Check stub front matter:
cat mkdocs/docs/about-us.mdVerify
template:points to override filename (not path):template: about-us.html # Correct template: overrides/about-us.html # WRONG — causes TemplateNotFound -
Check MkDocs logs:
docker compose logs -f mkdocs
Solutions:
-
Missing files: Run validate endpoint to repair:
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:
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:
// 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:
SELECT id, slug, "editorMode" FROM landing_pages WHERE slug = 'my-page';
Behavior:
- CODE pages: Disk wins. Sync overwrites database
htmlOutputfrom disk. - VISUAL pages: Database wins. Sync does not overwrite GrapesJS-managed pages.
Solution:
-
Option 1: Edit file on disk directly:
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
editorModetoVISUALif you want database to be source of truth: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:
cat mkdocs/docs/about-us.md
Common Mistakes:
# 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:
curl -X POST -H "Authorization: Bearer <token>" \
http://api.cmlite.org/api/pages/validate
Related Documentation
- Frontend: LandingPagesPage - Landing page manager UI
- Frontend: PageEditorPage - GrapesJS editor wrapper
- Frontend: Public Landing Page - Public renderer
- Features: Landing Page Builder - Complete feature guide
- MkDocs Integration - MkDocs export system
- API Reference: Pages - Complete endpoint reference
- User Guide: Content Editor - Creating landing pages
- Troubleshooting: MkDocs Issues - MkDocs debugging guide