mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
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
This commit is contained in:
parent
a5127727f0
commit
ab2ef3b4c9
5 changed files with 288 additions and 2 deletions
|
|
@ -73,6 +73,8 @@ During development, run directly: `python3 mdcms.py <command>`
|
||||||
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
|
| `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 [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 <path>` | Same, using an explicit path. |
|
| `mdcms fetch-deps --path <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 <path> --port <port>` | Same, with explicit path and port. |
|
||||||
|
|
||||||
## PWA config keys
|
## PWA config keys
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ offline-message:
|
||||||
nb: "Du er frakoblet og noe innhold er utilgjengelig."
|
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`
|
## Architecture of `mdcms.py`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3362,7 +3362,7 @@ function fmtDatetime(dtStr) {
|
||||||
document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;">
|
document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;">
|
||||||
<h1 style="color:#EF4444;font-size:1.5rem;">MD-CMS Error</h1>
|
<h1 style="color:#EF4444;font-size:1.5rem;">MD-CMS Error</h1>
|
||||||
<p style="margin-top:1rem;color:#64748B;">${err.message}</p>
|
<p style="margin-top:1rem;color:#64748B;">${err.message}</p>
|
||||||
<p style="margin-top:0.5rem;color:#94A3B8;font-size:0.9rem;">Make sure config.yml exists. If running locally, use a local HTTP server (option 8 in mdcms.py).</p>
|
<p style="margin-top:0.5rem;color:#94A3B8;font-size:0.9rem;">Make sure config.yml exists. If running locally, use a local HTTP server (run <code>mdcms serve</code> in the site directory).</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
182
docs/hosting.md
Normal file
182
docs/hosting.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -41,6 +41,16 @@ Changes merged into `development` that have not yet been released to `main`.
|
||||||
|
|
||||||
### CLI (`mdcms.py`)
|
### 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`,
|
- **`mdcms fetch-deps` no longer crashes.** `CDN_DEPS`, `_WOFF2_URL_RE`,
|
||||||
`_fetch_bunny_fonts`, and `_patch_index_html` were lost in an earlier merge,
|
`_fetch_bunny_fonts`, and `_patch_index_html` were lost in an earlier merge,
|
||||||
raising `NameError` on every invocation; they have been restored.
|
raising `NameError` on every invocation; they have been restored.
|
||||||
|
|
|
||||||
92
mdcms.py
92
mdcms.py
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
"""MD-CMS v0.6.1 — CLI tool for managing and building MD-CMS sites."""
|
"""MD-CMS v0.6.1 — CLI tool for managing and building MD-CMS sites."""
|
||||||
|
|
||||||
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
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"))
|
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 ─────────────────────────────────────────────
|
# ─── Entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue