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
This commit is contained in:
Claude 2026-05-21 15:46:43 +00:00
parent 099320cde7
commit a09df3a63c
No known key found for this signature in database
6 changed files with 617 additions and 1 deletions

View file

@ -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; }
@ -1236,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() {
@ -1467,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, '&').replace(/"/g, '"'); const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"');
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>'; return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
} }
@ -1956,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; });
@ -2012,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 {
@ -2020,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);
} }

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",

View file

@ -4,6 +4,144 @@ 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` 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. `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.