# 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.