From 2412608c7c3de5024a0c427c16694eaef4fa36d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:55:04 +0000 Subject: [PATCH 1/5] v0.4 Phase 5: TOC tag + bump to 0.3.7 - Add renderTocTag(): groups visible pages by section in section sort order; sorts pages within each section by sort then filename; excludes current page, draft sections, and category-invisible pages; renders section headings and linked page lists; replaces the tag placeholder - Add toc dispatch in hydrateMdcmsTags() - Add .mdcms-toc, .mdcms-toc-section, .mdcms-toc-list CSS https://claude.ai/code/session_015XtsgTMi8UtmgxEgb5Qt2c --- app/index.html | 117 +++++++++++++++++++++++++++++++++++----------- app/pages/home.md | 99 +++++---------------------------------- mdcms.py | 6 +-- pyproject.toml | 2 +- test_phase.py | 5 +- 5 files changed, 110 insertions(+), 119 deletions(-) diff --git a/app/index.html b/app/index.html index 1cb851f..9c1b403 100644 --- a/app/index.html +++ b/app/index.html @@ -785,6 +785,39 @@ body { .main-content { padding: 1rem 1rem 3rem; } } +/* ═══════════════════════════════════════════ + TAG SYSTEM: CALLOUTS + ═══════════════════════════════════════════ */ +.mdcms-callout { + border-left: 4px solid; + border-radius: 0 6px 6px 0; + padding: 0.85rem 1rem 0.85rem 1rem; + margin: 1.25rem 0; +} +.mdcms-callout-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 700; + font-size: 0.95rem; + margin-bottom: 0.45rem; +} +.mdcms-callout-title .mdcms-icon { font-size: 1.1em; } +.mdcms-callout-body { font-size: 0.95rem; } +.mdcms-callout-body > *:first-child { margin-top: 0; } +.mdcms-callout-body > *:last-child { margin-bottom: 0; } + +/* ═══════════════════════════════════════════ + TAG SYSTEM: TABLE OF CONTENTS + ═══════════════════════════════════════════ */ +.mdcms-toc { margin: 1rem 0; } +.mdcms-toc-section { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 0.4rem; color: var(--font-colour-muted); border-bottom: 1px solid var(--divider); padding-bottom: 0.25rem; } +.mdcms-toc-list { list-style: none; padding: 0; margin: 0 0 0.5rem; } +.mdcms-toc-list li { padding: 0.2rem 0; border-bottom: 1px solid var(--divider); } +.mdcms-toc-list li:last-child { border-bottom: none; } +.mdcms-toc-list a { color: var(--accent); text-decoration: none; font-size: 0.95rem; } +.mdcms-toc-list a:hover { text-decoration: underline; } + /* ═══════════════════════════════════════════ TAG SYSTEM: POST LISTINGS ═══════════════════════════════════════════ */ @@ -862,32 +895,6 @@ 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; } @@ -1889,12 +1896,70 @@ function fmtDatetime(dtStr) { return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; } + function renderTocTag(container) { + const byCode = {}; + navSections.forEach(s => { byCode[s.code] = s; }); + + const sortedSections = navSections + .filter(s => !isDraftSection(s.code, byCode)) + .sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || (a.code || '').localeCompare(b.code || '')); + + const visiblePages = navData.filter(p => { + if (p.file === currentPage) return false; + if (!pageShouldDisplay(p)) return false; + const sid = p['section-id']; + if (sid && isDraftSection(sid, byCode)) return false; + return true; + }); + + const bySection = {}; + const unsectioned = []; + visiblePages.forEach(p => { + const sid = p['section-id'] || null; + if (sid) { (bySection[sid] = bySection[sid] || []).push(p); } + else unsectioned.push(p); + }); + + function sortPages(pages) { + return [...pages].sort((a, b) => + ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file)); + } + + function makeList(pages) { + const ul = document.createElement('ul'); + ul.className = 'mdcms-toc-list'; + pages.forEach(p => { + const a = el('a', { href: '#' + p.file, textContent: pageDisplayTitle(p) }); + a.addEventListener('click', e => { e.preventDefault(); navigateTo(p.file); }); + ul.appendChild(el('li', {}, a)); + }); + return ul; + } + + const div = el('div', { className: 'mdcms-toc' }); + + if (unsectioned.length) div.appendChild(makeList(sortPages(unsectioned))); + + sortedSections.forEach(section => { + const pages = bySection[section.code]; + if (!pages || !pages.length) return; + div.appendChild(el('h3', { className: 'mdcms-toc-section', textContent: sectionDisplayName(section) })); + div.appendChild(makeList(sortPages(pages))); + }); + + if (!div.children.length) div.textContent = 'No pages found.'; + + container.replaceWith(div); + } + 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 { renderPostTag(tagEl, cfg); } diff --git a/app/pages/home.md b/app/pages/home.md index 3ed6b63..1fe5fbc 100644 --- a/app/pages/home.md +++ b/app/pages/home.md @@ -3,101 +3,24 @@ title: Home sort: 100 --- -# Phase 4 — Callout Tags +# Phase 5 — Table of Contents Tag -Check each callout below. Each should show a coloured left border, an icon, a bold title in the accent colour, and a rendered body. +The `toc` tag renders a section-grouped list of all pages visible for the active category. The TOC page itself is excluded. --- -## Basic types +## Basic TOC -```mdcms callout-info -title: Information -This is an **info** callout. Supports *italic*, `code`, and lists: - -- Item one -- Item two -``` - -```mdcms callout-warning -title: Warning -Something needs your attention. This is a **warning** callout. -``` - -```mdcms callout-success -title: Success -The operation completed successfully. This is a **success** callout. -``` - -```mdcms callout-error -title: Error -Something went wrong. This is an **error** callout. +```mdcms +toc ``` --- -## No title +## What to verify -```mdcms callout-info -No title key here. The title row should not appear at all — just the body. -``` - ---- - -## 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. +- All sections appear as headings in sort order +- Pages within each section appear in sort order +- This page (Home) does **not** appear in the list +- Draft pages are excluded +- Switching category (if enabled) updates the page list diff --git a/mdcms.py b/mdcms.py index 473c2f6..593ed5e 100644 --- a/mdcms.py +++ b/mdcms.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # -# mdcms v0.3.6 — CLI companion +# mdcms v0.3.7 — CLI companion # # Copyright 2026 Kristian Benestad # Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0 -"""MD-CMS v0.3.5 — CLI tool for managing and building MD-CMS sites.""" +"""MD-CMS v0.3.7 — CLI tool for managing and building MD-CMS sites.""" import json import os @@ -21,7 +21,7 @@ import certifi import click import yaml -CLI_VERSION = "0.3.6" +CLI_VERSION = "0.3.7" CLI_RELEASE_DATE = "17 May 2026" MIN_SUPPORTED_VERSION = "0.3" diff --git a/pyproject.toml b/pyproject.toml index 74807ec..9124c4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdcms" -version = "0.3.6" +version = "0.3.7" 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 index 9104574..61dbfac 100644 --- a/test_phase.py +++ b/test_phase.py @@ -28,7 +28,7 @@ PHASES = { 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"), + 5: ("claude/toc-tag-phase5", "Table of contents tag"), 6: ("v0.4_phase6", "Offline / fetch-deps"), 7: ("v0.4_phase7", "PWA — service worker and manifest"), } @@ -108,6 +108,9 @@ EXTRA_FILES = { "app/config.yml", # has callouts: block for message: key test "app/pages/home.md", # has Phase 4 callout test cases ], + 5: [ + "app/pages/home.md", # has Phase 5 TOC test case + ], } From 9b7639cc62ed654d820c55d5b83743d52080cc19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:57:58 +0000 Subject: [PATCH 2/5] v0.4 Phase 6: fetch-deps command for offline/local dependency mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _http_get() general HTTP helper (SSL via certifi, 30s timeout) - Add CDN_DEPS table: 6 jsDelivr assets (js-yaml, marked, fuse.js, highlight.js, 2x highlight CSS) - Add _fetch_bunny_fonts(): reads theme.yml font-body/heading/code keys, fetches CSS from fonts.bunny.net, downloads woff2 files to assets/fonts/, rewrites CSS to use relative local paths, writes per-font CSS file - Add _patch_index_html(): replaces CDN URLs with local vendor paths, injects tags for locally downloaded fonts - Add fetch-deps CLI command: downloads vendors, fetches fonts if theme.yml present, patches index.html — site makes no external network requests - index.html loadFonts(): skip if data-mdcms-fonts link already present (set by patched index.html after fetch-deps) - Update CLAUDE.md CLI command table with fetch-deps entries https://claude.ai/code/session_015XtsgTMi8UtmgxEgb5Qt2c --- CLAUDE.md | 2 + app/index.html | 1 + mdcms.py | 157 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1ba4a59..68bccfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ During development, run directly: `python3 mdcms.py ` | `mdcms build ` | Build `nav.yml` and `search.json` for a registered site. | | `mdcms build --path ` | Build using an explicit path — no registry needed. Intended for CI/CD. | | `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. | +| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. | +| `mdcms fetch-deps --path ` | Same, using an explicit path. | **Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS. diff --git a/app/index.html b/app/index.html index 9c1b403..8037ea1 100644 --- a/app/index.html +++ b/app/index.html @@ -1321,6 +1321,7 @@ body { // ─── Fonts ──────────────────────────────────────────────── function loadFonts(tc) { + if (document.querySelector('link[data-mdcms-fonts]')) return; function parseFont(spec) { if (!spec) return null; const parts = spec.split(':'); diff --git a/mdcms.py b/mdcms.py index 593ed5e..c24d101 100644 --- a/mdcms.py +++ b/mdcms.py @@ -492,6 +492,130 @@ def run_build(site_path: Path): )) +# ─── Dependency fetching ────────────────────────────────────── + +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 _http_get(url: str, timeout: int = 30) -> bytes: + ssl_ctx = ssl.create_default_context(cafile=certifi.where()) + req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"}) + with urllib.request.urlopen(req, context=ssl_ctx, timeout=timeout) as resp: + return resp.read() + + +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") + + # ─── GitHub template download ───────────────────────────────── def _github_get(url: str) -> bytes: @@ -732,6 +856,39 @@ def build(name, path_override): click.echo(click.style("Build complete.", fg="green")) +@cli.command("fetch-deps") +@click.argument("name", required=False, default=None) +@click.option("--path", "path_override", default=None, type=click.Path(), + help="Explicit site path (no registry lookup).") +def fetch_deps(name, path_override): + """Download external JS/CSS dependencies and patch index.html for offline use.""" + site_path = resolve_site_path(name, path_override) + if not (site_path / "index.html").exists(): + raise click.ClickException(f"No index.html found at {site_path}") + + click.echo(f"Fetching dependencies for {site_path} ...") + + vendors_dir = site_path / "assets" / "required" / "vendors" + vendors_dir.mkdir(parents=True, exist_ok=True) + + for cdn_url, rel_dest in CDN_DEPS: + dest = site_path / rel_dest + click.echo(f" {rel_dest}") + try: + dest.write_bytes(_http_get(cdn_url)) + except Exception as e: + raise click.ClickException(f"Failed to download {cdn_url}: {e}") + + cfg = read_config(site_path) + local_font_css: list = [] + if cfg.get("theme"): + local_font_css = _fetch_bunny_fonts(site_path, cfg["theme"]) + + _patch_index_html(site_path, local_font_css) + + click.echo(click.style("Done. Site is ready for offline use.", fg="green")) + + # ─── Entry point ───────────────────────────────────────────── def main(): From 60694c9ecf03c000a008f146c43dc7614f6ce850 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 18:54:56 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Update=20test=5Fphase.py:=20phase=205=20?= =?UTF-8?q?=E2=86=92=20claude/toc-tag-phase5,=20phase=206=20=E2=86=92=20cl?= =?UTF-8?q?aude/fetch-deps-phase6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_01UP8Wo2CKPNhvvTkzX48CWF --- test_phase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_phase.py b/test_phase.py index 61dbfac..9c56567 100644 --- a/test_phase.py +++ b/test_phase.py @@ -29,7 +29,7 @@ PHASES = { 3: ("v0.4_phase3", "Asset validation in mdcms build"), 4: ("claude/debug-api-errors-gd730", "Callout tags"), 5: ("claude/toc-tag-phase5", "Table of contents tag"), - 6: ("v0.4_phase6", "Offline / fetch-deps"), + 6: ("claude/fetch-deps-phase6", "Offline / fetch-deps"), 7: ("v0.4_phase7", "PWA — service worker and manifest"), } From 81d0f2ffd0963a556e781bd0b8bf25378d859a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 19:06:12 +0000 Subject: [PATCH 4/5] Bump version to 0.3.8 https://claude.ai/code/session_01UP8Wo2CKPNhvvTkzX48CWF --- mdcms.py | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mdcms.py b/mdcms.py index c24d101..bfb26c5 100644 --- a/mdcms.py +++ b/mdcms.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # -# mdcms v0.3.7 — CLI companion +# mdcms v0.3.8 — CLI companion # # Copyright 2026 Kristian Benestad # Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0 -"""MD-CMS v0.3.7 — CLI tool for managing and building MD-CMS sites.""" +"""MD-CMS v0.3.8 — CLI tool for managing and building MD-CMS sites.""" import json import os @@ -21,7 +21,7 @@ import certifi import click import yaml -CLI_VERSION = "0.3.7" +CLI_VERSION = "0.3.8" CLI_RELEASE_DATE = "17 May 2026" MIN_SUPPORTED_VERSION = "0.3" diff --git a/pyproject.toml b/pyproject.toml index 9124c4c..65aeb49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdcms" -version = "0.3.7" +version = "0.3.8" description = "MD-CMS — Markdown-based CMS companion CLI" readme = "README.md" license = { text = "Apache-2.0" } From cd1668dd040b0cd236ed0fd834cd1af24c78044d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 19:22:56 +0000 Subject: [PATCH 5/5] Fix callout border-color and background CSS variables https://claude.ai/code/session_01UP8Wo2CKPNhvvTkzX48CWF --- app/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/index.html b/app/index.html index 8037ea1..1466873 100644 --- a/app/index.html +++ b/app/index.html @@ -789,7 +789,8 @@ body { TAG SYSTEM: CALLOUTS ═══════════════════════════════════════════ */ .mdcms-callout { - border-left: 4px solid; + border-left: 4px solid var(--callout-primary, var(--accent)); + background: var(--callout-bg, transparent); border-radius: 0 6px 6px 0; padding: 0.85rem 1rem 0.85rem 1rem; margin: 1.25rem 0;