From 92615fad1cac2da764272c4d5d2c0b3d7b21388c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 17:02:37 +0000 Subject: [PATCH] v0.4 Phase 7: PWA support + bump to 0.3.8 - Add generate_pwa(): builds manifest.json (name, short_name, theme_color, standalone display, favicon icon) and service-worker.js with a cache-first strategy; cache name is versioned by a hash of the precache file list so new builds automatically invalidate old caches; precache list covers index.html, config.yml, nav.yml, search.json, theme file, and all pages/posts/assets - Call generate_pwa() from run_build() when pwa: yes/true in config.yml - index.html: add and SW registration script in ; both silently no-op when the generated files are absent - index.html boot(): write offline-message from config to localStorage on every load so the message survives cache eviction - index.html navigateTo(): show localStorage offline message when a page fetch fails instead of the generic not-found message - Update CLAUDE.md with PWA config key reference https://claude.ai/code/session_015XtsgTMi8UtmgxEgb5Qt2c --- CLAUDE.md | 14 ++++++++ app/index.html | 26 ++++++++++++--- mdcms.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1ba4a59..0760507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,20 @@ During development, run directly: `python3 mdcms.py ` | `mdcms build --path ` | Build using an explicit path — no registry needed. Intended for CI/CD. | | `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. | +## PWA config keys + +Set in `config.yml`. `mdcms build` generates `manifest.json` and `service-worker.js` when `pwa: yes`. + +```yaml +pwa: yes +pwa-name: "My Documentation" # mandatory if pwa: yes +pwa-shortname: "MyDocs" # optional short name for home screen +pwa-colour: "#2563EB" # optional browser chrome colour +offline-message: + en: "You are offline and some content is unavailable." + 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. ## Architecture of `mdcms.py` diff --git a/app/index.html b/app/index.html index 1cb851f..ca0b5c4 100644 --- a/app/index.html +++ b/app/index.html @@ -24,6 +24,14 @@ MD-CMS + + @@ -2541,10 +2549,11 @@ function fmtDatetime(dtStr) { const result = await fetchPageFile(file); if (!result.ok) { - contentEl.innerHTML = `
-

Page not available

-

${pageNotFoundMessage()}

-
`; + const offlineMsg = localStorage.getItem('mdcms-offline'); + const bodyMsg = offlineMsg + ? `

${offlineMsg}

` + : `

${pageNotFoundMessage()}

`; + contentEl.innerHTML = `

Page not available

${bodyMsg}
`; document.title = (config.sitename || 'MD-CMS'); refreshCategoryBar(); if (categoriesUse) populateCategoryOptions(''); @@ -2639,6 +2648,15 @@ function fmtDatetime(dtStr) { } catch (e) { /* fall back to hardcoded CSS defaults */ } } + // Write offline message to localStorage for SW offline fallback + const offlineMsgCfg = config['offline-message']; + if (offlineMsgCfg) { + const offlineText = typeof offlineMsgCfg === 'string' + ? offlineMsgCfg + : (offlineMsgCfg[defaultCategoryCode] || offlineMsgCfg['en'] || Object.values(offlineMsgCfg)[0] || ''); + if (offlineText) localStorage.setItem('mdcms-offline', offlineText); + } + loadFonts(themeConfig); initCategories(); diff --git a/mdcms.py b/mdcms.py index 473c2f6..244f56d 100644 --- a/mdcms.py +++ b/mdcms.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # -# mdcms v0.3.6 — CLI companion +# mdcms v0.3.9 — CLI companion # # Copyright 2026 Kristian Benestad # Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0 -"""MD-CMS v0.3.5 — CLI tool for managing and building MD-CMS sites.""" +"""MD-CMS v0.3.9 — CLI tool for managing and building MD-CMS sites.""" import json import os @@ -21,7 +21,7 @@ import certifi import click import yaml -CLI_VERSION = "0.3.6" +CLI_VERSION = "0.3.9" CLI_RELEASE_DATE = "17 May 2026" MIN_SUPPORTED_VERSION = "0.3" @@ -480,6 +480,10 @@ def run_build(site_path: Path): ) click.echo(f" Wrote search.json ({len(live_pages) + len(post_records)} entries)") + pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true") + if pwa_enabled: + generate_pwa(site_path, cfg) + asset_warnings = validate_assets(site_path, cfg) for w in asset_warnings: click.echo(click.style(w, fg="yellow")) @@ -492,6 +496,82 @@ def run_build(site_path: Path): )) +# ─── PWA generation ─────────────────────────────────────────── + +def generate_pwa(site_path: Path, cfg: dict): + """Generate manifest.json and service-worker.js when pwa: yes.""" + pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site")) + pwa_shortname = cfg.get("pwa-shortname", pwa_name) + pwa_colour = cfg.get("pwa-colour", "#2563EB") + + # manifest.json + manifest = { + "name": pwa_name, + "short_name": pwa_shortname, + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": pwa_colour, + "icons": [ + {"src": "assets/images/favicon.png", "sizes": "any", "type": "image/png"} + ], + } + (site_path / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + click.echo(" Wrote manifest.json") + + # Collect all files to precache + precache: list = [ + "index.html", "config.yml", "nav.yml", "search.json", + ] + theme_file = cfg.get("theme") + if theme_file and (site_path / theme_file).exists(): + precache.append(theme_file) + + for folder in ("pages", "posts", "assets"): + d = site_path / folder + if not d.is_dir(): + continue + for f in sorted(d.rglob("*")): + if f.is_file(): + precache.append(str(f.relative_to(site_path)).replace("\\", "/")) + + # Version hash — deterministic from sorted file list + cache_hash = format(hash(tuple(sorted(precache))) & 0xFFFFFFFF, "08x") + cache_name = f"mdcms-{cache_hash}" + + urls_js = json.dumps(precache, indent=2, ensure_ascii=False) + sw = f"""// mdcms service worker — generated by mdcms build +const CACHE_NAME = '{cache_name}'; +const PRECACHE_URLS = {urls_js}; + +self.addEventListener('install', event => {{ + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}}); + +self.addEventListener('activate', event => {{ + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}}); + +self.addEventListener('fetch', event => {{ + if (event.request.method !== 'GET') return; + event.respondWith( + caches.match(event.request).then(cached => cached || fetch(event.request)) + ); +}}); +""" + (site_path / "service-worker.js").write_text(sw, encoding="utf-8") + click.echo(f" Wrote service-worker.js (cache: {cache_name})") + # ─── GitHub template download ───────────────────────────────── def _github_get(url: str) -> bytes: diff --git a/pyproject.toml b/pyproject.toml index 74807ec..876b032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdcms" -version = "0.3.6" +version = "0.3.9" description = "MD-CMS — Markdown-based CMS companion CLI" readme = "README.md" license = { text = "Apache-2.0" }