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

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 404index.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:

  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.