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 f83ba46..d6483da 100644 --- a/app/index.html +++ b/app/index.html @@ -901,7 +901,7 @@ body { background: var(--bg); color: var(--font-colour); font-size: 0.85rem; - text-align: centre; + text-align: center; } .post-load-more { @@ -1143,7 +1143,15 @@ body { const span = document.createElement('span'); span.className = 'mdcms-icon' + (className ? ' ' + className : ''); const filename = normaliseIconName(name); - span.innerHTML = svg || '[missing: ' + filename + ']'; + if (svg) { + span.innerHTML = svg; + } else { + const img = document.createElement('img'); + img.src = 'assets/icons/' + encodeURIComponent(filename); + img.alt = '[missing: ' + filename + ']'; + img.style.cssText = 'width:1em;height:1em;display:inline-block;'; + span.appendChild(img); + } return span; } @@ -1169,6 +1177,31 @@ body { return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim(); } + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + // Reject hrefs with dangerous schemes (javascript:, data:, vbscript:). + function safeUrl(url) { + var u = String(url == null ? '' : url).trim(); + if (/^[a-z][a-z0-9+.\-]*:/i.test(u)) { + if (/^(https?|mailto|tel|ftp):/i.test(u)) return u; + return '#'; + } + return u; // relative URL or fragment + } + + // A routable page file is a relative .md path with no scheme or traversal. + // Heading-anchor hashes (no .md) and external URLs both fail this check. + function isSafePagePath(file) { + return typeof file === 'string' + && /^[\w./-]+\.md$/.test(file) + && !file.includes('..') + && file[0] !== '/'; + } + function parseFrontmatter(md) { const match = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); if (!match) return { meta: {}, body: md }; @@ -1243,7 +1276,7 @@ body { window.history.replaceState(null, '', url); maybeLoadCategoryFont(code).then(() => { renderNav(); - if (currentPage) navigateTo(currentPage); + if (currentPage) navigateTo(currentPage, { replace: true }); }); } @@ -1397,7 +1430,7 @@ body { function getInitialTheme() { const saved = localStorage.getItem('md-cms-theme'); - if (saved) return saved; + if (saved === 'light' || saved === 'dark') return saved; const def = config['default-theme'] || 'system'; if (def === 'system') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; @@ -1583,7 +1616,9 @@ body { } // ─── Markdown ───────────────────────────────────────────── - function renderMarkdown(mdBody) { + let _markedConfigured = false; + function configureMarked() { + if (_markedConfigured) return; marked.setOptions({ gfm: true, breaks: false, headerIds: true, mangle: false }); const renderer = new marked.Renderer(); @@ -1603,13 +1638,14 @@ body { } const isExternal = linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://')); const isMd = linkHref && linkHref.endsWith('.md'); + const titleAttr = linkTitle ? ` title="${escapeHtml(linkTitle)}"` : ''; if (isExternal) { - return `${linkText}`; + return `${linkText}`; } if (isMd) { - return `${linkText}`; + return `${linkText}`; } - return `${linkText}`; + return `${linkText}`; }; renderer.code = function(code, lang, escaped) { @@ -1637,6 +1673,11 @@ body { }; marked.use({ renderer }); + _markedConfigured = true; + } + + function renderMarkdown(mdBody) { + configureMarked(); return marked.parse(mdBody); } @@ -2400,24 +2441,36 @@ function fmtDatetime(dtStr) { } function hydrateMdcmsTags() { - document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) { - try { - var cfg = JSON.parse(tagEl.getAttribute('data-config')); - if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) { - renderCalloutTag(tagEl, cfg); - } else if (cfg.tagName === 'toc') { - renderTocTag(tagEl); - } else if (/^tab(-underline|-filled)?$/.test(cfg.tagName)) { - renderTabsTag(tagEl, cfg); - } else if (/^accordion(-underline|-filled)?$/.test(cfg.tagName)) { - renderAccordionTag(tagEl, cfg); - } else { - renderPostTag(tagEl, cfg); + // Rendering a tag (tab/accordion/callout) can emit further .mdcms-tag + // elements in its body, so keep sweeping until none are left. A processed + // marker and an iteration cap guard against runaway loops. + var MAX_PASSES = 10; + for (var pass = 0; pass < MAX_PASSES; pass++) { + var pending = Array.prototype.filter.call( + document.querySelectorAll('.mdcms-tag'), + function(t) { return !t.hasAttribute('data-mdcms-hydrated'); } + ); + if (!pending.length) break; + pending.forEach(function(tagEl) { + tagEl.setAttribute('data-mdcms-hydrated', ''); + try { + var cfg = JSON.parse(tagEl.getAttribute('data-config')); + if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) { + renderCalloutTag(tagEl, cfg); + } else if (cfg.tagName === 'toc') { + renderTocTag(tagEl); + } else if (/^tab(-underline|-filled)?$/.test(cfg.tagName)) { + renderTabsTag(tagEl, cfg); + } else if (/^accordion(-underline|-filled)?$/.test(cfg.tagName)) { + renderAccordionTag(tagEl, cfg); + } else { + renderPostTag(tagEl, cfg); + } + } catch (e) { + tagEl.textContent = 'Error rendering tag.'; } - } catch (e) { - tagEl.textContent = 'Error rendering tag.'; - } - }); + }); + } } // ─── Shell ──────────────────────────────────────────────── @@ -3067,9 +3120,23 @@ function fmtDatetime(dtStr) { } // ─── Page loading ───────────────────────────────────────── - async function navigateTo(file) { - currentPage = file; + // opts.replace: replace the current history entry instead of pushing a new + // one. Used for the initial load and for back/forward (popstate/hashchange), + // where pushing would corrupt the history stack. User navigations push, so + // the browser Back button returns to the previous page. + async function navigateTo(file, opts) { + const replace = !!(opts && opts.replace); const contentEl = document.getElementById('pageContent'); + + // Guard the router: only fetch relative .md paths. This blocks loading + // attacker-controlled external URLs (e.g. #https://evil/x.md) or traversal + // paths injected via the location hash. + if (!isSafePagePath(file)) { + contentEl.innerHTML = `

Page not available

${escapeHtml(pageNotFoundMessage())}

`; + return; + } + + currentPage = file; highlightNav(file); // If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible) @@ -3107,7 +3174,7 @@ function fmtDatetime(dtStr) { u.pathname = basePath; u.hash = '#' + file; } - window.history.replaceState(null, '', u); + window.history[replace ? 'replaceState' : 'pushState'](null, '', u); contentEl.innerHTML = '
'; @@ -3115,8 +3182,8 @@ function fmtDatetime(dtStr) { if (!result.ok) { const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline'); const bodyMsg = offlineMsg - ? `

${offlineMsg}

` - : `

${pageNotFoundMessage()}

`; + ? `

${escapeHtml(offlineMsg)}

` + : `

${escapeHtml(pageNotFoundMessage())}

`; contentEl.innerHTML = `

Page not available

${bodyMsg}
`; document.title = (config.sitename || 'MD-CMS'); refreshCategoryBar(); @@ -3127,9 +3194,9 @@ function fmtDatetime(dtStr) { const { meta, body } = parseFrontmatter(result.text); let html = `
- ${config.sitename || 'MD-CMS'} + ${escapeHtml(config.sitename || 'MD-CMS')} - ${meta.title || file} + ${escapeHtml(meta.title || file)}
`; html += '
' + renderMarkdown(body) + '
'; contentEl.innerHTML = html; @@ -3179,14 +3246,16 @@ function fmtDatetime(dtStr) { window.addEventListener('hashchange', () => { const page = getPageFromHash(); - if (page && page !== currentPage) navigateTo(page); + // Ignore in-page heading anchors (e.g. #installation) — only route real .md + // pages. Without this, clicking a heading link wipes the page with a 404. + if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page, { replace: true }); }); window.addEventListener('popstate', () => { const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, ''); const pathPage = slug ? resolveSlugToFile(slug) : null; const page = pathPage || getPageFromHash(); - if (page && page !== currentPage) navigateTo(page); + if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page, { replace: true }); }); // ─── Scroll to top ─────────────────────────────────────── @@ -3219,6 +3288,11 @@ function fmtDatetime(dtStr) { } catch (e) { /* fall back to hardcoded CSS defaults */ } } + loadFonts(themeConfig); + initCategories(); + + // Resolve the offline message after initCategories(), which sets + // defaultCategoryCode — otherwise a per-category default is missed. const offlineMsgCfg = config['offline-message']; if (offlineMsgCfg) { const offlineText = typeof offlineMsgCfg === 'string' @@ -3227,9 +3301,6 @@ function fmtDatetime(dtStr) { if (offlineText) localStorage.setItem('mdcms-offline', offlineText); } - loadFonts(themeConfig); - initCategories(); - const iconsToPreload = [...STANDARD_ICONS]; if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']); await Promise.all(iconsToPreload.map(name => loadIcon(name))); @@ -3286,12 +3357,12 @@ function fmtDatetime(dtStr) { } const hashPage = getPageFromHash(); - await navigateTo(routeFromPath || hashPage || defaultPage()); + await navigateTo(routeFromPath || hashPage || defaultPage(), { replace: true }); } catch (err) { 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/app/service-worker.js b/app/service-worker.js index 791adf2..e86b91c 100644 --- a/app/service-worker.js +++ b/app/service-worker.js @@ -59,8 +59,18 @@ self.addEventListener('activate', event => { }); self.addEventListener('fetch', event => { - if (event.request.method !== 'GET') return; + const req = event.request; + if (req.method !== 'GET') return; + // App-shell routing: serve cached index.html for every navigation, including + // clean URLs like /section-id on reload. Without this the static host returns + // 404 for those paths before any JavaScript runs. Works offline too. + if (req.mode === 'navigate') { + event.respondWith( + caches.match('index.html').then(shell => shell || fetch(req)) + ); + return; + } event.respondWith( - caches.match(event.request).then(cached => cached || fetch(event.request)) + caches.match(req).then(cached => cached || fetch(req)) ); }); 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 6681a79..de556d8 100644 --- a/docs/unreleased.md +++ b/docs/unreleased.md @@ -4,6 +4,62 @@ Changes merged into `development` that have not yet been released to `main`. --- +## Security & bug fixes (v0.6.1) + +### Renderer (`app/index.html`) + +- **Router now rejects unsafe page paths.** `navigateTo` and the + `hashchange`/`popstate` handlers only load relative `.md` paths via a new + `isSafePagePath` check. Previously a crafted link such as + `#https://evil.example/x.md` made the renderer fetch and render an + attacker-controlled document on the site's own origin (stored/reflected XSS). +- **In-page heading anchors no longer 404.** A markdown link to `#some-heading` + used to be treated as a page file and blew the page away with a + "Page not available" error; such hashes are now ignored by the router so the + browser scrolls to the heading. +- **Escaped untrusted interpolation.** `meta.title` (title bar), link `href` + and `title` attributes, the page-not-found / offline messages, and the icon + fallback `` are now HTML-escaped. Link hrefs with `javascript:`, + `data:`, and other non-allowlisted schemes are neutralised via `safeUrl`. +- **Nested `mdcms` tags now hydrate.** Tags emitted inside a tab, accordion, or + callout body (e.g. a post list inside a tab) are processed by re-sweeping + until none remain, instead of rendering as empty divs. +- **Browser Back/Forward now navigate within the app.** User navigations use + `history.pushState` (the initial load, back/forward, and category re-renders + still replace), so Back returns to the previous page instead of leaving the + site. +- **Clean-URL reloads (Ctrl-R) work.** The generated service worker now serves + the cached `index.html` app shell for any navigation request, so reloading a + pretty URL such as `example.com/section-id` no longer 404s before the + client-side router runs. This also makes pretty-URL reloads work offline. + (Generated by `mdcms build`; `app/service-worker.js` regenerated.) +- **`marked` is configured once** instead of re-registering the renderer on + every page render. +- Stored `md-cms-theme` value is validated against `light`/`dark` before use; + fixed invalid `text-align: centre` → `center` on the pagination jump input; + per-category `offline-message` is now resolved after categories initialise. + +### 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. +- **No more spurious "update available" warning.** Site markers are compared + against a dedicated `SITE_FORMAT_VERSION` (with zero-padded version + comparison) rather than `CLI_VERSION`, so a `v0.6` site no longer reports as + outdated against CLI `v0.6.x`, and CLI patch releases that share the site + format stay quiet. + ## Tabs & Accordions (`app/index.html`) Four new `mdcms` fenced-block types for rich content layout. All four variants read from the active theme automatically — no new config keys, no per-theme overrides needed. diff --git a/mdcms.py b/mdcms.py index 2023519..460df6a 100644 --- a/mdcms.py +++ b/mdcms.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# mdcms v0.6.0 — CLI companion +# mdcms v0.6.1 — CLI companion # # Copyright 2026 Kristian Benestad # @@ -16,8 +16,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""MD-CMS v0.6.0 — 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 os import re @@ -32,8 +33,12 @@ import certifi import click import yaml -CLI_VERSION = "0.6.0" -CLI_RELEASE_DATE = "7 June 2026" +CLI_VERSION = "0.6.1" +CLI_RELEASE_DATE = "12 June 2026" +# Site file-format version this CLI emits/expects. Distinct from CLI_VERSION: +# many CLI releases share the same site format. Site markers are validated +# against this, not against the CLI version. +SITE_FORMAT_VERSION = "0.6" MIN_SUPPORTED_VERSION = "0.3" MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE) @@ -55,6 +60,19 @@ def _parse_ver(v: str) -> tuple: return tuple(int(x) for x in v.split(".")) +def _cmp_ver(a: str, b: str) -> int: + """Compare two dotted version strings, padding missing components with zeros. + + Returns -1, 0, or 1. Padding means the site marker "0.6" and the CLI + version "0.6.0" compare equal rather than "0.6" being treated as older. + """ + ta, tb = _parse_ver(a), _parse_ver(b) + width = max(len(ta), len(tb)) + ta += (0,) * (width - len(ta)) + tb += (0,) * (width - len(tb)) + return (ta > tb) - (ta < tb) + + def read_site_version(site_path: Path) -> "str | None": config = site_path / "config.yml" if not config.exists(): @@ -69,14 +87,11 @@ def read_site_version(site_path: Path) -> "str | None": def version_status(site_version: str) -> "tuple[str, str]": """Returns (status_code, display_message). status_code: 'ok', 'outdated', 'unsupported', 'newer'.""" - sv = _parse_ver(site_version) - min_sv = _parse_ver(MIN_SUPPORTED_VERSION) - cur = _parse_ver(CLI_VERSION) - if sv < min_sv: + if _cmp_ver(site_version, MIN_SUPPORTED_VERSION) < 0: return "unsupported", f"v{site_version} — below minimum supported v{MIN_SUPPORTED_VERSION}" - if sv < cur: + if _cmp_ver(site_version, SITE_FORMAT_VERSION) < 0: return "outdated", f"v{site_version} — update available (CLI is v{CLI_VERSION})" - if sv > cur: + if _cmp_ver(site_version, SITE_FORMAT_VERSION) > 0: return "newer", f"v{site_version} — site newer than CLI (consider upgrading mdcms)" return "ok", f"v{site_version}" @@ -649,9 +664,19 @@ self.addEventListener('activate', event => {{ }}); self.addEventListener('fetch', event => {{ - if (event.request.method !== 'GET') return; + const req = event.request; + if (req.method !== 'GET') return; + // App-shell routing: serve cached index.html for every navigation, including + // clean URLs like /section-id on reload. Without this the static host returns + // 404 for those paths before any JavaScript runs. Works offline too. + if (req.mode === 'navigate') {{ + event.respondWith( + caches.match('index.html').then(shell => shell || fetch(req)) + ); + return; + }} event.respondWith( - caches.match(event.request).then(cached => cached || fetch(event.request)) + caches.match(req).then(cached => cached || fetch(req)) ); }}); """ @@ -703,7 +728,7 @@ def generate_site_manifest(site_path: Path): empty_dirs.append(str(rel).replace("\\", "/")) manifest: dict = { - "mdcms": read_site_version(site_path) or "0.4", + "mdcms": read_site_version(site_path) or SITE_FORMAT_VERSION, "files": files, } if empty_dirs: @@ -1033,6 +1058,123 @@ def build(name, path_override): click.echo(click.style("Build complete.", fg="green")) +# ─── Dependency fetching (offline mode) ─────────────────────── + +CDN_DEPS = [ + ( + "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js", + "assets/required/vendors/js-yaml.min.js", + ), + ( + "https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js", + "assets/required/vendors/marked.min.js", + ), + ( + "https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js", + "assets/required/vendors/fuse.min.js", + ), + ( + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js", + "assets/required/vendors/highlight.min.js", + ), + ( + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css", + "assets/required/vendors/github.min.css", + ), + ( + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css", + "assets/required/vendors/github-dark.min.css", + ), +] + +_WOFF2_URL_RE = re.compile( + r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""", + re.IGNORECASE, +) + + +def _fetch_bunny_fonts(site_path: Path, theme_file: str) -> list: + """Download Bunny Fonts from theme.yml to assets/fonts/. Returns list of local CSS paths.""" + theme_path = site_path / theme_file + if not theme_path.exists(): + return [] + try: + theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return [] + + fonts_dir = site_path / "assets" / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + + seen: set = set() + local_css_paths: list = [] + + for key in ("font-body", "font-heading", "font-code"): + spec = theme_data.get(key) + if not spec: + continue + parts = str(spec).split(":") + if len(parts) < 3 or parts[0].strip().lower() != "bunny": + continue + name = parts[1].strip() + weight = parts[-1].strip() + font_id = f"{name}:{weight}" + if font_id in seen: + continue + seen.add(font_id) + + bunny_url = f"https://fonts.bunny.net/css?family={name.replace(' ', '+')}:{weight}" + click.echo(f" Fetching font: {name} {weight}") + try: + css_text = _http_get(bunny_url).decode("utf-8") + except Exception as e: + click.echo(click.style(f" Warning: could not fetch {bunny_url}: {e}", fg="yellow")) + continue + + def _rewrite(m: "re.Match") -> str: + woff2_url = m.group(1) + filename = woff2_url.split("/")[-1].split("?")[0] + dest = fonts_dir / filename + if not dest.exists(): + try: + dest.write_bytes(_http_get(woff2_url)) + except Exception as e: + click.echo(click.style(f" Warning: could not fetch {woff2_url}: {e}", fg="yellow")) + return m.group(0) + return f"url('../fonts/{filename}')" + + local_css = _WOFF2_URL_RE.sub(_rewrite, css_text) + safe_name = name.lower().replace(" ", "-") + css_filename = f"{safe_name}-{weight}.css" + (fonts_dir / css_filename).write_text(local_css, encoding="utf-8") + local_css_paths.append(f"assets/fonts/{css_filename}") + click.echo(f" Wrote assets/fonts/{css_filename}") + + return local_css_paths + + +def _patch_index_html(site_path: Path, local_font_css: list): + """Replace CDN tags with local paths and inject font link tags.""" + index_path = site_path / "index.html" + if not index_path.exists(): + raise click.ClickException("index.html not found in site directory.") + + html = index_path.read_text(encoding="utf-8") + + for cdn_url, local_path in CDN_DEPS: + html = html.replace(cdn_url, local_path) + + if local_font_css: + links = "\n".join( + f'' + for p in local_font_css + ) + html = html.replace("", f"{links}\n", 1) + + index_path.write_text(html, encoding="utf-8") + click.echo(" Patched index.html") + + @cli.command("fetch-deps") @click.argument("name", required=False, default=None) @click.option("--path", "path_override", default=None, type=click.Path(), @@ -1066,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(): diff --git a/pyproject.toml b/pyproject.toml index f900124..09871eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdcms" -version = "0.6.0" +version = "0.6.1" description = "MD-CMS — Markdown-based CMS companion CLI" readme = "README.md" license = { text = "Apache-2.0" }