Compare commits

..

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

503 changed files with 1355 additions and 58333 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

@ -31,12 +31,6 @@ jobs:
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
@ -77,9 +71,9 @@ jobs:
--url "https://github.com/kbenestad/mdcms" \
--maintainer "Kristian Benestad" \
--license "Apache-2.0" \
--architecture "${{ matrix.os == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" \
--architecture amd64 \
--category utils \
dist/${{ matrix.artifact_name }}=/usr/local/bin/mdcms
dist/mdcms-linux-amd64=/usr/local/bin/mdcms
- name: Upload binary artifact
uses: actions/upload-artifact@v4
@ -94,7 +88,7 @@ jobs:
if: matrix.make_deb
uses: actions/upload-artifact@v4
with:
name: deb-package-${{ matrix.artifact_name }}
name: deb-package
path: "*.deb"
release:
@ -126,8 +120,6 @@ jobs:
--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
artifacts/deb-package/*.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

119
CLAUDE.md
View file

@ -2,26 +2,10 @@
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.
- **Code changes** (`mdcms.py`, `pyproject.toml`, `app/`, `.github/`) — use branch `mdcms_cli` (create from `main` if it doesn't exist), PR to `main`.
- **Documentation only** (`CLAUDE.md`, `docs/`) — push directly to `main`.
## What this project is
@ -38,8 +22,8 @@ The `app/` folder is the deployable artifact and the starter template downloaded
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
index.html ← renderer + v0.3 version marker
config.yml ← starter config + v0.3 version marker
nav.yml ← generated
search.json ← generated
pages/
@ -71,22 +55,6 @@ During development, run directly: `python3 mdcms.py <command>`
| `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.
@ -101,7 +69,7 @@ Single-module Python script. Logical layers in order:
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.
8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json.
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()`.
@ -109,9 +77,8 @@ Single-module Python script. Logical layers in order:
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`
- `config.yml` line 1: `# mdcms v0.3 | DO NOT REMOVE THIS COMMENT`
- `index.html` line 1: `<!-- mdcms v0.3 | 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.
@ -151,7 +118,8 @@ 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
date: 2025-01-01
datetime: 2025-01-01 13:00 # use this for posts (not `date` alone — see known limitations)
modified: 2025-01-15 09:00
keywords: foo, bar
description: Short description for search
@ -174,7 +142,6 @@ For nested navigation, set `parent: <parent-section-code>` and `parent-sort` on
- 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)
@ -182,13 +149,13 @@ Embed post lists in pages using fenced blocks:
````markdown
```mdcms
posts-created-reversechronological
posts-datetime-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.
Reliable tags (others are known-broken): `posts-datetime-chronological-byyearmonth`, `posts-datetime-reversechronological`. Use `datetime` frontmatter (format: `YYYY-MM-DD HH:MM`) for posts`date` alone does not work reliably.
## Release workflow
@ -207,74 +174,12 @@ All artifacts are attached to the GitHub release using `gh release create`. The
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.
Then: `git tag v0.3.1 && git push origin v0.3.1`
## 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

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

View file

@ -1,5 +1,5 @@
# mdcms v0.6.0 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.6.0 — Site configuration
# mdcms v0.3 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.3 — Site configuration
#
# Only `sitename` and `navigation` are required. Uncomment and edit the rest
# as needed. See https://kbenestad.codeberg.page/md-cms for the full reference.
@ -16,21 +16,12 @@
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licence is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the Licence for the specific language governing permissions and
# limitations under the Licence.
# ──────────────────────────────────
# Site identity
# ──────────────────────────────────
sitename: MD-CMS Phase 7 Test
navigation: sidebar # sidebar | topbar
theme: theme.yml # presentational config — edit theme.yml to customise colours, fonts, and layout
pwa: yes
pwa-name: MD-CMS Phase 7 Test
pwa-shortname: MDCMS Test
pwa-colour: "#2563EB"
offline-message: "This page is not available offline. Connect to the internet and reload."
sitename: MD-CMS New Site
navigation: topbar # sidebar | topbar
# homepage: pages/home.md # override the default landing page
@ -39,25 +30,22 @@ offline-message: "This page is not available offline. Connect to the internet an
# favicon: favicon.png
# footer: "© 2026 Your Name"
# ──────────────────────────────────
# Typography (optional)
# ──────────────────────────────────
# font-title: "Inter:700"
# font-body: Inter
# font-code: JetBrains Mono
# ──────────────────────────────────
# Layout (optional)
# ──────────────────────────────────
# main-width: 80em
# nav-width: 20em
# nav-position: left # left | right (sidebar mode)
# Typography and colours are configured in theme.yml, not here.
# ──────────────────────────────────
# Features (optional)
# ──────────────────────────────────
# search: true
# default-theme: system # light | dark | system
# ──────────────────────────────────
# Reusable callout messages (optional)
# ──────────────────────────────────
callouts:
aitranslation:
type: warning
en:
title: "PLEASE NOTE:"
text: This page has been translated with artificial intelligence. It has not been reviewed by staff yet.

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,6 +1,7 @@
# nav.yml — generated by mdcms
# nav.yml — generated by mdcms.py
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
# pagesvisibility, categorynames) are preserved on rebuild.
# pagesvisibility, categorynames) are preserved on rebuild. New sections
# are auto-created from page frontmatter section-id values.
sections:
# (none yet — add section-id to page frontmatter to auto-create)
@ -8,15 +9,6 @@ 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
variants: [en]
titles:
en: Home

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

@ -3,24 +3,65 @@ title: Home
sort: 100
---
# Phase 7 — PWA Test
# MD-CMS
This page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.
This is the default startpage for MD-CMS.
## Test procedure
## Testing MD-CMS
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
If you want to test `MD-CMS` you can grab `samplesite` from the repo and place the content in your website root. This page (`pages/home.md`) won't be replaced.
## What to look for
**Post listing tests** below contains various custom tags to display posts. There are no posts now, but if you download the `samplesite` it will fetch the posts in
- `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
## Post listing tests
## Reverse chronological (newest first)
```mdcms
posts-date-reversechronological
limit: 3
paginate: no
```
## Chronological (oldest first)
```mdcms
posts-date-chronological
limit: all
paginate: none
```
## By year (date, reverse chrono)
```mdcms
posts-date-reversechronological-byyear
limit: all
defaultyear: current
selectyear: yes
paginate: none
```
## By year+month (datetime, chrono)
```mdcms
posts-datetime-chronological-byyearmonth
limit: all
defaultyear: 2024
selectyear: yes
```
## Last 30 days
```mdcms
posts-date-reversechronological-lastmonth
limit: all
paginate: none
```
## Paginated (2 per page)
```mdcms
posts-datetime-reversechronological
limit: 2
paginate: yes
```

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

@ -1,28 +1,4 @@
[
{
"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",
@ -30,21 +6,10 @@
"keywords": "",
"description": "",
"author": null,
"created": "",
"modified": "",
"date": "",
"datetime": "",
"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"
"body": "# Post Listing Tests\n\n## Reverse chronological (newest first)\n\n```mdcms\nposts-date-reversechronological\nlimit: 3\npaginate: no\n```\n\n## Chronological (oldest first)\n\n```mdcms\nposts-date-chronological\nlimit: all\npaginate: none\n```\n\n## By year (date, reverse chrono)\n\n```mdcms\nposts-date-reversechronological-byyear\nlimit: all\ndefaultyear: current\nselectyear: yes\npaginate: none\n```\n\n## By year+month (datetime, chrono)\n\n```mdcms\nposts-datetime-chronological-byyearmonth\nlimit: all\ndefaultyear: 2024\nselectyear: yes\n```\n\n## Last 30 days\n\n```mdcms\nposts-date-reversechronological-lastmonth\nlimit: all\npaginate: none\n```\n\n## Paginated (2 per page)\n\n```mdcms\nposts-datetime-reversechronological\nlimit: 2\npaginate: yes\n```\n",
"category": "en"
}
]

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 @@
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,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 +1,169 @@
# Setting up MD-CMS for your site
This document walks you through the installation of MD-CMS.
# Setting up mdcms for your site
## 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.
This guide walks through installing mdcms, registering your site, and setting up automatic builds on GitHub so that `nav.yml` and `search.json` are always up to date when you push content.
## Recommended install
To properly use MD-CMS you need to download the CLI tool.
## 1. Install mdcms
### 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`.
Choose one method:
#### 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
**Standalone binary (no Python required — recommended for most users)**
Download the binary for your platform from the [latest release](https://github.com/kbenestad/mdcms/releases/latest):
- **Linux:** `mdcms-linux-amd64` → move to `/usr/local/bin/mdcms` and `chmod +x`
- **Linux (deb):** `mdcms_<version>_amd64.deb``sudo dpkg -i mdcms_<version>_amd64.deb`
- **macOS:** `mdcms-macos-arm64` → move to `/usr/local/bin/mdcms` and `chmod +x`
- **Windows:** `mdcms-windows-amd64.exe` → rename to `mdcms.exe` and place it somewhere on your PATH
**Via pip (from GitHub)**
```bash
pip install git+https://github.com/kbenestad/mdcms.git
```
#### 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
**Via pipx (from GitHub)**
```bash
pipx install git+https://github.com/kbenestad/mdcms.git
```
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
Verify the installation:
```bash
mdcms --version
```
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
## 2. Register your site
In Windows 10 or 11, open PowerShell and run the following command:
Navigate to your site directory (where `index.html` lives) and register it:
```
Invoke-WebRequest https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/windows/mdcms.exe -OutFile "$env:USERPROFILE\AppData\Local\Microsoft\WindowsApps\mdcms.exe"
```bash
cd /path/to/your/site
mdcms register mysite
```
Verify which version you have installed by running `mdcms --version`.
Or pass the path explicitly:
```bash
mdcms register mysite /path/to/your/site
```
## Update
**If no mdcms site exists at that path**, mdcms will download the starter template from GitHub automatically — `index.html`, `config.yml`, `pages/`, `posts/`, and `assets/` will be created for you.
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.
**If a site already exists** (you cloned an existing mdcms repo), mdcms will detect the version marker in `config.yml` and register it as-is.
---
## 3. Edit your config
Open `config.yml` in your site directory and set at minimum:
```yaml
sitename: Your Site Name
navigation: topbar # or: sidebar
```
---
## 4. Build locally
Run a build to generate `nav.yml` and `search.json` from your content:
```bash
mdcms build mysite
```
Check your site by starting a local server:
```bash
cd /path/to/your/site
python3 -m http.server 8800
```
Then open `http://localhost:8800` in your browser.
---
## 5. Set up automatic builds on GitHub
When you push new pages or posts to GitHub, you'll want `nav.yml` and `search.json` rebuilt automatically. This is done with a simple GitHub Actions workflow.
### One-time GitHub setup
1. Go to your site repository on GitHub
2. **Settings → Actions → General**
3. Under **Workflow permissions**, select **Read and write permissions**
4. Click **Save**
This allows the workflow to commit the rebuilt files back to your repository.
### Add the workflow file
Create `.github/workflows/build.yml` in your site repository with this content:
```yaml
name: Build
on:
push:
branches:
- main
paths:
- "pages/**"
- "posts/**"
- "config.yml"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-latest
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
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
```
Commit and push this file to your repository. From that point on, every push that touches `pages/`, `posts/`, or `config.yml` will automatically rebuild and commit `nav.yml` and `search.json`.
> The workflow only commits if the files actually changed — `git diff --staged --quiet` skips the commit if nothing is different.
---
## Day-to-day workflow
1. Write or edit a page in `pages/` or a post in `posts/`
2. Commit and push to `main`
3. GitHub Actions rebuilds `nav.yml` and `search.json` automatically (~30 seconds)
4. Your deployed site picks up the changes immediately
To build locally at any time (without pushing):
```bash
mdcms build mysite
```
---
## Useful commands
```bash
mdcms view # list all registered sites
mdcms view mysite # show details for a site
mdcms build mysite # build nav.yml + search.json
mdcms build --path . # build from current directory (no registry needed)
mdcms delete mysite # remove a site from the local registry
```

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.

30
docs/knownlimitations.md Normal file
View file

@ -0,0 +1,30 @@
# 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`
To correctly show posts, use `datetime` in frontmatter.
The tag `created` is defined in the frontmatter as the created date of a file, but it is not used.
Use `datetime` to indicate the date and time a file was created, using the format `YYYY-MM-DD HH:MM` (e.g., `2026-01-14 13:35`).
Use `modified` using the format `YYYY-MM-DD HH:MM` (e.g., `2026-01-14 13:35`) to show users when a file was last updated.

34
docs/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 [docs.benestad.net](https://docs.benestad.net) for documentation.

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,10 +1,10 @@
# Release workflow
# Releasing a new version of mdcms
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.
This guide covers publishing a new mdcms release — producing binaries, a `.deb` package, and a GitHub release with all artifacts attached.
## Prerequisites
- Push access to your GitHub release repository
- Push access to `kbenestad/mdcms` on GitHub
- GitHub Actions enabled on the repository (it is by default on new repos)
## One-time GitHub setup
@ -14,7 +14,7 @@ This document covers the automatic creation of release binaries of MD-CMS for Li
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**
2. **Settings → Actions → General**
3. Scroll to **Workflow permissions**
4. Select **Read and write permissions**
5. Click **Save**
@ -24,60 +24,47 @@ 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:
Before tagging a release, update the version number in two places:
**`mdcms.py`** — find this line near the top and bump it:
```python
CLI_VERSION = "0.3.8"
CLI_VERSION = "0.3.1"
```
**`pyproject.toml`** — bump the matching line:
```toml
version = "0.3.8"
version = "0.3.1"
```
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
```
> **Site format version** — the markers in `app/config.yml` and `app/index.html` (`# mdcms v0.3`) are separate from the CLI version. Only update them if the site file format has a breaking change that requires users to re-download the template. Most releases do not touch these.
#### Commit locally
Commit the version bump:
```bash
git add mdcms.py pyproject.toml
git commit -m "Bump version to 0.3.8"
git commit -m "Bump version to 0.3.1"
git push origin main
```
#### Commit on the web
Save each file with a version bump notice: `"Bump version to 0.3.8"`.
Save each file with a version bump notice: `"Bump version to 0.3.1"`.
### Tagging the release
#### Locally
Push a version tag to trigger the workflow:
```bash
git tag v0.3.8
git push origin v0.3.8
git tag v0.3.1
git push origin v0.3.1
```
#### 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. Click Choose a tag → type v0.3.0 → click Create new tag: v0.3.0 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.
@ -106,14 +93,11 @@ A final job collects all artifacts and creates the GitHub release with auto-gene
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
git tag -d v0.3.1
git push origin --delete v0.3.1
# fix the issue, then re-tag
git tag v0.3.1
git push origin v0.3.1
```
## The finished release
@ -121,6 +105,3 @@ git push origin v0.3.8
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,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.

477
mdcms.py
View file

@ -1,28 +1,16 @@
#!/usr/bin/env python3
#
# mdcms v0.6.0 — CLI companion
# mdcms v0.3 — CLI companion
#
# 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.
# Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0
"""MD-CMS v0.6.0 — CLI tool for managing and building MD-CMS sites."""
"""MD-CMS v0.3 — CLI tool for managing and building MD-CMS sites."""
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
from pathlib import Path
@ -32,21 +20,14 @@ import certifi
import click
import yaml
CLI_VERSION = "0.6.0"
CLI_RELEASE_DATE = "7 June 2026"
CLI_VERSION = "0.3"
MIN_SUPPORTED_VERSION = "0.3"
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
REGISTRY_FILE = Path.home() / ".config" / "mdcms" / "sites.json"
TEMPLATE_BASE_URL = "https://raw.githubusercontent.com/kbenestad/mdcms/main/app"
MANIFEST_FILENAME = "mdcms.json"
GITHUB_URL_RE = re.compile(
r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?"
r"(?:/tree/([^/]+?)(?:/(.+?))?)?/?$"
)
GITHUB_CONTENTS_API = "https://api.github.com/repos/kbenestad/mdcms/contents/app"
# ─── Version helpers ──────────────────────────────────────────
@ -119,12 +100,9 @@ def read_config(site_path: Path) -> dict:
return {}
try:
text = config_file.read_text(encoding="utf-8")
except OSError as e:
raise click.ClickException(f"Could not read config.yml: {e}")
try:
return yaml.safe_load(text) or {}
except yaml.YAMLError as e:
raise click.ClickException(f"config.yml is not valid YAML: {e}")
except (OSError, yaml.YAMLError):
return {}
def get_category_info(cfg: dict) -> dict:
@ -196,6 +174,8 @@ def scan_and_categorize(directory: Path, site_root: Path, known_codes: set) -> l
"sort": meta.get("sort"),
"section-id": meta.get("section-id"),
"author": meta.get("author"),
"date": str(meta.get("date", "")),
"datetime": str(meta.get("datetime", "")),
"created": str(meta.get("created", "")),
"modified": str(meta.get("modified", "")),
"language": meta.get("language", "en"),
@ -274,19 +254,11 @@ def build_page_nav(
"sort": sort,
}
if categories_use:
is_post = file.startswith("posts/")
covered = {}
has_uncategorized = False
for code, record in variants.items():
if code is None:
if is_post:
has_uncategorized = True
elif default_code:
covered[default_code] = record.get("title", "")
else:
covered[code] = record.get("title", "")
if has_uncategorized:
entry["uncategorized"] = True
key = code if code is not None else default_code
if key:
covered[key] = record.get("title", "")
entry["variants"] = sorted(covered.keys())
entry["titles"] = covered
out.append(entry)
@ -330,8 +302,6 @@ def generate_nav_yml(sections: list, pages: list, categories_use: bool = False)
if p.get("section-id"):
lines.append(f" section-id: {p['section-id']}")
lines.append(f" sort: {p.get('sort', 100)}")
if categories_use and p.get("uncategorized"):
lines.append(" uncategorized: true")
if categories_use and p.get("variants"):
lines.append(f" variants: [{', '.join(p['variants'])}]")
if categories_use and p.get("titles"):
@ -357,99 +327,20 @@ def generate_search_json(
"keywords": r.get("keywords", ""),
"description": r.get("description", ""),
"author": r.get("author"),
"created": r.get("created", ""),
"modified": r.get("modified", ""),
"date": r.get("date", ""),
"datetime": r.get("datetime", ""),
"language": r.get("language", "en"),
"body": r.get("body", ""),
}
if categories_use:
code = r.get("code")
is_post = r.get("file", "").startswith("posts/")
if code is not None:
entry["category"] = code
elif is_post:
entry["category"] = None # null = show in all categories
else:
entry["category"] = default_code
entry["category"] = code if code is not None else default_code
out.append(entry)
return json.dumps(out, indent=2, ensure_ascii=False)
# ─── Asset validation ─────────────────────────────────────────
_ASSET_RE = re.compile(r'assets/[\w.\-/]+')
def _collect_yaml_assets(val, source: str, out: list):
if isinstance(val, str):
if val.startswith("assets/"):
out.append((val, source))
elif isinstance(val, dict):
for v in val.values():
_collect_yaml_assets(v, source, out)
elif isinstance(val, list):
for item in val:
_collect_yaml_assets(item, source, out)
def validate_assets(site_path: Path, cfg: dict) -> list:
"""Return list of warning strings for assets/ references that don't exist on disk."""
refs: list = []
_collect_yaml_assets(cfg, "config.yml", refs)
theme_file = cfg.get("theme")
if theme_file:
theme_path = site_path / theme_file
if theme_path.exists():
try:
theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {}
_collect_yaml_assets(theme_data, theme_file, refs)
except (OSError, yaml.YAMLError):
pass
for folder in ("pages", "posts"):
d = site_path / folder
if not d.is_dir():
continue
for md_file in sorted(d.rglob("*.md")):
try:
content = md_file.read_text(encoding="utf-8")
rel = str(md_file.relative_to(site_path)).replace("\\", "/")
for m in _ASSET_RE.finditer(content):
refs.append((m.group(), rel))
except OSError:
pass
warnings = []
seen: set = set()
for asset_path, source in refs:
key = (asset_path, source)
if key in seen:
continue
seen.add(key)
if not (site_path / asset_path).exists():
warnings.append(
f"Warning: asset not found: {asset_path}\n Referenced in: {source}"
)
return warnings
# ─── Core build logic ─────────────────────────────────────────
_TITLE_RE = re.compile(r"<title>[^<]*</title>")
def _patch_html_title(site_path: Path, sitename: str) -> None:
index = site_path / "index.html"
if not index.exists():
return
html = index.read_text(encoding="utf-8")
new_html = _TITLE_RE.sub(f"<title>{sitename}</title>", html, count=1)
if new_html != html:
index.write_text(new_html, encoding="utf-8")
def run_build(site_path: Path):
"""Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure."""
if not site_path.is_dir():
@ -529,18 +420,6 @@ def run_build(site_path: Path):
)
click.echo(f" Wrote search.json ({len(live_pages) + len(post_records)} entries)")
_patch_html_title(site_path, cfg.get("sitename", ""))
pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true")
if pwa_enabled:
generate_pwa(site_path, cfg)
else:
cleanup_pwa(site_path)
asset_warnings = validate_assets(site_path, cfg)
for w in asset_warnings:
click.echo(click.style(w, fg="yellow"))
if auto_created:
click.echo(click.style(
f"\nNotice: {len(auto_created)} section(s) auto-created: {', '.join(auto_created)}\n"
@ -548,127 +427,10 @@ def run_build(site_path: Path):
fg="cyan",
))
generate_site_manifest(site_path)
# ─── GitHub template download ─────────────────────────────────
# ─── PWA generation ───────────────────────────────────────────
def cleanup_pwa(site_path: Path):
"""When pwa: no, write a self-unregistering service worker and remove manifest.json.
Browsers keep the previously installed service worker active until a new one is
installed. Writing a stub that immediately unregisters itself ensures any stale
caching worker is evicted on the next visit after a pwa: yes pwa: no change.
"""
sw = site_path / "service-worker.js"
sw.write_text(
"// mdcms: PWA disabled — unregisters any previously installed service worker.\n"
"self.addEventListener('install', () => self.skipWaiting());\n"
"self.addEventListener('activate', event => {\n"
" event.waitUntil(self.registration.unregister());\n"
"});\n",
encoding="utf-8",
)
manifest = site_path / "manifest.json"
if manifest.exists():
manifest.unlink()
click.echo(" Removed manifest.json (pwa: no)")
click.echo(" Wrote service-worker.js (self-unregistering stub, pwa: no)")
def generate_pwa(site_path: Path, cfg: dict):
"""Generate manifest.json and service-worker.js when pwa: yes."""
pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site"))
pwa_shortname = cfg.get("pwa-shortname", pwa_name)
pwa_colour = cfg.get("pwa-colour", "#2563EB")
favicon = cfg.get("favicon", "favicon.png")
icon_src = f"assets/images/{favicon}"
icons = []
if (site_path / icon_src).exists():
icons = [
{"src": icon_src, "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": icon_src, "sizes": "512x512", "type": "image/png", "purpose": "any"},
]
# manifest.json
manifest = {
"id": "/",
"name": pwa_name,
"short_name": pwa_shortname,
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": pwa_colour,
"icons": icons,
}
(site_path / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
click.echo(" Wrote manifest.json")
# Collect all files to precache
precache: list = [
"index.html", "config.yml", "nav.yml", "search.json",
]
theme_file = cfg.get("theme")
if theme_file and (site_path / theme_file).exists():
precache.append(theme_file)
for folder in ("pages", "posts", "assets"):
d = site_path / folder
if not d.is_dir():
continue
for f in sorted(d.rglob("*")):
if f.is_file():
precache.append(str(f.relative_to(site_path)).replace("\\", "/"))
# Version hash — deterministic from sorted file list
cache_hash = format(hash(tuple(sorted(precache))) & 0xFFFFFFFF, "08x")
cache_name = f"mdcms-{cache_hash}"
urls_js = json.dumps(precache, indent=2, ensure_ascii=False)
sw = f"""// mdcms service worker — generated by mdcms build
const CACHE_NAME = '{cache_name}';
const PRECACHE_URLS = {urls_js};
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))
);
}});
"""
(site_path / "service-worker.js").write_text(sw, encoding="utf-8")
click.echo(f" Wrote service-worker.js (cache: {cache_name})")
# ─── HTTP helpers ─────────────────────────────────────────────
def _http_get(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
ctx = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
return resp.read()
def _http_get_github(url: str) -> bytes:
"""HTTP GET with GitHub API Accept header (for Contents API responses)."""
def _github_get(url: str) -> bytes:
req = urllib.request.Request(
url,
headers={
@ -681,134 +443,23 @@ def _http_get_github(url: str) -> bytes:
return resp.read()
# ─── Site manifest generation ─────────────────────────────────
def generate_site_manifest(site_path: Path):
"""Write mdcms.json to site_path listing all deployable files and empty dirs."""
files = []
empty_dirs = []
for entry in sorted(site_path.rglob("*")):
rel = entry.relative_to(site_path)
# Skip anything inside a hidden directory or with a hidden name
if any(p.startswith(".") for p in rel.parts):
continue
if entry.is_file():
rel_str = str(rel).replace("\\", "/")
if rel_str != MANIFEST_FILENAME:
files.append(rel_str)
elif entry.is_dir():
# Only list dirs that have no non-hidden children
visible = [c for c in entry.iterdir() if not c.name.startswith(".")]
if not visible:
empty_dirs.append(str(rel).replace("\\", "/"))
manifest: dict = {
"mdcms": read_site_version(site_path) or "0.4",
"files": files,
}
if empty_dirs:
manifest["dirs"] = empty_dirs
(site_path / MANIFEST_FILENAME).write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
click.echo(f" Wrote {MANIFEST_FILENAME} ({len(files)} files)")
# ─── Template download ────────────────────────────────────────
def _parse_github_url(url: str) -> "tuple | None":
"""Return (owner, repo, branch, subpath) for a GitHub URL, else None."""
m = GITHUB_URL_RE.match(url.strip())
if not m:
return None
owner = m.group(1)
repo = m.group(2)
branch = m.group(3) or "main"
subpath = (m.group(4) or "").strip("/")
return owner, repo, branch, subpath
def _fetch_manifest(base_url: str) -> "dict | None":
"""Fetch mdcms.json from base_url. Returns parsed dict or None if not found."""
url = base_url.rstrip("/") + "/" + MANIFEST_FILENAME
try:
data = _http_get(url)
manifest = json.loads(data.decode("utf-8"))
if isinstance(manifest.get("files"), list):
return manifest
except Exception:
pass
return None
def _apply_manifest(manifest: dict, base_url: str, dest: Path):
"""Download all files in manifest from base_url into dest."""
base = base_url.rstrip("/")
for rel in manifest.get("files", []):
file_dest = dest / rel
file_dest.parent.mkdir(parents=True, exist_ok=True)
click.echo(f" {rel}")
file_dest.write_bytes(_http_get(f"{base}/{rel}"))
for rel in manifest.get("dirs", []):
(dest / rel).mkdir(parents=True, exist_ok=True)
def _download_tree_api(api_url: str, dest: Path, depth: int = 0):
"""Recursively download from the GitHub Contents API (fallback when no manifest)."""
items = json.loads(_http_get_github(api_url).decode("utf-8"))
def _download_tree(api_url: str, dest: Path, depth: int = 0):
items = json.loads(_github_get(api_url).decode("utf-8"))
for item in items:
item_dest = dest / item["name"]
if item["type"] == "dir":
item_dest.mkdir(parents=True, exist_ok=True)
_download_tree_api(item["url"], item_dest, depth + 1)
_download_tree(item["url"], item_dest, depth + 1)
elif item["type"] == "file":
click.echo(f" {' ' * depth}{item['name']}")
item_dest.parent.mkdir(parents=True, exist_ok=True)
item_dest.write_bytes(_http_get(item["download_url"]))
item_dest.write_bytes(_github_get(item["download_url"]))
def download_template(dest: Path, source: str = None):
"""Download a site template from a URL or GitHub address.
source may be:
- A GitHub repo URL (https://github.com/owner/repo or .../tree/branch/path)
- Any HTTPS URL pointing to a deployed mdcms site that has mdcms.json
- None uses the built-in mdcms starter template
"""
effective = (source or TEMPLATE_BASE_URL).rstrip("/")
def download_template(dest: Path):
click.echo(f"Downloading site template into {dest} ...")
try:
github = _parse_github_url(effective)
if github:
owner, repo, branch, subpath = github
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
if subpath:
raw_base = f"{raw_base}/{subpath}"
manifest = _fetch_manifest(raw_base)
if manifest is not None:
_apply_manifest(manifest, raw_base, dest)
else:
# No manifest — fall back to GitHub Contents API tree walk
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents"
if subpath:
api_url = f"{api_url}/{subpath}"
if branch not in ("main", "master"):
api_url += f"?ref={branch}"
_download_tree_api(api_url, dest)
else:
manifest = _fetch_manifest(effective)
if manifest is None:
if source:
raise click.ClickException(
f"No {MANIFEST_FILENAME} found at {effective}.\n"
"The URL must point to a deployed mdcms site with a manifest, "
"or to a GitHub repository."
)
raise click.ClickException(
f"Could not fetch template manifest from {effective}"
)
_apply_manifest(manifest, effective, dest)
_download_tree(GITHUB_CONTENTS_API, dest)
click.echo(click.style("Template downloaded successfully.", fg="green"))
except urllib.error.URLError as e:
raise click.ClickException(f"Download failed: {e}")
@ -816,29 +467,8 @@ def download_template(dest: Path, source: str = None):
# ─── CLI commands ─────────────────────────────────────────────
def _version_callback(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo(f"mdcms v{CLI_VERSION} (released {CLI_RELEASE_DATE})")
url = f"https://raw.githubusercontent.com/kbenestad/mdcms/refs/heads/main/docs/banner/v{CLI_VERSION}.txt?t={int(time.time())}"
try:
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
with urllib.request.urlopen(req, context=ssl_ctx, timeout=5) as resp:
click.echo(resp.read().decode("utf-8").strip())
except urllib.error.HTTPError as e:
if e.code == 404:
click.echo("There is no online information defined for this version.")
else:
click.echo("There is no online information defined for this version.")
except Exception:
click.echo("There is no online information defined for this version.")
ctx.exit()
@click.group()
@click.option("--version", is_flag=True, is_eager=True, expose_value=False,
callback=_version_callback, help="Show version and exit.")
@click.version_option(CLI_VERSION, prog_name="mdcms")
def cli():
"""MD-CMS — Markdown-based CMS companion CLI.
@ -848,22 +478,12 @@ def cli():
@cli.command()
@click.argument("name")
@click.argument("path", required=False, default=None)
@click.option("--from", "source", default=None, metavar="URL",
help="Download template from a GitHub repo or deployed site URL.")
def register(name, path, source):
@click.argument("path", required=False, default=None, type=click.Path())
def register(name, path):
"""Register a site by NAME at PATH (default: current directory).
PATH may be a local directory or a URL to download from. If no mdcms
site is found at the local path, the template is downloaded from --from
(or PATH if it is a URL, or the built-in mdcms starter by default).
\b
Examples:
mdcms register mysite
mdcms register mysite ./mydir
mdcms register mysite https://github.com/owner/repo
mdcms register mysite --from https://example.com/deployed-site
If no mdcms site is found at the target path, the starter template is
downloaded from GitHub automatically.
"""
reg = load_registry()
@ -872,12 +492,6 @@ def register(name, path, source):
f"'{name}' is already registered. Use 'mdcms delete {name}' to remove it first."
)
# If PATH looks like a URL, treat it as the download source rather than a local path.
if path and path.startswith(("http://", "https://", "git://")):
if source is None:
source = path
path = None
site_path = Path(path).resolve() if path else Path.cwd()
if not site_path.is_dir():
@ -895,7 +509,7 @@ def register(name, path, source):
if site_version is None:
click.echo(f"No mdcms site found at {site_path}.")
download_template(site_path, source)
download_template(site_path)
site_version = read_site_version(site_path)
if site_version is None:
raise click.ClickException(
@ -1033,39 +647,6 @@ def build(name, path_override):
click.echo(click.style("Build complete.", fg="green"))
@cli.command("fetch-deps")
@click.argument("name", required=False, default=None)
@click.option("--path", "path_override", default=None, type=click.Path(),
help="Explicit site path (no registry lookup).")
def fetch_deps(name, path_override):
"""Download external JS/CSS dependencies and patch index.html for offline use."""
site_path = resolve_site_path(name, path_override)
if not (site_path / "index.html").exists():
raise click.ClickException(f"No index.html found at {site_path}")
click.echo(f"Fetching dependencies for {site_path} ...")
vendors_dir = site_path / "assets" / "required" / "vendors"
vendors_dir.mkdir(parents=True, exist_ok=True)
for cdn_url, rel_dest in CDN_DEPS:
dest = site_path / rel_dest
click.echo(f" {rel_dest}")
try:
dest.write_bytes(_http_get(cdn_url))
except Exception as e:
raise click.ClickException(f"Failed to download {cdn_url}: {e}")
cfg = read_config(site_path)
local_font_css: list = []
if cfg.get("theme"):
local_font_css = _fetch_bunny_fonts(site_path, cfg["theme"])
_patch_index_html(site_path, local_font_css)
click.echo(click.style("Done. Site is ready for offline use.", fg="green"))
# ─── Entry point ─────────────────────────────────────────────
def main():

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mdcms"
version = "0.6.0"
version = "0.3.0"
description = "MD-CMS — Markdown-based CMS companion CLI"
readme = "README.md"
license = { text = "Apache-2.0" }

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.

View file

@ -1,63 +0,0 @@
---
title: "Knife Skills: The One Investment Worth Making in Your Cooking"
created: 2025-01-15 10:00
author: Amelia Fontaine
keywords: knife skills, chopping, chef's knife, sharpening, technique
description: Which knife to buy, how to sharpen it, and five essential cuts explained with technique — including why a sharp knife is not just convenient but fundamental.
---
# Knife Skills: The One Investment Worth Making in Your Cooking
If someone asked me where to spend their first serious kitchen investment, I would not say a stand mixer, a cast-iron pan, or a sous vide machine. I would say: one good knife and the time to learn to use it.
A skilled cook with a sharp knife and a wooden board can do almost anything. The average home cook with a drawer full of mediocre, dull knives is constantly fighting their ingredients, which is why cooking sometimes feels exhausting.
## Which Knife
You need one knife primarily: an 8-inch (20cm) chef's knife. Everything else is supplementary. A paring knife for small work, a serrated bread knife for bread — but the chef's knife is the tool you will use for 90% of kitchen tasks.
**What to look for:**
- **Full tang**: The metal should extend all the way through the handle. Partial-tang knives are less balanced and more likely to fail.
- **Weight**: A matter of preference. Heavier knives (German-style, like Wüsthof or Henckels) power through root vegetables. Lighter knives (Japanese-style, like Global or MAC) are more agile for precise work. Try them in your hand before buying if possible.
- **Steel hardness**: Measured in Rockwell hardness (HRC). Japanese knives are typically HRC 6065 (harder, holds an edge longer, more brittle). German knives are typically HRC 5658 (softer, easier to resharpen, less likely to chip). Both work excellently.
You do not need to spend a fortune. A reliable £6080 chef's knife from Victorinox or similar will outperform a poorly maintained £300 knife. What matters more than the knife is the sharpening.
## Sharpening: The Non-Negotiable
A dull knife is dangerous. It requires force, which causes slipping and loss of control. A sharp knife does the work; your job is merely to guide it.
**Whetstone** (recommended): The best method. Use a double-sided stone, 1000 grit (medium) on one side for regular maintenance, 30006000 grit (fine) for polishing. Hold the blade at 1520 degrees to the stone (German knives: 20 degrees; Japanese: 15 degrees). Move the blade across the stone as if slicing a thin layer off the top, heel to tip. Ten strokes per side, then switch. Finish on the fine grit.
This takes practice. The first few times are imperfect. Within a month of weekly sessions, you will feel the difference.
**Honing steel**: Not sharpening — honing realigns the edge between sharpenings. Use before every significant cooking session. The edge of a knife bends microscopically with use; honing straightens it.
**Electric sharpener**: Convenient but aggressive. Removes more metal than a whetstone. Use occasionally as a backup, not as a primary method.
Test sharpness: a sharp knife should glide through a sheet of paper cleanly, or shave arm hair without dragging.
## The Five Essential Cuts
**1. The Chop (rough cut)**
For onions, root vegetables, and anything that doesn't require precision. Curl your fingers so the knuckles act as a guide for the flat of the blade. The knife never leaves contact with the board — you rock from the tip forward. This technique protects your fingertips.
**2. The Slice**
For meat, fish, bread, soft vegetables. A single, fluid motion from heel to tip. Never press or saw; draw the knife through the material. Pressure causes crushing; motion causes cutting.
**3. The Julienne**
Cut the vegetable into planks, then stack and cut into matchsticks. Requires a very sharp knife to avoid crushing soft vegetables. Essential for stir-fries and salads.
**4. The Brunoise (fine dice)**
Julienne first, then cut across the matchsticks to produce tiny cubes (23mm). The gold standard for mirepoix, garnishes, and anything where you want the vegetable to disappear into a sauce.
**5. The Chiffonade**
Stack leafy herbs or greens, roll tightly, and slice crosswise into thin ribbons. Minimises bruising compared to chopping.
## The Onion, Properly
The onion is the test of knife skill. Make two horizontal cuts parallel to the board, stopping before the root end. Make multiple vertical cuts from top to root end, again stopping before the root. Then slice crosswise to produce a fine dice that holds together until the last cut because the root end acts as the anchor. The whole thing should take 20 seconds with practice.
Achieving this requires a sharp knife and the technique above. With a dull knife, onions do not cut — they crush, releasing their sulphurous compounds and causing significantly more eye irritation. Another reason sharpening is not optional.
The investment in knife skill repays itself immediately and permanently. It is the one thing in cooking that, once learned, never stops saving you time.

View file

@ -1,63 +0,0 @@
---
title: "On Slow Food: Why I Stopped Making Quick Dinners"
created: 2025-02-08 11:00
author: Amelia Fontaine
keywords: ribollita, Tuscan soup, slow cooking, beans, bread soup, philosophy
description: A personal essay on unhurried cooking, what it teaches, and a recipe for ribollita — the classic Tuscan bean and bread soup that rewards patience.
---
# On Slow Food: Why I Stopped Making Quick Dinners
There is a cooking genre that has dominated food media for years: the thirty-minute meal. I understand its appeal. Most evenings, after work, a long recipe feels like a burden rather than a pleasure. And yet I have become increasingly resistant to optimising everything in my kitchen for speed, because the things I cook quickly are consistently the things I care about least.
This is not an argument against efficiency. It is an argument for noticing what the efficient mode costs you.
When I make ribollita — the Tuscan bean and bread soup that requires at minimum a day of preparation and ideally two — I am in contact with something different from when I assemble a weeknight pasta. The process demands attention at intervals: checking the beans as they soak, tasting the soup as it reduces, deciding whether it needs more kale, more bread, more time. The cooking is a form of thinking, and the thinking changes how I relate to what I'm eating.
Carlo Petrini, who founded the Slow Food movement in 1989, was arguing partly about sourcing — buying from small producers, preserving food cultures — but the underlying idea is also about tempo: that the pace at which we engage with food shapes what the food means to us.
I am not evangelical about this. Quick meals have their place. But I notice that the meals I remember, the ones that feel genuinely nourishing rather than merely functional, are almost always the ones that took time.
## Ribollita
The name means "reboiled." This is a soup that is made one day and eaten the next, when the bread has fully absorbed the broth and the whole pot needs to be reheated — reboiled — before serving. It is cheap, warming, and in its complexity of flavour, as satisfying as any elaborate preparation.
**Ingredients (serves 6):**
- 400g dried cannellini beans, soaked overnight
- 1 head cavolo nero (black kale), stalks removed, roughly chopped
- ½ savoy cabbage, roughly shredded
- 1 large onion, roughly chopped
- 3 stalks celery, chopped
- 3 carrots, chopped
- 4 cloves garlic, sliced
- 400g tin whole plum tomatoes
- 4 tbsp olive oil, plus more for serving
- 1 sprig rosemary
- 2 bay leaves
- 200g day-old Tuscan or sourdough bread, roughly torn
- Salt and black pepper
- Parmigiano Reggiano rind (if you have it — adds considerable depth)
**Day One:**
Drain the soaked beans and cover with fresh cold water in a large pot. Add the Parmigiano rind if using. Bring to a simmer and cook for 11.5 hours until completely tender. Do not add salt until the last 10 minutes — it toughens the skins. Drain, reserving the cooking liquid. Mash or blend about a third of the beans until smooth; leave the rest whole.
In the same pot, warm the olive oil over medium heat. Cook the onion, celery, and carrot for 12 minutes until soft. Add the garlic, rosemary, and bay. Cook for 2 minutes. Add the tomatoes, crushing them with your hand as you add them. Cook for 10 minutes.
Add the whole and puréed beans, the cabbage, and the cavolo nero. Pour in the reserved bean cooking liquid and enough water to make a thick, substantial soup. Bring to a simmer and cook for 30 minutes.
Add the torn bread and stir it in. The bread will absorb the liquid and disintegrate partially, thickening the soup into something between a soup and a stew. Season generously. Remove the rosemary sprig and bay leaves.
Cool, cover, and refrigerate overnight.
**Day Two:**
Reheat gently, adding a little water if needed — it will have thickened further. Bring back to a simmer and cook for 1520 minutes. Taste and adjust seasoning.
Serve in deep bowls with a generous pour of your best olive oil over the top, a grinding of black pepper, and Parmigiano if you like. In Florence they sometimes add a drizzle of new-season olive oil and nothing else.
## What Slow Cooking Teaches
It teaches you that the most important ingredient in cooking is often time, which money cannot substitute for. It teaches patience, because you cannot make the beans cook faster without a pressure cooker, and even then the texture changes in ways that are less interesting. It teaches attention, because slow-cooked food needs checking and tasting as it develops.
And it teaches proportion — that a Sunday afternoon in the kitchen is not time lost but time spent. The ribollita that arrives on Monday evening required no effort that day at all. It is simply there, waiting, improved by its rest, ready to give you something back.

View file

@ -1,66 +0,0 @@
---
title: "Eggs Benedict and the Science of Hollandaise"
created: 2025-03-28 09:30
author: Amelia Fontaine
keywords: eggs benedict, hollandaise, poaching eggs, brunch, sauce
description: The emulsion science behind hollandaise, why it breaks and how to fix it, perfect poached eggs, and classic eggs Benedict assembly.
---
# Eggs Benedict and the Science of Hollandaise
Hollandaise has a fearsome reputation, and the fear is understandable: it is an emulsified butter sauce that breaks easily, cannot be made far in advance, and requires you to pay attention during service — the moment when you least want another technical demand. The reputation is somewhat deserved.
But understanding *why* hollandaise behaves as it does makes it dramatically more manageable. It is, at its core, chemistry. The chemistry is not complicated once you know it.
## The Emulsion
An emulsion is a stable mixture of two liquids that would normally separate: in hollandaise, fat (butter) and water (lemon juice, the water in egg yolks). These two phases resist mixing because fat molecules are nonpolar and water molecules are polar. Left alone, they separate.
The stabiliser is lecithin, found in egg yolks at high concentrations. Lecithin molecules have a nonpolar end (attracted to fat) and a polar end (attracted to water). They position themselves at the boundary between fat and water droplets, surrounding the fat droplets and preventing them from coalescing.
This works only within a temperature range: warm enough to keep the butter fluid and to partially cook the egg proteins (which helps stabilise the emulsion), but not so hot that the proteins cook fully and coagulate, which causes the sauce to "break" — separate into greasy curds.
The ideal temperature for hollandaise is 6070°C. Below this range it is too thin; above it breaks.
## The Method
**Ingredients (serves 4):**
- 4 egg yolks
- 250g unsalted butter, clarified (or just very good quality, melted and warm)
- 2 tbsp water
- 1 tbsp white wine vinegar
- Juice of half a lemon
- Salt and white pepper
**Clarifying butter** removes the milk solids and water, leaving pure butterfat. This gives a more stable emulsion and a cleaner flavour, though whole butter (used at room temperature) also works and is easier.
**Method:**
1. In a small saucepan, reduce the white wine vinegar with the water by half. Set aside to cool slightly.
2. In a heatproof bowl, whisk the egg yolks with the reduced liquid until pale and thickened — they should leave a trail (a "ribbon") when the whisk is lifted.
3. Set the bowl over a pan of barely simmering water (the bowl should not touch the water). Continue whisking while the mixture warms, 34 minutes, until it thickens further and holds a ribbon. This is the *sabayon* — the base.
4. Remove from the heat. Begin adding the warm clarified butter in a very thin stream while whisking constantly. The first few tablespoons are critical — add them very slowly, building the emulsion. Once the emulsion is established, you can add the butter more quickly.
5. When all the butter is incorporated, adjust with lemon juice, salt, and white pepper. The sauce should coat the back of a spoon heavily.
**If it breaks:** If you see greasy, curdled separation, it is usually because the temperature was too high or the butter was added too quickly. To rescue: start with a clean bowl and a fresh egg yolk whisked with a tablespoon of warm water. Very slowly whisk the broken sauce into this new base, treating it as the butter in the original recipe.
Keep hollandaise warm by setting the bowl over warm (not hot) water, whisking occasionally. Use within 3045 minutes.
## Poaching Eggs
Use the freshest eggs possible — older eggs spread more because the white becomes more liquid. Room temperature is preferable to cold from the fridge.
Bring a wide pan of water to a bare simmer. Add a splash of white wine vinegar (it helps the white cohere, though this is debated). Create a gentle swirl in the water with a spoon. Crack the egg into a small cup, lower the cup to the water surface, and slide the egg in gently. The swirl wraps the white around the yolk. Poach for 3 minutes for a runny yolk, 4 minutes for a more set result.
Lift with a slotted spoon, drain on kitchen paper. Trim any ragged white edges with scissors for a clean presentation.
## Assembly
**Eggs Benedict:** Split, toast, and butter English muffins. Top each half with a slice of back bacon (or Canadian bacon) that has been briefly warmed. Set a poached egg on top. Pour hollandaise generously over. Finish with a small amount of cayenne or smoked paprika.
**Eggs Royale:** As above but with smoked salmon instead of bacon.
**Eggs Florentine:** As above but with wilted spinach, squeezed very dry, instead of bacon.
The whole assembly takes about 15 minutes once you have the hollandaise made. The eggs can be poached ahead of time and kept in cold water, then reheated by placing in warm water for 60 seconds.
Eggs Benedict is weekend cooking at its best: technically interesting, visually impressive, and deeply satisfying to eat.

View file

@ -1,55 +0,0 @@
---
title: "A Year of Eating Seasonally: What I Learned"
created: 2025-05-10 10:00
author: Amelia Fontaine
keywords: seasonal eating, produce, UK seasons, local food, vegetables
description: A month-by-month guide to seasonal produce in Northern Europe, what eating with the seasons changed about cooking, and the real cost comparison.
---
# A Year of Eating Seasonally: What I Learned
Three years ago I made an experiment: for one year, I would cook primarily with whatever was in season in the UK, buying from farmers' markets when possible and from supermarkets when necessary but choosing seasonal produce. No tomatoes in January, no asparagus in October. I kept a food diary.
What I expected: virtuous inconvenience, occasional genuine pleasure, and a sense of moral superiority.
What I found: far better food than I had been eating, a dramatically changed relationship with the kitchen calendar, and — this surprised me most — lower grocery bills.
## Month-by-Month: Northern European Seasonal Guide
**January / February**
Root vegetables at their peak after frost (parsnips, celeriac, beetroot, swede). Leeks, Brussels sprouts, kale, Savoy cabbage. Forced rhubarb arrives in late January — pink and tender. Blood oranges from Sicily and Spain. Bergamot lemons briefly. Game birds if you eat them.
**March / April**
The hungry gap — the hardest time. Purple sprouting broccoli bridges it magnificently. Spring greens, wild garlic (from hedgerows and woods), radishes, early spinach. Jersey Royal new potatoes appear in late April — small, earthy, best boiled and eaten with good butter. Nothing else required.
**May / June**
The garden wakes up. Asparagus season (MayJune, approximately six weeks — eat it every day). Peas and broad beans, best eaten young and raw or barely cooked. Strawberries from mid-June. Early courgettes and their flowers. Elderflower for cordial and fritters. Wet garlic — young, soft-skinned, sweet, milder than cured.
**July / August**
The abundance. Tomatoes, courgettes, cucumbers, French beans. Sweetcorn. Raspberries, blueberries, gooseberries. Plums and early apples. Fennel. Aubergines in a good summer.
**September / October**
The transition into autumn is the most dramatic flavour shift of the year. Wild mushrooms. Autumn raspberries continue. Quince — underused and extraordinary in paste and as an accompaniment to cheese. Cobnuts. Main crop apples and pears at their best. Butternut squash and pumpkins. Jerusalem artichokes begin.
**November / December**
Roots again, and brassicas at their best after frost (a frost improves both Brussels sprouts and parsnips by converting starch to sugar). Chestnuts. Seville oranges arrive in December for marmalade. Celery.
## What Changed
**Cooking became easier.** When you stop trying to make out-of-season ingredients taste like themselves, you stop fighting your food. A parsnip in January is magnificent. A parsnip in July is pointless.
**The same vegetables, prepared differently, stopped boring me.** By February I had been eating celeriac for three months and had remoulade, dauphinoise, roasted, puréed, raw, in soup, and with preserved lemon. The constraint produced creativity I would not have found otherwise.
**I discovered vegetables I had ignored.** Swede. Salsify. Jerusalem artichokes (marvellous despite their intestinal reputation, which is somewhat exaggerated). Purple sprouting broccoli, which I now grow in my own small garden because the window of freshness before it reaches shops is part of its quality.
## The Cost Comparison
Seasonal, locally produced vegetables at farmers' markets are sometimes more expensive per unit than supermarket imports. But the yield is different. A genuinely ripe July tomato is twice the tomato of a January hothouse import — you use fewer, you eat more slowly, it satisfies better.
Across the year, my food costs were slightly lower, primarily because I stopped throwing away vegetables that had been disappointing enough to not eat.
## The One Compromise
I kept buying citrus fruit year-round. The lemons that are fundamental to most of my cooking have no British equivalent, and I was not prepared to become a purist at the expense of most of my sauces. I also kept tinned tomatoes for winter — at their best they are superior to fresh winter tomatoes and I feel no guilt about this at all.
Within those limits, the experiment became permanent. I cook this way now not because I decided to continue the experiment but because it simply became how I cook.

View file

@ -1,65 +0,0 @@
---
title: "The Focaccia That Changed How I Think About Bread"
created: 2025-06-25 09:00
author: Amelia Fontaine
keywords: focaccia, Ligurian, bread, olive oil, high hydration, overnight
description: Ligurian focaccia with rosemary — the high-hydration dough science, dimple technique, olive oil pools, and an overnight cold proof that transforms the texture.
---
# The Focaccia That Changed How I Think About Bread
I had made focaccia a dozen times before I went to Liguria, and each time it had been perfectly acceptable: flat, dimpled, herbed, slightly oily. Fine. The focaccia I ate at a bakery in Recco on a Monday morning in October was something else entirely — thin, blistered, puffy at the edges and nearly hollow in the centre, utterly saturated with local olive oil and sea salt, the texture something between bread and a cloud.
I stood outside eating it from a paper bag and immediately began trying to understand what had happened to it.
## What Makes Ligurian Focaccia Different
Standard focaccia is about 7075% hydration (water weight as a percentage of flour weight). Ligurian focaccia — *focaccia al formaggio* and the simpler *focaccia classica* — runs at 8085% or higher. More water means a more open crumb structure, lighter texture, and larger air pockets. It also means the dough is harder to handle: it spreads and sticks and refuses to behave like normal bread dough.
The solution is not more flour. The solution is understanding that this dough is not meant to be shaped the way a boule or a baguette is shaped. It is poured into the pan. Handled with wet hands. Stretched gently by gravity. This is a fundamentally different relationship with the dough.
The olive oil is not a finishing touch. It is a structural element. An absurd quantity of olive oil goes into the bottom of the pan before the dough, and a generous pour goes on top after dimpling. As the focaccia bakes, this oil fries the bottom of the bread while the steam from the high-water dough creates the airy interior. The result is a bread that is crisp underneath, soft and pillowy within, and absolutely soaked with oil throughout.
## The Recipe
**Ingredients:**
- 500g strong bread flour (or 00 flour)
- 430ml warm water (86% hydration)
- 10g fine salt
- 7g instant dried yeast (or 14g fresh)
- 1 tsp honey
- 8 tbsp (120ml) good quality olive oil, divided
- Flaky sea salt
- Fresh rosemary sprigs
**Equipment:** A 30×40cm baking tray (rimmed), or two smaller trays.
**Method:**
Dissolve the honey in the warm water. Add the yeast and let stand for 5 minutes. Combine flour and salt in a large bowl. Add the liquid and 4 tablespoons of the olive oil. Mix until combined — the dough will be sticky and shaggy. Do not add more flour.
Cover the bowl and leave at room temperature for 30 minutes. Then perform three sets of stretch-and-folds at 30-minute intervals: reach underneath the dough, stretch upward, fold over the top, rotate the bowl a quarter turn, repeat four times per set.
After the final fold, cover tightly and refrigerate overnight (816 hours). Cold fermentation develops flavour dramatically and makes the dough much easier to handle.
**The next day:**
Remove from the fridge and allow to warm for 1 hour. Pour 3 tablespoons of olive oil into the baking tray, coating the base entirely. Tip the dough onto the tray and gently stretch it toward the corners — do not force it; let it rest 5 minutes and stretch again. With a very wet or oiled hand, prod the dough to dimple it all over: press firmly, all the way to the base of the pan, every centimetre.
Mix the remaining olive oil with 4 tablespoons of water and pour this emulsion over the surface. The dimples will fill with oil-water pools — this is correct and desirable. Scatter generously with flaky salt and press rosemary sprigs into the dimples.
Leave to prove at room temperature for 4560 minutes until noticeably puffed.
Bake at 230°C (fan 210°C) for 2025 minutes until deep golden and blistered. The top should have some very dark patches — this is part of the character, not burning.
Cool for at least 10 minutes before eating, though it is extraordinarily good still warm.
## The Oil-Water Emulsion
The poured emulsion on top — oil and water together — is the technique I learned from reading about the Ligurian focaccerie. By the time it goes into the oven, the oil and water have separated into their constituent phases. The water steams in the oven, helping the surface bubble and blister, while the olive oil prevents the surface from drying and enables the characteristic golden-speckled finish. This is the technique that produces the texture I was eating in Recco.
## What It Taught Me
Focaccia taught me that hydration is not just a technical variable but a choice about what kind of bread you want to make. More water means more open texture means more delicacy. The trade-off is handling difficulty. Once I stopped trying to handle high-hydration dough like regular dough — stopped treating it as a problem to be solved — it became significantly more interesting to work with.
Bread, I think, rewards an attitude of curiosity more than one of mastery. It changes with the flour, the season, the humidity, the particular wild microorganisms in your starter or your water. The focaccia you make in January is not quite the focaccia you make in July. This is not a flaw. It is the thing that makes it interesting to keep making.

View file

@ -1,58 +0,0 @@
---
title: "Everything I Know About Olive Oil (It Took 10 Years to Learn)"
created: 2025-08-14 11:00
author: Amelia Fontaine
keywords: olive oil, extra virgin, cooking, polyphenols, sourcing guide
description: Pressing methods, polyphenols, smoke points, fraudulent EVOO, which to cook with versus finish with, and a practical sourcing guide.
---
# Everything I Know About Olive Oil (It Took 10 Years to Learn)
I have been thinking about olive oil for ten years and I am still not sure I understand it fully. It is, in the world of cooking ingredients, unusually complex — variable by region, variety, vintage, harvest date, pressing method, and storage — and the fraud rate in the industry is high enough that even careful shoppers are routinely misled.
What follows is what I have learned, mostly through buying, tasting, cooking, and occasionally ruining things.
## What "Extra Virgin" Actually Means
Extra virgin olive oil (EVOO) is produced by mechanically pressing fresh olives — no heat, no chemicals — and meeting specific quality standards: free acidity below 0.8%, and organoleptic standards requiring the oil to have positive attributes (fruitiness, bitterness, pungency) and no defects (rancidity, mustiness, winey notes).
The category below this is "virgin" (acidity up to 2%, some defects permitted), and below that is "olive oil" — a blend of refined oil (chemically treated to remove defects) and virgin oil. These are quite different products.
The problem is that "extra virgin" on a label guarantees very little in practice. The fraud issue is substantial: studies repeatedly find that 5080% of olive oil sold as Italian EVOO in international markets either fails quality standards or has been diluted with other oils. The EU has certification systems, but enforcement is inconsistent.
## How to Buy Better
The indicators of genuine quality:
- **Harvest date**: Look for it on the label. EVOO is perishable — it should ideally be used within 18 months of harvest and definitely within 2 years. "Best before" is a poor proxy; harvest date is what matters.
- **Single origin, single estate**: Oil from a single producer in a specific region is more verifiable than blends.
- **PDO/PGI designation**: Protected Designations of Origin (Tuscan EVOO, Kalamata PDO) have more rigorous controls.
- **Tin rather than dark glass**: Light degrades oil. Opaque tins are the ideal container. Clear glass is the worst.
- **Peppery burn**: Good EVOO — especially Tuscan and Sicilian varieties — should cause a noticeable burn at the back of the throat when tasted neat. This is the polyphenols, and it is a quality marker, not a flaw.
## Polyphenols
Polyphenols are the antioxidant compounds in olive oil, associated with most of its health benefits and responsible for the distinctive bitterness and pungency of high-quality oils. Early harvest oils (October-November, before full ripeness) contain more polyphenols but are more aggressive in flavour. Late harvest oils are milder and rounder.
High-polyphenol olive oils — which are increasingly available from specialist producers — have measurements above 250mg/kg on the label. These are robust, almost savoury, and can taste almost bitter neat. They are extraordinary for dressing strong-flavoured foods.
## Smoke Point and Cooking
The great misunderstanding: "olive oil has a low smoke point and shouldn't be used for high-heat cooking." This is largely wrong. **Extra virgin olive oil's actual smoke point is 190210°C**, depending on quality and age. This is more than sufficient for sautéing, shallow frying, and roasting. The smoke point of cheap refined oils is often higher, but polyphenols in EVOO make it more oxidatively stable at high temperatures.
The practical advice: do not deep-fry in EVOO (the economics are prohibitive and the smoke point, while adequate, gives less margin). For everything else — sautéing, roasting, making dressings — extra virgin is fine and often produces better flavour than neutral oils.
## Finishing vs. Cooking
There is a meaningful distinction between oils used to cook with and oils used to finish dishes. Cooking oil gets hot; much of its aroma evaporates and its flavour integrates into the dish. Finishing oil goes on cold or room-temperature food and its full character is experienced directly.
For cooking: a mid-range EVOO (£812 for 500ml) is excellent and economical. The heat will integrate rather than present its flavour.
For finishing: this is where a genuinely exceptional oil earns its price. A Sicilian *Nocellara del Belice* (full, fruity, grassy) or a Tuscan *Moraiolo* (pungent, bitter, peppery) drizzled over bruschetta, soup, fish, or legumes transforms the dish in a way that a cooking oil cannot.
## Storage
Away from heat, away from light, used within 6 months of opening. Not next to the stove; not on a windowsill; not in a clear bottle on a kitchen counter. In a cool cupboard, in a tin or dark bottle, used regularly. Rancid oil — which smells of crayons or old butter — is the most common quality problem and entirely preventable.
## The Practical Upshot
Buy from a specialist importer or directly from a producer if you can. Look for a harvest date. Spend more on the finishing oil you taste directly; spend less on the cooking oil the heat will transform. Keep it in the dark and use it promptly. Taste it out of the bottle sometimes — you will learn more about it that way than any other. It should make you want to eat something.

View file

@ -1,70 +0,0 @@
---
title: "Roasted Butternut Squash Soup with Crispy Sage and Brown Butter"
created: 2025-09-30 10:00
author: Amelia Fontaine
keywords: butternut squash, soup, brown butter, sage, autumn, roasting
description: Roasting vs steaming for depth, brown butter science, sage frying technique, and a full recipe with variations for a perfect autumn soup.
---
# Roasted Butternut Squash Soup with Crispy Sage and Brown Butter
The question with any squash soup is whether to roast the squash first or to steam or boil it. The answer, if you want the best-tasting soup, is always to roast. Steaming produces a pale, sweet, slightly watery result. Roasting produces caramelisation and depth that dramatically change what the soup can be.
The science is the Maillard reaction again: sugars in the squash combine with amino acids at high heat to produce hundreds of new flavour compounds. Squash is particularly susceptible to this because of its high sugar content. Roasted at 200°C until the cut surfaces are deeply golden and sticky, a butternut squash is a fundamentally different ingredient from a boiled one.
The brown butter amplifies this in a direction that seems designed for this vegetable specifically.
## Brown Butter
*Beurre noisette* — noisette meaning hazelnut, for the colour and aroma — is butter that has been cooked until the milk solids brown. The transformation happens in three stages:
1. Butter melts and the water begins to evaporate (you hear it fizzing).
2. The milk solids separate and begin to colour. The butter goes from opaque and milky to clear and golden.
3. The milk solids turn brown and the butter smells of toasted hazelnuts and toffee. This is the goal.
The danger: stage 3 transitions to stage 4 (burning) in about 30 seconds. Watch it constantly and remove from the heat the moment it smells right — it will continue to colour from the residual heat of the pan.
Brown butter adds a toasted, nutty complexity to anything that contains cream or dairy. On soup, drizzled over at serving, it is remarkable.
## Sage Crisps
Sage fried in butter or oil becomes a completely different ingredient: the volatile aromatic compounds that can make fresh sage taste slightly medicinal transform into something woody and resinous and addictive. Fried sage crisps are one of the best things you can put on soup, pasta, risotto, or gnocchi.
Heat a shallow layer of olive oil or clarified butter in a small pan until a sage leaf sizzles immediately on contact. Fry the leaves in batches for 3045 seconds until darkened and crisp — they continue to crisp as they drain. Remove to kitchen paper immediately. They keep for several hours at room temperature.
## The Recipe (serves 4)
**Ingredients:**
- 1 large butternut squash (about 1.2kg), halved, seeds removed
- 1 large onion, roughly chopped
- 4 cloves garlic, unpeeled
- 750ml vegetable or chicken stock, warm
- 100ml double cream
- 3 tbsp olive oil
- Salt, pepper, nutmeg
**For the brown butter:**
- 80g unsalted butter
- A handful of fresh sage leaves
**Method:**
Brush the squash halves with 2 tablespoons of olive oil. Season generously with salt and pepper. Place cut-side down on a baking tray with the unpeeled garlic cloves and roast at 200°C for 4555 minutes until the cut surface is deeply golden and the flesh is completely tender. Cool slightly.
Meanwhile, soften the onion in 1 tablespoon of olive oil in a large pot over medium heat until translucent and sweet, about 12 minutes.
Scoop the squash flesh from the skin. Squeeze the roasted garlic from its skins. Add both to the pot with the onion. Add the stock and bring to a simmer for 5 minutes.
Blend until completely smooth — a high-powered blender produces the best result; use a hand blender if that's all you have. Add the cream, a generous grating of nutmeg, and more salt and pepper. Taste carefully.
**To serve:** Reheat the soup gently if needed. Ladle into warm bowls. Make the brown butter in a small pan, watching carefully, and pull off the heat at hazelnut colour. Add the sage leaves to crisp in the same pan (the butter will sizzle vigorously). Drizzle brown butter over each bowl and top with crispy sage.
## Variations
**Spiced version**: Add 1 tsp ground cumin, ½ tsp smoked paprika, and a pinch of chilli to the onion as it softens. Finish with a swirl of yoghurt instead of cream, and toasted pumpkin seeds instead of sage.
**Thai-inspired**: Replace the cream with coconut milk. Add a stalk of lemongrass and a slice of galangal (or ginger) while blending, then strain. Finish with lime juice and fresh coriander.
**The bread bowl**: Hollow out a small round sourdough loaf and serve the soup inside it. Theatrical and more satisfying than it has any right to be.
The plain version, with brown butter and sage, is my preferred autumn lunch: made on a Sunday, reheated during the week, always excellent. The depth from the roasting means it doesn't need elaborate garnishes — the soup itself is doing most of the work.

View file

@ -1,85 +0,0 @@
---
title: "Cassoulet: A Two-Day Recipe Worth Every Minute"
created: 2025-11-05 09:00
author: Amelia Fontaine
keywords: cassoulet, confit duck, Toulouse sausage, beans, French, slow cooking
description: History of cassoulet, the Toulouse vs Castelnaudary debate, confit duck, Toulouse sausages, haricot tarbais beans — the full two-day recipe.
---
# Cassoulet: A Two-Day Recipe Worth Every Minute
Cassoulet is the greatest argument for unhurried cooking that I know. It is a Languedoc bean casserole made with confit duck, Toulouse sausages, and slow-cooked pork — a dish that takes two days and rewards the effort with something that no quick version can approximate.
There is a formal, quasi-legal dispute about its origins that I find charming in its intensity. The towns of Carcassonne, Toulouse, and Castelnaudary all claim cassoulet as their own, and the specific composition of the "authentic" version differs by town. Carcassonne includes lamb; Toulouse includes lamb and preserved goose; Castelnaudary uses pork, pork rind, and duck confit only. The Academy of Cassoulet in Toulouse publishes official rules.
My version is closer to Toulouse. I do not have official standing.
## The Components
**Haricot Tarbais beans** are the traditional choice — grown in the Bigorre region of the Pyrénées since the 17th century, with a thin skin and mealy, creamy interior. They are available online from specialist suppliers and are genuinely worth seeking out. Dried haricot blanc or cannellini are acceptable alternatives.
**Confit duck legs** can be made at home (the method follows), or bought from a good butcher or online supplier. The home-made version is superior.
**Toulouse sausages** are coarse-ground pork with salt, pepper, nutmeg, and wine — nothing more. They are available from French specialist butchers. Do not substitute ordinary sausages; the flavour and texture are fundamentally different.
## Day One: Confit Duck and Bean Preparation
### Confit Duck Legs
- 4 duck legs
- 30g coarse salt
- 4 bay leaves
- 10 peppercorns
- 4 sprigs fresh thyme
- 4 cloves garlic, crushed
- About 600g duck fat (or a combination of duck fat and lard)
Combine the salt, bay, peppercorns, thyme, and garlic. Coat the duck legs and refrigerate for 1224 hours. Rinse, pat dry.
Melt the duck fat in a deep casserole. Add the duck legs — they should be submerged. Cook at 90°C (just barely simmering) for 2.53 hours until the meat is tender when pierced. Remove and reserve. Store in the fat if making ahead — this is the principle of confit, and the duck keeps for weeks this way.
### Beans
Soak 500g dried haricot tarbais overnight in plenty of cold water. Drain, cover with fresh cold water by 5cm. Add a carrot, an onion, and a bouquet garni. Simmer for 45 minutes until tender but not quite done — they will cook more in the cassoulet. Reserve with their cooking liquid. Season with salt only in the last 10 minutes.
## Day Two: Assembly
**The base:**
- 200g pork belly, cut into thick pieces
- 200g pork rind, blanched for 5 minutes and cut into strips
- 2 onions, chopped
- 4 cloves garlic, sliced
- 400g tinned whole plum tomatoes
- 200ml white wine
- Reserved bean cooking liquid
- Salt, pepper
Brown the pork belly in a large pot until deeply coloured. Remove. Soften the onions in the rendered fat. Add the garlic, then the white wine; reduce by half. Add the tomatoes, crushing them. Add the bean cooking liquid (about 500ml) and the pork rind. Simmer for 20 minutes.
**Building the cassoulet:**
You need a large, wide casserole — traditional earthenware cassoles are ideal; a large Dutch oven works.
Layer one: one-third of the beans, some of the pork belly, pork rind, and tomato base.
Layer two: the duck legs and Toulouse sausages (46 sausages, depending on size, slightly browned in a pan first).
Layer three: the remaining beans, the remaining pork, more base sauce. The liquid should just reach the top of the beans — add more bean cooking liquid or stock if needed.
**Breadcrumbs**: Strew a generous layer of fine dried breadcrumbs over the top. Drizzle with duck fat or olive oil.
Bake at 150°C for at least 2 hours. At intervals, break the crust that forms on the surface with a spoon and push it into the cassoulet. Add liquid if it looks dry. The traditional instruction is to break the crust seven times; the practical instruction is to break it whenever you walk past.
The cassoulet is done when the top is deep golden-brown, the interior is thick and bubbling, and the whole kitchen smells of Gascony.
## Serving
Bring the cassoulet to the table in its dish. Serve with nothing more than good bread and a simple green salad dressed only with vinaigrette. The cassoulet is the meal; it needs nothing.
Leftovers — there will be leftovers — are better still the next day. This is one of the dishes that improves with every reheating.
## On the Investment
Two days is a lot. The answer to this objection is that very little of those two days involves active cooking: the confit sits in the oven unattended, the beans soak overnight, the cassoulet bakes slowly. The active time is perhaps two hours spread across both days.
What you get for this investment is a dish that feeds six to eight people generously, that improves overnight, that contains more depth and complexity than almost anything you could make in an hour, and that marks the meal as an occasion — which is sometimes exactly what cooking should do.

View file

@ -1,92 +0,0 @@
---
title: "My Year of Fermentation: Kimchi, Kraut, and Lacto-Pickles"
created: 2026-01-08 10:00
author: Amelia Fontaine
keywords: fermentation, kimchi, sauerkraut, lacto-fermentation, pickles
description: Lacto-fermentation science, basic kimchi recipe, simple sauerkraut, quick lacto-pickles, equipment needed, and safety notes.
---
# My Year of Fermentation: Kimchi, Kraut, and Lacto-Pickles
Fermentation feels like the opposite of modern cooking: it is slow, unpredictable, invisible, and yields results that are difficult to specify in advance. You cannot ferment something in thirty minutes. You cannot reliably repeat results across batches. What you can do, with basic understanding and some patience, is create a range of preserved, probiotic-rich, complex-flavoured foods from very simple ingredients.
I spent much of last year making things in jars. This is what I learned.
## The Science of Lacto-Fermentation
Lacto-fermentation is not about dairy. It refers to *Lactobacillus* bacteria, which are present naturally on the surface of most vegetables. When vegetables are salted and submerged in brine, these bacteria produce lactic acid as they metabolise the sugars in the vegetables. The lactic acid lowers the pH, creating an environment inhospitable to pathogens but hospitable to more Lactobacillus bacteria.
This is why lacto-fermentation is safe without refrigeration or sterilisation: the lactic acid is the preservative. The pH drops quickly enough in the first 2448 hours to prevent pathogenic bacteria from establishing themselves. As long as the vegetables remain submerged below the brine — where anaerobic conditions prevail — the process is reliable and safe.
Salt concentration matters: 22.5% salt by weight of the vegetables (plus added water if making a brine) is the standard range for most lacto-fermented vegetables. Too little salt and unwanted bacteria can establish themselves before the pH drops; too much and the Lactobacillus bacteria are inhibited.
## Equipment
You need:
- **Glass jars** (wide-mouth mason jars or Kilner jars) — 1 litre or larger
- **A clean weight** to keep vegetables submerged — a small jar filled with water, a zip-lock bag filled with water and brine, or commercial fermentation weights
- **A kitchen scale** — weight measurements are essential for correct salt concentration
- **Patience** — most ferments take 37 days at room temperature before they are ready
You do not need: special airlocks (useful but optional), canning equipment, expensive fermentation crocks (though they are excellent), or a vacuum sealer.
## Sauerkraut
The simplest lacto-ferment. One ingredient, plus salt.
- 1kg white or green cabbage, finely shredded (about 2mm)
- 20g fine sea salt (2% by weight)
Combine in a large bowl and massage vigorously for 510 minutes until the cabbage has released significant liquid — it will reduce in volume by half and the brine should be sufficient to submerge the cabbage when pressed.
Pack tightly into a 1-litre jar, pressing down hard after each handful. The brine should rise to cover the cabbage. Place your weight on top to keep the cabbage submerged. Cover with a cloth and secure with a rubber band.
Leave at room temperature (1822°C is ideal) for 57 days. "Burp" the jar daily by pressing the cabbage down if it has floated. Taste after day 3 — it should be slightly sour. Continue until it reaches your preferred sourness, then seal and refrigerate.
Sauerkraut keeps refrigerated for months.
## Simple Kimchi
Kimchi is more complex than sauerkraut but follows the same principles.
**For the cabbage:**
- 1 medium napa (Chinese) cabbage (about 1kg)
- 60g coarse salt
Quarter the cabbage lengthwise. Dissolve the salt in enough water to submerge the cabbage. Soak for 12 hours until pliable. Rinse thoroughly and squeeze as dry as possible.
**The paste (yangnyeom):**
- 4 tbsp gochugaru (Korean red pepper flakes — not chilli flakes)
- 1 tbsp fish sauce (or soy sauce for vegan version)
- 4 cloves garlic, grated
- 1 tsp fresh ginger, grated
- 1 tsp sugar
- 3 spring onions, cut into 5cm pieces
Mix the paste ingredients. Cut the cabbage quarters crosswise into 5cm pieces. Combine with the paste and spring onions, wearing gloves (the gochugaru stains). Mix thoroughly until all the cabbage is coated.
Pack into jars. There should be little to no brine visible initially — it will develop within 24 hours. Leave at room temperature for 2448 hours, then refrigerate. The kimchi is ready to eat but improves over two to four weeks as it continues to ferment slowly in the fridge.
## Quick Lacto-Pickles
These use a salt brine rather than the vegetable's own liquid, which makes them work for firmer vegetables and more varied ingredients.
**Brine**: Dissolve 20g salt in 1 litre of water (2% brine).
**Suitable vegetables**: Cucumber (cut into spears or coins), carrot, radish, green beans, cauliflower florets, garlic.
**Additions**: Fresh dill, bay leaf, peppercorns, coriander seeds, garlic, chilli.
Pack the vegetables and aromatics tightly into a jar. Pour brine over to cover. Weight and cover as with sauerkraut. At room temperature for 35 days, then refrigerate. These are lighter and more delicate in flavour than sauerkraut — a bridge between vinegar pickles and full fermentation.
## Safety
Lacto-fermentation has an excellent safety record when done correctly. The key rules:
- Keep vegetables fully submerged throughout fermentation
- Use the correct salt concentration
- Ferment at room temperature, not in the warmth of the oven or near a heat source
- If you see pink or black mould (not the white kahm yeast, which is harmless), discard and start again
The strong smell of fermentation can be alarming — sauerkraut at day two smells aggressively sour. This is normal. Trust the science.
Fermentation changed how I think about food preservation: not as the avoidance of microbial activity, but as the selective cultivation of it. The bacteria doing the work are on the vegetables already; you are just creating conditions in which they thrive and others don't. There is something deeply satisfying about that collaboration.

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