1435 lines
35 KiB
Markdown
1435 lines
35 KiB
Markdown
# 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 <token>" \
|
|
"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": "<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:**
|
|
|
|
```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 <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:**
|
|
|
|
```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": "<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):
|
|
```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 <token>" \
|
|
"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 <token>" \
|
|
"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 <token>" \
|
|
"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: '<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:**
|
|
|
|
```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 %}
|
|
<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:
|
|
|
|
```html
|
|
<!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:**
|
|
|
|
```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<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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:**
|
|
```bash
|
|
ls mkdocs/overrides/about-us.html
|
|
```
|
|
|
|
2. **Check stub file exists:**
|
|
```bash
|
|
ls mkdocs/docs/about-us.md
|
|
```
|
|
|
|
3. **Check stub front matter:**
|
|
```bash
|
|
cat mkdocs/docs/about-us.md
|
|
```
|
|
|
|
Verify `template:` points to override filename (not path):
|
|
```yaml
|
|
template: about-us.html # Correct
|
|
template: overrides/about-us.html # WRONG — causes TemplateNotFound
|
|
```
|
|
|
|
4. **Check MkDocs logs:**
|
|
```bash
|
|
docker compose logs -f mkdocs
|
|
```
|
|
|
|
**Solutions:**
|
|
|
|
- **Missing files:** Run validate endpoint to repair:
|
|
```bash
|
|
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:
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```sql
|
|
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:
|
|
```bash
|
|
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:
|
|
```sql
|
|
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:
|
|
|
|
```bash
|
|
cat mkdocs/docs/about-us.md
|
|
```
|
|
|
|
**Common Mistakes:**
|
|
|
|
```yaml
|
|
# 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:
|
|
|
|
```bash
|
|
curl -X POST -H "Authorization: Bearer <token>" \
|
|
http://api.cmlite.org/api/pages/validate
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Frontend: LandingPagesPage](/v2/frontend/pages/admin/landing-pages-page.md) - Landing page manager UI
|
|
- [Frontend: PageEditorPage](/v2/frontend/pages/admin/page-editor-page.md) - GrapesJS editor wrapper
|
|
- [Frontend: Public Landing Page](/v2/frontend/pages/public/landing-page.md) - Public renderer
|
|
- [Features: Landing Page Builder](/v2/features/landing-pages/overview.md) - Complete feature guide
|
|
- [MkDocs Integration](/v2/deployment/mkdocs-integration.md) - MkDocs export system
|
|
- [API Reference: Pages](/v2/api-reference/pages.md) - Complete endpoint reference
|
|
- [User Guide: Content Editor](/v2/user-guides/content-editor-guide.md) - Creating landing pages
|
|
- [Troubleshooting: MkDocs Issues](/v2/troubleshooting/mkdocs-issues.md) - MkDocs debugging guide
|