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:

  1. Admin creates page via LandingPagesPage
  2. Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
  3. Admin drags blocks, configures properties, saves (Ctrl+S)
  4. API stores projectData (GrapesJS JSON), htmlOutput, cssOutput
  5. On publish: API exports .html override + .md stub to MkDocs
  6. 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 number
  • limit (number, default: 20, max: 100) — Results per page
  • search (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 description
  • editorMode (enum?, default: VISUAL) — VISUAL or CODE
  • mkdocsPath (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.)
  • blocks initialized as empty JSON object
  • published defaults to false

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?) — GrapesJS projectData
  • htmlOutput (string?) — Rendered HTML
  • cssOutput (string?) — Rendered CSS
  • published (boolean?) — Publish status
  • mkdocsPath (string?) — Custom override path
  • mkdocsExportMode (enum?) — THEMED or STANDALONE
  • mkdocsHideNav (boolean?)
  • mkdocsHideToc (boolean?)
  • mkdocsSkipExport (boolean?)
  • seoTitle (string?)
  • seoDescription (string?)
  • seoImage (string?)

Response: Updated LandingPage object

Errors:

  • 404 PAGE_NOT_FOUND — Page doesn't exist
  • 400 INVALID_MKDOCS_PATH — Invalid path

Side Effects:

  • On publish (published=true, mkdocsSkipExport=false): Exports to MkDocs (writes .html + .md stub)
  • On unpublish or mkdocsSkipExport=true: Removes MkDocs files
  • On title change: Regenerates slug, updates mkdocsPath if 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 (.html override + .md stub) 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:

  1. Scans mkdocs/docs/overrides/ recursively for .html files
  2. For untracked files: Creates new CODE-mode page (published=true)
  3. For tracked CODE-mode pages: Updates htmlOutput from disk (disk wins)
  4. For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
  5. Backfills missing .md stubs 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:

  1. Queries all published, non-skipped pages with mkdocsPath
  2. Checks if .html override and .md stub exist
  3. Re-exports if either missing
  4. Updates mkdocsStubPath if changed
  5. 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

  1. Navigate: Admin sidebar → Pages
  2. Click: "Create Page" button
  3. Fill form:
    • Title: "About Us" (slug auto-generated: about-us)
    • Description: "Learn about our campaign" (optional)
    • Editor Mode: VISUAL (default) or CODE
  4. Submit: "Create & Edit" button
  5. Result: Redirected to full-screen editor

Visual Editing (VISUAL Mode)

  1. 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)
  2. Add blocks: Drag "Hero Section" from left panel to canvas
  3. Configure: Click hero → Edit title/subtitle/CTA in right panel
  4. Save: Press Ctrl+S (or Cmd+S on Mac) → API saves projectData, htmlOutput, cssOutput
  5. Close: Click "X" or "Back to Pages" → Returns to table

Code Editing (CODE Mode)

  1. Editor opens: Split-view Monaco editors:
    • Left: HTML editor
    • Right: CSS editor (optional)
  2. Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)
  3. Save: Press Ctrl+S → API saves htmlOutput, cssOutput
  4. Close: Click "Back to Pages"

Publishing a Page

Option 1: From Table

  1. Locate page in table
  2. Click "Publish" button in Actions column
  3. Status tag changes: Draft → Published
  4. Page accessible at /p/{slug}

Option 2: From Settings Modal

  1. Click gear icon (Settings) in Actions column
  2. Settings modal opens
  3. (Field not shown in modal — use table toggle)

Side Effects (on publish):

  • If mkdocsSkipExport=false: Exports .html + .md to MkDocs
  • If mkdocsSkipExport=true: Only accessible via /p/:slug (no MkDocs export)

Configuring SEO

  1. Click gear icon (Settings) in Actions column
  2. Fill SEO section:
    • SEO Title: Custom title for <title> and Open Graph (defaults to title)
    • SEO Description: Meta description for search engines
    • SEO Image: Full URL to Open Graph image (e.g., https://cdn.example.com/og.jpg)
  3. Click "Save"
  4. Re-export to MkDocs if already published

MkDocs Integration Settings

Access: Page Settings modal → MkDocs Integration section

Fields:

  1. 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)
  2. 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
  3. 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)
  4. Hide navigation sidebar (checkbox, only for THEMED mode)

    • Adds hide: [navigation] to .md stub front matter
    • Hides left sidebar on page
    • Default: false
  5. Hide table of contents (checkbox, only for THEMED mode)

    • Adds hide: [toc] to .md stub front matter
    • Hides right sidebar on page
    • Default: false

