diff --git a/CLAUDE.md b/CLAUDE.md index 68bccfb..38dab57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,20 @@ During development, run directly: `python3 mdcms.py ` | `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. | +## 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/assets/images/favicon.png b/app/assets/images/favicon.png new file mode 100644 index 0000000..0d8dd34 Binary files /dev/null and b/app/assets/images/favicon.png differ diff --git a/app/config.yml b/app/config.yml index d4f01c7..ca3604f 100644 --- a/app/config.yml +++ b/app/config.yml @@ -1,5 +1,5 @@ -# Minimum supported version: mdcms v0.3.2 | DO NOT REMOVE THIS COMMENT -# MD-CMS v0.3.2 — Site configuration +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# MD-CMS v0.4 — Site configuration # # Only `sitename` and `navigation` are required. Uncomment and edit the rest # as needed. See https://kbenestad.codeberg.page/md-cms for the full reference. @@ -20,10 +20,16 @@ # ────────────────────────────────── # Site identity # ────────────────────────────────── -sitename: MD-CMS New Site -navigation: topbar # sidebar | topbar +sitename: MD-CMS Phase 7 Test +navigation: sidebar # sidebar | topbar theme: theme.yml # presentational config — edit theme.yml to customise colours, fonts, and layout +pwa: yes +pwa-name: MD-CMS Phase 7 Test +pwa-shortname: MDCMS Test +pwa-colour: "#2563EB" +offline-message: "This page is not available offline. Connect to the internet and reload." + # homepage: pages/home.md # override the default landing page # sitedescription: A short description for meta tags diff --git a/app/index.html b/app/index.html index 1466873..48a33d2 100644 --- a/app/index.html +++ b/app/index.html @@ -1,4 +1,4 @@ - + @@ -2608,10 +2616,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(''); @@ -2706,6 +2715,14 @@ function fmtDatetime(dtStr) { } catch (e) { /* fall back to hardcoded CSS defaults */ } } + 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/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..85a543b --- /dev/null +++ b/app/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "/", + "name": "MD-CMS Phase 7 Test", + "short_name": "MDCMS Test", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2563EB", + "icons": [ + { + "src": "assets/images/favicon.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "assets/images/favicon.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} \ No newline at end of file diff --git a/app/nav.yml b/app/nav.yml index 68a0854..655a3c5 100644 --- a/app/nav.yml +++ b/app/nav.yml @@ -1,7 +1,6 @@ -# nav.yml — generated by mdcms.py +# nav.yml — generated by mdcms # Manual edits to section metadata (defaultname, sort, parent, parent-sort, -# pagesvisibility, categorynames) are preserved on rebuild. New sections -# are auto-created from page frontmatter section-id values. +# pagesvisibility, categorynames) are preserved on rebuild. sections: # (none yet — add section-id to page frontmatter to auto-create) @@ -9,6 +8,11 @@ pages: - file: pages/home.md title: Home sort: 100 - variants: [en] - titles: - en: Home + + - file: pages/about.md + title: About + sort: 200 + + - file: pages/docs.md + title: Docs + sort: 300 diff --git a/app/pages/about.md b/app/pages/about.md new file mode 100644 index 0000000..77812fc --- /dev/null +++ b/app/pages/about.md @@ -0,0 +1,8 @@ +--- +title: About +sort: 200 +--- + +# About + +This is a sample page for Phase 7 PWA testing. Navigate here from the sidebar, then go offline and reload — this page should still be available from the service worker cache. diff --git a/app/pages/docs.md b/app/pages/docs.md new file mode 100644 index 0000000..4f26c2d --- /dev/null +++ b/app/pages/docs.md @@ -0,0 +1,8 @@ +--- +title: Docs +sort: 300 +--- + +# Docs + +Another sample page for Phase 7 PWA testing. Visit this page while online, then go offline — it should remain accessible from the cache. diff --git a/app/pages/home.md b/app/pages/home.md index 1fe5fbc..d3a115e 100644 --- a/app/pages/home.md +++ b/app/pages/home.md @@ -3,24 +3,24 @@ title: Home sort: 100 --- -# Phase 5 — Table of Contents Tag +# Phase 7 — PWA Test -The `toc` tag renders a section-grouped list of all pages visible for the active category. The TOC page itself is excluded. +This page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`. ---- +## Test procedure -## Basic TOC +1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/` +2. Load `http://localhost:8800` — service worker registers on first load +3. Navigate to the **About** and **Docs** pages so they are fetched and cached +4. Stop the HTTP server (`Ctrl+C` in its terminal) +5. Reload — site should load fully from the service worker cache +6. Navigate between pages — all should work offline +7. Check that a page not yet visited shows the offline message -```mdcms -toc -``` +## What to look for ---- - -## What to verify - -- All sections appear as headings in sort order -- Pages within each section appear in sort order -- This page (Home) does **not** appear in the list -- Draft pages are excluded -- Switching category (if enabled) updates the page list +- `manifest.json` and `service-worker.js` exist after build +- DevTools → Application → Service Workers: status **activated and running** +- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed +- Site loads fully with server stopped +- Offline message (`config.yml: offline-message`) appears for uncached pages diff --git a/app/search.json b/app/search.json index 5ec1f8a..d6dfd7a 100644 --- a/app/search.json +++ b/app/search.json @@ -1,4 +1,28 @@ [ + { + "file": "pages/about.md", + "title": "About", + "section-id": null, + "keywords": "", + "description": "", + "author": null, + "created": "", + "modified": "", + "language": "en", + "body": "# About\n\nThis is a sample page for Phase 7 PWA testing. Navigate here from the sidebar, then go offline and reload — this page should still be available from the service worker cache.\n" + }, + { + "file": "pages/docs.md", + "title": "Docs", + "section-id": null, + "keywords": "", + "description": "", + "author": null, + "created": "", + "modified": "", + "language": "en", + "body": "# Docs\n\nAnother sample page for Phase 7 PWA testing. Visit this page while online, then go offline — it should remain accessible from the cache.\n" + }, { "file": "pages/home.md", "title": "Home", @@ -6,10 +30,9 @@ "keywords": "", "description": "", "author": null, - "date": "", - "datetime": "", + "created": "", + "modified": "", "language": "en", - "body": "# Post Listing Tests\n\n## Reverse chronological (newest first)\n\n```mdcms\nposts-date-reversechronological\nlimit: 3\npaginate: no\n```\n\n## Chronological (oldest first)\n\n```mdcms\nposts-date-chronological\nlimit: all\npaginate: none\n```\n\n## By year (date, reverse chrono)\n\n```mdcms\nposts-date-reversechronological-byyear\nlimit: all\ndefaultyear: current\nselectyear: yes\npaginate: none\n```\n\n## By year+month (datetime, chrono)\n\n```mdcms\nposts-datetime-chronological-byyearmonth\nlimit: all\ndefaultyear: 2024\nselectyear: yes\n```\n\n## Last 30 days\n\n```mdcms\nposts-date-reversechronological-lastmonth\nlimit: all\npaginate: none\n```\n\n## Paginated (2 per page)\n\n```mdcms\nposts-datetime-reversechronological\nlimit: 2\npaginate: yes\n```\n", - "category": "en" + "body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n" } ] \ No newline at end of file diff --git a/app/service-worker.js b/app/service-worker.js new file mode 100644 index 0000000..a357757 --- /dev/null +++ b/app/service-worker.js @@ -0,0 +1,57 @@ +// mdcms service worker — generated by mdcms build +const CACHE_NAME = 'mdcms-eb384247'; +const PRECACHE_URLS = [ + "index.html", + "config.yml", + "nav.yml", + "search.json", + "theme.yml", + "pages/about.md", + "pages/docs.md", + "pages/home.md", + "posts/.gitkeep", + "assets/fonts/.gitkeep", + "assets/icons/.gitkeep", + "assets/icons/arrow_drop_down.svg", + "assets/icons/arrow_right.svg", + "assets/icons/dangerous.svg", + "assets/icons/dark_mode.svg", + "assets/icons/error.svg", + "assets/icons/exclamation.svg", + "assets/icons/history.svg", + "assets/icons/info.svg", + "assets/icons/language.svg", + "assets/icons/light_mode.svg", + "assets/icons/menu.svg", + "assets/icons/mobile_arrow_down.svg", + "assets/icons/report.svg", + "assets/icons/search.svg", + "assets/icons/success.svg", + "assets/icons/text_compare.svg", + "assets/icons/warning.svg", + "assets/images/.gitkeep", + "assets/images/favicon.png" +]; + +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)) + ); +}); diff --git a/app/theme.yml b/app/theme.yml index d6fead7..30be1bf 100644 --- a/app/theme.yml +++ b/app/theme.yml @@ -1,3 +1,4 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT # mdcms theme — default # Edit colours, fonts, and layout here. See docs for full reference. diff --git a/mdcms.py b/mdcms.py index bfb26c5..26c8414 100644 --- a/mdcms.py +++ b/mdcms.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # -# mdcms v0.3.8 — CLI companion +# mdcms v0.4.0 — CLI companion # # Copyright 2026 Kristian Benestad # Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0 -"""MD-CMS v0.3.8 — CLI tool for managing and building MD-CMS sites.""" +"""MD-CMS v0.4.0 — 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.8" +CLI_VERSION = "0.4.0" 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,129 +496,89 @@ def run_build(site_path: Path): )) -# ─── Dependency fetching ────────────────────────────────────── +# ─── PWA generation ─────────────────────────────────────────── -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", - ), -] +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") + favicon = cfg.get("favicon", "favicon.png") + icon_src = f"assets/images/{favicon}" -_WOFF2_URL_RE = re.compile( - r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""", - re.IGNORECASE, -) + icons = [] + if (site_path / icon_src).exists(): + icons = [ + {"src": icon_src, "sizes": "192x192", "type": "image/png", "purpose": "any"}, + {"src": icon_src, "sizes": "512x512", "type": "image/png", "purpose": "any"}, + ] + # manifest.json + manifest = { + "id": "/", + "name": pwa_name, + "short_name": pwa_shortname, + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": pwa_colour, + "icons": icons, + } + (site_path / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + click.echo(" Wrote manifest.json") -def _http_get(url: str, timeout: int = 30) -> bytes: - ssl_ctx = ssl.create_default_context(cafile=certifi.where()) - req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"}) - with urllib.request.urlopen(req, context=ssl_ctx, timeout=timeout) as resp: - return resp.read() + # 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) - -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: + for folder in ("pages", "posts", "assets"): + d = site_path / folder + if not d.is_dir(): 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) + for f in sorted(d.rglob("*")): + if f.is_file(): + precache.append(str(f.relative_to(site_path)).replace("\\", "/")) - 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 + # Version hash — deterministic from sorted file list + cache_hash = format(hash(tuple(sorted(precache))) & 0xFFFFFFFF, "08x") + cache_name = f"mdcms-{cache_hash}" - 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}')" + 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}; - 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}") +self.addEventListener('install', event => {{ + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}}); - 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") +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 ───────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index 65aeb49..1b33a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdcms" -version = "0.3.8" +version = "0.4.0" description = "MD-CMS — Markdown-based CMS companion CLI" readme = "README.md" license = { text = "Apache-2.0" } diff --git a/test_phase.py b/test_phase.py index 9c56567..534a728 100644 --- a/test_phase.py +++ b/test_phase.py @@ -30,7 +30,7 @@ PHASES = { 4: ("claude/debug-api-errors-gd730", "Callout tags"), 5: ("claude/toc-tag-phase5", "Table of contents tag"), 6: ("claude/fetch-deps-phase6", "Offline / fetch-deps"), - 7: ("v0.4_phase7", "PWA — service worker and manifest"), + 7: ("claude/pwa-phase7", "PWA — service worker and manifest"), } VERIFY = { @@ -111,6 +111,12 @@ EXTRA_FILES = { 5: [ "app/pages/home.md", # has Phase 5 TOC test case ], + 7: [ + "app/config.yml", # pwa: yes, sidebar nav, offline-message + "app/pages/home.md", # Phase 7 test instructions + "app/pages/about.md", # sample page to cache and test offline + "app/pages/docs.md", # sample page to cache and test offline + ], }