# Unreleased changes Changes merged into `development` that have not yet been released to `main`. --- ## 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. ### Block types | Language tag | Alias for | Renders as | |---|---|---| | `tab-underline` | — | Tab strip, active tab marked with underline | | `tab` | `tab-underline` | (same) | | `tab-filled` | — | Tab strip, tabs as filled chips | | `accordion-underline` | — | Stacked accordion, header underline style | | `accordion` | `accordion-underline` | (same) | | `accordion-filled` | — | Stacked accordion, filled card style | ### Authoring syntax Open a fenced block with the language tag `mdcms `. The body is YAML with a single top-level key `items:`, whose value is a list of item objects. ~~~markdown ```mdcms tab-underline items: - title: Install default: selected content: | Install with `npm i mdcms` or `pnpm add mdcms`. - title: Configure content: | Drop a `mdcms.config.yaml` next to your content folder. - title: Deploy content: | Any static host. The build emits plain HTML. ``` ~~~ ### Per-item keys | Key | Required | Type | Notes | |---|---|---|---| | `title` | yes | plain string | Label shown on the tab button or accordion header. Plain text only — no Markdown. | | `content` | yes | Markdown block | Body content. Use the YAML literal block scalar (`\|`) for multi-line Markdown. Rendered with the same pipeline as the surrounding page (GFM, syntax highlighting, internal links). | | `default` | no | string | **Tabs:** `selected` marks the tab that is open on load; if no item has `selected`, the first item is used. `notselected` (or omitting the key) leaves the tab inactive. Exactly one tab should be `selected`. **Accordions:** `open` makes the item expanded on load; `closed` (or omitting) leaves it collapsed. Any number of accordion items may be `open`. | | `title-style` | no | string | Heading level for screen readers and external TOC tools. One of `"#"`, `"##"`, `"###"`, `"####"`, `"#####"`, `"######"`, or `""` (default). Visual size is always fixed by the component — this only changes the underlying ARIA role and level. Use a value when you want the item to be picked up as a heading by assistive technology. | ### Examples **Tabs — underline (default)** ~~~markdown ```mdcms tab items: - title: npm default: selected content: | ```bash npm install mdcms ``` - title: pnpm content: | ```bash pnpm add mdcms ``` - title: yarn content: | ```bash yarn add mdcms ``` ``` ~~~ **Tabs — filled chips** ~~~markdown ```mdcms tab-filled items: - title: Overview default: selected content: | MD-CMS is a markdown-based static site system with no build step. - title: Features content: | - Sidebar navigation - Full-text search - PWA + offline support - Dark / light theme ``` ~~~ **Accordion — underline (default)** ~~~markdown ```mdcms accordion items: - title: What is MD-CMS? default: open content: | A single-file browser renderer. No build pipeline, no compilation, no server required. - title: How do I install it? content: | Run `pip install mdcms` or download a binary from the GitHub releases page. - title: Does it work offline? content: | Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable `pwa: yes` in `config.yml` for full offline support. ``` ~~~ **Accordion — filled cards** ~~~markdown ```mdcms accordion-filled items: - title: Can I use custom themes? default: open content: | Yes. Create a `theme.yml` and reference it with `theme: theme.yml` in `config.yml`. The theme controls colours, fonts, and layout. - title: title-style example title-style: "##" content: | This header is announced as an `