Workflow:

  1. Edit page settings
  2. Configure MkDocs options
  3. Save settings
  4. If published: API auto-exports with new settings
  5. Rebuild MkDocs: Admin → Pages → "Build Site" button

Syncing Overrides

Purpose: Import hand-coded .html files from disk

Workflow:

  1. Place .html files in mkdocs/docs/overrides/ (on Docker host)
  2. Admin → Pages → "Sync Overrides" button
  3. API scans directory, imports new files as CODE-mode pages
  4. Table refreshes, new pages appear
  5. 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:

  1. Admin → Pages → "Validate Exports" button
  2. API checks all published pages:
    • .html override exists?
    • .md stub exists?
  3. Re-exports if either missing
  4. 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

  1. User navigates: https://yoursite.com/p/about-us
  2. React router: Matches /p/:slug route → Loads LandingPage.tsx
  3. API call: GET /api/pages/about-us/view
  4. Response: Returns htmlOutput, cssOutput, SEO fields
  5. Render:
    • Sets document.title = seoTitle || title
    • Updates meta description, Open Graph image
    • Injects cssOutput as <style> tag
    • Renders htmlOutput via dangerouslySetInnerHTML
  6. Video hydration: Scans for .video-block divs, 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:

  1. LandingPage.tsx mounts → Scans for .video-block elements
  2. Reads data-* attributes
  3. Creates React root for each block
  4. Renders AdvancedVideoPlayer or VideoPlayer component
  5. Replaces placeholder with live player

Supported Attributes:

  • data-video-id (required) — Media library video ID
  • data-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:

  1. Verify installation:

    cd admin && npm list grapesjs
    # Should show: grapesjs@0.21.x
    
  2. Check CSS import:

    // In GrapesJSEditor.tsx
    import 'grapesjs/dist/css/grapes.min.css';
    
  3. Check browser console:

    • Look for grapesjs variable in global scope
    • Verify all plugins loaded successfully
  4. 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:

  1. Verify route registration:

    // In admin/src/App.tsx
    <Route path="/p/:slug" element={<LandingPage />} />
    
  2. Check slug in URL:

    • Slug is case-sensitive: /p/About-Us/p/about-us
    • Use lowercase, hyphenated: /p/about-us
  3. Test API directly:

    curl http://localhost:4000/api/pages/about-us/view
    # Should return JSON, not 404
    
  4. 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:

  1. Check actual viewport width:

    // In browser console
    console.log(window.innerWidth);
    // Should be > 768 for desktop
    
  2. Undock DevTools:

    • Press F12 → Click ⋮ (three dots) → Dock to right/bottom → Undock
    • Increases available viewport width
  3. Verify breakpoint hook:

    // In PageEditorPage.tsx
    const screens = Grid.useBreakpoint();
    const isMobile = !screens.md; // md = 768px
    
  4. 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:

  1. Verify publish status:

    SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';
    -- Both should be true/false appropriately
    
  2. Check export path:

    ls -la mkdocs/docs/overrides/about.html
    # Should exist if published and not skipped
    
  3. Validate exports:

    • Admin → Pages → "Validate Exports" button
    • Check repair count
  4. Rebuild MkDocs:

    docker compose exec mkdocs mkdocs build
    # Or in admin: Pages → "Build Site"
    
  5. 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-us but already taken

Causes:

  • Existing page with same slug (possibly unpublished)
  • Soft-deleted page (if soft delete implemented)

Solutions:

  1. Check existing pages:

    SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';
    
  2. Delete duplicate:

    • If old page is unwanted: Admin → Pages → Delete
    • New page can reuse slug
  3. Use unique title:

    • Rename new page: "About Us 2026" → slug about-us-2026
  4. Manual slug override:

    • After create: Edit page → Settings → Override Path → about-us-custom.html

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:

  1. Check video ID in editor:

    • Open GrapesJS editor → Select video block
    • Properties panel → Video ID field should be numeric (e.g., 123)
    • Not PLACEHOLDER
  2. Verify HTML output:

    <!-- Bad -->
    <div class="video-block" data-video-id="PLACEHOLDER">...</div>
    
    <!-- Good -->
    <div class="video-block" data-video-id="42">...</div>
    
  3. 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]);
    
  4. 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 published for 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 dangerouslySetInnerHTML for 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)

Frontend Components

Backend Modules

Database

Feature Documentation

External Resources