Merge pull request #21 from kbenestad/development
Some checks failed
/ mirror (push) Has been cancelled

Merge main into development — sync all theme system changes
This commit is contained in:
Kristian Benestad 2026-06-05 10:56:29 +07:00 committed by GitHub
commit 8295cbca2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2278 additions and 25 deletions

View file

@ -8,13 +8,17 @@ Every merge into `main` is a release. Before committing any change to `mdcms.py`
## 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`.
- **`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.
- **Phased branches** (`claude/<feature>`) are allowed when a large feature needs staged review, but the final merge target is always `main` via `development`.
- **`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.
- **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.
**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
`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.
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.
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.
10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()``cli()`.

View file

@ -21,7 +21,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MD-CMS</title>
<title></title>
<meta name="description" content="">
<link rel="icon" href="assets/images/favicon.png">
<link rel="manifest" href="manifest.json">
@ -917,6 +917,146 @@ body {
}
.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 {
.sidebar, .topbar, .scroll-top, .hamburger,
.mobile-header, .theme-toggle, .search-container { display: none !important; }
@ -952,7 +1092,7 @@ body {
// Category state (phase 3)
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 defaultCategoryCode = null;
let activeCategory = null; // current code
@ -1135,11 +1275,19 @@ body {
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) {
// conceptualFile like "pages/foo.md". Returns { ok, text, resolvedFile } or { ok: false }.
if (!categoriesUse) {
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 };
}
const base = conceptualFile.replace(/\.md$/, '');
@ -1169,7 +1317,7 @@ body {
if (seen.has(url)) continue;
seen.add(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 };
}
@ -1202,13 +1350,15 @@ body {
// - Home page: always show (per config.homepage or default 'pages/home.md')
// - Variant exists for active category: show
// - Active category has notfoundmessage: show (renderer falls back to default language)
// - Active category has visibilityifnocontent: visible: show (renderer shows pagenotfoundmessage)
// - Otherwise: hide
if (!categoriesUse) return true;
if (page.file === defaultPage()) return true;
if (page.uncategorized) return true;
const variants = page.variants || [];
if (variants.includes(activeCategory)) return true;
const cat = categoriesByCode[activeCategory];
return !!(cat && cat.notfoundmessage);
return !!(cat && (cat.notfoundmessage || cat.visibilityifnocontent === 'visible'));
}
// ─── Theme ────────────────────────────────────────────────
@ -1226,6 +1376,7 @@ body {
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
}
computeDerivedTokens();
}
function getInitialTheme() {
@ -1457,6 +1608,10 @@ body {
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
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;');
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
}
@ -1607,7 +1762,7 @@ function fmtDatetime(dtStr) {
// Category filter
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
@ -1946,6 +2101,95 @@ function fmtDatetime(dtStr) {
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) {
const byCode = {};
navSections.forEach(s => { byCode[s.code] = s; });
@ -2002,6 +2246,143 @@ function fmtDatetime(dtStr) {
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() {
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
try {
@ -2010,6 +2391,10 @@ function fmtDatetime(dtStr) {
renderCalloutTag(tagEl, cfg);
} else if (cfg.tagName === 'toc') {
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 {
renderPostTag(tagEl, cfg);
}
@ -2243,16 +2628,17 @@ function fmtDatetime(dtStr) {
function visibleCategoryCodesForCurrentPage() {
// Which categories should appear in the dropdown:
// - 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
const out = new Set();
const page = currentPage
? navData.find(p => p.file === currentPage)
: null;
categoriesList.forEach(cat => {
const hasVariant = !page || !page.variants || page.variants.includes(cat.code);
const hasMsg = !!cat.notfoundmessage;
if (hasVariant || hasMsg || cat.code === activeCategory) out.add(cat.code);
const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code);
const alwaysVisible = !!cat.notfoundmessage || cat.visibilityifnocontent === 'visible';
if (hasVariant || alwaysVisible || cat.code === activeCategory) out.add(cat.code);
});
return out;
}
@ -2277,7 +2663,7 @@ function fmtDatetime(dtStr) {
'data-code': cat.code
});
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) {
option.appendChild(el('span', { className: 'secondary', textContent: cat.notfoundmessage }));
} else if (secondary) {
@ -2642,6 +3028,24 @@ function fmtDatetime(dtStr) {
const contentEl = document.getElementById('pageContent');
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
const u = new URL(window.location);
if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) {
@ -2656,7 +3060,7 @@ function fmtDatetime(dtStr) {
const result = await fetchPageFile(file);
if (!result.ok) {
const offlineMsg = localStorage.getItem('mdcms-offline');
const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
const bodyMsg = offlineMsg
? `<p>${offlineMsg}</p>`
: `<p>${pageNotFoundMessage()}</p>`;

View file

@ -16,3 +16,7 @@ pages:
- file: pages/docs.md
title: Docs
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": "",
"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"
},
{
"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
const CACHE_NAME = 'mdcms-eb384247';
const CACHE_NAME = 'mdcms-a1862733';
const PRECACHE_URLS = [
"index.html",
"config.yml",
@ -9,20 +9,29 @@ const PRECACHE_URLS = [
"pages/about.md",
"pages/docs.md",
"pages/home.md",
"pages/tabs-accordions.md",
"posts/.gitkeep",
"assets/fonts/.gitkeep",
"assets/icons/.gitkeep",
"assets/icons/add.svg",
"assets/icons/arrow_drop_down.svg",
"assets/icons/arrow_right.svg",
"assets/icons/collapse_content.svg",
"assets/icons/dangerous.svg",
"assets/icons/dark_mode.svg",
"assets/icons/error.svg",
"assets/icons/exclamation.svg",
"assets/icons/expand_content.svg",
"assets/icons/history.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/light_mode.svg",
"assets/icons/menu.svg",
"assets/icons/minimize.svg",
"assets/icons/mobile_arrow_down.svg",
"assets/icons/report.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.
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.
# 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
name: Norsk
direction: ltr
- code: ar
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.
# 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.
```
### 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
@ -228,6 +273,30 @@ offline-message:
nb: "Du er frakoblet. Koble til og last inn på nytt."
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:
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
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 {}
try:
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 {}
except (OSError, yaml.YAMLError):
return {}
except yaml.YAMLError as e:
raise click.ClickException(f"config.yml is not valid YAML: {e}")
def get_category_info(cfg: dict) -> dict:
@ -265,11 +268,19 @@ def build_page_nav(
"sort": sort,
}
if categories_use:
is_post = file.startswith("posts/")
covered = {}
has_uncategorized = False
for code, record in variants.items():
key = code if code is not None else default_code
if key:
covered[key] = record.get("title", "")
if code is None:
if is_post:
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["titles"] = covered
out.append(entry)
@ -313,6 +324,8 @@ def generate_nav_yml(sections: list, pages: list, categories_use: bool = False)
if p.get("section-id"):
lines.append(f" section-id: {p['section-id']}")
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"):
lines.append(f" variants: [{', '.join(p['variants'])}]")
if categories_use and p.get("titles"):
@ -345,7 +358,13 @@ def generate_search_json(
}
if categories_use:
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)
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 ─────────────────────────────────────────
_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):
"""Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure."""
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)")
_patch_html_title(site_path, cfg.get("sitename", ""))
pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true")
if pwa_enabled:
generate_pwa(site_path, cfg)
else:
cleanup_pwa(site_path)
asset_warnings = validate_assets(site_path, cfg)
for w in asset_warnings:
@ -509,6 +545,29 @@ def run_build(site_path: Path):
# ─── 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):
"""Generate manifest.json and service-worker.js when pwa: yes."""
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