- New `mdcms serve` command: local preview server with SPA clean-URL fallback. Unknown extension-less paths (e.g. /section-id) are served index.html so reloads and shared clean URLs work; paths with an extension still 404, preserving the renderer's category-variant fallback. Serves .md as text/markdown and .yml as text/yaml. Options: [name], --path, --port (default 8800), --bind. - New docs/hosting.md: explains why clean URLs 404 on plain static hosts and documents the fix per environment (mdcms serve, service worker, GitHub Pages 404.html, Netlify/Cloudflare _redirects, nginx, Apache, Caddy), plus a standalone Python preview script. - Update stale boot error message in index.html to point at `mdcms serve` instead of the removed "option 8". - Update CLAUDE.md command table and local-preview note. https://claude.ai/code/session_018KXUwmSNMGF2UBywTChCcS
7.3 KiB
Clean URLs and hosting
MD-CMS gives pages whose filename matches a nav section-id a clean URL — example.com/timesheet instead of example.com/#pages/timesheet.md. This page explains why clean URLs need help from the web server, and how to set that up on every kind of host.
The problem
Clean URLs are virtual: there is no file called timesheet on the server. The mapping from /timesheet to pages/timesheet.md exists only in the renderer's JavaScript inside index.html.
Navigation within the site always works — clicking a nav link never asks the server for /timesheet. But three things do ask the server directly:
- Reloading the page (Ctrl-R / F5)
- Opening a shared link to a clean URL in a fresh tab
- Bookmarks pointing at a clean URL
In all three cases the browser sends GET /timesheet to the server before any JavaScript runs. A plain static server looks for a file at that path, finds nothing, and returns 404 — index.html is never served, so the router that knows how to resolve the slug never executes.
The fix is always the same idea: get the server (or the browser) to answer unknown extension-less paths with index.html, so the client-side router can take over.
Solutions by environment
| Environment | Solution | Setup |
|---|---|---|
| Local preview | mdcms serve |
nothing — built in |
| Any host, PWA enabled | service worker app-shell fallback | pwa: yes in config.yml + mdcms build |
| GitHub Pages | 404.html redirect |
ships with the starter template |
| Netlify / Cloudflare Pages | _redirects file |
one line, see below |
| nginx | try_files rule |
see below |
| Apache | .htaccess rewrite |
see below |
| Caddy | try_files rule |
see below |
Local preview: mdcms serve
python3 -m http.server is a "dumb" static server: no rewrites, no custom 404 page. Use the built-in preview server instead:
mdcms serve # serve the current directory
mdcms serve mysite # serve a registered site
mdcms serve --path ./site # serve an explicit path
mdcms serve --port 9000 # custom port (default: 8800)
Then open http://localhost:8800. The server rewrites unknown extension-less paths (like /timesheet) to index.html, while requests with an extension (like a missing pages/timesheet.nb.md category variant) still return 404 — the renderer depends on that for its category-fallback logic. It also serves .md files with the correct text/markdown type.
Never open index.html directly from disk — browsers block local file access due to CORS.
Any host: the service worker (PWA)
If pwa: yes is set in config.yml, the service worker generated by mdcms build answers every navigation request with the cached index.html app shell — the request never reaches the server. This makes clean-URL reloads work on any host, including ones where you can't configure rewrites, and it works offline.
Caveat: the service worker is only installed after the first visit, so the very first request for a clean URL in a fresh browser still hits the server. Pair it with one of the server-side solutions below for full coverage.
GitHub Pages: 404.html
The starter template ships an app/404.html. GitHub Pages serves it as the body of any 404 response; it encodes the intended path as ?_route=/timesheet and redirects to the app root, where index.html picks the route up and cleans the URL. No configuration needed — just deploy the file with the rest of the site.
Netlify and Cloudflare Pages: _redirects
Create a file called _redirects in the site root (next to index.html) with this single line:
/* /index.html 200
The 200 makes it a rewrite (the URL stays the same), not a redirect. The renderer detects these rewrites: a missing .md file that comes back as index.html is recognised by its text/html content type and treated as not found, so category fallback still works.
nginx
Add a try_files rule to the site's location block:
server {
listen 80;
server_name example.com;
root /var/www/mysite;
index index.html;
# Serve real files when they exist; otherwise hand the request
# to index.html so the MD-CMS router can resolve the clean URL.
location / {
try_files $uri $uri/ /index.html;
}
# Correct content type for markdown (optional but recommended —
# the renderer rejects text/html responses for .md requests).
types {
text/markdown md;
}
}
Apache
Create an .htaccess file in the site root (requires mod_rewrite):
RewriteEngine On
RewriteBase /
# Serve existing files and directories as-is
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Everything else goes to index.html for the client-side router
RewriteRule ^ index.html [L]
# Correct content type for markdown
AddType text/markdown .md
Caddy
example.com {
root * /var/www/mysite
try_files {path} {path}/ /index.html
file_server
}
Sample script: standalone preview server
If mdcms is not installed (for example on a machine that only has Python), this standalone script reproduces what mdcms serve does. Save it as serve.py in the site root and run python3 serve.py:
#!/usr/bin/env python3
"""Minimal static server with SPA clean-URL fallback for MD-CMS sites.
Unknown extension-less paths (e.g. /timesheet) are served index.html so the
MD-CMS client-side router can resolve them. Requests with a file extension
that don't exist (e.g. a missing .md category variant) still return 404,
which the renderer relies on.
"""
import http.server
from pathlib import Path
PORT = 8800
class SpaHandler(http.server.SimpleHTTPRequestHandler):
extensions_map = {
**http.server.SimpleHTTPRequestHandler.extensions_map,
".md": "text/markdown; charset=utf-8",
".yml": "text/yaml; charset=utf-8",
".json": "application/json; charset=utf-8",
}
def _rewrite_spa(self):
if Path(self.translate_path(self.path)).exists():
return
clean = self.path.split("?", 1)[0].split("#", 1)[0]
last = clean.rstrip("/").rsplit("/", 1)[-1]
if "." not in last:
self.path = "/index.html"
def do_GET(self):
self._rewrite_spa()
super().do_GET()
def do_HEAD(self):
self._rewrite_spa()
super().do_HEAD()
if __name__ == "__main__":
with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), SpaHandler) as httpd:
print(f"Serving on http://localhost:{PORT}/ (Ctrl-C to stop)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
How the pieces interact
A request for a clean URL is resolved by the first layer that catches it:
- Service worker (if installed) — serves the cached shell, even offline.
- Server rewrite (
_redirects, nginx, Apache, Caddy,mdcms serve) — servesindex.htmlwith status 200. 404.html(GitHub Pages) — redirects to the app root with?_route=.
Whichever layer answers, index.html boots, reads the path (or ?_route=), matches the last segment against the nav section-id list, and renders the right page. If the segment matches nothing, the renderer shows its normal page-not-found message.