From ab2ef3b4c913bc91266157a4cb680917d5921586 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 10:12:53 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 4 +- app/index.html | 2 +- docs/hosting.md | 182 +++++++++++++++++++++++++++++++++++++++++++++ docs/unreleased.md | 10 +++ mdcms.py | 92 +++++++++++++++++++++++ 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 docs/hosting.md diff --git a/CLAUDE.md b/CLAUDE.md index cad1d52..2edd9c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ During development, run directly: `python3 mdcms.py ` | `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. | | `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. | | `mdcms fetch-deps --path ` | Same, using an explicit path. | +| `mdcms serve [name]` | Local preview server with SPA clean-URL fallback (default port 8800). Unknown extension-less paths are served `index.html`; paths with an extension still 404 (the renderer's category fallback depends on this). | +| `mdcms serve --path --port ` | Same, with explicit path and port. | ## PWA config keys @@ -88,7 +90,7 @@ offline-message: nb: "Du er frakoblet og noe innhold er utilgjengelig." ``` -**Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS. +**Local preview:** Run `mdcms serve` in the site directory and open `http://localhost:8800`. Unlike `python3 -m http.server`, this handles clean-URL reloads (`/section-id`). Do not open `index.html` directly — browsers block local file access due to CORS. See `docs/hosting.md` for clean-URL setup on production hosts. ## Architecture of `mdcms.py` diff --git a/app/index.html b/app/index.html index fc60ea3..d6483da 100644 --- a/app/index.html +++ b/app/index.html @@ -3362,7 +3362,7 @@ function fmtDatetime(dtStr) { document.getElementById('app').innerHTML = `

MD-CMS Error

${err.message}

-

Make sure config.yml exists. If running locally, use a local HTTP server (option 8 in mdcms.py).

+

Make sure config.yml exists. If running locally, use a local HTTP server (run mdcms serve in the site directory).

`; } } diff --git a/docs/hosting.md b/docs/hosting.md new file mode 100644 index 0000000..c2ec41a --- /dev/null +++ b/docs/hosting.md @@ -0,0 +1,182 @@ +# 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. diff --git a/docs/unreleased.md b/docs/unreleased.md index f90e0a8..de556d8 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -41,6 +41,16 @@ Changes merged into `development` that have not yet been released to `main`. ### CLI (`mdcms.py`) +- **New command: `mdcms serve`.** 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 during preview; 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 page: `docs/hosting.md`.** Explains why clean URLs 404 on dumb + static hosts and documents the fix for every environment (`mdcms serve`, + service worker, GitHub Pages `404.html`, Netlify/Cloudflare `_redirects`, + nginx, Apache, Caddy), including a standalone Python preview script. - **`mdcms fetch-deps` no longer crashes.** `CDN_DEPS`, `_WOFF2_URL_RE`, `_fetch_bunny_fonts`, and `_patch_index_html` were lost in an earlier merge, raising `NameError` on every invocation; they have been restored. diff --git a/mdcms.py b/mdcms.py index 3baf526..460df6a 100644 --- a/mdcms.py +++ b/mdcms.py @@ -18,6 +18,7 @@ """MD-CMS v0.6.1 — CLI tool for managing and building MD-CMS sites.""" +import http.server import json import os import re @@ -1207,6 +1208,97 @@ def fetch_deps(name, path_override): click.echo(click.style("Done. Site is ready for offline use.", fg="green")) +# ─── Local preview server ───────────────────────────────────── + +def _make_preview_handler(site_dir: Path): + """Build a request handler that serves site_dir with SPA clean-URL fallback. + + Static servers cannot resolve clean URLs like /section-id — the mapping to + pages/section-id.md exists only in the renderer's JavaScript. Any request + whose last path segment has no file extension and doesn't exist on disk is + rewritten to /index.html so the client-side router can resolve it. Requests + with an extension (e.g. missing .md category variants) still 404, which the + renderer relies on for its variant-fallback logic. + """ + + class PreviewHandler(http.server.SimpleHTTPRequestHandler): + # Explicit types for the site's text formats. .md must never be served + # as text/html: the renderer treats text/html responses as "not found" + # to defeat SPA rewrites, including this one. + extensions_map = { + **http.server.SimpleHTTPRequestHandler.extensions_map, + ".md": "text/markdown; charset=utf-8", + ".yml": "text/yaml; charset=utf-8", + ".yaml": "text/yaml; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(site_dir), **kwargs) + + def _rewrite_spa(self): + local = Path(self.translate_path(self.path)) + if local.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() + + return PreviewHandler + + +@cli.command() +@click.argument("name", required=False) +@click.option("--path", "path_override", type=click.Path(), default=None, + help="Explicit site path (no registry lookup).") +@click.option("--port", default=8800, show_default=True, help="Port to listen on.") +@click.option("--bind", default="127.0.0.1", show_default=True, + help="Address to bind to (use 0.0.0.0 to expose on the network).") +def serve(name, path_override, port, bind): + """Preview a site locally with clean-URL support. + + Unlike a plain static server (e.g. python3 -m http.server), unknown + extension-less paths such as /section-id are served the app shell so + reloading or sharing clean URLs works during local preview. + + \b + Examples: + mdcms serve mysite # registered site by name + mdcms serve --path ./site # explicit path + mdcms serve # current directory + """ + site_path = resolve_site_path(name, path_override) + if not (site_path / "index.html").exists(): + raise click.ClickException(f"No index.html found at {site_path}") + + handler = _make_preview_handler(site_path) + try: + httpd = http.server.ThreadingHTTPServer((bind, port), handler) + except OSError as e: + raise click.ClickException( + f"Could not bind {bind}:{port} ({e.strerror or e}). " + "Is another server already running? Try a different --port." + ) + with httpd: + click.echo(f"Serving {site_path}") + click.echo(f" http://{'localhost' if bind in ('127.0.0.1', '0.0.0.0') else bind}:{port}/") + click.echo("Press Ctrl-C to stop.") + try: + httpd.serve_forever() + except KeyboardInterrupt: + click.echo("\nStopped.") + + # ─── Entry point ───────────────────────────────────────────── def main():