From 0f50bf4a4154cc583a369d8bd5ed30d50475f89e Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 6 Feb 2026 18:36:23 +0000 Subject: [PATCH] Add custom markdown pages feature to web dashboard Allows adding static content pages (About, FAQ, etc.) as markdown files with YAML frontmatter. Pages are stored in PAGES_HOME directory (default: ./pages), automatically appear in navigation menu, and are included in the sitemap. - Add PageLoader class to parse markdown with frontmatter - Add /pages/{slug} route for rendering custom pages - Add PAGES_HOME config setting to WebSettings - Add prose CSS styles for markdown content - Add pages to navigation and sitemap - Update docker-compose.yml with pages volume mount - Add comprehensive tests for PageLoader and routes Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 22 + README.md | 58 ++- docker-compose.yml | 3 + example/pages/about.md | 29 ++ pyproject.toml | 4 + src/meshcore_hub/common/config.py | 13 + src/meshcore_hub/web/app.py | 22 + src/meshcore_hub/web/pages.py | 119 ++++++ src/meshcore_hub/web/routes/__init__.py | 2 + src/meshcore_hub/web/routes/pages.py | 36 ++ src/meshcore_hub/web/templates/base.html | 28 ++ src/meshcore_hub/web/templates/page.html | 15 + tests/test_web/test_pages.py | 488 +++++++++++++++++++++++ 13 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 example/pages/about.md create mode 100644 src/meshcore_hub/web/pages.py create mode 100644 src/meshcore_hub/web/routes/pages.py create mode 100644 src/meshcore_hub/web/templates/page.html create mode 100644 tests/test_web/test_pages.py diff --git a/AGENTS.md b/AGENTS.md index 3994e74..dafcd79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -455,6 +455,7 @@ See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list. Key variables: - `DATA_HOME` - Base directory for runtime data (default: `./data`) - `SEED_HOME` - Directory containing seed data files (default: `./seed`) +- `PAGES_HOME` - Directory containing custom markdown pages (default: `./pages`) - `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection - `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`) - `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys @@ -472,6 +473,27 @@ ${SEED_HOME}/ └── members.yaml # Network members list ``` +**Custom Pages (`PAGES_HOME`)** - Contains custom markdown pages for the web dashboard: +``` +${PAGES_HOME}/ +├── about.md # Example: About page (/pages/about) +├── faq.md # Example: FAQ page (/pages/faq) +└── getting-started.md # Example: Getting Started (/pages/getting-started) +``` + +Pages use YAML frontmatter for metadata: +```markdown +--- +title: About Us # Browser tab title and nav link (not rendered on page) +slug: about # URL path (default: filename without .md) +menu_order: 10 # Nav sort order (default: 100, lower = earlier) +--- + +# About Our Network + +Markdown content here (include your own heading)... +``` + **Runtime Data (`DATA_HOME`)** - Contains runtime data (gitignored): ``` ${DATA_HOME}/ diff --git a/README.md b/README.md index 0043073..594d306 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,55 @@ The collector automatically cleans up old event data and inactive nodes: | `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address | | `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link | | `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL | +| `PAGES_HOME` | `./pages` | Directory containing custom markdown pages | + +### Custom Pages + +The web dashboard supports custom markdown pages for adding static content like "About Us", "Getting Started", or "FAQ" pages. Pages are stored as markdown files with YAML frontmatter. + +**Setup:** +```bash +# Create pages directory +mkdir -p pages + +# Create a custom page +cat > pages/about.md << 'EOF' +--- +title: About Us +slug: about +menu_order: 10 +--- + +# About Our Network + +Welcome to our MeshCore mesh network! + +## Getting Started + +1. Get a compatible LoRa device +2. Flash MeshCore firmware +3. Configure your radio settings +EOF +``` + +**Frontmatter fields:** +| Field | Default | Description | +|-------|---------|-------------| +| `title` | Filename titlecased | Browser tab title and navigation link text (not rendered on page) | +| `slug` | Filename without `.md` | URL path (e.g., `about` → `/pages/about`) | +| `menu_order` | `100` | Sort order in navigation (lower = earlier) | + +The markdown content is rendered as-is, so include your own `# Heading` if desired. + +Pages automatically appear in the navigation menu and sitemap. With Docker, mount the pages directory: + +```yaml +# docker-compose.yml (already configured) +volumes: + - ${PAGES_HOME:-./pages}:/pages:ro +environment: + - PAGES_HOME=/pages +``` ## Seed Data @@ -542,10 +591,13 @@ meshcore-hub/ ├── alembic/ # Database migrations ├── etc/ # Configuration files (mosquitto.conf) ├── example/ # Example files for testing -│ └── seed/ # Example seed data files -│ ├── node_tags.yaml # Example node tags -│ └── members.yaml # Example network members +│ ├── seed/ # Example seed data files +│ │ ├── node_tags.yaml # Example node tags +│ │ └── members.yaml # Example network members +│ └── pages/ # Example custom pages +│ └── about.md # Example about page ├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/) +├── pages/ # Custom pages directory (PAGES_HOME, optional) ├── data/ # Runtime data directory (DATA_HOME, created at runtime) ├── Dockerfile # Docker build configuration ├── docker-compose.yml # Docker Compose services diff --git a/docker-compose.yml b/docker-compose.yml index 7724b14..88c7e30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -241,6 +241,8 @@ services: condition: service_healthy ports: - "${WEB_PORT:-8080}:8080" + volumes: + - ${PAGES_HOME:-./pages}:/pages:ro environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - API_BASE_URL=http://api:8000 @@ -258,6 +260,7 @@ services: - NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-} - NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-} - NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-} + - PAGES_HOME=/pages command: ["web"] healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] diff --git a/example/pages/about.md b/example/pages/about.md new file mode 100644 index 0000000..eeaa04b --- /dev/null +++ b/example/pages/about.md @@ -0,0 +1,29 @@ +--- +title: About +slug: about +menu_order: 10 +--- + +# About Our Network + +Welcome to our MeshCore mesh network! This page demonstrates the custom pages feature. + +## What is MeshCore? + +MeshCore is an open-source off-grid LoRa mesh networking platform. It enables peer-to-peer communication without relying on traditional internet or cellular infrastructure. + +## Our Mission + +Our community-operated network aims to: + +- Provide resilient communication during emergencies +- Enable outdoor enthusiasts to stay connected in remote areas +- Build a community of mesh networking enthusiasts + +## Getting Started + +To join our network, you'll need: + +1. A compatible LoRa device (T-Beam, Heltec, RAK, etc.) +2. MeshCore firmware installed +3. The correct radio configuration for our region diff --git a/pyproject.toml b/pyproject.toml index 937f706..757791c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "aiosqlite>=0.19.0", "meshcore>=2.2.0", "pyyaml>=6.0.0", + "python-frontmatter>=1.0.0", + "markdown>=3.5.0", ] [project.optional-dependencies] @@ -111,6 +113,8 @@ module = [ "uvicorn.*", "alembic.*", "meshcore.*", + "frontmatter.*", + "markdown.*", ] ignore_missing_imports = true diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 498b0d9..9022ca9 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -295,6 +295,19 @@ class WebSettings(CommonSettings): default=None, description="Welcome text for homepage" ) + # Custom pages directory + pages_home: Optional[str] = Field( + default=None, + description="Directory containing custom markdown pages (default: ./pages)", + ) + + @property + def effective_pages_home(self) -> str: + """Get the effective pages home directory.""" + from pathlib import Path + + return str(Path(self.pages_home or "./pages")) + @property def web_data_dir(self) -> str: """Get the web data directory path.""" diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index cec6d22..0e70dc8 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from meshcore_hub import __version__ from meshcore_hub.common.schemas import RadioConfig +from meshcore_hub.web.pages import PageLoader logger = logging.getLogger(__name__) @@ -126,6 +127,11 @@ def create_app( templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.state.templates = templates + # Initialize page loader for custom markdown pages + page_loader = PageLoader(settings.effective_pages_home) + page_loader.load_pages() + app.state.page_loader = page_loader + # Mount static files if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @@ -217,6 +223,17 @@ def create_app( except Exception as e: logger.warning(f"Failed to fetch nodes for sitemap: {e}") + # Add custom pages to sitemap + page_loader = request.app.state.page_loader + for page in page_loader.get_menu_pages(): + urls.append( + f" \n" + f" {base_url}{page.url}\n" + f" weekly\n" + f" 0.6\n" + f" " + ) + xml = ( '\n' '\n' @@ -260,6 +277,10 @@ def get_network_context(request: Request) -> dict: request.app.state.network_radio_config ) + # Get custom pages for navigation + page_loader = request.app.state.page_loader + custom_pages = page_loader.get_menu_pages() + return { "network_name": request.app.state.network_name, "network_city": request.app.state.network_city, @@ -270,5 +291,6 @@ def get_network_context(request: Request) -> dict: "network_contact_github": request.app.state.network_contact_github, "network_welcome_text": request.app.state.network_welcome_text, "admin_enabled": request.app.state.admin_enabled, + "custom_pages": custom_pages, "version": __version__, } diff --git a/src/meshcore_hub/web/pages.py b/src/meshcore_hub/web/pages.py new file mode 100644 index 0000000..3c60265 --- /dev/null +++ b/src/meshcore_hub/web/pages.py @@ -0,0 +1,119 @@ +"""Custom markdown pages loader for MeshCore Hub Web Dashboard.""" + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import frontmatter +import markdown + +logger = logging.getLogger(__name__) + + +@dataclass +class CustomPage: + """Represents a custom markdown page.""" + + slug: str + title: str + menu_order: int + content_html: str + file_path: str + + @property + def url(self) -> str: + """Get the URL path for this page.""" + return f"/pages/{self.slug}" + + +class PageLoader: + """Loads and manages custom markdown pages from a directory.""" + + def __init__(self, pages_dir: str) -> None: + """Initialize the page loader. + + Args: + pages_dir: Path to the directory containing markdown pages. + """ + self.pages_dir = Path(pages_dir) + self._pages: dict[str, CustomPage] = {} + self._md = markdown.Markdown( + extensions=["tables", "fenced_code", "toc"], + output_format="html", + ) + + def load_pages(self) -> None: + """Load all markdown pages from the pages directory.""" + self._pages.clear() + + if not self.pages_dir.exists(): + logger.debug(f"Pages directory does not exist: {self.pages_dir}") + return + + if not self.pages_dir.is_dir(): + logger.warning(f"Pages path is not a directory: {self.pages_dir}") + return + + for md_file in self.pages_dir.glob("*.md"): + try: + page = self._load_page(md_file) + if page: + self._pages[page.slug] = page + logger.info(f"Loaded custom page: {page.slug} ({md_file.name})") + except Exception as e: + logger.error(f"Failed to load page {md_file}: {e}") + + logger.info(f"Loaded {len(self._pages)} custom page(s)") + + def _load_page(self, file_path: Path) -> Optional[CustomPage]: + """Load a single markdown page. + + Args: + file_path: Path to the markdown file. + + Returns: + CustomPage instance or None if loading failed. + """ + content = file_path.read_text(encoding="utf-8") + post = frontmatter.loads(content) + + # Extract frontmatter fields + slug = post.get("slug", file_path.stem) + title = post.get("title", slug.replace("-", " ").replace("_", " ").title()) + menu_order = post.get("menu_order", 100) + + # Convert markdown to HTML + self._md.reset() + content_html = self._md.convert(post.content) + + return CustomPage( + slug=slug, + title=title, + menu_order=menu_order, + content_html=content_html, + file_path=str(file_path), + ) + + def get_page(self, slug: str) -> Optional[CustomPage]: + """Get a page by its slug. + + Args: + slug: The page slug. + + Returns: + CustomPage instance or None if not found. + """ + return self._pages.get(slug) + + def get_menu_pages(self) -> list[CustomPage]: + """Get all pages sorted by menu_order for navigation. + + Returns: + List of CustomPage instances sorted by menu_order. + """ + return sorted(self._pages.values(), key=lambda p: (p.menu_order, p.title)) + + def reload(self) -> None: + """Reload all pages from disk.""" + self.load_pages() diff --git a/src/meshcore_hub/web/routes/__init__.py b/src/meshcore_hub/web/routes/__init__.py index 3642457..defce5c 100644 --- a/src/meshcore_hub/web/routes/__init__.py +++ b/src/meshcore_hub/web/routes/__init__.py @@ -10,6 +10,7 @@ from meshcore_hub.web.routes.advertisements import router as advertisements_rout from meshcore_hub.web.routes.map import router as map_router from meshcore_hub.web.routes.members import router as members_router from meshcore_hub.web.routes.admin import router as admin_router +from meshcore_hub.web.routes.pages import router as pages_router # Create main web router web_router = APIRouter() @@ -23,5 +24,6 @@ web_router.include_router(advertisements_router) web_router.include_router(map_router) web_router.include_router(members_router) web_router.include_router(admin_router) +web_router.include_router(pages_router) __all__ = ["web_router"] diff --git a/src/meshcore_hub/web/routes/pages.py b/src/meshcore_hub/web/routes/pages.py new file mode 100644 index 0000000..2a23c65 --- /dev/null +++ b/src/meshcore_hub/web/routes/pages.py @@ -0,0 +1,36 @@ +"""Custom pages route for MeshCore Hub Web Dashboard.""" + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +router = APIRouter(tags=["Pages"]) + + +@router.get("/pages/{slug}", response_class=HTMLResponse) +async def custom_page(request: Request, slug: str) -> HTMLResponse: + """Render a custom markdown page. + + Args: + request: FastAPI request object. + slug: The page slug from the URL. + + Returns: + Rendered HTML page. + + Raises: + HTTPException: 404 if page not found. + """ + page_loader = request.app.state.page_loader + page = page_loader.get_page(slug) + + if not page: + raise HTTPException(status_code=404, detail=f"Page '{slug}' not found") + + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + context["page"] = page + + return templates.TemplateResponse("page.html", context) diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html index 9d33ee9..98f6ebb 100644 --- a/src/meshcore_hub/web/templates/base.html +++ b/src/meshcore_hub/web/templates/base.html @@ -61,6 +61,28 @@ text-overflow: ellipsis; white-space: nowrap; } + + /* Prose styling for custom markdown pages */ + .prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; } + .prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; } + .prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; } + .prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; } + .prose p { margin-bottom: 1rem; line-height: 1.75; } + .prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; } + .prose ul { list-style-type: disc; } + .prose ol { list-style-type: decimal; } + .prose li { margin-bottom: 0.25rem; } + .prose a { color: oklch(var(--p)); text-decoration: underline; } + .prose a:hover { color: oklch(var(--pf)); } + .prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; } + .prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; } + .prose pre code { background: none; padding: 0; } + .prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; } + .prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; } + .prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; } + .prose th { background: oklch(var(--b2)); font-weight: 600; } + .prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; } + .prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; } {% block extra_head %}{% endblock %} @@ -83,6 +105,9 @@
  • Messages
  • Map
  • Members
  • + {% for page in custom_pages %} +
  • {{ page.title }}
  • + {% endfor %} @@ -101,6 +126,9 @@
  • Messages
  • Map
  • Members
  • + {% for page in custom_pages %} +
  • {{ page.title }}
  • + {% endfor %}