` to screen readers, even though its visual size is set by the accordion component. ``` ~~~ ### How the appearance adapts to themes The components derive their fill colours and bar/border colours from the active theme at runtime. No new keys in `config.yml` or `theme.yml` are needed. **Bold themes** (nav background is visually distinct from the page — e.g. a dark sidebar on a light page, or a coloured nav like red or navy): filled tabs and accordion headers use the nav background colour as their fill; the bar/border uses the nav colour. This makes the components look like an extension of the sidebar chrome. **Subtle themes** (nav background is almost identical to the page — e.g. both near-white or both near-dark): filled tabs use a light tint of the accent colour; the bar and border use the accent colour directly. This keeps the components visible without a strong nav background to borrow from. The switch between bold and subtle is automatic. The algorithm uses HSL chroma (`S × (1−|2L−1|)`) rather than raw HSL saturation, which would give false "bold" readings for near-white or near-black nav backgrounds. --- ## `mdcms build` patches `` with sitename `mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name. --- ## Untranslated posts now visible in all categories **Status:** On `development`, pending release. ### What was broken When the category system is enabled, a post file without a category suffix (e.g. `posts/my-post.md`) was silently assigned to the default category only. Switching to any other category caused those posts to disappear from the nav and from `posts-*` tag listings — even though no translated version existed. If you wrote posts without a language suffix, they simply vanished the moment a visitor switched category. Pages without a category suffix are unaffected: they continue to be assigned to the default category, which is the correct behaviour for pages. ### What it does now Posts without a category suffix are treated as uncategorised — meaning they appear in every category. A post called `my-post.md` now shows up regardless of which category is active. A post called `my-post.en.md` still appears only in the `en` category as before. Mixed situations work as expected: if you have both `my-post.md` and `my-post.nb.md`, the Norwegian variant is shown when the `nb` category is active, and the bare `my-post.md` is shown for every other category. ### What changes in the build output After rebuilding a site with `mdcms build`, affected post entries in `nav.yml` gain an `uncategorized: true` field: ```yaml - file: posts/my-post.md title: My Post sort: 100 uncategorized: true ``` In `search.json`, these entries carry `"category": null` instead of the default category code. This is what tells the renderer to include them universally. A rebuild is required for existing sites to pick up the change. --- ## Fix: category-variant pages fail to load on servers with SPA routing (e.g. Cloudflare Pages) When a site uses category-suffixed page files (e.g. `page.current.md`) and is hosted on a server configured with SPA fallback routing (serving `index.html` with HTTP 200 for any unknown path), the renderer's `fetchPageFile` mistook the HTML fallback for a found markdown file. It returned `index.html` content instead of falling through to try the `.current.md` variant. The page rendered the raw HTML of `index.html` as markdown, showing the `<title>` text (`sitename`) in the content area. `fetchPageFile` now checks the `Content-Type` response header and rejects any response with `text/html`, continuing to the next candidate URL instead. --- ## Fix: stale service worker not removed when `pwa: no` `index.html` unconditionally registers `service-worker.js` on every page load. When a site switched from `pwa: yes` to `pwa: no`, `mdcms build` stopped generating a new service worker, but the old one remained active in browsers that had visited the site before. The stale worker continued to serve cached responses from the old build. `mdcms build` now writes a self-unregistering `service-worker.js` when `pwa: no`. On the visitor's next page load, the browser installs this stub worker, which immediately unregisters itself and evicts any previously cached content. `manifest.json` is also removed if present. --- ## Manifest-driven template download (`mdcms.py`, `app/template-manifest.json`) `mdcms register` no longer uses the GitHub Contents API to discover and download the starter template. Instead it fetches `app/template-manifest.json` — a single JSON file that lists every file and empty directory in the template — then downloads each file directly as a raw URL. ### Why this matters The old approach walked the GitHub tree API recursively (one authenticated API call per directory). This hit rate limits, required GitHub-specific logic, and made it impossible to host the template anywhere other than the GitHub API endpoint. The new approach fetches one manifest then one raw file per entry. Raw downloads bypass API rate limits entirely and work from any HTTP source: a CDN, a self-hosted mirror, or a local server. `download_template()` accepts an optional `base_url` argument for this purpose. ### `app/template-manifest.json` format ```json { "mdcms": "0.4", "files": ["index.html", "config.yml", "assets/icons/add.svg", ...], "dirs": ["assets/fonts", "posts"] } ``` `files` — paths relative to the app root that are fetched and written verbatim. `dirs` — empty directories to create (no file is needed to keep them). Generated files (`manifest.json`, `service-worker.js`, `search.json`) are intentionally absent; they are produced by `mdcms build` and should not be pre-populated in a fresh site. ### `_http_get` replaces `_github_get` The old `_github_get` sent GitHub API headers (`Accept: application/vnd.github.v3+json`) and returned raw bytes. It is replaced by a generic `_http_get(url)` that works with any HTTP source. This function is also referenced by `fetch-deps`. --- ## Clean URLs for section-id pages (`app/index.html`, `app/404.html`) Pages whose filename matches a nav section-id can now be accessed at a clean URL path (e.g. `example.com/timesheet`) instead of the hash-based URL (`example.com/#pages/timesheet.md`). ### How it works When you navigate to a page whose base filename (`timesheet`) matches a `code` entry in the `sections:` block of `nav.yml`, the renderer uses `history.replaceState` to rewrite the URL from `/#pages/timesheet.md` to `/timesheet`. All other pages continue to use hash-based URLs unchanged. On startup, if the URL pathname already contains a section-id slug (because the user typed or was linked to `example.com/timesheet` directly), the renderer detects it, sets the correct base path, and loads the matching page. Subpath deployments (e.g. `example.com/mysite/`) are handled automatically: the renderer determines the base from the initial pathname. ### 404.html for GitHub Pages A new `app/404.html` file enables direct clean-URL access on GitHub Pages. When GitHub Pages serves the 404 page for an unknown path (e.g. `/timesheet`), `404.html` encodes the path as `?_route=/timesheet` and redirects to the app root. `index.html` reads `_route`, cleans up the URL, and routes to the right page. For other static hosts (Netlify, Cloudflare Pages, etc.) a `/*` → `/index.html` rewrite rule in the host's config achieves the same result. ### Condition Only pages files that are both: 1. located in `pages/` with a name matching a section `code` in `nav.yml`, and 2. present in the `pages:` list in `nav.yml` …get a clean URL. All other pages continue to use `#` routing. --- ## Fix: `config.yml` YAML parse errors now abort the build with a clear message A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar. `read_config` now raises `ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing silently with an empty config.