Compare commits

..

9 Commits

Author SHA1 Message Date
JingleManSweep 5831592f88 Merge pull request #79 from ipnet-mesh/feat/custom-pages
Feat/custom pages
2026-02-06 19:14:53 +00:00
Louis King bc7bff8b82 Updates 2026-02-06 19:14:19 +00:00
Louis King 9445d2150c Fix links and update join guide
- Fix T114 manufacturer (Heltec, not LilyGO) and link
- Fix T1000-E product link
- Fix Google Play and App Store links
- Add Amazon to where to buy options
- Add radio configuration step

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:10:00 +00:00
Louis King 3e9f478a65 Replace example about page with join guide
Add getting started guide covering:
- Node types (Companion, Repeater, Room Server)
- Frequency regulations (868MHz EU/UK, 915MHz US/AU)
- Recommended hardware (Heltec V3, T114, T1000-E, T-Deck Plus)
- Mobile apps (Android/iOS)
- Links to MeshCore docs and web flasher

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:04:56 +00:00
JingleManSweep 6656bd8214 Merge pull request #78 from ipnet-mesh/feat/custom-pages
Add custom markdown pages feature to web dashboard
2026-02-06 18:40:42 +00:00
Louis King 0f50bf4a41 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 <noreply@anthropic.com>
2026-02-06 18:36:23 +00:00
Louis King 99206f7467 Updated README 2026-02-06 17:53:02 +00:00
Louis King 3a89daa9c0 Use empty Disallow in robots.txt for broader compatibility
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:58:52 +00:00
Louis King 86c5ff8f1c SEO fixes 2026-02-06 14:38:26 +00:00
13 changed files with 918 additions and 6 deletions
+22
View File
@@ -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}/
+68 -3
View File
@@ -217,6 +217,19 @@ SERIAL_PORT=/dev/ttyUSB0
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
```
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
```bash
# List available devices by ID
ls -la /dev/serial/by-id/
# Example output:
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
# Configure using the stable ID
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
```
### Manual Installation
```bash
@@ -325,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
@@ -529,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
+3
View File
@@ -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')"]
+87
View File
@@ -0,0 +1,87 @@
---
title: Join
slug: join
menu_order: 10
---
# Getting Started with MeshCore
MeshCore is an open-source off-grid LoRa mesh networking platform. This guide will help you get connected to the network.
For detailed documentation, see the [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md).
## Node Types
MeshCore devices operate in different modes:
| Mode | Description |
|------|-------------|
| **Companion** | Connects to your phone via Bluetooth. Use this for messaging and interacting with the network. |
| **Repeater** | Standalone node that extends network coverage. Place these in elevated locations for best results. |
| **Room Server** | Hosts chat rooms that persist messages for offline users. |
Most users start with a **Companion** node paired to their phone.
## Frequency Regulations
MeshCore uses LoRa radio, which operates on unlicensed ISM bands. You **must** use the correct frequency for your region:
| Region | Frequency | Notes |
|--------|-----------|-------|
| Europe (EU) | 868 MHz | EU868 band |
| United Kingdom | 868 MHz | Same as EU |
| North America | 915 MHz | US915 band |
| Australia | 915 MHz | AU915 band |
Using the wrong frequency is illegal and may cause interference. Check your local regulations.
## Compatible Hardware
MeshCore runs on inexpensive low-power LoRa devices. Popular options include:
### Recommended Devices
| Device | Manufacturer | Features |
|--------|--------------|----------|
| [Heltec V3](https://heltec.org/project/wifi-lora-32-v3/) | Heltec | Budget-friendly, OLED display |
| [T114](https://heltec.org/project/mesh-node-t114/) | Heltec | Compact, GPS, colour display |
| [T1000-E](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | Seeed Studio | Credit-card sized, GPS, weatherproof |
| [T-Deck Plus](https://www.lilygo.cc/products/t-deck-plus) | LilyGO | Built-in keyboard, touchscreen, GPS |
Ensure you purchase the correct frequency variant (868MHz for EU/UK, 915MHz for US/AU).
### Where to Buy
- **Heltec**: [Official Store](https://heltec.org/) or AliExpress
- **LilyGO**: [Official Store](https://lilygo.cc/) or AliExpress
- **Seeed Studio**: [Official Store](https://www.seeedstudio.com/)
- **Amazon**: Search for device name + "LoRa 868" (or 915 for US)
## Mobile Apps
Connect to your Companion node using the official MeshCore apps:
| Platform | App | Link |
|----------|-----|------|
| Android | MeshCore | [Google Play](https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android) |
| iOS | MeshCore | [App Store](https://apps.apple.com/us/app/meshcore/id6742354151) |
The app connects via Bluetooth to your Companion node, allowing you to send messages, view the network, and configure your device.
## Flashing Firmware
1. Use the [MeshCore Web Flasher](https://flasher.meshcore.co.uk/) for easy browser-based flashing
2. Select your device type and region (frequency)
3. Connect via USB and flash
## Next Steps
Once your device is flashed and paired:
1. Open the MeshCore app on your phone
2. Enable Bluetooth and pair with your device
3. Set your node name in the app settings
4. Configure your radio settings/profile for your region
4. You should start seeing other nodes on the network
Welcome to the mesh!
+4
View File
@@ -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
+13
View File
@@ -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."""
+33 -3
View File
@@ -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")
@@ -152,16 +158,24 @@ def create_app(
except Exception as e:
return {"status": "not_ready", "api": str(e)}
def _get_https_base_url(request: Request) -> str:
"""Get base URL, ensuring HTTPS is used for public-facing URLs."""
base_url = str(request.base_url).rstrip("/")
# Ensure HTTPS for sitemaps and robots.txt (SEO requires canonical URLs)
if base_url.startswith("http://"):
base_url = "https://" + base_url[7:]
return base_url
@app.get("/robots.txt", response_class=PlainTextResponse)
async def robots_txt(request: Request) -> str:
"""Serve robots.txt to control search engine crawling."""
base_url = str(request.base_url).rstrip("/")
return f"User-agent: *\nAllow: /\n\nSitemap: {base_url}/sitemap.xml\n"
base_url = _get_https_base_url(request)
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
@app.get("/sitemap.xml")
async def sitemap_xml(request: Request) -> Response:
"""Generate dynamic sitemap including all node pages."""
base_url = str(request.base_url).rstrip("/")
base_url = _get_https_base_url(request)
# Static pages
static_pages = [
@@ -209,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" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
@@ -252,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,
@@ -262,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__,
}
+119
View File
@@ -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()
+2
View File
@@ -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"]
+36
View File
@@ -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)
+28
View File
@@ -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; }
</style>
{% block extra_head %}{% endblock %}
@@ -83,6 +105,9 @@
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
{% for page in custom_pages %}
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
@@ -101,6 +126,9 @@
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
{% for page in custom_pages %}
<li><a href="{{ page.url }}" class="{% if request.url.path == page.url %}active{% endif %}">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
<div class="navbar-end">
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} - {{ network_name }}{% endblock %}
{% block meta_description %}{{ page.title }} - {{ network_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body prose prose-lg max-w-none">
{{ page.content_html | safe }}
</div>
</div>
</div>
{% endblock %}
+488
View File
@@ -0,0 +1,488 @@
"""Tests for custom pages functionality."""
import tempfile
from collections.abc import Generator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.pages import CustomPage, PageLoader
class TestCustomPage:
"""Tests for the CustomPage dataclass."""
def test_url_property(self) -> None:
"""Test that url property returns correct path."""
page = CustomPage(
slug="about",
title="About Us",
menu_order=10,
content_html="<p>Content</p>",
file_path="/pages/about.md",
)
assert page.url == "/pages/about"
def test_url_property_with_hyphenated_slug(self) -> None:
"""Test url property with hyphenated slug."""
page = CustomPage(
slug="terms-of-service",
title="Terms of Service",
menu_order=50,
content_html="<p>Terms</p>",
file_path="/pages/terms-of-service.md",
)
assert page.url == "/pages/terms-of-service"
class TestPageLoader:
"""Tests for the PageLoader class."""
def test_load_pages_nonexistent_directory(self) -> None:
"""Test loading from a non-existent directory."""
loader = PageLoader("/nonexistent/path")
loader.load_pages()
assert loader.get_menu_pages() == []
def test_load_pages_empty_directory(self) -> None:
"""Test loading from an empty directory."""
with tempfile.TemporaryDirectory() as tmpdir:
loader = PageLoader(tmpdir)
loader.load_pages()
assert loader.get_menu_pages() == []
def test_load_pages_with_frontmatter(self) -> None:
"""Test loading a page with full frontmatter."""
with tempfile.TemporaryDirectory() as tmpdir:
page_path = Path(tmpdir) / "about.md"
page_path.write_text(
"""---
title: About Us
slug: about
menu_order: 10
---
# About
This is the about page.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].slug == "about"
assert pages[0].title == "About Us"
assert pages[0].menu_order == 10
assert "About</h1>" in pages[0].content_html
assert "<p>This is the about page.</p>" in pages[0].content_html
def test_load_pages_default_slug_from_filename(self) -> None:
"""Test that slug defaults to filename when not specified."""
with tempfile.TemporaryDirectory() as tmpdir:
page_path = Path(tmpdir) / "my-custom-page.md"
page_path.write_text(
"""---
title: My Custom Page
---
Content here.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].slug == "my-custom-page"
def test_load_pages_default_title_from_slug(self) -> None:
"""Test that title defaults to titlecased slug when not specified."""
with tempfile.TemporaryDirectory() as tmpdir:
page_path = Path(tmpdir) / "terms-of-service.md"
page_path.write_text("Just content, no frontmatter.")
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].title == "Terms Of Service"
def test_load_pages_default_menu_order(self) -> None:
"""Test that menu_order defaults to 100."""
with tempfile.TemporaryDirectory() as tmpdir:
page_path = Path(tmpdir) / "page.md"
page_path.write_text(
"""---
title: Test Page
---
Content.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].menu_order == 100
def test_load_pages_sorted_by_menu_order(self) -> None:
"""Test that pages are sorted by menu_order then title."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create pages with different menu_order values
(Path(tmpdir) / "page-z.md").write_text(
"""---
title: Z Page
menu_order: 30
---
Content.
"""
)
(Path(tmpdir) / "page-a.md").write_text(
"""---
title: A Page
menu_order: 10
---
Content.
"""
)
(Path(tmpdir) / "page-m.md").write_text(
"""---
title: M Page
menu_order: 20
---
Content.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 3
assert [p.title for p in pages] == ["A Page", "M Page", "Z Page"]
def test_load_pages_secondary_sort_by_title(self) -> None:
"""Test that pages with same menu_order are sorted by title."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "zebra.md").write_text(
"""---
title: Zebra
menu_order: 10
---
Content.
"""
)
(Path(tmpdir) / "apple.md").write_text(
"""---
title: Apple
menu_order: 10
---
Content.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 2
assert [p.title for p in pages] == ["Apple", "Zebra"]
def test_get_page_returns_correct_page(self) -> None:
"""Test that get_page returns the page with the given slug."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "about.md").write_text(
"""---
title: About
slug: about
---
About content.
"""
)
(Path(tmpdir) / "contact.md").write_text(
"""---
title: Contact
slug: contact
---
Contact content.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
page = loader.get_page("about")
assert page is not None
assert page.slug == "about"
assert page.title == "About"
page = loader.get_page("contact")
assert page is not None
assert page.slug == "contact"
def test_get_page_returns_none_for_unknown_slug(self) -> None:
"""Test that get_page returns None for unknown slugs."""
loader = PageLoader("/nonexistent")
loader.load_pages()
assert loader.get_page("unknown") is None
def test_reload_clears_and_reloads(self) -> None:
"""Test that reload() clears existing pages and reloads from disk."""
with tempfile.TemporaryDirectory() as tmpdir:
page_path = Path(tmpdir) / "page.md"
page_path.write_text(
"""---
title: Original
---
Content.
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].title == "Original"
# Update the file
page_path.write_text(
"""---
title: Updated
---
New content.
"""
)
loader.reload()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].title == "Updated"
def test_load_pages_ignores_non_md_files(self) -> None:
"""Test that only .md files are loaded."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "page.md").write_text("# Valid Page")
(Path(tmpdir) / "readme.txt").write_text("Not a markdown file")
(Path(tmpdir) / "data.json").write_text('{"key": "value"}')
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert pages[0].slug == "page"
def test_markdown_tables_rendered(self) -> None:
"""Test that markdown tables are rendered to HTML."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "tables.md").write_text(
"""---
title: Tables
---
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert "<table>" in pages[0].content_html
assert "<th>" in pages[0].content_html
def test_markdown_fenced_code_rendered(self) -> None:
"""Test that fenced code blocks are rendered."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "code.md").write_text(
"""---
title: Code
---
```python
def hello():
print("Hello!")
```
"""
)
loader = PageLoader(tmpdir)
loader.load_pages()
pages = loader.get_menu_pages()
assert len(pages) == 1
assert "<pre>" in pages[0].content_html
assert "def hello():" in pages[0].content_html
class TestPagesRoute:
"""Tests for the custom pages route."""
@pytest.fixture
def pages_dir(self) -> Generator[str, None, None]:
"""Create a temporary directory with test pages."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "about.md").write_text(
"""---
title: About Us
slug: about
menu_order: 10
---
# About Our Network
Welcome to the network.
"""
)
(Path(tmpdir) / "faq.md").write_text(
"""---
title: FAQ
slug: faq
menu_order: 20
---
# Frequently Asked Questions
Here are some answers.
"""
)
yield tmpdir
@pytest.fixture
def web_app_with_pages(
self, pages_dir: str, mock_http_client: Any
) -> Generator[Any, None, None]:
"""Create a web app with custom pages configured."""
import os
# Temporarily set PAGES_HOME environment variable
os.environ["PAGES_HOME"] = pages_dir
from meshcore_hub.web.app import create_app
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
)
app.state.http_client = mock_http_client
yield app
# Cleanup
del os.environ["PAGES_HOME"]
@pytest.fixture
def client_with_pages(
self, web_app_with_pages: Any, mock_http_client: Any
) -> TestClient:
"""Create a test client with custom pages."""
web_app_with_pages.state.http_client = mock_http_client
return TestClient(web_app_with_pages, raise_server_exceptions=True)
def test_get_page_success(self, client_with_pages: TestClient) -> None:
"""Test successfully retrieving a custom page."""
response = client_with_pages.get("/pages/about")
assert response.status_code == 200
assert "About Us" in response.text
assert "About Our Network" in response.text
assert "Welcome to the network" in response.text
def test_get_page_not_found(self, client_with_pages: TestClient) -> None:
"""Test 404 for unknown page slug."""
response = client_with_pages.get("/pages/nonexistent")
assert response.status_code == 404
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
"""Test that custom pages appear in navigation."""
response = client_with_pages.get("/pages/about")
assert response.status_code == 200
# Check for navigation links
assert 'href="/pages/about"' in response.text
assert 'href="/pages/faq"' in response.text
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
"""Test that pages are sorted by menu_order in navigation."""
response = client_with_pages.get("/pages/about")
assert response.status_code == 200
# About (order 10) should appear before FAQ (order 20)
about_pos = response.text.find('href="/pages/about"')
faq_pos = response.text.find('href="/pages/faq"')
assert about_pos < faq_pos
class TestPagesInSitemap:
"""Tests for custom pages in sitemap."""
@pytest.fixture
def pages_dir(self) -> Generator[str, None, None]:
"""Create a temporary directory with test pages."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "about.md").write_text(
"""---
title: About
slug: about
---
About page.
"""
)
yield tmpdir
@pytest.fixture
def client_with_pages_for_sitemap(
self, pages_dir: str, mock_http_client: Any
) -> Generator[TestClient, None, None]:
"""Create a test client with custom pages for sitemap testing."""
import os
os.environ["PAGES_HOME"] = pages_dir
from meshcore_hub.web.app import create_app
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
yield client
del os.environ["PAGES_HOME"]
def test_pages_included_in_sitemap(
self, client_with_pages_for_sitemap: TestClient
) -> None:
"""Test that custom pages are included in sitemap.xml."""
response = client_with_pages_for_sitemap.get("/sitemap.xml")
assert response.status_code == 200
assert "/pages/about" in response.text
assert "<changefreq>weekly</changefreq>" in response.text