mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
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:
commit
ac0b634cc0
15 changed files with 283 additions and 152 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -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 [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. |
|
||||||
|
|
||||||
|
## 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.
|
**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`
|
## Architecture of `mdcms.py`
|
||||||
|
|
|
||||||
BIN
app/assets/images/favicon.png
Normal file
BIN
app/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 747 B |
|
|
@ -1,5 +1,5 @@
|
||||||
# Minimum supported version: mdcms v0.3.2 | DO NOT REMOVE THIS COMMENT
|
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
|
||||||
# MD-CMS v0.3.2 — Site configuration
|
# MD-CMS v0.4 — Site configuration
|
||||||
#
|
#
|
||||||
# Only `sitename` and `navigation` are required. Uncomment and edit the rest
|
# Only `sitename` and `navigation` are required. Uncomment and edit the rest
|
||||||
# as needed. See https://kbenestad.codeberg.page/md-cms for the full reference.
|
# as needed. See https://kbenestad.codeberg.page/md-cms for the full reference.
|
||||||
|
|
@ -20,10 +20,16 @@
|
||||||
# ──────────────────────────────────
|
# ──────────────────────────────────
|
||||||
# Site identity
|
# Site identity
|
||||||
# ──────────────────────────────────
|
# ──────────────────────────────────
|
||||||
sitename: MD-CMS New Site
|
sitename: MD-CMS Phase 7 Test
|
||||||
navigation: topbar # sidebar | topbar
|
navigation: sidebar # sidebar | topbar
|
||||||
theme: theme.yml # presentational config — edit theme.yml to customise colours, fonts, and layout
|
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
|
# homepage: pages/home.md # override the default landing page
|
||||||
|
|
||||||
# sitedescription: A short description for meta tags
|
# sitedescription: A short description for meta tags
|
||||||
|
|
|
||||||
|
|
@ -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
|
MD-CMS v0.3.8 — Renderer
|
||||||
|
|
||||||
|
|
@ -24,6 +24,14 @@
|
||||||
<title>MD-CMS</title>
|
<title>MD-CMS</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
<link rel="icon" href="assets/images/favicon.png">
|
<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) -->
|
<!-- Libraries (CDN) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
<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);
|
const result = await fetchPageFile(file);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
contentEl.innerHTML = `<div class="error-message">
|
const offlineMsg = localStorage.getItem('mdcms-offline');
|
||||||
<h2>Page not available</h2>
|
const bodyMsg = offlineMsg
|
||||||
<p>${pageNotFoundMessage()}</p>
|
? `<p>${offlineMsg}</p>`
|
||||||
</div>`;
|
: `<p>${pageNotFoundMessage()}</p>`;
|
||||||
|
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2>${bodyMsg}</div>`;
|
||||||
document.title = (config.sitename || 'MD-CMS');
|
document.title = (config.sitename || 'MD-CMS');
|
||||||
refreshCategoryBar();
|
refreshCategoryBar();
|
||||||
if (categoriesUse) populateCategoryOptions('');
|
if (categoriesUse) populateCategoryOptions('');
|
||||||
|
|
@ -2706,6 +2715,14 @@ function fmtDatetime(dtStr) {
|
||||||
} catch (e) { /* fall back to hardcoded CSS defaults */ }
|
} 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);
|
loadFonts(themeConfig);
|
||||||
initCategories();
|
initCategories();
|
||||||
|
|
||||||
|
|
|
||||||
23
app/manifest.json
Normal file
23
app/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
app/nav.yml
16
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,
|
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
|
||||||
# pagesvisibility, categorynames) are preserved on rebuild. New sections
|
# pagesvisibility, categorynames) are preserved on rebuild.
|
||||||
# are auto-created from page frontmatter section-id values.
|
|
||||||
|
|
||||||
sections:
|
sections:
|
||||||
# (none yet — add section-id to page frontmatter to auto-create)
|
# (none yet — add section-id to page frontmatter to auto-create)
|
||||||
|
|
@ -9,6 +8,11 @@ pages:
|
||||||
- file: pages/home.md
|
- file: pages/home.md
|
||||||
title: Home
|
title: Home
|
||||||
sort: 100
|
sort: 100
|
||||||
variants: [en]
|
|
||||||
titles:
|
- file: pages/about.md
|
||||||
en: Home
|
title: About
|
||||||
|
sort: 200
|
||||||
|
|
||||||
|
- file: pages/docs.md
|
||||||
|
title: Docs
|
||||||
|
sort: 300
|
||||||
|
|
|
||||||
8
app/pages/about.md
Normal file
8
app/pages/about.md
Normal 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
8
app/pages/docs.md
Normal 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.
|
||||||
|
|
@ -3,24 +3,24 @@ title: Home
|
||||||
sort: 100
|
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
|
## What to look for
|
||||||
toc
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- `manifest.json` and `service-worker.js` exist after build
|
||||||
|
- DevTools → Application → Service Workers: status **activated and running**
|
||||||
## What to verify
|
- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed
|
||||||
|
- Site loads fully with server stopped
|
||||||
- All sections appear as headings in sort order
|
- Offline message (`config.yml: offline-message`) appears for uncached pages
|
||||||
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"file": "pages/home.md",
|
||||||
"title": "Home",
|
"title": "Home",
|
||||||
|
|
@ -6,10 +30,9 @@
|
||||||
"keywords": "",
|
"keywords": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": null,
|
"author": null,
|
||||||
"date": "",
|
"created": "",
|
||||||
"datetime": "",
|
"modified": "",
|
||||||
"language": "en",
|
"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",
|
"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"
|
||||||
"category": "en"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
57
app/service-worker.js
Normal file
57
app/service-worker.js
Normal 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))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
|
||||||
# mdcms theme — default
|
# mdcms theme — default
|
||||||
# Edit colours, fonts, and layout here. See docs for full reference.
|
# Edit colours, fonts, and layout here. See docs for full reference.
|
||||||
|
|
||||||
|
|
|
||||||
194
mdcms.py
194
mdcms.py
|
|
@ -1,11 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
#
|
#
|
||||||
# mdcms v0.3.8 — CLI companion
|
# mdcms v0.4.0 — CLI companion
|
||||||
#
|
#
|
||||||
# Copyright 2026 Kristian Benestad
|
# Copyright 2026 Kristian Benestad
|
||||||
# Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0
|
# 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 json
|
||||||
import os
|
import os
|
||||||
|
|
@ -21,7 +21,7 @@ import certifi
|
||||||
import click
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
CLI_VERSION = "0.3.8"
|
CLI_VERSION = "0.4.0"
|
||||||
CLI_RELEASE_DATE = "17 May 2026"
|
CLI_RELEASE_DATE = "17 May 2026"
|
||||||
MIN_SUPPORTED_VERSION = "0.3"
|
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)")
|
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)
|
asset_warnings = validate_assets(site_path, cfg)
|
||||||
for w in asset_warnings:
|
for w in asset_warnings:
|
||||||
click.echo(click.style(w, fg="yellow"))
|
click.echo(click.style(w, fg="yellow"))
|
||||||
|
|
@ -492,129 +496,89 @@ def run_build(site_path: Path):
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# ─── Dependency fetching ──────────────────────────────────────
|
# ─── PWA generation ───────────────────────────────────────────
|
||||||
|
|
||||||
CDN_DEPS = [
|
def generate_pwa(site_path: Path, cfg: dict):
|
||||||
(
|
"""Generate manifest.json and service-worker.js when pwa: yes."""
|
||||||
"https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
|
pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site"))
|
||||||
"assets/required/vendors/js-yaml.min.js",
|
pwa_shortname = cfg.get("pwa-shortname", pwa_name)
|
||||||
),
|
pwa_colour = cfg.get("pwa-colour", "#2563EB")
|
||||||
(
|
favicon = cfg.get("favicon", "favicon.png")
|
||||||
"https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js",
|
icon_src = f"assets/images/{favicon}"
|
||||||
"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(
|
icons = []
|
||||||
r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""",
|
if (site_path / icon_src).exists():
|
||||||
re.IGNORECASE,
|
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:
|
# Collect all files to precache
|
||||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
precache: list = [
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
|
"index.html", "config.yml", "nav.yml", "search.json",
|
||||||
with urllib.request.urlopen(req, context=ssl_ctx, timeout=timeout) as resp:
|
]
|
||||||
return resp.read()
|
theme_file = cfg.get("theme")
|
||||||
|
if theme_file and (site_path / theme_file).exists():
|
||||||
|
precache.append(theme_file)
|
||||||
|
|
||||||
|
for folder in ("pages", "posts", "assets"):
|
||||||
def _fetch_bunny_fonts(site_path: Path, theme_file: str) -> list:
|
d = site_path / folder
|
||||||
"""Download Bunny Fonts from theme.yml to assets/fonts/. Returns list of local CSS paths."""
|
if not d.is_dir():
|
||||||
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
|
continue
|
||||||
parts = str(spec).split(":")
|
for f in sorted(d.rglob("*")):
|
||||||
if len(parts) < 3 or parts[0].strip().lower() != "bunny":
|
if f.is_file():
|
||||||
continue
|
precache.append(str(f.relative_to(site_path)).replace("\\", "/"))
|
||||||
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}"
|
# Version hash — deterministic from sorted file list
|
||||||
click.echo(f" Fetching font: {name} {weight}")
|
cache_hash = format(hash(tuple(sorted(precache))) & 0xFFFFFFFF, "08x")
|
||||||
try:
|
cache_name = f"mdcms-{cache_hash}"
|
||||||
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:
|
urls_js = json.dumps(precache, indent=2, ensure_ascii=False)
|
||||||
woff2_url = m.group(1)
|
sw = f"""// mdcms service worker — generated by mdcms build
|
||||||
filename = woff2_url.split("/")[-1].split("?")[0]
|
const CACHE_NAME = '{cache_name}';
|
||||||
dest = fonts_dir / filename
|
const PRECACHE_URLS = {urls_js};
|
||||||
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)
|
self.addEventListener('install', event => {{
|
||||||
safe_name = name.lower().replace(" ", "-")
|
event.waitUntil(
|
||||||
css_filename = f"{safe_name}-{weight}.css"
|
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
|
||||||
(fonts_dir / css_filename).write_text(local_css, encoding="utf-8")
|
);
|
||||||
local_css_paths.append(f"assets/fonts/{css_filename}")
|
self.skipWaiting();
|
||||||
click.echo(f" Wrote assets/fonts/{css_filename}")
|
}});
|
||||||
|
|
||||||
return local_css_paths
|
self.addEventListener('activate', event => {{
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
def _patch_index_html(site_path: Path, local_font_css: list):
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
"""Replace CDN tags with local paths and inject font link tags."""
|
)
|
||||||
index_path = site_path / "index.html"
|
);
|
||||||
if not index_path.exists():
|
self.clients.claim();
|
||||||
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
|
|
||||||
)
|
|
||||||
html = html.replace("</head>", f"{links}\n</head>", 1)
|
|
||||||
|
|
||||||
index_path.write_text(html, encoding="utf-8")
|
|
||||||
click.echo(" Patched index.html")
|
|
||||||
|
|
||||||
|
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 ─────────────────────────────────
|
# ─── GitHub template download ─────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mdcms"
|
name = "mdcms"
|
||||||
version = "0.3.8"
|
version = "0.4.0"
|
||||||
description = "MD-CMS — Markdown-based CMS companion CLI"
|
description = "MD-CMS — Markdown-based CMS companion CLI"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "Apache-2.0" }
|
license = { text = "Apache-2.0" }
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ PHASES = {
|
||||||
4: ("claude/debug-api-errors-gd730", "Callout tags"),
|
4: ("claude/debug-api-errors-gd730", "Callout tags"),
|
||||||
5: ("claude/toc-tag-phase5", "Table of contents tag"),
|
5: ("claude/toc-tag-phase5", "Table of contents tag"),
|
||||||
6: ("claude/fetch-deps-phase6", "Offline / fetch-deps"),
|
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 = {
|
VERIFY = {
|
||||||
|
|
@ -111,6 +111,12 @@ EXTRA_FILES = {
|
||||||
5: [
|
5: [
|
||||||
"app/pages/home.md", # has Phase 5 TOC test case
|
"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
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue