diff --git a/app/index.html b/app/index.html
index f83ba46..7c1f2a7 100644
--- a/app/index.html
+++ b/app/index.html
@@ -901,7 +901,7 @@ body {
background: var(--bg);
color: var(--font-colour);
font-size: 0.85rem;
- text-align: centre;
+ text-align: center;
}
.post-load-more {
@@ -1143,7 +1143,15 @@ body {
const span = document.createElement('span');
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
const filename = normaliseIconName(name);
- span.innerHTML = svg || '
';
+ if (svg) {
+ span.innerHTML = svg;
+ } else {
+ const img = document.createElement('img');
+ img.src = 'assets/icons/' + encodeURIComponent(filename);
+ img.alt = '[missing: ' + filename + ']';
+ img.style.cssText = 'width:1em;height:1em;display:inline-block;';
+ span.appendChild(img);
+ }
return span;
}
@@ -1169,6 +1177,31 @@ body {
return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim();
}
+ function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
+ });
+ }
+
+ // Reject hrefs with dangerous schemes (javascript:, data:, vbscript:).
+ function safeUrl(url) {
+ var u = String(url == null ? '' : url).trim();
+ if (/^[a-z][a-z0-9+.\-]*:/i.test(u)) {
+ if (/^(https?|mailto|tel|ftp):/i.test(u)) return u;
+ return '#';
+ }
+ return u; // relative URL or fragment
+ }
+
+ // A routable page file is a relative .md path with no scheme or traversal.
+ // Heading-anchor hashes (no .md) and external URLs both fail this check.
+ function isSafePagePath(file) {
+ return typeof file === 'string'
+ && /^[\w./-]+\.md$/.test(file)
+ && !file.includes('..')
+ && file[0] !== '/';
+ }
+
function parseFrontmatter(md) {
const match = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
if (!match) return { meta: {}, body: md };
@@ -1397,7 +1430,7 @@ body {
function getInitialTheme() {
const saved = localStorage.getItem('md-cms-theme');
- if (saved) return saved;
+ if (saved === 'light' || saved === 'dark') return saved;
const def = config['default-theme'] || 'system';
if (def === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
@@ -1583,7 +1616,9 @@ body {
}
// ─── Markdown ─────────────────────────────────────────────
- function renderMarkdown(mdBody) {
+ let _markedConfigured = false;
+ function configureMarked() {
+ if (_markedConfigured) return;
marked.setOptions({ gfm: true, breaks: false, headerIds: true, mangle: false });
const renderer = new marked.Renderer();
@@ -1603,13 +1638,14 @@ body {
}
const isExternal = linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://'));
const isMd = linkHref && linkHref.endsWith('.md');
+ const titleAttr = linkTitle ? ` title="${escapeHtml(linkTitle)}"` : '';
if (isExternal) {
- return `${linkText}`;
+ return `${linkText}`;
}
if (isMd) {
- return `${linkText}`;
+ return `${linkText}`;
}
- return `${linkText}`;
+ return `${linkText}`;
};
renderer.code = function(code, lang, escaped) {
@@ -1637,6 +1673,11 @@ body {
};
marked.use({ renderer });
+ _markedConfigured = true;
+ }
+
+ function renderMarkdown(mdBody) {
+ configureMarked();
return marked.parse(mdBody);
}
@@ -2400,24 +2441,36 @@ function fmtDatetime(dtStr) {
}
function hydrateMdcmsTags() {
- document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
- try {
- var cfg = JSON.parse(tagEl.getAttribute('data-config'));
- if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
- 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);
+ // Rendering a tag (tab/accordion/callout) can emit further .mdcms-tag
+ // elements in its body, so keep sweeping until none are left. A processed
+ // marker and an iteration cap guard against runaway loops.
+ var MAX_PASSES = 10;
+ for (var pass = 0; pass < MAX_PASSES; pass++) {
+ var pending = Array.prototype.filter.call(
+ document.querySelectorAll('.mdcms-tag'),
+ function(t) { return !t.hasAttribute('data-mdcms-hydrated'); }
+ );
+ if (!pending.length) break;
+ pending.forEach(function(tagEl) {
+ tagEl.setAttribute('data-mdcms-hydrated', '');
+ try {
+ var cfg = JSON.parse(tagEl.getAttribute('data-config'));
+ if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
+ 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);
+ }
+ } catch (e) {
+ tagEl.textContent = 'Error rendering tag.';
}
- } catch (e) {
- tagEl.textContent = 'Error rendering tag.';
- }
- });
+ });
+ }
}
// ─── Shell ────────────────────────────────────────────────
@@ -3068,8 +3121,17 @@ function fmtDatetime(dtStr) {
// ─── Page loading ─────────────────────────────────────────
async function navigateTo(file) {
- currentPage = file;
const contentEl = document.getElementById('pageContent');
+
+ // Guard the router: only fetch relative .md paths. This blocks loading
+ // attacker-controlled external URLs (e.g. #https://evil/x.md) or traversal
+ // paths injected via the location hash.
+ if (!isSafePagePath(file)) {
+ contentEl.innerHTML = `
Page not available
${escapeHtml(pageNotFoundMessage())}
`;
+ return;
+ }
+
+ currentPage = file;
highlightNav(file);
// If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible)
@@ -3115,8 +3177,8 @@ function fmtDatetime(dtStr) {
if (!result.ok) {
const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
const bodyMsg = offlineMsg
- ? `${offlineMsg}
`
- : `${pageNotFoundMessage()}
`;
+ ? `${escapeHtml(offlineMsg)}
`
+ : `${escapeHtml(pageNotFoundMessage())}
`;
contentEl.innerHTML = `Page not available
${bodyMsg}`;
document.title = (config.sitename || 'MD-CMS');
refreshCategoryBar();
@@ -3127,9 +3189,9 @@ function fmtDatetime(dtStr) {
const { meta, body } = parseFrontmatter(result.text);
let html = `
- ${config.sitename || 'MD-CMS'}
+ ${escapeHtml(config.sitename || 'MD-CMS')}
›
- ${meta.title || file}
+ ${escapeHtml(meta.title || file)}
`;
html += '' + renderMarkdown(body) + '
';
contentEl.innerHTML = html;
@@ -3179,14 +3241,16 @@ function fmtDatetime(dtStr) {
window.addEventListener('hashchange', () => {
const page = getPageFromHash();
- if (page && page !== currentPage) navigateTo(page);
+ // Ignore in-page heading anchors (e.g. #installation) — only route real .md
+ // pages. Without this, clicking a heading link wipes the page with a 404.
+ if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page);
});
window.addEventListener('popstate', () => {
const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, '');
const pathPage = slug ? resolveSlugToFile(slug) : null;
const page = pathPage || getPageFromHash();
- if (page && page !== currentPage) navigateTo(page);
+ if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page);
});
// ─── Scroll to top ───────────────────────────────────────
@@ -3219,6 +3283,11 @@ function fmtDatetime(dtStr) {
} catch (e) { /* fall back to hardcoded CSS defaults */ }
}
+ loadFonts(themeConfig);
+ initCategories();
+
+ // Resolve the offline message after initCategories(), which sets
+ // defaultCategoryCode — otherwise a per-category default is missed.
const offlineMsgCfg = config['offline-message'];
if (offlineMsgCfg) {
const offlineText = typeof offlineMsgCfg === 'string'
@@ -3227,9 +3296,6 @@ function fmtDatetime(dtStr) {
if (offlineText) localStorage.setItem('mdcms-offline', offlineText);
}
- loadFonts(themeConfig);
- initCategories();
-
const iconsToPreload = [...STANDARD_ICONS];
if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']);
await Promise.all(iconsToPreload.map(name => loadIcon(name)));
diff --git a/docs/unreleased.md b/docs/unreleased.md
index 6681a79..bfc4ac5 100644
--- a/docs/unreleased.md
+++ b/docs/unreleased.md
@@ -4,6 +4,43 @@ Changes merged into `development` that have not yet been released to `main`.
---
+## Security & bug fixes (v0.6.1)
+
+### Renderer (`app/index.html`)
+
+- **Router now rejects unsafe page paths.** `navigateTo` and the
+ `hashchange`/`popstate` handlers only load relative `.md` paths via a new
+ `isSafePagePath` check. Previously a crafted link such as
+ `#https://evil.example/x.md` made the renderer fetch and render an
+ attacker-controlled document on the site's own origin (stored/reflected XSS).
+- **In-page heading anchors no longer 404.** A markdown link to `#some-heading`
+ used to be treated as a page file and blew the page away with a
+ "Page not available" error; such hashes are now ignored by the router so the
+ browser scrolls to the heading.
+- **Escaped untrusted interpolation.** `meta.title` (title bar), link `href`
+ and `title` attributes, the page-not-found / offline messages, and the icon
+ fallback `
` are now HTML-escaped. Link hrefs with `javascript:`,
+ `data:`, and other non-allowlisted schemes are neutralised via `safeUrl`.
+- **Nested `mdcms` tags now hydrate.** Tags emitted inside a tab, accordion, or
+ callout body (e.g. a post list inside a tab) are processed by re-sweeping
+ until none remain, instead of rendering as empty divs.
+- **`marked` is configured once** instead of re-registering the renderer on
+ every page render.
+- Stored `md-cms-theme` value is validated against `light`/`dark` before use;
+ fixed invalid `text-align: centre` → `center` on the pagination jump input;
+ per-category `offline-message` is now resolved after categories initialise.
+
+### CLI (`mdcms.py`)
+
+- **`mdcms fetch-deps` no longer crashes.** `CDN_DEPS`, `_WOFF2_URL_RE`,
+ `_fetch_bunny_fonts`, and `_patch_index_html` were lost in an earlier merge,
+ raising `NameError` on every invocation; they have been restored.
+- **No more spurious "update available" warning.** Site markers are compared
+ against a dedicated `SITE_FORMAT_VERSION` (with zero-padded version
+ comparison) rather than `CLI_VERSION`, so a `v0.6` site no longer reports as
+ outdated against CLI `v0.6.x`, and CLI patch releases that share the site
+ format stay quiet.
+
## 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.
diff --git a/mdcms.py b/mdcms.py
index 2023519..886c791 100644
--- a/mdcms.py
+++ b/mdcms.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# mdcms v0.6.0 — CLI companion
+# mdcms v0.6.1 — CLI companion
#
# Copyright 2026 Kristian Benestad
#
@@ -16,7 +16,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""MD-CMS v0.6.0 — CLI tool for managing and building MD-CMS sites."""
+"""MD-CMS v0.6.1 — CLI tool for managing and building MD-CMS sites."""
import json
import os
@@ -32,8 +32,12 @@ import certifi
import click
import yaml
-CLI_VERSION = "0.6.0"
-CLI_RELEASE_DATE = "7 June 2026"
+CLI_VERSION = "0.6.1"
+CLI_RELEASE_DATE = "12 June 2026"
+# Site file-format version this CLI emits/expects. Distinct from CLI_VERSION:
+# many CLI releases share the same site format. Site markers are validated
+# against this, not against the CLI version.
+SITE_FORMAT_VERSION = "0.6"
MIN_SUPPORTED_VERSION = "0.3"
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
@@ -55,6 +59,19 @@ def _parse_ver(v: str) -> tuple:
return tuple(int(x) for x in v.split("."))
+def _cmp_ver(a: str, b: str) -> int:
+ """Compare two dotted version strings, padding missing components with zeros.
+
+ Returns -1, 0, or 1. Padding means the site marker "0.6" and the CLI
+ version "0.6.0" compare equal rather than "0.6" being treated as older.
+ """
+ ta, tb = _parse_ver(a), _parse_ver(b)
+ width = max(len(ta), len(tb))
+ ta += (0,) * (width - len(ta))
+ tb += (0,) * (width - len(tb))
+ return (ta > tb) - (ta < tb)
+
+
def read_site_version(site_path: Path) -> "str | None":
config = site_path / "config.yml"
if not config.exists():
@@ -69,14 +86,11 @@ def read_site_version(site_path: Path) -> "str | None":
def version_status(site_version: str) -> "tuple[str, str]":
"""Returns (status_code, display_message). status_code: 'ok', 'outdated', 'unsupported', 'newer'."""
- sv = _parse_ver(site_version)
- min_sv = _parse_ver(MIN_SUPPORTED_VERSION)
- cur = _parse_ver(CLI_VERSION)
- if sv < min_sv:
+ if _cmp_ver(site_version, MIN_SUPPORTED_VERSION) < 0:
return "unsupported", f"v{site_version} — below minimum supported v{MIN_SUPPORTED_VERSION}"
- if sv < cur:
+ if _cmp_ver(site_version, SITE_FORMAT_VERSION) < 0:
return "outdated", f"v{site_version} — update available (CLI is v{CLI_VERSION})"
- if sv > cur:
+ if _cmp_ver(site_version, SITE_FORMAT_VERSION) > 0:
return "newer", f"v{site_version} — site newer than CLI (consider upgrading mdcms)"
return "ok", f"v{site_version}"
@@ -703,7 +717,7 @@ def generate_site_manifest(site_path: Path):
empty_dirs.append(str(rel).replace("\\", "/"))
manifest: dict = {
- "mdcms": read_site_version(site_path) or "0.4",
+ "mdcms": read_site_version(site_path) or SITE_FORMAT_VERSION,
"files": files,
}
if empty_dirs:
@@ -1033,6 +1047,123 @@ def build(name, path_override):
click.echo(click.style("Build complete.", fg="green"))
+# ─── Dependency fetching (offline mode) ───────────────────────
+
+CDN_DEPS = [
+ (
+ "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
+ "assets/required/vendors/js-yaml.min.js",
+ ),
+ (
+ "https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js",
+ "assets/required/vendors/marked.min.js",
+ ),
+ (
+ "https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js",
+ "assets/required/vendors/fuse.min.js",
+ ),
+ (
+ "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js",
+ "assets/required/vendors/highlight.min.js",
+ ),
+ (
+ "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css",
+ "assets/required/vendors/github.min.css",
+ ),
+ (
+ "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css",
+ "assets/required/vendors/github-dark.min.css",
+ ),
+]
+
+_WOFF2_URL_RE = re.compile(
+ r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""",
+ re.IGNORECASE,
+)
+
+
+def _fetch_bunny_fonts(site_path: Path, theme_file: str) -> list:
+ """Download Bunny Fonts from theme.yml to assets/fonts/. Returns list of local CSS paths."""
+ theme_path = site_path / theme_file
+ if not theme_path.exists():
+ return []
+ try:
+ theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {}
+ except (OSError, yaml.YAMLError):
+ return []
+
+ fonts_dir = site_path / "assets" / "fonts"
+ fonts_dir.mkdir(parents=True, exist_ok=True)
+
+ seen: set = set()
+ local_css_paths: list = []
+
+ for key in ("font-body", "font-heading", "font-code"):
+ spec = theme_data.get(key)
+ if not spec:
+ continue
+ parts = str(spec).split(":")
+ if len(parts) < 3 or parts[0].strip().lower() != "bunny":
+ continue
+ name = parts[1].strip()
+ weight = parts[-1].strip()
+ font_id = f"{name}:{weight}"
+ if font_id in seen:
+ continue
+ seen.add(font_id)
+
+ bunny_url = f"https://fonts.bunny.net/css?family={name.replace(' ', '+')}:{weight}"
+ click.echo(f" Fetching font: {name} {weight}")
+ try:
+ css_text = _http_get(bunny_url).decode("utf-8")
+ except Exception as e:
+ click.echo(click.style(f" Warning: could not fetch {bunny_url}: {e}", fg="yellow"))
+ continue
+
+ def _rewrite(m: "re.Match") -> str:
+ woff2_url = m.group(1)
+ filename = woff2_url.split("/")[-1].split("?")[0]
+ dest = fonts_dir / filename
+ if not dest.exists():
+ try:
+ dest.write_bytes(_http_get(woff2_url))
+ except Exception as e:
+ click.echo(click.style(f" Warning: could not fetch {woff2_url}: {e}", fg="yellow"))
+ return m.group(0)
+ return f"url('../fonts/{filename}')"
+
+ local_css = _WOFF2_URL_RE.sub(_rewrite, css_text)
+ safe_name = name.lower().replace(" ", "-")
+ css_filename = f"{safe_name}-{weight}.css"
+ (fonts_dir / css_filename).write_text(local_css, encoding="utf-8")
+ local_css_paths.append(f"assets/fonts/{css_filename}")
+ click.echo(f" Wrote assets/fonts/{css_filename}")
+
+ return local_css_paths
+
+
+def _patch_index_html(site_path: Path, local_font_css: list):
+ """Replace CDN tags with local paths and inject font link tags."""
+ index_path = site_path / "index.html"
+ if not index_path.exists():
+ raise click.ClickException("index.html not found in site directory.")
+
+ html = index_path.read_text(encoding="utf-8")
+
+ for cdn_url, local_path in CDN_DEPS:
+ html = html.replace(cdn_url, local_path)
+
+ if local_font_css:
+ links = "\n".join(
+ f''
+ for p in local_font_css
+ )
+ html = html.replace("", f"{links}\n", 1)
+
+ index_path.write_text(html, encoding="utf-8")
+ click.echo(" Patched index.html")
+
+
@cli.command("fetch-deps")
@click.argument("name", required=False, default=None)
@click.option("--path", "path_override", default=None, type=click.Path(),
diff --git a/pyproject.toml b/pyproject.toml
index f900124..09871eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mdcms"
-version = "0.6.0"
+version = "0.6.1"
description = "MD-CMS — Markdown-based CMS companion CLI"
readme = "README.md"
license = { text = "Apache-2.0" }