Compare commits
No commits in common. "main" and "v0.2.2" have entirely different histories.
13
.github/workflows/mirror.yml
vendored
|
|
@ -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 }}
|
|
||||||
133
.github/workflows/release.yml
vendored
|
|
@ -1,133 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build — ${{ matrix.label }}
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
label: Linux amd64
|
|
||||||
binary_name: mdcms
|
|
||||||
artifact_name: mdcms-linux-amd64
|
|
||||||
make_deb: true
|
|
||||||
|
|
||||||
- os: macos-latest
|
|
||||||
label: macOS arm64
|
|
||||||
binary_name: mdcms
|
|
||||||
artifact_name: mdcms-macos-arm64
|
|
||||||
make_deb: false
|
|
||||||
|
|
||||||
- os: ubuntu-24.04-arm
|
|
||||||
label: Linux arm64
|
|
||||||
binary_name: mdcms
|
|
||||||
artifact_name: mdcms-linux-arm64
|
|
||||||
make_deb: true
|
|
||||||
|
|
||||||
- os: windows-latest
|
|
||||||
label: Windows amd64
|
|
||||||
binary_name: mdcms.exe
|
|
||||||
artifact_name: mdcms-windows-amd64
|
|
||||||
make_deb: false
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install pyinstaller click pyyaml certifi
|
|
||||||
|
|
||||||
- name: Build binary
|
|
||||||
run: pyinstaller --onefile --name mdcms mdcms.py
|
|
||||||
|
|
||||||
- name: Rename binary (non-Windows)
|
|
||||||
if: matrix.os != 'windows-latest'
|
|
||||||
run: mv dist/mdcms dist/${{ matrix.artifact_name }}
|
|
||||||
|
|
||||||
- name: Rename binary (Windows)
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
run: mv dist/mdcms.exe dist/${{ matrix.artifact_name }}.exe
|
|
||||||
|
|
||||||
- name: Build .deb (Linux only)
|
|
||||||
if: matrix.make_deb
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
|
||||||
sudo gem install fpm --no-document
|
|
||||||
fpm \
|
|
||||||
-s dir -t deb \
|
|
||||||
-n mdcms \
|
|
||||||
-v "$VERSION" \
|
|
||||||
--description "MD-CMS companion CLI — manage and build MD-CMS sites" \
|
|
||||||
--url "https://github.com/kbenestad/mdcms" \
|
|
||||||
--maintainer "Kristian Benestad" \
|
|
||||||
--license "Apache-2.0" \
|
|
||||||
--architecture "${{ matrix.os == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" \
|
|
||||||
--category utils \
|
|
||||||
dist/${{ matrix.artifact_name }}=/usr/local/bin/mdcms
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.artifact_name }}
|
|
||||||
path: |
|
|
||||||
dist/${{ matrix.artifact_name }}
|
|
||||||
dist/${{ matrix.artifact_name }}.exe
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: Upload .deb artifact
|
|
||||||
if: matrix.make_deb
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: deb-package-${{ matrix.artifact_name }}
|
|
||||||
path: "*.deb"
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Create GitHub Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
merge-multiple: false
|
|
||||||
|
|
||||||
- name: List artifacts
|
|
||||||
run: find artifacts -type f
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
PRERELEASE=""
|
|
||||||
if [[ "${{ github.ref_name }}" == *"-"* ]]; then
|
|
||||||
PRERELEASE="--prerelease"
|
|
||||||
fi
|
|
||||||
gh release delete "${{ github.ref_name }}" --repo "${{ github.repository }}" --yes 2>/dev/null || true
|
|
||||||
gh release create "${{ github.ref_name }}" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--title "mdcms ${{ github.ref_name }}" \
|
|
||||||
--generate-notes \
|
|
||||||
$PRERELEASE \
|
|
||||||
artifacts/mdcms-linux-amd64/mdcms-linux-amd64 \
|
|
||||||
artifacts/mdcms-linux-arm64/mdcms-linux-arm64 \
|
|
||||||
artifacts/mdcms-macos-arm64/mdcms-macos-arm64 \
|
|
||||||
artifacts/mdcms-windows-amd64/mdcms-windows-amd64.exe \
|
|
||||||
artifacts/deb-package-mdcms-linux-amd64/*.deb \
|
|
||||||
artifacts/deb-package-mdcms-linux-arm64/*.deb
|
|
||||||
10
.gitignore
vendored
|
|
@ -1,13 +1,3 @@
|
||||||
### Python ###
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.pyo
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.egg-info/
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
### AL ###
|
### AL ###
|
||||||
#Template for AL projects for Dynamics 365 Business Central
|
#Template for AL projects for Dynamics 365 Business Central
|
||||||
#launch.json folder
|
#launch.json folder
|
||||||
|
|
|
||||||
286
CLAUDE.md
|
|
@ -1,286 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Versioning rule
|
|
||||||
|
|
||||||
Every merge into `main` is a release. Before committing any change to `mdcms.py`, ask: "Is this intended to be merged to main immediately?" If yes, bump `CLI_VERSION` and `CLI_RELEASE_DATE` in `mdcms.py` and `version` in `pyproject.toml` before committing. If the work is exploratory or not yet ready to merge, leave the version unchanged and ask again when the merge is imminent.
|
|
||||||
|
|
||||||
## Branching convention
|
|
||||||
|
|
||||||
Only two branches exist in this repository: **`main`** and **`development`**. No other branches should be created or left alive.
|
|
||||||
|
|
||||||
- **`main`** is the release branch. Every merge to `main` is a release. Never commit work-in-progress directly to `main`.
|
|
||||||
- **`development`** is the default branch for all development, including all Claude-driven work. Always commit to `development` — never create a new branch per conversation or feature.
|
|
||||||
- **Documentation only** (`CLAUDE.md`, `docs/`) — may be pushed directly to `main`.
|
|
||||||
- **If a non-canonical branch is created** (e.g. for a large staged feature), it must be deleted immediately after it is merged. The repo returns to `main` + `development` only.
|
|
||||||
|
|
||||||
In practice: check out `development`, do the work, push to `development`, PR `development` → `main` when ready to release.
|
|
||||||
|
|
||||||
**When a branch isn't visible locally:** always run `git fetch origin <branch-name>` before concluding a branch doesn't exist. Never create a new branch if the user names one — fetch it from the remote first.
|
|
||||||
|
|
||||||
## Unreleased changelog
|
|
||||||
|
|
||||||
`docs/unreleased.md` is a living document that tracks every fix or feature on `development` that has not yet been merged to `main`. Keep it current: whenever a change lands on `development`, add or update an entry in `unreleased.md` in the same commit (or a follow-up commit to `development`). When a batch of changes is merged to `main` and released, clear the entries that were released and leave the file in place for the next round of work.
|
|
||||||
|
|
||||||
## What this project is
|
|
||||||
|
|
||||||
MD-CMS is a markdown-based static site system with two distinct parts:
|
|
||||||
|
|
||||||
1. **`mdcms.py`** — a Python 3 CLI tool (`click` + `PyYAML` + `certifi`). Manages a registry of sites, scans content, generates `nav.yml` and `search.json`, and is designed for both local use and GitHub Actions pipelines.
|
|
||||||
2. **`app/index.html`** — a single-file browser renderer that reads markdown, config, and nav at runtime entirely client-side. There is no build pipeline, no compilation, no server.
|
|
||||||
|
|
||||||
The `app/` folder is the deployable artifact and the starter template downloaded when registering a new site. `mdcms.py` lives outside it.
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```
|
|
||||||
mdcms.py ← CLI tool
|
|
||||||
pyproject.toml ← packaging (entry point, dependencies)
|
|
||||||
app/
|
|
||||||
index.html ← renderer + v0.4 version marker
|
|
||||||
config.yml ← starter config + v0.4 version marker
|
|
||||||
nav.yml ← generated
|
|
||||||
search.json ← generated
|
|
||||||
pages/
|
|
||||||
posts/
|
|
||||||
assets/
|
|
||||||
docs/
|
|
||||||
banner/
|
|
||||||
documentation.md
|
|
||||||
knownlimitations.md
|
|
||||||
quickstart.md
|
|
||||||
install.md
|
|
||||||
release.md
|
|
||||||
.github/workflows/release.yml ← cross-platform release builds
|
|
||||||
samplesite/ ← reference implementation (not deployed)
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI commands
|
|
||||||
|
|
||||||
Install: `pip install mdcms` / `pipx install mdcms` — or use the standalone binary from a GitHub release.
|
|
||||||
|
|
||||||
During development, run directly: `python3 mdcms.py <command>`
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `mdcms register <name> [path]` | Register a site. Downloads starter template from GitHub if no mdcms site is found at the path. Defaults to current directory. |
|
|
||||||
| `mdcms delete <name>` | Remove a site from the registry. Does not delete files. Prompts for confirmation. |
|
|
||||||
| `mdcms view` | List all registered sites with version and status. |
|
|
||||||
| `mdcms view <name>` | Show details: path, version, sitename, pages/posts count, sections, categories. |
|
|
||||||
| `mdcms build <name>` | Build `nav.yml` and `search.json` for a registered site. |
|
|
||||||
| `mdcms build --path <path>` | Build using an explicit path — no registry needed. Intended for CI/CD. |
|
|
||||||
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
|
|
||||||
| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. |
|
|
||||||
| `mdcms fetch-deps --path <path>` | Same, using an explicit path. |
|
|
||||||
|
|
||||||
## PWA config keys
|
|
||||||
|
|
||||||
Set in `config.yml`. `mdcms build` generates `manifest.json` and `service-worker.js` when `pwa: yes`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pwa: yes
|
|
||||||
pwa-name: "My Documentation" # mandatory if pwa: yes
|
|
||||||
pwa-shortname: "MyDocs" # optional short name for home screen
|
|
||||||
pwa-colour: "#2563EB" # optional browser chrome colour
|
|
||||||
offline-message:
|
|
||||||
en: "You are offline and some content is unavailable."
|
|
||||||
nb: "Du er frakoblet og noe innhold er utilgjengelig."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS.
|
|
||||||
|
|
||||||
## Architecture of `mdcms.py`
|
|
||||||
|
|
||||||
Single-module Python script. Logical layers in order:
|
|
||||||
|
|
||||||
1. **Version helpers** — `read_site_version()` reads the `mdcms v0.3` marker from the first line of `config.yml`. `version_status()` classifies sites as `ok`, `outdated`, `newer`, or `unsupported` against `MIN_SUPPORTED_VERSION`.
|
|
||||||
2. **Registry** — `~/.config/mdcms/sites.json` stores `{name: {path, version}}`. `load_registry()` / `save_registry()` / `resolve_site_path()`.
|
|
||||||
3. **Config reading** — `read_config()` reads `config.yml` with `yaml.safe_load()`. `get_category_info()` extracts category settings from the parsed dict.
|
|
||||||
4. **Frontmatter parser** (`parse_frontmatter`) — reads `---` YAML blocks using `yaml.safe_load()`. Returns `(meta_dict, body_text)`.
|
|
||||||
5. **Category system** — `identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list.
|
|
||||||
6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`.
|
|
||||||
7. **Nav/search generators** — `generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild.
|
|
||||||
8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json → patch `<title>` in `index.html` with `sitename` → generate PWA files if enabled. The `<title>` patch ensures crawlers and link-preview scrapers (WhatsApp, Slack, etc.) see the correct site name in the static HTML before any JavaScript runs.
|
|
||||||
9. **Template download** (`download_template`) — fetches `app/` from GitHub via the Contents API using `urllib` + `certifi` for SSL. Recursively downloads files and directories.
|
|
||||||
10. **CLI commands** (`register`, `delete`, `view`, `build`) — implemented with `click`. Entry point: `main()` → `cli()`.
|
|
||||||
|
|
||||||
## Version markers
|
|
||||||
|
|
||||||
Every mdcms site has a version marker on the first line of two files:
|
|
||||||
|
|
||||||
- `config.yml` line 1: `# mdcms v0.4 | DO NOT REMOVE THIS COMMENT`
|
|
||||||
- `index.html` line 1: `<!-- mdcms v0.4 | DO NOT REMOVE THIS COMMENT -->`
|
|
||||||
- `theme.yml` line 1: `# mdcms v0.4 | DO NOT REMOVE THIS COMMENT`
|
|
||||||
|
|
||||||
`register` and `build` both read the marker from `config.yml` to detect and validate the site. Sites with no marker are not recognised as mdcms sites. Sites below `MIN_SUPPORTED_VERSION` are rejected.
|
|
||||||
|
|
||||||
There are two distinct version numbers:
|
|
||||||
- **CLI version** (`CLI_VERSION` in `mdcms.py`, `version` in `pyproject.toml`) — bumped with every release.
|
|
||||||
- **Site format version** (markers in `config.yml` and `index.html`) — only bumped when the site file format has a breaking change. Many CLI releases may share the same site format version.
|
|
||||||
|
|
||||||
## Site structure
|
|
||||||
|
|
||||||
The registered path points directly to the directory containing `index.html` (the site root). There is no `website/` subdirectory.
|
|
||||||
|
|
||||||
```
|
|
||||||
<site-root>/
|
|
||||||
index.html ← renderer
|
|
||||||
config.yml ← required: sitename, navigation; rest optional
|
|
||||||
nav.yml ← generated; manual edits to section metadata are preserved
|
|
||||||
search.json ← generated
|
|
||||||
pages/
|
|
||||||
home.md ← default landing page
|
|
||||||
about.md
|
|
||||||
about.nb.md ← Norwegian variant (category suffix = nb)
|
|
||||||
posts/
|
|
||||||
2025-01-01-my-first-post.md
|
|
||||||
assets/
|
|
||||||
fonts/
|
|
||||||
images/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Page frontmatter fields
|
|
||||||
|
|
||||||
All optional except `title`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
title: Page Title
|
|
||||||
sort: 100 # controls nav ordering (lower = higher)
|
|
||||||
section-id: blog # assigns page to a nav section
|
|
||||||
draft: true # exclude from nav and search
|
|
||||||
author: Name
|
|
||||||
created: 2025-01-01 13:00
|
|
||||||
modified: 2025-01-15 09:00
|
|
||||||
keywords: foo, bar
|
|
||||||
description: Short description for search
|
|
||||||
language: en
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
## nav.yml structure
|
|
||||||
|
|
||||||
Sections and pages are separate lists. `mdcms.py` preserves manual edits to section fields (`defaultname`, `sort`, `parent`, `parent-sort`, `pagesvisibility`, `categorynames`) on each rebuild. New sections are auto-created from `section-id` values found in frontmatter.
|
|
||||||
|
|
||||||
`pagesvisibility` can be `visible`, `hidden`, or `draft` (draft excludes pages from `search.json`).
|
|
||||||
|
|
||||||
For nested navigation, set `parent: <parent-section-code>` and `parent-sort` on a section.
|
|
||||||
|
|
||||||
## Category system
|
|
||||||
|
|
||||||
- `categories-use: yes` in `config.yml` enables categories
|
|
||||||
- `default-category.code` is required when categories are enabled
|
|
||||||
- Variant files: `<base>.<code>.md` — the suffix is only treated as a category if the code is declared in config
|
|
||||||
- `categories-sectionnames: per-category` requires each section in `nav.yml` to have a `categorynames` block with an entry per category code
|
|
||||||
- RTL is set per category via `direction: rtl`
|
|
||||||
- Line height is set per category via `line-height: 2.8` (useful for scripts like Nastaliq that need extra vertical space). Restores to theme default when switching to a category without this key.
|
|
||||||
|
|
||||||
## Dynamic post tags (mdcms code blocks)
|
|
||||||
|
|
||||||
Embed post lists in pages using fenced blocks:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mdcms
|
|
||||||
posts-created-reversechronological
|
|
||||||
limit: 10
|
|
||||||
paginate: yes
|
|
||||||
```
|
|
||||||
````
|
|
||||||
|
|
||||||
Reliable tags (others are known-broken): `posts-created-chronological-byyearmonth`, `posts-created-reversechronological`. Use `created` frontmatter (format: `YYYY-MM-DD HH:MM`) for posts.
|
|
||||||
|
|
||||||
## Release workflow
|
|
||||||
|
|
||||||
`.github/workflows/release.yml` triggers on version tags (`v*`). Uses a matrix of three runners:
|
|
||||||
|
|
||||||
| Runner | Output |
|
|
||||||
|---|---|
|
|
||||||
| `ubuntu-latest` | `mdcms-linux-amd64` binary + `mdcms_<version>_amd64.deb` (via PyInstaller + fpm) |
|
|
||||||
| `macos-latest` | `mdcms-macos-arm64` binary |
|
|
||||||
| `windows-latest` | `mdcms-windows-amd64.exe` |
|
|
||||||
|
|
||||||
All artifacts are attached to the GitHub release using `gh release create`. The workflow sets `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true` to opt into the Node.js 24 runner ahead of the June 2026 forced migration.
|
|
||||||
|
|
||||||
**Release checklist** — before tagging:
|
|
||||||
1. Update `CLI_VERSION` in `mdcms.py`
|
|
||||||
2. Update `version` in `pyproject.toml`
|
|
||||||
3. Update site format markers in `app/config.yml` and `app/index.html` only if the site format changed
|
|
||||||
|
|
||||||
Then: `git tag v0.4.1 && git push origin v0.4.1`
|
|
||||||
|
|
||||||
**Note:** Git tag pushes must be done from a local machine — the cloud environment cannot push tags (HTTP 403). Use `gh release create <tag>` locally after pushing the tag.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- Most `posts-*` tag variants are broken. Only `posts-datetime-chronological-byyearmonth` and `posts-datetime-reversechronological` reliably work.
|
|
||||||
- Section headings in the nav are non-clickable (sections-sitemap is not yet implemented).
|
|
||||||
- **`navigation: topbar` is broken.** Always use `navigation: sidebar` in `config.yml` for any test sites or starter templates.
|
|
||||||
|
|
||||||
## v0.4 renderer features (index.html)
|
|
||||||
|
|
||||||
Features added in v0.4, all rendered client-side in `app/index.html`:
|
|
||||||
|
|
||||||
### Callout tags
|
|
||||||
Fenced `mdcms` blocks with `callout-info`, `callout-warning`, `callout-success`, `callout-error`. Each has a coloured left border, low-opacity tinted background, optional icon + title row, and full markdown body. The JS sets `--callout-primary` and `--callout-bg` CSS variables on the container; the CSS must reference these (not hardcoded colours). Config-defined messages: `message: <key>` resolves title and body from the `callouts:` block in `config.yml`.
|
|
||||||
|
|
||||||
### Table of contents tag
|
|
||||||
Fenced `mdcms` block with `toc`. Renders a section-grouped list of all visible, non-draft pages in the active category, excluding the TOC page itself. Groups by nav section.
|
|
||||||
|
|
||||||
### Theme system (`theme.yml`)
|
|
||||||
Presentational config separate from `config.yml`. Controls accent colour, dark/light mode palette, fonts, and layout. `index.html` loads it at runtime.
|
|
||||||
|
|
||||||
**Colour keys per mode** (`light:` and `dark:` blocks):
|
|
||||||
|
|
||||||
| Key | CSS variable | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `accent` | `--accent` | `#2563EB` / `#60A5FA` |
|
|
||||||
| `background` | `--bg-main` | `#FFFFFF` / `#0F172A` |
|
|
||||||
| `nav-background` | `--bg-nav` | `#F8FAFC` / `#1E293B` |
|
|
||||||
| `text` | `--font-colour` | `#1E293B` / `#F1F5F9` |
|
|
||||||
| `text-muted` | `--font-colour-muted` | `#64748B` / `#94A3B8` |
|
|
||||||
| `nav-link` | `--nav-link-colour` | falls back to `text` |
|
|
||||||
| `nav-link-active` | `--nav-link-active-colour` | falls back to `accent` |
|
|
||||||
| `nav-section-heading` | `--nav-section-heading-colour` | falls back to `text-muted` |
|
|
||||||
| `nav-sitename` | `--nav-sitename-colour` | falls back to `nav-link` |
|
|
||||||
| `nav-description` | `--nav-description-colour` | falls back to `nav-section-heading` |
|
|
||||||
| `nav-toggle` | `--nav-toggle-colour` | falls back to `nav-section-heading` |
|
|
||||||
| `divider` | `--divider` | `color-mix(in srgb, background 85%, text)` |
|
|
||||||
|
|
||||||
**When to use nav-link keys:** When `nav-background` matches or is very close to `accent`, the default behaviour (active link coloured with `accent`) makes links invisible. Set `nav-link`, `nav-link-active`, and `nav-section-heading` explicitly so all three are legible against `nav-background`. Example: a red nav background needs white (`#FFFFFF`) for all three nav colour keys.
|
|
||||||
|
|
||||||
**Semantic colours:**
|
|
||||||
|
|
||||||
- `colours-semantic` — applies to both light and dark modes. Use for colours that read on both backgrounds, or when you don't need per-mode control.
|
|
||||||
- `colours-semantic-dark` — overrides semantic colours in dark mode only. Use lighter/more saturated variants here so callout borders and tinted backgrounds remain legible on dark page backgrounds.
|
|
||||||
|
|
||||||
Keys in both blocks: `info`, `warning`, `success`, `error`.
|
|
||||||
|
|
||||||
**Nav section toggle icons** (top-level keys, not inside `light:`/`dark:`):
|
|
||||||
|
|
||||||
| Key | Default | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `nav-section-expand-icon` | `arrow_right` | icon shown when section is collapsed |
|
|
||||||
| `nav-section-collapse-icon` | `arrow_drop_down` | icon shown when section is expanded |
|
|
||||||
|
|
||||||
Available icon names: `arrow_right`, `arrow_drop_down`, `keyboard_arrow_right`, `keyboard_arrow_down`, `keyboard_double_arrow_right`, `keyboard_double_arrow_down`, `expand_content`, `collapse_content`, `add`, `minimize`.
|
|
||||||
|
|
||||||
These only apply to nav sections with `pagesvisibility: hidden` (collapsible sections).
|
|
||||||
|
|
||||||
### Icon system
|
|
||||||
All UI icons served as local SVGs from `app/assets/icons/`. No Google Fonts or external icon font. Icon names are normalised (lowercase, spaces → hyphens).
|
|
||||||
|
|
||||||
### PWA
|
|
||||||
`manifest.json` and `service-worker.js` generated by `mdcms build` when `pwa: yes`. Cache-first SW precaches all pages, posts, and assets. Offline message from `config.yml` (`offline-message` key) stored in `localStorage` and shown when a page can't be fetched. Requires a `favicon.png` in `assets/images/` for the install icon (192×192 recommended).
|
|
||||||
|
|
||||||
### `fetch-deps` offline mode
|
|
||||||
`mdcms fetch-deps` downloads all CDN JS/CSS to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`, then patches `index.html` CDN URLs to local paths. After this, the site makes no external network requests.
|
|
||||||
|
|
||||||
## Key implementation details
|
|
||||||
|
|
||||||
- `generate_nav_yml()` emits a fixed-format YAML subset. It is **not** a general YAML emitter — do not assume it handles arbitrary structures.
|
|
||||||
- `yaml.safe_load()` is used for all YAML reading (config.yml, nav.yml, frontmatter). The nav.yml parser depends on PyYAML, not a hand-rolled parser.
|
|
||||||
- Category code validation uses `CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")` — codes must match this.
|
|
||||||
- `scan_and_categorize()` takes both `directory` and `site_root` — paths in records are always relative to `site_root`.
|
|
||||||
- The `samplesite/` directory is a reference implementation with multi-language categories (English, Norwegian, Arabic including RTL). It is not deployed; it exists for reference and testing.
|
|
||||||
- Template download uses `urllib` (stdlib) with `certifi` for SSL certificate verification — required for PyInstaller binaries on Linux/macOS where the bundled Python cannot find system CA certificates.
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# Contributing to mdcms
|
|
||||||
|
|
||||||
Thanks for your interest in contributing. This is a small, focused project — contributions that fit the existing scope and style are welcome.
|
|
||||||
|
|
||||||
## What to work on
|
|
||||||
|
|
||||||
Check the [open issues](https://github.com/kbenestad/mdcms/issues) for bugs and planned work. If you want to add something new, open an issue first to discuss it before writing code — this avoids wasted effort if the feature doesn't fit the project direction.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/kbenestad/mdcms.git
|
|
||||||
cd mdcms
|
|
||||||
pip install -e ".[dev]" # or: pip install click PyYAML certifi
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the CLI directly during development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 mdcms.py --help
|
|
||||||
```
|
|
||||||
|
|
||||||
For local site preview, run `python3 -m http.server 8800` inside a site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access.
|
|
||||||
|
|
||||||
## Making changes
|
|
||||||
|
|
||||||
- **CLI changes** (`mdcms.py`, `pyproject.toml`, `app/`, `.github/`) — branch from `main`, PR back to `main`.
|
|
||||||
- **Docs only** (`docs/`) — can go directly to `main`.
|
|
||||||
- Keep commits focused. One logical change per commit.
|
|
||||||
- Match the existing code style — single-file script, stdlib where possible, no unnecessary dependencies.
|
|
||||||
|
|
||||||
## Submitting a pull request
|
|
||||||
|
|
||||||
1. Fork the repo and create a branch from `main`.
|
|
||||||
2. Make your changes.
|
|
||||||
3. Test manually against a real site directory.
|
|
||||||
4. Open a PR with a clear description of what changed and why.
|
|
||||||
|
|
||||||
There are no automated tests yet, so describe what you tested in the PR description.
|
|
||||||
|
|
||||||
## Reporting bugs
|
|
||||||
|
|
||||||
Open a GitHub issue with:
|
|
||||||
- mdcms version (`mdcms --version`)
|
|
||||||
- OS and Python version
|
|
||||||
- The command you ran and the full output
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE).
|
|
||||||
201
LICENSE
|
|
@ -1,201 +0,0 @@
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 Kristian Benestad
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
175
README.md
|
|
@ -1,106 +1,123 @@
|
||||||
# MD-CMS
|
# MD-CMS
|
||||||
|
|
||||||
Write your content as `.md` files, run one command, and deploy to any static host. All rendering happens in the browser at runtime.
|
> Markdown-based static site publishing — no server, no database, no terminal required.
|
||||||
|
|
||||||
With the GitHub Actions workflow, you can automatically rebuild the navigation structure and search index on each commit.
|
MD-CMS lets you write and publish a website entirely in markdown. Drop your `.md` files in a folder, run the build tool, upload the output to any static host, and you're done. All rendering happens in the browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
- - -
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
MD-CMS has two parts:
|
MD-CMS has two parts:
|
||||||
|
|
||||||
The web app:
|
**`index.html`** — a single-file browser renderer. It reads your markdown files, config, and navigation at runtime and renders everything client-side. No build pipeline, no framework, no compilation step.
|
||||||
|
|
||||||
- `index.html` — a single-file browser renderer. It reads your markdown, config,
|
**`mdcms.py`** — a zero-dependency Python CLI tool. It scans your content, generates `nav.yml` and `search.json`, validates your config, and packages everything into a zip file ready for upload.
|
||||||
and nav at runtime and renders everything client-side. No compilation, no
|
|
||||||
framework.
|
|
||||||
- `config.yml` — contains site configuration.
|
|
||||||
- `nav.yml` — navigation tree.
|
|
||||||
- `search.json` — supports site-wide search on static host.
|
|
||||||
|
|
||||||
The local app: `mdcms` — a CLI tool that scans your content, generates `nav.yml`
|
---
|
||||||
and `search.json`, and can wire up automatic builds via GitHub Actions.
|
|
||||||
|
|
||||||
## Installation
|
## Features
|
||||||
|
|
||||||
**Standalone binary**
|
- **Write in markdown** — pages and posts with YAML frontmatter
|
||||||
|
- **Categories** — serve multiple versions of the same page (e.g. languages, destinations, variants) via `?cat=` URL parameter and a dropdown UI
|
||||||
|
- **Sections** — nested navigation defined in `nav.yml`; pages declare their section via frontmatter
|
||||||
|
- **Full-text search** — category-aware, generated at build time
|
||||||
|
- **Dynamic content tags** — embed post lists with date sorting, pagination, and year grouping using fenced `mdcms` code blocks
|
||||||
|
- **RTL support** — per-category text direction
|
||||||
|
- **Custom fonts per category** — load a font file from `assets/fonts/` when a category is selected
|
||||||
|
- **Light and dark mode** — fully themeable via `config.yml`
|
||||||
|
- **No server required** — everything is static; deploy to GitHub Pages, Codeberg Pages, Cloudflare Pages, Netlify, or any file host
|
||||||
|
- **Zero dependencies** — `mdcms.py` uses only the Python standard library
|
||||||
|
|
||||||
1. Download from the
|
---
|
||||||
[latest release](https://github.com/kbenestad/mdcms/releases/latest).
|
|
||||||
2. Move the binary and make it executable
|
|
||||||
- Linux
|
|
||||||
- Download the [latest mdcms](latest/linux/mdcms)
|
|
||||||
- Move it to `/usr/local/bin/`: `sudo mv mdcms /usr/local/bin/mdcms`
|
|
||||||
- Make it executable: `sudo chmod +x /usr/local/bin/mdcms`
|
|
||||||
- Mac
|
|
||||||
- Download the [latest mdcms](latest/macos/mdcms)
|
|
||||||
- Move it to `/usr/local/bin/`: `sudo mv mdcms /usr/local/bin/mdcms`
|
|
||||||
- Make it executable: `sudo chmod +x /usr/local/bin/mdcms`
|
|
||||||
- Remove the quarantine flag: `xattr -dr com.apple.quarantine /usr/local/bin/mdcms`
|
|
||||||
- Windows
|
|
||||||
- Download the [latest mdcms](latest/windows/mdcms.exe)
|
|
||||||
- Move it to a permanent folder, e.g. `C:\tools\mdcms.exe`
|
|
||||||
- Add `C:\tools\` to your PATH: Start → search `"Environment Variables"` → edit the `Path user variable` → add `C:\tools\`
|
|
||||||
- Open a new PowerShell window and verify: `mdcms --version`
|
|
||||||
|
|
||||||
## Use
|
|
||||||
To get started, run `mdcms register mysite` to register the current working directory as the `mysite` MD-CMS project directory. You can also specify the project path: `mdcms register mysite /path/to/mysiteNote`.
|
|
||||||
|
|
||||||
If there is an existing MD-CMS instance in your specified directory, the app will register this site; if there is no MD-CMS instance present, it will download the latest version from the GitHub repo.
|
|
||||||
|
|
||||||
Add your pages, posts, and assets. Once you're ready to upload your site, run `mdcms build mysite` to update `nav.yml` and `search.json`. You can also run `mdcms build --path` to build the website without registering the site.
|
|
||||||
|
|
||||||
The final project directory can be uploaded to a static host of your choice.
|
|
||||||
|
|
||||||
## File structure
|
## File structure
|
||||||
|
|
||||||
```
|
```
|
||||||
[project directory]/
|
mdcms.py ← build tool, run this
|
||||||
index.html ← renderer
|
quickstart.md ← getting started guide
|
||||||
config.yml ← site configuration and theme
|
website/ ← everything in here gets deployed
|
||||||
nav.yml ← navigation structure
|
index.html
|
||||||
search.json ← search index
|
config.yml
|
||||||
|
nav.yml
|
||||||
pages/ ← Put your permanent pages here
|
search.json
|
||||||
home.md ← Main page
|
pages/
|
||||||
...
|
home.md
|
||||||
|
about.md
|
||||||
posts/ ← Put your time-bound posts here
|
about.nb.md ← Norwegian variant of about.md
|
||||||
...
|
posts/
|
||||||
|
2025-01-01-my-first-post.md
|
||||||
assets/ ← Holds directories for non-content files
|
assets/
|
||||||
required/ ← Logo, favicon, and icons go here.
|
images/
|
||||||
...
|
fonts/
|
||||||
|
|
||||||
images/ ← Images
|
|
||||||
...
|
|
||||||
|
|
||||||
fonts/ ← Fonts referenced in config.yml
|
|
||||||
...
|
|
||||||
|
|
||||||
files/ ← Files for download
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**IMPORTANT:** All links are read from `index.html` in the root. Therefore, you must provide full paths for each file from the root.
|
The `website/` folder is your deployable site. `mdcms.py` lives outside it.
|
||||||
|
|
||||||
Example: You want to put a link to `page2.md` in `home.md`. `page2.md` is in the same directory as `home.md`.
|
---
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
**Requirements:** Python 3 (standard library only). A modern browser.
|
||||||
|
|
||||||
|
1. Clone or download this repository.
|
||||||
|
2. Run `python3 mdcms.py` and choose **option 2** to build your config and folder structure from scratch.
|
||||||
|
3. Write your pages in `website/pages/` and posts in `website/posts/`.
|
||||||
|
4. Run `mdcms.py` again and choose **option 3** to generate `nav.yml` and `search.json`.
|
||||||
|
5. Choose **option 8** to start a local webserver and preview your site.
|
||||||
|
6. When ready to publish, choose **option 1** to validate, build, and export `website.zip`.
|
||||||
|
7. Upload the contents of `website.zip` to your static host.
|
||||||
|
|
||||||
|
> **Local preview note:** Open `index.html` via the built-in webserver (option 8), not by double-clicking the file. Browsers block local file access due to CORS restrictions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Site behaviour is controlled by two YAML files in `website/`:
|
||||||
|
|
||||||
|
**`config.yml`** — site title, logo, default page, search settings, typography, layout dimensions, light/dark theme colours, and category definitions.
|
||||||
|
|
||||||
|
**`nav.yml`** — navigation structure. Sections are defined here; pages declare their section via `section-id` in frontmatter. Sections can be nested.
|
||||||
|
|
||||||
|
Both files are human-readable and comment-supported. The `mdcms.py` wizard generates them for you and can fill in missing values interactively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
Categories let you publish multiple versions of the same page — different languages, regions, or product variants — under a single URL with a `?cat=` parameter.
|
||||||
|
|
||||||
|
Each variant is a separate file:
|
||||||
|
|
||||||
The correct link is therefore:
|
|
||||||
```
|
```
|
||||||
[Link text](pages/page2.md)
|
about.md ← default
|
||||||
|
about.en-gb.md ← British English variant
|
||||||
|
about.nb.md ← Norwegian variant
|
||||||
```
|
```
|
||||||
|
|
||||||
This does not link to `page2.md`:
|
The category dropdown shows only categories for which a variant exists (or where a "not available" message is configured). All internal links preserve the active category.
|
||||||
```
|
|
||||||
[Link text](page2.md)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Further reading
|
---
|
||||||
|
|
||||||
For further documentation, refer to:
|
## Tag system
|
||||||
|
|
||||||
- [docs/](docs/README.md): Documentation relevant to this repo
|
Embed dynamic post lists in any page using fenced `mdcms` code blocks:
|
||||||
- [docs.benestad.net](https://docs.benestad.net/): Further help -- including an example of an MD-CMS site.
|
|
||||||
|
````markdown
|
||||||
|
```mdcms
|
||||||
|
posts-date-reversechronological
|
||||||
|
limit: 10
|
||||||
|
paginate: yes
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Available tags cover chronological and reverse-chronological post lists, grouped by year, with date or datetime display, and configurable pagination.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
This software is © 2026 Kristian Benestad. Licensed under the [Apache License, Version 2.0](LICENSE).
|
|
||||||
|
Apache 2.0 — see [LICENCE]((https://www.apache.org/licenses/LICENSE-2.0)).
|
||||||
|
|
||||||
|
© Kristian Benestad
|
||||||
|
|
|
||||||
32
SECURITY.md
|
|
@ -1,32 +0,0 @@
|
||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported versions
|
|
||||||
|
|
||||||
Only the latest release of mdcms receives security fixes.
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
|---------|-----------|
|
|
||||||
| Latest | Yes |
|
|
||||||
| Older | No |
|
|
||||||
|
|
||||||
## Reporting a vulnerability
|
|
||||||
|
|
||||||
Please **do not** open a public GitHub issue for security vulnerabilities.
|
|
||||||
|
|
||||||
Report them privately via [GitHub's private vulnerability reporting](https://github.com/kbenestad/mdcms/security/advisories/new), or email **kristian@benestad.net** if you prefer.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
- A description of the vulnerability
|
|
||||||
- Steps to reproduce it
|
|
||||||
- The version of mdcms affected
|
|
||||||
- Any suggested fix, if you have one
|
|
||||||
|
|
||||||
You can expect an acknowledgement within a few days and a fix or response within 30 days depending on severity.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
mdcms is a local CLI tool and static site renderer. It does not run a server or handle untrusted network input in normal use. The main areas of concern are:
|
|
||||||
|
|
||||||
- **Template download** (`mdcms register`) — fetches files from GitHub over HTTPS using `certifi` for SSL verification.
|
|
||||||
- **YAML parsing** — uses `yaml.safe_load()` throughout; `yaml.load()` with an untrusted loader is never used.
|
|
||||||
- **File path handling** — paths are resolved relative to a user-supplied site directory; symlink traversal or path escape bugs would be in scope.
|
|
||||||
30
app/404.html
|
|
@ -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>
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 5.3 KiB |
3307
app/index.html
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
22
app/nav.yml
|
|
@ -1,22 +0,0 @@
|
||||||
# nav.yml — generated by mdcms
|
|
||||||
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
|
|
||||||
# pagesvisibility, categorynames) are preserved on rebuild.
|
|
||||||
|
|
||||||
sections:
|
|
||||||
# (none yet — add section-id to page frontmatter to auto-create)
|
|
||||||
pages:
|
|
||||||
- file: pages/home.md
|
|
||||||
title: Home
|
|
||||||
sort: 100
|
|
||||||
|
|
||||||
- file: pages/about.md
|
|
||||||
title: About
|
|
||||||
sort: 200
|
|
||||||
|
|
||||||
- file: pages/docs.md
|
|
||||||
title: Docs
|
|
||||||
sort: 300
|
|
||||||
|
|
||||||
- file: pages/tabs-accordions.md
|
|
||||||
title: Tabs & Accordions
|
|
||||||
sort: 400
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
title: Home
|
|
||||||
sort: 100
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 7 — PWA Test
|
|
||||||
|
|
||||||
This page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.
|
|
||||||
|
|
||||||
## Test procedure
|
|
||||||
|
|
||||||
1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`
|
|
||||||
2. Load `http://localhost:8800` — service worker registers on first load
|
|
||||||
3. Navigate to the **About** and **Docs** pages so they are fetched and cached
|
|
||||||
4. Stop the HTTP server (`Ctrl+C` in its terminal)
|
|
||||||
5. Reload — site should load fully from the service worker cache
|
|
||||||
6. Navigate between pages — all should work offline
|
|
||||||
7. Check that a page not yet visited shows the offline message
|
|
||||||
|
|
||||||
## What to look for
|
|
||||||
|
|
||||||
- `manifest.json` and `service-worker.js` exist after build
|
|
||||||
- DevTools → Application → Service Workers: status **activated and running**
|
|
||||||
- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed
|
|
||||||
- Site loads fully with server stopped
|
|
||||||
- Offline message (`config.yml: offline-message`) appears for uncached pages
|
|
||||||
|
|
@ -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.
|
|
||||||
```
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"file": "pages/about.md",
|
|
||||||
"title": "About",
|
|
||||||
"section-id": null,
|
|
||||||
"keywords": "",
|
|
||||||
"description": "",
|
|
||||||
"author": null,
|
|
||||||
"created": "",
|
|
||||||
"modified": "",
|
|
||||||
"language": "en",
|
|
||||||
"body": "# About\n\nThis is a sample page for Phase 7 PWA testing. Navigate here from the sidebar, then go offline and reload — this page should still be available from the service worker cache.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pages/docs.md",
|
|
||||||
"title": "Docs",
|
|
||||||
"section-id": null,
|
|
||||||
"keywords": "",
|
|
||||||
"description": "",
|
|
||||||
"author": null,
|
|
||||||
"created": "",
|
|
||||||
"modified": "",
|
|
||||||
"language": "en",
|
|
||||||
"body": "# Docs\n\nAnother sample page for Phase 7 PWA testing. Visit this page while online, then go offline — it should remain accessible from the cache.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pages/home.md",
|
|
||||||
"title": "Home",
|
|
||||||
"section-id": null,
|
|
||||||
"keywords": "",
|
|
||||||
"description": "",
|
|
||||||
"author": null,
|
|
||||||
"created": "",
|
|
||||||
"modified": "",
|
|
||||||
"language": "en",
|
|
||||||
"body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pages/tabs-accordions.md",
|
|
||||||
"title": "Tabs & Accordions",
|
|
||||||
"section-id": null,
|
|
||||||
"keywords": "",
|
|
||||||
"description": "",
|
|
||||||
"author": null,
|
|
||||||
"created": "",
|
|
||||||
"modified": "",
|
|
||||||
"language": "en",
|
|
||||||
"body": "# Tabs & Accordions\n\n## Tab — Underline variant\n\n```mdcms tab-underline\nitems:\n - title: Install\n default: selected\n content: |\n Install with `npm i mdcms` or `pnpm add mdcms`.\n - title: Configure\n content: |\n Drop a `mdcms.config.yaml` next to your content folder.\n - title: Deploy\n content: |\n Any static host. The build emits plain HTML.\n```\n\n## Tab — Filled variant\n\n```mdcms tab-filled\nitems:\n - title: Overview\n default: selected\n content: |\n MD-CMS is a markdown-based static site system with no build step.\n - title: Features\n content: |\n - Sidebar navigation with sections\n - Full-text search via Fuse.js\n - PWA support with offline caching\n - Dark / light theme toggle\n - title: Architecture\n content: |\n Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).\n```\n\n## Accordion — Underline variant\n\n```mdcms accordion-underline\nitems:\n - title: What is MD-CMS?\n default: open\n content: |\n MD-CMS is a single-file browser renderer that reads markdown, config,\n and nav at runtime entirely client-side. No build pipeline, no compilation.\n - title: How do I install it?\n content: |\n Run `pip install mdcms` or download a binary from the GitHub releases page.\n - title: Does it work offline?\n content: |\n Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then\n enable `pwa: yes` in `config.yml` for full offline support.\n```\n\n## Accordion — Filled variant\n\n```mdcms accordion-filled\nitems:\n - title: Can I use custom themes?\n default: open\n content: |\n Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in\n your `config.yml`. The theme controls colours, fonts, and layout.\n - title: What markdown features are supported?\n content: |\n GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,\n strikethrough, and autolinks. Syntax highlighting via highlight.js.\n - title: Can I nest categories?\n content: |\n Categories are flat (no nesting), but nav sections support a `parent:`\n key for two-level sidebar grouping.\n```\n"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -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))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Placeholder
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
V0.3.1 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
V0.3.2 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
This version is outdated. Please visit https://github.com/kbenestad/mdcms/ to update.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
You are using the latest version.
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Release workflow
|
|
||||||
|
|
||||||
This document covers the automatic creation of release binaries of MD-CMS for Linux, macOS and Windows. This document is relevant for you who want to continue developing MD-CMS on your own.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Push access to your GitHub release repository
|
|
||||||
- GitHub Actions enabled on the repository (it is by default on new repos)
|
|
||||||
|
|
||||||
## One-time GitHub setup
|
|
||||||
|
|
||||||
### Enable workflow write permissions
|
|
||||||
|
|
||||||
The release workflow creates a GitHub release using the built-in `GITHUB_TOKEN`. You need to confirm it has write access:
|
|
||||||
|
|
||||||
1. Go to your repository on GitHub
|
|
||||||
2. Go to **Settings → Actions → General**
|
|
||||||
3. Scroll to **Workflow permissions**
|
|
||||||
4. Select **Read and write permissions**
|
|
||||||
5. Click **Save**
|
|
||||||
|
|
||||||
That's the only setup required. No secrets, no tokens, no third-party services.
|
|
||||||
|
|
||||||
## The release checklist
|
|
||||||
|
|
||||||
### Update version number
|
|
||||||
Before tagging a release, update the version number in at least places:
|
|
||||||
|
|
||||||
**`mdcms.py`** — find this line near the top and bump it:
|
|
||||||
```python
|
|
||||||
CLI_VERSION = "0.3.8"
|
|
||||||
```
|
|
||||||
|
|
||||||
**`pyproject.toml`** — bump the matching line:
|
|
||||||
```toml
|
|
||||||
version = "0.3.8"
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have made changes that affect the `index.html` and/or `config.yml`, update the minimum supported version comment at the top of each file:
|
|
||||||
|
|
||||||
**`app/index.html`** — bump the matching line:
|
|
||||||
```html
|
|
||||||
<!-- Minimum supported version: mdcms v0.3.8 | DO NOT REMOVE THIS COMMENT -->
|
|
||||||
```
|
|
||||||
|
|
||||||
**`app/config.yml`** — bump the matching line:
|
|
||||||
```yml
|
|
||||||
# Minimum supported version: mdcms v0.3.2 | DO NOT REMOVE THIS COMMENT
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### Commit locally
|
|
||||||
Commit the version bump:
|
|
||||||
```bash
|
|
||||||
git add mdcms.py pyproject.toml
|
|
||||||
git commit -m "Bump version to 0.3.8"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
#### Commit on the web
|
|
||||||
Save each file with a version bump notice: `"Bump version to 0.3.8"`.
|
|
||||||
|
|
||||||
### Tagging the release
|
|
||||||
|
|
||||||
#### Locally
|
|
||||||
Push a version tag to trigger the workflow:
|
|
||||||
```bash
|
|
||||||
git tag v0.3.8
|
|
||||||
git push origin v0.3.8
|
|
||||||
```
|
|
||||||
#### On the web
|
|
||||||
|
|
||||||
1. Go to your repository → Releases (right sidebar)
|
|
||||||
1. Click Draft a new release
|
|
||||||
1. Click Choose a tag → type v0.3.7 → click Create new tag: v0.3.8 on publish
|
|
||||||
1. Leave the target branch as main
|
|
||||||
1. Click Generate release notes
|
|
||||||
1. Click Publish release
|
|
||||||
|
|
||||||
The binaries will be generated by
|
|
||||||
|
|
||||||
#### Common
|
|
||||||
|
|
||||||
The tag must start with `v`. The workflow triggers immediately.
|
|
||||||
|
|
||||||
> **Pre-releases** — if the tag contains a hyphen (e.g. `v0.4.0-beta.1`), the GitHub release is automatically marked as pre-release.
|
|
||||||
|
|
||||||
## What the workflow does
|
|
||||||
|
|
||||||
The workflow (`.github/workflows/release.yml`) runs three parallel jobs:
|
|
||||||
|
|
||||||
| Runner | Output |
|
|
||||||
|---|---|
|
|
||||||
| `ubuntu-latest` | `mdcms-linux-amd64` (standalone binary) + `mdcms_0.3.1_amd64.deb` |
|
|
||||||
| `macos-latest` | `mdcms-macos-arm64` (standalone binary) |
|
|
||||||
| `windows-latest` | `mdcms-windows-amd64.exe` |
|
|
||||||
|
|
||||||
Each binary is built with PyInstaller — Python is bundled inside, so end users need nothing pre-installed. The `.deb` is built with `fpm` and installs mdcms to `/usr/local/bin`.
|
|
||||||
|
|
||||||
A final job collects all artifacts and creates the GitHub release with auto-generated release notes.
|
|
||||||
|
|
||||||
## Monitoring the build
|
|
||||||
|
|
||||||
1. Go to **Actions** on the repository
|
|
||||||
2. Click the workflow run triggered by your tag
|
|
||||||
3. Each platform job takes roughly 3–5 minutes
|
|
||||||
|
|
||||||
If a job fails, the release is not created. Fix the issue, delete the tag, and re-push:
|
|
||||||
```bash
|
|
||||||
git tag -d v0.3.8
|
|
||||||
git push origin --delete v0.3.8
|
|
||||||
```
|
|
||||||
|
|
||||||
Fix the issue, then re-tag:
|
|
||||||
```bash
|
|
||||||
git tag v0.3.8
|
|
||||||
git push origin v0.3.8
|
|
||||||
```
|
|
||||||
|
|
||||||
## The finished release
|
|
||||||
|
|
||||||
Once all jobs pass, the release appears under **Releases** on the repository with:
|
|
||||||
- Auto-generated changelog (commits since the last tag)
|
|
||||||
- All four artifacts attached for download
|
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
If you serve `latest` binaries from a standard URL, you also have to make sure to update these.
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
````
|
|
||||||
|
|
@ -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 (h1–h6) 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"
|
|
||||||
```
|
|
||||||
|
|
@ -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−|2L−1|)`) 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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "mdcms"
|
|
||||||
version = "0.6.0"
|
|
||||||
description = "MD-CMS — Markdown-based CMS companion CLI"
|
|
||||||
readme = "README.md"
|
|
||||||
license = { text = "Apache-2.0" }
|
|
||||||
authors = [{ name = "Kristian Benestad" }]
|
|
||||||
requires-python = ">=3.9"
|
|
||||||
dependencies = [
|
|
||||||
"click>=8.0",
|
|
||||||
"PyYAML>=6.0",
|
|
||||||
"certifi>=2024.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
mdcms = "mdcms:main"
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
Homepage = "https://github.com/kbenestad/mdcms"
|
|
||||||
Documentation = "https://docs.benestad.net"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
py-modules = ["mdcms"]
|
|
||||||
22
resources/knownlimitations.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# MD-CMS — Known limitations
|
||||||
|
|
||||||
|
MD-CMS is under active development.
|
||||||
|
|
||||||
|
## mdcms.py only targets `/website` directory inside a project directory
|
||||||
|
You can run `mdcms.py` from anywhere and define multiple projects. However, the current version excepts the website itself to live inside a `/website` directory inside your project directory.
|
||||||
|
|
||||||
|
In other words, you cannot keep the files in
|
||||||
|
|
||||||
|
- `/home/username/mdcmssites/site-1` and
|
||||||
|
- `/home/username/mdcmssites/site-2`
|
||||||
|
|
||||||
|
they must be in
|
||||||
|
|
||||||
|
- `/home/username/mdcmssites/site-1/website` and
|
||||||
|
- `/home/username/mdcmssites/site-2/website`.
|
||||||
|
|
||||||
|
## mdcms tags for posts
|
||||||
|
The tags that lists posts are broken. Currently, the only tags that reliably show posts are:
|
||||||
|
|
||||||
|
- `posts-datetime-chronological-byyearmonth`
|
||||||
|
- `posts-datetime-reversechronological`
|
||||||
34
resources/quickstart.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# MD-CMS — Quickstart
|
||||||
|
|
||||||
|
A lightweight Markdown-based CMS. Content is written as plain Markdown with YAML frontmatter; configuration lives in `website/config.yml`; navigation and search are generated by `mdcms.py`.
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
1. Put content into `website/pages/` and `website/posts/`.
|
||||||
|
2. Edit `website/config.yml` with your site name and preferences.
|
||||||
|
3. From this directory, run `python3 mdcms.py`.
|
||||||
|
4. In the menu, pick option **7** to register the website path (point it at the `website/` folder).
|
||||||
|
5. Pick option **3** to build `nav.yml` and `search.json`.
|
||||||
|
6. Pick option **8** to start a local webserver and preview the site.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mdcms.py
|
||||||
|
quickstart.md
|
||||||
|
website/
|
||||||
|
assets/
|
||||||
|
fonts/
|
||||||
|
images/
|
||||||
|
pages/
|
||||||
|
home.md
|
||||||
|
posts/
|
||||||
|
index.html
|
||||||
|
config.yml
|
||||||
|
nav.yml (generated)
|
||||||
|
search.json (generated)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Apache 2.0. See <https://kbenestad.codeberg.page/md-cms> for documentation.
|
||||||
|
|
@ -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
|
|
||||||
* [ ] …
|
|
||||||
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# 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.
|
|
||||||
|
|
@ -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
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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.*
|
|
||||||
|
|
@ -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.
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# 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 8–10 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 3–4 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 60–90 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.
|
|
||||||
|
|
@ -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.
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# 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**: 24–26°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 50–100% 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 4–6 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 4–6 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 12–24 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.
|
|
||||||
|
|
@ -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 4–5 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 60–90 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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.5–3 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 3–4 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 25–30 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 45–60 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.
|
|
||||||
|
|
@ -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 20–30 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 1–2 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.
|
|
||||||
|
|
@ -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 10–12 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 8–10 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 3–3.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 5–10 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.
|
|
||||||
|
|
@ -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 (5–7mm). Spread on a rack in a low oven (50–60°C) with the door slightly ajar, or use a dehydrator, for 4–6 hours until completely dry and brittle. Store in an airtight jar.
|
|
||||||
|
|
||||||
**Rehydrating**: Soak in warm water for 20–30 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 3–4 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.
|
|
||||||
|
|
@ -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 (2–6 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 1–1.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 2–2.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 3–4 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 6–8 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: 20–25 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.
|
|
||||||
|
|
@ -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 18–22 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 5–8 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 25–30 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 10–14 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.
|
|
||||||