Compare commits

...

30 commits

Author SHA1 Message Date
8295cbca2c
Merge pull request #21 from kbenestad/development
Some checks failed
/ mirror (push) Has been cancelled
Merge main into development — sync all theme system changes
2026-06-05 10:56:29 +07:00
be1e908615
Merge pull request #25 from kbenestad/claude/mdcms-arm-release-compile-8R4Sa
Add Linux arm64 release binary and .deb package
2026-06-05 10:56:04 +07:00
Claude
0e14e80d1f
Add Linux arm64 release binary and .deb package
Adds an ubuntu-24.04-arm matrix entry so each release produces
mdcms-linux-arm64 and a matching arm64 .deb alongside the existing
amd64 artefacts. Fixes the fpm binary path and .deb artifact name to
be matrix-driven so both Linux builds are independent.

https://claude.ai/code/session_01LScjwzJJgLKsNrqEPLxJS8
2026-06-05 03:52:18 +00:00
2e2fd2093f
Add files via upload 2026-05-21 23:57:51 +07:00
Claude
ee3a967b86
docs: add tabs and accordion reference to reference-pages.md
Documents all four block types (tab-underline, tab-filled,
accordion-underline, accordion-filled and their aliases) with
per-item key tables and worked examples, in the same style as
the existing callout/toc/posts-* sections.

https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs
2026-05-21 16:56:44 +00:00
Claude
a4031bc008
docs: remind Claude to fetch remote branches before assuming one doesn't exist
https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs
2026-05-21 16:41:45 +00:00
Claude
a09df3a63c
Add tabs and accordion content components to index.html
Implements four new mdcms fenced-block types:
  tab-underline / tab, tab-filled,
  accordion-underline / accordion, accordion-filled

Each block reads items: from a YAML body. Tab state and accordion
open/close are managed with aria-selected / aria-expanded and
data-open attributes. Markdown content inside each item is rendered
with the same pipeline as the surrounding page.

Adds computeDerivedTokens() — called on every applyTheme() — which
computes --mdcms-bar, --mdcms-filled-bg/border/fg, --mdcms-strip-border
from the active palette. Uses HSL chroma (S × (1-|2L-1|)) instead of raw
HSL S for the bold-nav heuristic, avoiding a false-positive on near-white
nav colours like the default #F8FAFC.

Adds app/pages/tabs-accordions.md as a visual test page and
docs/unreleased.md to track this change ahead of the next release.

https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs
2026-05-21 16:41:27 +00:00
099320cde7
Create .gitkeep 2026-05-21 23:21:43 +07:00
Claude
4c8ca31651
fix: hidden categories now auto-switch to default instead of showing error
Two bugs fixed in navigateTo:

1. When a category has visibilityifnocontent: hidden (default) and the
   current page has no variant for it, the renderer now silently switches
   to the default category before fetching. Previously the category stayed
   active (kept visible in the selector via the activeCategory guard),
   the fetch failed, and an error page was shown.

2. The offline message stored in localStorage was shown for any failed
   fetch, not just genuine offline situations. Now gated on !navigator.onLine
   so missing pages always show pagenotfoundmessage instead.
2026-05-21 15:27:10 +00:00
Claude
5f3175497b
Merge branch claude/update-config-docs-NnldI into development 2026-05-21 15:07:09 +00:00
Claude
ef4197fa83
feat: add visibilityifnocontent per-category config key
When set to visible, the category always appears in the selector
even when no variant exists for the current page. Navigating to
such a page shows pagenotfoundmessage with no fallback to
default-category content. Default behaviour (hidden) is unchanged.

