v0.4.0 — Phase 7: PWA (service worker, manifest, offline support)

Completes the v0.4 milestone. All 7 phases merged and tested.

Phase 7: manifest.json + service-worker.js generation, cache-first offline SW,
offline message from config.yml, favicon.png, PWA installable on desktop.

Also: callout border-color/background CSS fix; v0.4.0 version bump across all files.
This commit is contained in:
Kristian Benestad 2026-05-18 03:40:58 +07:00 committed by GitHub
commit ac0b634cc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 283 additions and 152 deletions

View file

@ -62,6 +62,20 @@ During development, run directly: `python3 mdcms.py <command>`
| `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. |
## 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`

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

View file

@ -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

View file

@ -1,4 +1,4 @@
<!-- Minimum supported version: mdcms v0.3.8 | DO NOT REMOVE THIS COMMENT -->
<!-- mdcms v0.4 | DO NOT REMOVE THIS COMMENT -->
<!--
MD-CMS v0.3.8 — Renderer
@ -24,6 +24,14 @@
<title>MD-CMS</title>
<meta name="description" content="">
<link rel="icon" href="assets/images/favicon.png">
<link rel="manifest" href="manifest.json">
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js').catch(() => {});
});
}
</script>
<!-- Libraries (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
@ -2608,10 +2616,11 @@ function fmtDatetime(dtStr) {
const result = await fetchPageFile(file);
if (!result.ok) {
contentEl.innerHTML = `<div class="error-message">
<h2>Page not available</h2>
<p>${pageNotFoundMessage()}</p>
</div>`;
const offlineMsg = localStorage.getItem('mdcms-offline');
const bodyMsg = offlineMsg
? `<p>${offlineMsg}</p>`
: `<p>${pageNotFoundMessage()}</p>`;
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2>${bodyMsg}</div>`;
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();

23
app/manifest.json Normal file
View file

@ -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"
}
]
}

View file

@ -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

8
app/pages/about.md Normal file
View file

@ -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.

8
app/pages/docs.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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"
}
]

57
app/service-worker.js Normal file
View file

@ -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))
);
});

View file

@ -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.

204
mdcms.py
View file

@ -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"},
]
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()
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'<link rel="stylesheet" href="{p}" data-mdcms-fonts="1">'
for p in local_font_css
# 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"
)
html = html.replace("</head>", f"{links}\n</head>", 1)
click.echo(" Wrote manifest.json")
index_path.write_text(html, encoding="utf-8")
click.echo(" Patched index.html")
# 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 ─────────────────────────────────

View file

@ -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" }

View file

@ -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
],
}