diff --git a/app/config.yml b/app/config.yml
index 0da7820..d4f01c7 100644
--- a/app/config.yml
+++ b/app/config.yml
@@ -43,3 +43,13 @@ theme: theme.yml # presentational config — edit theme.yml to custo
# ──────────────────────────────────
# search: true
# default-theme: system # light | dark | system
+
+# ──────────────────────────────────
+# Reusable callout messages (optional)
+# ──────────────────────────────────
+callouts:
+ aitranslation:
+ type: warning
+ en:
+ title: "PLEASE NOTE:"
+ text: This page has been translated with artificial intelligence. It has not been reviewed by staff yet.
diff --git a/app/index.html b/app/index.html
index 9576974..1cb851f 100644
--- a/app/index.html
+++ b/app/index.html
@@ -862,6 +862,32 @@ body {
}
.post-load-more:hover { background: var(--nav-hover-bg); }
+/* ── Callout tags ──────────────────────────────────────── */
+.mdcms-callout {
+ border-left: 4px solid var(--callout-primary, var(--accent));
+ background: var(--callout-bg, transparent);
+ border-radius: 0 0.4rem 0.4rem 0;
+ padding: 0.75rem 1rem;
+ margin: 1rem 0;
+}
+.mdcms-callout-title {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-weight: 700;
+ color: var(--callout-primary, var(--accent));
+ margin-bottom: 0.4rem;
+}
+.mdcms-callout-title .mdcms-icon svg {
+ fill: var(--callout-primary, var(--accent));
+ width: 1.2em;
+ height: 1.2em;
+ display: block;
+}
+.mdcms-callout-body { margin: 0; }
+.mdcms-callout-body > :first-child { margin-top: 0; }
+.mdcms-callout-body > :last-child { margin-bottom: 0; }
+
@media print {
.sidebar, .topbar, .scroll-top, .hamburger,
.mobile-header, .theme-toggle, .search-container { display: none !important; }
@@ -893,6 +919,7 @@ body {
let searchIndex = [];
let fuseInstance = null;
let currentPage = null;
+ let themeConfig = {};
// Category state (phase 3)
let categoriesUse = false;
@@ -929,7 +956,8 @@ body {
const svg = getIcon(name);
const span = document.createElement('span');
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
- span.innerHTML = svg || '
';
+ const filename = normaliseIconName(name);
+ span.innerHTML = svg || '
';
return span;
}
@@ -1367,8 +1395,11 @@ body {
} else {
codeText = code; codeLang = lang;
}
- if (codeLang === 'mdcms') {
- const tag = parseMdcmsTag(codeText);
+ // Match both ```mdcms (type in content) and ```mdcms callout-info (type in fence)
+ if (codeLang && (codeLang === 'mdcms' || codeLang.startsWith('mdcms '))) {
+ const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
+ const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
+ const tag = parseMdcmsTag(fullText);
const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"');
return '
';
}
@@ -1487,11 +1518,18 @@ function fmtDatetime(dtStr) {
var lines = text.trim().split('\n');
var tagName = lines[0].trim();
var options = {};
+ var bodyStart = lines.length;
for (var i = 1; i < lines.length; i++) {
- var m = lines[i].match(/^\s*([a-z\-]+)\s*:\s*(.+)$/i);
- if (m) options[m[1].toLowerCase()] = m[2].trim();
+ var m = lines[i].match(/^\s*([a-z\-]+)\s*:\s*(.*)$/i);
+ if (m) {
+ options[m[1].toLowerCase()] = m[2].trim();
+ } else {
+ bodyStart = i;
+ break;
+ }
}
- return { tagName: tagName, options: options };
+ var body = lines.slice(bodyStart).join('\n').trim();
+ return { tagName: tagName, options: options, body: body };
}
function parsePostTagName(name) {
@@ -1769,11 +1807,97 @@ function fmtDatetime(dtStr) {
renderYear();
}
+ // Callout type defaults (fallback when theme.yml has no callouts block)
+ const CALLOUT_DEFAULTS = {
+ info: { icon: 'info', colour: '#2563EB' },
+ warning: { icon: 'warning', colour: '#D97706' },
+ success: { icon: 'success', colour: '#16A34A' },
+ error: { icon: 'error', colour: '#DC2626' },
+ };
+
+ function renderCalloutTag(container, tag) {
+ var typeMatch = tag.tagName.match(/^callout-(info|warning|success|error)$/);
+ var calloutType = typeMatch ? typeMatch[1] : 'info';
+
+ var opts = tag.options;
+ var msgKey = opts.message || null;
+ var title = opts.title || null;
+ var iconName = opts.icon || null;
+ var bodyMd = tag.body || '';
+
+ // Resolve message: key — config.yml callouts block
+ if (msgKey) {
+ var msgDefs = config.callouts || {};
+ var msgDef = msgDefs[msgKey];
+ if (msgDef) {
+ // Override callout type from message definition
+ if (msgDef.type) calloutType = msgDef.type;
+ // Language resolution: activeCategory → defaultCategoryCode → first key
+ var lang = activeCategory || defaultCategoryCode;
+ var langEntry = (lang && msgDef[lang]) || msgDef[defaultCategoryCode];
+ if (!langEntry) {
+ var keys = Object.keys(msgDef).filter(function(k) { return k !== 'type'; });
+ langEntry = msgDef[keys[0]];
+ }
+ if (langEntry) {
+ title = langEntry.title || null;
+ bodyMd = langEntry.text || '';
+ }
+ if (opts.title || tag.body) {
+ console.warn('[mdcms] callout: message: key takes precedence; inline title/body ignored.');
+ }
+ }
+ }
+
+ // Get callout colours/icon from theme.yml callouts block, then fallback
+ var themeCallouts = (themeConfig.callouts || {})[calloutType] || {};
+ var fallback = CALLOUT_DEFAULTS[calloutType] || CALLOUT_DEFAULTS.info;
+ var primaryColour = themeCallouts['primary-colour'] || fallback.colour;
+ var bgColour = themeCallouts['background-colour'] || fallback.colour;
+ if (!iconName) iconName = themeCallouts.icon || fallback.icon;
+
+ // Build element
+ container.className = 'mdcms-callout mdcms-callout-' + calloutType;
+ container.style.setProperty('--callout-primary', primaryColour);
+ container.style.setProperty('--callout-bg', hexToRgba(bgColour, 0.08));
+
+ if (title) {
+ var titleRow = document.createElement('div');
+ titleRow.className = 'mdcms-callout-title';
+ titleRow.style.color = primaryColour;
+ titleRow.appendChild(iconEl(iconName));
+ var titleText = document.createElement('span');
+ titleText.textContent = title;
+ titleRow.appendChild(titleText);
+ container.appendChild(titleRow);
+ }
+
+ if (bodyMd) {
+ var bodyEl = document.createElement('div');
+ bodyEl.className = 'mdcms-callout-body';
+ bodyEl.innerHTML = marked.parse(bodyMd);
+ container.appendChild(bodyEl);
+ }
+ }
+
+ function hexToRgba(hex, alpha) {
+ var h = hex.replace('#', '');
+ if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
+ var r = parseInt(h.substring(0,2), 16);
+ var g = parseInt(h.substring(2,4), 16);
+ var b = parseInt(h.substring(4,6), 16);
+ return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
+ }
+
function hydrateMdcmsTags() {
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
try {
var cfg = JSON.parse(tagEl.getAttribute('data-config'));
- renderPostTag(tagEl, cfg);
+ if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
+ renderCalloutTag(tagEl, cfg);
+ } else {
+ renderPostTag(tagEl, cfg);
+ }
} catch (e) {
tagEl.textContent = 'Error rendering tag.';
}
@@ -2508,7 +2632,6 @@ function fmtDatetime(dtStr) {
if (link) link.href = `assets/images/${config.logo}`;
}
- let themeConfig = {};
if (config.theme) {
try {
const themeResp = await fetch(config.theme);
diff --git a/app/pages/home.md b/app/pages/home.md
index ccf41dc..3ed6b63 100644
--- a/app/pages/home.md
+++ b/app/pages/home.md
@@ -3,65 +3,101 @@ title: Home
sort: 100
---
-# MD-CMS
+# Phase 4 — Callout Tags
-This is the default startpage for MD-CMS.
+Check each callout below. Each should show a coloured left border, an icon, a bold title in the accent colour, and a rendered body.
-## Testing MD-CMS
+---
-If you want to test `MD-CMS` you can grab `samplesite` from the repo and place the content in your website root. This page (`pages/home.md`) won't be replaced.
+## Basic types
-**Post listing tests** below contains various custom tags to display posts. There are no posts now, but if you download the `samplesite` it will fetch the posts in
+```mdcms callout-info
+title: Information
+This is an **info** callout. Supports *italic*, `code`, and lists:
-## Post listing tests
-
-## Reverse chronological (newest first)
-
-```mdcms
-posts-created-reversechronological
-limit: 3
-paginate: no
+- Item one
+- Item two
```
-## Chronological (oldest first)
-
-```mdcms
-posts-created-chronological
-limit: all
-paginate: none
+```mdcms callout-warning
+title: Warning
+Something needs your attention. This is a **warning** callout.
```
-## By year (reverse chrono)
-
-```mdcms
-posts-created-reversechronological-byyear
-limit: all
-defaultyear: current
-selectyear: yes
-paginate: none
+```mdcms callout-success
+title: Success
+The operation completed successfully. This is a **success** callout.
```
-## By year+month (chrono)
-
-```mdcms
-posts-created-chronological-byyearmonth
-limit: all
-defaultyear: 2024
-selectyear: yes
+```mdcms callout-error
+title: Error
+Something went wrong. This is an **error** callout.
```
-## Last 30 days
+---
-```mdcms
-posts-created-reversechronological-lastmonth
-limit: all
-paginate: none
+## No title
+
+```mdcms callout-info
+No title key here. The title row should not appear at all — just the body.
```
-## Paginated (2 per page)
+---
-```mdcms
-posts-created-reversechronological
-limit: 2
-paginate: yes
+## Markdown body
+
+```mdcms callout-warning
+title: Rich body
+- List item one
+- List item two
+
+A paragraph with `inline code` and a [link](https://example.com).
```
+
+---
+
+## Custom icon override
+
+```mdcms callout-info
+title: Info with warning icon
+icon: warning
+This info callout uses the warning icon instead of the default info icon.
+```
+
+---
+
+## Config-defined message (message: key)
+
+The callout below uses `message: aitranslation` to pull its title and body from the `callouts:` block in `config.yml`. The type (`warning`) also comes from the config entry, not the tag name.
+
+```mdcms callout-info
+message: aitranslation
+```
+
+---
+
+## message: overrides inline content
+
+When `message:` is present, any inline `title:` or body text is ignored. A warning should appear in the browser console.
+
+```mdcms callout-info
+message: aitranslation
+title: This title should be ignored
+This body text should also be ignored. Check the console for a warning.
+```
+
+---
+
+## Missing icon
+
+This callout uses a non-existent icon name. A broken image should appear where the icon would be.
+
+```mdcms callout-info
+title: Custom icon that does not exist
+icon: nonexistent_icon
+The icon to the left of this title should show as a broken image.
+```
+
+---
+
+Toggle dark mode and check all four callout types still look correct.
diff --git a/mdcms.py b/mdcms.py
index 8b298cd..473c2f6 100644
--- a/mdcms.py
+++ b/mdcms.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# mdcms v0.3.5 — CLI companion
+# mdcms v0.3.6 — CLI companion
#
# Copyright 2026 Kristian Benestad
# Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0
@@ -21,8 +21,8 @@ import certifi
import click
import yaml
-CLI_VERSION = "0.3.5"
-CLI_RELEASE_DATE = "16 May 2026"
+CLI_VERSION = "0.3.6"
+CLI_RELEASE_DATE = "17 May 2026"
MIN_SUPPORTED_VERSION = "0.3"
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
diff --git a/pyproject.toml b/pyproject.toml
index a139d27..74807ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mdcms"
-version = "0.3.5"
+version = "0.3.6"
description = "MD-CMS — Markdown-based CMS companion CLI"
readme = "README.md"
license = { text = "Apache-2.0" }
diff --git a/test_phase.py b/test_phase.py
new file mode 100644
index 0000000..9104574
--- /dev/null
+++ b/test_phase.py
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+"""
+MD-CMS v0.4 phase test runner.
+
+Usage:
+ python3 test_phase.py [phase]
+
+ phase: 1-7 (default: run all phases sequentially)
+
+Each phase fetches the corresponding branch, checks out the renderer and
+content files for that phase, starts a local HTTP server, and opens the
+browser. Press Enter when done to continue to the next phase.
+"""
+
+import functools
+import http.server
+import subprocess
+import sys
+import threading
+import time
+import webbrowser
+from pathlib import Path
+
+PORT = 8800
+
+PHASES = {
+ 1: ("main", "theme.yml and colour system"),
+ 2: ("v0.4_phase2", "Icon system — local SVGs, no Google Fonts"),
+ 3: ("v0.4_phase3", "Asset validation in mdcms build"),
+ 4: ("claude/debug-api-errors-gd730", "Callout tags"),
+ 5: ("v0.4_phase5", "Table of contents tag"),
+ 6: ("v0.4_phase6", "Offline / fetch-deps"),
+ 7: ("v0.4_phase7", "PWA — service worker and manifest"),
+}
+
+VERIFY = {
+ 1: [
+ "Existing site renders correctly with theme.yml present",
+ "Missing theme: key falls back gracefully to hardcoded defaults",
+ "Accent colour and dark/light mode colours apply from theme.yml",
+ ],
+ 2: [
+ "All UI icons render correctly from local SVG files (no Google Icons font)",
+ "Theme toggle, search, hamburger, nav arrows all show icons",
+ "Broken image displays for a missing icon (test by renaming one SVG)",
+ "Icon name normalisation: 'arrow-right' and 'Arrow Right' both resolve",
+ ],
+ 3: [
+ "Run: python3 mdcms.py build --path app/ (NOT the installed mdcms command)",
+ "Warning printed for assets/images/missing-photo.png (referenced in pages/about.md)",
+ "No warning for assets/images/logo.png (file exists)",
+ "Build continues and completes after warnings",
+ ],
+ 4: [
+ "── Basic types ─────────────────────────────────────────────────",
+ "callout-info, callout-warning, callout-success, callout-error all render",
+ "Each has: coloured left border + low-opacity background in the right colour",
+ "── Title row ───────────────────────────────────────────────────",
+ "Title row shows: icon (inlined SVG) + bold title text in the accent colour",
+ "Title text is correct for each type: Information / Warning / Success / Error",
+ "── No-title callout ────────────────────────────────────────────",
+ "Callout with no title key: no title row rendered, just the body",
+ "── Markdown body ───────────────────────────────────────────────",
+ "Body renders full markdown: bold, italic, lists, inline code, links",
+ "── Custom icon override ────────────────────────────────────────",
+ "icon: warning on a callout-info renders the warning SVG, not the info SVG",
+ "── Config-defined message: key ─────────────────────────────────",
+ "message: aitranslation resolves title 'PLEASE NOTE:' and body from config.yml",
+ "── Dark mode ───────────────────────────────────────────────────",
+ "Toggle dark mode: all four callout types still look correct",
+ "Colours adapt (border and background tint remain visible on dark background)",
+ ],
+ 5: [
+ "TOC tag renders a section-grouped page list",
+ "Only pages visible for active category are listed",
+ "Draft pages are excluded",
+ "TOC page itself is excluded from the listing",
+ "Section headings and sort order are correct",
+ ],
+ 6: [
+ "mdcms fetch-deps downloads JS/CSS to assets/required/vendors/",
+ "Patched index.html makes no external network requests",
+ "Fonts load correctly from local paths",
+ "Site loads fully offline after fetch-deps",
+ ],
+ 7: [
+ "service-worker.js and manifest.json generated when pwa: yes",
+ "Full site accessible offline after one online visit",
+ "Cache updates correctly on new build deployment",
+ "Offline message displays when cache evicted",
+ ],
+}
+
+# Files checked out from the phase branch on every phase switch.
+# Renderer + presentational config. Content files (home.md, config.yml) are
+# added per phase in EXTRA_FILES so the right test content is loaded.
+RENDERER_FILES = [
+ "app/index.html",
+ "app/theme.yml",
+ "app/assets/icons",
+ "mdcms.py",
+]
+
+# Per-phase extra files to check out from the phase branch.
+# Use this for content or config files that differ between phases.
+EXTRA_FILES = {
+ 4: [
+ "app/config.yml", # has callouts: block for message: key test
+ "app/pages/home.md", # has Phase 4 callout test cases
+ ],
+}
+
+
+def checkout_phase(phase: int) -> bool:
+ branch, _ = PHASES[phase]
+ repo_root = Path(__file__).parent
+
+ print(f"\n Fetching branch {branch} from origin...")
+ result = subprocess.run(
+ ["git", "fetch", "origin", branch],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f" ERROR fetching: {result.stderr.strip()}")
+ return False
+
+ files = RENDERER_FILES + EXTRA_FILES.get(phase, [])
+ print(f" Checking out {len(files)} file groups from origin/{branch}...")
+ result = subprocess.run(
+ ["git", "checkout", f"origin/{branch}", "--"] + files,
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f" ERROR checking out files: {result.stderr.strip()}")
+ return False
+
+ print(" Rebuilding nav.yml...")
+ result = subprocess.run(
+ ["python3", "mdcms.py", "build", "--path", "app/"],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f" WARNING: mdcms build failed: {result.stderr.strip()}")
+ else:
+ out = result.stdout.strip()
+ if out:
+ print(f" {out}")
+
+ print(" Ready.\n")
+ return True
+
+
+def serve(app_dir: Path):
+ handler = functools.partial(
+ http.server.SimpleHTTPRequestHandler,
+ directory=str(app_dir),
+ )
+ handler.log_message = lambda *a: None
+ with http.server.HTTPServer(("", PORT), handler) as httpd:
+ httpd.serve_forever()
+
+
+def run_phase(phase: int):
+ branch, description = PHASES[phase]
+
+ print("\n" + "=" * 62)
+ print(f" Phase {phase}: {description}")
+ print(f" Branch: {branch}")
+ print("=" * 62)
+
+ repo_root = Path(__file__).parent
+ app_dir = repo_root / "app"
+
+ if phase > 1:
+ if not checkout_phase(phase):
+ print(" Checkout failed — skipping this phase.")
+ return
+
+ print(f" Serving app/ at http://localhost:{PORT}\n")
+ print(" Checklist:")
+ for item in VERIFY[phase]:
+ if item.startswith("──"):
+ print(f"\n {item}")
+ else:
+ print(f" [ ] {item}")
+
+ t = threading.Thread(target=serve, args=(app_dir,), daemon=True)
+ t.start()
+ time.sleep(0.3)
+ webbrowser.open(f"http://localhost:{PORT}")
+
+ print("\n Press Enter when done (Ctrl+C to abort)...")
+ try:
+ input()
+ except KeyboardInterrupt:
+ print("\n Aborted.")
+
+
+def main():
+ if len(sys.argv) == 2:
+ try:
+ phase = int(sys.argv[1])
+ except ValueError:
+ print("Usage: python3 test_phase.py [1-7]")
+ sys.exit(1)
+ if phase not in PHASES:
+ print(f"Phase must be 1-7, got {phase}")
+ sys.exit(1)
+ run_phase(phase)
+ else:
+ for phase in sorted(PHASES):
+ run_phase(phase)
+ print(f"\n Phase {phase} done.")
+
+
+if __name__ == "__main__":
+ main()