29 KiB
Page Builder
Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.
Overview
The Page Builder system provides a comprehensive solution for creating custom landing pages without coding. Administrators can use a visual drag-and-drop interface or write raw HTML/CSS directly.
Key Features
- Dual-Mode Editing: Switch between VISUAL (GrapesJS drag-and-drop) and CODE (raw HTML editor)
- Slug-Based Routing: Public pages accessible at
/p/:slug(e.g.,/p/about-us) - MkDocs Export: Publish pages to MkDocs documentation site with Material theme integration
- SEO Meta Tags: Configure title, description, and Open Graph images
- Custom Blocks: Reusable components (hero, features, CTA, testimonials, contact forms)
- Video Integration: Embed media library videos with standard or advanced players
- Mobile Detection: Editor warns users on small screens (desktop-only editing)
Architecture Overview
graph LR
A[Admin] --> B[LandingPagesPage]
B --> C[Create Page Modal]
C --> D[LandingPageEditor]
D --> E[GrapesJS Editor]
E --> F[Save API]
F --> G[(LandingPage Model)]
G --> H[Public Route]
H --> I[/p/:slug]
D --> J[Publish Toggle]
J --> K[MkDocs Export]
K --> L[overrides/*.html]
K --> M[docs/*.md stub]
style E fill:#9d4edd
style G fill:#3498db
style K fill:#2ecc71
Flow:
- Admin creates page via LandingPagesPage
- Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
- Admin drags blocks, configures properties, saves (Ctrl+S)
- API stores
projectData(GrapesJS JSON),htmlOutput,cssOutput - On publish: API exports
.htmloverride +.mdstub to MkDocs - Public users access page at
/p/:slug(React route renders HTML)
Database Models
LandingPage
Table: landing_pages
Key Fields:
| Field | Type | Description |
|---|---|---|
id |
String (UUID) | Primary key |
slug |
String | Unique URL-safe identifier (auto-generated from title) |
title |
String | Page title (internal + fallback SEO) |
description |
String? | Page description (internal) |
editorMode |
Enum | VISUAL (GrapesJS) or CODE (raw HTML) |
blocks |
JSON | GrapesJS projectData (components tree) |
htmlOutput |
String? | Rendered HTML (cached output from editor) |
cssOutput |
String? | Rendered CSS (cached output from editor) |
mkdocsPath |
String? | Override file path (e.g., about.html) |
mkdocsStubPath |
String? | Stub Markdown path (e.g., about.md) |
mkdocsExportMode |
Enum | THEMED (extends main.html) or STANDALONE (full HTML) |
mkdocsHideNav |
Boolean | Hide navigation sidebar in MkDocs |
mkdocsHideToc |
Boolean | Hide table of contents in MkDocs |
mkdocsSkipExport |
Boolean | Don't export to MkDocs (only accessible via /p/:slug) |
published |
Boolean | Public visibility (false = draft) |
seoTitle |
String? | Custom SEO title (overrides title) |
seoDescription |
String? | Meta description for search engines |
seoImage |
String? | Open Graph image URL |
createdAt |
DateTime | Creation timestamp |
updatedAt |
DateTime | Last modification timestamp |
Indexes:
slug(unique)published(filter index)
Relationships:
- None (standalone model)
PageBlock
See Block Library documentation.
API Endpoints
Admin Routes
Prefix: /api/pages
Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
List Pages
GET /api/pages?page=1&limit=20&search=campaign&published=true
Query Parameters:
page(number, default: 1) — Page numberlimit(number, default: 20, max: 100) — Results per pagesearch(string?) — Search title, description, or slug (case-insensitive)published(string?) — Filter by status:"true","false", or omit for all
Response:
{
"pages": [
{
"id": "abc123",
"slug": "about-us",
"title": "About Our Campaign",
"description": "Learn more about our mission.",
"editorMode": "VISUAL",
"blocks": { /* GrapesJS JSON */ },
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"mkdocsPath": "about.html",
"mkdocsStubPath": "about.md",
"mkdocsExportMode": "THEMED",
"mkdocsHideNav": false,
"mkdocsHideToc": true,
"mkdocsSkipExport": false,
"published": true,
"seoTitle": "About Us | Campaign 2026",
"seoDescription": "Join our movement for change.",
"seoImage": "https://example.com/og-image.jpg",
"createdAt": "2026-01-15T10:00:00Z",
"updatedAt": "2026-02-13T14:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 5,
"totalPages": 1
}
}
Get Page
GET /api/pages/:id
Response: Single LandingPage object (same structure as list item above)
Errors:
404 PAGE_NOT_FOUND— Page doesn't exist
Create Page
POST /api/pages
Content-Type: application/json
{
"title": "New Landing Page",
"description": "Page description",
"editorMode": "VISUAL"
}
Request Body:
title(string, required) — Page title (slug auto-generated)description(string?) — Internal descriptioneditorMode(enum?, default:VISUAL) —VISUALorCODEmkdocsPath(string?) — Custom override path (defaults to{slug}.html)
Response: Created LandingPage object (201 status)
Errors:
400 INVALID_MKDOCS_PATH— Invalid path (traversal attempt, missing .html extension)
Behavior:
- Slug auto-generated from title (lowercased, spaces→hyphens, alphanumeric only)
- Slug collision handling (appends
-2,-3, etc.) blocksinitialized as empty JSON objectpublisheddefaults tofalse
Update Page
PUT /api/pages/:id
Content-Type: application/json
{
"blocks": { /* GrapesJS projectData */ },
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"published": true
}
Request Body: (all fields optional)
title(string?) — New title (regenerates slug if changed)description(string?)blocks(JSON?) — GrapesJSprojectDatahtmlOutput(string?) — Rendered HTMLcssOutput(string?) — Rendered CSSpublished(boolean?) — Publish statusmkdocsPath(string?) — Custom override pathmkdocsExportMode(enum?) —THEMEDorSTANDALONEmkdocsHideNav(boolean?)mkdocsHideToc(boolean?)mkdocsSkipExport(boolean?)seoTitle(string?)seoDescription(string?)seoImage(string?)
Response: Updated LandingPage object
Errors:
404 PAGE_NOT_FOUND— Page doesn't exist400 INVALID_MKDOCS_PATH— Invalid path
Side Effects:
- On publish (published=true, mkdocsSkipExport=false): Exports to MkDocs (writes
.html+.mdstub) - On unpublish or mkdocsSkipExport=true: Removes MkDocs files
- On title change: Regenerates slug, updates
mkdocsPathif it was auto-generated, cleans up old exports
Delete Page
DELETE /api/pages/:id
Response: 204 No Content
Errors:
404 PAGE_NOT_FOUND— Page doesn't exist
Side Effects:
- Removes MkDocs exports (
.htmloverride +.mdstub) if they exist
Sync Overrides
POST /api/pages/sync
Purpose: Import untracked .html files from mkdocs/docs/overrides/ as CODE-mode pages. Useful for migrating hand-crafted HTML templates.
Response:
{
"imported": 2,
"updated": 1,
"stubs": 3
}
Behavior:
- Scans
mkdocs/docs/overrides/recursively for.htmlfiles - For untracked files: Creates new CODE-mode page (published=true)
- For tracked CODE-mode pages: Updates
htmlOutputfrom disk (disk wins) - For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
- Backfills missing
.mdstubs for published pages
Use Cases:
- Migrate legacy hand-coded landing pages
- Import templates from designers
- Sync after manual file system edits
Validate Exports
POST /api/pages/validate
Purpose: Verify MkDocs exports exist on disk, repair if missing.
Response:
{
"validated": 10,
"repaired": 2,
"errors": [
{
"pageId": "xyz789",
"slug": "broken-page",
"error": "EACCES: permission denied"
}
]
}
Behavior:
- Queries all published, non-skipped pages with
mkdocsPath - Checks if
.htmloverride and.mdstub exist - Re-exports if either missing
- Updates
mkdocsStubPathif changed - Returns error list for manual intervention
Use Cases:
- Recover from accidental file deletion
- Fix export state after container restarts
- Audit before MkDocs rebuild
Public Routes
Prefix: /api/pages
Authentication: None (public access)
View Published Page
GET /api/pages/:slug/view
Example:
GET /api/pages/about-us/view
Response:
{
"id": "abc123",
"slug": "about-us",
"title": "About Our Campaign",
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"seoTitle": "About Us | Campaign 2026",
"seoDescription": "Join our movement for change.",
"seoImage": "https://example.com/og-image.jpg",
"createdAt": "2026-01-15T10:00:00Z",
"updatedAt": "2026-02-13T14:30:00Z"
}
Errors:
404 PAGE_NOT_FOUND— Page doesn't exist or is unpublished
Security:
- Only returns published pages (
published=true) - Omits editor-only fields (
blocks,mkdocsPath, etc.)
Configuration
Environment Variables
# MkDocs integration
MKDOCS_DOCS_PATH=/mkdocs/docs
# Override path: ${MKDOCS_DOCS_PATH}/overrides/
# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)
Docker Volume:
volumes:
- ./mkdocs:/mkdocs:rw
Note: API container needs write access to export files.
Site Settings
Feature Flag: ENABLE_LANDING_PAGES
Location: Admin → Settings → Features → Landing Pages
Default: true
Effect: Shows/hides "Pages" menu item in admin sidebar
Admin Workflow
Creating a Page
- Navigate: Admin sidebar → Pages
- Click: "Create Page" button
- Fill form:
- Title:
"About Us"(slug auto-generated:about-us) - Description:
"Learn about our campaign"(optional) - Editor Mode:
VISUAL(default) orCODE
- Title:
- Submit: "Create & Edit" button
- Result: Redirected to full-screen editor
Visual Editing (VISUAL Mode)
- Editor opens: GrapesJS interface with 3 panels:
- Left: Block library (drag-and-drop components)
- Center: Canvas (preview + inline editing)
- Right: Properties panel (configure selected component)
- Add blocks: Drag "Hero Section" from left panel to canvas
- Configure: Click hero → Edit title/subtitle/CTA in right panel
- Save: Press
Ctrl+S(orCmd+Son Mac) → API savesprojectData,htmlOutput,cssOutput - Close: Click "X" or "Back to Pages" → Returns to table
Code Editing (CODE Mode)
- Editor opens: Split-view Monaco editors:
- Left: HTML editor
- Right: CSS editor (optional)
- Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)
- Save: Press
Ctrl+S→ API saveshtmlOutput,cssOutput - Close: Click "Back to Pages"
Publishing a Page
Option 1: From Table
- Locate page in table
- Click "Publish" button in Actions column
- Status tag changes: Draft → Published
- Page accessible at
/p/{slug}
Option 2: From Settings Modal
- Click gear icon (Settings) in Actions column
- Settings modal opens
- (Field not shown in modal — use table toggle)
Side Effects (on publish):
- If
mkdocsSkipExport=false: Exports.html+.mdto MkDocs - If
mkdocsSkipExport=true: Only accessible via/p/:slug(no MkDocs export)
Configuring SEO
- Click gear icon (Settings) in Actions column
- Fill SEO section:
- SEO Title: Custom title for
<title>and Open Graph (defaults totitle) - SEO Description: Meta description for search engines
- SEO Image: Full URL to Open Graph image (e.g.,
https://cdn.example.com/og.jpg)
- SEO Title: Custom title for
- Click "Save"
- Re-export to MkDocs if already published
MkDocs Integration Settings
Access: Page Settings modal → MkDocs Integration section
Fields:
-
Skip MkDocs Export (checkbox)
- When enabled: Page NOT exported to MkDocs site
- Use case: Pages meant only for
/p/:slug(not documentation) - Default:
false(export enabled)
-
Override Path (text input)
- Custom filename for override (e.g.,
custom-about.html) - Default: Auto-generated from slug (
{slug}.html) - Validation: Must end with
.html, no path traversal
- Custom filename for override (e.g.,
-
Full page MkDocs (checkbox)
- When enabled: Exports as STANDALONE (full
<!DOCTYPE html>document) - When disabled: Exports as THEMED (wraps in
{% extends "main.html" %}) - Default:
false(THEMED) - Use case: Standalone pages with no MkDocs chrome (like
lander.html)
- When enabled: Exports as STANDALONE (full
-
Hide navigation sidebar (checkbox, only for THEMED mode)
- Adds
hide: [navigation]to.mdstub front matter - Hides left sidebar on page
- Default:
false
- Adds
-
Hide table of contents (checkbox, only for THEMED mode)
- Adds
hide: [toc]to.mdstub front matter - Hides right sidebar on page
- Default:
false
- Adds
Workflow:
- Edit page settings
- Configure MkDocs options
- Save settings
- If published: API auto-exports with new settings
- Rebuild MkDocs: Admin → Pages → "Build Site" button
Syncing Overrides
Purpose: Import hand-coded .html files from disk
Workflow:
- Place
.htmlfiles inmkdocs/docs/overrides/(on Docker host) - Admin → Pages → "Sync Overrides" button
- API scans directory, imports new files as CODE-mode pages
- Table refreshes, new pages appear
- Edit pages normally, publish as needed
Example:
# On Docker host
echo '<h1>Custom Page</h1>' > mkdocs/docs/overrides/custom.html
# In admin panel
# Click "Sync Overrides" → 1 imported
Validating Exports
Purpose: Verify MkDocs files exist, repair if missing
Workflow:
- Admin → Pages → "Validate Exports" button
- API checks all published pages:
.htmloverride exists?.mdstub exists?
- Re-exports if either missing
- Shows result:
Validated 10 pages: 2 repaired
Use Cases:
- After container restart (volume mount issues)
- After manual file deletion
- Before rebuilding MkDocs site
Public Workflow
Viewing a Published Page
- User navigates:
https://yoursite.com/p/about-us - React router: Matches
/p/:slugroute → LoadsLandingPage.tsx - API call:
GET /api/pages/about-us/view - Response: Returns
htmlOutput,cssOutput, SEO fields - Render:
- Sets
document.title = seoTitle || title - Updates meta description, Open Graph image
- Injects
cssOutputas<style>tag - Renders
htmlOutputviadangerouslySetInnerHTML
- Sets
- Video hydration: Scans for
.video-blockdivs, replaces placeholders with React VideoPlayer components
SEO Meta Tags
Applied automatically on page load:
<html>
<head>
<title>About Us | Campaign 2026</title>
<meta name="description" content="Join our movement for change.">
<meta property="og:image" content="https://example.com/og-image.jpg">
</head>
<body>
<style>section { padding: 40px; }</style>
<section>...</section>
</body>
</html>
Video Embedding
Editor Placeholder:
<div class="video-block"
data-video-id="123"
data-player-type="advanced"
data-width="100%"
data-autoplay="false"
data-controls="true"
data-show-reactions="true">
<div class="video-placeholder">
<!-- SVG play icon + metadata -->
</div>
</div>
Runtime Hydration:
LandingPage.tsxmounts → Scans for.video-blockelements- Reads
data-*attributes - Creates React root for each block
- Renders
AdvancedVideoPlayerorVideoPlayercomponent - Replaces placeholder with live player
Supported Attributes:
data-video-id(required) — Media library video IDdata-player-type("standard"or"advanced", default:"standard")data-width(CSS value, default:"100%")data-height(CSS value, default:"auto")data-autoplay("true"or"false", default:"false")data-controls("true"or"false", default:"true")data-show-reactions("true"or"false", default:"true", advanced player only)
Code Examples
Creating a Page (TypeScript)
import { api } from '@/lib/api';
async function createAboutPage() {
const { data } = await api.post('/pages', {
title: 'About Us',
description: 'Learn about our campaign',
editorMode: 'VISUAL',
});
console.log('Created page:', data.slug); // "about-us"
return data.id;
}
Saving Editor State (GrapesJS)
// In LandingPageEditor component
import { useRef } from 'react';
import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
const editorRef = useRef<GrapesJSEditorHandle>(null);
const handleSave = () => {
editorRef.current?.triggerSave(); // Calls registered save command
};
const handleEditorSave = async (data: { projectData: any; html: string; css: string }) => {
await api.put(`/pages/${pageId}`, {
blocks: data.projectData,
htmlOutput: data.html,
cssOutput: data.css,
});
message.success('Page saved');
};
return (
<GrapesJSEditor
ref={editorRef}
initialData={page.blocks}
onSave={handleEditorSave}
/>
);
Fetching Published Page (Public Route)
import axios from 'axios';
async function loadLandingPage(slug: string) {
try {
const { data } = await axios.get(`/api/pages/${slug}/view`);
// Set SEO
document.title = data.seoTitle || data.title;
// Inject CSS
const style = document.createElement('style');
style.textContent = data.cssOutput || '';
document.head.appendChild(style);
// Render HTML
return data.htmlOutput;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new Error('Page not found or unpublished');
}
throw error;
}
}
MkDocs Export Logic (Backend)
// From pages.service.ts
function wrapInMaterialOverride(html: string, css: string | null): string {
const styleBlock = css ? `<style>\n${css}\n</style>` : '';
return `{% extends "main.html" %}
{% block content %}
${styleBlock}
${html}
{% endblock %}
`;
}
async function exportToMkDocs(opts: ExportOptions): Promise<string> {
const { mkdocsPath, html, css, exportMode, title, seoTitle, seoDescription } = opts;
// Write override template
const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath);
const content = exportMode === 'STANDALONE'
? wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription)
: wrapInMaterialOverride(html, css);
await fs.writeFile(filePath, content, 'utf-8');
// Write .md stub
const stubPath = mkdocsPath.replace(/\.html$/, '.md');
const stubContent = `---
template: ${mkdocsPath}
title: "${seoTitle || title}"
---
`;
await fs.writeFile(path.join(MKDOCS_DOCS_ROOT, stubPath), stubContent, 'utf-8');
return stubPath;
}
Troubleshooting
Problem: GrapesJS Editor Not Loading
Symptoms:
- Blank screen in editor
- Console error:
Cannot read property 'init' of undefined
Causes:
- GrapesJS package not installed
- CSS import missing
- Plugin incompatibility
Solutions:
-
Verify installation:
cd admin && npm list grapesjs # Should show: grapesjs@0.21.x -
Check CSS import:
// In GrapesJSEditor.tsx import 'grapesjs/dist/css/grapes.min.css'; -
Check browser console:
- Look for
grapesjsvariable in global scope - Verify all plugins loaded successfully
- Look for
-
Clear cache:
# In browser DevTools # Right-click Reload → Empty Cache and Hard Reload
Problem: Published Page Not Rendering
Symptoms:
- 404 error at
/p/my-page - Page exists in database,
published=true
Causes:
- React route not registered
- Slug mismatch
- Public route mounted incorrectly
Solutions:
-
Verify route registration:
// In admin/src/App.tsx <Route path="/p/:slug" element={<LandingPage />} /> -
Check slug in URL:
- Slug is case-sensitive:
/p/About-Us≠/p/about-us - Use lowercase, hyphenated:
/p/about-us
- Slug is case-sensitive:
-
Test API directly:
curl http://localhost:4000/api/pages/about-us/view # Should return JSON, not 404 -
Check published status:
SELECT slug, published FROM landing_pages WHERE slug = 'about-us'; -- published should be true
Problem: Mobile Warning Shows on Desktop
Symptoms:
- "Desktop Required" warning displays on 1920px screen
- Editor won't load
Causes:
- Browser window width < 768px
- Breakpoint detection failure
- DevTools docked (reduces viewport width)
Solutions:
-
Check actual viewport width:
// In browser console console.log(window.innerWidth); // Should be > 768 for desktop -
Undock DevTools:
- Press F12 → Click ⋮ (three dots) → Dock to right/bottom → Undock
- Increases available viewport width
-
Verify breakpoint hook:
// In PageEditorPage.tsx const screens = Grid.useBreakpoint(); const isMobile = !screens.md; // md = 768px -
Test responsive mode:
- F12 → Toggle device toolbar (Ctrl+Shift+M)
- Select "Responsive" → Set width to 1024px
Problem: MkDocs Export Not Found
Symptoms:
- MkDocs site shows 404 for
/pages/about-us/ - Override file missing from
mkdocs/docs/overrides/
Causes:
- Page not published
mkdocsSkipExport=true- Export path incorrect
- MkDocs not rebuilt
Solutions:
-
Verify publish status:
SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us'; -- Both should be true/false appropriately -
Check export path:
ls -la mkdocs/docs/overrides/about.html # Should exist if published and not skipped -
Validate exports:
- Admin → Pages → "Validate Exports" button
- Check repair count
-
Rebuild MkDocs:
docker compose exec mkdocs mkdocs build # Or in admin: Pages → "Build Site" -
Check template path in stub:
cat mkdocs/docs/about.md # Should show: template: about.html (NOT overrides/about.html)
Problem: Slug Collision on Create
Symptoms:
- Create page with title "About Us" → slug becomes
about-us-2 - Expected
about-usbut already taken
Causes:
- Existing page with same slug (possibly unpublished)
- Soft-deleted page (if soft delete implemented)
Solutions:
-
Check existing pages:
SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%'; -
Delete duplicate:
- If old page is unwanted: Admin → Pages → Delete
- New page can reuse slug
-
Use unique title:
- Rename new page: "About Us 2026" → slug
about-us-2026
- Rename new page: "About Us 2026" → slug
-
Manual slug override:
- After create: Edit page → Settings → Override Path →
about-us-custom.html
- After create: Edit page → Settings → Override Path →
Problem: Video Block Not Hydrating
Symptoms:
- Video placeholder shows on published page
- No player renders
- Console error:
Invalid video ID: PLACEHOLDER
Causes:
data-video-id="PLACEHOLDER"not replaced- Video ID not numeric
- Hydration script not running
Solutions:
-
Check video ID in editor:
- Open GrapesJS editor → Select video block
- Properties panel → Video ID field should be numeric (e.g.,
123) - Not
PLACEHOLDER
-
Verify HTML output:
<!-- Bad --> <div class="video-block" data-video-id="PLACEHOLDER">...</div> <!-- Good --> <div class="video-block" data-video-id="42">...</div> -
Check hydration script:
// In LandingPage.tsx useEffect(() => { // Should scan for .video-block elements const videoBlocks = contentRef.current?.querySelectorAll('.video-block'); console.log('Found video blocks:', videoBlocks?.length); }, [page]); -
Test video ID validity:
curl http://localhost:4100/api/media/videos/42 # Should return video metadata, not 404
Performance Considerations
Editor Initialization
GrapesJS startup: ~500ms on modern desktop
Optimization strategies:
- Lazy load GrapesJS:
const GrapesJS = lazy(() => import('./GrapesJSEditor')) - Show loading spinner during init
- Preload on hover over "Edit" button
Large Pages
Complexity threshold: 100+ components
Symptoms:
- Laggy drag-and-drop
- Slow save operations
- Canvas rendering delay
Mitigations:
- Break into multiple pages (split hero + sections)
- Use CODE mode for complex layouts
- Minimize nested components
htmlOutput Storage
Database overhead: htmlOutput can be 50KB+ for complex pages
Considerations:
- Indexed by
publishedfor public queries (fast) - Not indexed by content (no full-text search on HTML)
- Consider external storage for very large pages (future enhancement)
Public Page Rendering
React hydration: Video blocks hydrate after initial render (~100ms delay)
Performance tips:
- Use
dangerouslySetInnerHTMLfor immediate HTML paint - Defer video hydration to
setTimeout(..., 100) - Preload video metadata for above-fold players
Security Considerations
Admin-Authored HTML
Risk: XSS via malicious HTML in editor
Mitigation:
- Accepted risk: Only admins can create/edit pages (trusted users)
- No user-supplied content: Public users cannot edit landing pages
- Authentication required: All write endpoints require admin role
Comment in code:
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
return <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />;
Slug Validation
Attack vector: Path traversal via slug injection
Protection:
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Alphanumeric + hyphens only
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
.slice(0, 80); // Max 80 chars
}
Safe slugs: about-us, campaign-2026, contact
Rejected: ../etc/passwd, <script>alert(1)</script>, ../../admin
MkDocs Path Validation
Attack vector: Write arbitrary files via path traversal in mkdocsPath
Protection:
function validateMkdocsPath(mkdocsPath: string): void {
if (mkdocsPath.includes('\0')) throw new Error('Null byte detected');
const normalized = path.normalize(mkdocsPath);
if (normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Path traversal not allowed');
}
if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
throw new Error('Encoded path traversal not allowed');
}
if (!mkdocsPath.endsWith('.html')) {
throw new Error('Path must end with .html');
}
}
Safe paths: about.html, pages/contact.html
Rejected: ../../../etc/passwd.html, /etc/shadow.html, %2e%2e/admin.html
Published Flag Enforcement
Attack vector: Access draft pages via public route
Protection:
// In pagesService.findBySlugPublic()
if (!page || !page.published) {
throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');
}
Behavior:
- Unpublished pages return 404 on public route
- Admin routes bypass check (can view drafts)
Related Documentation
Frontend Components
- LandingPageEditor — Full-screen editor wrapper
- LandingPagesPage — Table view + CRUD
- GrapesJSEditor — GrapesJS wrapper with forwardRef
- PublicLandingPage — Public page renderer
Backend Modules
- pages-admin.routes — Admin CRUD endpoints
- pages-public.routes — Public view endpoint
- pages.service — Business logic + MkDocs export
- pages.schemas — Zod validation schemas
Database
- LandingPage Model — Schema + relationships
- PageBlock Model — Block library schema
Feature Documentation
- GrapesJS Editor Integration — forwardRef pattern + custom blocks
- Block Library — Reusable components system
- MkDocs Export — Material theme integration
External Resources
- GrapesJS Documentation — Official editor docs
- GrapesJS Plugins — Available plugins
- MkDocs Material — Theme docs
- Jinja2 Templates — Template syntax