# Pages Module (Landing Page Builder) ## Overview The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (`/p/:slug`), and optionally export them to the MkDocs documentation site as Material theme overrides. **Key Features:** - **Dual editor modes:** - **VISUAL** — GrapesJS drag-and-drop WYSIWYG editor with custom blocks - **CODE** — Direct HTML editing for advanced users - **Automatic slug generation** from titles (collision-safe) - **MkDocs export system:** - Exports pages to `mkdocs/overrides/` directory - Creates `.md` stub files with front matter for MkDocs Material - Two export modes: THEMED (Jinja2 extends main.html) or STANDALONE (full HTML document) - Configurable nav/TOC hiding via Material theme front matter - **Reusable block library** (hero, text, image, CTA, features, testimonials, form) - **SEO metadata** (title, description, image) - **Public rendering** at `/p/:slug` route - **Sync & validation** tools for managing MkDocs exports - **Path traversal protection** (null bytes, `..`, encoded sequences) - **Published/draft workflow** ## File Paths | File | Purpose | |------|---------| | `api/src/modules/pages/pages-admin.routes.ts` | Admin router with 7 endpoints (114 lines) | | `api/src/modules/pages/pages-public.routes.ts` | Public router (1 endpoint, 21 lines) | | `api/src/modules/pages/blocks.routes.ts` | Block library router (5 endpoints, 88 lines) | | `api/src/modules/pages/pages.service.ts` | Landing page business logic + MkDocs export (637 lines) | | `api/src/modules/pages/blocks.service.ts` | Block CRUD service (89 lines) | | `api/src/modules/pages/pages.schemas.ts` | Zod validation schemas (83 lines) | ## Database Models ```prisma model LandingPage { id String @id @default(cuid()) slug String @unique title String description String? @db.Text blocks Json // JSON from GrapesJS editor htmlOutput String? @db.Text cssOutput String? @db.Text editorMode EditorMode @default(VISUAL) mkdocsPath String? // Path in mkdocs/overrides/ mkdocsStubPath String? // Path to .md stub in mkdocs/docs/ mkdocsExportMode MkdocsExportMode @default(THEMED) mkdocsHideNav Boolean @default(true) mkdocsHideToc Boolean @default(true) mkdocsSkipExport Boolean @default(false) published Boolean @default(false) seoTitle String? seoDescription String? @db.Text seoImage String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("landing_pages") } enum EditorMode { VISUAL // GrapesJS drag-and-drop editor CODE // Direct HTML editing } enum MkdocsExportMode { THEMED // Jinja2 extends main.html (Material theme integration) STANDALONE // Full HTML document (no Jinja2 inheritance) } model PageBlock { id String @id @default(cuid()) type String // hero, text, image, cta, features, testimonials, form label String schema Json // Block configuration schema (GrapesJS component definition) defaults Json // Default values for new instances thumbnail String? category String? sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("page_blocks") } ``` **Key Fields:** - **`blocks`** — GrapesJS JSON state (saved on Ctrl+S in editor) - **`htmlOutput`** — Rendered HTML (generated by GrapesJS or manually entered in CODE mode) - **`cssOutput`** — Extracted CSS (from GrapesJS styles or manual entry) - **`mkdocsPath`** — Relative path in `mkdocs/overrides/` (e.g., `landing-page.html`) - **`mkdocsStubPath`** — Relative path to `.md` stub (e.g., `landing-page.md`) - **`mkdocsExportMode`** — THEMED (Jinja2) or STANDALONE (full HTML) - **`mkdocsSkipExport`** — Skip MkDocs export (for internal pages only accessible via `/p/:slug`) **Slug Generation:** ```typescript function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with - .replace(/^-+|-+$/g, '') // Remove leading/trailing - .slice(0, 80); // Max 80 chars } ``` **Example Transformations:** - `"Landing Page"` → `landing-page` - `"About Us — Contact Info"` → `about-us-contact-info` - `"Landing Page"` (duplicate) → `landing-page-2` ## API Endpoints ### Admin Endpoints (Authentication Required) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/pages` | Admin roles | List landing pages with pagination/filters | | GET | `/api/pages/:id` | Admin roles | Get single landing page | | POST | `/api/pages` | Admin roles | Create landing page | | PUT | `/api/pages/:id` | Admin roles | Update landing page (triggers MkDocs export) | | DELETE | `/api/pages/:id` | Admin roles | Delete landing page (removes MkDocs export) | | POST | `/api/pages/sync` | Admin roles | Sync MkDocs overrides to database | | POST | `/api/pages/validate` | Admin roles | Validate and repair MkDocs exports | **Admin Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN` ### Block Library Endpoints (Admin Only) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/page-blocks` | Admin roles | List blocks with category filter | | GET | `/api/page-blocks/:id` | Admin roles | Get single block | | POST | `/api/page-blocks` | Admin roles | Create block | | PUT | `/api/page-blocks/:id` | Admin roles | Update block | | DELETE | `/api/page-blocks/:id` | Admin roles | Delete block | ### Public Endpoints (No Authentication) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/pages/:slug/view` | None | Get published page by slug | ## Admin Endpoint Details ### GET /api/pages List landing pages with pagination, search, and filtering. **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | page | number | No | 1 | Page number | | limit | number | No | 20 | Results per page (max 100) | | search | string | No | - | Search title, description, or slug | | published | enum | No | - | Filter by status: `'true'`, `'false'` | **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about" ``` **Response (200 OK):** ```json { "pages": [ { "id": "clx1234567890", "slug": "about-us", "title": "About Us", "description": "Learn about our organization", "editorMode": "VISUAL", "blocks": { "assets": [], "pages": [/* GrapesJS page structure */], "styles": [/* GrapesJS styles */] }, "htmlOutput": "
...
", "cssOutput": ".hero { background: #3498db; }", "mkdocsPath": "about-us.html", "mkdocsStubPath": "about-us.md", "mkdocsExportMode": "THEMED", "mkdocsHideNav": true, "mkdocsHideToc": true, "mkdocsSkipExport": false, "published": true, "seoTitle": "About Us — Changemaker Lite", "seoDescription": "Learn about our mission and values", "seoImage": "https://example.com/og-image.jpg", "createdAt": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-11T14:30:00.000Z" } ], "pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 } } ``` **Search Behavior:** ```typescript if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, { slug: { contains: search, mode: 'insensitive' } }, ]; } ``` --- ### GET /api/pages/:id Get single landing page with full editor state. **Path Parameters:** - `id` (string): Landing page ID **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/pages/clx1234567890" ``` **Response (200 OK):** Returns full landing page object (same format as GET list). **Error Responses:** - `404 Not Found`: Page not found --- ### POST /api/pages Create landing page with auto-generated slug. **Request Body:** ```json { "title": "About Us", "description": "Learn about our organization", "editorMode": "VISUAL", "blocks": {}, "htmlOutput": null, "cssOutput": null, "mkdocsExportMode": "THEMED", "mkdocsHideNav": true, "mkdocsHideToc": true, "published": false, "seoTitle": "About Us — Changemaker Lite", "seoDescription": "Learn about our mission and values", "seoImage": "https://example.com/og-image.jpg" } ``` **Response (201 Created):** Returns created landing page object. **Auto-Generated Fields:** - **`slug`** — Generated from `title` (collision-safe) - **`mkdocsPath`** — Defaults to `${slug}.html` if not provided **Validation:** - `title` is required - `mkdocsPath` must end with `.html` - `mkdocsPath` must not contain path traversal sequences (`..`, null bytes, encoded traversal) --- ### PUT /api/pages/:id Update landing page. Triggers MkDocs export if published. **Request Body (Partial):** ```json { "htmlOutput": "
Updated content
", "cssOutput": ".hero { background: #e74c3c; }", "published": true } ``` **Response (200 OK):** Returns updated landing page object. **Side Effects:** 1. **Slug regeneration** if title changes (preserves old slug if collision): ```typescript if (data.title && data.title !== existing.title) { const baseSlug = generateSlug(data.title); const newSlug = await resolveSlugCollision(baseSlug, id); updateData.slug = newSlug; // Update mkdocsPath if auto-generated if (existing.mkdocsPath === `${existing.slug}.html`) { updateData.mkdocsPath = `${newSlug}.html`; await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath); } } ``` 2. **MkDocs export** if `published === true && mkdocsSkipExport === false`: ```typescript if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) { const stubPath = await exportToMkDocs({ mkdocsPath: page.mkdocsPath, html: page.htmlOutput, css: page.cssOutput, editorMode: page.editorMode, exportMode: page.mkdocsExportMode, title: page.title, seoTitle: page.seoTitle, seoDescription: page.seoDescription, hideNav: page.mkdocsHideNav, hideToc: page.mkdocsHideToc, }); } ``` 3. **MkDocs cleanup** if `published === false || mkdocsSkipExport === true`: ```typescript await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath); ``` **Export Workflow:** ```mermaid graph TD A[Update Landing Page] --> B{Published?} B -->|No| C[Remove MkDocs Export] B -->|Yes| D{Skip Export?} D -->|Yes| C D -->|No| E{Has HTML Output?} E -->|No| F[No Action] E -->|Yes| G[Export to MkDocs] G --> H[Write Override HTML] H --> I[Write .md Stub] I --> J[Update stubPath in DB] ``` --- ### DELETE /api/pages/:id Delete landing page and remove MkDocs export. **Path Parameters:** - `id` (string): Landing page ID **Response (204 No Content):** No response body. **Side Effects:** - Removes MkDocs override HTML file (`mkdocs/overrides/{mkdocsPath}`) - Removes .md stub file (`mkdocs/docs/{mkdocsStubPath}`) --- ### POST /api/pages/sync Sync MkDocs override files to database (import untracked files, update CODE pages). **Example Request:** ```bash curl -X POST \ -H "Authorization: Bearer " \ "http://api.cmlite.org/api/pages/sync" ``` **Response (200 OK):** ```json { "imported": 2, "updated": 1, "stubs": 3 } ``` **Behavior:** 1. **Scan `mkdocs/overrides/` directory** for `.html` files: ```typescript const files = await scanOverrideFiles(MKDOCS_OVERRIDES); // Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...] ``` 2. **Import untracked files** as CODE pages: ```typescript if (!tracked) { // New file not in database const title = path.basename(file.relativePath, '.html'); const baseSlug = generateSlug(title); const slug = await resolveSlugCollision(baseSlug); await prisma.landingPage.create({ data: { slug, title, editorMode: 'CODE', htmlOutput: content, mkdocsPath: file.relativePath, published: true, blocks: {}, }, }); imported++; } ``` 3. **Update CODE pages from disk** (disk wins): ```typescript else if (tracked.editorMode === 'CODE') { // Tracked CODE page — sync from disk await prisma.landingPage.update({ where: { id: tracked.id }, data: { htmlOutput: content }, }); updated++; } // VISUAL pages: don't overwrite from disk (managed by GrapesJS) ``` 4. **Backfill missing .md stubs** for published pages: ```typescript for (const page of existingPages) { if (!page.published || !page.mkdocsPath) continue; const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath); const exists = await stubExistsOnDisk(expectedStubPath); if (!exists) { await writeStubFile(expectedStubPath, stubContent); stubs++; } } ``` **Use Cases:** - **Manual file creation** — Admin creates `.html` file directly in `mkdocs/overrides/`, then syncs to database - **Git pull** — After pulling changes that add override files, sync to database - **Stub recovery** — Re-create missing `.md` stub files --- ### POST /api/pages/validate Validate MkDocs exports and repair missing files. **Example Request:** ```bash curl -X POST \ -H "Authorization: Bearer " \ "http://api.cmlite.org/api/pages/validate" ``` **Response (200 OK):** ```json { "validated": 10, "repaired": 2, "errors": [ { "pageId": "clx1234567890", "slug": "broken-page", "error": "ENOENT: no such file or directory" } ] } ``` **Behavior:** 1. **Query all published pages** with `mkdocsSkipExport === false`: ```typescript const pages = await prisma.landingPage.findMany({ where: { published: true, mkdocsSkipExport: false, mkdocsPath: { not: null }, htmlOutput: { not: null }, }, }); ``` 2. **Check override HTML exists**: ```typescript const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath); await fs.access(overridePath); // Throws if missing ``` 3. **Check .md stub exists**: ```typescript const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath); const stubExists = await stubExistsOnDisk(expectedStubPath); ``` 4. **Repair if either missing**: ```typescript if (!overrideExists || !stubExists) { await exportToMkDocs({ mkdocsPath: page.mkdocsPath, html: page.htmlOutput, css: page.cssOutput, // ... }); repaired++; } ``` **Use Cases:** - **Missing exports after deploy** — MkDocs volume lost, re-export all pages - **Manual deletion** — Admin accidentally deleted override file, repair from database - **Health check** — Verify all published pages have correct exports --- ## Block Library Endpoint Details ### GET /api/page-blocks List blocks with optional category filter. **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | category | string | No | Filter by category | **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/page-blocks?category=hero" ``` **Response (200 OK):** ```json [ { "id": "clx1234567890", "type": "hero", "label": "Hero Section", "schema": { "type": "div", "classes": ["hero"], "attributes": { "data-gjs-type": "hero" }, "components": [/* ... */], "traits": [ { "type": "text", "name": "heading", "label": "Heading" }, { "type": "text", "name": "subheading", "label": "Subheading" } ] }, "defaults": { "heading": "Welcome to our site", "subheading": "Your journey starts here" }, "thumbnail": "https://example.com/hero-thumb.jpg", "category": "hero", "sortOrder": 1, "createdAt": "2026-01-15T12:00:00.000Z", "updatedAt": "2026-01-20T10:00:00.000Z" } ] ``` **Sort Order:** Blocks are sorted by `sortOrder` ASC. Lower numbers appear first in block library panel. --- ### POST /api/page-blocks Create block. **Request Body:** ```json { "type": "hero", "label": "Hero Section", "schema": { "type": "div", "classes": ["hero"], "attributes": { "data-gjs-type": "hero" } }, "defaults": { "heading": "Welcome", "subheading": "Your journey starts here" }, "thumbnail": "https://example.com/hero-thumb.jpg", "category": "hero", "sortOrder": 1 } ``` **Response (201 Created):** Returns created block object. --- ## Public Endpoint Details ### GET /api/pages/:slug/view Get published landing page by slug (no auth required). **Path Parameters:** - `slug` (string): Landing page slug **Example Request:** ```bash curl http://api.cmlite.org/api/pages/about-us/view ``` **Response (200 OK):** Returns full landing page object (same format as admin GET). **Filtering:** - Only returns pages with `published === true` - Throws 404 if page not found or not published **Error Responses:** - `404 Not Found`: Page not found or not published --- ## Service Functions ### pagesService.findAll(filters) List landing pages with pagination, search, and filtering. **Usage:** ```typescript import { pagesService } from './pages.service'; const result = await pagesService.findAll({ page: 1, limit: 20, search: 'about', published: 'true', }); console.log(result.pages.length); // Array of pages console.log(result.pagination); // { page, limit, total, totalPages } ``` --- ### pagesService.create(data) Create landing page with auto-generated slug and mkdocsPath. **Usage:** ```typescript const page = await pagesService.create({ title: 'About Us', description: 'Learn about our organization', editorMode: 'VISUAL', blocks: {}, published: false, }); console.log(page.slug); // 'about-us' console.log(page.mkdocsPath); // 'about-us.html' ``` --- ### pagesService.update(id, data) Update landing page with MkDocs export/cleanup side effects. **Usage:** ```typescript const page = await pagesService.update('clx1234567890', { htmlOutput: '
Updated
', cssOutput: '.hero { background: #e74c3c; }', published: true, }); // Side effect: Exports to mkdocs/overrides/{mkdocsPath} // Side effect: Creates .md stub in mkdocs/docs/{mkdocsStubPath} ``` **Export Trigger:** - Export happens if `published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutput` - Cleanup happens if `published === false || mkdocsSkipExport === true` --- ### pagesService.syncOverrides() Sync MkDocs override files to database. **Usage:** ```typescript const result = await pagesService.syncOverrides(); console.log(`Imported: ${result.imported}`); // New CODE pages imported console.log(`Updated: ${result.updated}`); // CODE pages synced from disk console.log(`Stubs: ${result.stubs}`); // Missing stubs created ``` **Workflow:** 1. Scan `mkdocs/overrides/` for `.html` files 2. Import untracked files as CODE pages 3. Update tracked CODE pages from disk (disk wins) 4. Don't overwrite VISUAL pages (managed by GrapesJS) 5. Backfill missing .md stubs --- ### pagesService.validateExports() Validate and repair MkDocs exports. **Usage:** ```typescript const result = await pagesService.validateExports(); console.log(`Validated: ${result.validated}`); // Pages checked console.log(`Repaired: ${result.repaired}`); // Missing exports repaired console.log(`Errors: ${result.errors.length}`); // Failed repairs ``` **Repair Logic:** ```typescript // Check override HTML exists const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath); const overrideExists = await fs.access(overridePath).then(() => true, () => false); // Check stub exists const stubExists = await stubExistsOnDisk(expectedStubPath); // Repair if either missing if (!overrideExists || !stubExists) { await exportToMkDocs({/* ... */}); repaired++; } ``` --- ## MkDocs Export System ### Export Modes **1. THEMED (Default)** Wraps HTML in Jinja2 template extending MkDocs Material theme: ```jinja2 {% extends "main.html" %} {% block content %} {{ html }} {% endblock %} ``` **Pros:** - Inherits Material theme navigation, footer, search - Consistent branding with main docs - Responsive out of the box **Cons:** - Limited control over layout - Must work within Material theme constraints **2. STANDALONE** Full HTML document without Jinja2 inheritance: ```html {{ seoTitle || title }} {{ html }} ``` **Pros:** - Full control over layout - No Material theme constraints - Custom navigation/footer **Cons:** - No Material theme features (search, nav, etc.) - Must implement responsive design - Separate branding --- ### .md Stub File Format The `.md` stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance. **Example:** ```markdown --- template: about-us.html hide: - navigation - toc title: "About Us — Changemaker Lite" description: "Learn about our mission and values" --- ``` **Front Matter Fields:** - **`template`** — Override filename (relative to `custom_dir`/overrides) - **`hide`** — Hide Material theme elements (`navigation`, `toc`) - **`title`** — Page title (SEO) - **`description`** — Page description (SEO) **Generation:** ```typescript function generateMdStub(opts: StubOptions): string { const hideItems: string[] = []; if (opts.hideNav) hideItems.push(' - navigation'); if (opts.hideToc) hideItems.push(' - toc'); const hideBlock = hideItems.length > 0 ? `hide:\n${hideItems.join('\n')}\n` : ''; const descLine = opts.description ? `description: "${opts.description.replace(/"/g, '\\"')}"\n` : ''; return `--- template: ${opts.overrideFilename} ${hideBlock}title: "${opts.title.replace(/"/g, '\\"')}" ${descLine}--- `; } ``` --- ### Path Validation All `mkdocsPath` values are validated to prevent path traversal attacks: ```typescript function validateMkdocsPath(mkdocsPath: string): void { // Check for null bytes if (mkdocsPath.includes('\0')) { throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH'); } // Normalize and check for traversal const normalized = path.normalize(mkdocsPath); if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH'); } // Check for encoded traversal sequences if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) { throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH'); } if (!mkdocsPath.endsWith('.html')) { throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH'); } } ``` **Blocked Patterns:** - Null bytes (`\0`) - Path traversal (`..`) - Absolute paths (`/etc/passwd`) - Encoded traversal (`%2e%2e/`, `%2E%2E/`) - Non-HTML files (must end with `.html`) --- ## Validation Schemas ### Create Landing Page Schema ```typescript export const createLandingPageSchema = z.object({ title: z.string().min(1, 'Title is required'), description: z.string().optional(), editorMode: z.enum(['VISUAL', 'CODE']).optional().default('VISUAL'), blocks: z.any().optional().default({}), htmlOutput: z.string().optional(), cssOutput: z.string().optional(), mkdocsPath: z.string().optional(), mkdocsExportMode: z.enum(['THEMED', 'STANDALONE']).optional().default('THEMED'), mkdocsHideNav: z.boolean().optional().default(true), mkdocsHideToc: z.boolean().optional().default(true), mkdocsSkipExport: z.boolean().optional().default(false), published: z.boolean().optional().default(false), seoTitle: z.string().optional(), seoDescription: z.string().optional(), seoImage: z.string().optional(), }); ``` **Defaults:** - `editorMode`: `VISUAL` - `blocks`: `{}` - `mkdocsExportMode`: `THEMED` - `mkdocsHideNav`: `true` - `mkdocsHideToc`: `true` - `mkdocsSkipExport`: `false` - `published`: `false` --- ### Create Page Block Schema ```typescript export const createPageBlockSchema = z.object({ type: z.string().min(1, 'Type is required'), label: z.string().min(1, 'Label is required'), schema: z.any().optional().default({}), defaults: z.any().optional().default({}), thumbnail: z.string().optional(), category: z.string().optional(), sortOrder: z.number().int().optional().default(0), }); ``` **Example Valid Input:** ```json { "type": "hero", "label": "Hero Section", "schema": { "type": "div", "classes": ["hero"] }, "defaults": { "heading": "Welcome" }, "category": "hero", "sortOrder": 1 } ``` --- ## Code Examples ### Admin: Create Landing Page ```typescript import { api } from '@/lib/api'; import { message } from 'antd'; const createPage = async () => { try { const { data } = await api.post('/api/pages', { title: 'About Us', description: 'Learn about our organization', editorMode: 'VISUAL', mkdocsExportMode: 'THEMED', mkdocsHideNav: true, mkdocsHideToc: true, published: false, seoTitle: 'About Us — Changemaker Lite', seoDescription: 'Learn about our mission and values', }); message.success(`Page created: ${data.slug}`); return data; } catch (error) { message.error('Failed to create page'); throw error; } }; ``` --- ### Admin: Publish Page (Triggers MkDocs Export) ```typescript import { api } from '@/lib/api'; import { message } from 'antd'; const publishPage = async (pageId: string, htmlOutput: string, cssOutput: string) => { try { const { data } = await api.put(`/api/pages/${pageId}`, { htmlOutput, cssOutput, published: true, }); message.success(`Page published and exported to MkDocs!`); return data; } catch (error) { message.error('Failed to publish page'); throw error; } }; ``` --- ### Admin: Sync MkDocs Overrides ```typescript import { api } from '@/lib/api'; import { message } from 'antd'; const syncOverrides = async () => { try { const { data } = await api.post('/api/pages/sync'); message.success( `Sync complete: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created` ); return data; } catch (error) { message.error('Failed to sync overrides'); throw error; } }; ``` --- ### Admin: Validate and Repair Exports ```typescript import { api } from '@/lib/api'; import { message } from 'antd'; const validateExports = async () => { try { const { data } = await api.post('/api/pages/validate'); if (data.errors.length > 0) { message.warning(`Validation complete: ${data.repaired} repaired, ${data.errors.length} errors`); } else { message.success(`Validation complete: ${data.validated} validated, ${data.repaired} repaired`); } return data; } catch (error) { message.error('Failed to validate exports'); throw error; } }; ``` --- ### Public: Render Landing Page ```typescript import axios from 'axios'; import { useParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; interface LandingPage { id: string; slug: string; title: string; htmlOutput: string; cssOutput: string | null; seoTitle: string | null; seoDescription: string | null; } const LandingPageRenderer = () => { const { slug } = useParams<{ slug: string }>(); const [page, setPage] = useState(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
Loading...
; if (!page) return
Page not found
; return ( <> {/* Inject CSS */} {page.cssOutput &&