v0.3.6 — Phase 4 callout tags (+ phases 1–3)

v0.3.6 — Phase 4 callout tags (+ phases 1–3)
This commit is contained in:
Kristian Benestad 2026-05-18 01:43:36 +07:00 committed by GitHub
commit ca8deba23f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 448 additions and 56 deletions

View file

@ -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.

View file

@ -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 || '<img src="" alt="" style="width:1em;height:1em;">';
const filename = normaliseIconName(name);
span.innerHTML = svg || '<img src="assets/icons/' + filename + '" alt="[missing: ' + filename + ']" style="width:1em;height:1em;display:inline-block;">';
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, '&amp;').replace(/"/g, '&quot;');
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
}
@ -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);

View file

@ -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.

View file

@ -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)

View file

@ -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" }

223
test_phase.py Normal file
View file

@ -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()