Compare commits

...

No commits in common. "main" and "v0.2.2" have entirely different histories.
main ... v0.2.2

519 changed files with 2290 additions and 59465 deletions

View file

@ -1,13 +0,0 @@
on: [push]
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: yesolutions/mirror-action@master
with:
REMOTE: 'https://codeberg.org/kbenestad/mdcms.git'
GIT_USERNAME: kbenestad
GIT_PASSWORD: ${{ secrets.GIT_PASSWORD_CODEBERG }}

View file

@ -1,133 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
name: Build — ${{ matrix.label }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
label: Linux amd64
binary_name: mdcms
artifact_name: mdcms-linux-amd64
make_deb: true
- os: macos-latest
label: macOS arm64
binary_name: mdcms
artifact_name: mdcms-macos-arm64
make_deb: false
- os: ubuntu-24.04-arm
label: Linux arm64
binary_name: mdcms
artifact_name: mdcms-linux-arm64
make_deb: true
- os: windows-latest
label: Windows amd64
binary_name: mdcms.exe
artifact_name: mdcms-windows-amd64
make_deb: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install pyinstaller click pyyaml certifi
- name: Build binary
run: pyinstaller --onefile --name mdcms mdcms.py
- name: Rename binary (non-Windows)
if: matrix.os != 'windows-latest'
run: mv dist/mdcms dist/${{ matrix.artifact_name }}
- name: Rename binary (Windows)
if: matrix.os == 'windows-latest'
run: mv dist/mdcms.exe dist/${{ matrix.artifact_name }}.exe
- name: Build .deb (Linux only)
if: matrix.make_deb
run: |
VERSION=${GITHUB_REF_NAME#v}
sudo gem install fpm --no-document
fpm \
-s dir -t deb \
-n mdcms \
-v "$VERSION" \
--description "MD-CMS companion CLI — manage and build MD-CMS sites" \
--url "https://github.com/kbenestad/mdcms" \
--maintainer "Kristian Benestad" \
--license "Apache-2.0" \
--architecture "${{ matrix.os == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" \
--category utils \
dist/${{ matrix.artifact_name }}=/usr/local/bin/mdcms
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
dist/${{ matrix.artifact_name }}
dist/${{ matrix.artifact_name }}.exe
if-no-files-found: ignore
- name: Upload .deb artifact
if: matrix.make_deb
uses: actions/upload-artifact@v4
with:
name: deb-package-${{ matrix.artifact_name }}
path: "*.deb"
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: false
- name: List artifacts
run: find artifacts -type f
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PRERELEASE=""
if [[ "${{ github.ref_name }}" == *"-"* ]]; then
PRERELEASE="--prerelease"
fi
gh release delete "${{ github.ref_name }}" --repo "${{ github.repository }}" --yes 2>/dev/null || true
gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "mdcms ${{ github.ref_name }}" \
--generate-notes \
$PRERELEASE \
artifacts/mdcms-linux-amd64/mdcms-linux-amd64 \
artifacts/mdcms-linux-arm64/mdcms-linux-arm64 \
artifacts/mdcms-macos-arm64/mdcms-macos-arm64 \
artifacts/mdcms-windows-amd64/mdcms-windows-amd64.exe \
artifacts/deb-package-mdcms-linux-amd64/*.deb \
artifacts/deb-package-mdcms-linux-arm64/*.deb

10
.gitignore vendored
View file

@ -1,13 +1,3 @@
### Python ###
__pycache__/
*.py[cod]
*.pyo
dist/
build/
*.egg-info/
.venv/
venv/
### AL ###
#Template for AL projects for Dynamics 365 Business Central
#launch.json folder

286
CLAUDE.md
View file

@ -1,286 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Versioning rule
Every merge into `main` is a release. Before committing any change to `mdcms.py`, ask: "Is this intended to be merged to main immediately?" If yes, bump `CLI_VERSION` and `CLI_RELEASE_DATE` in `mdcms.py` and `version` in `pyproject.toml` before committing. If the work is exploratory or not yet ready to merge, leave the version unchanged and ask again when the merge is imminent.
## Branching convention
Only two branches exist in this repository: **`main`** and **`development`**. No other branches should be created or left alive.
- **`main`** is the release branch. Every merge to `main` is a release. Never commit work-in-progress directly to `main`.
- **`development`** is the default branch for all development, including all Claude-driven work. Always commit to `development` — never create a new branch per conversation or feature.
- **Documentation only** (`CLAUDE.md`, `docs/`) — may be pushed directly to `main`.
- **If a non-canonical branch is created** (e.g. for a large staged feature), it must be deleted immediately after it is merged. The repo returns to `main` + `development` only.
In practice: check out `development`, do the work, push to `development`, PR `development``main` when ready to release.
**When a branch isn't visible locally:** always run `git fetch origin <branch-name>` before concluding a branch doesn't exist. Never create a new branch if the user names one — fetch it from the remote first.
## Unreleased changelog
`docs/unreleased.md` is a living document that tracks every fix or feature on `development` that has not yet been merged to `main`. Keep it current: whenever a change lands on `development`, add or update an entry in `unreleased.md` in the same commit (or a follow-up commit to `development`). When a batch of changes is merged to `main` and released, clear the entries that were released and leave the file in place for the next round of work.
## What this project is
MD-CMS is a markdown-based static site system with two distinct parts:
1. **`mdcms.py`** — a Python 3 CLI tool (`click` + `PyYAML` + `certifi`). Manages a registry of sites, scans content, generates `nav.yml` and `search.json`, and is designed for both local use and GitHub Actions pipelines.
2. **`app/index.html`** — a single-file browser renderer that reads markdown, config, and nav at runtime entirely client-side. There is no build pipeline, no compilation, no server.
The `app/` folder is the deployable artifact and the starter template downloaded when registering a new site. `mdcms.py` lives outside it.
## Repository layout
```
mdcms.py ← CLI tool
pyproject.toml ← packaging (entry point, dependencies)
app/
index.html ← renderer + v0.4 version marker
config.yml ← starter config + v0.4 version marker
nav.yml ← generated
search.json ← generated
pages/
posts/
assets/
docs/
banner/
documentation.md
knownlimitations.md
quickstart.md
install.md
release.md
.github/workflows/release.yml ← cross-platform release builds
samplesite/ ← reference implementation (not deployed)
```
## CLI commands
Install: `pip install mdcms` / `pipx install mdcms` — or use the standalone binary from a GitHub release.
During development, run directly: `python3 mdcms.py <command>`
| Command | Description |
|---|---|
| `mdcms register <name> [path]` | Register a site. Downloads starter template from GitHub if no mdcms site is found at the path. Defaults to current directory. |
| `mdcms delete <name>` | Remove a site from the registry. Does not delete files. Prompts for confirmation. |
| `mdcms view` | List all registered sites with version and status. |
| `mdcms view <name>` | Show details: path, version, sitename, pages/posts count, sections, categories. |
| `mdcms build <name>` | Build `nav.yml` and `search.json` for a registered site. |
| `mdcms build --path <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 <path>` | Same, using an explicit path. |
## PWA config keys
Set in `config.yml`. `mdcms build` generates `manifest.json` and `service-worker.js` when `pwa: yes`.
```yaml
pwa: yes
pwa-name: "My Documentation" # mandatory if pwa: yes
pwa-shortname: "MyDocs" # optional short name for home screen
pwa-colour: "#2563EB" # optional browser chrome colour
offline-message:
en: "You are offline and some content is unavailable."
nb: "Du er frakoblet og noe innhold er utilgjengelig."
```
**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.
## Architecture of `mdcms.py`
Single-module Python script. Logical layers in order:
1. **Version helpers**`read_site_version()` reads the `mdcms v0.3` marker from the first line of `config.yml`. `version_status()` classifies sites as `ok`, `outdated`, `newer`, or `unsupported` against `MIN_SUPPORTED_VERSION`.
2. **Registry**`~/.config/mdcms/sites.json` stores `{name: {path, version}}`. `load_registry()` / `save_registry()` / `resolve_site_path()`.
3. **Config reading**`read_config()` reads `config.yml` with `yaml.safe_load()`. `get_category_info()` extracts category settings from the parsed dict.
4. **Frontmatter parser** (`parse_frontmatter`) — reads `---` YAML blocks using `yaml.safe_load()`. Returns `(meta_dict, body_text)`.
5. **Category system**`identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list.
6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`.
7. **Nav/search generators**`generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild.
8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json → patch `<title>` in `index.html` with `sitename` → generate PWA files if enabled. The `<title>` patch ensures crawlers and link-preview scrapers (WhatsApp, Slack, etc.) see the correct site name in the static HTML before any JavaScript runs.
9. **Template download** (`download_template`) — fetches `app/` from GitHub via the Contents API using `urllib` + `certifi` for SSL. Recursively downloads files and directories.
10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()``cli()`.
## Version markers
Every mdcms site has a version marker on the first line of two files:
- `config.yml` line 1: `# mdcms v0.4 | DO NOT REMOVE THIS COMMENT`
- `index.html` line 1: `<!-- mdcms v0.4 | DO NOT REMOVE THIS COMMENT -->`
- `theme.yml` line 1: `# mdcms v0.4 | DO NOT REMOVE THIS COMMENT`
`register` and `build` both read the marker from `config.yml` to detect and validate the site. Sites with no marker are not recognised as mdcms sites. Sites below `MIN_SUPPORTED_VERSION` are rejected.
There are two distinct version numbers:
- **CLI version** (`CLI_VERSION` in `mdcms.py`, `version` in `pyproject.toml`) — bumped with every release.
- **Site format version** (markers in `config.yml` and `index.html`) — only bumped when the site file format has a breaking change. Many CLI releases may share the same site format version.
## Site structure
The registered path points directly to the directory containing `index.html` (the site root). There is no `website/` subdirectory.
```
<site-root>/
index.html ← renderer
config.yml ← required: sitename, navigation; rest optional
nav.yml ← generated; manual edits to section metadata are preserved
search.json ← generated
pages/
home.md ← default landing page
about.md
about.nb.md ← Norwegian variant (category suffix = nb)
posts/
2025-01-01-my-first-post.md
assets/
fonts/
images/
```
## Page frontmatter fields
All optional except `title`:
```yaml
---
title: Page Title
sort: 100 # controls nav ordering (lower = higher)
section-id: blog # assigns page to a nav section
draft: true # exclude from nav and search
author: Name
created: 2025-01-01 13:00
modified: 2025-01-15 09:00
keywords: foo, bar
description: Short description for search
language: en
---
```
## nav.yml structure
Sections and pages are separate lists. `mdcms.py` preserves manual edits to section fields (`defaultname`, `sort`, `parent`, `parent-sort`, `pagesvisibility`, `categorynames`) on each rebuild. New sections are auto-created from `section-id` values found in frontmatter.
`pagesvisibility` can be `visible`, `hidden`, or `draft` (draft excludes pages from `search.json`).
For nested navigation, set `parent: <parent-section-code>` and `parent-sort` on a section.
## Category system
- `categories-use: yes` in `config.yml` enables categories
- `default-category.code` is required when categories are enabled
- Variant files: `<base>.<code>.md` — the suffix is only treated as a category if the code is declared in config
- `categories-sectionnames: per-category` requires each section in `nav.yml` to have a `categorynames` block with an entry per category code
- RTL is set per category via `direction: rtl`
- Line height is set per category via `line-height: 2.8` (useful for scripts like Nastaliq that need extra vertical space). Restores to theme default when switching to a category without this key.
## Dynamic post tags (mdcms code blocks)
Embed post lists in pages using fenced blocks:
````markdown
```mdcms
posts-created-reversechronological
limit: 10
paginate: yes
```
````
Reliable tags (others are known-broken): `posts-created-chronological-byyearmonth`, `posts-created-reversechronological`. Use `created` frontmatter (format: `YYYY-MM-DD HH:MM`) for posts.
## Release workflow
`.github/workflows/release.yml` triggers on version tags (`v*`). Uses a matrix of three runners:
| Runner | Output |
|---|---|
| `ubuntu-latest` | `mdcms-linux-amd64` binary + `mdcms_<version>_amd64.deb` (via PyInstaller + fpm) |
| `macos-latest` | `mdcms-macos-arm64` binary |
| `windows-latest` | `mdcms-windows-amd64.exe` |
All artifacts are attached to the GitHub release using `gh release create`. The workflow sets `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true` to opt into the Node.js 24 runner ahead of the June 2026 forced migration.
**Release checklist** — before tagging:
1. Update `CLI_VERSION` in `mdcms.py`
2. Update `version` in `pyproject.toml`
3. Update site format markers in `app/config.yml` and `app/index.html` only if the site format changed
Then: `git tag v0.4.1 && git push origin v0.4.1`
**Note:** Git tag pushes must be done from a local machine — the cloud environment cannot push tags (HTTP 403). Use `gh release create <tag>` locally after pushing the tag.
## Known limitations
- Most `posts-*` tag variants are broken. Only `posts-datetime-chronological-byyearmonth` and `posts-datetime-reversechronological` reliably work.
- Section headings in the nav are non-clickable (sections-sitemap is not yet implemented).
- **`navigation: topbar` is broken.** Always use `navigation: sidebar` in `config.yml` for any test sites or starter templates.
## v0.4 renderer features (index.html)
Features added in v0.4, all rendered client-side in `app/index.html`:
### Callout tags
Fenced `mdcms` blocks with `callout-info`, `callout-warning`, `callout-success`, `callout-error`. Each has a coloured left border, low-opacity tinted background, optional icon + title row, and full markdown body. The JS sets `--callout-primary` and `--callout-bg` CSS variables on the container; the CSS must reference these (not hardcoded colours). Config-defined messages: `message: <key>` resolves title and body from the `callouts:` block in `config.yml`.
### Table of contents tag
Fenced `mdcms` block with `toc`. Renders a section-grouped list of all visible, non-draft pages in the active category, excluding the TOC page itself. Groups by nav section.
### Theme system (`theme.yml`)
Presentational config separate from `config.yml`. Controls accent colour, dark/light mode palette, fonts, and layout. `index.html` loads it at runtime.
**Colour keys per mode** (`light:` and `dark:` blocks):
| Key | CSS variable | Default |
|---|---|---|
| `accent` | `--accent` | `#2563EB` / `#60A5FA` |
| `background` | `--bg-main` | `#FFFFFF` / `#0F172A` |
| `nav-background` | `--bg-nav` | `#F8FAFC` / `#1E293B` |
| `text` | `--font-colour` | `#1E293B` / `#F1F5F9` |
| `text-muted` | `--font-colour-muted` | `#64748B` / `#94A3B8` |
| `nav-link` | `--nav-link-colour` | falls back to `text` |
| `nav-link-active` | `--nav-link-active-colour` | falls back to `accent` |
| `nav-section-heading` | `--nav-section-heading-colour` | falls back to `text-muted` |
| `nav-sitename` | `--nav-sitename-colour` | falls back to `nav-link` |
| `nav-description` | `--nav-description-colour` | falls back to `nav-section-heading` |
| `nav-toggle` | `--nav-toggle-colour` | falls back to `nav-section-heading` |
| `divider` | `--divider` | `color-mix(in srgb, background 85%, text)` |
**When to use nav-link keys:** When `nav-background` matches or is very close to `accent`, the default behaviour (active link coloured with `accent`) makes links invisible. Set `nav-link`, `nav-link-active`, and `nav-section-heading` explicitly so all three are legible against `nav-background`. Example: a red nav background needs white (`#FFFFFF`) for all three nav colour keys.
**Semantic colours:**
- `colours-semantic` — applies to both light and dark modes. Use for colours that read on both backgrounds, or when you don't need per-mode control.
- `colours-semantic-dark` — overrides semantic colours in dark mode only. Use lighter/more saturated variants here so callout borders and tinted backgrounds remain legible on dark page backgrounds.
Keys in both blocks: `info`, `warning`, `success`, `error`.
**Nav section toggle icons** (top-level keys, not inside `light:`/`dark:`):
| Key | Default | Purpose |
|---|---|---|
| `nav-section-expand-icon` | `arrow_right` | icon shown when section is collapsed |
| `nav-section-collapse-icon` | `arrow_drop_down` | icon shown when section is expanded |
Available icon names: `arrow_right`, `arrow_drop_down`, `keyboard_arrow_right`, `keyboard_arrow_down`, `keyboard_double_arrow_right`, `keyboard_double_arrow_down`, `expand_content`, `collapse_content`, `add`, `minimize`.
These only apply to nav sections with `pagesvisibility: hidden` (collapsible sections).
### Icon system
All UI icons served as local SVGs from `app/assets/icons/`. No Google Fonts or external icon font. Icon names are normalised (lowercase, spaces → hyphens).
### PWA
`manifest.json` and `service-worker.js` generated by `mdcms build` when `pwa: yes`. Cache-first SW precaches all pages, posts, and assets. Offline message from `config.yml` (`offline-message` key) stored in `localStorage` and shown when a page can't be fetched. Requires a `favicon.png` in `assets/images/` for the install icon (192×192 recommended).
### `fetch-deps` offline mode
`mdcms fetch-deps` downloads all CDN JS/CSS to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`, then patches `index.html` CDN URLs to local paths. After this, the site makes no external network requests.
## Key implementation details
- `generate_nav_yml()` emits a fixed-format YAML subset. It is **not** a general YAML emitter — do not assume it handles arbitrary structures.
- `yaml.safe_load()` is used for all YAML reading (config.yml, nav.yml, frontmatter). The nav.yml parser depends on PyYAML, not a hand-rolled parser.
- Category code validation uses `CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")` — codes must match this.
- `scan_and_categorize()` takes both `directory` and `site_root` — paths in records are always relative to `site_root`.
- The `samplesite/` directory is a reference implementation with multi-language categories (English, Norwegian, Arabic including RTL). It is not deployed; it exists for reference and testing.
- Template download uses `urllib` (stdlib) with `certifi` for SSL certificate verification — required for PyInstaller binaries on Linux/macOS where the bundled Python cannot find system CA certificates.

View file

@ -1,50 +0,0 @@
# Contributing to mdcms
Thanks for your interest in contributing. This is a small, focused project — contributions that fit the existing scope and style are welcome.
## What to work on
Check the [open issues](https://github.com/kbenestad/mdcms/issues) for bugs and planned work. If you want to add something new, open an issue first to discuss it before writing code — this avoids wasted effort if the feature doesn't fit the project direction.
## Getting started
```bash
git clone https://github.com/kbenestad/mdcms.git
cd mdcms
pip install -e ".[dev]" # or: pip install click PyYAML certifi
```
Run the CLI directly during development:
```bash
python3 mdcms.py --help
```
For local site preview, run `python3 -m http.server 8800` inside a site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access.
## Making changes
- **CLI changes** (`mdcms.py`, `pyproject.toml`, `app/`, `.github/`) — branch from `main`, PR back to `main`.
- **Docs only** (`docs/`) — can go directly to `main`.
- Keep commits focused. One logical change per commit.
- Match the existing code style — single-file script, stdlib where possible, no unnecessary dependencies.
## Submitting a pull request
1. Fork the repo and create a branch from `main`.
2. Make your changes.
3. Test manually against a real site directory.
4. Open a PR with a clear description of what changed and why.
There are no automated tests yet, so describe what you tested in the PR description.
## Reporting bugs
Open a GitHub issue with:
- mdcms version (`mdcms --version`)
- OS and Python version
- The command you ran and the full output
## License
By contributing, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE).

201
LICENSE
View file

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Kristian Benestad
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

175
README.md
View file

@ -1,106 +1,123 @@
# MD-CMS
Write your content as `.md` files, run one command, and deploy to any static host. All rendering happens in the browser at runtime.
> Markdown-based static site publishing — no server, no database, no terminal required.
With the GitHub Actions workflow, you can automatically rebuild the navigation structure and search index on each commit.
MD-CMS lets you write and publish a website entirely in markdown. Drop your `.md` files in a folder, run the build tool, upload the output to any static host, and you're done. All rendering happens in the browser.
---
- - -
## How it works
MD-CMS has two parts:
The web app:
**`index.html`** — a single-file browser renderer. It reads your markdown files, config, and navigation at runtime and renders everything client-side. No build pipeline, no framework, no compilation step.
- `index.html` — a single-file browser renderer. It reads your markdown, config,
and nav at runtime and renders everything client-side. No compilation, no
framework.
- `config.yml` — contains site configuration.
- `nav.yml` — navigation tree.
- `search.json` — supports site-wide search on static host.
**`mdcms.py`** — a zero-dependency Python CLI tool. It scans your content, generates `nav.yml` and `search.json`, validates your config, and packages everything into a zip file ready for upload.
The local app: `mdcms` — a CLI tool that scans your content, generates `nav.yml`
and `search.json`, and can wire up automatic builds via GitHub Actions.
---
## Installation
## Features
**Standalone binary**
- **Write in markdown** — pages and posts with YAML frontmatter
- **Categories** — serve multiple versions of the same page (e.g. languages, destinations, variants) via `?cat=` URL parameter and a dropdown UI
- **Sections** — nested navigation defined in `nav.yml`; pages declare their section via frontmatter
- **Full-text search** — category-aware, generated at build time
- **Dynamic content tags** — embed post lists with date sorting, pagination, and year grouping using fenced `mdcms` code blocks
- **RTL support** — per-category text direction
- **Custom fonts per category** — load a font file from `assets/fonts/` when a category is selected
- **Light and dark mode** — fully themeable via `config.yml`
- **No server required** — everything is static; deploy to GitHub Pages, Codeberg Pages, Cloudflare Pages, Netlify, or any file host
- **Zero dependencies**`mdcms.py` uses only the Python standard library
1. Download from the
[latest release](https://github.com/kbenestad/mdcms/releases/latest).
2. Move the binary and make it executable
- Linux
- Download the [latest mdcms](latest/linux/mdcms)
- Move it to `/usr/local/bin/`: `sudo mv mdcms /usr/local/bin/mdcms`
- Make it executable: `sudo chmod +x /usr/local/bin/mdcms`
- Mac
- Download the [latest mdcms](latest/macos/mdcms)
- Move it to `/usr/local/bin/`: `sudo mv mdcms /usr/local/bin/mdcms`
- Make it executable: `sudo chmod +x /usr/local/bin/mdcms`
- Remove the quarantine flag: `xattr -dr com.apple.quarantine /usr/local/bin/mdcms`
- Windows
- Download the [latest mdcms](latest/windows/mdcms.exe)
- Move it to a permanent folder, e.g. `C:\tools\mdcms.exe`
- Add `C:\tools\` to your PATH: Start → search `"Environment Variables"` → edit the `Path user variable` → add `C:\tools\`
- Open a new PowerShell window and verify: `mdcms --version`
## Use
To get started, run `mdcms register mysite` to register the current working directory as the `mysite` MD-CMS project directory. You can also specify the project path: `mdcms register mysite /path/to/mysiteNote`.
If there is an existing MD-CMS instance in your specified directory, the app will register this site; if there is no MD-CMS instance present, it will download the latest version from the GitHub repo.
Add your pages, posts, and assets. Once you're ready to upload your site, run `mdcms build mysite` to update `nav.yml` and `search.json`. You can also run `mdcms build --path` to build the website without registering the site.
The final project directory can be uploaded to a static host of your choice.
---
## File structure
```
[project directory]/
index.html ← renderer
config.yml ← site configuration and theme
nav.yml ← navigation structure
search.json ← search index
pages/ ← Put your permanent pages here
home.md ← Main page
...
posts/ ← Put your time-bound posts here
...
assets/ ← Holds directories for non-content files
required/ ← Logo, favicon, and icons go here.
...
images/ ← Images
...
fonts/ ← Fonts referenced in config.yml
...
files/ ← Files for download
...
mdcms.py ← build tool, run this
quickstart.md ← getting started guide
website/ ← everything in here gets deployed
index.html
config.yml
nav.yml
search.json
pages/
home.md
about.md
about.nb.md ← Norwegian variant of about.md
posts/
2025-01-01-my-first-post.md
assets/
images/
fonts/
```
**IMPORTANT:** All links are read from `index.html` in the root. Therefore, you must provide full paths for each file from the root.
The `website/` folder is your deployable site. `mdcms.py` lives outside it.
Example: You want to put a link to `page2.md` in `home.md`. `page2.md` is in the same directory as `home.md`.
---
## Getting started
**Requirements:** Python 3 (standard library only). A modern browser.
1. Clone or download this repository.
2. Run `python3 mdcms.py` and choose **option 2** to build your config and folder structure from scratch.
3. Write your pages in `website/pages/` and posts in `website/posts/`.
4. Run `mdcms.py` again and choose **option 3** to generate `nav.yml` and `search.json`.
5. Choose **option 8** to start a local webserver and preview your site.
6. When ready to publish, choose **option 1** to validate, build, and export `website.zip`.
7. Upload the contents of `website.zip` to your static host.
> **Local preview note:** Open `index.html` via the built-in webserver (option 8), not by double-clicking the file. Browsers block local file access due to CORS restrictions.
---
## Configuration
Site behaviour is controlled by two YAML files in `website/`:
**`config.yml`** — site title, logo, default page, search settings, typography, layout dimensions, light/dark theme colours, and category definitions.
**`nav.yml`** — navigation structure. Sections are defined here; pages declare their section via `section-id` in frontmatter. Sections can be nested.
Both files are human-readable and comment-supported. The `mdcms.py` wizard generates them for you and can fill in missing values interactively.
---
## Categories
Categories let you publish multiple versions of the same page — different languages, regions, or product variants — under a single URL with a `?cat=` parameter.
Each variant is a separate file:
The correct link is therefore:
```
[Link text](pages/page2.md)
about.md ← default
about.en-gb.md ← British English variant
about.nb.md ← Norwegian variant
```
This does not link to `page2.md`:
```
[Link text](page2.md)
```
The category dropdown shows only categories for which a variant exists (or where a "not available" message is configured). All internal links preserve the active category.
## Further reading
---
For further documentation, refer to:
- [docs/](docs/README.md): Documentation relevant to this repo
- [docs.benestad.net](https://docs.benestad.net/): Further help -- including an example of an MD-CMS site.
## Tag system
Embed dynamic post lists in any page using fenced `mdcms` code blocks:
````markdown
```mdcms
posts-date-reversechronological
limit: 10
paginate: yes
```
````
Available tags cover chronological and reverse-chronological post lists, grouped by year, with date or datetime display, and configurable pagination.
---
## Licence
This software is © 2026 Kristian Benestad. Licensed under the [Apache License, Version 2.0](LICENSE).
Apache 2.0 — see [LICENCE]((https://www.apache.org/licenses/LICENSE-2.0)).
© Kristian Benestad

View file

@ -1,32 +0,0 @@
# Security Policy
## Supported versions
Only the latest release of mdcms receives security fixes.
| Version | Supported |
|---------|-----------|
| Latest | Yes |
| Older | No |
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via [GitHub's private vulnerability reporting](https://github.com/kbenestad/mdcms/security/advisories/new), or email **kristian@benestad.net** if you prefer.
Include:
- A description of the vulnerability
- Steps to reproduce it
- The version of mdcms affected
- Any suggested fix, if you have one
You can expect an acknowledgement within a few days and a fix or response within 30 days depending on severity.
## Scope
mdcms is a local CLI tool and static site renderer. It does not run a server or handle untrusted network input in normal use. The main areas of concern are:
- **Template download** (`mdcms register`) — fetches files from GitHub over HTTPS using `certifi` for SSL verification.
- **YAML parsing** — uses `yaml.safe_load()` throughout; `yaml.load()` with an untrusted loader is never used.
- **File path handling** — paths are resolved relative to a user-supplied site directory; symlink traversal or path escape bugs would be in scope.

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting…</title>
<script>
// SPA routing for GitHub Pages: the server returns this 404 page for any path
// it can't resolve. We encode the intended path as ?_route= and redirect to the
// app root so index.html can pick it up and render the right page.
(function () {
var path = window.location.pathname;
var search = window.location.search;
var hash = window.location.hash;
// On GitHub Pages project sites the app lives at /repo-name/, so we keep
// that prefix and only encode the segment after it.
var parts = path.split('/');
var isGhPages = window.location.hostname.endsWith('.github.io') && parts.length > 2;
var base = isGhPages ? '/' + parts[1] + '/' : '/';
var route = '/' + parts.slice(isGhPages ? 2 : 1).join('/');
var qs = '_route=' + encodeURIComponent(route);
if (search) qs += '&' + search.slice(1);
window.location.replace(window.location.origin + base + '?' + qs + hash);
})();
</script>
</head>
<body></body>
</html>

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>

Before

Width:  |  Height:  |  Size: 134 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>

Before

Width:  |  Height:  |  Size: 113 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 17l5-5-5-5v10z"/></svg>

Before

Width:  |  Height:  |  Size: 117 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>

Before

Width:  |  Height:  |  Size: 176 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM12 17.3c-.72 0-1.3-.58-1.3-1.3s.58-1.3 1.3-1.3 1.3.58 1.3 1.3-.58 1.3-1.3 1.3zm1-4.3h-2V7h2v6z"/></svg>

Before

Width:  |  Height:  |  Size: 255 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/></svg>

Before

Width:  |  Height:  |  Size: 266 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/></svg>

Before

Width:  |  Height:  |  Size: 274 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>

Before

Width:  |  Height:  |  Size: 195 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>

Before

Width:  |  Height:  |  Size: 177 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>

Before

Width:  |  Height:  |  Size: 323 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>

Before

Width:  |  Height:  |  Size: 195 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>

Before

Width:  |  Height:  |  Size: 144 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>

Before

Width:  |  Height:  |  Size: 143 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18 6.41L16.59 5 12 9.58 7.41 5 6 6.41l6 6zm0 6l-1.41-1.41L12 15.58l-4.59-4.59L6 12.41l6 6z"/></svg>

Before

Width:  |  Height:  |  Size: 190 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.41 6L5 7.41 9.58 12 5 16.59 6.41 18l6-6zm6 0l-1.41 1.41L15.58 12l-4.58 4.59L12.41 18l6-6z"/></svg>

Before

Width:  |  Height:  |  Size: 191 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2s.07-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>

Before

Width:  |  Height:  |  Size: 888 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-12.37-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06c.39-.39.39-1.03 0-1.41s-1.03-.39-1.41 0zM7.05 18.36l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06c.39-.39.39-1.03 0-1.41s-1.03-.39-1.41 0z"/></svg>

Before

Width:  |  Height:  |  Size: 878 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>

Before

Width:  |  Height:  |  Size: 144 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13H5v-2h14v2z"/></svg>

Before

Width:  |  Height:  |  Size: 116 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>

Before

Width:  |  Height:  |  Size: 154 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14.4 6 14 4H5v17h2v-7h5.6l.4 2h7V6z"/></svg>

Before

Width:  |  Height:  |  Size: 135 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>

Before

Width:  |  Height:  |  Size: 333 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>

Before

Width:  |  Height:  |  Size: 215 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.3 7.8 2.5 12l3.8 4.2.8-.8L4.4 12l2.7-3.2-.8-.8zm11.4 0-.8.8L19.6 12l-2.7 3.2.8.8 3.8-4.2-3.8-4.2zm-3.7-4.6L9.9 20.8l1.4.4 4.1-17.6-1.4-.4z"/></svg>

Before

Width:  |  Height:  |  Size: 240 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>

Before

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,23 +0,0 @@
{
"id": "/",
"name": "MD-CMS Phase 7 Test",
"short_name": "MDCMS Test",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563EB",
"icons": [
{
"src": "assets/images/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/images/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View file

@ -1,45 +0,0 @@
{
"mdcms": "0.4",
"files": [
"404.html",
"assets/icons/add.svg",
"assets/icons/arrow_drop_down.svg",
"assets/icons/arrow_right.svg",
"assets/icons/collapse_content.svg",
"assets/icons/dangerous.svg",
"assets/icons/dark_mode.svg",
"assets/icons/error.svg",
"assets/icons/exclamation.svg",
"assets/icons/expand_content.svg",
"assets/icons/history.svg",
"assets/icons/info.svg",
"assets/icons/keyboard_arrow_down.svg",
"assets/icons/keyboard_arrow_right.svg",
"assets/icons/keyboard_double_arrow_down.svg",
"assets/icons/keyboard_double_arrow_right.svg",
"assets/icons/language.svg",
"assets/icons/light_mode.svg",
"assets/icons/menu.svg",
"assets/icons/minimize.svg",
"assets/icons/mobile_arrow_down.svg",
"assets/icons/report.svg",
"assets/icons/search.svg",
"assets/icons/success.svg",
"assets/icons/text_compare.svg",
"assets/icons/warning.svg",
"assets/images/favicon.png",
"config.yml",
"index.html",
"nav.yml",
"pages/about.md",
"pages/docs.md",
"pages/home.md",
"pages/tabs-accordions.md",
"search.json",
"theme.yml"
],
"dirs": [
"assets/fonts",
"posts"
]
}

View file

@ -1,22 +0,0 @@
# nav.yml — generated by mdcms
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
# pagesvisibility, categorynames) are preserved on rebuild.
sections:
# (none yet — add section-id to page frontmatter to auto-create)
pages:
- file: pages/home.md
title: Home
sort: 100
- file: pages/about.md
title: About
sort: 200
- file: pages/docs.md
title: Docs
sort: 300
- file: pages/tabs-accordions.md
title: Tabs & Accordions
sort: 400

View file

@ -1,8 +0,0 @@
---
title: About
sort: 200
---
# About
This is a sample page for Phase 7 PWA testing. Navigate here from the sidebar, then go offline and reload — this page should still be available from the service worker cache.

View file

@ -1,8 +0,0 @@
---
title: Docs
sort: 300
---
# Docs
Another sample page for Phase 7 PWA testing. Visit this page while online, then go offline — it should remain accessible from the cache.

View file

@ -1,26 +0,0 @@
---
title: Home
sort: 100
---
# Phase 7 — PWA Test
This page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.
## Test procedure
1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`
2. Load `http://localhost:8800` — service worker registers on first load
3. Navigate to the **About** and **Docs** pages so they are fetched and cached
4. Stop the HTTP server (`Ctrl+C` in its terminal)
5. Reload — site should load fully from the service worker cache
6. Navigate between pages — all should work offline
7. Check that a page not yet visited shows the offline message
## What to look for
- `manifest.json` and `service-worker.js` exist after build
- DevTools → Application → Service Workers: status **activated and running**
- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed
- Site loads fully with server stopped
- Offline message (`config.yml: offline-message`) appears for uncached pages

View file

@ -1,78 +0,0 @@
---
title: Tabs & Accordions
sort: 400
---
# Tabs & Accordions
## Tab — Underline variant
```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.
```
## Tab — Filled variant
```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 with sections
- Full-text search via Fuse.js
- PWA support with offline caching
- Dark / light theme toggle
- title: Architecture
content: |
Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).
```
## Accordion — Underline variant
```mdcms accordion-underline
items:
- title: What is MD-CMS?
default: open
content: |
MD-CMS is a single-file browser renderer that reads markdown, config,
and nav at runtime entirely client-side. No build pipeline, no compilation.
- 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 all vendor assets locally, then
enable `pwa: yes` in `config.yml` for full offline support.
```
## Accordion — Filled variant
```mdcms accordion-filled
items:
- title: Can I use custom themes?
default: open
content: |
Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in
your `config.yml`. The theme controls colours, fonts, and layout.
- title: What markdown features are supported?
content: |
GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,
strikethrough, and autolinks. Syntax highlighting via highlight.js.
- title: Can I nest categories?
content: |
Categories are flat (no nesting), but nav sections support a `parent:`
key for two-level sidebar grouping.
```

View file

View file

@ -1,50 +0,0 @@
[
{
"file": "pages/about.md",
"title": "About",
"section-id": null,
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"language": "en",
"body": "# About\n\nThis is a sample page for Phase 7 PWA testing. Navigate here from the sidebar, then go offline and reload — this page should still be available from the service worker cache.\n"
},
{
"file": "pages/docs.md",
"title": "Docs",
"section-id": null,
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"language": "en",
"body": "# Docs\n\nAnother sample page for Phase 7 PWA testing. Visit this page while online, then go offline — it should remain accessible from the cache.\n"
},
{
"file": "pages/home.md",
"title": "Home",
"section-id": null,
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"language": "en",
"body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n"
},
{
"file": "pages/tabs-accordions.md",
"title": "Tabs & Accordions",
"section-id": null,
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"language": "en",
"body": "# Tabs & Accordions\n\n## Tab — Underline variant\n\n```mdcms tab-underline\nitems:\n - title: Install\n default: selected\n content: |\n Install with `npm i mdcms` or `pnpm add mdcms`.\n - title: Configure\n content: |\n Drop a `mdcms.config.yaml` next to your content folder.\n - title: Deploy\n content: |\n Any static host. The build emits plain HTML.\n```\n\n## Tab — Filled variant\n\n```mdcms tab-filled\nitems:\n - title: Overview\n default: selected\n content: |\n MD-CMS is a markdown-based static site system with no build step.\n - title: Features\n content: |\n - Sidebar navigation with sections\n - Full-text search via Fuse.js\n - PWA support with offline caching\n - Dark / light theme toggle\n - title: Architecture\n content: |\n Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).\n```\n\n## Accordion — Underline variant\n\n```mdcms accordion-underline\nitems:\n - title: What is MD-CMS?\n default: open\n content: |\n MD-CMS is a single-file browser renderer that reads markdown, config,\n and nav at runtime entirely client-side. No build pipeline, no compilation.\n - title: How do I install it?\n content: |\n Run `pip install mdcms` or download a binary from the GitHub releases page.\n - title: Does it work offline?\n content: |\n Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then\n enable `pwa: yes` in `config.yml` for full offline support.\n```\n\n## Accordion — Filled variant\n\n```mdcms accordion-filled\nitems:\n - title: Can I use custom themes?\n default: open\n content: |\n Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in\n your `config.yml`. The theme controls colours, fonts, and layout.\n - title: What markdown features are supported?\n content: |\n GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,\n strikethrough, and autolinks. Syntax highlighting via highlight.js.\n - title: Can I nest categories?\n content: |\n Categories are flat (no nesting), but nav sections support a `parent:`\n key for two-level sidebar grouping.\n```\n"
}
]

View file

@ -1,66 +0,0 @@
// mdcms service worker — generated by mdcms build
const CACHE_NAME = 'mdcms-a1862733';
const PRECACHE_URLS = [
"index.html",
"config.yml",
"nav.yml",
"search.json",
"theme.yml",
"pages/about.md",
"pages/docs.md",
"pages/home.md",
"pages/tabs-accordions.md",
"posts/.gitkeep",
"assets/fonts/.gitkeep",
"assets/icons/.gitkeep",
"assets/icons/add.svg",
"assets/icons/arrow_drop_down.svg",
"assets/icons/arrow_right.svg",
"assets/icons/collapse_content.svg",
"assets/icons/dangerous.svg",
"assets/icons/dark_mode.svg",
"assets/icons/error.svg",
"assets/icons/exclamation.svg",
"assets/icons/expand_content.svg",
"assets/icons/history.svg",
"assets/icons/info.svg",
"assets/icons/keyboard_arrow_down.svg",
"assets/icons/keyboard_arrow_right.svg",
"assets/icons/keyboard_double_arrow_down.svg",
"assets/icons/keyboard_double_arrow_right.svg",
"assets/icons/language.svg",
"assets/icons/light_mode.svg",
"assets/icons/menu.svg",
"assets/icons/minimize.svg",
"assets/icons/mobile_arrow_down.svg",
"assets/icons/report.svg",
"assets/icons/search.svg",
"assets/icons/success.svg",
"assets/icons/text_compare.svg",
"assets/icons/warning.svg",
"assets/images/.gitkeep",
"assets/images/favicon.png"
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});

View file

@ -1,99 +0,0 @@
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.4 — Theme configuration
#
# Edit colours, fonts, and layout here. See docs for full reference.
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#2563EB"
background: "#FFFFFF"
nav-background: "#F8FAFC"
text: "#1E293B"
text-muted: "#64748B"
# nav-link: "#1E293B" # inactive nav link text (defaults to text)
# nav-link-active: "#2563EB" # active nav link text (defaults to accent)
# nav-section-heading: "#64748B" # nav section label text (defaults to text-muted)
# nav-sitename: "#1E293B" # site name in sidebar header (defaults to nav-link)
# nav-description: "#64748B" # site description in sidebar header (defaults to nav-section-heading)
# nav-toggle: "#64748B" # dark/light mode toggle (defaults to nav-section-heading)
# divider: "#CBD5E1" # border/hr colour (defaults to color-mix of background + text)
dark:
accent: "#60A5FA"
background: "#0F172A"
nav-background: "#1E293B"
text: "#F1F5F9"
text-muted: "#94A3B8"
# nav-link: "#E2E8F0" # inactive nav link text (defaults to text)
# nav-link-active: "#60A5FA" # active nav link text (defaults to accent)
# nav-section-heading: "#94A3B8" # nav section label text (defaults to text-muted)
# nav-sitename: "#E2E8F0" # site name in sidebar header (defaults to nav-link)
# nav-description: "#94A3B8" # site description in sidebar header (defaults to nav-section-heading)
# nav-toggle: "#94A3B8" # dark/light mode toggle (defaults to nav-section-heading)
# divider: "#334155" # border/hr colour (defaults to color-mix of background + text)
# ──────────────────────────────────
# Semantic colours
# Used by callout tags (info, warning, success, error).
# colours-semantic applies to both modes; colours-semantic-dark overrides for dark mode.
# ──────────────────────────────────
colours-semantic:
info: "#2563EB"
warning: "#D97706"
success: "#16A34A"
error: "#DC2626"
colours-semantic-dark:
info: "#60A5FA"
warning: "#F59E0B"
success: "#34D399"
error: "#F87171"
# ──────────────────────────────────
# Callout defaults
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#2563EB"
background-colour: "#2563EB"
warning:
icon: warning
primary-colour: "#D97706"
background-colour: "#D97706"
success:
icon: success
primary-colour: "#16A34A"
background-colour: "#16A34A"
error:
icon: error
primary-colour: "#DC2626"
background-colour: "#DC2626"
# ──────────────────────────────────
# Typography
# Format: "provider:Font Name:weight" (provider: bunny | google)
# ──────────────────────────────────
font-body: "bunny:Noto Sans:400"
font-heading: "bunny:Noto Sans:700"
font-size: 1.0 # unitless multiplier (1.0 = 16px base)
line-height: 1.7 # unitless multiplier
# ──────────────────────────────────
# Nav section toggle icons
# Used on sections with pagesvisibility: hidden (collapsible sections).
# expand-icon shown when section is collapsed; collapse-icon when expanded.
# Options: arrow_right/arrow_drop_down (default) | keyboard_arrow_right/keyboard_arrow_down
# keyboard_double_arrow_right/keyboard_double_arrow_down
# expand_content/collapse_content | add/minimize
# ──────────────────────────────────
# nav-section-expand-icon: arrow_right
# nav-section-collapse-icon: arrow_drop_down
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 80em
nav-width: 20em

View file

@ -1 +0,0 @@
Placeholder

View file

@ -1 +0,0 @@
V0.3.1 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.

View file

@ -1 +0,0 @@
V0.3.2 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.

View file

@ -1 +0,0 @@
This version is outdated. Please visit https://github.com/kbenestad/mdcms/ to update.

View file

@ -1 +0,0 @@
You are using the latest version.

View file

@ -1,309 +0,0 @@
# mdcms theme authoring guide for Claude Design
This document explains the `theme.yml` format so that Claude Design can produce
complete, correct theme files that render well in all nav configurations and in
both light and dark mode.
---
## Full theme.yml structure
```yaml
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# mdcms theme — <theme name>
# ──────────────────────────────────
# Colours
# ──────────────────────────────────
light:
accent: "#2563EB" # brand colour; used for links, active nav border, accents
background: "#FFFFFF" # main content area background
nav-background: "#F8FAFC" # sidebar/nav panel background
text: "#1E293B" # body text
text-muted: "#64748B" # secondary text, captions
nav-link: "#1E293B" # inactive nav link text
nav-link-active: "#2563EB" # active (current page) nav link text
nav-section-heading: "#64748B" # nav section label text (uppercase, small)
nav-sitename: "#1E293B" # site name in sidebar header
nav-description: "#64748B" # site description below the site name
nav-toggle: "#64748B" # dark/light mode toggle button
# divider: "#CBD5E1" # omit to auto-derive via color-mix(background, text)
dark:
accent: "#60A5FA"
background: "#0F172A"
nav-background: "#1E293B"
text: "#F1F5F9"
text-muted: "#94A3B8"
nav-link: "#E2E8F0"
nav-link-active: "#60A5FA"
nav-section-heading: "#94A3B8"
nav-sitename: "#E2E8F0"
nav-description: "#94A3B8"
nav-toggle: "#94A3B8"
# divider: "#334155" # omit to auto-derive via color-mix(background, text)
# ──────────────────────────────────
# Semantic colours
# colours-semantic applies to both modes.
# colours-semantic-dark overrides for dark mode only.
# ──────────────────────────────────
colours-semantic:
info: "#2563EB"
warning: "#D97706"
success: "#16A34A"
error: "#DC2626"
colours-semantic-dark:
info: "#60A5FA"
warning: "#F59E0B"
success: "#34D399"
error: "#F87171"
# ──────────────────────────────────
# Callout defaults
# primary-colour → left border and icon
# background-colour → tinted background (rendered at ~8% opacity)
# ──────────────────────────────────
callouts:
info:
icon: info
primary-colour: "#2563EB"
background-colour: "#2563EB"
warning:
icon: warning
primary-colour: "#D97706"
background-colour: "#D97706"
success:
icon: success
primary-colour: "#16A34A"
background-colour: "#16A34A"
error:
icon: error
primary-colour: "#DC2626"
background-colour: "#DC2626"
# ──────────────────────────────────
# Typography
# Format: "provider:Font Name:weight" (provider: bunny | google)
# ──────────────────────────────────
font-body: "bunny:IBM Plex Sans:400"
font-heading: "bunny:IBM Plex Sans:700"
font-size: 1.0 # unitless multiplier (1.0 = 16px base)
line-height: 1.7 # unitless multiplier
# ──────────────────────────────────
# Nav section toggle icons
# expand-icon: shown when section is collapsed
# collapse-icon: shown when section is expanded
# ──────────────────────────────────
nav-section-expand-icon: arrow_right # default
nav-section-collapse-icon: arrow_drop_down # default
# ──────────────────────────────────
# Layout
# ──────────────────────────────────
main-width: 80em
nav-width: 20em
```
---
## Nav section toggle icons
Sections with `pagesvisibility: hidden` in `nav.yml` are collapsible. The
expand and collapse icons are set independently at the top level of `theme.yml`
(not inside `light:` or `dark:` — they are not per-mode).
| Key | Default | Shown when |
|---|---|---|
| `nav-section-expand-icon` | `arrow_right` | section is collapsed |
| `nav-section-collapse-icon` | `arrow_drop_down` | section is expanded |
**Available pairs and their character:**
| Expand icon | Collapse icon | Character |
|---|---|---|
| `arrow_right` | `arrow_drop_down` | Solid filled triangles — compact, classic |
| `keyboard_arrow_right` | `keyboard_arrow_down` | Chevrons (/˅) — lighter, more modern |
| `keyboard_double_arrow_right` | `keyboard_double_arrow_down` | Double chevrons (»/⌄) — emphatic |
| `expand_content` | `collapse_content` | Corner-arrows — editorial, spatial |
| `add` | `minimize` | Plus/minus — very minimal, utilitarian |
Mix and match freely — the expand and collapse icons do not have to come from
the same pair, but keeping them visually related (same weight and style)
usually reads better.
**Matching icon style to nav style:** bold high-contrast themes (filled
triangle, plus/minus) suit designs with strong typographic weight. Lighter
themes pair better with chevrons. Editorial or magazine-style designs work
well with `expand_content`/`collapse_content`.
---
## Nav colour keys: when to set them
There are six nav colour keys divided into two groups:
**Nav links and labels** — control the navigation list itself:
- `nav-link` — inactive link text (defaults to `text`)
- `nav-link-active` — active/current page link text (defaults to `accent`)
- `nav-section-heading` — uppercase section labels (defaults to `text-muted`)
**Sidebar header elements** — control the branding area above the nav list:
- `nav-sitename` — site name (defaults to `nav-link`)
- `nav-description` — subtitle below the site name (defaults to `nav-section-heading`)
- `nav-toggle` — dark/light mode toggle button (defaults to `nav-section-heading`)
### When the defaults are fine
On themes where `nav-background` is a neutral near-white (light mode) or
near-black (dark mode), `text` and `text-muted` read well against the nav
background. All six keys can be omitted and the fallback chain works correctly.
### When to set the keys explicitly
Set all six keys whenever `nav-background` is anything other than a neutral:
any saturated brand colour (red, navy, forest green, teal), any noticeably
dark sidebar in an otherwise light design, or any light-but-tinted background.
The two groups can be set independently. On a subtly tinted nav where the
link defaults look fine but the site name needs slightly more weight or a
different shade, set only the header keys (`nav-sitename`, `nav-description`,
`nav-toggle`) and leave the nav link keys to their defaults.
**Rule of thumb:** if `nav-background` has saturation above ~20 % or lightness
below 30 % (dark sidebar) or differs from `background` by more than a slight
tint, set all six explicitly for that mode.
### Pattern: accent-coloured nav (e.g. brand red, navy, forest green)
```yaml
light:
accent: "#D00C33"
nav-background: "#D00C33" # same as accent — all nav keys must be set
nav-link: "#FFFFFF"
nav-link-active: "#FFFFFF"
nav-section-heading: "rgba(255,255,255,0.65)"
nav-sitename: "#FFFFFF"
nav-description: "rgba(255,255,255,0.65)"
nav-toggle: "rgba(255,255,255,0.65)"
dark:
accent: "#D00C33"
nav-background: "#000000"
nav-link: "#E2E2E2"
nav-link-active: "#FFFFFF"
nav-section-heading: "#888888"
nav-sitename: "#FFFFFF"
nav-description: "#888888"
nav-toggle: "#888888"
```
### Pattern: dark nav in light mode (sidebar darker than content)
```yaml
light:
nav-background: "#1E293B"
nav-link: "#CBD5E1"
nav-link-active: "#FFFFFF"
nav-section-heading: "#64748B"
nav-sitename: "#FFFFFF"
nav-description: "#64748B"
nav-toggle: "#64748B"
```
### Pattern: transparent / very light nav (default behaviour)
When `nav-background` is a light neutral, the defaults work fine.
You can omit `nav-link`, `nav-link-active`, and `nav-section-heading`
and the renderer will fall back to `text`, `accent`, and `text-muted`.
---
## Semantic colours and dark mode
`colours-semantic` values are applied globally (both modes). The callout
background is rendered at ~8% opacity, so a colour that looks fine on white
can wash out on a dark background — or conversely, a colour bright enough for
dark mode may be too vivid on white.
The solution is `colours-semantic-dark`: it overrides semantic colours in dark
mode only. Typical approach:
- **`colours-semantic`** — choose saturated but not neon values that work on white
- **`colours-semantic-dark`** — use lighter, more luminous variants of the same hues
```yaml
colours-semantic:
info: "#1D4ED8" # deep blue — strong on white
warning: "#B45309" # amber — strong on white
success: "#15803D" # green — strong on white
error: "#B91C1C" # red — strong on white
colours-semantic-dark:
info: "#93C5FD" # light blue — visible on dark background
warning: "#FCD34D" # light amber
success: "#6EE7B7" # light green
error: "#FCA5A5" # light red/pink
```
Match `callouts` `primary-colour` / `background-colour` values to
`colours-semantic` (light mode callout values), since the callout block
uses its own per-callout colour settings rather than the semantic variables.
---
## Legibility analysis
Before finalising any theme — and especially when refactoring an existing one —
work through every colour pairing in the design and check that text is
readable against its background.
**Pairs to check:**
| Text | Background |
|---|---|
| `text` | `background` |
| `text-muted` | `background` |
| `nav-link` | `nav-background` |
| `nav-link-active` | `nav-background` |
| `nav-section-heading` | `nav-background` |
| `nav-sitename` | `nav-background` |
| `nav-description` | `nav-background` |
| `nav-toggle` | `nav-background` |
| `accent` | `background` (used for inline links in content) |
| `colours-semantic.*` | `background` (callout borders and tinted backgrounds) |
| `colours-semantic-dark.*` | dark `background` |
**WCAG contrast targets:**
- Body text (`text`) on `background`: aim for **7:1** (AAA). Never go below 4.5:1 (AA).
- Secondary text (`text-muted`, `nav-section-heading`, `nav-description`): minimum **3:1**, aim for 4.5:1.
- Nav links and site name: minimum **4.5:1** against `nav-background`.
- Active/hover states: minimum **3:1** (they are reinforced by other visual cues).
**Common failure modes to look for:**
- A saturated accent on a white background can be vibrant but low-contrast — reds and oranges are frequent offenders.
- `text-muted` on a tinted or coloured background often falls below 3:1.
- Dark-mode `text-muted` on a near-black background is easy to get wrong when porting from a light-mode palette.
- `nav-description` and `nav-toggle` are small and low-weight, so they need more contrast than the minimum to feel comfortable — lean toward the higher targets for these.
When a pairing is marginal, adjust the lighter or darker of the two values by enough to clear the target. Do not simply accept values that are close to failing.
---
## Checklist before finalising a theme
- [ ] All six nav colour keys (`nav-link`, `nav-link-active`, `nav-section-heading`,
`nav-sitename`, `nav-description`, `nav-toggle`) set for both `light` and
`dark` whenever `nav-background` is non-neutral
- [ ] All nav colours contrast against `nav-background` (WCAG AA minimum; see Legibility analysis above)
- [ ] `text` on `background` meets 7:1 (AAA); never below 4.5:1
- [ ] `text-muted` and header element colours meet at least 3:1; aim for 4.5:1
- [ ] `accent` on `background` meets 4.5:1 (used for inline links)
- [ ] `colours-semantic-dark` provided with lighter variants of each colour
- [ ] `callouts` `primary-colour` matches `colours-semantic` values for consistency
- [ ] `divider` omitted unless the auto-derived value looks wrong (check hr and table borders)
- [ ] Dark mode `background` is not pure `#000000` unless intentional (use `#0A0A0A`+)
- [ ] `font-size` between `0.85` and `1.15`; `line-height` between `1.5` and `1.9`
- [ ] Version comment on line 1: `# mdcms v0.4 | DO NOT REMOVE THIS COMMENT`

View file

@ -1,126 +0,0 @@
# Release workflow
This document covers the automatic creation of release binaries of MD-CMS for Linux, macOS and Windows. This document is relevant for you who want to continue developing MD-CMS on your own.
## Prerequisites
- Push access to your GitHub release repository
- GitHub Actions enabled on the repository (it is by default on new repos)
## One-time GitHub setup
### Enable workflow write permissions
The release workflow creates a GitHub release using the built-in `GITHUB_TOKEN`. You need to confirm it has write access:
1. Go to your repository on GitHub
2. Go to **Settings → Actions → General**
3. Scroll to **Workflow permissions**
4. Select **Read and write permissions**
5. Click **Save**
That's the only setup required. No secrets, no tokens, no third-party services.
## The release checklist
### Update version number
Before tagging a release, update the version number in at least places:
**`mdcms.py`** — find this line near the top and bump it:
```python
CLI_VERSION = "0.3.8"
```
**`pyproject.toml`** — bump the matching line:
```toml
version = "0.3.8"
```
If you have made changes that affect the `index.html` and/or `config.yml`, update the minimum supported version comment at the top of each file:
**`app/index.html`** — bump the matching line:
```html
<!-- Minimum supported version: mdcms v0.3.8 | DO NOT REMOVE THIS COMMENT -->
```
**`app/config.yml`** — bump the matching line:
```yml
# Minimum supported version: mdcms v0.3.2 | DO NOT REMOVE THIS COMMENT
```
#### Commit locally
Commit the version bump:
```bash
git add mdcms.py pyproject.toml
git commit -m "Bump version to 0.3.8"
git push origin main
```
#### Commit on the web
Save each file with a version bump notice: `"Bump version to 0.3.8"`.
### Tagging the release
#### Locally
Push a version tag to trigger the workflow:
```bash
git tag v0.3.8
git push origin v0.3.8
```
#### On the web
1. Go to your repository → Releases (right sidebar)
1. Click Draft a new release
1. Click Choose a tag → type v0.3.7 → click Create new tag: v0.3.8 on publish
1. Leave the target branch as main
1. Click Generate release notes
1. Click Publish release
The binaries will be generated by
#### Common
The tag must start with `v`. The workflow triggers immediately.
> **Pre-releases** — if the tag contains a hyphen (e.g. `v0.4.0-beta.1`), the GitHub release is automatically marked as pre-release.
## What the workflow does
The workflow (`.github/workflows/release.yml`) runs three parallel jobs:
| Runner | Output |
|---|---|
| `ubuntu-latest` | `mdcms-linux-amd64` (standalone binary) + `mdcms_0.3.1_amd64.deb` |
| `macos-latest` | `mdcms-macos-arm64` (standalone binary) |
| `windows-latest` | `mdcms-windows-amd64.exe` |
Each binary is built with PyInstaller — Python is bundled inside, so end users need nothing pre-installed. The `.deb` is built with `fpm` and installs mdcms to `/usr/local/bin`.
A final job collects all artifacts and creates the GitHub release with auto-generated release notes.
## Monitoring the build
1. Go to **Actions** on the repository
2. Click the workflow run triggered by your tag
3. Each platform job takes roughly 35 minutes
If a job fails, the release is not created. Fix the issue, delete the tag, and re-push:
```bash
git tag -d v0.3.8
git push origin --delete v0.3.8
```
Fix the issue, then re-tag:
```bash
git tag v0.3.8
git push origin v0.3.8
```
## The finished release
Once all jobs pass, the release appears under **Releases** on the repository with:
- Auto-generated changelog (commits since the last tag)
- All four artifacts attached for download
## Follow-up
If you serve `latest` binaries from a standard URL, you also have to make sure to update these.

View file

@ -1,138 +0,0 @@
# Automatic generation of `nav.yml` and `search.json`
This document covers
## Enable GitHub Actions
Before you can set up the automation workflow you need to ensure that it has write access:
1. Go to your repository on GitHub
2. Go to **Settings → Actions → General**
3. Scroll to **Workflow permissions**
4. Select **Read and write permissions**
5. Click **Save**
That's the only setup required. No secrets, no tokens, no third-party services.
## Enable workflow on single site
Create `.github/workflows/build.yml` in your repository.
Assuming that the MD-CMS site is hosted at the repository root, you just need to paste the following into `build.yml`
```bash
name: Build
on:
push:
branches:
- main
paths:
- "pages/**"
- "posts/**"
- "config.yml"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install mdcms
run: pip install mdcms
- name: Build
run: mdcms build
- name: Commit updated files
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add nav.yml search.json
git diff --staged --quiet || git commit -m "Build: update nav.yml and search.json"
git push
```
If your MD-CMS instance is served from a directory within the repository (e.g., `docs`), you need to adjust `paths` and `git add` accordingly:
```bash
paths:
- "[PATH TO SITE]/pages/**"
- "[PATH TO SITE]/posts/**"
- "[PATH TO SITE]/config.yml"
```
and
```bash
git add [PATH TO SITE]/nav.yml [PATH TO SITE]/search.json
```
## Enable workflow on multisites
If you serve multiple MD-CMS instances from the same repository, you need to use a customised version of the script.
Create `.github/workflows/build.yml` in your repository.
Review the script below. It has been set up with four different directories (`DIRECTORY`, `DIRECTORY2`, `DIRECTORY3`, and `DIRECTORY4`). Change `paths` and `for section in` to your own directories:
```bash
name: Build
on:
push:
branches:
- main
paths:
- "[DIRECTORY1]/pages/**"
- "[DIRECTORY1]/posts/**"
- "[DIRECTORY1]/config.yml"
- "[DIRECTORY2]/pages/**"
- "[DIRECTORY2]/posts/**"
- "[DIRECTORY2]/config.yml"
- "[DIRECTORY3]/pages/**"
- "[DIRECTORY3]/posts/**"
- "[DIRECTORY3]/config.yml"
- "[DIRECTORY4]/pages/**"
- "[DIRECTORY4]/posts/**"
- "[DIRECTORY4]/config.yml"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install mdcms
run: pip install git+https://github.com/kbenestad/mdcms.git
- name: Build all sections
run: |
for section in docs legal learning reception sysadmin; do
cd $section
mdcms build
cd ..
done
- name: Commit updated files
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
for section in [DIRECTORY2] [DIRECTORY2] [DIRECTORY3] [DIRECTORY4]; do
git add $section/nav.yml $section/search.json
done
git diff --staged --quiet || git commit -m "Build: update nav.yml and search.json"
git push
```
This is different from the single site workflow. If you move from single site to multisite you need to updated the entire script -- it is not sufficient to just list the paths!

View file

@ -1,52 +0,0 @@
# Setting up MD-CMS for your site
This document walks you through the installation of MD-CMS.
## Minimum install
The bare minimum required to run MD-CMS is to download the content in [**app/**](https://github.com/kbenestad/mdcms/tree/main/app) and upload the files and folders to any web-server.
## Recommended install
To properly use MD-CMS you need to download the CLI tool.
### Linux
To download MD-CMS for Linux, you need to run the appropriate command below in the terminal. Verify which version you have installed by running `mdcms --version`.
#### Debian and Debian-based distros (including Ubuntu)
The .deb package handles all installation details. To download and install, run:
```
curl -fsSLO https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/linux/mdcms.deb && sudo dpkg -i mdcms.deb
```
#### All other Linux distros
For all other Linux distros, please run the following command in the terminal:
```
sudo curl -fsSL https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/linux/mdcms -o /usr/local/bin/mdcms && sudo chmod +x /usr/local/bin/mdcms
```
This command fetches the latest binary, moves it to `/usr/local/bin/mdcms` and makes it executable in one go.
### MacOS
Open terminal and run this command to install `mdcms`:
```
sudo curl -fsSL https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/macos/mdcms -o /usr/local/bin/mdcms && sudo chmod +x /usr/local/bin/mdcms
```
MacOS may block the binary on first run ("cannot be opened because the developer cannot be verified"). If so, run the following command:
```
sudo xattr -d com.apple.quarantine /usr/local/bin/mdcms
```
once to clear it. Verify which version you have installed by running `mdcms --version`.
### Windows
In Windows 10 or 11, open PowerShell and run the following command:
```
Invoke-WebRequest https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/windows/mdcms.exe -OutFile "$env:USERPROFILE\AppData\Local\Microsoft\WindowsApps\mdcms.exe"
```
Verify which version you have installed by running `mdcms --version`.
## Update
MD-CMS consists of two separate pieces of software: The CLI tool (which you run from the terminal) and the renderer (the index.html file, which the browser reads). To update the CLI, simply rerun the installation command and overwrite `mdcms`. To update the renderer, download the latest index.html and overwrite it in your sites.

View file

@ -1,35 +0,0 @@
# Known bugs
Bugs that have been identified but not yet fixed. Fixed bugs are moved to the release notes.
---
## Fixed in development (not yet released)
### Category-variant pages fail to load on servers with SPA routing
**Symptom:** On Cloudflare Pages (and any other server configured to serve `index.html` with HTTP 200 for missing paths), clicking a nav item whose page only exists as a category-variant file (e.g. `page.current.md`, no plain `page.md`) showed garbled content — the raw HTML of `index.html` rendered as markdown, with the site's `<title>` text visible in the content area.
**Root cause:** `fetchPageFile` tried the base filename (`pages/page.md`) first. Servers with SPA routing return this with HTTP 200 (serving `index.html`), so `r.ok` was true and the function returned without trying the actual variant file (`pages/page.current.md`).
**Fix:** `fetchPageFile` now checks the `Content-Type` response header and skips any response with `text/html`, continuing to the next candidate URL.
---
### Stale service worker not removed when `pwa: no`
**Symptom:** After changing a site from `pwa: yes` to `pwa: no` and rebuilding, the old service worker remained active in browsers that had previously visited the site. Cached responses from the old build continued to be served.
**Root cause:** `mdcms build` stopped generating PWA files when `pwa: no`, but `index.html` unconditionally registers `service-worker.js` on every page load. With no new SW to replace it, the old worker stayed installed indefinitely.
**Fix:** `mdcms build` now writes a self-unregistering stub `service-worker.js` when `pwa: no`. On the visitor's next visit, the browser installs the stub which immediately calls `self.registration.unregister()`, evicting the stale worker. `manifest.json` is also deleted if present.
---
### `config.yml` YAML parse errors were silently swallowed
**Symptom:** A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) caused `read_config` to catch the `YAMLError` and return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a `nav.yml` that omitted `variants` fields and listed category variant files (e.g. `page.current.md`) as plain pages. Pages with category variants would not appear in the sidebar.
**Root cause:** `read_config` caught `(OSError, yaml.YAMLError)` in a single block and silently returned `{}` on any error.
**Fix:** `read_config` now raises `click.ClickException` on both `OSError` and `yaml.YAMLError`, aborting the build with a descriptive error message instead of continuing with an empty config.

View file

@ -1,310 +0,0 @@
# config.yml reference
`config.yml` is the site configuration file. Only `sitename` and `navigation` are required — everything else is optional. `mdcms build` reads this file; so does `index.html` at runtime.
The first line must not be changed:
```yaml
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
```
---
## Required
```yaml
sitename: My Site # Displayed in the browser tab, nav header, and meta tags.
navigation: sidebar # Layout mode. sidebar or topbar. NOTE: topbar is currently broken — always use sidebar.
```
---
## Presentation
```yaml
theme: theme.yml # Path to theme file (relative to site root). Controls colours, fonts, layout.
# Omit to use built-in defaults.
logo: logo.png # Filename in assets/images/. Shown in the nav header above the site name.
favicon: favicon.png # Filename in assets/images/. Shown in the browser tab.
# Falls back to logo if not set. Required for PWA install icons.
sitedescription: A short description # Shown below the site name in the sidebar.
# Also used as the default meta description tag.
footer: "© 2026 Your Name" # Shown at the bottom of the nav. Supports inline markdown (bold, links, etc.).
```
---
## Navigation
```yaml
homepage: pages/home.md # Override the default landing page. Path relative to site root.
# Default: pages/home.md
nav-position: left # Sidebar position. left or right. Default: left.
# Only applies when navigation: sidebar.
search: true # Show the search box. Set to false to hide it.
# Default: true (search is on unless explicitly disabled).
```
---
## Theme mode
```yaml
default-theme: system # Starting colour mode. light, dark, or system.
# system follows the user's OS preference.
# Default: system.
```
---
## Layout overrides
These can also be set in theme.yml. Values here apply on top of theme.yml.
```yaml
main-width: 80em # Maximum width of the content column. Any CSS length unit.
nav-width: 20em # Width of the sidebar. Any CSS length unit.
```
---
## Typography overrides
These can also be set in theme.yml. Values here apply on top of theme.yml.
```yaml
font-body: "bunny:Noto Sans:400" # Body font. Format: "provider:Font Name:weight"
font-title: "bunny:Noto Sans:700" # Heading font.
font-code: "bunny:Fira Code:400" # Code font.
```
Provider options: `bunny` (GDPR-safe) or `google`. Omitting the provider defaults to Bunny Fonts.
---
## Localisation
```yaml
language: en # BCP 47 language code. Used for date formatting fallback.
date: system # Date format for post dates. system uses the browser locale.
# Or provide a format string, e.g. DD.MM.YYYY
time: system # Time format. system uses the browser locale.
monthnames: January, February, March, April, May, June, July, August, September, October, November, December
# Override month names (comma-separated, 12 values).
monthnamesabbreviated: Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
# Override abbreviated month names (comma-separated, 12 values).
pagenotfoundmessage: "Page not found." # Message shown when a page cannot be loaded.
```
---
## PWA (Progressive Web App)
`mdcms build` generates `manifest.json` and `service-worker.js` when `pwa: yes`.
```yaml
pwa: yes # Enable PWA. Generates manifest.json and service-worker.js on build.
pwa-name: "My Site" # Full app name shown on the install prompt and splash screen.
# Required when pwa: yes.
pwa-shortname: "MySite" # Short name for home screen icon labels (keep under ~12 chars).
# Falls back to pwa-name if omitted.
pwa-colour: "#2563EB" # Browser chrome colour (address bar on Android Chrome).
offline-message: "You are offline. Connect and reload."
# Shown when a page cannot be fetched and no cached version exists.
# Supports per-language values (see below).
```
Multi-language offline message:
```yaml
offline-message:
en: "You are offline. Connect and reload."
nb: "Du er frakoblet. Koble til og last inn på nytt."
```
The renderer picks the entry matching the active category's language, then falls back to `en`, then the first entry.
Requires a `favicon.png` (192×192 px recommended) at `assets/images/favicon.png` for the PWA install icon.
---
## Categories
Categories allow one site to serve multiple language or audience variants of the same content. Each page can have a variant per category using the filename suffix convention (`page.nb.md` for Norwegian).
```yaml
categories-use: yes # Enable the category system. Default: no.
default-category: # The category used when no ?cat= parameter is in the URL.
code: en # Short code. Used in filenames (page.en.md) and URL params.
name: English # Display name shown in the category dropdown list.
message: English # Label shown on the selector bar (trigger button). Falls back to name.
name-latin: English # Secondary label shown in the dropdown alongside name. Use when name
# is in a non-Latin script (e.g. Arabic, Devanagari) to aid recognition.
# Omit if name is already Latin or identical to name.
direction: ltr # Text direction. ltr or rtl. Default: ltr.
# rtl flips the nav position and content text direction.
notfoundmessage: "Not available in this language"
# Short note shown in the dropdown when no variant exists for the
# current page. Also enables fallback: the renderer will fall back to
# the default-category content instead of hiding the page.
# Omit to hide the category from the dropdown when no variant exists.
visibilityifnocontent: hidden # hidden (default) or visible.
# hidden: category disappears from the selector when no variant exists
# for the current page (unless notfoundmessage is also set).
# visible: category stays in the selector regardless. When the user
# navigates to a page with no variant, pagenotfoundmessage is shown
# in the content area. No fallback to default-category content.
pagenotfoundmessage: "This page is not yet available in English."
# Message shown in the content area when a page cannot be fetched for
# this category. Overrides the top-level pagenotfoundmessage.
font: NotoNastaliqUrdu-Regular.ttf
# Font filename inside assets/fonts/. Loaded on demand when this
# category is activated. Useful for scripts that need a specific font.
line-height: 2.8 # Line height override for this category. Useful for scripts like
# Nastaliq that need extra vertical space. Restores to theme default
# when switching away.
categories: # Additional categories. Each entry supports the same keys as
# default-category above.
- code: nb
name: Norsk
direction: ltr
- code: ar
name: عربي
name-latin: Arabic
direction: rtl
notfoundmessage: "غير متاح"
font: NotoNastaliqUrdu-Regular.ttf
line-height: 2.8
categories-sectionnames: same # How section names are shown per category.
# same: all categories share one section name (defaultname in nav.yml).
# per-category: each section has a name per category (categorynames in nav.yml).
categories-selecticon: globe # Icon shown in the category selector bar. SVG name from assets/icons/.
categories-selecttext: "Language" # Label shown next to the icon in the category selector bar.
```
### Per-category keys summary
| Key | Required | Description |
|---|---|---|
| `code` | Yes | Short identifier used in filenames (`page.nb.md`) and the `?cat=` URL param. |
| `name` | Yes | Display name shown in the dropdown list. |
| `message` | No | Label shown on the selector trigger button. Falls back to `name`. |
| `name-latin` | No | Secondary label in the dropdown, shown alongside `name` when `name` uses a non-Latin script. |
| `direction` | No | `ltr` or `rtl`. Default: `ltr`. RTL flips nav and content direction. |
| `notfoundmessage` | No | Short note shown in the dropdown when no variant exists for the current page. Also enables fallback to default-category content. |
| `visibilityifnocontent` | No | `hidden` (default) or `visible`. `visible` keeps the category in the selector when no variant exists; navigating to it shows `pagenotfoundmessage` with no fallback to default content. |
| `pagenotfoundmessage` | No | Message shown in the content area when a page cannot be fetched for this category. Overrides the top-level `pagenotfoundmessage`. |
| `font` | No | Font filename from `assets/fonts/`. Loaded on demand when this category is activated. |
| `line-height` | No | Body line height override for this category. Restores to theme default when switching away. |
---
## Reusable callout messages
Define named messages in config.yml and reference them in markdown with `message: <key>`. Useful for standard notices (e.g. AI translation warnings) used across many pages.
```yaml
callouts:
aitranslation: # Key name — used as message: aitranslation in markdown.
type: warning # Callout type: info, warning, success, error.
en:
title: "PLEASE NOTE:"
text: This page has been translated with artificial intelligence.
nb:
title: "VENNLIGST MERK:"
text: Denne siden er maskinoversatt.
```
Usage in a markdown page:
````markdown
```mdcms
callout-warning
message: aitranslation
```
````
---
## Full example
```yaml
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.4 — Site configuration
sitename: My Documentation
navigation: sidebar
theme: theme.yml
logo: logo.png
favicon: favicon.png
sitedescription: Reference documentation for My Project
footer: "© 2026 My Name — [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)"
homepage: pages/home.md
nav-position: left
search: true
default-theme: system
pwa: yes
pwa-name: "My Documentation"
pwa-shortname: "MyDocs"
pwa-colour: "#2563EB"
offline-message:
en: "You are offline. Connect to the internet and reload."
nb: "Du er frakoblet. Koble til og last inn på nytt."
language: en
pagenotfoundmessage: "Please select a page to continue."
categories-use: yes
default-category:
code: en
name: English
direction: ltr
categories:
- code: nb
name: Norsk
direction: ltr
visibilityifnocontent: visible
pagenotfoundmessage: "Denne siden er ikke tilgjengelig på norsk ennå."
- code: ar
name: عربي
name-latin: Arabic
direction: rtl
notfoundmessage: "غير متاح"
pagenotfoundmessage: "هذه الصفحة غير متاحة."
font: NotoNastaliqUrdu-Regular.ttf
line-height: 2.8
categories-sectionnames: same
categories-selecticon: globe
categories-selecttext: "Language"
callouts:
aitranslation:
type: warning
en:
title: "PLEASE NOTE:"
text: This page has been translated with artificial intelligence. It has not been reviewed by staff.
nb:
title: "VENNLIGST MERK:"
text: Denne siden er maskinoversatt og ikke gjennomgått av redaksjonen.
```

View file

@ -1,185 +0,0 @@
# nav.yml reference
`nav.yml` is generated by `mdcms build`. Do not write it from scratch — run the build command and edit the result.
```
python3 mdcms.py build --path app/
```
**What is preserved on rebuild:** All manual edits to section metadata fields (`defaultname`, `sort`, `parent`, `parent-sort`, `pagesvisibility`, `categorynames`) survive a rebuild. Page entries are re-generated from frontmatter each time.
**What is overwritten on rebuild:** Page `title`, `sort`, `section-id` — these are always taken from frontmatter.
---
## Top-level structure
```yaml
sections:
- code: my-section
defaultname: My Section
sort: 100
pagesvisibility: visible
pages:
- file: pages/my-page.md
title: My Page
section-id: my-section
sort: 100
```
---
## sections
Each section groups a set of pages under a heading in the nav. Sections are auto-created from `section-id` values found in page frontmatter.
```yaml
sections:
- code: guides # Internal identifier. Set via section-id in page frontmatter.
# Letters, numbers, hyphens, underscores. Not shown to users.
defaultname: Guides # Display name shown in the nav.
# Auto-generated from code on first build; edit freely after.
sort: 100 # Controls section ordering in the nav (lower = higher).
# Sections without a sort value sort to the bottom.
pagesvisibility: visible # Controls page visibility for this section.
# visible — pages appear in nav and search (default).
# hidden — pages are hidden from nav but included in search.
# draft — pages hidden from nav AND excluded from search.
parent: getting-started # Optional. Makes this section a child of another section.
# Value is the code of the parent section.
parent-sort: 50 # Sort position of this section within its parent.
# Required when parent is set.
categorynames: # Per-category section display names.
# Required when categories-sectionnames: per-category in config.yml.
en: Guides
nb: Veiledninger
ar: أدلة
```
### Section example — nested
```yaml
sections:
- code: getting-started
defaultname: Getting Started
sort: 100
pagesvisibility: visible
- code: installation
defaultname: Installation
sort: 100
pagesvisibility: visible
parent: getting-started
parent-sort: 10
- code: configuration
defaultname: Configuration
sort: 200
pagesvisibility: visible
parent: getting-started
parent-sort: 20
```
---
## pages
Page entries are generated from markdown frontmatter. The fields below are written by the build and read by the renderer.
```yaml
pages:
- file: pages/home.md # Path relative to site root. Written by build — do not change.
title: Home # Page title. Taken from frontmatter title: on each build.
section-id: guides # Assigns the page to a section. Taken from frontmatter.
# Omit to leave the page unsectioned (appears above all sections in nav).
sort: 100 # Nav ordering within its section (lower = higher).
# Taken from frontmatter sort: on each build.
```
**Note:** Pages are sorted within their section by `sort`, then alphabetically by filename as a tiebreaker. Draft pages (frontmatter `draft: true`) are excluded entirely from `nav.yml` and `search.json`.
---
## How sections and pages connect
1. Add `section-id: my-section` to a page's frontmatter.
2. Run `mdcms build` — the section is auto-created in `nav.yml` with `defaultname` derived from the code.
3. Edit `defaultname`, `sort`, `pagesvisibility` in `nav.yml` as needed. These edits are preserved on future rebuilds.
---
## Full example
```yaml
# nav.yml — generated by mdcms
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
# pagesvisibility, categorynames) are preserved on rebuild.
sections:
- code: getting-started
defaultname: Getting Started
sort: 100
pagesvisibility: visible
- code: reference
defaultname: Reference
sort: 200
pagesvisibility: visible
- code: advanced
defaultname: Advanced Topics
sort: 300
pagesvisibility: hidden # pages exist but are not shown in the nav
- code: changelog
defaultname: Changelog
sort: 400
pagesvisibility: visible
parent: reference
parent-sort: 99
pages:
- file: pages/home.md
title: Home
sort: 100
- file: pages/quickstart.md
title: Quick Start
section-id: getting-started
sort: 100
- file: pages/install.md
title: Installation
section-id: getting-started
sort: 200
- file: pages/config-reference.md
title: Configuration Reference
section-id: reference
sort: 100
- file: pages/api.md
title: API Reference
section-id: reference
sort: 200
- file: pages/internals.md
title: Internals
section-id: advanced
sort: 100
- file: pages/v04.md
title: v0.4
section-id: changelog
sort: 100
```

View file

@ -1,324 +0,0 @@
# Page reference — frontmatter and mdcms tags
All keys you can use inside a markdown page in `pages/` or `posts/`.
A page has two parts:
````markdown
---
# Frontmatter (YAML, optional except for title)
title: My Page
---
Markdown body goes here.
```mdcms
toc
```
Regular markdown, plus mdcms code blocks for callouts, table of contents, post lists.
````
---
## Frontmatter
The YAML block delimited by `---` at the top of the file. Read by `mdcms build` to populate `nav.yml` and `search.json`, and by `index.html` at runtime to set the page title, dates, and meta tags.
```yaml
---
title: Page Title # REQUIRED. Browser tab title, nav label, h1 fallback.
# Without this, the page is skipped from nav.yml.
sort: 100 # Position in the nav within its section. Lower = higher.
# Default: 100. Tiebreaker is filename.
section-id: guides # Assigns this page to a section. Must match (or auto-create)
# a code: in nav.yml. Omit to leave unsectioned.
draft: true # Excludes the page from nav.yml AND search.json.
# Default: false.
author: Jane Doe # Shown in the meta line under the page title (pages with author or created).
created: 2026-05-18 14:30 # Publish date. Format: YYYY-MM-DD or YYYY-MM-DD HH:MM.
# Required for posts to appear in posts-* tag listings.
# Used as the sort key in chronological/reverse-chronological lists.
modified: 2026-05-19 09:15 # Last-modified date. Shown next to created date if set.
description: Short summary # Used for the <meta name="description"> tag.
# Falls back to config.yml sitedescription if omitted.
keywords: foo, bar, baz # Comma-separated. Indexed in search.json.
language: en # BCP 47 code. Sets the <html lang=""> attribute when this page is loaded.
# Doesn't filter pages — that's what categories are for.
---
```
**Category variants** are not a frontmatter field — they are encoded in the filename. `about.nb.md` is the Norwegian variant of `about.md`, provided `nb` is declared in `config.yml` under `categories:`.
---
## mdcms code blocks
Fenced blocks with the `mdcms` language tag are intercepted by the renderer and replaced with dynamic HTML. The tag name goes either on the fence line or on the first line of the block:
````markdown
```mdcms callout-info
title: Heads up
This is the body.
```
````
…is equivalent to:
````markdown
```mdcms
callout-info
title: Heads up
This is the body.
```
````
Inside the block, lines matching `key: value` are parsed as options. The first non-matching line begins the body.
---
### Callout tags — `callout-info`, `callout-warning`, `callout-success`, `callout-error`
A bordered, tinted box for notes, warnings, success messages, errors. Colour and icon come from `theme.yml` (`callouts:` block); fall back to built-in defaults.
````markdown
```mdcms callout-info
title: Note # Optional. Bold title row with icon. Omit for a body-only callout.
icon: lightbulb # Optional. Override the default icon. Use an SVG name from assets/icons/.
message: aitranslation # Optional. Resolves title + body from config.yml callouts: block.
# Takes precedence over inline title/body.
Body text supports **full markdown** — bold, *italics*, `code`,
[links](https://example.com), lists, etc.
- item one
- item two
```
````
**Behaviour:**
- Type comes from the tag name suffix (`info`/`warning`/`success`/`error`).
- `message: <key>` looks up the named block in `config.yml`. When matched, the message's title and body override any inline values. The message's `type:` also overrides the tag type.
- For multi-language messages, the renderer picks the entry for the active category, then the default category, then the first key.
---
### Table of contents — `toc`
Renders a section-grouped, sorted list of all visible non-draft pages in the active category. The page containing the tag is excluded.
````markdown
```mdcms
toc
```
````
No options. Output is grouped by nav section in section sort order; pages within each section follow their own `sort:`.
---
### Post listings — `posts-created-*`
Generate a chronologically sorted list of posts (files in `posts/`). Requires each post to have a `created:` value in frontmatter.
**Reliable variants** (others are broken — do not use):
```
posts-created-chronological-byyearmonth
posts-created-reversechronological
```
The grammar:
```
posts-created-<order>[-<modifier>]
order: chronological | reversechronological
modifier: byyear | byyearmonth | lastyear | lastmonth (optional)
```
- `byyear` / `byyearmonth` — group output by year, or by year-and-month.
- `lastyear` / `lastmonth` — filter to posts from the last 365/30 days.
- No modifier — flat list of all posts.
````markdown
```mdcms
posts-created-reversechronological
limit: 10 # Max number of posts shown. Default: all.
# When paginate: yes, this is the page size (batch size).
paginate: yes # Pagination mode:
# yes — show a "load more" button after batchSize posts.
# none — show only the first <limit> posts, no pagination.
# no — show all posts at once (default).
```
````
**Category filtering:** When `categories-use: yes`, the listing automatically filters to the active category.
---
### Tabs — `tab-underline`, `tab-filled`, `tab`
A horizontal tab strip with a single visible content panel. The active tab is set with `default: selected`; if no item carries that value the first item is selected automatically.
| Tag name | Appearance |
|---|---|
| `tab-underline` | Labels in a row; active tab marked with a 2 px underline in the accent colour. |
| `tab` | Alias for `tab-underline`. |
| `tab-filled` | Each label is a chip with a filled background; active chip inverts to the page background with an accent border. |
The body of the block is YAML. It must start with `items:` followed by a list of item objects.
````markdown
```mdcms tab-underline
items:
- title: npm
default: selected
content: |
```bash
npm install mdcms
```
- title: pnpm
content: |
```bash
pnpm add mdcms
```
- title: yarn
content: |
```bash
yarn add mdcms
```
```
````
**Per-item keys:**
| Key | Required | Notes |
|---|---|---|
| `title` | yes | Label on the tab button. Plain text only. |
| `content` | yes | Tab panel body. Full Markdown, use `\|` for multi-line. |
| `default` | no | `selected` — open on load. If no item is `selected`, the first item is used. |
| `title-style` | no | Heading level for screen readers. One of `"#"``"######"` or `""` (default). Does not affect visual size. |
---
### Accordions — `accordion-underline`, `accordion-filled`, `accordion`
Stacked collapsible items. Each item has a clickable header and a body that expands below it. Any number of items can be open simultaneously.
| Tag name | Appearance |
|---|---|
| `accordion-underline` | Header separated from the content by a 2 px bar in the accent or nav colour; open content has a matching 1 px border on three sides. |
| `accordion` | Alias for `accordion-underline`. |
| `accordion-filled` | Closed header is a filled chip; when open the item becomes a single bordered card with the header fill at the top and the page background below. |
````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.
```
````
**Per-item keys:**
| Key | Required | Notes |
|---|---|---|
| `title` | yes | Header label. Plain text only. |
| `content` | yes | Body shown when expanded. Full Markdown, use `\|` for multi-line. |
| `default` | no | `open` — expanded on load. `closed` or omitted — collapsed. Multiple items may be `open`. |
| `title-style` | no | Heading level for screen readers. One of `"#"``"######"` or `""` (default). Does not affect visual size. |
**How the colour adapts to themes:** The bar/border colour and the chip fill are derived automatically from the active theme. On themes where the sidebar background is visually distinct from the page (dark nav on a light page, or a coloured nav), the components use the nav colour as their fill. On subtle themes where sidebar and page backgrounds are near-identical, the accent colour is used instead. No per-theme config is needed.
---
## Markdown features
Standard CommonMark plus GFM (GitHub-flavoured) extensions:
- Tables
- Strikethrough (`~~text~~`)
- Task lists (`- [ ]` / `- [x]`)
- Fenced code blocks with syntax language hints (`` ```python ``)
- Autolinks
**Raw HTML** passes through to the DOM. You can embed HTML directly:
```markdown
<meta http-equiv="refresh" content="0; url=docs/">
```
**Scripts injected via `<script>` tags in markdown do not execute** — the renderer uses `innerHTML`, which browsers block from running script tags. Use `<meta http-equiv="refresh">` for redirects.
**Links to other pages** can use either:
```markdown
[Docs](pages/docs.md) # Internal link — rewritten to a client-side route.
[External](https://example.com) # External — opens in new tab automatically.
```
---
## Full example
````markdown
---
title: Quick Start
sort: 100
section-id: getting-started
author: Jane Doe
created: 2026-05-18 14:30
description: How to install and run MD-CMS in five minutes.
keywords: install, setup, quickstart
---
# Quick start
Welcome. This page walks you through installing MD-CMS.
```mdcms callout-info
title: Before you begin
Make sure you have Python 3.9 or newer.
```
## Table of contents
```mdcms
toc
```
## Recent posts
```mdcms
posts-created-reversechronological
limit: 5
paginate: yes
```
## Translation notice
```mdcms callout-warning
message: aitranslation
```
````

View file

@ -1,293 +0,0 @@
# theme.yml reference
`theme.yml` controls the visual presentation of your site. It is separate from `config.yml` so you can update colours and fonts without touching site settings, and vice versa. `index.html` loads it at runtime — no build step required.
Point `config.yml` at it with:
```yaml
theme: theme.yml
```
---
## Colours — light and dark mode
`light` and `dark` are the two mode blocks. Both accept the same keys. mdcms switches between them based on the user's system preference or the theme toggle.
### Base colours
```yaml
light:
accent: "#2563EB" # Primary accent — links, active nav item, focus rings.
background: "#FFFFFF" # Main content area background.
nav-background: "#F8FAFC" # Sidebar / topbar background.
text: "#1E293B" # Body text colour.
text-muted: "#64748B" # Secondary text — descriptions, timestamps, captions.
divider: "#CBD5E1" # Border and hr colour.
# Omit to auto-derive: color-mix(background 85%, text).
dark:
accent: "#60A5FA"
background: "#0F172A"
nav-background: "#1E293B"
text: "#F1F5F9"
text-muted: "#94A3B8"
divider: "#334155" # Omit to auto-derive.
```
All values are CSS colour strings (hex, `rgb()`, `hsl()`, named colours, `rgba()`).
### Nav colours
These control every element inside the sidebar. When `nav-background` is a neutral near-white or near-black the defaults (which fall back to the base colours) work fine and can be omitted. Set them explicitly whenever `nav-background` is a saturated brand colour, a dark sidebar in an otherwise light design, or any noticeably tinted background.
```yaml
light:
# Nav links and section labels
nav-link: "#1E293B" # Inactive nav link text. Defaults to text.
nav-link-active: "#2563EB" # Active (current page) nav link. Defaults to accent.
nav-section-heading: "#64748B" # Section label text (uppercase, small). Defaults to text-muted.
# Sidebar header elements
nav-sitename: "#1E293B" # Site name in the sidebar header. Defaults to nav-link.
nav-description: "#64748B" # Site description below the site name. Defaults to nav-section-heading.
nav-toggle: "#64748B" # Dark/light mode toggle button. Defaults to nav-section-heading.
```
The same keys apply in the `dark:` block.
**When nav-background is a bold colour** (e.g. brand red, navy, deep green), set all six nav keys explicitly so links, labels, the site name, description, and toggle are all legible against the nav background. A common pattern is white (`#FFFFFF`) for `nav-link`, `nav-link-active`, and `nav-sitename`, and a semi-transparent white (e.g. `rgba(255,255,255,0.65)`) for `nav-section-heading`, `nav-description`, and `nav-toggle`.
---
## Semantic colours
Used by callout tags (`callout-info`, `callout-warning`, `callout-success`, `callout-error`). `colours-semantic` applies to both modes. `colours-semantic-dark` overrides for dark mode only — use lighter, more luminous variants so callout borders and tinted backgrounds remain legible on dark page backgrounds.
```yaml
colours-semantic:
info: "#2563EB"
warning: "#D97706"
success: "#16A34A"
error: "#DC2626"
colours-semantic-dark:
info: "#60A5FA" # Lighter blue — visible on dark background
warning: "#F59E0B"
success: "#34D399"
error: "#F87171"
```
If `colours-semantic-dark` is omitted, the `colours-semantic` values are used in both modes.
---
## Callout defaults
Overrides the icon and colour used for each callout type. `primary-colour` sets the left border; `background-colour` sets the tinted background fill (applied at ~8% opacity by the renderer).
```yaml
callouts:
info:
icon: info # SVG icon name from assets/icons/ (without .svg)
primary-colour: "#2563EB" # Left border colour
background-colour: "#2563EB" # Background tint colour
warning:
icon: warning
primary-colour: "#D97706"
background-colour: "#D97706"
success:
icon: success
primary-colour: "#16A34A"
background-colour: "#16A34A"
error:
icon: error
primary-colour: "#DC2626"
background-colour: "#DC2626"
```
Individual callout blocks in markdown can override the icon with `icon: <name>`.
---
## Nav section toggle icons
Sections with `pagesvisibility: hidden` in `nav.yml` are collapsible. These two top-level keys (not inside `light:`/`dark:`) set the icons used for the toggle.
```yaml
nav-section-expand-icon: arrow_right # Shown when section is collapsed. Default: arrow_right.
nav-section-collapse-icon: arrow_drop_down # Shown when section is expanded. Default: arrow_drop_down.
```
Available icon names:
| Expand | Collapse | Style |
|---|---|---|
| `arrow_right` | `arrow_drop_down` | Solid filled triangles (default) |
| `keyboard_arrow_right` | `keyboard_arrow_down` | Chevrons — lighter, more modern |
| `keyboard_double_arrow_right` | `keyboard_double_arrow_down` | Double chevrons — emphatic |
| `expand_content` | `collapse_content` | Corner arrows — editorial |
| `add` | `minimize` | Plus / minus — minimal |
The two keys are independent — expand and collapse do not have to use icons from the same pair, though keeping a consistent visual weight reads better.
---
## Typography
Font format: `"provider:Font Name:weight"`
- `provider`: `bunny` (privacy-friendly, GDPR-safe) or `google`
- `Font Name`: exact font family name as listed on the font provider
- `weight`: numeric CSS weight (`400`, `700`, etc.)
```yaml
font-body: "bunny:Noto Sans:400" # Body text font
font-heading: "bunny:Noto Sans:700" # Headings (h1h6) font
font-code: "bunny:Fira Code:400" # Code blocks and inline code
```
System fonts (no external request):
```yaml
font-body: "system-ui:400"
```
Shorthand without provider defaults to Bunny Fonts:
```yaml
font-body: "Noto Sans:400" # equivalent to bunny:Noto Sans:400
```
Size and spacing:
```yaml
font-size: 1.0 # Unitless multiplier. 1.0 = 16px base. 1.125 = 18px.
line-height: 1.7 # Unitless multiplier for body text line spacing.
```
---
## Layout
```yaml
main-width: 80em # Maximum width of the content column. Any CSS length unit.
nav-width: 20em # Width of the sidebar. Any CSS length unit.
```
---
## Full example
```yaml
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.4 — Theme configuration
light:
accent: "#2563EB"
background: "#FFFFFF"
nav-background: "#F8FAFC"
text: "#1E293B"
text-muted: "#64748B"
# nav-link, nav-link-active, nav-section-heading, nav-sitename,
# nav-description, nav-toggle — omit when nav-background is neutral.
# divider — omit to auto-derive from background + text.
dark:
accent: "#60A5FA"
background: "#0F172A"
nav-background: "#1E293B"
text: "#F1F5F9"
text-muted: "#94A3B8"
colours-semantic:
info: "#2563EB"
warning: "#D97706"
success: "#16A34A"
error: "#DC2626"
colours-semantic-dark:
info: "#60A5FA"
warning: "#F59E0B"
success: "#34D399"
error: "#F87171"
callouts:
info:
icon: info
primary-colour: "#2563EB"
background-colour: "#2563EB"
warning:
icon: warning
primary-colour: "#D97706"
background-colour: "#D97706"
success:
icon: success
primary-colour: "#16A34A"
background-colour: "#16A34A"
error:
icon: error
primary-colour: "#DC2626"
background-colour: "#DC2626"
# nav-section-expand-icon: arrow_right
# nav-section-collapse-icon: arrow_drop_down
font-body: "bunny:Noto Sans:400"
font-heading: "bunny:Noto Sans:700"
font-code: "bunny:Fira Code:400"
font-size: 1.0
line-height: 1.7
main-width: 80em
nav-width: 20em
```
---
## Bold nav background example
When `nav-background` matches or is close to `accent`, set all nav colour keys explicitly:
```yaml
light:
accent: "#D00C33"
background: "#FFFFFF"
nav-background: "#D00C33"
text: "#1A1A1A"
text-muted: "#5C5C5C"
nav-link: "#FFFFFF"
nav-link-active: "#FFFFFF"
nav-section-heading: "rgba(255,255,255,0.65)"
nav-sitename: "#FFFFFF"
nav-description: "rgba(255,255,255,0.65)"
nav-toggle: "rgba(255,255,255,0.65)"
dark:
accent: "#D00C33"
background: "#0A0A0A"
nav-background: "#000000"
text: "#FFFFFF"
text-muted: "#A89090"
nav-link: "#E2E2E2"
nav-link-active: "#FFFFFF"
nav-section-heading: "#888888"
nav-sitename: "#FFFFFF"
nav-description: "#888888"
nav-toggle: "#888888"
colours-semantic:
info: "#D00C33"
warning: "#B26A1F"
success: "#2F7A4A"
error: "#D00C33"
colours-semantic-dark:
info: "#FF6B6B"
warning: "#F59E0B"
success: "#34D399"
error: "#FF6B6B"
```

View file

@ -1,277 +0,0 @@
# 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 <type>`. 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 `<h2>` 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|2L1|)`) rather than raw HSL saturation, which would give false "bold" readings for near-white or near-black nav backgrounds.
---
## `mdcms build` patches `<title>` 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 download and URL-based register (`mdcms.py`, `app/mdcms.json`)
`mdcms build` now writes `mdcms.json` to the site root on every build. `mdcms register` can accept a GitHub repo URL or a plain HTTPS URL as the source to download from.
### `mdcms build` writes `mdcms.json`
At the end of each build, `generate_site_manifest()` walks the site directory, lists every non-hidden file (excluding `mdcms.json` itself), records any empty directories, and writes `mdcms.json`. This file is deployed alongside the rest of the site — it is the machine-readable index of what the site contains.
Format:
```json
{
"mdcms": "0.4",
"files": ["index.html", "config.yml", "assets/icons/add.svg", ...],
"dirs": ["assets/fonts", "posts"]
}
```
`files` — all deployable files, paths relative to the site root.
`dirs` — empty directories to create on download (no file needed to keep them alive).
### `mdcms register` accepts URLs
`PATH` can now be a GitHub repo URL or a plain HTTPS URL pointing to a deployed mdcms site. A `--from URL` option is also available as an explicit override.
```
mdcms register mysite # existing behaviour
mdcms register mysite ./mydir # local path
mdcms register mysite https://github.com/owner/repo # GitHub repo
mdcms register mysite https://github.com/owner/repo/tree/main/subdir
mdcms register mysite --from https://example.com/mysite # deployed site
```
**GitHub URL** — tries `mdcms.json` from the raw content URL first; falls back to the GitHub Contents API tree-walk if no manifest is found.
**Plain HTTPS URL** — fetches `{url}/mdcms.json`; if not found, reports an error with guidance.
### `app/mdcms.json`
The starter template now ships with its own `mdcms.json`. This means `mdcms register mysite https://github.com/kbenestad/mdcms/tree/main/app` works via the manifest path with no API calls.
### `_http_get` / `_http_get_github`
`_http_get(url)` — generic SSL-verified GET, no vendor headers. Used for raw file downloads and manifest fetches.
`_http_get_github(url)` — adds `Accept: application/vnd.github.v3+json` for Contents API responses (only needed in the fallback tree-walk path).
---
## 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.

View file

@ -1,52 +0,0 @@
# Setting up MD-CMS for your site
This document walks you through the installation of MD-CMS.
## Minimum install
The bare minimum required to run MD-CMS is to download the content in [**app/**](https://github.com/kbenestad/mdcms/tree/main/app) and upload the files and folders to any web-server.
## Recommended install
To properly use MD-CMS you need to download the CLI tool.
### Linux
To download MD-CMS for Linux, you need to run the appropriate command below in the terminal. Verify which version you have installed by running `mdcms --version`.
#### Debian and Debian-based distros (including Ubuntu)
The .deb package handles all installation details. To download and install, run:
```
curl -fsSLO https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/linux/mdcms.deb && sudo dpkg -i mdcms.deb
```
#### All other Linux distros
For all other Linux distros, please run the following command in the terminal:
```
sudo curl -fsSL https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/linux/mdcms -o /usr/local/bin/mdcms && sudo chmod +x /usr/local/bin/mdcms
```
This command fetches the latest binary, moves it to `/usr/local/bin/mdcms` and makes it executable in one go.
### MacOS
Open terminal and run this command to install `mdcms`:
```
sudo curl -fsSL https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/macos/mdcms -o /usr/local/bin/mdcms && sudo chmod +x /usr/local/bin/mdcms
```
MacOS may block the binary on first run ("cannot be opened because the developer cannot be verified"). If so, run the following command:
```
sudo xattr -d com.apple.quarantine /usr/local/bin/mdcms
```
once to clear it. Verify which version you have installed by running `mdcms --version`.
### Windows
In Windows 10 or 11, open PowerShell and run the following command:
```
Invoke-WebRequest https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/windows/mdcms.exe -OutFile "$env:USERPROFILE\AppData\Local\Microsoft\WindowsApps\mdcms.exe"
```
Verify which version you have installed by running `mdcms --version`.
## Update
MD-CMS consists of two separate pieces of software: The CLI tool (which you run from the terminal) and the renderer (the index.html file, which the browser reads). To update the CLI, simply rerun the installation command and overwrite `mdcms`. To update the renderer, download the latest index.html and overwrite it in your sites.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1890
mdcms.py

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mdcms"
version = "0.6.0"
description = "MD-CMS — Markdown-based CMS companion CLI"
readme = "README.md"
license = { text = "Apache-2.0" }
authors = [{ name = "Kristian Benestad" }]
requires-python = ">=3.9"
dependencies = [
"click>=8.0",
"PyYAML>=6.0",
"certifi>=2024.0",
]
[project.scripts]
mdcms = "mdcms:main"
[project.urls]
Homepage = "https://github.com/kbenestad/mdcms"
Documentation = "https://docs.benestad.net"
[tool.setuptools]
py-modules = ["mdcms"]

View file

@ -0,0 +1,22 @@
# MD-CMS — Known limitations
MD-CMS is under active development.
## mdcms.py only targets `/website` directory inside a project directory
You can run `mdcms.py` from anywhere and define multiple projects. However, the current version excepts the website itself to live inside a `/website` directory inside your project directory.
In other words, you cannot keep the files in
- `/home/username/mdcmssites/site-1` and
- `/home/username/mdcmssites/site-2`
they must be in
- `/home/username/mdcmssites/site-1/website` and
- `/home/username/mdcmssites/site-2/website`.
## mdcms tags for posts
The tags that lists posts are broken. Currently, the only tags that reliably show posts are:
- `posts-datetime-chronological-byyearmonth`
- `posts-datetime-reversechronological`

34
resources/quickstart.md Normal file
View file

@ -0,0 +1,34 @@
# MD-CMS — Quickstart
A lightweight Markdown-based CMS. Content is written as plain Markdown with YAML frontmatter; configuration lives in `website/config.yml`; navigation and search are generated by `mdcms.py`.
## First run
1. Put content into `website/pages/` and `website/posts/`.
2. Edit `website/config.yml` with your site name and preferences.
3. From this directory, run `python3 mdcms.py`.
4. In the menu, pick option **7** to register the website path (point it at the `website/` folder).
5. Pick option **3** to build `nav.yml` and `search.json`.
6. Pick option **8** to start a local webserver and preview the site.
## File layout
```
mdcms.py
quickstart.md
website/
assets/
fonts/
images/
pages/
home.md
posts/
index.html
config.yml
nav.yml (generated)
search.json (generated)
```
## Licence
Apache 2.0. See <https://kbenestad.codeberg.page/md-cms> for documentation.

View file

@ -1,12 +0,0 @@
## Add folder info using this document
* The contents of a **Readme.md** will show up embedded on the top of the folder it is in (in the web interface and the mobile apps)
* Formatting is supported with the bar on top (using Markdown)
* It uses Nextcloud Text so you can collaborate on it 🎉
* You can use and remix the templates as you like, they are in the public domain via the [CC0 license](https://creativecommons.org/publicdomain/zero/1.0/)
## Action items
* [ ] Try out the new templates
* [ ] Add your own templates in this folder
* [ ] …

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,6 +0,0 @@
# mdcms v0.3 | DO NOT REMOVE THIS COMMENT
sitename: The Kitchen Table
sitedescription: Recipes, techniques, and stories from Amelia Fontaine
navigation: topbar
search: true
footer: "© 2026 Amelia Fontaine · The Kitchen Table"

File diff suppressed because it is too large Load diff

View file

@ -1,50 +0,0 @@
# nav.yml — generated by mdcms.py
sections:
- code: site
defaultname: The Blog
sort: 100
pagesvisibility: visible
pages:
- file: pages/home.md
title: Welcome
section-id: site
sort: 100
variants: [en]
titles:
en: Welcome
- file: pages/about.md
title: About Amelia
section-id: site
sort: 110
variants: [en]
titles:
en: About Amelia
- file: pages/recipe-index.md
title: Recipe Index
section-id: site
sort: 120
variants: [en]
titles:
en: Recipe Index
- file: pages/techniques.md
title: Techniques
section-id: site
sort: 130
variants: [en]
titles:
en: Techniques
- file: pages/pantry.md
title: The Pantry
section-id: site
sort: 140
variants: [en]
titles:
en: The Pantry
- file: pages/kitchen-notes.md
title: Kitchen Notes
section-id: site
sort: 150
variants: [en]
titles:
en: Kitchen Notes

View file

@ -1,56 +0,0 @@
---
title: About Amelia
sort: 110
section-id: site
keywords: Amelia Fontaine, about, Lyon, Turin, cooking, Italian grandmother, French chef
description: Amelia Fontaine's story — growing up between Lyon and Turin, learning to cook from her grandmother and father, and why she started writing about food.
language: en
---
![Amelia's market in Lyon](assets/images/market.jpg)
# About Amelia
I grew up between two kitchens.
My mother's family is Italian, from Turin — the kind of Turin that is proud of its *gianduiotto* and its *bagna càuda* and the way Sunday lunch extends, inevitably, into Sunday afternoon. My grandmother Lucia kept a kitchen that operated more or less continuously: something was always soaking, something was always reducing, something was cooling on a rack. She baked her own bread until she was 82. She made her own pasta until she was 85. I do not know a person who cooked with more authority and less fuss.
My father is French. He trained as a chef in Lyon — the city that produced Paul Bocuse, Fernand Point, and the *mères lyonnaises*, the women who defined what French bourgeois cooking could be. He worked in professional kitchens for twelve years before deciding that he wanted a different life. He left the restaurant world, married my mother, and for as long as I can remember he cooked dinner every night as if he were still making something worth caring about.
Between the two of them, I received an education in food that took me years to understand the value of.
## Growing Up in Two Cuisines
In Italy, we cooked by season and by tradition. Lucia had dishes she made in autumn and dishes she made in spring, and the idea of making a pumpkin gnocchi in June would have struck her as slightly eccentric. Food was not meant to transcend its season; it was meant to celebrate it. Tomatoes in August were a different ingredient from tomatoes in January, and she treated them accordingly.
In France — or at least in my father's kitchen — technique was everything. Not in a cold, academic way; he was a home cook by the time I knew him, and the restaurant rigidity had softened. But there was always a *why*. Why do you sweat the onion before adding the liquid? Why do you deglaze the pan? Why do you not stir the risotto too fast? The questions were as much a part of cooking as the stirring and the chopping.
I spent my teenage summers in Lucia's kitchen and my school years in Lyon. I studied literature at the Université Lumière Lyon 2, which is where I discovered that I was more interested in food than in anything I was actually studying. I would cook for friends, obsessively, and I would stay up too late reading cookbooks and then wake up early to go to the market on the Quai Saint-Antoine.
## Cooking School and After
After my degree, I enrolled in a professional cooking programme in Paris. I did not want to be a chef — I had seen what the restaurant kitchen life took out of my father, and I knew I did not want it. But I wanted to understand technique at a level that home cooking had not given me. The programme was eight months of fundamentals: stocks, sauces, pastry, butchery, the French brigade system. I emerged with knife skills I had not had before, a better understanding of heat control, and a confirmed sense that my real interest was in home cooking rather than restaurant cooking.
After Paris, I spent time in Italy again — first with Lucia, then working in a trattoria in Bologna for a season, and then travelling through the south, which taught me that Italian food is a category containing enormous variety. The food of Puglia is not the food of Piedmont is not the food of Sicily. Each region has its own logic, its own pantry, its own idea of what a meal should be.
## Why I Started Writing
I began writing about food because I kept noticing that the recipes I was reading online were, very often, missing the interesting part. They gave you the ingredients and the steps, and if you were lucky they gave you some headnotes, but they rarely told you *why*. Why this method and not another? What should it look like at this stage? What does it mean when the sauce breaks, and how do you fix it?
I wanted to write the recipes I wished I had been given as a young cook — recipes that explained the reasoning, that described what you were looking for rather than just listing steps, that treated the reader as someone capable of understanding and not just following.
## What I Cook
My food is not particularly exotic. I cook Italian and French food, the food I grew up with, and other cuisines I have learned from books and travel and cooking with friends. I have a strong interest in the food of North Africa and the Levant, which shows up in some of my braised dishes. I bake bread twice a week. I keep a sourdough starter that is older than this blog.
I cook seasonally, not because I am precious about it but because seasonal produce tastes better and costs less. I use whole animals and whole fish when I can. I make stock on Sundays.
## About the Blog
I started writing here in 2024, posting once or twice a week. The posts fall into three rough categories: full recipes with technique explanations, shorter technique-focused pieces, and occasional essays about food and cooking. I test every recipe at least twice before I publish it.
I do not do sponsored content or paid partnerships. If I mention a product or a producer, it is because I use it and think it is worth telling you about.
The name comes from Lucia's kitchen in Turin. She had a kitchen table with a marble top, and everything happened at that table — pasta making, pastry, homework, wine in the evening. It was the centre of the house. I wanted to name the blog after that.
Come cook with me.

View file

@ -1,44 +0,0 @@
---
title: Welcome
sort: 100
section-id: site
keywords: cooking blog, home cooking, recipes, techniques, Amelia Fontaine, kitchen, food
description: The Kitchen Table — a home cooking blog by Amelia Fontaine. Recipes, techniques, and stories from a lifelong cook.
language: en
---
![The Kitchen Table](assets/images/hero.jpg)
# Welcome to The Kitchen Table
Pull up a chair. There is always something on the stove.
This is a blog about cooking — real cooking, in a real kitchen, with the kind of attention and care that makes meals memorable. I am Amelia Fontaine, and I have been cooking since I was tall enough to stand on a step stool beside my grandmother's range in Turin. Everything I know about food comes from people who cooked before me: my grandmother Lucia, who rolled pasta every Sunday morning without looking at a recipe; my father, who trained as a chef in Lyon and taught me that French technique is mostly about paying attention; and the long tradition of cooks who figured out, through taste and repetition and curiosity, how things work.
I started writing this blog because I wanted a place to share what I have learned — not just the recipes, but the reasoning behind them. Understanding *why* you roast bones before making stock, why you rest meat before slicing it, why you add pasta water to the sauce — that understanding changes how you cook. It makes you more confident, more adaptable, and more able to fix things when they go wrong.
## What You Will Find Here
**Recipes** — complete, tested recipes with actual measurements, actual steps, and actual explanations of what to look for at each stage. I do not round up to the nearest "a handful" and I do not omit the part where things can go wrong.
**Technique** — posts focused on a specific technique rather than a specific dish. How to roast, how to braise, how to make stock, how to achieve an emulsion. These are the foundation skills that make every recipe easier.
**Stories** — food without context is just fuel. I write about my grandmother's kitchen, about markets in Lyon in early spring, about the first time I got a hollandaise right. The stories are as much a part of cooking as the recipes.
**Kitchen Science** — I am fascinated by why things work. The Maillard reaction, the role of fat in emulsification, what happens to proteins when you cook them. Where the science helps explain the technique, I include it.
## Recent Posts
```mdcms
posts-datetime-reversechronological
limit: 10
paginate: yes
```
## A Note on Ingredients
I use European-style butter, good olive oil, and fresh ingredients as much as possible. I give metric measurements first with approximate imperial equivalents. Most things in cooking are forgiving; I will tell you when they are not.
Welcome to the table. I hope you find something here that you want to cook tonight.
— Amelia

View file

@ -1,83 +0,0 @@
---
title: Kitchen Notes
sort: 150
section-id: site
keywords: kitchen tips, equipment, seasonal produce, substitutions, cooking notes
description: Tips, equipment recommendations, notes on seasonal produce, and a substitutions guide for The Kitchen Table recipes.
language: en
---
# Kitchen Notes
Accumulated notes on equipment, seasonal produce, and practical matters that come up across the recipes on this blog. Updated regularly.
## Equipment I Actually Use
**Knives:** Three knives cover everything. A 20cm chef's knife is the most important; I use mine for probably 90% of all cutting tasks. A small paring knife (8cm) for fine work and peeling. A serrated bread knife. These three cover everything. I sharpen my chef's knife on a whetstone every two weeks and hone it before every use. See my knife skills post for the full guide.
I prefer German-style knives (heavier, more robust) for most tasks. Japanese knives are sharper and more precise but require more careful maintenance and are not forgiving with harder vegetables.
**Pans:**
- A 28cm stainless steel frying pan: for searing, making omelettes, pan sauces. Do not use non-stick for tasks that require high heat or where fond (browned bits) is needed.
- A 24cm non-stick frying pan: for eggs. That is mostly what a non-stick pan is for.
- A 30cm cast iron frying pan: for searing large pieces of meat, for cooking that goes from stovetop to oven.
- A 5-litre saucepan: for stocks, pasta water, soups.
- A 2-litre saucepan: for sauces.
- A 28cm or 30cm Dutch oven / cocotte: the most useful single piece of equipment in my kitchen. For braises, sourdough bread, soups, anything that goes in the oven.
**Other equipment I reach for constantly:**
- Kitchen scales: weight measurements are more accurate than volume for baking and are how professional recipes are written.
- An instant-read thermometer: for checking the internal temperature of roasts and bread. Removes the guesswork.
- A spider/skimmer: for pulling pasta, blanched vegetables, and fried food from boiling water or oil without draining away everything.
- A bench scraper: for transferring chopped food, handling pastry, and cleaning work surfaces.
- A mortar and pestle: for spices, garlic paste, and pestos. Better than a food processor for small quantities and for maintaining texture.
**What I do not have:** A stand mixer, a food processor, a sous vide machine, a pressure cooker. These are all useful tools; I choose not to have them because I prefer to cook with fewer, simpler tools. The absence of a stand mixer means I knead bread by hand; this takes longer and I find the process satisfying.
## Seasonal Produce Notes (Northern Europe)
The seasons I cook by are for Northern Europe (UK, France, Germany, Benelux). Adjust for your location.
**Spring (March-May):** Asparagus (the main event of spring; eat as much as you can afford for the 6-week season), purple sprouting broccoli, watercress, wild garlic, spring onions, radishes, new season morels. Lamb (the seasonal spring meat).
**Summer (June-August):** Tomatoes (peak in July-August; buy from farms, not supermarkets), courgettes (in abundance — the recipes are for using them before they become marrows), cucumber, broad beans, French beans, sweetcorn, basil. Stone fruits (cherries, peaches, apricots, plums).
**Autumn (September-November):** Squash and pumpkins, mushrooms (wild mushroom season; also when cultivated mushrooms are at their best), apples and pears, quince, root vegetables beginning, walnuts and hazelnuts fresh from the shell, game season begins.
**Winter (December-February):** Root vegetables (parsnips, swede, celeriac, carrots — all improve after a frost), brassicas (cavolo nero, Brussels sprouts, red cabbage), forced chicory, blood oranges from January. Citrus fruit generally.
**Year-round:** Good onions, garlic, potatoes, leeks, spinach (but prefer to use in season), celery.
## Substitutions Guide
A selection of substitutions that work well when you cannot find the original ingredient:
**Guanciale → Pancetta (not streaky bacon):** Guanciale (cured pork cheek) is used in authentic carbonara and amatriciana. Pancetta is an acceptable substitute; it has a similar fat ratio and cures cleanly. Streaky bacon, smoked or unsmoked, is not a good substitute — the smoking and different fat structure produce different results.
**San Marzano tomatoes → Good quality tinned plum tomatoes:** Any tinned plum tomato from a reputable producer will work. Avoid cheap tinned tomatoes in recipes where tomato quality is paramount.
**00 flour → Plain flour for fresh pasta:** In a pinch, plain flour works for fresh pasta. The texture will be less silky but perfectly acceptable. Not recommended for pizza dough, where 00 flour's specific protein content matters more.
**Parmesan → Grana Padano:** Very similar in flavour profile. Grana Padano has slightly less intensity and is generally cheaper. For finishing pasta or using as a condiment, Parmesan; for cooking into sauces where it will melt, Grana Padano works equally well.
**Fresh herbs → Dried (factor):** Not all herbs substitute equally. For robust herbs like oregano, rosemary, and thyme: use one-third the amount of dried compared to fresh. For delicate herbs like basil and parsley: no substitute. Dried basil bears no relation to fresh basil and should not be used in the same way.
**White wine (in cooking) → Dry white vermouth:** Vermouth's higher concentration of flavour compounds means you use slightly less, and it keeps in the cupboard indefinitely. I use it for any recipe that calls for white wine in a sauce.
**Buttermilk → Milk with lemon juice:** Add 1 tablespoon of lemon juice or white wine vinegar to 240ml of regular milk, stir, and let stand 5 minutes. The milk will curdle slightly. This works well for baking recipes.
## Notes on Heat
The most common mistake home cooks make is not getting pans hot enough before adding food. When you add food to an insufficiently hot pan, the food steams in its own moisture rather than searing. Chicken skin does not crisp; meat does not brown; vegetables go soft rather than caramelise.
Test heat with a drop of water: it should dance and evaporate within a second. Or use an infrared thermometer if you have one.
The corollary: do not leave food unattended over high heat. High heat gives excellent results quickly; it also burns food quickly.
## Notes on Salt
Professional kitchens season throughout cooking, not just at the end. Each layer of cooking is an opportunity to build flavour.
I use flaky sea salt (Maldon, or fleur de sel for finishing) and fine sea salt for cooking. I do not use table salt; the iodine imparts an off-flavour.
Salt pasta water generously — it should taste pleasantly salty, not like sea water. This is the only opportunity to season the pasta itself.

View file

@ -1,102 +0,0 @@
---
title: The Pantry
sort: 140
section-id: site
keywords: pantry guide, olive oil, vinegar, tinned fish, pasta, spices, flour, pantry essentials
description: Amelia Fontaine's essential pantry guide — what to keep, why it matters, and how to choose well.
language: en
---
# The Pantry
A well-stocked pantry is the difference between cooking feeling like a chore and cooking feeling like an opportunity. When you open the cupboard and find good olive oil, the right vinegars, and the pasta shapes you want, the question "what's for dinner?" becomes easier to answer well.
This is not a list of everything you could have. It is a list of the things I consider non-negotiable — the things I always have and that I think are worth spending money on when budget allows.
## Olive Oils
I keep two olive oils. One for cooking; one for finishing.
**Cooking olive oil:** A good, mid-range extra virgin olive oil from any reputable source. It will lose its delicate flavour compounds when heated, but it will still taste like olive oil and will not introduce off-flavours. I buy this in 3-litre tins for economy. The Sicilian and Calabrian oils are good value; look for a harvest date on the tin, not just a best-before date, and buy oil pressed within the past 18 months.
**Finishing olive oil:** This is worth spending money on. A single-estate extra virgin from Liguria, Tuscany, or Crete, pungent and fresh, used as a condiment rather than a cooking fat. Drizzled on beans, soup, burrata, grilled fish, roasted vegetables at the moment of serving. You use less of it, so the cost per use is not as high as it appears. Taste before you buy if possible.
**A note on "extra virgin":** Extra virgin means the oil is cold-pressed and has low acidity. It says nothing about flavour quality or freshness. There is a significant industry of inferior oils labelled EVOO; tasting is the only way to tell. Good finishing olive oil should taste grassy, peppery, and fresh; it should catch in your throat slightly. If it tastes flat or rancid, it is old or was never good.
## Vinegars
**Red wine vinegar:** The workhorse. For dressings, deglazing, marinades, quick pickles. I want a proper aged vinegar, not a cheap acidic substitute. The difference is enormous.
**White wine vinegar:** Similar uses to red, but milder and less assertive. Better for delicate dressings and where you do not want red tones.
**Aged balsamic from Modena:** Not the cheap stuff, which is caramel-coloured grape must. Traditional balsamic is aged for a minimum of 12 years, sweet-sour, thick, and extraordinary on strawberries, Parmigiano, or vanilla ice cream. You use it by the drop. A small bottle lasts for years.
**Sherry vinegar:** The most underused vinegar in my opinion. It has a nuttiness and complexity that suits braised dishes, bean soups, and Spanish-influenced food. I use it to finish lentil soup and it transforms the dish.
**Apple cider vinegar:** Good for pickling, dressings, and as an acid balance in certain meat dishes.
## Tinned Fish
My pantry considers tinned fish a staple rather than an emergency protein. Good tinned fish is not a compromise.
**Anchovies in olive oil:** One of the most useful ingredients in the kitchen. They dissolve into almost any dish they are added to, leaving flavour rather than fishiness. I add them to tomato sauces, to braised meat dishes, to dressings. A tin of Ortiz anchovies is worth the premium.
**Tinned sardines:** Portuguese sardines are exceptional — meaty, flavourful, and sustainable. I eat them on toast with good butter and lemon, or in pasta with breadcrumbs and raisins (pasta con le sarde, the Sicilian classic).
**Tinned tuna in olive oil:** Not in water. The oil-packed version has a completely different texture and flavour. Good for tonnato sauce, for pasta, for salads. The Ortiz brand is excellent; Spanish albacore is my standard.
**Tinned clams:** For quick pasta alle vongole when fresh clams are unavailable.
## Dried Pasta
Pasta shapes matter because the sauce adhesion, cooking time, and mouthfeel vary by shape. I keep:
**Rigatoni or penne rigate:** For hearty sauces, baked pasta, and dishes where the sauce needs to go inside as well as outside.
**Spaghetti:** For carbonara, aglio e olio, and the classics.
**Linguine:** Slightly flatter than spaghetti; better with seafood sauces.
**Pappardelle:** Wide, flat; made for mushroom and game ragù.
**Casarecce or trofie:** Short, twisted shapes that hold pesto and chunky sauces.
I buy pasta made with bronze-die extrusion, which gives a rougher texture that holds sauce better. De Cecco and Rummo are widely available and reliable. Setaro is exceptional if you can find it.
## Tinned and Jarred Tomatoes
**Whole San Marzano tomatoes:** For tomato sauces that need to cook down. The San Marzano variety has thick flesh, few seeds, and low acidity. The DOP (Denominazione di Origine Protetta) certification is meaningful here; it designates tomatoes actually grown in the Agro Sarnese-Nocerino area of Campania.
**Passata:** Sieved tomato purée, for quick sauces and soups. I make my own in late summer; otherwise I buy the Mutti brand.
**Tomato paste:** Concentrated tomato flavour, to be used in small quantities as a base layer in ragù, braises, and other long-cooked dishes.
## Spices
I keep fewer spices than most kitchens and replace them more often. Spices go stale. The most important ones to keep fresh:
- Whole black pepper (always grind fresh)
- Whole nutmeg (for béchamel and pasta)
- Cumin seeds (toast and grind as needed)
- Coriander seeds
- Smoked paprika (Spanish pimentón, ideally)
- Dried chilli flakes
- Bay leaves (dried; fresh are better but dried are reliable)
- Cinnamon stick (for braises, not powder)
- Saffron threads
## Flours
**00 flour:** The finely milled, low-gluten flour for fresh pasta and pizza doughs. The protein content and fine milling give the silky, supple dough that pasta requires.
**Plain (all-purpose) flour:** For general baking, thickening sauces, and most everyday uses.
**Strong bread flour:** High-gluten flour for bread. The higher protein content creates the gluten network that gives bread its structure.
**Fine semolina:** For dusting work surfaces when rolling pasta, for certain breads, and as a dusting agent to prevent sticking.
## Pantry Organisation
I keep oils, vinegars, and dried goods in a cool, dark cupboard away from the stove. Heat and light degrade oils and spices quickly. Tinned goods on a dedicated shelf, oldest at the front. Spices in tightly sealed jars, checked annually — if a spice smells of nothing when you open the jar, replace it.
The pantry does not need to be large. It needs to be thoughtful.

View file

@ -1,58 +0,0 @@
---
title: Recipe Index
sort: 120
section-id: site
keywords: recipe index, pasta, risotto, soups, roasts, baking, salads, sauces
description: An organised index of recipes on The Kitchen Table, organised by category with descriptions of each.
language: en
---
# Recipe Index
All recipes published on The Kitchen Table, organised by category. Every recipe has been tested multiple times. Measurements are given in metric first, with approximate imperial equivalents. Difficulty notes are honest.
## Pasta and Risotto
The backbone of my Italian side. I grew up eating pasta several times a week, and I still do. The recipes here range from the very simple (cacio e pepe, which is three ingredients and takes twenty minutes but can go wrong in a dozen ways) to the more involved (fresh pasta in all its shapes). Risotto has its own section because risotto is its own world.
Key recipes: carbonara (the real way, with guanciale and egg yolk emulsion), wild mushroom pappardelle, spring pea and mint risotto, cacio e pepe with proper technique.
## Soups and Stews
Soup is the most forgiving thing in cooking and also the category that rewards the most attention. A good stock makes a great soup. A mediocre stock makes a mediocre soup. These recipes include the stock foundation and build from there. The slow-braised lamb shoulder belongs in this section as much as the stews section — the line between a braise and a stew is the liquid ratio.
Key recipes: ribollita (slow Tuscan bean soup), roasted butternut squash soup with brown butter, French onion soup with proper technique.
## Roasts and Braises
Low-and-slow cooking produces the most deeply flavoured food. These are the recipes I return to when I want to impress without stress — the magic of a proper braise is that it gets better the longer you leave it. Roasting is faster but equally rewarding when done right.
Key recipes: the perfect roast chicken (with dry brining and pan sauce), slow-braised lamb shoulder with preserved lemon and olives, cassoulet (the two-day version, worth every minute).
## Baking
I bake bread twice a week: usually a sourdough loaf and a focaccia. Pastry appears occasionally. The bread recipes here require time and patience but no special equipment beyond a Dutch oven. The focaccia is the most forgiving thing I bake; the sourdough requires the most sustained attention.
Key recipes: sourdough starter from scratch (7-day guide), Ligurian focaccia with rosemary, Grandmother Lucia's Christmas cookies (cuccidati and brutti ma buoni).
## Salads and Vegetables
I eat a lot of vegetables, but I rarely make salads the centrepiece. The recipes in this section are more about preparations that showcase vegetables — roasted, confit, dressed with interesting things — than about assembled salads. The tomato preparations are in this section and are some of the things I am most proud of on this blog.
Key recipes: six preparations for peak-season tomatoes, seasonal eating guide by month.
## Sauces and Condiments
Hollandaise, pan sauces, stocks, and the preserved and fermented things I keep in my fridge. These are the foundations that other recipes build on. The hollandaise post includes a detailed discussion of emulsion science; the stocks post is the one I recommend to new cooks first.
Key recipes: hollandaise (with the science and the fixes), chicken and veal stocks, lacto-fermented kimchi and sauerkraut.
---
## Latest Recipes
```mdcms
posts-datetime-reversechronological
limit: 10
paginate: yes
```

View file

@ -1,82 +0,0 @@
---
title: Techniques
sort: 130
section-id: site
keywords: cooking techniques, mise en place, deglazing, rendering fat, emulsification, caramelisation, blanching
description: A guide to the fundamental cooking techniques referenced throughout The Kitchen Table blog.
language: en
---
# Techniques
This page is a reference for the fundamental techniques that appear repeatedly throughout the blog. Understanding these techniques means you can adapt any recipe rather than just follow it. I will keep adding to this page as new techniques come up in posts.
## Mise en Place
*Everything in its place.*
Mise en place is a French professional kitchen concept that translates simply as preparing everything before you start cooking. Chopped vegetables, measured spices, stocks ready, equipment assembled. Before the pan goes on the heat, everything should be within reach.
Why it matters: Heat does not wait. When a pan is at the right temperature, a sauce is reducing, or an omelette is setting, you cannot stop to find the lid or chop the garlic. The moment you turn away, something burns. Mise en place is the habit that gives you control.
At home, this does not require professional kitchen organisation. It means: read the recipe all the way through before you start. Then prepare everything the recipe will need before you light the first burner. Chop, measure, bring things to room temperature, get your tools out. Then cook.
The five minutes of preparation pays back ten minutes of calm.
## Deglazing
Deglazing is the technique of adding liquid to a hot pan after searing or roasting, then using that liquid to dissolve the caramelised bits (the *fond*) stuck to the bottom.
The fond — the browned proteins and sugars that have cooked onto the pan surface — contains enormous flavour. It is not burnt food to be discarded; it is concentrated, caramelised flavour to be incorporated. Deglazing releases it.
**How to deglaze:**
1. Remove the meat or vegetables from the pan, leaving the fond.
2. If there is excess fat, pour most of it off (leave a tablespoon or so).
3. With the pan still hot, add your deglazing liquid: wine, stock, water, cider, brandy.
4. The liquid will steam violently. This is correct.
5. Scrape the bottom of the pan with a wooden spoon or spatula as the liquid comes up to temperature. The fond will dissolve into the liquid.
6. Reduce the liquid to a sauce consistency, or use it as the base for a longer braise.
Wine (red or white) and stock are the most common deglazing liquids. The choice shapes the flavour of the resulting sauce.
## Rendering Fat
Rendering is the process of melting fat from meat (bacon, pancetta, guanciale, duck) over low heat so that it can be used as a cooking medium. The remaining solids — called lardons, when the meat is pork — become crispy and flavourful.
**Why render rather than add oil:** The rendered fat carries the flavour of the meat and will flavour everything cooked in it. Pancetta-rendered fat is the starting point for many Italian dishes. Duck fat is the basis for confit. The flavour integration is a feature, not a byproduct.
**How to render:** Start in a cold pan. Cut the fat into small pieces and put them in a cold, dry pan over low-medium heat. Resist the urge to turn up the heat. Low heat melts the fat without burning the surrounding meat. As the fat melts out, the temperature of the pan stabilises. Stir occasionally. After 8-15 minutes (depending on the fat), the pieces will be golden and crispy. Remove them with a slotted spoon and proceed with the recipe using the fat in the pan.
## Emulsification
An emulsion is a stable mixture of two liquids that would not naturally mix — most often, fat and water. Vinaigrette, hollandaise, mayonnaise, and the sauce on a properly-finished pasta are all emulsions.
Emulsions are stabilised by emulsifiers — molecules that have both fat-soluble and water-soluble ends, allowing them to bind to both phases simultaneously. Egg yolk lecithin is the most common culinary emulsifier; it is why hollandaise, mayonnaise, and carbonara work. Mustard contains emulsifying compounds, which is why a vinaigrette made with mustard stays together longer than one without.
**Temporary emulsions** (like vinaigrette whisked quickly) separate when left to stand — the fat globules coalesce and the water phase settles out. **Permanent emulsions** (like mayonnaise) remain stable because the egg lecithin has formed a physical barrier around each fat droplet, preventing them from merging.
**When emulsions break:** A hollandaise that breaks — where the sauce separates into greasy pools and watery liquid — has lost its emulsification. The fat and water phases have separated. The causes: too much heat, too much fat added too quickly, or not enough lecithin to stabilise the amount of fat. The fix is in my hollandaise post.
## Caramelisation and the Maillard Reaction
These are two distinct chemical reactions that both produce browning and flavour, and they are frequently confused.
**Caramelisation** is what happens when sugar is heated: it breaks down into hundreds of flavour compounds, producing the characteristic nutty, complex sweetness of caramel. Caramelisation requires temperatures above 160°C/320°F. It is what happens when you make caramel sauce, when you caramelise onions over low-medium heat for 45 minutes until they are sweet and deeply brown, or when the sugars in a crème brûlée crust.
**The Maillard reaction** is a chemical reaction between amino acids and reducing sugars that produces browning, complex flavour, and hundreds of flavour compounds. It requires temperatures above approximately 140°C/285°F. It is what produces the crust on bread, the sear on a steak, the colour on roasted vegetables, the golden skin of a roast chicken. It is not caramelisation — it involves proteins, not just sugars — and the flavour compounds it produces are different and more complex.
For practical cooking: both reactions require high heat and low moisture. Wet surfaces steam rather than brown. This is why you pat meat dry before searing, why you roast vegetables at high heat with space between them, and why bread crust forms in the dry heat of the oven rather than the moist heat of a steamer.
## Blanching
Blanching is the technique of briefly cooking a vegetable in vigorously boiling, generously salted water, then immediately transferring it to ice water to stop the cooking.
**Why blanch:** The brief cooking sets colour (the vibrant green of blanched green beans comes from heat driving air out of the cells and stabilising the chlorophyll). It also softens vegetables enough to make them pleasant to eat while maintaining their texture. The ice bath stops the cooking instantly at exactly the moment you choose.
Blanching is the technique behind *mise en place* vegetable prep in professional kitchens: blanch the vegetables in advance, ice-bath them, then finish them in butter or olive oil at service. The hard work is done; the final cooking takes two minutes.
**Ratios and timing:** Use a large pot of water — the more water, the faster it returns to the boil after you add the vegetables. Salt it generously (the water should taste like pleasant seawater). Timing varies by vegetable: green beans 2-3 minutes, asparagus 1-2 minutes, broccoli 2 minutes, potatoes longer. The goal is "just cooked but still with texture."
---
*More techniques are added regularly as they come up in posts. Check the blog or use the search function to find technique discussions in specific recipe posts.*

View file

@ -1,68 +0,0 @@
---
title: "The Only Carbonara Recipe You Need (And Why Most Are Wrong)"
created: 2024-02-14 10:00
author: Amelia Fontaine
keywords: carbonara, pasta, eggs, guanciale, Italian, technique
description: Authentic spaghetti alla carbonara — no cream, no shortcuts — with a deep dive into why the technique matters and how to nail the emulsification every time.
---
![Pasta carbonara](assets/images/pasta.jpg)
# The Only Carbonara Recipe You Need (And Why Most Are Wrong)
I learned to make carbonara from a Roman butcher named Giorgio who sold guanciale out of a refrigerated cabinet the size of a wardrobe. It was 2011. I was twenty-three, living in Trastevere for the summer on a fellowship that paid almost nothing, and I ate pasta four nights a week because it was what I could afford. Giorgio noticed I kept buying pancetta instead of guanciale and, with the patience of a man who had seen tourists make terrible decisions for thirty years, spent fifteen minutes explaining why this was wrong.
That conversation changed how I cook.
## Why Cream is Not Just "a Variation"
Let me be clear before we begin: carbonara does not contain cream. This is not culinary snobbery or Italian chauvinism. It is a matter of understanding what the dish is. Carbonara is a demonstration of emulsification — the technique by which fat, egg proteins, and starchy pasta water combine into a glossy, clingy sauce. Cream short-circuits this process. It works, yes. You get something vaguely carbonara-like, pale and rich. But you have bypassed the thing the dish is teaching you, which is how to make a sauce from almost nothing using heat and motion.
Learning carbonara without cream is like learning to drive on an automatic: functional, but you miss something important about how the machine works.
## The Ingredients
For two people:
- **200g spaghetti** (or rigatoni, if you prefer something to grip the sauce)
- **150g guanciale**, cut into lardons roughly 1cm × 0.5cm
- **3 egg yolks** plus 1 whole egg
- **60g Pecorino Romano**, finely grated (or a 50/50 blend with Parmigiano)
- **Freshly ground black pepper** — and lots of it
- **Salt** for the pasta water only
Guanciale is cured pig cheek. It is fattier and more flavourful than pancetta, with a particular sweetness that pancetta lacks. In Rome, there is no substitute. In the UK or US, good-quality pancetta works as a reasonable second. Bacon does not work — the smoke flavour fights the egg.
The pepper is not optional. "Carbonara" takes its name from *carbone* (charcoal). The dish was allegedly made by charcoal workers, and the pepper represents the charcoal dust. Use it generously.
## The Method
**1. Get the pasta water boiling.** Heavily salted — it should taste like mild seawater. This starch-rich water is your sauce's best friend.
**2. Render the guanciale slowly.** In a large pan (you will need the surface area later), cook the guanciale over medium-low heat until the fat is mostly rendered and the edges are crispy but the interior is still yielding, about 810 minutes. Do not go too high — you want rendered fat, not burnt crisps. Turn off the heat.
**3. Make the egg mixture.** In a bowl, whisk together the yolks, whole egg, Pecorino, and a very generous amount of pepper. The mixture should be thick and pale yellow. Set aside.
**4. Cook the pasta until 90% done.** It will finish cooking in the pan, so pull it out a minute before al dente. Reserve at least 200ml of pasta water before draining.
**5. The critical moment.** Transfer the pasta directly into the guanciale pan (heat off). Add 34 tablespoons of pasta water and toss vigorously for 30 seconds until the pasta is well-coated and the temperature has dropped slightly — you want it hot but not searing.
**6. Add the egg mixture off the heat.** Pour the egg and cheese mixture over the pasta and toss constantly and rapidly. The residual heat from the pasta and the pan cooks the eggs gently. Add pasta water a tablespoon at a time to adjust consistency — you want the sauce creamy and flowing, not dry and clumped. The whole process takes about 6090 seconds.
**7. Serve immediately.** Carbonara waits for no one. The sauce continues to thicken as it cools. Finish with more Pecorino and more pepper at the table.
## Why It Scrambles (And How to Stop It)
Egg proteins begin to set at around 63°C and are fully cooked at 73°C. The goal is to stay below 73°C while getting the proteins warm enough to thicken the sauce — you want the texture of custard, not scrambled eggs.
The safeguards:
- **Turn the heat off** before adding the egg mixture. Always.
- **The pasta water** lowers the temperature of the pan and adds starch, which buffers the egg proteins and prevents rapid coagulation.
- **Constant motion** distributes heat evenly and coats every strand.
- **Working quickly** matters more than anything. Have everything ready before you cook the pasta.
If it scrambles anyway: the pan was too hot or the water was too starchy. Cool the pan in cold water for 10 seconds before adding the egg. Add more pasta water. Breathe.
## The Version Giorgio Made
Giorgio's carbonara was almost indistinguishable from mine except for two things. He used only Pecorino, never Parmigiano. And he always added one extra yolk "per il colore" (for the colour) — which turned the sauce a deeper, more vivid gold. I now do the same. It makes no rational sense that I have never been able to verify, but the carbonara tastes better for it, and that is probably all the reason I need.

View file

@ -1,89 +0,0 @@
---
title: Starting a Sourdough Starter from Scratch
created: 2024-03-22 09:00
author: Amelia Fontaine
keywords: sourdough, starter, fermentation, bread, wild yeast
description: A complete seven-day guide to creating a sourdough starter from nothing but flour, water, and patience — with troubleshooting and the science of wild fermentation.
---
![Freshly baked bread](assets/images/bread.jpg)
# Starting a Sourdough Starter from Scratch
A sourdough starter is, at its most fundamental, a controlled environment for wild yeast and lactic acid bacteria. You are not adding anything to the flour and water except conditions — warmth, time, regular feeding. The microorganisms are already present on the grain, in the air, on your hands. Your job is to select for the ones you want.
This sounds mystical. It is actually chemistry. Lactic acid bacteria produce acids that lower the pH of the mixture, creating conditions that favour *Saccharomyces cerevisiae* (the yeast responsible for rise) and *Lactobacillus* species (responsible for flavour). By day seven, if conditions are right, you will have a stable, predictable culture you can use for the rest of your life.
## What You Need
- **Flour**: Wholegrain rye or wholemeal wheat is ideal for starting — higher in wild yeast and nutrients than white flour. Once established, you can switch to white.
- **Water**: Filtered or left to stand overnight if chlorinated. Chlorine inhibits fermentation.
- **A jar**: At least 500ml capacity. Glass is ideal so you can observe activity.
- **A scale**: Precision matters here. Volume measurements are unreliable.
- **Temperature**: 2426°C is ideal. A kitchen counter in summer works. In winter, try near (not on) a warm appliance, or inside the oven with just the light on.
## The Seven-Day Guide
### Day 1 — Creating the Base
Combine 50g wholegrain rye flour with 50g room-temperature water in your jar. Mix thoroughly until no dry flour remains. Scrape down the sides, cover loosely (a cloth held with a rubber band, or a jar lid placed on top without sealing), and leave at room temperature.
Do nothing else today.
### Day 2 — First Signs
You may see small bubbles. You may see nothing. Both are normal. The mixture might smell slightly unpleasant — musty or even nail-polish-like. This is also normal; undesirable bacteria are colonising first before the yeast creates conditions that suppress them.
Discard all but 50g of the mixture. Add 50g rye flour and 50g water. Mix, cover, leave.
### Day 3 — Activity Increases
By now you should see more consistent bubbling. The smell may be getting more sour and less unpleasant. This is the lactic acid bacteria beginning to dominate.
Repeat the discard and feed: keep 50g, add 50g flour, 50g water.
### Day 4 — The Starter Wakes Up
You should now be seeing a predictable rise — the mixture expanding within a few hours of feeding before dropping back. Mark the level on the jar with a rubber band after feeding to track the rise. Aim for 50100% increase at peak.
Switch to twice-daily feeding if your kitchen is above 24°C. Once daily is fine below that. Continue: 50g starter, 50g flour, 50g water.
### Day 5 — Transition to White Flour
If using rye to establish the culture, you can now transition: use 25g rye and 25g white bread flour for a couple of days, then move to all white bread flour if you prefer a milder flavour and lighter bread.
The starter should now smell pleasantly sour and yeasty, like a good craft beer. If it smells of cheese or acetone at this stage, it's too warm or not being fed frequently enough.
### Day 6 — The Float Test
A healthy, active starter will float when a small amount is dropped into a glass of water. Try this 46 hours after feeding, when the starter is at or near peak activity. If it floats, you're ready to bake.
If it sinks, continue feeding twice daily for another day or two. Patience.
### Day 7 — Ready
A starter that passes the float test and reliably doubles within 46 hours of feeding is ready to use. You have created a living culture that, with basic maintenance, can last indefinitely. Some bakeries use starters that are decades old.
## Ongoing Maintenance
**If baking daily**: Keep at room temperature, feed once or twice a day (same discard-and-feed routine).
**If baking occasionally**: Store in the refrigerator. Feed once a week. Remove from the fridge 1224 hours before baking to bring it to room temperature and let it peak.
**The discard**: You discard starter each time you feed to prevent the jar from overflowing and to maintain a consistent ratio of culture to fresh flour. The discarded starter is excellent in pancakes, flatbreads, crackers, and waffles.
## Troubleshooting
**No activity after four days**: Make sure the water isn't chlorinated. Try a slightly warmer location. Use wholegrain flour.
**Pink or orange streaks**: These indicate contamination. Discard everything, sterilise the jar, start again.
**Liquid layer on top ("hooch")**: Grey-black liquid means the starter is hungry and hungry for longer than it should be. Pour it off and feed immediately; consider switching to twice-daily feeding.
**The smell**: Acetone/nail polish = too warm or too acidic; cheese = too cold; pleasantly sour and yeasty = correct.
## Why This Works
Wild yeast produces carbon dioxide (creating rise) and ethanol (which evaporates in baking). Lactic acid bacteria produce lactic and acetic acids, which contribute flavour and preservation. The ratio of these two acids depends on temperature and hydration: warmer and wetter conditions favour lactic acid (milder, yoghurt-like); cooler and stiffer conditions favour acetic acid (sharper, vinegar-like). This is why bakeries in different climates produce sourdough with distinct flavour profiles even from similar flour.
Your starter is genuinely local. The wild yeasts on your flour, in your kitchen air, on your hands — they are specific to your environment. A starter begun in Manchester will differ from one begun in Marseille. This is one of the things I find quietly extraordinary about bread.

View file

@ -1,66 +0,0 @@
---
title: Spring Pea and Mint Risotto
created: 2024-04-10 11:00
author: Amelia Fontaine
keywords: risotto, peas, mint, spring, Italian, technique
description: The first spring peas at the market, a technique deep-dive on why risotto works, and a recipe using pea purée and crispy prosciutto.
---
# Spring Pea and Mint Risotto
Every year the first fresh peas at the market feel like a small event. They arrive sometime in April, in pods that are bright and firm and squeak when you press them. The ratio of pod to pea is almost never in your favour — you need a lot — but the flavour of a just-shelled pea is one of those things that makes you understand why people have grown food for ten thousand years.
This risotto uses peas two ways: most of them puréed into a deeply green, sweet sauce that coats the rice, and a handful kept whole for texture. Crispy prosciutto on top because salt and fat and crunch are exactly what the sweetness needs.
## On Risotto Technique
There is a persistent myth that risotto requires constant stirring. It does not. What it requires is *frequent* stirring and attention — you should not walk away — but continuous stirring actually over-develops the starch and produces gluey results. Every two minutes or so works well.
The mechanism: Arborio (or Carnaroli, which I prefer) rice contains a starchy exterior that dissolves into the cooking liquid, producing the creaminess. The interior of the grain stays somewhat firm. Stirring mechanically releases this surface starch; too much stirring releases all of it at once and produces paste.
The wine is not optional. Its acidity balances the sweetness of the rice and the richness of the stock. White wine that you wouldn't drink is fine here — but not cooking wine, which is salted.
## Ingredients (serves 4)
- 350g Carnaroli or Arborio rice
- 1.5 litres hot vegetable or light chicken stock, kept warm
- 600g fresh peas in pods, shelled (about 200g shelled weight)
- 1 medium white onion, finely diced
- 2 cloves garlic, minced
- 120ml dry white wine
- 60g unsalted butter, cold and diced
- 50g Parmigiano Reggiano, finely grated
- A handful of fresh mint leaves, roughly torn
- 4 slices prosciutto crudo
- 3 tbsp olive oil
- Salt and white pepper
## Method
### The Pea Purée
Blanch two-thirds of the peas in boiling salted water for 90 seconds, then plunge immediately into ice water. Drain and blend with 45 tbsp of warm stock, a pinch of salt, and half the mint until very smooth. Pass through a sieve for a silkier result, or leave it slightly textured — your preference. Set aside. Keep the remaining peas raw.
### The Crispy Prosciutto
Lay the prosciutto slices flat in a dry frying pan over medium-high heat. Press down with a spatula. They will take 6090 seconds per side to become dark, crisp, and fragrant. Remove onto kitchen paper. They will crisp further as they cool.
### The Risotto
Heat the olive oil with half the butter in a wide, heavy-bottomed pan over medium heat. Soften the onion with a pinch of salt for 8 minutes until translucent and very soft — do not let it colour. Add the garlic, cook for 1 minute more.
Add the rice and toast, stirring, for 2 minutes until the grains are slightly translucent at the edges. Add the wine; it will steam dramatically. Stir until completely absorbed.
Now begin adding the stock: one ladleful at a time, stirring every couple of minutes and adding the next ladleful only once the previous one is absorbed. The heat should be brisk but not violent — you want a gentle, active simmer. This process takes about 18 minutes total. The rice is done when it is tender with a slight bite at the very centre.
### Bringing It Together
Remove from the heat. Add the pea purée and stir to combine — the mixture will turn a striking green. Add the cold butter and Parmigiano. Beat vigorously with a wooden spoon for 60 seconds (this is the *mantecatura* — the final emulsification of fat into the rice). The risotto should flow slowly when you shake the pan: the Italians call this *all'onda*, wave-like.
Fold in the raw peas and the remaining mint. Taste and adjust salt and white pepper.
Spoon into warm, wide bowls. Top with the shards of crispy prosciutto, a drizzle of good olive oil, and a few more mint leaves. Serve immediately.
## Notes
**Make it vegetarian**: Omit the prosciutto. The dish is more than sufficient without it. A handful of toasted hazelnuts adds the needed crunch and a pleasant nutty contrast.
**On the stock**: Good risotto requires good stock. Cube stock will produce cube-flavoured risotto. A simple vegetable stock takes 30 minutes and costs almost nothing.
**Leftovers**: Risotto does not reheat well (it sets solid as it cools). The traditional solution is *risotto al salto* — fry cold risotto patties in butter until crispy on both sides. Excellent for lunch the next day.

View file

@ -1,61 +0,0 @@
---
title: "The Perfect Roast Chicken: Everything I Know"
created: 2024-05-05 10:00
author: Amelia Fontaine
keywords: roast chicken, dry brine, pan sauce, technique, Sunday roast
description: Dry brining, the trussing debate, temperature science, resting, and a glossy pan sauce — everything you need to roast the best chicken you've ever made.
---
# The Perfect Roast Chicken: Everything I Know
Roast chicken is the thing I cook most often when I want to impress without appearing to try. It arrives at the table deeply bronzed and crackling, the kitchen filled with the smell of caramelised skin and rendered fat, and there is very little effort involved once you understand a few things about what is actually happening in the oven.
The technique I use now is the result of about eight years of incremental adjustment. I will share everything.
## The Single Most Important Step: Dry Brining
Twenty-four to forty-eight hours before roasting, season the chicken generously all over — including inside the cavity — with fine sea salt. Use about 1 teaspoon per kilogram of bird. Pat dry with paper towel, then refrigerate uncovered.
What happens: the salt draws moisture to the surface through osmosis. The moisture then dissolves the salt, creating a concentrated brine. This brine is then reabsorbed into the meat via osmosis. Simultaneously, the exposed skin dries out in the fridge, which is exactly what you want.
The result is twofold: the meat is seasoned all the way through (not just on the surface), and the skin becomes dry enough to crisp dramatically in the oven.
Do not skip this step. More than any other single technique, this is what separates a good roast chicken from a great one.
## The Trussing Debate
Trussing — tying the legs together and tucking the wings — is traditional but, I now believe, counterproductive for even cooking. The legs take longer to cook than the breast. If you truss the bird tightly, you bring all parts into proximity and the breast overcooks while waiting for the dark meat to finish.
I leave the bird untrussed, legs loosely apart. The breast still finishes first, but I compensate by starting the chicken breast-side down for the first third of the cooking time, then flipping to finish breast-side up. The direct pan heat starts rendering the back fat; the eventual breast-up position crisps the skin.
## Temperature and Timing
I roast chickens at a single consistent temperature: 220°C (fan)/240°C (conventional), no lower. High heat renders fat quickly and drives the Maillard reaction on the skin. A lower temperature produces pale, soft skin even if the interior is correctly cooked.
**Timing**: approximately 20 minutes per 500g, plus 20 minutes resting. A 1.5kg bird takes about 80 minutes in the oven. But timing is a guide, not a rule.
**The test that matters**: an instant-read thermometer in the thickest part of the thigh (not touching bone) should read 74°C. The juices, when the thigh is pierced, should run clear. If you see any pink, return it to the oven for 10 more minutes.
## Resting
Resting is not optional. After the chicken comes out of the oven, rest it uncovered (not tented with foil, which creates steam and softens the skin) for at least 20 minutes. During this time the internal temperature continues to rise slightly and the muscle fibres, contracted from heat, relax and allow the juices to redistribute. A chicken carved immediately after roasting loses significantly more juice than one that has rested.
While the chicken rests, make the pan sauce.
## Pan Sauce
Pour off most of the fat from the roasting tin, leaving the browned fond and about 2 tablespoons of fat. Place the tin directly over medium heat. Add a glass of white wine or vermouth and scrape vigorously — every browned bit is flavour. Add 200ml chicken stock. Reduce by half, stirring occasionally. Taste: it should be intensely savoury and slightly glossy. Swirl in a small knob of cold butter off the heat. Strain through a fine sieve, pushing gently on any solids.
This sauce takes 8 minutes and rewards the roast enormously.
## The Aromatics
Inside the cavity: half a lemon, a few garlic cloves (unpeeled, slightly crushed), some thyme. These aromatics perfume the inside of the bird gently as it cooks. They are not a recipe element — they are flavour infrastructure. On the roasting tin: roughly chopped onion, carrot, celery, which will contribute to the pan sauce and the flavour of the drippings.
## The Variations
**With tarragon butter**: Mix 80g softened butter with 2 tbsp tarragon, a clove of garlic, lemon zest, salt. Carefully loosen the breast skin with your fingers and push the butter underneath. The butter bastes the breast from the inside as it melts.
**Spatchcocked**: Remove the backbone with kitchen shears and flatten the bird. It cooks in about 45 minutes and the skin coverage is even and extraordinary. Excellent for weeknights.
**The next day**: Strip the carcass. Make stock. Use the stock to make the risotto from last month's post. This is not a meal plan — this is a system.

View file

@ -1,57 +0,0 @@
---
title: "What to Do with Peak-Season Tomatoes (Besides Salad)"
created: 2024-06-18 09:30
author: Amelia Fontaine
keywords: tomatoes, summer, slow roast, confit, gazpacho, umami
description: Six preparations for peak summer tomatoes — from slow roasting to a raw sauce and tomato jam — plus the science of why cooked tomatoes taste different.
---
# What to Do with Peak-Season Tomatoes (Besides Salad)
There is a window each summer, roughly six to eight weeks, when tomatoes are worth cooking with. Outside this window — and for most of the year in Northern Europe this is outside this window — tomatoes are a disappointment, flavourless and watery, and you are better off using good tinned San Marzano. But in July and August, when the tomatoes at the market have been grown outside in actual sunshine and handled briefly before they reach you, they are extraordinary.
The problem is that most people only know how to do one thing with a great tomato: put it in a salad. Here are six more.
## Why Cooked Tomatoes Taste Different
Tomatoes contain glutamates — the amino acids responsible for umami — as well as volatile aromatic compounds. When raw, the aromatics are what you notice: fresh, bright, slightly acidic. When cooked, two things happen. First, heat drives off the volatile compounds, reducing the fresh brightness. Second, the glutamates become more concentrated as water evaporates, intensifying the savoury quality. This is why tomato paste has much more umami impact than raw tomato, and why slow-cooked tomato sauces taste fundamentally different from quick ones.
Different preparations exploit these properties in different ways.
## 1. Slow Roasted
Cut tomatoes in half, place cut-side up in a single layer on a baking sheet. Season with salt, pepper, sugar (a pinch), olive oil. Roast at 150°C for 2.53 hours until collapsed, concentrated, and slightly caramelised.
These are transformative. The flavour intensity is extraordinary — ten times the tomato per square centimetre of a raw slice. Use them on bruschetta, in pasta (they are a sauce already), on pizza, alongside burrata, in sandwiches. They keep refrigerated in olive oil for a week.
## 2. Confit
Confit is slower and lower than slow roasting: 120°C for 34 hours, generously covered with olive oil, with garlic cloves and fresh thyme in the pan. The tomatoes do not colour; they soften into silky, oil-poached jewels. The oil they are cooked in becomes extraordinary — use it on everything.
## 3. Raw Sauce (Pasta al Pomodoro Crudo)
No cooking required. Dice a pound of ripe tomatoes roughly. Add a clove of crushed garlic, a lot of torn basil, 4 tablespoons of your best olive oil, salt, a pinch of sugar if needed. Let sit at room temperature for 30 minutes — the salt draws juice from the tomatoes, which mixes with the oil and basil into a sauce.
Cook pasta until slightly undercooked, drain with a little water still clinging to it, and toss with the raw sauce. The heat of the pasta gently warms the tomatoes without cooking them. Serve in warm bowls. This is the best pasta of the entire year when the tomatoes are right.
## 4. Gazpacho
Blend 1kg roughly chopped tomatoes, half a cucumber, half a red pepper, 2 garlic cloves, a thick slice of stale white bread (soaked in water, squeezed dry), 100ml olive oil, 3 tbsp sherry vinegar, salt. Blend until completely smooth. Season. Refrigerate at least 2 hours, ideally overnight.
Serve in cold glasses with a drizzle of olive oil and very finely diced cucumber, pepper, and tomato on top. It should be thick but pourable. The bread emulsifies the olive oil; without it the gazpacho separates.
## 5. Tomato Tart
Make a rough puff pastry or buy good butter puff. Spread with a thin layer of Dijon mustard. Scatter over Gruyère or Comté. Arrange sliced tomatoes in slightly overlapping rows. Season with salt, pepper, thyme. Bake at 200°C for 2530 minutes until the pastry is deeply golden and the tomatoes have collapsed slightly.
This is a Provençal dish by temperament — it requires very good tomatoes, very good cheese, and the confidence to do almost nothing to them.
## 6. Tomato Jam
This is the unexpected one, and people are always suspicious until they eat it. Combine 1kg chopped tomatoes with 200g sugar, a tablespoon of lemon juice, a teaspoon of grated fresh ginger, half a teaspoon of cumin, a pinch of chilli. Cook over medium heat, stirring frequently, for 4560 minutes until thick and jammy.
It is extraordinary with aged cheese, cold cuts, and pork. It will keep refrigerated for a month. The sweetness and acidity of the jam makes sense once you taste it — it is actually just another way of intensifying the tomato.
## The One Rule
Whatever you make, use the best tomatoes you can find and trust them. Good tomatoes need very little help. Poor tomatoes cannot be rescued.

View file

@ -1,53 +0,0 @@
---
title: "The French Omelette: A Meditation on Technique"
created: 2024-07-30 10:00
author: Amelia Fontaine
keywords: omelette, French, technique, eggs, Jacques Pépin
description: Twenty failures, Escoffier's technique, pan choice, heat control, and the roll vs. fold debate — a complete guide to the most technically demanding dish in basic cooking.
---
# The French Omelette: A Meditation on Technique
I failed at the French omelette approximately twenty times before I got it right. I say approximately because I stopped counting around failure fourteen. It is, I am convinced, the most technically demanding simple dish in cooking — more difficult than hollandaise, which at least gives you warning signs, more difficult than soufflé, which has more margin than its reputation suggests. The French omelette happens in ninety seconds and forgives nothing.
## What Makes It French
The French omelette (omelette française) is pale, barely coloured, folded into a torpedo shape, with a creamy, slightly underdone interior. It is not the British omelette (folded in half, lightly browned). It is not the American diner omelette (rolled with filling, browned all over). The French method is distinguished by high heat, constant motion, and a very short cooking time that leaves the interior *baveux* — a word French cooks use to mean just barely set, almost wet, definitely still trembling when the plate arrives.
This is the egg at its most civilised. The flavour is pure and clean, just egg and butter. It is not the thing you make when eggs are a vehicle for other things. It is the thing you make when the egg is the point.
## What Escoffier Said
Auguste Escoffier, the architect of classical French cuisine, said that the omelette is nothing more than "scrambled eggs enclosed in a coating of coagulated egg." He is correct and his description helps: you are making very soft scrambled eggs and then convincing the exterior to set around them into a smooth, closed shape.
The exterior and interior cook differently. The exterior is in direct contact with the pan and sets quickly. The interior is cooked by radiated heat from the exterior and remains fluid longer. The motion — constant, vigorous stirring — disperses the heat so that the transition from fluid to set happens gradually rather than all at once.
## The Equipment
**Pan**: A 20cm non-stick pan, reserved for eggs only. This is not a counsel of perfection. It is a practical necessity. Carbon-steel pans work once perfectly seasoned but require maintenance. Stainless steel requires precision that even experienced cooks find difficult. Non-stick is honest: it tells you exactly what is happening rather than hiding problems.
**Heat**: Medium-high to high. The professional instruction is "very hot," and I know this is terrifying, but slow heat produces rubbery, overdone eggs. The butter should foam actively the moment it hits the pan and the foam should subside after 2030 seconds. If it doesn't foam at all, the pan is not hot enough.
## The Method
Beat 3 eggs with a fork until thoroughly combined — no streaks of white remaining. Season with fine salt only (add pepper on the plate; pepper in a hot omelette turns bitter).
Heat the pan over medium-high heat for 12 minutes. Add 15g unsalted butter. As the foam subsides (but before the butter browns), add the eggs all at once.
Immediately begin shaking the pan forward and backward while simultaneously stirring the eggs in the pan with a heatproof spatula or fork in small circular motions. The combination of motion creates tiny curds throughout the egg mass while the shaking prevents the bottom from setting. Keep this up for about 45 seconds.
When the eggs are barely set — still trembling, still slightly liquid on the surface — stop stirring. Let the omelette sit for 5 seconds to allow the bottom to set into a smooth surface.
Tilt the pan at 45 degrees and, using the spatula, gently fold the near side of the omelette towards the centre. Then tip the pan further, letting the far edge fold over as you slide the omelette onto the plate, rolling it off the pan so it arrives seam-side down in a neat torpedo shape.
## The Roll vs. Fold Debate
There is an alternative approach, used by many French home cooks, which involves folding the omelette in thirds rather than rolling: fold the near third over the centre, fold the far third over the near, slide onto the plate. This produces a more rectangular shape and is significantly easier. Jacques Pépin does it. I have seen it described as "the real" French omelette as opposed to the rolled version.
Both are correct. The rolled version is more impressive and produces a slightly creamier interior because the folding is faster. The folded version is more forgiving and produces consistently good results even for beginners. Learn the rolled version; use the folded version when you're tired.
## Why It's Worth Learning
The French omelette teaches you more about heat control, timing, and attention than almost any other dish. Once you can make one reliably — pale, soft, creamy, rolled in ninety seconds — you understand something about cooking that is difficult to learn any other way. The difficulty is precisely the lesson.
Cook it for breakfast on a Sunday when nothing else is demanding your attention. Make three in a row. The second will be better than the first; the third will be better than the second.

View file

@ -1,71 +0,0 @@
---
title: "Slow-Braised Lamb Shoulder with Preserved Lemon and Olives"
created: 2024-09-12 11:00
author: Amelia Fontaine
keywords: lamb, braise, preserved lemon, olives, North African, slow cooking
description: A North African-inspired lamb shoulder braise with the science of why slow cooking transforms tough cuts, served with couscous. Recipe for six.
---
# Slow-Braised Lamb Shoulder with Preserved Lemon and Olives
Lamb shoulder is one of those cuts that rewards patience so generously you wonder why anyone would rush. It is inexpensive, flavourful, and absolutely requires the low-and-slow treatment — braising over three to four hours — to surrender its considerable potential. At high heat it is tough and unpleasant. Given time and liquid, the collagen in its connective tissue converts to gelatin, and the result is meat that pulls apart easily, silky and rich, in a sauce of deep complexity.
This preparation is North African in spirit, though not strictly authentic to any one cuisine. The combination of preserved lemon, olives, coriander, and saffron is Moroccan in temperament; the method is classical French. The two coexist happily.
## Ingredients (serves 6)
**For the lamb:**
- 2kg bone-in lamb shoulder
- 2 tbsp olive oil
- 2 large onions, roughly chopped
- 4 cloves garlic, sliced
- 2 tsp ground cumin
- 2 tsp ground coriander
- 1 tsp smoked paprika
- 1 tsp ground ginger
- ½ tsp cinnamon
- 1 pinch saffron, steeped in 2 tbsp warm water
- 400g tin chopped tomatoes
- 300ml lamb or chicken stock
- 1 preserved lemon, pulp discarded, rind finely sliced
- 150g pitted green olives (Castelvetrano work well)
- Salt and black pepper
**To serve:**
- 400g couscous
- 30g unsalted butter
- A large bunch of fresh coriander
- Pomegranate seeds (optional but excellent)
- Plain yoghurt
## Method
**Day before (if possible):** Season the lamb shoulder generously all over with salt. Refrigerate uncovered overnight. This dry brine improves the crust and seasons the meat throughout.
**Searing.** The single most important step for flavour. Pat the lamb completely dry. Heat the olive oil in a large casserole or Dutch oven over high heat until smoking. Sear the lamb on all sides — do not rush this — until deeply browned, almost mahogany, on all surfaces. This takes 1012 minutes total. Remove and set aside.
The Maillard reaction is happening here: amino acids and sugars on the surface of the meat are combining under heat to create hundreds of new flavour compounds. You cannot get this depth of flavour from poaching or slow-cooking from raw. The sear is not about sealing in juices (that is a myth); it is about creating new ones.
**Building the braise.** Reduce heat to medium. In the same pan, cook the onions in the residual fat with a pinch of salt for 810 minutes until soft and golden. Add the garlic and all the spices; cook for 2 minutes until fragrant. Add the saffron with its soaking water. Add the tomatoes and stock, scraping up all the browned bits from the pan.
Nestle the lamb back in, fat-side up. The liquid should come about halfway up the meat — add more stock or water if needed. Bring to a gentle simmer.
**Braising.** Cover tightly and transfer to an oven set to 160°C. Braise for 33.5 hours, turning the lamb once at the halfway point. The meat is done when it yields completely to gentle pressure and a probe thermometer reads above 90°C in the centre.
**Adding the preserved lemon and olives.** In the last 30 minutes of braising, add the preserved lemon rind and the olives. These are added late to preserve their bright, assertive flavours — too long in the braise and they lose their character.
**Finishing.** Remove the lamb to a warm dish. Skim the fat from the surface of the braising liquid. If the sauce is thin, reduce it over high heat for 510 minutes on the stovetop. Taste for seasoning.
## The Couscous
Pour 400g couscous into a large bowl. Add 1 tsp salt and 1 tsp olive oil. Pour over 420ml boiling water. Cover tightly with cling film for 5 minutes. Uncover, add the butter, and fluff with a fork. Stir through most of the coriander.
## Serving
Bring the whole casserole to the table if possible. Pull the lamb apart with two forks — it should offer no resistance. Serve on a bed of couscous, ladled with sauce, scattered with remaining coriander, pomegranate seeds if using, and a spoonful of yoghurt alongside.
## Notes
This improves overnight. The flavours deepen and the fat congeals, making it easy to remove completely from the surface before reheating gently. Leftovers make an extraordinary filling for flatbreads with harissa and more yoghurt.
The preserved lemon can be made at home (and should be — it takes 10 minutes to prepare and four weeks to mature) or found in any Middle Eastern grocery. Do not skip it: no other ingredient produces exactly that combination of brininess and preserved citrus intensity.

View file

@ -1,73 +0,0 @@
---
title: "Wild Mushroom Pasta: An Autumn Recipe and a Foraging Story"
created: 2024-10-25 09:00
author: Amelia Fontaine
keywords: mushrooms, pasta, pappardelle, foraging, autumn, dried mushrooms
description: A day foraging in the countryside, what different mushrooms taste like, how to dry and rehydrate them, and a pappardelle recipe with wild mushrooms.
---
# Wild Mushroom Pasta: An Autumn Recipe and a Foraging Story
It was an October Saturday, dense fog in the valleys and the light coming through the beech trees at a particular angle that I associate entirely with this season. A friend who has been foraging since she was a child had invited me along, with the clear instruction that I was not to pick anything she hadn't identified and that I should wear waterproof boots regardless of what the forecast said.
She was right about the boots.
We found chanterelles first — unmistakable, egg-yolk orange, smelling faintly of apricots, firm as you'd want. Then a large cluster of hen-of-the-woods (*Grifola frondosa*) at the base of an oak, grey-brown and fanning outward. Some bay boletes, beautiful with their reddish-brown caps and pale undersides. A single, enormous porcino — boletus edulis — which my friend regarded with the reverence usually reserved for something religious.
I took home about 600g of mixed mushrooms and some deeply practical lessons about what I didn't know.
## On Flavour
Different mushrooms taste genuinely different in ways that matter for cooking.
**Chanterelles**: Fruity, slightly peppery, delicate. Best treated simply — butter, garlic, thyme. They do not need much company.
**Porcini (ceps)**: The most intensely savoury wild mushroom. Glutamate-rich. Their flavour is deeper and meatier than anything farmed. Dried porcini are one of the most powerful flavour concentrators in any kitchen.
**Hen-of-the-woods (maitake)**: More substantial in texture than most, with a woodsy, slightly spicy quality. Excellent for high-heat searing because their fronds crisp beautifully.
**Bay boletes**: Milder than porcini but with the same family character. Good for drying.
**Farmed alternatives**: Oyster mushrooms have a gentle, shellfish quality and a beautiful texture. Shiitake are reliable and rich. Cremini and chestnut are neutral workhorses. None have the character of wild mushrooms, but dried porcini added to any combination will provide the umami backbone that wild mushrooms supply naturally.
## Drying Mushrooms
Drying concentrates flavour dramatically and extends shelf life indefinitely. Slice mushrooms thinly (57mm). Spread on a rack in a low oven (5060°C) with the door slightly ajar, or use a dehydrator, for 46 hours until completely dry and brittle. Store in an airtight jar.
**Rehydrating**: Soak in warm water for 2030 minutes. The soaking liquid is extremely flavourful — use it as stock. Pour carefully to leave the grit at the bottom of the bowl.
## Wild Mushroom Pappardelle (serves 4)
**Ingredients:**
- 400g pappardelle (or tagliatelle)
- 400g mixed fresh mushrooms (whatever you have — chanterelle, chestnut, oyster, maitake)
- 20g dried porcini, soaked in 150ml warm water
- 3 cloves garlic, thinly sliced
- 3 tbsp olive oil
- 40g unsalted butter
- 100ml dry white wine
- 100ml double cream
- A small bunch of fresh thyme
- Fresh flat-leaf parsley, roughly chopped
- Parmigiano Reggiano to serve
- Salt and black pepper
**Method:**
Drain the porcini, reserving the soaking liquid. Chop the porcini roughly.
Tear or cut the fresh mushrooms into pieces — irregular shapes are fine and create more texture than uniform slices.
Heat olive oil and half the butter in a large, wide pan over high heat until shimmering. Add the fresh mushrooms in a single layer (work in batches if needed — overcrowding causes steaming instead of browning). Leave them undisturbed for 2 minutes, then toss. You want golden-brown patches on the mushrooms, not grey-steamed ones. Season with salt.
Add the garlic and thyme, cook for 1 minute. Add the chopped porcini. Add the wine and let it reduce by half. Add the porcini soaking liquid, pouring carefully to leave any grit behind. Reduce by half again. Add the cream. Simmer gently for 34 minutes until slightly thickened. Remove the thyme stems.
Cook the pappardelle in heavily salted water until al dente. Reserve a mug of pasta water. Drain and add to the mushroom sauce with the remaining butter. Toss vigorously, adding pasta water if needed, until the sauce coats the pasta.
Finish with parsley and serve immediately with Parmigiano grated at the table.
## The Lesson About Foraging
My friend told me something on that October walk that I have thought about often since. She said: "When you forage, you stop looking at the forest as scenery and start reading it. You notice moisture patterns, which trees grow where, how the light affects the soil temperature. You stop being a visitor." This is true of cooking too. When you understand what you're doing and why, you stop following recipes and start reading the food.
I came home with the mushrooms, with mud on my boots, and with a much clearer sense of what I had been doing wrong in my kitchen for years.

View file

@ -1,76 +0,0 @@
---
title: "Why I Make Stock Every Sunday (And You Should Too)"
created: 2024-11-28 10:00
author: Amelia Fontaine
keywords: stock, broth, chicken stock, veal stock, vegetable stock, technique
description: Chicken, veal, vegetable, and fish stock recipes — the difference between stock and broth, how to freeze it, and what stock makes possible in your cooking.
---
# Why I Make Stock Every Sunday (And You Should Too)
Stock is not glamorous. It is also not optional if you want to cook seriously. Every great sauce, every braised meat, every risotto, every soup — behind all of them is a question: what liquid are you using? If the answer is water or a stock cube, there is a ceiling on what is possible. Good stock removes that ceiling.
I make stock every Sunday, usually while doing other things. The bones go in the pot, the water goes on, and three hours later I have something that will unlock the whole week's cooking.
## Stock vs. Broth: The Actual Difference
These terms are used interchangeably in casual conversation, but they refer to different things.
**Stock** is made primarily from bones, with or without some meat. The long cooking time (26 hours depending on type) extracts gelatin from the collagen in the bones. When chilled, good stock sets to a jelly. This gelatin is what gives sauces their body, their gloss, their ability to coat a spoon.
**Broth** is made primarily from meat, cooked for a shorter time. It has more flavour from the meat but less body from gelatin. Broths are better for drinking or for light soups; stocks are better for reducing into sauces.
The distinction matters most when reducing. If you reduce a stock to intensify it, the gelatin concentrates and the result is viscous, glossy, and extraordinary. If you reduce a broth to the same degree, you often get something oversalted and thin.
## Chicken Stock
This is the most versatile and the one to start with.
**Ingredients:**
- Roast chicken carcass (or 11.5kg raw chicken bones/wings/feet)
- 1 large onion, halved
- 2 carrots, roughly chopped
- 2 celery stalks
- 1 head of garlic, halved crosswise
- 1 bay leaf
- A few peppercorns
- Cold water to cover (about 22.5 litres)
**Method:** If using raw bones, roast them at 220°C for 30 minutes until golden (this adds depth and colour). Place everything in a large pot and cover with cold water. Bring very slowly to a simmer — this is important; a rapid boil makes the stock cloudy. Skim the grey foam from the surface in the first 15 minutes. Simmer very gently, partially covered, for 34 hours. Strain through a fine sieve. Cool completely, then refrigerate; skim the solidified fat from the surface.
## Veal (Brown) Stock
The king of stocks. More laborious, more complex, the foundation of the classical French kitchen's grand sauces.
Roast 2kg veal bones (knuckles and necks are best) at 220°C until deeply browned, 45 minutes. Brown 2 onions, 3 carrots, and 3 celery stalks in a large pot until dark. Add the bones and cover with cold water. Simmer 68 hours. Strain, reduce by a third.
This stock, reduced by three-quarters, becomes *demi-glace* — a dark, intensely gelatinous, barely pourable concentrate that transforms any sauce it touches.
## Vegetable Stock
Made in 45 minutes. No long cooking required — vegetables release their flavour quickly and can turn bitter if cooked too long.
Onion, carrot, celery, fennel, leek tops, garlic, mushroom stems, parsley stems, peppercorns, bay leaf. No cruciferous vegetables (cabbage, broccoli) which add bitterness. Cover with cold water, bring to a simmer, cook 40 minutes. Strain.
Flavour it from the beginning; it will not develop complexity over time like bones do.
## Fish Stock
The quickest of all: 2025 minutes only, or it turns bitter.
Fish frames (the bones and head, with gills removed), leek, onion, fennel, white wine, peppercorns, bay leaf. Add all to cold water, bring to a simmer, skim, cook 20 minutes. Strain.
## Freezing
Stock freezes perfectly. For storage efficiency, reduce it by half before freezing — you can always add water when you use it. Freeze in ice cube trays for small amounts (good for pan sauces), in 300ml containers for risotto and soups, and in 1 litre bags for braises. Label with date and type. Use within six months.
## What Stock Makes Possible
Once you have good stock in your freezer:
- **Pan sauces** become 10-minute wonders rather than 45-minute projects
- **Risotto** tastes completely different (see the spring pea risotto post)
- **Braised meats** have a depth of flavour impossible to achieve with water
- **Soups** need almost no other flavouring — the stock does the work
- **Rice dishes** gain complexity that is impossible to fake
Stock is, at bottom, a patience tax: you invest Sunday morning so that the rest of the week's cooking is easier and better. It is the most productive kitchen habit I know.

View file

@ -1,83 +0,0 @@
---
title: "Grandmother Lucia's Christmas Cookies: Cuccidati and Brutti ma Buoni"
created: 2024-12-20 09:00
author: Amelia Fontaine
keywords: Christmas cookies, cuccidati, brutti ma buoni, Italian baking, Sicilian
description: Two Italian Christmas cookies from my grandmother's kitchen — fig-filled Sicilian cuccidati and craggy hazelnut brutti ma buoni — with the family story behind them.
---
# Grandmother Lucia's Christmas Cookies: Cuccidati and Brutti ma Buoni
My grandmother Lucia baked these cookies every December without a written recipe. She had made them so many times since her mother taught her in Catania in the 1950s that the quantities lived in her hands rather than in her head. The first time I watched carefully enough to write things down, she was eighty-one, and she regarded my notebook with amused scepticism. "You're going to measure everything?" she said in Italian, as if this were a charming eccentricity.
I was. These are the results.
## Cuccidati (Sicilian Fig Cookies)
*Cuccidati* — pronounced ku-chi-DAH-tee — are the Christmas cookie of Sicily: a buttery pastry encasing a dark, fragrant filling of dried figs, nuts, candied fruit, and spices. They are sometimes called *buccellati* in western Sicily, and the variations are endless. Every family has their version. This is Lucia's.
### The Filling (make first — it needs to rest)
- 400g dried figs, stems removed
- 100g raisins
- 80g blanched almonds, roughly chopped and toasted
- 50g walnuts, roughly chopped
- 50g candied orange peel, chopped
- 4 tbsp honey
- 50ml Marsala (or brandy or orange juice)
- Zest of 1 orange
- 1 tsp ground cinnamon
- ½ tsp ground cloves
- ¼ tsp black pepper
Place the figs and raisins in a food processor and pulse until finely chopped but not puréed — you want texture. Combine with all other ingredients in a bowl and mix well. Cover and refrigerate for at least one hour, ideally overnight, so the flavours meld. The filling can be made three days ahead.
### The Pastry
- 400g plain flour
- 1 tsp baking powder
- Pinch of salt
- 80g caster sugar
- 150g cold unsalted butter, cubed
- 1 large egg
- 60ml cold water (approximately)
- Zest of 1 lemon
Combine flour, baking powder, salt, and sugar. Rub in the butter until it resembles breadcrumbs. Beat the egg with the lemon zest and add to the bowl with most of the water. Bring together into a soft, non-sticky dough, adding more water if needed. Wrap and refrigerate for 30 minutes.
### Assembly
Preheat oven to 180°C. Roll the pastry to about 3mm thickness. Cut into rectangles approximately 8cm × 12cm. Place a sausage of filling (about 1.5cm diameter, 8cm long) along the long edge. Roll up and pinch the seam closed. Bend into a horseshoe shape, or cut into 3cm pieces for straight cookies.
Place on baking paper-lined trays. Make three diagonal slashes across the top of each. Bake for 1822 minutes until golden. Cool completely before glazing.
**Glaze**: Mix 150g icing sugar with enough lemon juice to make a thick glaze. Drizzle over cooled cookies. Top with coloured sugar strands or finely chopped pistachios if you like.
---
## Brutti ma Buoni (Ugly but Good)
The name is exactly right. These hazelnut and meringue cookies look craggy, irregular, and entirely resistant to elegance. They are extraordinary: intensely nutty, chewy at the centre, crisp at the edge, flavoured with vanilla and a touch of spice.
- 300g blanched hazelnuts
- 200g caster sugar
- 3 egg whites
- 1 tsp vanilla extract
- ½ tsp cinnamon
- Pinch of salt
Toast the hazelnuts at 180°C for 8 minutes until golden. Cool slightly, then pulse in a food processor until roughly chopped — you want some chunks, some powder.
Whisk the egg whites and salt to stiff peaks. Gradually add the sugar, whisking between each addition, until you have a stiff, glossy meringue. Fold in the hazelnuts, vanilla, and cinnamon.
Transfer the mixture to a saucepan. Cook over medium heat, stirring constantly, for 58 minutes until the mixture dries slightly and pulls away from the sides. It will become quite stiff and less glossy. Remove from heat.
Drop spoonfuls (about the size of a tablespoon) onto baking paper-lined trays. They will be irregular — this is their character. Bake at 160°C for 2530 minutes until firm and lightly golden. They firm further as they cool.
---
## How to Gift-Pack Them
Line a tin with tissue paper. Place cuccidati in a single layer, then a layer of greaseproof paper, then brutti ma buoni on top. The cookies keep for 1014 days in a cool place. They improve after two or three days as the flavours settle.
Lucia always sent them wrapped in newspaper (the most insulating material available in 1970s Catania) secured with kitchen string. I use better packaging now but the principle is the same: make more than you think you need, give most of them away, and eat the imperfect ones yourself.

Some files were not shown because too many files have changed in this diff Show more