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 .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

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:

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 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):

{
  "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):

    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:

    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:

    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:

  1. Scan mkdocs/overrides/ directory for .html files:

    const files = await scanOverrideFiles(MKDOCS_OVERRIDES);
    // Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]
    
  2. 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++;
    }
    
  3. 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)
    
  4. 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 .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:

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:

  1. Query all published pages with mkdocsSkipExport === false:

    const pages = await prisma.landingPage.findMany({
      where: {
        published: true,
        mkdocsSkipExport: false,
        mkdocsPath: { not: null },
        htmlOutput: { not: null },
      },
    });
    
  2. Check override HTML exists:

    const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);
    await fs.access(overridePath);  // Throws if missing
    
  3. Check .md stub exists:

    const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);
    const stubExists = await stubExistsOnDisk(expectedStubPath);
    
  4. 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:

  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:

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 to custom_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: VISUAL
  • blocks: {}
  • mkdocsExportMode: THEMED
  • mkdocsHideNav: true
  • mkdocsHideToc: true
  • mkdocsSkipExport: false
  • published: 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 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:

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:

  1. Check override file exists:

    ls mkdocs/overrides/about-us.html
    
  2. Check stub file exists:

    ls mkdocs/docs/about-us.md
    
  3. Check stub front matter:

    cat mkdocs/docs/about-us.md
    

    Verify template: points to override filename (not path):

    template: about-us.html  # Correct
    template: overrides/about-us.html  # WRONG — causes TemplateNotFound
    
  4. 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 htmlOutput from 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 editorMode to VISUAL if 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