Updates pageShouldDisplay and visibleCategoryCodesForCurrentPage
to honour the new key alongside the existing notfoundmessage logic.
Docs updated with key description, summary table, and full example.
2026-05-21 15:02:31 +00:00
Claude
cc4ed7b881
docs: add missing per-category config keys to reference-config.md
Documents all keys that can appear under default-category and
categories entries: message, name-latin, notfoundmessage,
pagenotfoundmessage, font, and line-height. Adds a summary table
and updates the full example to show these keys in context.
2026-05-21 14:46:46 +00:00
Claude
b9410d4b88
Fix two bugs: SPA-routing page load failure and stale service worker
fetchPageFile now rejects text/html responses so servers with SPA routing
(e.g. Cloudflare Pages with /* /index.html 200) no longer trick the renderer
into treating a fallback index.html as a found markdown file. Category-variant
pages (page.current.md with no plain page.md) now fall through correctly to
their variant URL.

mdcms build now writes a self-unregistering service-worker.js when pwa: no,
evicting any stale caching worker left over from a previous pwa: yes build.
manifest.json is also removed when pwa: no.

https://claude.ai/code/session_01Xs5GyREFhjWxhS1UhW2wA8
2026-05-19 14:55:51 +00:00
Claude
0bf8cf319b
Merge branch claude/config-sitename-title-Dmzsl into development
Picks up blank <title></title> in app/index.html template (cleaner than
the hardcoded MD-CMS value; mdcms build overwrites it with sitename on
every build either way).

https://claude.ai/code/session_01Xs5GyREFhjWxhS1UhW2wA8
2026-05-19 14:23:19 +00:00
2fd48fcb7f
Merge pull request #24 from kbenestad/main
Refactor sidebar behavior and add new favicon
2026-05-19 21:19:00 +07:00
Claude
431b1c054c
Fix: raise on config.yml parse errors instead of silently returning empty dict
A YAML parse error in config.yml (e.g. a stray tab character) caused
read_config to swallow the exception and return {}, disabling categories
and producing a broken nav.yml with no variants fields and wrong filenames.
read_config now raises ClickException on both OSError and YAMLError.

Documented in docs/knownbugs.md and docs/unreleased.md.

https://claude.ai/code/session_01Xs5GyREFhjWxhS1UhW2wA8
2026-05-19 14:15:38 +00:00
Claude
7b2d54da57
Update CLAUDE.md: document <title> patching in run_build 2026-05-19 08:38:19 +00:00
Claude
28b248735f
Patch <title> in index.html with sitename during build
mdcms build now writes the sitename from config.yml into the <title> tag
of index.html. WhatsApp, Slack, and other link-preview crawlers read the
static HTML without executing JavaScript, so the title must be correct in
the raw file. Previously it was blank (or "MD-CMS" in older templates).
2026-05-19 08:37:57 +00:00
Claude
a8fcc79ba9
Set page title from config.sitename instead of hardcoded MD-CMS
The static <title> element previously showed "MD-CMS" before JavaScript
loaded. The JS already sets document.title from config.sitename on boot,
so clearing the initial value ensures the browser tab never displays the
hardcoded string.
2026-05-19 08:37:16 +00:00
Claude
ee3d4872a0
Patch <title> in index.html with sitename during build
mdcms build now writes the sitename from config.yml into the <title> tag
of index.html. WhatsApp, Slack, and other link-preview crawlers read the
static HTML without executing JavaScript, so the title must be correct in
the raw file. Previously it was blank (or "MD-CMS" in older templates).
2026-05-19 08:31:47 +00:00
Claude
269980ea28
Set page title from config.sitename instead of hardcoded MD-CMS
The static <title> element previously showed "MD-CMS" before JavaScript
loaded. The JS already sets document.title from config.sitename on boot,
so clearing the initial value ensures the browser tab never displays the
hardcoded string.
2026-05-19 08:27:53 +00:00
Claude
11dc053118
Scope uncategorized-post fix to posts/ only
Pages without a category suffix still map to the default category.
Only posts/ files without a suffix get uncategorized: true in nav.yml
and category: null in search.json.

https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e
2026-05-19 07:00:36 +00:00
Claude
51cb68c4f9
Merge branch 'main' of http://127.0.0.1:45849/git/kbenestad/mdcms into development 2026-05-19 06:58:52 +00:00
Claude
1279b8035d
Add unreleased.md documenting development-only changes
https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e
2026-05-19 06:57:12 +00:00
Claude
e1527d8e3b
Show category-less posts/pages in all categories
Files without a category suffix (e.g. post.md alongside post.en.md)
previously only appeared in the default category. They now appear in
every category, so untranslated content is always visible.

- mdcms.py: nav entries with a bare variant get `uncategorized: true`;
  search.json keeps `category: null` instead of mapping to default code
- index.html: pageShouldDisplay, posts filter, and category dropdown
  all treat uncategorized/null-category items as universally visible

https://claude.ai/code/session_01EzU13EL8D5Ud2ngQUKDj9e
2026-05-19 06:54:58 +00:00
Claude
aa9ea34683
Enforce two-branch policy in CLAUDE.md: main + development only
Non-canonical branches must be deleted immediately after merging.

https://claude.ai/code/session_01R4b6mihSGtCUzyW4Sd5jzM
2026-05-19 06:22:06 +00:00
Claude
24f428d8d3
Merge remote-tracking branch 'origin/main' into development 2026-05-19 06:20:58 +00:00
Claude
a4eb1c25fe
Merge remote-tracking branch 'origin/development' into development 2026-05-19 06:17:46 +00:00
Claude
da7d33ccc2
Merge main into development — sync
https://claude.ai/code/session_01NQKywehSj8Ku4yKhwB4VNB
2026-05-18 15:21:22 +00:00
Claude
6b491846d7
Merge main into development — sync all theme system changes
https://claude.ai/code/session_01NQKywehSj8Ku4yKhwB4VNB
2026-05-18 15:16:56 +00:00
29 changed files with 2290 additions and 29 deletions

View file

@ -31,6 +31,12 @@ jobs:
artifact_name: mdcms-macos-arm64 artifact_name: mdcms-macos-arm64
make_deb: false make_deb: false
- os: ubuntu-24.04-arm
label: Linux arm64
binary_name: mdcms
artifact_name: mdcms-linux-arm64
make_deb: true
- os: windows-latest - os: windows-latest
label: Windows amd64 label: Windows amd64
binary_name: mdcms.exe binary_name: mdcms.exe
@ -71,9 +77,9 @@ jobs:
--url "https://github.com/kbenestad/mdcms" \ --url "https://github.com/kbenestad/mdcms" \
--maintainer "Kristian Benestad" \ --maintainer "Kristian Benestad" \
--license "Apache-2.0" \ --license "Apache-2.0" \
--architecture amd64 \ --architecture "${{ matrix.os == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" \
--category utils \ --category utils \
dist/mdcms-linux-amd64=/usr/local/bin/mdcms dist/${{ matrix.artifact_name }}=/usr/local/bin/mdcms
- name: Upload binary artifact - name: Upload binary artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -88,7 +94,7 @@ jobs:
if: matrix.make_deb if: matrix.make_deb
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: deb-package name: deb-package-${{ matrix.artifact_name }}
path: "*.deb" path: "*.deb"
release: release:
@ -120,6 +126,8 @@ jobs:
--generate-notes \ --generate-notes \
$PRERELEASE \ $PRERELEASE \
artifacts/mdcms-linux-amd64/mdcms-linux-amd64 \ artifacts/mdcms-linux-amd64/mdcms-linux-amd64 \
artifacts/mdcms-linux-arm64/mdcms-linux-arm64 \
artifacts/mdcms-macos-arm64/mdcms-macos-arm64 \ artifacts/mdcms-macos-arm64/mdcms-macos-arm64 \
artifacts/mdcms-windows-amd64/mdcms-windows-amd64.exe \ artifacts/mdcms-windows-amd64/mdcms-windows-amd64.exe \
artifacts/deb-package/*.deb artifacts/deb-package-mdcms-linux-amd64/*.deb \
artifacts/deb-package-mdcms-linux-arm64/*.deb

View file

@ -8,13 +8,17 @@ Every merge into `main` is a release. Before committing any change to `mdcms.py`
## Branching convention ## Branching convention
Only two branches exist in this repository: **`main`** and **`development`**. No other branches should be created or left alive.
- **`main`** is the release branch. Every merge to `main` is a release. Never commit work-in-progress directly to `main`. - **`main`** is the release branch. Every merge to `main` is a release. Never commit work-in-progress directly to `main`.
- **`development`** is the default branch for all development, including all Claude-driven work. Create it from `main` if it doesn't exist. Do not create a new branch per conversation. - **`development`** is the default branch for all development, including all Claude-driven work. Always commit to `development` — never create a new branch per conversation or feature.
- **Phased branches** (`claude/<feature>`) are allowed when a large feature needs staged review, but the final merge target is always `main` via `development`.
- **Documentation only** (`CLAUDE.md`, `docs/`) — may be pushed directly to `main`. - **Documentation only** (`CLAUDE.md`, `docs/`) — may be pushed directly to `main`.
- **If a non-canonical branch is created** (e.g. for a large staged feature), it must be deleted immediately after it is merged. The repo returns to `main` + `development` only.
In practice: check out `development`, do the work, push to `development`, PR `development``main` when ready to release. In practice: check out `development`, do the work, push to `development`, PR `development``main` when ready to release.
**When a branch isn't visible locally:** always run `git fetch origin <branch-name>` before concluding a branch doesn't exist. Never create a new branch if the user names one — fetch it from the remote first.
## Unreleased changelog ## Unreleased changelog
`docs/unreleased.md` is a living document that tracks every fix or feature on `development` that has not yet been merged to `main`. Keep it current: whenever a change lands on `development`, add or update an entry in `unreleased.md` in the same commit (or a follow-up commit to `development`). When a batch of changes is merged to `main` and released, clear the entries that were released and leave the file in place for the next round of work. `docs/unreleased.md` is a living document that tracks every fix or feature on `development` that has not yet been merged to `main`. Keep it current: whenever a change lands on `development`, add or update an entry in `unreleased.md` in the same commit (or a follow-up commit to `development`). When a batch of changes is merged to `main` and released, clear the entries that were released and leave the file in place for the next round of work.
@ -97,7 +101,7 @@ Single-module Python script. Logical layers in order:
5. **Category system**`identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list. 5. **Category system**`identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list.
6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`. 6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`.
7. **Nav/search generators**`generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild. 7. **Nav/search generators**`generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild.
8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json. 8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json → patch `<title>` in `index.html` with `sitename` → generate PWA files if enabled. The `<title>` patch ensures crawlers and link-preview scrapers (WhatsApp, Slack, etc.) see the correct site name in the static HTML before any JavaScript runs.
9. **Template download** (`download_template`) — fetches `app/` from GitHub via the Contents API using `urllib` + `certifi` for SSL. Recursively downloads files and directories. 9. **Template download** (`download_template`) — fetches `app/` from GitHub via the Contents API using `urllib` + `certifi` for SSL. Recursively downloads files and directories.
10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()``cli()`. 10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()``cli()`.

View file

@ -21,7 +21,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MD-CMS</title> <title></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"> <link rel="manifest" href="manifest.json">
@ -917,6 +917,146 @@ body {
} }
.post-load-more:hover { background: var(--nav-hover-bg); } .post-load-more:hover { background: var(--nav-hover-bg); }
/* ═══════════════════════════════════════════
TAG SYSTEM: TABS
═══════════════════════════════════════════ */
.mdcms-tabs { margin: 1.25rem 0; }
/* Underline variant */
.mdcms-tabs-underline .mdcms-tabs-strip {
display: flex;
flex-wrap: wrap;
gap: 0 18px;
border-bottom: 1px solid var(--mdcms-strip-border, color-mix(in srgb, var(--font-colour) 12%, transparent));
}
.mdcms-tabs-underline .mdcms-tab-btn {
padding: 8px 2px;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 500;
color: var(--font-colour-muted);
line-height: inherit;
transition: color 0.15s;
}
.mdcms-tabs-underline .mdcms-tab-btn:hover { color: var(--font-colour); }
.mdcms-tabs-underline .mdcms-tab-btn[aria-selected="true"] {
font-weight: 600;
color: var(--font-colour);
border-bottom-color: var(--accent);
}
/* Filled variant */
.mdcms-tabs-filled .mdcms-tabs-strip { display: flex; flex-wrap: wrap; gap: 4px; }
.mdcms-tabs-filled .mdcms-tab-btn {
padding: 6px 11px;
border-radius: 4px;
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
color: var(--mdcms-filled-fg-muted, var(--font-colour-muted));
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 400;
line-height: inherit;
transition: color 0.15s;
}
.mdcms-tabs-filled .mdcms-tab-btn:hover { color: var(--mdcms-filled-fg, var(--font-colour)); }
.mdcms-tabs-filled .mdcms-tab-btn[aria-selected="true"] {
background: var(--mdcms-bg, var(--bg-main));
border-color: rgba(var(--accent-rgb), 0.55);
color: var(--accent);
font-weight: 600;
}
/* Shared panel */
.mdcms-tabs-panel { padding-top: 1rem; }
.mdcms-tabs-panel[hidden] { display: none; }
.mdcms-tabs-panel > *:first-child { margin-top: 0; }
.mdcms-tabs-panel > *:last-child { margin-bottom: 0; }
/* ═══════════════════════════════════════════
TAG SYSTEM: ACCORDIONS
═══════════════════════════════════════════ */
.mdcms-accordion { margin: 1.25rem 0; }
.mdcms-accordion-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: inherit;
font-family: inherit;
text-align: left;
line-height: inherit;
}
.mdcms-accordion-chevron {
display: inline-flex;
flex-shrink: 0;
width: 1.1em;
height: 1.1em;
transition: transform 0.2s ease;
transform: rotate(0deg);
}
.mdcms-accordion-item[data-open="false"] .mdcms-accordion-chevron { transform: rotate(-90deg); }
.mdcms-accordion-body[hidden] { display: none; }
.mdcms-accordion-body > *:first-child { margin-top: 0; }
.mdcms-accordion-body > *:last-child { margin-bottom: 0; }
/* Underline variant */
.mdcms-accordion-underline .mdcms-accordion-item { margin-bottom: 6px; }
.mdcms-accordion-underline .mdcms-accordion-btn {
padding: 8px 2px 9px;
border-bottom: 2px solid var(--mdcms-bar, var(--accent));
font-weight: 600;
font-size: 0.75rem;
color: var(--font-colour);
}
.mdcms-accordion-underline .mdcms-accordion-chevron { color: var(--mdcms-bar, var(--accent)); }
.mdcms-accordion-underline .mdcms-accordion-body {
border-left: 1px solid var(--mdcms-bar, var(--accent));
border-right: 1px solid var(--mdcms-bar, var(--accent));
border-bottom: 1px solid var(--mdcms-bar, var(--accent));
border-radius: 0 0 3px 3px;
padding: 8px 10px 9px;
color: var(--font-colour-muted);
}
/* Filled variant — closed */
.mdcms-accordion-filled .mdcms-accordion-item { margin-bottom: 6px; }
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] { margin-bottom: 8px; }
.mdcms-accordion-filled .mdcms-accordion-btn {
padding: 8px 11px;
border-radius: 4px;
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
color: var(--mdcms-filled-fg, var(--font-colour));
}
.mdcms-accordion-filled .mdcms-accordion-chevron { color: var(--mdcms-filled-fg, var(--font-colour)); }
/* Filled variant — open: item becomes the outer frame */
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] {
border: 1px solid var(--mdcms-bar, var(--accent));
border-radius: 4px;
overflow: hidden;
}
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] > .mdcms-accordion-btn {
border: none;
border-radius: 0;
}
.mdcms-accordion-filled .mdcms-accordion-body {
background: var(--mdcms-bg, var(--bg-main));
padding: 8px 11px 9px;
color: var(--font-colour-muted);
}
@media print { @media print {
.sidebar, .topbar, .scroll-top, .hamburger, .sidebar, .topbar, .scroll-top, .hamburger,
.mobile-header, .theme-toggle, .search-container { display: none !important; } .mobile-header, .theme-toggle, .search-container { display: none !important; }
@ -952,7 +1092,7 @@ body {
// Category state (phase 3) // Category state (phase 3)
let categoriesUse = false; let categoriesUse = false;
let categoriesList = []; // [{code, name, direction, message, notfoundmessage, pagenotfoundmessage, font, ...}] let categoriesList = []; // [{code, name, direction, message, notfoundmessage, pagenotfoundmessage, visibilityifnocontent, font, ...}]
let categoriesByCode = {}; // code → category object let categoriesByCode = {}; // code → category object
let defaultCategoryCode = null; let defaultCategoryCode = null;
let activeCategory = null; // current code let activeCategory = null; // current code
@ -1135,11 +1275,19 @@ body {
if (b) b.remove(); if (b) b.remove();
} }
function _isMdResponse(r) {
// Reject HTML responses — servers with SPA routing (e.g. Cloudflare Pages with
// "/* /index.html 200") return index.html with 200 for missing files, which would
// be mistaken for a found markdown file.
const ct = r.headers.get('content-type') || '';
return !ct.startsWith('text/html');
}
async function fetchPageFile(conceptualFile) { async function fetchPageFile(conceptualFile) {
// conceptualFile like "pages/foo.md". Returns { ok, text, resolvedFile } or { ok: false }. // conceptualFile like "pages/foo.md". Returns { ok, text, resolvedFile } or { ok: false }.
if (!categoriesUse) { if (!categoriesUse) {
const r = await fetch(conceptualFile); const r = await fetch(conceptualFile);
if (r.ok) return { ok: true, text: await r.text(), resolvedFile: conceptualFile }; if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: conceptualFile };
return { ok: false }; return { ok: false };
} }
const base = conceptualFile.replace(/\.md$/, ''); const base = conceptualFile.replace(/\.md$/, '');
@ -1169,7 +1317,7 @@ body {
if (seen.has(url)) continue; if (seen.has(url)) continue;
seen.add(url); seen.add(url);
const r = await fetch(url); const r = await fetch(url);
if (r.ok) return { ok: true, text: await r.text(), resolvedFile: url }; if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: url };
} }
return { ok: false }; return { ok: false };
} }
@ -1202,13 +1350,15 @@ body {
// - Home page: always show (per config.homepage or default 'pages/home.md') // - Home page: always show (per config.homepage or default 'pages/home.md')
// - Variant exists for active category: show // - Variant exists for active category: show
// - Active category has notfoundmessage: show (renderer falls back to default language) // - Active category has notfoundmessage: show (renderer falls back to default language)
// - Active category has visibilityifnocontent: visible: show (renderer shows pagenotfoundmessage)
// - Otherwise: hide // - Otherwise: hide
if (!categoriesUse) return true; if (!categoriesUse) return true;
if (page.file === defaultPage()) return true; if (page.file === defaultPage()) return true;
if (page.uncategorized) return true;
const variants = page.variants || []; const variants = page.variants || [];
if (variants.includes(activeCategory)) return true; if (variants.includes(activeCategory)) return true;
const cat = categoriesByCode[activeCategory]; const cat = categoriesByCode[activeCategory];
return !!(cat && cat.notfoundmessage); return !!(cat && (cat.notfoundmessage || cat.visibilityifnocontent === 'visible'));
} }
// ─── Theme ──────────────────────────────────────────────── // ─── Theme ────────────────────────────────────────────────
@ -1226,6 +1376,7 @@ body {
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode')); btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' })); btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
} }
computeDerivedTokens();
} }
function getInitialTheme() { function getInitialTheme() {
@ -1457,6 +1608,10 @@ body {
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim(); const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || ''); const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
const tag = parseMdcmsTag(fullText); const tag = parseMdcmsTag(fullText);
// For tab/accordion blocks, preserve the raw fence body to avoid trim() breaking YAML indentation.
if (/^tab(-underline|-filled)?$|^accordion(-underline|-filled)?$/.test(tag.tagName)) {
tag.rawBody = codeText || '';
}
const encoded = JSON.stringify(tag).replace(/&/g, '&amp;').replace(/"/g, '&quot;'); const encoded = JSON.stringify(tag).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>'; return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
} }
@ -1607,7 +1762,7 @@ function fmtDatetime(dtStr) {
// Category filter // Category filter
if (categoriesUse && activeCategory) { if (categoriesUse && activeCategory) {
posts = posts.filter(function(e) { return e.category === activeCategory; }); posts = posts.filter(function(e) { return !e.category || e.category === activeCategory; });
} }
// Field filter // Field filter
@ -1946,6 +2101,95 @@ function fmtDatetime(dtStr) {
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
} }
function parseColorToHex(val) {
if (!val) return null;
val = val.trim();
if (val.startsWith('#')) {
if (val.length === 4) return '#' + val[1]+val[1]+val[2]+val[2]+val[3]+val[3];
return val.toLowerCase();
}
var m = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (m) return '#' + [m[1],m[2],m[3]].map(function(n) { return parseInt(n).toString(16).padStart(2,'0'); }).join('');
return null;
}
function relativeLuminance(hex) {
hex = hex.replace('#','');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var r = parseInt(hex.substr(0,2),16)/255;
var g = parseInt(hex.substr(2,2),16)/255;
var b = parseInt(hex.substr(4,2),16)/255;
function lin(c) { return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
}
function hexToHsl(hex) {
hex = hex.replace('#','');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var r = parseInt(hex.substr(0,2),16)/255;
var g = parseInt(hex.substr(2,2),16)/255;
var b = parseInt(hex.substr(4,2),16)/255;
var max = Math.max(r,g,b), min = Math.min(r,g,b);
var h = 0, s = 0, l = (max+min)/2;
if (max !== min) {
var d = max - min;
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch(max) {
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
case g: h = ((b-r)/d + 2)/6; break;
case b: h = ((r-g)/d + 4)/6; break;
}
}
return [h, s, l];
}
// HSL chroma: S × (1-|2L-1|) — gives perceptually meaningful colorfulness
// unlike raw HSL S which is artificially high near white/black.
function hslChroma(hex) {
var hsl = hexToHsl(hex);
return hsl[1] * (1 - Math.abs(2 * hsl[2] - 1));
}
function computeDerivedTokens() {
var cs = getComputedStyle(document.documentElement);
var bgHex = parseColorToHex(cs.getPropertyValue('--bg-main').trim());
var navHex = parseColorToHex(cs.getPropertyValue('--bg-nav').trim());
var textHex = parseColorToHex(cs.getPropertyValue('--font-colour').trim());
var mutedHex = parseColorToHex(cs.getPropertyValue('--font-colour-muted').trim());
var accentHex = parseColorToHex(cs.getPropertyValue('--accent').trim());
if (!bgHex || !navHex || !textHex || !mutedHex || !accentHex) return;
var bgL = relativeLuminance(bgHex);
var navL = relativeLuminance(navHex);
var navC = hslChroma(navHex);
var bgC = hslChroma(bgHex);
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var navIsAccent = Math.abs(bgL - navL) > 0.22 || (navC > 0.35 && Math.abs(navC - bgC) > 0.25);
var navIsInverted = Math.abs(bgL - navL) > 0.35;
var navText = navIsInverted ? (navL < 0.5 ? '#F2EFE8' : '#161412') : textHex;
var navTextMuted = navIsInverted ? hexToRgba(navText, 0.6) : mutedHex;
var filledBg = navIsAccent ? navHex : hexToRgba(accentHex, 0.10);
var filledBorder = navIsAccent ? hexToRgba(navText, 0.18) : hexToRgba(accentHex, 0.30);
var filledFg = navIsAccent ? navText : textHex;
var filledFgMuted = navIsAccent ? navTextMuted : mutedHex;
var barColor = navIsAccent ? navHex : accentHex;
var stripAlpha = isDark ? 0.14 : 0.10;
var root = document.documentElement;
root.style.setProperty('--mdcms-bg', bgHex);
root.style.setProperty('--mdcms-accent', accentHex);
root.style.setProperty('--mdcms-filled-bg', filledBg);
root.style.setProperty('--mdcms-filled-border', filledBorder);
root.style.setProperty('--mdcms-filled-fg', filledFg);
root.style.setProperty('--mdcms-filled-fg-muted',filledFgMuted);
root.style.setProperty('--mdcms-bar', barColor);
root.style.setProperty('--mdcms-strip-border', hexToRgba(textHex, stripAlpha));
}
function renderTocTag(container) { function renderTocTag(container) {
const byCode = {}; const byCode = {};
navSections.forEach(s => { byCode[s.code] = s; }); navSections.forEach(s => { byCode[s.code] = s; });
@ -2002,6 +2246,143 @@ function fmtDatetime(dtStr) {
container.replaceWith(div); container.replaceWith(div);
} }
function renderTabsTag(container, cfg) {
var variant = cfg.tagName === 'tab' ? 'tab-underline' : cfg.tagName;
var isFilled = variant === 'tab-filled';
var varClass = isFilled ? 'filled' : 'underline';
var items = [];
try {
// Use rawBody (pre-trim YAML) when available; fall back to reconstructed form.
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
var parsed = jsyaml.load(rawYaml);
items = (parsed && parsed.items) || [];
} catch (e) {
container.textContent = 'Error parsing tab items.';
return;
}
if (!items.length) { container.textContent = 'No tab items.'; return; }
var selectedIdx = items.findIndex(function(it) { return it && it.default === 'selected'; });
if (selectedIdx < 0) selectedIdx = 0;
var wrapper = el('div', { className: 'mdcms-tabs mdcms-tabs-' + varClass });
var strip = el('div', { className: 'mdcms-tabs-strip', role: 'tablist' });
var panels = [];
items.forEach(function(item, i) {
if (!item) return;
var isSelected = i === selectedIdx;
var btn = el('button', {
className: 'mdcms-tab-btn',
role: 'tab',
type: 'button',
'aria-selected': String(isSelected)
});
var titleStyle = item['title-style'] || '';
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
var titleSpan;
if (lvlMatch) {
titleSpan = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
titleSpan.textContent = item.title || '';
} else {
titleSpan = el('span', { textContent: item.title || '' });
}
btn.appendChild(titleSpan);
strip.appendChild(btn);
var panel = el('div', { className: 'mdcms-tabs-panel', role: 'tabpanel' });
panel.innerHTML = renderMarkdown(String(item.content || ''));
if (!isSelected) panel.setAttribute('hidden', '');
panels.push(panel);
btn.addEventListener('click', (function(idx) {
return function() {
strip.querySelectorAll('.mdcms-tab-btn').forEach(function(b, j) {
b.setAttribute('aria-selected', String(j === idx));
if (j === idx) panels[j].removeAttribute('hidden');
else panels[j].setAttribute('hidden', '');
});
};
})(i));
});
wrapper.appendChild(strip);
panels.forEach(function(p) { wrapper.appendChild(p); });
container.replaceWith(wrapper);
}
function renderAccordionTag(container, cfg) {
var variant = cfg.tagName === 'accordion' ? 'accordion-underline' : cfg.tagName;
var isFilled = variant === 'accordion-filled';
var varClass = isFilled ? 'filled' : 'underline';
var items = [];
try {
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
var parsed = jsyaml.load(rawYaml);
items = (parsed && parsed.items) || [];
} catch (e) {
container.textContent = 'Error parsing accordion items.';
return;
}
if (!items.length) { container.textContent = 'No accordion items.'; return; }
var CHEVRON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
var wrapper = el('div', { className: 'mdcms-accordion mdcms-accordion-' + varClass });
items.forEach(function(item) {
if (!item) return;
var isOpen = item.default === 'open';
var itemEl = el('div', { className: 'mdcms-accordion-item' });
itemEl.setAttribute('data-open', String(isOpen));
var btn = el('button', {
className: 'mdcms-accordion-btn',
type: 'button',
'aria-expanded': String(isOpen)
});
var titleStyle = item['title-style'] || '';
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
var titleEl = el('span', { className: 'mdcms-accordion-title' });
if (lvlMatch) {
var heading = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
heading.textContent = item.title || '';
titleEl.appendChild(heading);
} else {
titleEl.textContent = item.title || '';
}
btn.appendChild(titleEl);
var chevron = el('span', { className: 'mdcms-accordion-chevron' });
chevron.innerHTML = CHEVRON_SVG;
btn.appendChild(chevron);
var body = el('div', { className: 'mdcms-accordion-body' });
body.innerHTML = renderMarkdown(String(item.content || ''));
if (!isOpen) body.setAttribute('hidden', '');
btn.addEventListener('click', function() {
var open = itemEl.getAttribute('data-open') === 'true';
var next = !open;
itemEl.setAttribute('data-open', String(next));
btn.setAttribute('aria-expanded', String(next));
if (next) body.removeAttribute('hidden');
else body.setAttribute('hidden', '');
});
itemEl.appendChild(btn);
itemEl.appendChild(body);
wrapper.appendChild(itemEl);
});
container.replaceWith(wrapper);
}
function hydrateMdcmsTags() { function hydrateMdcmsTags() {
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) { document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
try { try {
@ -2010,6 +2391,10 @@ function fmtDatetime(dtStr) {
renderCalloutTag(tagEl, cfg); renderCalloutTag(tagEl, cfg);
} else if (cfg.tagName === 'toc') { } else if (cfg.tagName === 'toc') {
renderTocTag(tagEl); 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 { } else {
renderPostTag(tagEl, cfg); renderPostTag(tagEl, cfg);
} }
@ -2243,16 +2628,17 @@ function fmtDatetime(dtStr) {
function visibleCategoryCodesForCurrentPage() { function visibleCategoryCodesForCurrentPage() {
// Which categories should appear in the dropdown: // Which categories should appear in the dropdown:
// - the variant exists for this page, OR // - the variant exists for this page, OR
// - the category has a notfoundmessage // - the category has a notfoundmessage (fallback to default content), OR
// - the category has visibilityifnocontent: visible (shows pagenotfoundmessage instead)
// - always include the active category so user can see what they're on // - always include the active category so user can see what they're on
const out = new Set(); const out = new Set();
const page = currentPage const page = currentPage
? navData.find(p => p.file === currentPage) ? navData.find(p => p.file === currentPage)
: null; : null;
categoriesList.forEach(cat => { categoriesList.forEach(cat => {
const hasVariant = !page || !page.variants || page.variants.includes(cat.code); const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code);
const hasMsg = !!cat.notfoundmessage; const alwaysVisible = !!cat.notfoundmessage || cat.visibilityifnocontent === 'visible';
if (hasVariant || hasMsg || cat.code === activeCategory) out.add(cat.code); if (hasVariant || alwaysVisible || cat.code === activeCategory) out.add(cat.code);
}); });
return out; return out;
} }
@ -2277,7 +2663,7 @@ function fmtDatetime(dtStr) {
'data-code': cat.code 'data-code': cat.code
}); });
option.appendChild(document.createTextNode(primary)); option.appendChild(document.createTextNode(primary));
const hasVariant = !page || !page.variants || page.variants.includes(cat.code); const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code);
if (!hasVariant && cat.notfoundmessage) { if (!hasVariant && cat.notfoundmessage) {
option.appendChild(el('span', { className: 'secondary', textContent: cat.notfoundmessage })); option.appendChild(el('span', { className: 'secondary', textContent: cat.notfoundmessage }));
} else if (secondary) { } else if (secondary) {
@ -2642,6 +3028,24 @@ function fmtDatetime(dtStr) {
const contentEl = document.getElementById('pageContent'); const contentEl = document.getElementById('pageContent');
highlightNav(file); highlightNav(file);
// If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible)
// and this page has no variant for it, silently switch to the default category instead of
// showing an error.
if (categoriesUse && activeCategory !== defaultCategoryCode && file !== defaultPage()) {
const cat = categoriesByCode[activeCategory];
const isHidden = cat && !cat.notfoundmessage && cat.visibilityifnocontent !== 'visible';
if (isHidden) {
const pageEntry = navData.find(p => p.file === file);
const hasVariant = !pageEntry || pageEntry.uncategorized
|| !(pageEntry.variants && pageEntry.variants.length)
|| pageEntry.variants.includes(activeCategory);
if (!hasVariant) {
setActiveCategory(defaultCategoryCode);
return;
}
}
}
// Build a clean URL: keep origin + path, set ?cat only when non-default, set hash to conceptual file // Build a clean URL: keep origin + path, set ?cat only when non-default, set hash to conceptual file
const u = new URL(window.location); const u = new URL(window.location);
if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) { if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) {
@ -2656,7 +3060,7 @@ function fmtDatetime(dtStr) {
const result = await fetchPageFile(file); const result = await fetchPageFile(file);
if (!result.ok) { if (!result.ok) {
const offlineMsg = localStorage.getItem('mdcms-offline'); const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
const bodyMsg = offlineMsg const bodyMsg = offlineMsg
? `<p>${offlineMsg}</p>` ? `<p>${offlineMsg}</p>`
: `<p>${pageNotFoundMessage()}</p>`; : `<p>${pageNotFoundMessage()}</p>`;

View file

@ -16,3 +16,7 @@ pages:
- file: pages/docs.md - file: pages/docs.md
title: Docs title: Docs
sort: 300 sort: 300
- file: pages/tabs-accordions.md
title: Tabs & Accordions
sort: 400

View file

@ -0,0 +1,78 @@
---
title: Tabs & Accordions
sort: 400
---
# Tabs & Accordions
## Tab — Underline variant
```mdcms tab-underline
items:
- title: Install
default: selected
content: |
Install with `npm i mdcms` or `pnpm add mdcms`.
- title: Configure
content: |
Drop a `mdcms.config.yaml` next to your content folder.
- title: Deploy
content: |
Any static host. The build emits plain HTML.
```
## Tab — Filled variant
```mdcms tab-filled
items:
- title: Overview
default: selected
content: |
MD-CMS is a markdown-based static site system with no build step.
- title: Features
content: |
- Sidebar navigation with sections
- Full-text search via Fuse.js
- PWA support with offline caching
- Dark / light theme toggle
- title: Architecture
content: |
Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).
```
## Accordion — Underline variant
```mdcms accordion-underline
items:
- title: What is MD-CMS?
default: open
content: |
MD-CMS is a single-file browser renderer that reads markdown, config,
and nav at runtime entirely client-side. No build pipeline, no compilation.
- title: How do I install it?
content: |
Run `pip install mdcms` or download a binary from the GitHub releases page.
- title: Does it work offline?
content: |
Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then
enable `pwa: yes` in `config.yml` for full offline support.
```
## Accordion — Filled variant
```mdcms accordion-filled
items:
- title: Can I use custom themes?
default: open
content: |
Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in
your `config.yml`. The theme controls colours, fonts, and layout.
- title: What markdown features are supported?
content: |
GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,
strikethrough, and autolinks. Syntax highlighting via highlight.js.
- title: Can I nest categories?
content: |
Categories are flat (no nesting), but nav sections support a `parent:`
key for two-level sidebar grouping.
```

View file

@ -34,5 +34,17 @@
"modified": "", "modified": "",
"language": "en", "language": "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" "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"
},
{
"file": "pages/tabs-accordions.md",
"title": "Tabs & Accordions",
"section-id": null,
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"language": "en",
"body": "# Tabs & Accordions\n\n## Tab — Underline variant\n\n```mdcms tab-underline\nitems:\n - title: Install\n default: selected\n content: |\n Install with `npm i mdcms` or `pnpm add mdcms`.\n - title: Configure\n content: |\n Drop a `mdcms.config.yaml` next to your content folder.\n - title: Deploy\n content: |\n Any static host. The build emits plain HTML.\n```\n\n## Tab — Filled variant\n\n```mdcms tab-filled\nitems:\n - title: Overview\n default: selected\n content: |\n MD-CMS is a markdown-based static site system with no build step.\n - title: Features\n content: |\n - Sidebar navigation with sections\n - Full-text search via Fuse.js\n - PWA support with offline caching\n - Dark / light theme toggle\n - title: Architecture\n content: |\n Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).\n```\n\n## Accordion — Underline variant\n\n```mdcms accordion-underline\nitems:\n - title: What is MD-CMS?\n default: open\n content: |\n MD-CMS is a single-file browser renderer that reads markdown, config,\n and nav at runtime entirely client-side. No build pipeline, no compilation.\n - title: How do I install it?\n content: |\n Run `pip install mdcms` or download a binary from the GitHub releases page.\n - title: Does it work offline?\n content: |\n Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then\n enable `pwa: yes` in `config.yml` for full offline support.\n```\n\n## Accordion — Filled variant\n\n```mdcms accordion-filled\nitems:\n - title: Can I use custom themes?\n default: open\n content: |\n Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in\n your `config.yml`. The theme controls colours, fonts, and layout.\n - title: What markdown features are supported?\n content: |\n GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,\n strikethrough, and autolinks. Syntax highlighting via highlight.js.\n - title: Can I nest categories?\n content: |\n Categories are flat (no nesting), but nav sections support a `parent:`\n key for two-level sidebar grouping.\n```\n"
} }
] ]

View file

@ -1,5 +1,5 @@
// mdcms service worker — generated by mdcms build // mdcms service worker — generated by mdcms build
const CACHE_NAME = 'mdcms-eb384247'; const CACHE_NAME = 'mdcms-a1862733';
const PRECACHE_URLS = [ const PRECACHE_URLS = [
"index.html", "index.html",
"config.yml", "config.yml",
@ -9,20 +9,29 @@ const PRECACHE_URLS = [
"pages/about.md", "pages/about.md",
"pages/docs.md", "pages/docs.md",
"pages/home.md", "pages/home.md",
"pages/tabs-accordions.md",
"posts/.gitkeep", "posts/.gitkeep",
"assets/fonts/.gitkeep", "assets/fonts/.gitkeep",
"assets/icons/.gitkeep", "assets/icons/.gitkeep",
"assets/icons/add.svg",
"assets/icons/arrow_drop_down.svg", "assets/icons/arrow_drop_down.svg",
"assets/icons/arrow_right.svg", "assets/icons/arrow_right.svg",
"assets/icons/collapse_content.svg",
"assets/icons/dangerous.svg", "assets/icons/dangerous.svg",
"assets/icons/dark_mode.svg", "assets/icons/dark_mode.svg",
"assets/icons/error.svg", "assets/icons/error.svg",
"assets/icons/exclamation.svg", "assets/icons/exclamation.svg",
"assets/icons/expand_content.svg",
"assets/icons/history.svg", "assets/icons/history.svg",
"assets/icons/info.svg", "assets/icons/info.svg",
"assets/icons/keyboard_arrow_down.svg",
"assets/icons/keyboard_arrow_right.svg",
"assets/icons/keyboard_double_arrow_down.svg",
"assets/icons/keyboard_double_arrow_right.svg",
"assets/icons/language.svg", "assets/icons/language.svg",
"assets/icons/light_mode.svg", "assets/icons/light_mode.svg",
"assets/icons/menu.svg", "assets/icons/menu.svg",
"assets/icons/minimize.svg",
"assets/icons/mobile_arrow_down.svg", "assets/icons/mobile_arrow_down.svg",
"assets/icons/report.svg", "assets/icons/report.svg",
"assets/icons/search.svg", "assets/icons/search.svg",

35
docs/knownbugs.md Normal file
View file

@ -0,0 +1,35 @@
# Known bugs
Bugs that have been identified but not yet fixed. Fixed bugs are moved to the release notes.
---
## Fixed in development (not yet released)
### Category-variant pages fail to load on servers with SPA routing
**Symptom:** On Cloudflare Pages (and any other server configured to serve `index.html` with HTTP 200 for missing paths), clicking a nav item whose page only exists as a category-variant file (e.g. `page.current.md`, no plain `page.md`) showed garbled content — the raw HTML of `index.html` rendered as markdown, with the site's `<title>` text visible in the content area.
**Root cause:** `fetchPageFile` tried the base filename (`pages/page.md`) first. Servers with SPA routing return this with HTTP 200 (serving `index.html`), so `r.ok` was true and the function returned without trying the actual variant file (`pages/page.current.md`).
**Fix:** `fetchPageFile` now checks the `Content-Type` response header and skips any response with `text/html`, continuing to the next candidate URL.
---
### Stale service worker not removed when `pwa: no`
**Symptom:** After changing a site from `pwa: yes` to `pwa: no` and rebuilding, the old service worker remained active in browsers that had previously visited the site. Cached responses from the old build continued to be served.
**Root cause:** `mdcms build` stopped generating PWA files when `pwa: no`, but `index.html` unconditionally registers `service-worker.js` on every page load. With no new SW to replace it, the old worker stayed installed indefinitely.
**Fix:** `mdcms build` now writes a self-unregistering stub `service-worker.js` when `pwa: no`. On the visitor's next visit, the browser installs the stub which immediately calls `self.registration.unregister()`, evicting the stale worker. `manifest.json` is also deleted if present.
---
### `config.yml` YAML parse errors were silently swallowed
**Symptom:** A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) caused `read_config` to catch the `YAMLError` and return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a `nav.yml` that omitted `variants` fields and listed category variant files (e.g. `page.current.md`) as plain pages. Pages with category variants would not appear in the sidebar.
**Root cause:** `read_config` caught `(OSError, yaml.YAMLError)` in a single block and silently returned `{}` on any error.
**Fix:** `read_config` now raises `click.ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing with an empty config.

View file

@ -151,16 +151,46 @@ categories-use: yes # Enable the category system. Default: no.
default-category: # The category used when no ?cat= parameter is in the URL. default-category: # The category used when no ?cat= parameter is in the URL.
code: en # Short code. Used in filenames (page.en.md) and URL params. code: en # Short code. Used in filenames (page.en.md) and URL params.
name: English # Display name shown in the category selector. name: English # Display name shown in the category dropdown list.
message: English # Label shown on the selector bar (trigger button). Falls back to name.
name-latin: English # Secondary label shown in the dropdown alongside name. Use when name
# is in a non-Latin script (e.g. Arabic, Devanagari) to aid recognition.
# Omit if name is already Latin or identical to name.
direction: ltr # Text direction. ltr or rtl. Default: ltr. direction: ltr # Text direction. ltr or rtl. Default: ltr.
# rtl flips the nav position and content text direction.
notfoundmessage: "Not available in this language"
# Short note shown in the dropdown when no variant exists for the
# current page. Also enables fallback: the renderer will fall back to
# the default-category content instead of hiding the page.
# Omit to hide the category from the dropdown when no variant exists.
visibilityifnocontent: hidden # hidden (default) or visible.
# hidden: category disappears from the selector when no variant exists
# for the current page (unless notfoundmessage is also set).
# visible: category stays in the selector regardless. When the user
# navigates to a page with no variant, pagenotfoundmessage is shown
# in the content area. No fallback to default-category content.
pagenotfoundmessage: "This page is not yet available in English."
# Message shown in the content area when a page cannot be fetched for
# this category. Overrides the top-level pagenotfoundmessage.
font: NotoNastaliqUrdu-Regular.ttf
# Font filename inside assets/fonts/. Loaded on demand when this
# category is activated. Useful for scripts that need a specific font.
line-height: 2.8 # Line height override for this category. Useful for scripts like
# Nastaliq that need extra vertical space. Restores to theme default
# when switching away.
categories: # Additional categories. categories: # Additional categories. Each entry supports the same keys as
# default-category above.
- code: nb - code: nb
name: Norsk name: Norsk
direction: ltr direction: ltr
- code: ar - code: ar
name: عربي name: عربي
direction: rtl # RTL flips nav position and content text direction. name-latin: Arabic
direction: rtl
notfoundmessage: "غير متاح"
font: NotoNastaliqUrdu-Regular.ttf
line-height: 2.8
categories-sectionnames: same # How section names are shown per category. categories-sectionnames: same # How section names are shown per category.
# same: all categories share one section name (defaultname in nav.yml). # same: all categories share one section name (defaultname in nav.yml).
@ -170,6 +200,21 @@ categories-selecticon: globe # Icon shown in the category selector bar. SVG na
categories-selecttext: "Language" # Label shown next to the icon in the category selector bar. categories-selecttext: "Language" # Label shown next to the icon in the category selector bar.
``` ```
### Per-category keys summary
| Key | Required | Description |
|---|---|---|
| `code` | Yes | Short identifier used in filenames (`page.nb.md`) and the `?cat=` URL param. |
| `name` | Yes | Display name shown in the dropdown list. |
| `message` | No | Label shown on the selector trigger button. Falls back to `name`. |
| `name-latin` | No | Secondary label in the dropdown, shown alongside `name` when `name` uses a non-Latin script. |
| `direction` | No | `ltr` or `rtl`. Default: `ltr`. RTL flips nav and content direction. |
| `notfoundmessage` | No | Short note shown in the dropdown when no variant exists for the current page. Also enables fallback to default-category content. |
| `visibilityifnocontent` | No | `hidden` (default) or `visible`. `visible` keeps the category in the selector when no variant exists; navigating to it shows `pagenotfoundmessage` with no fallback to default content. |
| `pagenotfoundmessage` | No | Message shown in the content area when a page cannot be fetched for this category. Overrides the top-level `pagenotfoundmessage`. |
| `font` | No | Font filename from `assets/fonts/`. Loaded on demand when this category is activated. |
| `line-height` | No | Body line height override for this category. Restores to theme default when switching away. |
--- ---
## Reusable callout messages ## Reusable callout messages
@ -228,6 +273,30 @@ offline-message:
nb: "Du er frakoblet. Koble til og last inn på nytt." nb: "Du er frakoblet. Koble til og last inn på nytt."
language: en language: en
pagenotfoundmessage: "Please select a page to continue."
categories-use: yes
default-category:
code: en
name: English
direction: ltr
categories:
- code: nb
name: Norsk
direction: ltr
visibilityifnocontent: visible
pagenotfoundmessage: "Denne siden er ikke tilgjengelig på norsk ennå."
- code: ar
name: عربي
name-latin: Arabic
direction: rtl
notfoundmessage: "غير متاح"
pagenotfoundmessage: "هذه الصفحة غير متاحة."
font: NotoNastaliqUrdu-Regular.ttf
line-height: 2.8
categories-sectionnames: same
categories-selecticon: globe
categories-selecttext: "Language"
callouts: callouts:
aitranslation: aitranslation:

View file

@ -167,6 +167,92 @@ paginate: yes # Pagination mode:
--- ---
### Tabs — `tab-underline`, `tab-filled`, `tab`
A horizontal tab strip with a single visible content panel. The active tab is set with `default: selected`; if no item carries that value the first item is selected automatically.
| Tag name | Appearance |
|---|---|
| `tab-underline` | Labels in a row; active tab marked with a 2 px underline in the accent colour. |
| `tab` | Alias for `tab-underline`. |
| `tab-filled` | Each label is a chip with a filled background; active chip inverts to the page background with an accent border. |
The body of the block is YAML. It must start with `items:` followed by a list of item objects.
````markdown
```mdcms tab-underline
items:
- title: npm
default: selected
content: |
```bash
npm install mdcms
```
- title: pnpm
content: |
```bash
pnpm add mdcms
```
- title: yarn
content: |
```bash
yarn add mdcms
```
```
````
**Per-item keys:**
| Key | Required | Notes |
|---|---|---|
| `title` | yes | Label on the tab button. Plain text only. |
| `content` | yes | Tab panel body. Full Markdown, use `\|` for multi-line. |
| `default` | no | `selected` — open on load. If no item is `selected`, the first item is used. |
| `title-style` | no | Heading level for screen readers. One of `"#"``"######"` or `""` (default). Does not affect visual size. |
---
### Accordions — `accordion-underline`, `accordion-filled`, `accordion`
Stacked collapsible items. Each item has a clickable header and a body that expands below it. Any number of items can be open simultaneously.
| Tag name | Appearance |
|---|---|
| `accordion-underline` | Header separated from the content by a 2 px bar in the accent or nav colour; open content has a matching 1 px border on three sides. |
| `accordion` | Alias for `accordion-underline`. |
| `accordion-filled` | Closed header is a filled chip; when open the item becomes a single bordered card with the header fill at the top and the page background below. |
````markdown
```mdcms accordion
items:
- title: What is MD-CMS?
default: open
content: |
A single-file browser renderer. No build pipeline, no compilation,
no server required.
- title: How do I install it?
content: |
Run `pip install mdcms` or download a binary from the GitHub releases page.
- title: Does it work offline?
content: |
Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable
`pwa: yes` in `config.yml` for full offline support.
```
````
**Per-item keys:**
| Key | Required | Notes |
|---|---|---|
| `title` | yes | Header label. Plain text only. |
| `content` | yes | Body shown when expanded. Full Markdown, use `\|` for multi-line. |
| `default` | no | `open` — expanded on load. `closed` or omitted — collapsed. Multiple items may be `open`. |
| `title-style` | no | Heading level for screen readers. One of `"#"``"######"` or `""` (default). Does not affect visual size. |
**How the colour adapts to themes:** The bar/border colour and the chip fill are derived automatically from the active theme. On themes where the sidebar background is visually distinct from the page (dark nav on a light page, or a coloured nav), the components use the nav colour as their fill. On subtle themes where sidebar and page backgrounds are near-identical, the accent colour is used instead. No per-theme config is needed.
---
## Markdown features ## Markdown features
Standard CommonMark plus GFM (GitHub-flavoured) extensions: Standard CommonMark plus GFM (GitHub-flavoured) extensions:

204
docs/unreleased.md Normal file
View file

@ -0,0 +1,204 @@
# Unreleased changes
Changes merged into `development` that have not yet been released to `main`.
---
## 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.
### Block types
| Language tag | Alias for | Renders as |
|---|---|---|
| `tab-underline` | — | Tab strip, active tab marked with underline |
| `tab` | `tab-underline` | (same) |
| `tab-filled` | — | Tab strip, tabs as filled chips |
| `accordion-underline` | — | Stacked accordion, header underline style |
| `accordion` | `accordion-underline` | (same) |
| `accordion-filled` | — | Stacked accordion, filled card style |
### Authoring syntax
Open a fenced block with the language tag `mdcms <type>`. The body is YAML with a single top-level key `items:`, whose value is a list of item objects.
~~~markdown
```mdcms tab-underline
items:
- title: Install
default: selected
content: |
Install with `npm i mdcms` or `pnpm add mdcms`.
- title: Configure
content: |
Drop a `mdcms.config.yaml` next to your content folder.
- title: Deploy
content: |
Any static host. The build emits plain HTML.
```
~~~
### Per-item keys
| Key | Required | Type | Notes |
|---|---|---|---|
| `title` | yes | plain string | Label shown on the tab button or accordion header. Plain text only — no Markdown. |
| `content` | yes | Markdown block | Body content. Use the YAML literal block scalar (`\|`) for multi-line Markdown. Rendered with the same pipeline as the surrounding page (GFM, syntax highlighting, internal links). |
| `default` | no | string | **Tabs:** `selected` marks the tab that is open on load; if no item has `selected`, the first item is used. `notselected` (or omitting the key) leaves the tab inactive. Exactly one tab should be `selected`. **Accordions:** `open` makes the item expanded on load; `closed` (or omitting) leaves it collapsed. Any number of accordion items may be `open`. |
| `title-style` | no | string | Heading level for screen readers and external TOC tools. One of `"#"`, `"##"`, `"###"`, `"####"`, `"#####"`, `"######"`, or `""` (default). Visual size is always fixed by the component — this only changes the underlying ARIA role and level. Use a value when you want the item to be picked up as a heading by assistive technology. |
### Examples
**Tabs — underline (default)**
~~~markdown
```mdcms tab
items:
- title: npm
default: selected
content: |
```bash
npm install mdcms
```
- title: pnpm
content: |
```bash
pnpm add mdcms
```
- title: yarn
content: |
```bash
yarn add mdcms
```
```
~~~
**Tabs — filled chips**
~~~markdown
```mdcms tab-filled
items:
- title: Overview
default: selected
content: |
MD-CMS is a markdown-based static site system with no build step.
- title: Features
content: |
- Sidebar navigation
- Full-text search
- PWA + offline support
- Dark / light theme
```
~~~
**Accordion — underline (default)**
~~~markdown
```mdcms accordion
items:
- title: What is MD-CMS?
default: open
content: |
A single-file browser renderer. No build pipeline, no compilation,
no server required.
- title: How do I install it?
content: |
Run `pip install mdcms` or download a binary from the GitHub releases page.
- title: Does it work offline?
content: |
Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable
`pwa: yes` in `config.yml` for full offline support.
```
~~~
**Accordion — filled cards**
~~~markdown
```mdcms accordion-filled
items:
- title: Can I use custom themes?
default: open
content: |
Yes. Create a `theme.yml` and reference it with `theme: theme.yml` in
`config.yml`. The theme controls colours, fonts, and layout.
- title: title-style example
title-style: "##"
content: |
This header is announced as an `<h2>` to screen readers, even though
its visual size is set by the accordion component.
```
~~~
### How the appearance adapts to themes
The components derive their fill colours and bar/border colours from the active theme at runtime. No new keys in `config.yml` or `theme.yml` are needed.
**Bold themes** (nav background is visually distinct from the page — e.g. a dark sidebar on a light page, or a coloured nav like red or navy): filled tabs and accordion headers use the nav background colour as their fill; the bar/border uses the nav colour. This makes the components look like an extension of the sidebar chrome.
**Subtle themes** (nav background is almost identical to the page — e.g. both near-white or both near-dark): filled tabs use a light tint of the accent colour; the bar and border use the accent colour directly. This keeps the components visible without a strong nav background to borrow from.
The switch between bold and subtle is automatic. The algorithm uses HSL chroma (`S × (1|2L1|)`) rather than raw HSL saturation, which would give false "bold" readings for near-white or near-black nav backgrounds.
---
## `mdcms build` patches `<title>` with sitename
`mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name.
---
## Untranslated posts now visible in all categories
**Status:** On `development`, pending release.
### What was broken
When the category system is enabled, a post file without a category suffix (e.g. `posts/my-post.md`) was silently assigned to the default category only. Switching to any other category caused those posts to disappear from the nav and from `posts-*` tag listings — even though no translated version existed. If you wrote posts without a language suffix, they simply vanished the moment a visitor switched category.
Pages without a category suffix are unaffected: they continue to be assigned to the default category, which is the correct behaviour for pages.
### What it does now
Posts without a category suffix are treated as uncategorised — meaning they appear in every category. A post called `my-post.md` now shows up regardless of which category is active. A post called `my-post.en.md` still appears only in the `en` category as before.
Mixed situations work as expected: if you have both `my-post.md` and `my-post.nb.md`, the Norwegian variant is shown when the `nb` category is active, and the bare `my-post.md` is shown for every other category.
### What changes in the build output
After rebuilding a site with `mdcms build`, affected post entries in `nav.yml` gain an `uncategorized: true` field:
```yaml
- file: posts/my-post.md
title: My Post
sort: 100
uncategorized: true
```
In `search.json`, these entries carry `"category": null` instead of the default category code. This is what tells the renderer to include them universally.
A rebuild is required for existing sites to pick up the change.
---
## Fix: category-variant pages fail to load on servers with SPA routing (e.g. Cloudflare Pages)
When a site uses category-suffixed page files (e.g. `page.current.md`) and is hosted on a server configured with SPA fallback routing (serving `index.html` with HTTP 200 for any unknown path), the renderer's `fetchPageFile` mistook the HTML fallback for a found markdown file. It returned `index.html` content instead of falling through to try the `.current.md` variant. The page rendered the raw HTML of `index.html` as markdown, showing the `<title>` text (`sitename`) in the content area.
`fetchPageFile` now checks the `Content-Type` response header and rejects any response with `text/html`, continuing to the next candidate URL instead.
---
## Fix: stale service worker not removed when `pwa: no`
`index.html` unconditionally registers `service-worker.js` on every page load. When a site switched from `pwa: yes` to `pwa: no`, `mdcms build` stopped generating a new service worker, but the old one remained active in browsers that had visited the site before. The stale worker continued to serve cached responses from the old build.
`mdcms build` now writes a self-unregistering `service-worker.js` when `pwa: no`. On the visitor's next page load, the browser installs this stub worker, which immediately unregisters itself and evicts any previously cached content. `manifest.json` is also removed if present.
---
## Fix: `config.yml` YAML parse errors now abort the build with a clear message
A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar.
`read_config` now raises `ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing silently with an empty config.

View file

@ -113,9 +113,12 @@ def read_config(site_path: Path) -> dict:
return {} return {}
try: try:
text = config_file.read_text(encoding="utf-8") text = config_file.read_text(encoding="utf-8")
except OSError as e:
raise click.ClickException(f"Could not read config.yml: {e}")
try:
return yaml.safe_load(text) or {} return yaml.safe_load(text) or {}
except (OSError, yaml.YAMLError): except yaml.YAMLError as e:
return {} raise click.ClickException(f"config.yml is not valid YAML: {e}")
def get_category_info(cfg: dict) -> dict: def get_category_info(cfg: dict) -> dict:
@ -265,11 +268,19 @@ def build_page_nav(
"sort": sort, "sort": sort,
} }
if categories_use: if categories_use:
is_post = file.startswith("posts/")
covered = {} covered = {}
has_uncategorized = False
for code, record in variants.items(): for code, record in variants.items():
key = code if code is not None else default_code if code is None:
if key: if is_post:
covered[key] = record.get("title", "") has_uncategorized = True
elif default_code:
covered[default_code] = record.get("title", "")
else:
covered[code] = record.get("title", "")
if has_uncategorized:
entry["uncategorized"] = True
entry["variants"] = sorted(covered.keys()) entry["variants"] = sorted(covered.keys())
entry["titles"] = covered entry["titles"] = covered
out.append(entry) out.append(entry)
@ -313,6 +324,8 @@ def generate_nav_yml(sections: list, pages: list, categories_use: bool = False)
if p.get("section-id"): if p.get("section-id"):
lines.append(f" section-id: {p['section-id']}") lines.append(f" section-id: {p['section-id']}")
lines.append(f" sort: {p.get('sort', 100)}") lines.append(f" sort: {p.get('sort', 100)}")
if categories_use and p.get("uncategorized"):
lines.append(" uncategorized: true")
if categories_use and p.get("variants"): if categories_use and p.get("variants"):
lines.append(f" variants: [{', '.join(p['variants'])}]") lines.append(f" variants: [{', '.join(p['variants'])}]")
if categories_use and p.get("titles"): if categories_use and p.get("titles"):
@ -345,7 +358,13 @@ def generate_search_json(
} }
if categories_use: if categories_use:
code = r.get("code") code = r.get("code")
entry["category"] = code if code is not None else default_code is_post = r.get("file", "").startswith("posts/")
if code is not None:
entry["category"] = code
elif is_post:
entry["category"] = None # null = show in all categories
else:
entry["category"] = default_code
out.append(entry) out.append(entry)
return json.dumps(out, indent=2, ensure_ascii=False) return json.dumps(out, indent=2, ensure_ascii=False)
@ -412,6 +431,19 @@ def validate_assets(site_path: Path, cfg: dict) -> list:
# ─── Core build logic ───────────────────────────────────────── # ─── Core build logic ─────────────────────────────────────────
_TITLE_RE = re.compile(r"<title>[^<]*</title>")
def _patch_html_title(site_path: Path, sitename: str) -> None:
index = site_path / "index.html"
if not index.exists():
return
html = index.read_text(encoding="utf-8")
new_html = _TITLE_RE.sub(f"<title>{sitename}</title>", html, count=1)
if new_html != html:
index.write_text(new_html, encoding="utf-8")
def run_build(site_path: Path): def run_build(site_path: Path):
"""Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure.""" """Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure."""
if not site_path.is_dir(): if not site_path.is_dir():
@ -491,9 +523,13 @@ 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)")
_patch_html_title(site_path, cfg.get("sitename", ""))
pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true") pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true")
if pwa_enabled: if pwa_enabled:
generate_pwa(site_path, cfg) generate_pwa(site_path, cfg)
else:
cleanup_pwa(site_path)
asset_warnings = validate_assets(site_path, cfg) asset_warnings = validate_assets(site_path, cfg)
for w in asset_warnings: for w in asset_warnings:
@ -509,6 +545,29 @@ def run_build(site_path: Path):
# ─── PWA generation ─────────────────────────────────────────── # ─── PWA generation ───────────────────────────────────────────
def cleanup_pwa(site_path: Path):
"""When pwa: no, write a self-unregistering service worker and remove manifest.json.
Browsers keep the previously installed service worker active until a new one is
installed. Writing a stub that immediately unregisters itself ensures any stale
caching worker is evicted on the next visit after a pwa: yes pwa: no change.
"""
sw = site_path / "service-worker.js"
sw.write_text(
"// mdcms: PWA disabled — unregisters any previously installed service worker.\n"
"self.addEventListener('install', () => self.skipWaiting());\n"
"self.addEventListener('activate', event => {\n"
" event.waitUntil(self.registration.unregister());\n"
"});\n",
encoding="utf-8",
)
manifest = site_path / "manifest.json"
if manifest.exists():
manifest.unlink()
click.echo(" Removed manifest.json (pwa: no)")
click.echo(" Wrote service-worker.js (self-unregistering stub, pwa: no)")
def generate_pwa(site_path: Path, cfg: dict): def generate_pwa(site_path: Path, cfg: dict):
"""Generate manifest.json and service-worker.js when pwa: yes.""" """Generate manifest.json and service-worker.js when pwa: yes."""
pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site")) pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site"))

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,76 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-adwaita
# GNOME / Adwaita feel. Warm near-white paper, soft window-bg chrome,
# the familiar Adwaita blue accent. Cantarell typeface throughout.
# ──────────────────────────────────
# Colours — based on published libadwaita tokens
# light: window_bg_color #fafafa, view_bg #ffffff, accent #1c71d8 (blue 4),
# fg ~rgba(0,0,0,0.8), dim ~rgba(0,0,0,0.55)
# dark: window_bg_color #242424, view_bg #1e1e1e, accent #78aeed (blue 1)
# ──────────────────────────────────
light:
accent: "#1C71D8"
background: "#FFFFFF"
nav-background: "#FAFAFA"
text: "#202020"
text-muted: "#5E5C64"
dark:
accent: "#78AEED"
background: "#1E1E1E"
nav-background: "#242424"
text: "#FFFFFF"
text-muted: "#C0BFBC"
# ──────────────────────────────────
# Semantic colours — Adwaita "named colors": green-3, yellow-5, red-3
# ──────────────────────────────────
colours-semantic:
info: "#1C71D8"
warning: "#E5A50A"
success: "#26A269"
error: "#C01C28"
colours-semantic-dark:
info: "#78AEED"
warning: "#F8E45C"
success: "#57E389"
error: "#F66151"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#1C71D8"
background-colour: "#1C71D8"
warning:
icon: warning
primary-colour: "#E5A50A"
background-colour: "#E5A50A"
success:
icon: success
primary-colour: "#26A269"
background-colour: "#26A269"
error:
icon: error
primary-colour: "#C01C28"
background-colour: "#C01C28"
# ──────────────────────────────────
# Typography
# Cantarell is GNOME's UI typeface — humanist sans, slightly tall x-height.
# Available on Google Fonts.
# ──────────────────────────────────
font-body: "google:Cantarell:400"
font-heading: "google:Cantarell:700"
font-size: 1.00
line-height: 1.65
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,82 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-aero
# Windows Vista / 7 "Aero Glass" feel. Pale glass-tinted blue chrome,
# translucent sidebar vibes, bright sky-blue accent. The desktop your
# laptop sweated to render in 2009.
#
# Colours approximated from the default Aero theme palette:
# accent (taskbar / button glow) #1A78D4
# glass tint #B8D6F0 (frosted blue)
# window face #F0F4F9
# text #1B1B1B
# Aero Dark / "Aero Black" variant uses the same accent over near-black.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#1A78D4"
background: "#F4F9FE"
nav-background: "#B8D6F0"
text: "#1B1B1B"
text-muted: "#525E6E"
dark:
accent: "#4FC3F7"
background: "#0F1A2A"
nav-background: "#1A2A40"
text: "#EAF2FC"
text-muted: "#8FA8C4"
# ──────────────────────────────────
# Semantic colours — Vista/7 standard hues
# ──────────────────────────────────
colours-semantic:
info: "#1A78D4"
warning: "#E59400"
success: "#1E8C3F"
error: "#C42B1C"
colours-semantic-dark:
info: "#4FC3F7"
warning: "#FFC74A"
success: "#7AD18F"
error: "#FF7A7A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#1A78D4"
background-colour: "#1A78D4"
warning:
icon: warning
primary-colour: "#E59400"
background-colour: "#E59400"
success:
icon: success
primary-colour: "#1E8C3F"
background-colour: "#1E8C3F"
error:
icon: error
primary-colour: "#C42B1C"
background-colour: "#C42B1C"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred: Segoe UI (Vista/7 default — first
# Microsoft OS to ship it). Open Segoe-metric-compatible alternative:
# "Selawik". Drop your TTFs in /fonts and swap font-body / font-heading.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,82 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-amiga
# Workbench 1.3 revival. Iconic blue/orange/white/black on Workbench grey.
# Pixel-screen energy. The most idiosyncratic theme in the set.
#
# Original Workbench 1.x palette (4 colours, hardware-fixed):
# #0055AA blue (window chrome, background)
# #FFFFFF white
# #000000 black
# #FF8800 orange (highlights)
# 2.x onward added the warm grey #AAAAAA.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#FF8800"
background: "#FFFFFF"
nav-background: "#0055AA"
text: "#000000"
text-muted: "#555555"
dark:
accent: "#FF8800"
background: "#0055AA"
nav-background: "#003D7A"
text: "#FFFFFF"
text-muted: "#AAC4E0"
# ──────────────────────────────────
# Semantic colours
# ──────────────────────────────────
colours-semantic:
info: "#0055AA"
warning: "#FF8800"
success: "#00AA55"
error: "#CC0000"
colours-semantic-dark:
info: "#7FB2E0"
warning: "#FFB04A"
success: "#7FD9A4"
error: "#FF6B6B"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#0055AA"
background-colour: "#0055AA"
warning:
icon: warning
primary-colour: "#FF8800"
background-colour: "#FF8800"
success:
icon: success
primary-colour: "#00AA55"
background-colour: "#00AA55"
error:
icon: error
primary-colour: "#CC0000"
background-colour: "#CC0000"
# ──────────────────────────────────
# Typography
# VT323 portable default for the pixel-screen feel.
# Preferred for true Workbench-fidelity: "Topaz" or "Topaz New" (free
# pixel font replicas of the Amiga system font, widely available as TTF).
# For a more readable modern take, swap to "bunny:IBM Plex Mono:400".
# ──────────────────────────────────
font-body: "google:VT323:400"
font-heading: "google:VT323:400"
font-size: 1.15
line-height: 1.45
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 76em
nav-width: 20em

View file

@ -0,0 +1,80 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-beos
# BeOS / Haiku revival. Iconic yellow window tab, cream paper,
# navy text. The friendly weird desktop of 1996 that won't quit.
#
# Colours from Haiku's default "Beige" palette:
# panel background #DCDCDC
# document-tab yellow #FFCB00
# text #000000
# accent (link/button) #336699 navy
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#336699"
background: "#F8F8E8"
nav-background: "#FFCB00"
text: "#000000"
text-muted: "#4A4A3E"
dark:
accent: "#FFCB00"
background: "#1A1A14"
nav-background: "#2A2515"
text: "#F8F8E8"
text-muted: "#A89E70"
# ──────────────────────────────────
# Semantic colours
# ──────────────────────────────────
colours-semantic:
info: "#336699"
warning: "#CC7700"
success: "#3F8F3F"
error: "#B22222"
colours-semantic-dark:
info: "#7FB2E0"
warning: "#FFCB00"
success: "#86C58B"
error: "#E07A7A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#336699"
background-colour: "#336699"
warning:
icon: warning
primary-colour: "#CC7700"
background-colour: "#CC7700"
success:
icon: success
primary-colour: "#3F8F3F"
background-colour: "#3F8F3F"
error:
icon: error
primary-colour: "#B22222"
background-colour: "#B22222"
# ──────────────────────────────────
# Typography
# Noto Sans is the portable default and Haiku's actual UI font.
# Preferred (BeOS original): Swis721 BT / "Be Sans" (proprietary, paid).
# DejaVu Sans is a very close free alternative.
# ──────────────────────────────────
font-body: "bunny:Noto Sans:400"
font-heading: "bunny:Noto Sans:700"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,86 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-breeze
# KDE Plasma / Breeze feel. Cool neutral chrome, the famous Plasma blue
# accent. Breeze is intentionally subtle — light, low-saturation, with
# just a touch of cool grey.
#
# Colours from KDE's Breeze stylesheet (qss + colour scheme):
# accent (Highlight) #3DAEE9
# view-background-color #FCFCFC
# window-background-color #EFF0F1
# foreground (Text) #232629
# foreground-inactive #7F8C8D
# Breeze Dark:
# view-background-color #1B1E20
# window-background-color #232629
# foreground #FCFCFC
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#3DAEE9"
background: "#FCFCFC"
nav-background: "#EFF0F1"
text: "#232629"
text-muted: "#7F8C8D"
dark:
accent: "#3DAEE9"
background: "#1B1E20"
nav-background: "#232629"
text: "#FCFCFC"
text-muted: "#A1A9B1"
# ──────────────────────────────────
# Semantic colours — Breeze "positive / neutral / negative" tones
# ──────────────────────────────────
colours-semantic:
info: "#3DAEE9"
warning: "#F67400"
success: "#27AE60"
error: "#DA4453"
colours-semantic-dark:
info: "#61C1F0"
warning: "#F8A04A"
success: "#56C883"
error: "#ED7077"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#3DAEE9"
background-colour: "#3DAEE9"
warning:
icon: warning
primary-colour: "#F67400"
background-colour: "#F67400"
success:
icon: success
primary-colour: "#27AE60"
background-colour: "#27AE60"
error:
icon: error
primary-colour: "#DA4453"
background-colour: "#DA4453"
# ──────────────────────────────────
# Typography
# Noto Sans is the portable default and KDE's current UI font.
# Preferred (classic Plasma 4 era): "Oxygen Sans" — open SIL-licensed,
# available on Google Fonts as "Oxygen".
# ──────────────────────────────────
font-body: "bunny:Noto Sans:400"
font-heading: "bunny:Noto Sans:600"
font-size: 1.00
line-height: 1.6
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,85 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-chromeos
# ChromeOS feel. Bright white shelf, soft Google-grey surface,
# Google Blue accent. Material-rooted but its own dialect.
#
# Colours from Google's public ChromeOS / Material reference:
# Google Blue 600 #1A73E8 (accent light)
# Google Blue 200 #8AB4F8 (accent dark)
# Surface #FFFFFF / #202124
# Surface variant #F1F3F4 / #292A2D
# On-surface #202124 / #E8EAED
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#1A73E8"
background: "#FFFFFF"
nav-background: "#F1F3F4"
text: "#202124"
text-muted: "#5F6368"
dark:
accent: "#8AB4F8"
background: "#202124"
nav-background: "#292A2D"
text: "#E8EAED"
text-muted: "#9AA0A6"
# ──────────────────────────────────
# Semantic colours — Google standard hues
# Green 700 #1E8E3E / Green 300 #81C995
# Yellow 700 #F29900 / Yellow 300 #FDD663
# Red 600 #D93025 / Red 300 #F28B82
# ──────────────────────────────────
colours-semantic:
info: "#1A73E8"
warning: "#F29900"
success: "#1E8E3E"
error: "#D93025"
colours-semantic-dark:
info: "#8AB4F8"
warning: "#FDD663"
success: "#81C995"
error: "#F28B82"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#1A73E8"
background-colour: "#1A73E8"
warning:
icon: warning
primary-colour: "#F29900"
background-colour: "#F29900"
success:
icon: success
primary-colour: "#1E8E3E"
background-colour: "#1E8E3E"
error:
icon: error
primary-colour: "#D93025"
background-colour: "#D93025"
# ──────────────────────────────────
# Typography
# Roboto is the portable default and ChromeOS's body font.
# Preferred for headings: "Google Sans" (proprietary, restricted).
# Open near-equivalent for Google Sans display: "Product Sans"
# — also restricted; use Roboto for both and you'll be fine.
# ──────────────────────────────────
font-body: "bunny:Roboto:400"
font-heading: "bunny:Roboto:500"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,72 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-cupertino-graphite
# Mac desktop, Graphite accent variant — for people who switch the system
# tint to "Graphite" because they're serious. Pure neutral chrome.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#6E6E73"
background: "#FFFFFF"
nav-background: "#F2F2F7"
text: "#1D1D1F"
text-muted: "#6E6E73"
dark:
accent: "#98989D"
background: "#1E1E1E"
nav-background: "#2C2C2E"
text: "#F5F5F7"
text-muted: "#98989D"
# ──────────────────────────────────
# Semantic colours
# ──────────────────────────────────
colours-semantic:
info: "#6E6E73"
warning: "#FF9500"
success: "#34C759"
error: "#FF3B30"
colours-semantic-dark:
info: "#98989D"
warning: "#FF9F0A"
success: "#30D158"
error: "#FF453A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#6E6E73"
background-colour: "#6E6E73"
warning:
icon: warning
primary-colour: "#FF9500"
background-colour: "#FF9500"
success:
icon: success
primary-colour: "#34C759"
background-colour: "#34C759"
error:
icon: error
primary-colour: "#FF3B30"
background-colour: "#FF3B30"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred: SF Pro Text / SF Pro Display.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,85 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-cupertino
# Mac desktop feel. Bright white paper, light-platinum sidebar,
# vivid system blue accent. Dark mode goes near-black with brighter blue.
#
# Colours from Apple's publicly-documented system colour palette
# (developer.apple.com → Human Interface Guidelines → Color):
# systemBlue light #007AFF dark #0A84FF
# secondarySystemBackground (light) #F2F2F7
# systemBackground (dark) #000000
# secondarySystemBackground (dark) #1C1C1E
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#007AFF"
background: "#FFFFFF"
nav-background: "#F2F2F7"
text: "#1D1D1F"
text-muted: "#6E6E73"
dark:
accent: "#0A84FF"
background: "#1E1E1E"
nav-background: "#2C2C2E"
text: "#F5F5F7"
text-muted: "#98989D"
# ──────────────────────────────────
# Semantic colours — Apple system colours (light / dark)
# green #34C759 / #30D158
# orange #FF9500 / #FF9F0A
# red #FF3B30 / #FF453A
# ──────────────────────────────────
colours-semantic:
info: "#007AFF"
warning: "#FF9500"
success: "#34C759"
error: "#FF3B30"
colours-semantic-dark:
info: "#0A84FF"
warning: "#FF9F0A"
success: "#30D158"
error: "#FF453A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#007AFF"
background-colour: "#007AFF"
warning:
icon: warning
primary-colour: "#FF9500"
background-colour: "#FF9500"
success:
icon: success
primary-colour: "#34C759"
background-colour: "#34C759"
error:
icon: error
primary-colour: "#FF3B30"
background-colour: "#FF3B30"
# ──────────────────────────────────
# Typography
# Inter is the portable default — close metrics to SF.
# Preferred on Apple platforms: SF Pro Text (body), SF Pro Display (headings).
# Drop your own TTFs in /fonts and change font-body / font-heading to
# "local:SF Pro Text:400" etc.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,81 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-elementary
# elementary OS feel. Calm paper, "Slate" silver chrome,
# Blueberry-blue accent. Inter is their actual UI typeface (Inter Variable).
#
# Colours from elementary's published Stylesheet (Granite/Pantheon):
# Blueberry 500 #3689E6 (accent)
# Slate 100 #F4F4F4 / Slate 700 #333333
# Strawberry/Lime/Banana/Cherry are the named semantic palette.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#3689E6"
background: "#FAFAFA"
nav-background: "#F4F4F4"
text: "#333333"
text-muted: "#7E8087"
dark:
accent: "#64BAFF"
background: "#1A1A1A"
nav-background: "#262626"
text: "#FFFFFF"
text-muted: "#A6A6A6"
# ──────────────────────────────────
# Semantic colours — elementary named palette
# Lime 500 #68B723 success
# Banana 500 #F9C440 warning
# Strawberry 500 #C6262E error
# Blueberry 500 #3689E6 info
# ──────────────────────────────────
colours-semantic:
info: "#3689E6"
warning: "#F9C440"
success: "#68B723"
error: "#C6262E"
colours-semantic-dark:
info: "#64BAFF"
warning: "#FFD66B"
success: "#9BDB4D"
error: "#E14852"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#3689E6"
background-colour: "#3689E6"
warning:
icon: warning
primary-colour: "#F9C440"
background-colour: "#F9C440"
success:
icon: success
primary-colour: "#68B723"
background-colour: "#68B723"
error:
icon: error
primary-colour: "#C6262E"
background-colour: "#C6262E"
# ──────────────────────────────────
# Typography
# Inter is elementary's actual UI typeface — used as-is.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,71 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-fluent-dark
# Windows 11 dark mica. Same accent system, dark-first defaults.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#005FB8"
background: "#202020"
nav-background: "#2C2C2C"
text: "#FFFFFF"
text-muted: "#C7C7C7"
dark:
accent: "#60CDFF"
background: "#1A1A1A"
nav-background: "#202020"
text: "#FFFFFF"
text-muted: "#C7C7C7"
# ──────────────────────────────────
# Semantic colours
# ──────────────────────────────────
colours-semantic:
info: "#60CDFF"
warning: "#FCE100"
success: "#6CCB5F"
error: "#FF99A4"
colours-semantic-dark:
info: "#60CDFF"
warning: "#FCE100"
success: "#6CCB5F"
error: "#FF99A4"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#60CDFF"
background-colour: "#60CDFF"
warning:
icon: warning
primary-colour: "#FCE100"
background-colour: "#FCE100"
success:
icon: success
primary-colour: "#6CCB5F"
background-colour: "#6CCB5F"
error:
icon: error
primary-colour: "#FF99A4"
background-colour: "#FF99A4"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred: Segoe UI Variable / Selawik.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,81 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-fluent
# Windows 11 / Fluent feel. Cool near-white "Mica" paper, light gray sidebar,
# Windows accent blue. Dark mode uses the dark mica neutrals.
#
# Colours from Microsoft's public Fluent 2 design tokens:
# accent (light): #005FB8 accent (dark): #60CDFF
# neutralBackground1 light #F9F9F9 / sidebar #F3F3F3
# neutralBackground1 dark #202020 / sidebar #2C2C2C
# neutralForeground1 light #1A1A1A / dark #FFFFFF
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#005FB8"
background: "#F9F9F9"
nav-background: "#F3F3F3"
text: "#1A1A1A"
text-muted: "#5C5C5C"
dark:
accent: "#60CDFF"
background: "#202020"
nav-background: "#2C2C2C"
text: "#FFFFFF"
text-muted: "#C7C7C7"
# ──────────────────────────────────
# Semantic colours — Fluent persona / shared colours
# ──────────────────────────────────
colours-semantic:
info: "#005FB8"
warning: "#9D5D00"
success: "#107C10"
error: "#C42B1C"
colours-semantic-dark:
info: "#60CDFF"
warning: "#FCE100"
success: "#6CCB5F"
error: "#FF99A4"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#005FB8"
background-colour: "#005FB8"
warning:
icon: warning
primary-colour: "#9D5D00"
background-colour: "#9D5D00"
success:
icon: success
primary-colour: "#107C10"
background-colour: "#107C10"
error:
icon: error
primary-colour: "#C42B1C"
background-colour: "#C42B1C"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred on Windows: Segoe UI Variable Text
# (body) / Segoe UI Variable Display (headings). Open alternatives:
# "Selawik" or "Selawik Semilight" (Microsoft's Segoe-metric-compatible
# release). Drop TTFs in /fonts and swap to "local:Segoe UI Variable:400".
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,80 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-ios
# iPhone feel. Crisp white paper, grouped-table grey sidebar,
# iOS system blue. Dark mode goes true-black like the OLED dark mode.
#
# Colours from Apple's iOS system colour palette:
# systemBlue light #007AFF / dark #0A84FF
# systemBackground light #FFFFFF / dark #000000
# secondarySystemBackground light #F2F2F7 / dark #1C1C1E
# label light #000000 / dark #FFFFFF
# secondaryLabel light #3C3C43 60% / dark #EBEBF5 60%
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#007AFF"
background: "#FFFFFF"
nav-background: "#F2F2F7"
text: "#000000"
text-muted: "#8E8E93"
dark:
accent: "#0A84FF"
background: "#000000"
nav-background: "#1C1C1E"
text: "#FFFFFF"
text-muted: "#8E8E93"
# ──────────────────────────────────
# Semantic colours — iOS system colours
# ──────────────────────────────────
colours-semantic:
info: "#007AFF"
warning: "#FF9500"
success: "#34C759"
error: "#FF3B30"
colours-semantic-dark:
info: "#0A84FF"
warning: "#FF9F0A"
success: "#30D158"
error: "#FF453A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#007AFF"
background-colour: "#007AFF"
warning:
icon: warning
primary-colour: "#FF9500"
background-colour: "#FF9500"
success:
icon: success
primary-colour: "#34C759"
background-colour: "#34C759"
error:
icon: error
primary-colour: "#FF3B30"
background-colour: "#FF3B30"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred on iOS: SF Pro Text (body),
# SF Pro Display (headings ≥20pt), SF Pro Rounded for friendly UI.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:600"
font-size: 1.00
line-height: 1.5
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,80 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-material-you
# Android / Material 3 feel. Tonal-palette neutrals built on the M3
# baseline purple (#6750A4). Pale lavender paper, soft surface chrome.
#
# Colours from the Material 3 baseline scheme:
# primary light #6750A4 / dark #D0BCFF
# surface light #FEF7FF / dark #141218
# surface-container-low light #F7F2FA / dark #1D1B20
# on-surface light #1D1B20 / dark #E6E0E9
# on-surface-variant light #49454F / dark #CAC4D0
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#6750A4"
background: "#FEF7FF"
nav-background: "#F7F2FA"
text: "#1D1B20"
text-muted: "#49454F"
dark:
accent: "#D0BCFF"
background: "#141218"
nav-background: "#1D1B20"
text: "#E6E0E9"
text-muted: "#CAC4D0"
# ──────────────────────────────────
# Semantic colours — M3 baseline error + standard tertiary/green/yellow
# ──────────────────────────────────
colours-semantic:
info: "#6750A4"
warning: "#9A6700"
success: "#386A20"
error: "#B3261E"
colours-semantic-dark:
info: "#D0BCFF"
warning: "#EFBE6E"
success: "#A6D388"
error: "#F2B8B5"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#6750A4"
background-colour: "#6750A4"
warning:
icon: warning
primary-colour: "#9A6700"
background-colour: "#9A6700"
success:
icon: success
primary-colour: "#386A20"
background-colour: "#386A20"
error:
icon: error
primary-colour: "#B3261E"
background-colour: "#B3261E"
# ──────────────────────────────────
# Typography
# Roboto is the portable default and the Material default.
# Preferred: Roboto Flex (variable) or Google Sans for headings.
# ──────────────────────────────────
font-body: "bunny:Roboto:400"
font-heading: "bunny:Roboto:500"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,84 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-nextstep
# NeXTSTEP / OPENSTEP revival. Cool 50% greys everywhere, jet-black
# title chrome, and the famous NeXT magenta as the accent. Heavy,
# considered, very 1989 Cube energy.
#
# Colours approximated from NeXTSTEP's 2-bit greyscale + colour passes:
# #555555 dark window chrome (title bars, scrollbar wells)
# #AAAAAA panel face (50% grey)
# #DDDDDD highlight
# #000000 ink
# #C72A86 NeXT magenta (used in logo + accents)
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#C72A86"
background: "#DDDDDD"
nav-background: "#555555"
text: "#000000"
text-muted: "#5A5A5A"
dark:
accent: "#E579B5"
background: "#1A1A1A"
nav-background: "#000000"
text: "#DDDDDD"
text-muted: "#A0A0A0"
# ──────────────────────────────────
# Semantic colours — kept restrained, in keeping with the grey-on-grey
# NeXTSTEP discipline.
# ──────────────────────────────────
colours-semantic:
info: "#3A6FA5"
warning: "#A06A00"
success: "#3E7A3E"
error: "#A02828"
colours-semantic-dark:
info: "#88AED9"
warning: "#D9B36B"
success: "#86C58B"
error: "#E07A7A"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#3A6FA5"
background-colour: "#3A6FA5"
warning:
icon: warning
primary-colour: "#A06A00"
background-colour: "#A06A00"
success:
icon: success
primary-colour: "#3E7A3E"
background-colour: "#3E7A3E"
error:
icon: error
primary-colour: "#A02828"
background-colour: "#A02828"
# ──────────────────────────────────
# Typography
# Inter portable default. NeXTSTEP used Helvetica system-wide.
# Preferred: "Helvetica Neue" (Apple system) — falls back to Inter.
# For the more brutalist OPENSTEP feel try "bunny:Helvetica:400"
# if you have it installed locally.
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:700"
font-size: 1.00
line-height: 1.55
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 76em
nav-width: 20em

View file

@ -0,0 +1,79 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-pop
# Pop!_OS feel. Warm Cosmic-grey chrome, signature Pop orange accent.
# System76's GTK-rooted desktop with its own distinctive warmth.
#
# Colours from System76's public Pop palette:
# Pop Orange #FAA41A (primary accent)
# Cosmic Light bg #F2F2F2 / surface #FAFAFA
# Cosmic Dark bg #2D2D2D / surface #232323
# Text light #181818 / dark #F2F2F2
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#FAA41A"
background: "#FAFAFA"
nav-background: "#F2F2F2"
text: "#181818"
text-muted: "#5C5C5C"
dark:
accent: "#FAA41A"
background: "#232323"
nav-background: "#2D2D2D"
text: "#F2F2F2"
text-muted: "#A8A8A8"
# ──────────────────────────────────
# Semantic colours — Pop palette greens/yellows/reds with the warm cast
# ──────────────────────────────────
colours-semantic:
info: "#1B6091"
warning: "#FAA41A"
success: "#73C48F"
error: "#F15D22"
colours-semantic-dark:
info: "#88B8DC"
warning: "#FFC664"
success: "#9BD7AF"
error: "#FF8A5C"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#1B6091"
background-colour: "#1B6091"
warning:
icon: warning
primary-colour: "#FAA41A"
background-colour: "#FAA41A"
success:
icon: success
primary-colour: "#73C48F"
background-colour: "#73C48F"
error:
icon: error
primary-colour: "#F15D22"
background-colour: "#F15D22"
# ──────────────────────────────────
# Typography
# Fira Sans is the portable default and Pop!_OS's actual UI font.
# Available on Google Fonts. Pop also ships Fira Mono for code.
# ──────────────────────────────────
font-body: "bunny:Fira Sans:400"
font-heading: "bunny:Fira Sans:600"
font-size: 1.00
line-height: 1.6
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 78em
nav-width: 20em

View file

@ -0,0 +1,84 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — os-system-7
# Classic Macintosh System 7 / early Mac OS. Black on white with the
# Platinum-grey window chrome that arrived around System 7.5. Very flat,
# very calm, very monochrome.
#
# Colours:
# #FFFFFF paper (white)
# #DDDDDD Platinum window chrome
# #000000 ink (1-bit Mac heritage)
# #B0B0B0 shadow grey
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#000000"
background: "#FFFFFF"
nav-background: "#DDDDDD"
text: "#000000"
text-muted: "#555555"
dark:
accent: "#FFFFFF"
background: "#1A1A1A"
nav-background: "#262626"
text: "#FFFFFF"
text-muted: "#A0A0A0"
# ──────────────────────────────────
# Semantic colours — restrained, since System 7 was a 1-bit interface
# until colour Macs. Kept muted and "drawn-in-MacPaint".
# ──────────────────────────────────
colours-semantic:
info: "#000000"
warning: "#7A5A00"
success: "#1F5A1F"
error: "#8B0000"
colours-semantic-dark:
info: "#FFFFFF"
warning: "#E5C36B"
success: "#7FB87F"
error: "#E08585"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#000000"
background-colour: "#000000"
warning:
icon: warning
primary-colour: "#7A5A00"
background-colour: "#7A5A00"
success:
icon: success
primary-colour: "#1F5A1F"
background-colour: "#1F5A1F"
error:
icon: error
primary-colour: "#8B0000"
background-colour: "#8B0000"
# ──────────────────────────────────
# Typography
# Inter portable default. Preferred for true System 7 fidelity:
# "ChicagoFLF" (free Chicago revival, body) for headings
# "Geneva" (system) for body — or any free Geneva-alike like
# "Charcoal CY" or "ArkPixel".
# For an authentic 1-bit look, try a pixel font like "VT323".
# ──────────────────────────────────
font-body: "bunny:Inter:400"
font-heading: "bunny:Inter:700"
font-size: 1.00
line-height: 1.6
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 76em
nav-width: 20em