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 || '[missing: ' + filename + ']'; 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()