mdcms/docs/hosting.md
Claude ab2ef3b4c9
Add mdcms serve and clean-URL hosting documentation
- 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
2026-06-12 10:12:53 +00:00

182 lines
7.3 KiB
Markdown

# 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:
```nginx
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`):
```apache
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
```caddyfile
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`:
```python
#!/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:
1. **Service worker** (if installed) — serves the cached shell, even offline.
2. **Server rewrite** (`_redirects`, nginx, Apache, Caddy, `mdcms serve`) — serves `index.html` with status 200.
3. **`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.