Compare commits
131 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a25829984d | |||
| 1f083fd12f | |||
|
|
c7fde737f2 | ||
|
|
c43d8415a4 | ||
|
|
8e7f5d3ae9 | ||
| be698a2bdd | |||
|
|
e559e67341 | ||
|
|
810ed975e5 | ||
|
|
31330d19e2 | ||
| 8295cbca2c | |||
| be1e908615 | |||
|
|
0e14e80d1f | ||
| 21cae07a9b | |||
| 5af611502a | |||
| 2e2fd2093f | |||
|
|
ee3a967b86 | ||
|
|
a4031bc008 | ||
|
|
a09df3a63c | ||
| 099320cde7 | |||
|
|
4c8ca31651 | ||
|
|
5f3175497b | ||
|
|
ef4197fa83 | ||
|
|
cc4ed7b881 | ||
| a939883bef | |||
| 80877cbaea | |||
| 7ab7d468e5 | |||
|
|
b9410d4b88 | ||
|
|
0bf8cf319b | ||
| 2fd48fcb7f | |||
|
|
431b1c054c | ||
| 893588d89b | |||
| 03adf65c62 | |||
| 65c4944a84 | |||
|
|
7b2d54da57 | ||
|
|
28b248735f | ||
|
|
a8fcc79ba9 | ||
|
|
ee3d4872a0 | ||
|
|
269980ea28 | ||
|
|
283e0f8299 | ||
|
|
3ac0a89be1 | ||
|
|
11dc053118 | ||
|
|
51cb68c4f9 | ||
|
|
d63fc0035f | ||
|
|
1279b8035d | ||
|
|
e1527d8e3b | ||
|
|
cb0bc180a4 | ||
| cd02f43e82 | |||
| c1d83b4bd6 | |||
|
|
aa9ea34683 | ||
|
|
24f428d8d3 | ||
|
|
cef2318210 | ||
|
|
a4eb1c25fe | ||
|
|
237fae111a | ||
|
|
a67d731700 | ||
|
|
b69af5dfa2 | ||
|
|
8c4486a1f8 | ||
|
|
c77e423bdd | ||
|
|
da7d33ccc2 | ||
| cfe11cd24b | |||
| f08e8d896a | |||
|
|
6b491846d7 | ||
|
|
bcb451a9ee | ||
| f836e320b3 | |||
|
|
737049f19e | ||
|
|
648f1afcfd | ||
|
|
f4a41ed3ae | ||
|
|
1d76226311 | ||
|
|
4e66024be0 | ||
|
|
f2bc729e40 | ||
|
|
92af867da2 | ||
|
|
923626855f | ||
| 9ea0c0cc55 | |||
| c7ddf32720 | |||
|
|
f9f3b9b81b | ||
|
|
74681670d7 | ||
| 3858307302 | |||
| 59efc20dde | |||
| b3c46ec4bc | |||
|
|
0054e050a7 | ||
|
|
7fa60aae59 | ||
|
|
d72a7c0d7f | ||
| ac0b634cc0 | |||
|
|
6b2eb490fa | ||
|
|
8b4114004e | ||
|
|
9856a94d26 | ||
|
|
a9e48ffee4 | ||
| db794cedf7 | |||
|
|
cd1668dd04 | ||
|
|
020b05f1f6 | ||
|
|
ad2f7b47ee | ||
|
|
383dbcd420 | ||
|
|
92615fad1c | ||
| 8993a4285c | |||
|
|
81d0f2ffd0 | ||
|
|
60694c9ecf | ||
|
|
9b7639cc62 | ||
| 321202e5e0 | |||
|
|
2412608c7c | ||
| ca8deba23f | |||
|
|
c657529303 | ||
|
|
cd1e6e14ce | ||
|
|
dd306b004c | ||
|
|
b626d5e066 | ||
|
|
690965df7d | ||
|
|
8a39bc31e9 | ||
|
|
e8f3f39470 | ||
|
|
8ae283fe41 | ||
|
|
1051a36044 | ||
|
|
0386422a99 | ||
|
|
373ec4035e | ||
| 3c27e86cf2 | |||
| 56c22f075d | |||
| 7edc7ab339 | |||
|
|
546235020f | ||
|
|
6492329b27 | ||
|
|
503eb3d83d | ||
| bcf716bfd0 | |||
| a17cfc6f1a | |||
| 0a83ca7e38 | |||
| 6dc83e176d | |||
| a2822a6458 | |||
|
|
e3eb654996 | ||
|
|
ce6f9ec09f | ||
|
|
b1fc97a024 | ||
| b1b64917cf | |||
| fb1b7e6ee4 | |||
| ea039c1d78 | |||
| 767b370202 | |||
|
|
71bda790ef | ||
|
|
5e4612c01d | ||
|
|
a6eb3119e0 |
13
.github/workflows/mirror.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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 }}
|
||||
16
.github/workflows/release.yml
vendored
|
|
@ -31,6 +31,12 @@ jobs:
|
|||
artifact_name: mdcms-macos-arm64
|
||||
make_deb: false
|
||||
|
||||
- os: ubuntu-24.04-arm
|
||||
label: Linux arm64
|
||||
binary_name: mdcms
|
||||
artifact_name: mdcms-linux-arm64
|
||||
make_deb: true
|
||||
|
||||
- os: windows-latest
|
||||
label: Windows amd64
|
||||
binary_name: mdcms.exe
|
||||
|
|
@ -71,9 +77,9 @@ jobs:
|
|||
--url "https://github.com/kbenestad/mdcms" \
|
||||
--maintainer "Kristian Benestad" \
|
||||
--license "Apache-2.0" \
|
||||
--architecture amd64 \
|
||||
--architecture "${{ matrix.os == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" \
|
||||
--category utils \
|
||||
dist/mdcms-linux-amd64=/usr/local/bin/mdcms
|
||||
dist/${{ matrix.artifact_name }}=/usr/local/bin/mdcms
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
@ -88,7 +94,7 @@ jobs:
|
|||
if: matrix.make_deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb-package
|
||||
name: deb-package-${{ matrix.artifact_name }}
|
||||
path: "*.deb"
|
||||
|
||||
release:
|
||||
|
|
@ -120,6 +126,8 @@ jobs:
|
|||
--generate-notes \
|
||||
$PRERELEASE \
|
||||
artifacts/mdcms-linux-amd64/mdcms-linux-amd64 \
|
||||
artifacts/mdcms-linux-arm64/mdcms-linux-arm64 \
|
||||
artifacts/mdcms-macos-arm64/mdcms-macos-arm64 \
|
||||
artifacts/mdcms-windows-amd64/mdcms-windows-amd64.exe \
|
||||
artifacts/deb-package/*.deb
|
||||
artifacts/deb-package-mdcms-linux-amd64/*.deb \
|
||||
artifacts/deb-package-mdcms-linux-arm64/*.deb
|
||||
|
|
|
|||
10
.gitignore
vendored
|
|
@ -1,3 +1,13 @@
|
|||
### Python ###
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
### AL ###
|
||||
#Template for AL projects for Dynamics 365 Business Central
|
||||
#launch.json folder
|
||||
|
|
|
|||
119
CLAUDE.md
|
|
@ -2,10 +2,26 @@
|
|||
|
||||
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
|
||||
|
||||
- **Code changes** (`mdcms.py`, `pyproject.toml`, `app/`, `.github/`) — use branch `mdcms_cli` (create from `main` if it doesn't exist), PR to `main`.
|
||||
- **Documentation only** (`CLAUDE.md`, `docs/`) — push directly to `main`.
|
||||
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
|
||||
|
||||
|
|
@ -22,8 +38,8 @@ The `app/` folder is the deployable artifact and the starter template downloaded
|
|||
mdcms.py ← CLI tool
|
||||
pyproject.toml ← packaging (entry point, dependencies)
|
||||
app/
|
||||
index.html ← renderer + v0.3 version marker
|
||||
config.yml ← starter config + v0.3 version marker
|
||||
index.html ← renderer + v0.4 version marker
|
||||
config.yml ← starter config + v0.4 version marker
|
||||
nav.yml ← generated
|
||||
search.json ← generated
|
||||
pages/
|
||||
|
|
@ -55,6 +71,22 @@ During development, run directly: `python3 mdcms.py <command>`
|
|||
| `mdcms build <name>` | Build `nav.yml` and `search.json` for a registered site. |
|
||||
| `mdcms build --path <path>` | Build using an explicit path — no registry needed. Intended for CI/CD. |
|
||||
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
|
||||
| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. |
|
||||
| `mdcms fetch-deps --path <path>` | Same, using an explicit path. |
|
||||
|
||||
## PWA config keys
|
||||
|
||||
Set in `config.yml`. `mdcms build` generates `manifest.json` and `service-worker.js` when `pwa: yes`.
|
||||
|
||||
```yaml
|
||||
pwa: yes
|
||||
pwa-name: "My Documentation" # mandatory if pwa: yes
|
||||
pwa-shortname: "MyDocs" # optional short name for home screen
|
||||
pwa-colour: "#2563EB" # optional browser chrome colour
|
||||
offline-message:
|
||||
en: "You are offline and some content is unavailable."
|
||||
nb: "Du er frakoblet og noe innhold er utilgjengelig."
|
||||
```
|
||||
|
||||
**Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS.
|
||||
|
||||
|
|
@ -69,7 +101,7 @@ Single-module Python script. Logical layers in order:
|
|||
5. **Category system** — `identify_variant()` splits `.md` paths into `(base, category_code)`. A suffix is only treated as a category code if it appears in the declared code list.
|
||||
6. **Scanner** (`scan_and_categorize`) — walks a directory, skips drafts, returns records with the first 5000 chars of body for search indexing. Paths are relative to `site_root`.
|
||||
7. **Nav/search generators** — `generate_nav_yml()` emits a fixed-format YAML subset. `generate_search_json()` emits a JSON array. `merge_sections()` preserves existing section metadata on rebuild.
|
||||
8. **Core build** (`run_build`) — orchestrates the full build: version check → config read → scan → merge → write nav.yml and search.json.
|
||||
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()`.
|
||||
|
||||
|
|
@ -77,8 +109,9 @@ Single-module Python script. Logical layers in order:
|
|||
|
||||
Every mdcms site has a version marker on the first line of two files:
|
||||
|
||||
- `config.yml` line 1: `# mdcms v0.3 | DO NOT REMOVE THIS COMMENT`
|
||||
- `index.html` line 1: `<!-- mdcms v0.3 | DO NOT REMOVE THIS COMMENT -->`
|
||||
- `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.
|
||||
|
||||
|
|
@ -118,8 +151,7 @@ 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
|
||||
date: 2025-01-01
|
||||
datetime: 2025-01-01 13:00 # use this for posts (not `date` alone — see known limitations)
|
||||
created: 2025-01-01 13:00
|
||||
modified: 2025-01-15 09:00
|
||||
keywords: foo, bar
|
||||
description: Short description for search
|
||||
|
|
@ -142,6 +174,7 @@ For nested navigation, set `parent: <parent-section-code>` and `parent-sort` on
|
|||
- Variant files: `<base>.<code>.md` — the suffix is only treated as a category if the code is declared in config
|
||||
- `categories-sectionnames: per-category` requires each section in `nav.yml` to have a `categorynames` block with an entry per category code
|
||||
- RTL is set per category via `direction: rtl`
|
||||
- Line height is set per category via `line-height: 2.8` (useful for scripts like Nastaliq that need extra vertical space). Restores to theme default when switching to a category without this key.
|
||||
|
||||
## Dynamic post tags (mdcms code blocks)
|
||||
|
||||
|
|
@ -149,13 +182,13 @@ Embed post lists in pages using fenced blocks:
|
|||
|
||||
````markdown
|
||||
```mdcms
|
||||
posts-datetime-reversechronological
|
||||
posts-created-reversechronological
|
||||
limit: 10
|
||||
paginate: yes
|
||||
```
|
||||
````
|
||||
|
||||
Reliable tags (others are known-broken): `posts-datetime-chronological-byyearmonth`, `posts-datetime-reversechronological`. Use `datetime` frontmatter (format: `YYYY-MM-DD HH:MM`) for posts — `date` alone does not work reliably.
|
||||
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
|
||||
|
||||
|
|
@ -174,12 +207,74 @@ All artifacts are attached to the GitHub release using `gh release create`. The
|
|||
2. Update `version` in `pyproject.toml`
|
||||
3. Update site format markers in `app/config.yml` and `app/index.html` only if the site format changed
|
||||
|
||||
Then: `git tag v0.3.1 && git push origin v0.3.1`
|
||||
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
|
||||
|
||||
|
|
|
|||
30
app/404.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!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>
|
||||
0
app/assets/icons/.gitkeep
Normal file
1
app/assets/icons/add.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
|
After Width: | Height: | Size: 134 B |
1
app/assets/icons/arrow_drop_down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
|
||||
|
After Width: | Height: | Size: 113 B |
1
app/assets/icons/arrow_right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 17l5-5-5-5v10z"/></svg>
|
||||
|
After Width: | Height: | Size: 117 B |
1
app/assets/icons/collapse_content.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 176 B |
1
app/assets/icons/dangerous.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 255 B |
1
app/assets/icons/dark_mode.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 266 B |
1
app/assets/icons/error.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 274 B |
1
app/assets/icons/exclamation.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 195 B |
1
app/assets/icons/expand_content.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 177 B |
1
app/assets/icons/history.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 323 B |
1
app/assets/icons/info.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 195 B |
1
app/assets/icons/keyboard_arrow_down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 144 B |
1
app/assets/icons/keyboard_arrow_right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 143 B |
1
app/assets/icons/keyboard_double_arrow_down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 190 B |
1
app/assets/icons/keyboard_double_arrow_right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 191 B |
1
app/assets/icons/language.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 888 B |
1
app/assets/icons/light_mode.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 878 B |
1
app/assets/icons/menu.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||
|
After Width: | Height: | Size: 144 B |
1
app/assets/icons/minimize.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13H5v-2h14v2z"/></svg>
|
||||
|
After Width: | Height: | Size: 116 B |
1
app/assets/icons/mobile_arrow_down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 154 B |
1
app/assets/icons/report.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 135 B |
1
app/assets/icons/search.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 333 B |
1
app/assets/icons/success.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 215 B |
1
app/assets/icons/text_compare.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 240 B |
1
app/assets/icons/warning.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 149 B |
BIN
app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
# mdcms v0.3 | DO NOT REMOVE THIS COMMENT
|
||||
# MD-CMS v0.3 — Site configuration
|
||||
# mdcms v0.6.0 | DO NOT REMOVE THIS COMMENT
|
||||
# MD-CMS v0.6.0 — Site configuration
|
||||
#
|
||||
# Only `sitename` and `navigation` are required. Uncomment and edit the rest
|
||||
# as needed. See https://kbenestad.codeberg.page/md-cms for the full reference.
|
||||
|
|
@ -16,12 +16,21 @@
|
|||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the Licence is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the Licence for the specific language governing permissions and
|
||||
# limitations under the Licence.
|
||||
|
||||
# ──────────────────────────────────
|
||||
# Site identity
|
||||
# ──────────────────────────────────
|
||||
sitename: MD-CMS New Site
|
||||
navigation: topbar # sidebar | topbar
|
||||
sitename: MD-CMS Phase 7 Test
|
||||
navigation: sidebar # sidebar | topbar
|
||||
theme: theme.yml # presentational config — edit theme.yml to customise colours, fonts, and layout
|
||||
|
||||
pwa: yes
|
||||
pwa-name: MD-CMS Phase 7 Test
|
||||
pwa-shortname: MDCMS Test
|
||||
pwa-colour: "#2563EB"
|
||||
offline-message: "This page is not available offline. Connect to the internet and reload."
|
||||
|
||||
# homepage: pages/home.md # override the default landing page
|
||||
|
||||
|
|
@ -30,22 +39,25 @@ navigation: topbar # sidebar | topbar
|
|||
# favicon: favicon.png
|
||||
# footer: "© 2026 Your Name"
|
||||
|
||||
# ──────────────────────────────────
|
||||
# Typography (optional)
|
||||
# ──────────────────────────────────
|
||||
# font-title: "Inter:700"
|
||||
# font-body: Inter
|
||||
# font-code: JetBrains Mono
|
||||
|
||||
# ──────────────────────────────────
|
||||
# Layout (optional)
|
||||
# ──────────────────────────────────
|
||||
# main-width: 80em
|
||||
# nav-width: 20em
|
||||
# nav-position: left # left | right (sidebar mode)
|
||||
|
||||
# Typography and colours are configured in theme.yml, not here.
|
||||
|
||||
# ──────────────────────────────────
|
||||
# Features (optional)
|
||||
# ──────────────────────────────────
|
||||
# search: true
|
||||
# default-theme: system # light | dark | system
|
||||
|
||||
# ──────────────────────────────────
|
||||
# Reusable callout messages (optional)
|
||||
# ──────────────────────────────────
|
||||
callouts:
|
||||
aitranslation:
|
||||
type: warning
|
||||
en:
|
||||
title: "PLEASE NOTE:"
|
||||
text: This page has been translated with artificial intelligence. It has not been reviewed by staff yet.
|
||||
|
|
|
|||
1264
app/index.html
23
app/manifest.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
app/mdcms.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
20
app/nav.yml
|
|
@ -1,7 +1,6 @@
|
|||
# nav.yml — generated by mdcms.py
|
||||
# nav.yml — generated by mdcms
|
||||
# Manual edits to section metadata (defaultname, sort, parent, parent-sort,
|
||||
# pagesvisibility, categorynames) are preserved on rebuild. New sections
|
||||
# are auto-created from page frontmatter section-id values.
|
||||
# pagesvisibility, categorynames) are preserved on rebuild.
|
||||
|
||||
sections:
|
||||
# (none yet — add section-id to page frontmatter to auto-create)
|
||||
|
|
@ -9,6 +8,15 @@ pages:
|
|||
- file: pages/home.md
|
||||
title: Home
|
||||
sort: 100
|
||||
variants: [en]
|
||||
titles:
|
||||
en: Home
|
||||
|
||||
- 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
|
||||
|
|
|
|||
8
app/pages/about.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
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.
|
||||
8
app/pages/docs.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -3,65 +3,24 @@ title: Home
|
|||
sort: 100
|
||||
---
|
||||
|
||||
# MD-CMS
|
||||
# Phase 7 — PWA Test
|
||||
|
||||
This is the default startpage for MD-CMS.
|
||||
This page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.
|
||||
|
||||
## Testing MD-CMS
|
||||
## Test procedure
|
||||
|
||||
If you want to test `MD-CMS` you can grab `samplesite` from the repo and place the content in your website root. This page (`pages/home.md`) won't be replaced.
|
||||
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
|
||||
|
||||
**Post listing tests** below contains various custom tags to display posts. There are no posts now, but if you download the `samplesite` it will fetch the posts in
|
||||
## What to look for
|
||||
|
||||
## Post listing tests
|
||||
|
||||
## Reverse chronological (newest first)
|
||||
|
||||
```mdcms
|
||||
posts-date-reversechronological
|
||||
limit: 3
|
||||
paginate: no
|
||||
```
|
||||
|
||||
## Chronological (oldest first)
|
||||
|
||||
```mdcms
|
||||
posts-date-chronological
|
||||
limit: all
|
||||
paginate: none
|
||||
```
|
||||
|
||||
## By year (date, reverse chrono)
|
||||
|
||||
```mdcms
|
||||
posts-date-reversechronological-byyear
|
||||
limit: all
|
||||
defaultyear: current
|
||||
selectyear: yes
|
||||
paginate: none
|
||||
```
|
||||
|
||||
## By year+month (datetime, chrono)
|
||||
|
||||
```mdcms
|
||||
posts-datetime-chronological-byyearmonth
|
||||
limit: all
|
||||
defaultyear: 2024
|
||||
selectyear: yes
|
||||
```
|
||||
|
||||
## Last 30 days
|
||||
|
||||
```mdcms
|
||||
posts-date-reversechronological-lastmonth
|
||||
limit: all
|
||||
paginate: none
|
||||
```
|
||||
|
||||
## Paginated (2 per page)
|
||||
|
||||
```mdcms
|
||||
posts-datetime-reversechronological
|
||||
limit: 2
|
||||
paginate: yes
|
||||
```
|
||||
- `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
|
||||
|
|
|
|||
78
app/pages/tabs-accordions.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
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,4 +1,28 @@
|
|||
[
|
||||
{
|
||||
"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",
|
||||
|
|
@ -6,10 +30,21 @@
|
|||
"keywords": "",
|
||||
"description": "",
|
||||
"author": null,
|
||||
"date": "",
|
||||
"datetime": "",
|
||||
"created": "",
|
||||
"modified": "",
|
||||
"language": "en",
|
||||
"body": "# Post Listing Tests\n\n## Reverse chronological (newest first)\n\n```mdcms\nposts-date-reversechronological\nlimit: 3\npaginate: no\n```\n\n## Chronological (oldest first)\n\n```mdcms\nposts-date-chronological\nlimit: all\npaginate: none\n```\n\n## By year (date, reverse chrono)\n\n```mdcms\nposts-date-reversechronological-byyear\nlimit: all\ndefaultyear: current\nselectyear: yes\npaginate: none\n```\n\n## By year+month (datetime, chrono)\n\n```mdcms\nposts-datetime-chronological-byyearmonth\nlimit: all\ndefaultyear: 2024\nselectyear: yes\n```\n\n## Last 30 days\n\n```mdcms\nposts-date-reversechronological-lastmonth\nlimit: all\npaginate: none\n```\n\n## Paginated (2 per page)\n\n```mdcms\nposts-datetime-reversechronological\nlimit: 2\npaginate: yes\n```\n",
|
||||
"category": "en"
|
||||
"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"
|
||||
}
|
||||
]
|
||||
66
app/service-worker.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// 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))
|
||||
);
|
||||
});
|
||||
99
app/theme.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 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
docs/banner/v0.3.1.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
V0.3.1 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.
|
||||
1
docs/banner/v0.3.2.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
V0.3.2 is outdated. Please visit https://github.com/kbenestad/mdcms/ for update instructions.
|
||||
1
docs/banner/v0.3.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
This version is outdated. Please visit https://github.com/kbenestad/mdcms/ to update.
|
||||
1
docs/banner/v0.4
Normal file
|
|
@ -0,0 +1 @@
|
|||
You are using the latest version.
|
||||
309
docs/claude-design.md
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
# 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,10 +1,10 @@
|
|||
# Releasing a new version of mdcms
|
||||
# Release workflow
|
||||
|
||||
This guide covers publishing a new mdcms release — producing binaries, a `.deb` package, and a GitHub release with all artifacts attached.
|
||||
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 `kbenestad/mdcms` on GitHub
|
||||
- Push access to your GitHub release repository
|
||||
- GitHub Actions enabled on the repository (it is by default on new repos)
|
||||
|
||||
## One-time GitHub setup
|
||||
|
|
@ -14,7 +14,7 @@ This guide covers publishing a new mdcms release — producing binaries, a `.deb
|
|||
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. **Settings → Actions → General**
|
||||
2. Go to **Settings → Actions → General**
|
||||
3. Scroll to **Workflow permissions**
|
||||
4. Select **Read and write permissions**
|
||||
5. Click **Save**
|
||||
|
|
@ -24,47 +24,60 @@ 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 two places:
|
||||
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.1"
|
||||
CLI_VERSION = "0.3.8"
|
||||
```
|
||||
|
||||
**`pyproject.toml`** — bump the matching line:
|
||||
```toml
|
||||
version = "0.3.1"
|
||||
version = "0.3.8"
|
||||
```
|
||||
|
||||
> **Site format version** — the markers in `app/config.yml` and `app/index.html` (`# mdcms v0.3`) are separate from the CLI version. Only update them if the site file format has a breaking change that requires users to re-download the template. Most releases do not touch these.
|
||||
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.1"
|
||||
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.1"`.
|
||||
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.1
|
||||
git push origin v0.3.1
|
||||
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.0 → click Create new tag: v0.3.0 on publish
|
||||
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.
|
||||
|
|
@ -93,11 +106,14 @@ A final job collects all artifacts and creates the GitHub release with auto-gene
|
|||
|
||||
If a job fails, the release is not created. Fix the issue, delete the tag, and re-push:
|
||||
```bash
|
||||
git tag -d v0.3.1
|
||||
git push origin --delete v0.3.1
|
||||
# fix the issue, then re-tag
|
||||
git tag v0.3.1
|
||||
git push origin v0.3.1
|
||||
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
|
||||
|
|
@ -105,3 +121,6 @@ git push origin v0.3.1
|
|||
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.
|
||||
138
docs/github-workflow.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# 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!
|
||||
187
docs/install.md
|
|
@ -1,169 +1,52 @@
|
|||
# Setting up mdcms for your site
|
||||
# Setting up MD-CMS for your site
|
||||
This document walks you through the installation of MD-CMS.
|
||||
|
||||
This guide walks through installing mdcms, registering your site, and setting up automatic builds on GitHub so that `nav.yml` and `search.json` are always up to date when you push content.
|
||||
## 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.
|
||||
|
||||
## 1. Install mdcms
|
||||
## Recommended install
|
||||
To properly use MD-CMS you need to download the CLI tool.
|
||||
|
||||
Choose one method:
|
||||
### 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`.
|
||||
|
||||
**Standalone binary (no Python required — recommended for most users)**
|
||||
|
||||
Download the binary for your platform from the [latest release](https://github.com/kbenestad/mdcms/releases/latest):
|
||||
|
||||
- **Linux:** `mdcms-linux-amd64` → move to `/usr/local/bin/mdcms` and `chmod +x`
|
||||
- **Linux (deb):** `mdcms_<version>_amd64.deb` → `sudo dpkg -i mdcms_<version>_amd64.deb`
|
||||
- **macOS:** `mdcms-macos-arm64` → move to `/usr/local/bin/mdcms` and `chmod +x`
|
||||
- **Windows:** `mdcms-windows-amd64.exe` → rename to `mdcms.exe` and place it somewhere on your PATH
|
||||
|
||||
**Via pip (from GitHub)**
|
||||
```bash
|
||||
pip install git+https://github.com/kbenestad/mdcms.git
|
||||
#### 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
|
||||
```
|
||||
|
||||
**Via pipx (from GitHub)**
|
||||
```bash
|
||||
pipx install git+https://github.com/kbenestad/mdcms.git
|
||||
#### All other Linux distros
|
||||
For all other Linux distros, please run the following command in the terminal:
|
||||
```
|
||||
sudo curl -fsSL https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/linux/mdcms -o /usr/local/bin/mdcms && sudo chmod +x /usr/local/bin/mdcms
|
||||
```
|
||||
|
||||
Verify the installation:
|
||||
```bash
|
||||
mdcms --version
|
||||
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`.
|
||||
|
||||
## 2. Register your site
|
||||
### Windows
|
||||
|
||||
Navigate to your site directory (where `index.html` lives) and register it:
|
||||
In Windows 10 or 11, open PowerShell and run the following command:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/site
|
||||
mdcms register mysite
|
||||
```
|
||||
Invoke-WebRequest https://raw.githubusercontent.com/kbenestad/mdcms/main/latest/windows/mdcms.exe -OutFile "$env:USERPROFILE\AppData\Local\Microsoft\WindowsApps\mdcms.exe"
|
||||
```
|
||||
|
||||
Or pass the path explicitly:
|
||||
```bash
|
||||
mdcms register mysite /path/to/your/site
|
||||
```
|
||||
Verify which version you have installed by running `mdcms --version`.
|
||||
|
||||
**If no mdcms site exists at that path**, mdcms will download the starter template from GitHub automatically — `index.html`, `config.yml`, `pages/`, `posts/`, and `assets/` will be created for you.
|
||||
## Update
|
||||
|
||||
**If a site already exists** (you cloned an existing mdcms repo), mdcms will detect the version marker in `config.yml` and register it as-is.
|
||||
|
||||
---
|
||||
|
||||
## 3. Edit your config
|
||||
|
||||
Open `config.yml` in your site directory and set at minimum:
|
||||
|
||||
```yaml
|
||||
sitename: Your Site Name
|
||||
navigation: topbar # or: sidebar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Build locally
|
||||
|
||||
Run a build to generate `nav.yml` and `search.json` from your content:
|
||||
|
||||
```bash
|
||||
mdcms build mysite
|
||||
```
|
||||
|
||||
Check your site by starting a local server:
|
||||
```bash
|
||||
cd /path/to/your/site
|
||||
python3 -m http.server 8800
|
||||
```
|
||||
|
||||
Then open `http://localhost:8800` in your browser.
|
||||
|
||||
---
|
||||
|
||||
## 5. Set up automatic builds on GitHub
|
||||
|
||||
When you push new pages or posts to GitHub, you'll want `nav.yml` and `search.json` rebuilt automatically. This is done with a simple GitHub Actions workflow.
|
||||
|
||||
### One-time GitHub setup
|
||||
|
||||
1. Go to your site repository on GitHub
|
||||
2. **Settings → Actions → General**
|
||||
3. Under **Workflow permissions**, select **Read and write permissions**
|
||||
4. Click **Save**
|
||||
|
||||
This allows the workflow to commit the rebuilt files back to your repository.
|
||||
|
||||
### Add the workflow file
|
||||
|
||||
Create `.github/workflows/build.yml` in your site repository with this content:
|
||||
|
||||
```yaml
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pages/**"
|
||||
- "posts/**"
|
||||
- "config.yml"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install mdcms
|
||||
run: pip install git+https://github.com/kbenestad/mdcms.git
|
||||
|
||||
- name: Build
|
||||
run: mdcms build
|
||||
|
||||
- name: Commit updated files
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add nav.yml search.json
|
||||
git diff --staged --quiet || git commit -m "Build: update nav.yml and search.json"
|
||||
git push
|
||||
```
|
||||
|
||||
Commit and push this file to your repository. From that point on, every push that touches `pages/`, `posts/`, or `config.yml` will automatically rebuild and commit `nav.yml` and `search.json`.
|
||||
|
||||
> The workflow only commits if the files actually changed — `git diff --staged --quiet` skips the commit if nothing is different.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day workflow
|
||||
|
||||
1. Write or edit a page in `pages/` or a post in `posts/`
|
||||
2. Commit and push to `main`
|
||||
3. GitHub Actions rebuilds `nav.yml` and `search.json` automatically (~30 seconds)
|
||||
4. Your deployed site picks up the changes immediately
|
||||
|
||||
To build locally at any time (without pushing):
|
||||
```bash
|
||||
mdcms build mysite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
mdcms view # list all registered sites
|
||||
mdcms view mysite # show details for a site
|
||||
mdcms build mysite # build nav.yml + search.json
|
||||
mdcms build --path . # build from current directory (no registry needed)
|
||||
mdcms delete mysite # remove a site from the local registry
|
||||
```
|
||||
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.
|
||||
35
docs/knownbugs.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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,30 +0,0 @@
|
|||
# MD-CMS — Known limitations
|
||||
|
||||
MD-CMS is under active development.
|
||||
|
||||
## mdcms.py only targets `/website` directory inside a project directory
|
||||
You can run `mdcms.py` from anywhere and define multiple projects. However, the current version excepts the website itself to live inside a `/website` directory inside your project directory.
|
||||
|
||||
In other words, you cannot keep the files in
|
||||
|
||||
- `/home/username/mdcmssites/site-1` and
|
||||
- `/home/username/mdcmssites/site-2`
|
||||
|
||||
they must be in
|
||||
|
||||
- `/home/username/mdcmssites/site-1/website` and
|
||||
- `/home/username/mdcmssites/site-2/website`.
|
||||
|
||||
## mdcms tags for posts
|
||||
The tags that lists posts are broken. Currently, the only tags that reliably show posts are:
|
||||
|
||||
- `posts-datetime-chronological-byyearmonth`
|
||||
- `posts-datetime-reversechronological`
|
||||
|
||||
To correctly show posts, use `datetime` in frontmatter.
|
||||
|
||||
The tag `created` is defined in the frontmatter as the created date of a file, but it is not used.
|
||||
|
||||
Use `datetime` to indicate the date and time a file was created, using the format `YYYY-MM-DD HH:MM` (e.g., `2026-01-14 13:35`).
|
||||
|
||||
Use `modified` using the format `YYYY-MM-DD HH:MM` (e.g., `2026-01-14 13:35`) to show users when a file was last updated.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# MD-CMS — Quickstart
|
||||
|
||||
A lightweight Markdown-based CMS. Content is written as plain Markdown with YAML frontmatter; configuration lives in `website/config.yml`; navigation and search are generated by `mdcms.py`.
|
||||
|
||||
## First run
|
||||
|
||||
1. Put content into `website/pages/` and `website/posts/`.
|
||||
2. Edit `website/config.yml` with your site name and preferences.
|
||||
3. From this directory, run `python3 mdcms.py`.
|
||||
4. In the menu, pick option **7** to register the website path (point it at the `website/` folder).
|
||||
5. Pick option **3** to build `nav.yml` and `search.json`.
|
||||
6. Pick option **8** to start a local webserver and preview the site.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
mdcms.py
|
||||
quickstart.md
|
||||
website/
|
||||
assets/
|
||||
fonts/
|
||||
images/
|
||||
pages/
|
||||
home.md
|
||||
posts/
|
||||
index.html
|
||||
config.yml
|
||||
nav.yml (generated)
|
||||
search.json (generated)
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
Apache 2.0. See [docs.benestad.net](https://docs.benestad.net) for documentation.
|
||||
310
docs/reference-config.md
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
# 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.
|
||||
```
|
||||
185
docs/reference-nav.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# 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
|
||||
```
|
||||
324
docs/reference-pages.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# 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
|
||||
```
|
||||
````
|
||||
293
docs/reference-theme.md
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
# 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"
|
||||
```
|
||||
277
docs/unreleased.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# 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.
|
||||
52
docs/workflows.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 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.
|
||||
BIN
latest/linux/mdcms.deb
Normal file
477
mdcms.py
|
|
@ -1,16 +1,28 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# mdcms v0.3 — CLI companion
|
||||
# mdcms v0.6.0 — CLI companion
|
||||
#
|
||||
# Copyright 2026 Kristian Benestad
|
||||
# Apache License, Version 2.0 — https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""MD-CMS v0.3 — CLI tool for managing and building MD-CMS sites."""
|
||||
"""MD-CMS v0.6.0 — CLI tool for managing and building MD-CMS sites."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
|
@ -20,14 +32,21 @@ import certifi
|
|||
import click
|
||||
import yaml
|
||||
|
||||
CLI_VERSION = "0.3"
|
||||
CLI_VERSION = "0.6.0"
|
||||
CLI_RELEASE_DATE = "7 June 2026"
|
||||
MIN_SUPPORTED_VERSION = "0.3"
|
||||
|
||||
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
|
||||
CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
|
||||
|
||||
REGISTRY_FILE = Path.home() / ".config" / "mdcms" / "sites.json"
|
||||
GITHUB_CONTENTS_API = "https://api.github.com/repos/kbenestad/mdcms/contents/app"
|
||||
TEMPLATE_BASE_URL = "https://raw.githubusercontent.com/kbenestad/mdcms/main/app"
|
||||
MANIFEST_FILENAME = "mdcms.json"
|
||||
|
||||
GITHUB_URL_RE = re.compile(
|
||||
r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?"
|
||||
r"(?:/tree/([^/]+?)(?:/(.+?))?)?/?$"
|
||||
)
|
||||
|
||||
|
||||
# ─── Version helpers ──────────────────────────────────────────
|
||||
|
|
@ -100,9 +119,12 @@ def read_config(site_path: Path) -> dict:
|
|||
return {}
|
||||
try:
|
||||
text = config_file.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise click.ClickException(f"Could not read config.yml: {e}")
|
||||
try:
|
||||
return yaml.safe_load(text) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return {}
|
||||
except yaml.YAMLError as e:
|
||||
raise click.ClickException(f"config.yml is not valid YAML: {e}")
|
||||
|
||||
|
||||
def get_category_info(cfg: dict) -> dict:
|
||||
|
|
@ -174,8 +196,6 @@ def scan_and_categorize(directory: Path, site_root: Path, known_codes: set) -> l
|
|||
"sort": meta.get("sort"),
|
||||
"section-id": meta.get("section-id"),
|
||||
"author": meta.get("author"),
|
||||
"date": str(meta.get("date", "")),
|
||||
"datetime": str(meta.get("datetime", "")),
|
||||
"created": str(meta.get("created", "")),
|
||||
"modified": str(meta.get("modified", "")),
|
||||
"language": meta.get("language", "en"),
|
||||
|
|
@ -254,11 +274,19 @@ def build_page_nav(
|
|||
"sort": sort,
|
||||
}
|
||||
if categories_use:
|
||||
is_post = file.startswith("posts/")
|
||||
covered = {}
|
||||
has_uncategorized = False
|
||||
for code, record in variants.items():
|
||||
key = code if code is not None else default_code
|
||||
if key:
|
||||
covered[key] = record.get("title", "")
|
||||
if code is None:
|
||||
if is_post:
|
||||
has_uncategorized = True
|
||||
elif default_code:
|
||||
covered[default_code] = record.get("title", "")
|
||||
else:
|
||||
covered[code] = record.get("title", "")
|
||||
if has_uncategorized:
|
||||
entry["uncategorized"] = True
|
||||
entry["variants"] = sorted(covered.keys())
|
||||
entry["titles"] = covered
|
||||
out.append(entry)
|
||||
|
|
@ -302,6 +330,8 @@ def generate_nav_yml(sections: list, pages: list, categories_use: bool = False)
|
|||
if p.get("section-id"):
|
||||
lines.append(f" section-id: {p['section-id']}")
|
||||
lines.append(f" sort: {p.get('sort', 100)}")
|
||||
if categories_use and p.get("uncategorized"):
|
||||
lines.append(" uncategorized: true")
|
||||
if categories_use and p.get("variants"):
|
||||
lines.append(f" variants: [{', '.join(p['variants'])}]")
|
||||
if categories_use and p.get("titles"):
|
||||
|
|
@ -327,20 +357,99 @@ def generate_search_json(
|
|||
"keywords": r.get("keywords", ""),
|
||||
"description": r.get("description", ""),
|
||||
"author": r.get("author"),
|
||||
"date": r.get("date", ""),
|
||||
"datetime": r.get("datetime", ""),
|
||||
"created": r.get("created", ""),
|
||||
"modified": r.get("modified", ""),
|
||||
"language": r.get("language", "en"),
|
||||
"body": r.get("body", ""),
|
||||
}
|
||||
if categories_use:
|
||||
code = r.get("code")
|
||||
entry["category"] = code if code is not None else default_code
|
||||
is_post = r.get("file", "").startswith("posts/")
|
||||
if code is not None:
|
||||
entry["category"] = code
|
||||
elif is_post:
|
||||
entry["category"] = None # null = show in all categories
|
||||
else:
|
||||
entry["category"] = default_code
|
||||
out.append(entry)
|
||||
return json.dumps(out, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# ─── Asset validation ─────────────────────────────────────────
|
||||
|
||||
_ASSET_RE = re.compile(r'assets/[\w.\-/]+')
|
||||
|
||||
|
||||
def _collect_yaml_assets(val, source: str, out: list):
|
||||
if isinstance(val, str):
|
||||
if val.startswith("assets/"):
|
||||
out.append((val, source))
|
||||
elif isinstance(val, dict):
|
||||
for v in val.values():
|
||||
_collect_yaml_assets(v, source, out)
|
||||
elif isinstance(val, list):
|
||||
for item in val:
|
||||
_collect_yaml_assets(item, source, out)
|
||||
|
||||
|
||||
def validate_assets(site_path: Path, cfg: dict) -> list:
|
||||
"""Return list of warning strings for assets/ references that don't exist on disk."""
|
||||
refs: list = []
|
||||
|
||||
_collect_yaml_assets(cfg, "config.yml", refs)
|
||||
|
||||
theme_file = cfg.get("theme")
|
||||
if theme_file:
|
||||
theme_path = site_path / theme_file
|
||||
if theme_path.exists():
|
||||
try:
|
||||
theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {}
|
||||
_collect_yaml_assets(theme_data, theme_file, refs)
|
||||
except (OSError, yaml.YAMLError):
|
||||
pass
|
||||
|
||||
for folder in ("pages", "posts"):
|
||||
d = site_path / folder
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for md_file in sorted(d.rglob("*.md")):
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
rel = str(md_file.relative_to(site_path)).replace("\\", "/")
|
||||
for m in _ASSET_RE.finditer(content):
|
||||
refs.append((m.group(), rel))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
warnings = []
|
||||
seen: set = set()
|
||||
for asset_path, source in refs:
|
||||
key = (asset_path, source)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not (site_path / asset_path).exists():
|
||||
warnings.append(
|
||||
f"Warning: asset not found: {asset_path}\n Referenced in: {source}"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
# ─── Core build logic ─────────────────────────────────────────
|
||||
|
||||
_TITLE_RE = re.compile(r"<title>[^<]*</title>")
|
||||
|
||||
|
||||
def _patch_html_title(site_path: Path, sitename: str) -> None:
|
||||
index = site_path / "index.html"
|
||||
if not index.exists():
|
||||
return
|
||||
html = index.read_text(encoding="utf-8")
|
||||
new_html = _TITLE_RE.sub(f"<title>{sitename}</title>", html, count=1)
|
||||
if new_html != html:
|
||||
index.write_text(new_html, encoding="utf-8")
|
||||
|
||||
|
||||
def run_build(site_path: Path):
|
||||
"""Scan pages/ and posts/, write nav.yml and search.json. Raises ClickException on failure."""
|
||||
if not site_path.is_dir():
|
||||
|
|
@ -420,6 +529,18 @@ def run_build(site_path: Path):
|
|||
)
|
||||
click.echo(f" Wrote search.json ({len(live_pages) + len(post_records)} entries)")
|
||||
|
||||
_patch_html_title(site_path, cfg.get("sitename", ""))
|
||||
|
||||
pwa_enabled = str(cfg.get("pwa", "no")).lower() in ("yes", "true")
|
||||
if pwa_enabled:
|
||||
generate_pwa(site_path, cfg)
|
||||
else:
|
||||
cleanup_pwa(site_path)
|
||||
|
||||
asset_warnings = validate_assets(site_path, cfg)
|
||||
for w in asset_warnings:
|
||||
click.echo(click.style(w, fg="yellow"))
|
||||
|
||||
if auto_created:
|
||||
click.echo(click.style(
|
||||
f"\nNotice: {len(auto_created)} section(s) auto-created: {', '.join(auto_created)}\n"
|
||||
|
|
@ -427,10 +548,127 @@ def run_build(site_path: Path):
|
|||
fg="cyan",
|
||||
))
|
||||
|
||||
generate_site_manifest(site_path)
|
||||
|
||||
# ─── GitHub template download ─────────────────────────────────
|
||||
|
||||
def _github_get(url: str) -> bytes:
|
||||
# ─── PWA generation ───────────────────────────────────────────
|
||||
|
||||
def cleanup_pwa(site_path: Path):
|
||||
"""When pwa: no, write a self-unregistering service worker and remove manifest.json.
|
||||
|
||||
Browsers keep the previously installed service worker active until a new one is
|
||||
installed. Writing a stub that immediately unregisters itself ensures any stale
|
||||
caching worker is evicted on the next visit after a pwa: yes → pwa: no change.
|
||||
"""
|
||||
sw = site_path / "service-worker.js"
|
||||
sw.write_text(
|
||||
"// mdcms: PWA disabled — unregisters any previously installed service worker.\n"
|
||||
"self.addEventListener('install', () => self.skipWaiting());\n"
|
||||
"self.addEventListener('activate', event => {\n"
|
||||
" event.waitUntil(self.registration.unregister());\n"
|
||||
"});\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest = site_path / "manifest.json"
|
||||
if manifest.exists():
|
||||
manifest.unlink()
|
||||
click.echo(" Removed manifest.json (pwa: no)")
|
||||
click.echo(" Wrote service-worker.js (self-unregistering stub, pwa: no)")
|
||||
|
||||
|
||||
def generate_pwa(site_path: Path, cfg: dict):
|
||||
"""Generate manifest.json and service-worker.js when pwa: yes."""
|
||||
pwa_name = cfg.get("pwa-name", cfg.get("sitename", "MD-CMS Site"))
|
||||
pwa_shortname = cfg.get("pwa-shortname", pwa_name)
|
||||
pwa_colour = cfg.get("pwa-colour", "#2563EB")
|
||||
favicon = cfg.get("favicon", "favicon.png")
|
||||
icon_src = f"assets/images/{favicon}"
|
||||
|
||||
icons = []
|
||||
if (site_path / icon_src).exists():
|
||||
icons = [
|
||||
{"src": icon_src, "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": icon_src, "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||
]
|
||||
|
||||
# manifest.json
|
||||
manifest = {
|
||||
"id": "/",
|
||||
"name": pwa_name,
|
||||
"short_name": pwa_shortname,
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": pwa_colour,
|
||||
"icons": icons,
|
||||
}
|
||||
(site_path / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
click.echo(" Wrote manifest.json")
|
||||
|
||||
# Collect all files to precache
|
||||
precache: list = [
|
||||
"index.html", "config.yml", "nav.yml", "search.json",
|
||||
]
|
||||
theme_file = cfg.get("theme")
|
||||
if theme_file and (site_path / theme_file).exists():
|
||||
precache.append(theme_file)
|
||||
|
||||
for folder in ("pages", "posts", "assets"):
|
||||
d = site_path / folder
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in sorted(d.rglob("*")):
|
||||
if f.is_file():
|
||||
precache.append(str(f.relative_to(site_path)).replace("\\", "/"))
|
||||
|
||||
# Version hash — deterministic from sorted file list
|
||||
cache_hash = format(hash(tuple(sorted(precache))) & 0xFFFFFFFF, "08x")
|
||||
cache_name = f"mdcms-{cache_hash}"
|
||||
|
||||
urls_js = json.dumps(precache, indent=2, ensure_ascii=False)
|
||||
sw = f"""// mdcms service worker — generated by mdcms build
|
||||
const CACHE_NAME = '{cache_name}';
|
||||
const PRECACHE_URLS = {urls_js};
|
||||
|
||||
self.addEventListener('install', event => {{
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
}});
|
||||
|
||||
self.addEventListener('activate', event => {{
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
}});
|
||||
|
||||
self.addEventListener('fetch', event => {{
|
||||
if (event.request.method !== 'GET') return;
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
||||
);
|
||||
}});
|
||||
"""
|
||||
(site_path / "service-worker.js").write_text(sw, encoding="utf-8")
|
||||
click.echo(f" Wrote service-worker.js (cache: {cache_name})")
|
||||
|
||||
# ─── HTTP helpers ─────────────────────────────────────────────
|
||||
|
||||
def _http_get(url: str) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
|
||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _http_get_github(url: str) -> bytes:
|
||||
"""HTTP GET with GitHub API Accept header (for Contents API responses)."""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
|
|
@ -443,23 +681,134 @@ def _github_get(url: str) -> bytes:
|
|||
return resp.read()
|
||||
|
||||
|
||||
def _download_tree(api_url: str, dest: Path, depth: int = 0):
|
||||
items = json.loads(_github_get(api_url).decode("utf-8"))
|
||||
# ─── Site manifest generation ─────────────────────────────────
|
||||
|
||||
def generate_site_manifest(site_path: Path):
|
||||
"""Write mdcms.json to site_path listing all deployable files and empty dirs."""
|
||||
files = []
|
||||
empty_dirs = []
|
||||
for entry in sorted(site_path.rglob("*")):
|
||||
rel = entry.relative_to(site_path)
|
||||
# Skip anything inside a hidden directory or with a hidden name
|
||||
if any(p.startswith(".") for p in rel.parts):
|
||||
continue
|
||||
if entry.is_file():
|
||||
rel_str = str(rel).replace("\\", "/")
|
||||
if rel_str != MANIFEST_FILENAME:
|
||||
files.append(rel_str)
|
||||
elif entry.is_dir():
|
||||
# Only list dirs that have no non-hidden children
|
||||
visible = [c for c in entry.iterdir() if not c.name.startswith(".")]
|
||||
if not visible:
|
||||
empty_dirs.append(str(rel).replace("\\", "/"))
|
||||
|
||||
manifest: dict = {
|
||||
"mdcms": read_site_version(site_path) or "0.4",
|
||||
"files": files,
|
||||
}
|
||||
if empty_dirs:
|
||||
manifest["dirs"] = empty_dirs
|
||||
|
||||
(site_path / MANIFEST_FILENAME).write_text(
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
click.echo(f" Wrote {MANIFEST_FILENAME} ({len(files)} files)")
|
||||
|
||||
|
||||
# ─── Template download ────────────────────────────────────────
|
||||
|
||||
def _parse_github_url(url: str) -> "tuple | None":
|
||||
"""Return (owner, repo, branch, subpath) for a GitHub URL, else None."""
|
||||
m = GITHUB_URL_RE.match(url.strip())
|
||||
if not m:
|
||||
return None
|
||||
owner = m.group(1)
|
||||
repo = m.group(2)
|
||||
branch = m.group(3) or "main"
|
||||
subpath = (m.group(4) or "").strip("/")
|
||||
return owner, repo, branch, subpath
|
||||
|
||||
|
||||
def _fetch_manifest(base_url: str) -> "dict | None":
|
||||
"""Fetch mdcms.json from base_url. Returns parsed dict or None if not found."""
|
||||
url = base_url.rstrip("/") + "/" + MANIFEST_FILENAME
|
||||
try:
|
||||
data = _http_get(url)
|
||||
manifest = json.loads(data.decode("utf-8"))
|
||||
if isinstance(manifest.get("files"), list):
|
||||
return manifest
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _apply_manifest(manifest: dict, base_url: str, dest: Path):
|
||||
"""Download all files in manifest from base_url into dest."""
|
||||
base = base_url.rstrip("/")
|
||||
for rel in manifest.get("files", []):
|
||||
file_dest = dest / rel
|
||||
file_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
click.echo(f" {rel}")
|
||||
file_dest.write_bytes(_http_get(f"{base}/{rel}"))
|
||||
for rel in manifest.get("dirs", []):
|
||||
(dest / rel).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _download_tree_api(api_url: str, dest: Path, depth: int = 0):
|
||||
"""Recursively download from the GitHub Contents API (fallback when no manifest)."""
|
||||
items = json.loads(_http_get_github(api_url).decode("utf-8"))
|
||||
for item in items:
|
||||
item_dest = dest / item["name"]
|
||||
if item["type"] == "dir":
|
||||
item_dest.mkdir(parents=True, exist_ok=True)
|
||||
_download_tree(item["url"], item_dest, depth + 1)
|
||||
_download_tree_api(item["url"], item_dest, depth + 1)
|
||||
elif item["type"] == "file":
|
||||
click.echo(f" {' ' * depth}{item['name']}")
|
||||
item_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
item_dest.write_bytes(_github_get(item["download_url"]))
|
||||
item_dest.write_bytes(_http_get(item["download_url"]))
|
||||
|
||||
|
||||
def download_template(dest: Path):
|
||||
def download_template(dest: Path, source: str = None):
|
||||
"""Download a site template from a URL or GitHub address.
|
||||
|
||||
source may be:
|
||||
- A GitHub repo URL (https://github.com/owner/repo or .../tree/branch/path)
|
||||
- Any HTTPS URL pointing to a deployed mdcms site that has mdcms.json
|
||||
- None — uses the built-in mdcms starter template
|
||||
"""
|
||||
effective = (source or TEMPLATE_BASE_URL).rstrip("/")
|
||||
click.echo(f"Downloading site template into {dest} ...")
|
||||
try:
|
||||
_download_tree(GITHUB_CONTENTS_API, dest)
|
||||
github = _parse_github_url(effective)
|
||||
if github:
|
||||
owner, repo, branch, subpath = github
|
||||
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
|
||||
if subpath:
|
||||
raw_base = f"{raw_base}/{subpath}"
|
||||
manifest = _fetch_manifest(raw_base)
|
||||
if manifest is not None:
|
||||
_apply_manifest(manifest, raw_base, dest)
|
||||
else:
|
||||
# No manifest — fall back to GitHub Contents API tree walk
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents"
|
||||
if subpath:
|
||||
api_url = f"{api_url}/{subpath}"
|
||||
if branch not in ("main", "master"):
|
||||
api_url += f"?ref={branch}"
|
||||
_download_tree_api(api_url, dest)
|
||||
else:
|
||||
manifest = _fetch_manifest(effective)
|
||||
if manifest is None:
|
||||
if source:
|
||||
raise click.ClickException(
|
||||
f"No {MANIFEST_FILENAME} found at {effective}.\n"
|
||||
"The URL must point to a deployed mdcms site with a manifest, "
|
||||
"or to a GitHub repository."
|
||||
)
|
||||
raise click.ClickException(
|
||||
f"Could not fetch template manifest from {effective}"
|
||||
)
|
||||
_apply_manifest(manifest, effective, dest)
|
||||
click.echo(click.style("Template downloaded successfully.", fg="green"))
|
||||
except urllib.error.URLError as e:
|
||||
raise click.ClickException(f"Download failed: {e}")
|
||||
|
|
@ -467,8 +816,29 @@ def download_template(dest: Path):
|
|||
|
||||
# ─── CLI commands ─────────────────────────────────────────────
|
||||
|
||||
def _version_callback(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
click.echo(f"mdcms v{CLI_VERSION} (released {CLI_RELEASE_DATE})")
|
||||
url = f"https://raw.githubusercontent.com/kbenestad/mdcms/refs/heads/main/docs/banner/v{CLI_VERSION}.txt?t={int(time.time())}"
|
||||
try:
|
||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
|
||||
with urllib.request.urlopen(req, context=ssl_ctx, timeout=5) as resp:
|
||||
click.echo(resp.read().decode("utf-8").strip())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
click.echo("There is no online information defined for this version.")
|
||||
else:
|
||||
click.echo("There is no online information defined for this version.")
|
||||
except Exception:
|
||||
click.echo("There is no online information defined for this version.")
|
||||
ctx.exit()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(CLI_VERSION, prog_name="mdcms")
|
||||
@click.option("--version", is_flag=True, is_eager=True, expose_value=False,
|
||||
callback=_version_callback, help="Show version and exit.")
|
||||
def cli():
|
||||
"""MD-CMS — Markdown-based CMS companion CLI.
|
||||
|
||||
|
|
@ -478,12 +848,22 @@ def cli():
|
|||
|
||||
@cli.command()
|
||||
@click.argument("name")
|
||||
@click.argument("path", required=False, default=None, type=click.Path())
|
||||
def register(name, path):
|
||||
@click.argument("path", required=False, default=None)
|
||||
@click.option("--from", "source", default=None, metavar="URL",
|
||||
help="Download template from a GitHub repo or deployed site URL.")
|
||||
def register(name, path, source):
|
||||
"""Register a site by NAME at PATH (default: current directory).
|
||||
|
||||
If no mdcms site is found at the target path, the starter template is
|
||||
downloaded from GitHub automatically.
|
||||
PATH may be a local directory or a URL to download from. If no mdcms
|
||||
site is found at the local path, the template is downloaded from --from
|
||||
(or PATH if it is a URL, or the built-in mdcms starter by default).
|
||||
|
||||
\b
|
||||
Examples:
|
||||
mdcms register mysite
|
||||
mdcms register mysite ./mydir
|
||||
mdcms register mysite https://github.com/owner/repo
|
||||
mdcms register mysite --from https://example.com/deployed-site
|
||||
"""
|
||||
reg = load_registry()
|
||||
|
||||
|
|
@ -492,6 +872,12 @@ def register(name, path):
|
|||
f"'{name}' is already registered. Use 'mdcms delete {name}' to remove it first."
|
||||
)
|
||||
|
||||
# If PATH looks like a URL, treat it as the download source rather than a local path.
|
||||
if path and path.startswith(("http://", "https://", "git://")):
|
||||
if source is None:
|
||||
source = path
|
||||
path = None
|
||||
|
||||
site_path = Path(path).resolve() if path else Path.cwd()
|
||||
|
||||
if not site_path.is_dir():
|
||||
|
|
@ -509,7 +895,7 @@ def register(name, path):
|
|||
|
||||
if site_version is None:
|
||||
click.echo(f"No mdcms site found at {site_path}.")
|
||||
download_template(site_path)
|
||||
download_template(site_path, source)
|
||||
site_version = read_site_version(site_path)
|
||||
if site_version is None:
|
||||
raise click.ClickException(
|
||||
|
|
@ -647,6 +1033,39 @@ def build(name, path_override):
|
|||
click.echo(click.style("Build complete.", fg="green"))
|
||||
|
||||
|
||||
@cli.command("fetch-deps")
|
||||
@click.argument("name", required=False, default=None)
|
||||
@click.option("--path", "path_override", default=None, type=click.Path(),
|
||||
help="Explicit site path (no registry lookup).")
|
||||
def fetch_deps(name, path_override):
|
||||
"""Download external JS/CSS dependencies and patch index.html for offline use."""
|
||||
site_path = resolve_site_path(name, path_override)
|
||||
if not (site_path / "index.html").exists():
|
||||
raise click.ClickException(f"No index.html found at {site_path}")
|
||||
|
||||
click.echo(f"Fetching dependencies for {site_path} ...")
|
||||
|
||||
vendors_dir = site_path / "assets" / "required" / "vendors"
|
||||
vendors_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for cdn_url, rel_dest in CDN_DEPS:
|
||||
dest = site_path / rel_dest
|
||||
click.echo(f" {rel_dest}")
|
||||
try:
|
||||
dest.write_bytes(_http_get(cdn_url))
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to download {cdn_url}: {e}")
|
||||
|
||||
cfg = read_config(site_path)
|
||||
local_font_css: list = []
|
||||
if cfg.get("theme"):
|
||||
local_font_css = _fetch_bunny_fonts(site_path, cfg["theme"])
|
||||
|
||||
_patch_index_html(site_path, local_font_css)
|
||||
|
||||
click.echo(click.style("Done. Site is ready for offline use.", fg="green"))
|
||||
|
||||
|
||||
# ─── Entry point ─────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "mdcms"
|
||||
version = "0.3.0"
|
||||
version = "0.6.0"
|
||||
description = "MD-CMS — Markdown-based CMS companion CLI"
|
||||
readme = "README.md"
|
||||
license = { text = "Apache-2.0" }
|
||||
|
|
|
|||
12
sample-sites/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
## 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
|
||||
* [ ] …
|
||||
BIN
sample-sites/kitchen-table/assets/images/bread.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
sample-sites/kitchen-table/assets/images/hero.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
sample-sites/kitchen-table/assets/images/market.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
sample-sites/kitchen-table/assets/images/pasta.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
6
sample-sites/kitchen-table/config.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 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,5 +1,6 @@
|
|||
<!-- Minimum supported version: mdcms v0.3.8 | DO NOT REMOVE THIS COMMENT -->
|
||||
<!--
|
||||
MD-CMS v0.2 — Renderer
|
||||
MD-CMS v0.3.8 — Renderer
|
||||
|
||||
Copyright 2026 Kristian Benestad | kbenestad.codeberg.page
|
||||
|
||||
|
|
@ -31,8 +32,6 @@
|
|||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" id="hljs-light">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" id="hljs-dark" disabled>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
|
||||
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════
|
||||
|
|
@ -116,6 +115,11 @@
|
|||
--font-body-weight: 400;
|
||||
--main-width: 80em;
|
||||
--nav-width: 20em;
|
||||
--line-height-body: 1.7;
|
||||
--colour-info: #2563EB;
|
||||
--colour-warning: #D97706;
|
||||
--colour-success: #16A34A;
|
||||
--colour-error: #DC2626;
|
||||
}
|
||||
|
||||
html { font-size: 16px; scroll-behavior: smooth; }
|
||||
|
|
@ -125,7 +129,7 @@ body {
|
|||
font-weight: var(--font-body-weight);
|
||||
color: var(--font-colour);
|
||||
background: var(--bg-main);
|
||||
line-height: 1.7;
|
||||
line-height: var(--line-height-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
|
|
@ -276,13 +280,15 @@ body {
|
|||
}
|
||||
.nav-section-heading.toggleable:hover { color: var(--font-colour); }
|
||||
.nav-section-heading .toggle-icon {
|
||||
font-family: var(--font-code);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
width: 0.9em;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mdcms-icon { display: inline-flex; align-items: center; line-height: 1; }
|
||||
.mdcms-icon svg { width: 1em; height: 1em; fill: currentColor; display: block; }
|
||||
.nav-item.depth-1 { padding-left: 2.5rem; }
|
||||
.nav-item.depth-2 { padding-left: 3.5rem; }
|
||||
.nav-item.depth-3 { padding-left: 4.5rem; }
|
||||
|
|
@ -370,6 +376,33 @@ body {
|
|||
}
|
||||
.topbar-nav .nav-item.active { border-left: none; background: var(--nav-active-bg); }
|
||||
|
||||
/* ─── Topbar grouped navigation (dropdowns) ─── */
|
||||
.topbar-nav .nav-group { position: relative; }
|
||||
.topbar-nav .nav-trigger {
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.35rem 0.75rem; border-radius: 5px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--font-colour); font-size: 0.85rem;
|
||||
font-family: inherit; white-space: nowrap; text-decoration: none; line-height: inherit;
|
||||
}
|
||||
.topbar-nav .nav-trigger:hover,
|
||||
.topbar-nav .nav-group.open > .nav-trigger { background: var(--nav-hover-bg); }
|
||||
.topbar-nav .nav-group.has-active > .nav-trigger { background: var(--nav-active-bg); }
|
||||
.topbar-nav .nav-caret { font-size: 0.6rem; color: var(--font-colour-muted); opacity: 0.55; line-height: 1; }
|
||||
.topbar-nav .nav-dropdown {
|
||||
display: none; position: absolute; top: calc(100% + 4px); left: 0;
|
||||
background: var(--bg-nav); border: 1px solid var(--divider);
|
||||
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
min-width: 160px; z-index: 200; padding: 0.25rem 0;
|
||||
}
|
||||
.topbar-nav .nav-group.open > .nav-dropdown { display: block; }
|
||||
.topbar-nav .nav-dropdown .nav-item {
|
||||
display: block; padding: 0.45rem 1rem;
|
||||
border-left: none; border-radius: 0; white-space: nowrap;
|
||||
}
|
||||
.topbar-nav .nav-dropdown .nav-item:hover { background: var(--nav-hover-bg); }
|
||||
.topbar-nav .nav-dropdown .nav-item.active { background: var(--nav-active-bg); font-weight: 600; }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
|
||||
.topbar-search { position: relative; }
|
||||
|
|
@ -511,9 +544,9 @@ body {
|
|||
.category-bar[dir="rtl"] { justify-content: flex-start; }
|
||||
|
||||
.category-icon {
|
||||
font-family: 'Material Symbols Outlined', 'Material Icons', sans-serif;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: var(--font-colour-muted);
|
||||
}
|
||||
|
||||
|
|
@ -731,6 +764,19 @@ body {
|
|||
border-left: none;
|
||||
}
|
||||
.layout-topbar .mobile-nav-panel .nav-item.active { border-left: none; font-weight: 600; }
|
||||
.layout-topbar .mobile-nav-panel .nav-group-row { display: flex; align-items: stretch; }
|
||||
.layout-topbar .mobile-nav-panel .nav-group-row .nav-item { flex: 1; }
|
||||
.layout-topbar .mobile-nav-panel .nav-section-label {
|
||||
flex: 1; display: flex; align-items: center;
|
||||
padding: 0.6rem 1.25rem; font-size: 1rem; color: var(--font-colour); font-weight: 500;
|
||||
}
|
||||
.layout-topbar .mobile-nav-panel .nav-expand-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--font-colour-muted); padding: 0 1.25rem; font-size: 1.2rem; line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.layout-topbar .mobile-nav-panel .nav-group-children { display: none; }
|
||||
.layout-topbar .mobile-nav-panel .nav-group-children.open { display: block; }
|
||||
.layout-topbar .mobile-nav-panel .nav-group-children .nav-item { padding-left: 2.5rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
|
@ -739,6 +785,40 @@ body {
|
|||
.main-content { padding: 1rem 1rem 3rem; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
TAG SYSTEM: CALLOUTS
|
||||
═══════════════════════════════════════════ */
|
||||
.mdcms-callout {
|
||||
border-left: 4px solid var(--callout-primary, var(--accent));
|
||||
background: var(--callout-bg, transparent);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.85rem 1rem 0.85rem 1rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
.mdcms-callout-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.mdcms-callout-title .mdcms-icon { font-size: 1.1em; }
|
||||
.mdcms-callout-body { font-size: 0.95rem; }
|
||||
.mdcms-callout-body > *:first-child { margin-top: 0; }
|
||||
.mdcms-callout-body > *:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
TAG SYSTEM: TABLE OF CONTENTS
|
||||
═══════════════════════════════════════════ */
|
||||
.mdcms-toc { margin: 1rem 0; }
|
||||
.mdcms-toc-section { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 0.4rem; color: var(--font-colour-muted); border-bottom: 1px solid var(--divider); padding-bottom: 0.25rem; }
|
||||
.mdcms-toc-list { list-style: none; padding: 0; margin: 0 0 0.5rem; }
|
||||
.mdcms-toc-list li { padding: 0.2rem 0; border-bottom: 1px solid var(--divider); }
|
||||
.mdcms-toc-list li:last-child { border-bottom: none; }
|
||||
.mdcms-toc-list a { color: var(--accent); text-decoration: none; font-size: 0.95rem; }
|
||||
.mdcms-toc-list a:hover { text-decoration: underline; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
TAG SYSTEM: POST LISTINGS
|
||||
═══════════════════════════════════════════ */
|
||||
|
|
@ -847,6 +927,7 @@ body {
|
|||
let searchIndex = [];
|
||||
let fuseInstance = null;
|
||||
let currentPage = null;
|
||||
let themeConfig = {};
|
||||
|
||||
// Category state (phase 3)
|
||||
let categoriesUse = false;
|
||||
|
|
@ -858,13 +939,35 @@ body {
|
|||
let loadedFonts = new Set(); // track which font files have been loaded
|
||||
|
||||
// ─── Icons ────────────────────────────────────────────────
|
||||
const ICONS = {
|
||||
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>',
|
||||
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||
menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
||||
};
|
||||
const STANDARD_ICONS = ['dark_mode','light_mode','menu','search','arrow_right','arrow_drop_down','mobile_arrow_down','language','info','warning','error','success','exclamation','dangerous','report','history','text_compare'];
|
||||
const iconCache = {};
|
||||
|
||||
function normaliseIconName(name) {
|
||||
return String(name).trim().replace(/\.svg$/i, '').toLowerCase().replace(/[\s-]+/g, '_') + '.svg';
|
||||
}
|
||||
|
||||
async function loadIcon(name) {
|
||||
const filename = normaliseIconName(name);
|
||||
if (filename in iconCache) return iconCache[filename];
|
||||
try {
|
||||
const resp = await fetch('assets/icons/' + filename);
|
||||
iconCache[filename] = resp.ok ? await resp.text() : null;
|
||||
} catch (e) { iconCache[filename] = null; }
|
||||
return iconCache[filename];
|
||||
}
|
||||
|
||||
function getIcon(name) {
|
||||
return iconCache[normaliseIconName(name)] || null;
|
||||
}
|
||||
|
||||
function iconEl(name, className) {
|
||||
const svg = getIcon(name);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
|
||||
const filename = normaliseIconName(name);
|
||||
span.innerHTML = svg || '<img src="assets/icons/' + filename + '" alt="[missing: ' + filename + ']" style="width:1em;height:1em;display:inline-block;">';
|
||||
return span;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function el(tag, attrs, children) {
|
||||
|
|
@ -1091,8 +1194,9 @@ body {
|
|||
const btn = document.querySelector('.theme-toggle');
|
||||
if (btn) {
|
||||
const isDark = theme === 'dark';
|
||||
btn.innerHTML = (isDark ? ICONS.sun : ICONS.moon) +
|
||||
'<span>' + (isDark ? 'Light mode' : 'Dark mode') + '</span>';
|
||||
btn.innerHTML = '';
|
||||
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
|
||||
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1111,6 +1215,53 @@ body {
|
|||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
function applyThemeYml(tc) {
|
||||
if (!tc) return;
|
||||
const root = document.documentElement;
|
||||
const getOrCreateStyle = id => {
|
||||
let s = document.getElementById(id);
|
||||
if (!s) { s = document.createElement('style'); s.id = id; document.head.appendChild(s); }
|
||||
return s;
|
||||
};
|
||||
|
||||
let modeCss = '';
|
||||
['light', 'dark'].forEach(mode => {
|
||||
const m = tc[mode];
|
||||
if (!m) return;
|
||||
const vars = [];
|
||||
if (m.accent) {
|
||||
const rgb = hexToRgb(m.accent);
|
||||
vars.push(`--accent: ${m.accent}`);
|
||||
vars.push(`--accent-rgb: ${rgb}`);
|
||||
vars.push(`--nav-active-bg: rgba(${rgb}, 0.10)`);
|
||||
vars.push(`--nav-hover-bg: rgba(${rgb}, 0.05)`);
|
||||
vars.push(`--table-header-bg: rgba(${rgb}, 0.08)`);
|
||||
vars.push(`--link-colour: ${m.accent}`);
|
||||
}
|
||||
if (m.background) { vars.push(`--bg-main: ${m.background}`); vars.push(`--search-bg: ${m.background}`); }
|
||||
if (m['nav-background']) vars.push(`--bg-nav: ${m['nav-background']}`);
|
||||
if (m.text) { vars.push(`--font-colour: ${m.text}`); vars.push(`--code-font: ${m.text}`); }
|
||||
if (m['text-muted']) vars.push(`--font-colour-muted: ${m['text-muted']}`);
|
||||
if (vars.length) modeCss += `:root[data-theme="${mode}"] { ${vars.join('; ')}; }\n`;
|
||||
});
|
||||
if (modeCss) getOrCreateStyle('theme-overrides').textContent = modeCss;
|
||||
|
||||
if (tc['colours-semantic']) {
|
||||
const sem = tc['colours-semantic'];
|
||||
const semVars = [];
|
||||
if (sem.info) semVars.push(`--colour-info: ${sem.info}`);
|
||||
if (sem.warning) semVars.push(`--colour-warning: ${sem.warning}`);
|
||||
if (sem.success) semVars.push(`--colour-success: ${sem.success}`);
|
||||
if (sem.error) semVars.push(`--colour-error: ${sem.error}`);
|
||||
if (semVars.length) getOrCreateStyle('theme-semantic').textContent = `:root { ${semVars.join('; ')}; }`;
|
||||
}
|
||||
|
||||
if (tc['main-width']) root.style.setProperty('--main-width', tc['main-width']);
|
||||
if (tc['nav-width']) root.style.setProperty('--nav-width', tc['nav-width']);
|
||||
if (tc['line-height']) root.style.setProperty('--line-height-body', String(tc['line-height']));
|
||||
if (tc['font-size']) document.documentElement.style.fontSize = `${tc['font-size'] * 16}px`;
|
||||
}
|
||||
|
||||
function applyConfigTheme() {
|
||||
const root = document.documentElement;
|
||||
['light', 'dark'].forEach(mode => {
|
||||
|
|
@ -1170,35 +1321,49 @@ body {
|
|||
}
|
||||
|
||||
// ─── Fonts ────────────────────────────────────────────────
|
||||
function loadFonts() {
|
||||
const fonts = {};
|
||||
['font-title', 'font-body', 'font-code'].forEach(key => {
|
||||
const val = config[key];
|
||||
if (!val) return;
|
||||
const [name, weight] = val.split(':');
|
||||
fonts[key] = { name: name.trim(), weight: weight ? weight.trim() : '400' };
|
||||
});
|
||||
const googleFonts = [];
|
||||
Object.entries(fonts).forEach(([, { name, weight }]) => {
|
||||
googleFonts.push(`${name.replace(/ /g, '+')}:wght@${weight}`);
|
||||
});
|
||||
function loadFonts(tc) {
|
||||
if (document.querySelector('link[data-mdcms-fonts]')) return;
|
||||
function parseFont(spec) {
|
||||
if (!spec) return null;
|
||||
const parts = spec.split(':');
|
||||
if (parts.length >= 3) return { provider: parts[0].trim(), name: parts.slice(1, -1).join(':').trim(), weight: parts[parts.length - 1].trim() };
|
||||
if (parts.length === 2) return { provider: 'google', name: parts[0].trim(), weight: parts[1].trim() };
|
||||
return { provider: 'google', name: parts[0].trim(), weight: '400' };
|
||||
}
|
||||
|
||||
const src = tc || {};
|
||||
const bodyFont = parseFont(src['font-body'] || config['font-body']);
|
||||
const headingFont = parseFont(src['font-heading'] || src['font-title'] || config['font-title']);
|
||||
const codeFont = parseFont(src['font-code'] || config['font-code']);
|
||||
const allFonts = [bodyFont, headingFont, codeFont].filter(Boolean);
|
||||
|
||||
const bunnyFonts = allFonts.filter(f => f.provider === 'bunny');
|
||||
const googleFonts = allFonts.filter(f => f.provider === 'google');
|
||||
|
||||
if (bunnyFonts.length) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.bunny.net/css?family=${bunnyFonts.map(f => `${f.name.replace(/ /g, '+')}:${f.weight}`).join('&family=')}`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (googleFonts.length) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?${googleFonts.map(f => `family=${f}`).join('&')}&display=swap`;
|
||||
link.href = `https://fonts.googleapis.com/css2?${googleFonts.map(f => `family=${f.name.replace(/ /g, '+')}:wght@${f.weight}`).join('&')}&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (fonts['font-title']) {
|
||||
root.style.setProperty('--font-title', `"${fonts['font-title'].name}", system-ui, sans-serif`);
|
||||
root.style.setProperty('--font-title-weight', fonts['font-title'].weight);
|
||||
if (headingFont) {
|
||||
root.style.setProperty('--font-title', `"${headingFont.name}", system-ui, sans-serif`);
|
||||
root.style.setProperty('--font-title-weight', headingFont.weight);
|
||||
}
|
||||
if (fonts['font-body']) {
|
||||
root.style.setProperty('--font-body', `"${fonts['font-body'].name}", system-ui, sans-serif`);
|
||||
root.style.setProperty('--font-body-weight', fonts['font-body'].weight);
|
||||
if (bodyFont) {
|
||||
root.style.setProperty('--font-body', `"${bodyFont.name}", system-ui, sans-serif`);
|
||||
root.style.setProperty('--font-body-weight', bodyFont.weight);
|
||||
}
|
||||
if (fonts['font-code']) {
|
||||
root.style.setProperty('--font-code', `"${fonts['font-code'].name}", monospace`);
|
||||
if (codeFont) {
|
||||
root.style.setProperty('--font-code', `"${codeFont.name}", monospace`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1239,8 +1404,11 @@ body {
|
|||
} else {
|
||||
codeText = code; codeLang = lang;
|
||||
}
|
||||
if (codeLang === 'mdcms') {
|
||||
const tag = parseMdcmsTag(codeText);
|
||||
// Match both ```mdcms (type in content) and ```mdcms callout-info (type in fence)
|
||||
if (codeLang && (codeLang === 'mdcms' || codeLang.startsWith('mdcms '))) {
|
||||
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
|
||||
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
|
||||
const tag = parseMdcmsTag(fullText);
|
||||
const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"');
|
||||
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
|
||||
}
|
||||
|
|
@ -1342,14 +1510,15 @@ body {
|
|||
return h + ':' + m + suffix;
|
||||
}
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
var parts = dateStr.split('-');
|
||||
function fmtDate(dateStr) {
|
||||
var s = dateStr instanceof Date ? dateStr.toISOString().slice(0, 10) : String(dateStr);
|
||||
var parts = s.split('-');
|
||||
var y = parseInt(parts[0], 10), m = parseInt(parts[1], 10), d = parseInt(parts[2], 10);
|
||||
return applyDatePattern(getDatePattern(), y, m, d);
|
||||
}
|
||||
|
||||
function fmtDatetime(dtStr) {
|
||||
var sp = dtStr.split(' ');
|
||||
function fmtDatetime(dtStr) {
|
||||
var s = dtStr instanceof Date ? dtStr.toISOString().slice(0, 16).replace('T', ' ') : String(dtStr);
|
||||
var sp = s.split(' ');
|
||||
var datePart = sp[0], timePart = sp[1] || '00:00';
|
||||
return fmtDate(datePart) + ' at ' + formatTime(timePart);
|
||||
}
|
||||
|
|
@ -1358,23 +1527,30 @@ body {
|
|||
var lines = text.trim().split('\n');
|
||||
var tagName = lines[0].trim();
|
||||
var options = {};
|
||||
var bodyStart = lines.length;
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
var m = lines[i].match(/^\s*([a-z\-]+)\s*:\s*(.+)$/i);
|
||||
if (m) options[m[1].toLowerCase()] = m[2].trim();
|
||||
var m = lines[i].match(/^\s*([a-z\-]+)\s*:\s*(.*)$/i);
|
||||
if (m) {
|
||||
options[m[1].toLowerCase()] = m[2].trim();
|
||||
} else {
|
||||
bodyStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { tagName: tagName, options: options };
|
||||
var body = lines.slice(bodyStart).join('\n').trim();
|
||||
return { tagName: tagName, options: options, body: body };
|
||||
}
|
||||
|
||||
function parsePostTagName(name) {
|
||||
var m = name.match(
|
||||
/^posts-(date|datetime)-(chronological|reversechronological)(?:-(byyear|byyearmonth|lastyear|lastmonth))?$/
|
||||
/^posts-created-(chronological|reversechronological)(?:-(byyear|byyearmonth|lastyear|lastmonth))?$/
|
||||
);
|
||||
if (!m) return null;
|
||||
return { field: m[1], order: m[2], modifier: m[3] || null };
|
||||
return { order: m[1], modifier: m[2] || null };
|
||||
}
|
||||
|
||||
function getPostEntries(parsed, options) {
|
||||
const { field, order, modifier } = parsed;
|
||||
const { order, modifier } = parsed;
|
||||
|
||||
// Start with posts from search index
|
||||
let posts = (searchIndex || []).filter(function(e) {
|
||||
|
|
@ -1387,11 +1563,7 @@ body {
|
|||
}
|
||||
|
||||
// Field filter
|
||||
if (field === 'datetime') {
|
||||
posts = posts.filter(function(e) { return !!e.datetime; });
|
||||
} else {
|
||||
posts = posts.filter(function(e) { return !!e.date; });
|
||||
}
|
||||
posts = posts.filter(function(e) { return !!e.created; });
|
||||
|
||||
// Time-window filter
|
||||
if (modifier === 'lastyear' || modifier === 'lastmonth') {
|
||||
|
|
@ -1400,15 +1572,13 @@ body {
|
|||
if (modifier === 'lastyear') cutoff.setDate(cutoff.getDate() - 365);
|
||||
else cutoff.setDate(cutoff.getDate() - 30);
|
||||
posts = posts.filter(function(e) {
|
||||
var raw = field === 'datetime' ? e.datetime.replace(' ', 'T') : e.date;
|
||||
return new Date(raw) >= cutoff;
|
||||
return new Date(e.created.replace(' ', 'T')) >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
var sortKey = field === 'datetime' ? 'datetime' : 'date';
|
||||
posts.sort(function(a, b) {
|
||||
var da = a[sortKey] || '', db = b[sortKey] || '';
|
||||
var da = a.created || '', db = b.created || '';
|
||||
return order === 'chronological' ? da.localeCompare(db) : db.localeCompare(da);
|
||||
});
|
||||
|
||||
|
|
@ -1521,7 +1691,6 @@ body {
|
|||
|
||||
var opts = tag.options;
|
||||
var posts = getPostEntries(parsed, opts);
|
||||
var field = parsed.field;
|
||||
var modifier = parsed.modifier;
|
||||
var paginate = opts.paginate || 'no';
|
||||
var limitVal = opts.limit || 'all';
|
||||
|
|
@ -1530,7 +1699,7 @@ body {
|
|||
// Format each entry
|
||||
var formatted = posts.map(function(p) {
|
||||
return {
|
||||
display: field === 'datetime' ? fmtDatetime(p.datetime) : fmtDate(p.date),
|
||||
display: fmtDatetime(p.created),
|
||||
title: p.title,
|
||||
file: p.file
|
||||
};
|
||||
|
|
@ -1563,12 +1732,10 @@ body {
|
|||
|
||||
// Grouped by year (or year+month)
|
||||
function getYear(p) {
|
||||
var d = field === 'datetime' ? p.datetime : p.date;
|
||||
return d ? d.substring(0, 4) : 'Unknown';
|
||||
return p.created ? p.created.substring(0, 4) : 'Unknown';
|
||||
}
|
||||
function getYearMonth(p) {
|
||||
var d = field === 'datetime' ? p.datetime : p.date;
|
||||
return d ? d.substring(0, 7) : 'Unknown';
|
||||
return p.created ? p.created.substring(0, 7) : 'Unknown';
|
||||
}
|
||||
function monthLabel(ym) {
|
||||
var m = parseInt(ym.substring(5, 7), 10);
|
||||
|
|
@ -1649,11 +1816,155 @@ body {
|
|||
renderYear();
|
||||
}
|
||||
|
||||
// Callout type defaults (fallback when theme.yml has no callouts block)
|
||||
const CALLOUT_DEFAULTS = {
|
||||
info: { icon: 'info', colour: '#2563EB' },
|
||||
warning: { icon: 'warning', colour: '#D97706' },
|
||||
success: { icon: 'success', colour: '#16A34A' },
|
||||
error: { icon: 'error', colour: '#DC2626' },
|
||||
};
|
||||
|
||||
function renderCalloutTag(container, tag) {
|
||||
var typeMatch = tag.tagName.match(/^callout-(info|warning|success|error)$/);
|
||||
var calloutType = typeMatch ? typeMatch[1] : 'info';
|
||||
|
||||
var opts = tag.options;
|
||||
var msgKey = opts.message || null;
|
||||
var title = opts.title || null;
|
||||
var iconName = opts.icon || null;
|
||||
var bodyMd = tag.body || '';
|
||||
|
||||
// Resolve message: key — config.yml callouts block
|
||||
if (msgKey) {
|
||||
var msgDefs = config.callouts || {};
|
||||
var msgDef = msgDefs[msgKey];
|
||||
if (msgDef) {
|
||||
// Override callout type from message definition
|
||||
if (msgDef.type) calloutType = msgDef.type;
|
||||
// Language resolution: activeCategory → defaultCategoryCode → first key
|
||||
var lang = activeCategory || defaultCategoryCode;
|
||||
var langEntry = (lang && msgDef[lang]) || msgDef[defaultCategoryCode];
|
||||
if (!langEntry) {
|
||||
var keys = Object.keys(msgDef).filter(function(k) { return k !== 'type'; });
|
||||
langEntry = msgDef[keys[0]];
|
||||
}
|
||||
if (langEntry) {
|
||||
title = langEntry.title || null;
|
||||
bodyMd = langEntry.text || '';
|
||||
}
|
||||
if (opts.title || tag.body) {
|
||||
console.warn('[mdcms] callout: message: key takes precedence; inline title/body ignored.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get callout colours/icon from theme.yml callouts block, then fallback
|
||||
var themeCallouts = (themeConfig.callouts || {})[calloutType] || {};
|
||||
var fallback = CALLOUT_DEFAULTS[calloutType] || CALLOUT_DEFAULTS.info;
|
||||
var primaryColour = themeCallouts['primary-colour'] || fallback.colour;
|
||||
var bgColour = themeCallouts['background-colour'] || fallback.colour;
|
||||
if (!iconName) iconName = themeCallouts.icon || fallback.icon;
|
||||
|
||||
// Build element
|
||||
container.className = 'mdcms-callout mdcms-callout-' + calloutType;
|
||||
container.style.setProperty('--callout-primary', primaryColour);
|
||||
container.style.setProperty('--callout-bg', hexToRgba(bgColour, 0.08));
|
||||
|
||||
if (title) {
|
||||
var titleRow = document.createElement('div');
|
||||
titleRow.className = 'mdcms-callout-title';
|
||||
titleRow.style.color = primaryColour;
|
||||
titleRow.appendChild(iconEl(iconName));
|
||||
var titleText = document.createElement('span');
|
||||
titleText.textContent = title;
|
||||
titleRow.appendChild(titleText);
|
||||
container.appendChild(titleRow);
|
||||
}
|
||||
|
||||
if (bodyMd) {
|
||||
var bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'mdcms-callout-body';
|
||||
bodyEl.innerHTML = marked.parse(bodyMd);
|
||||
container.appendChild(bodyEl);
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
var h = hex.replace('#', '');
|
||||
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
|
||||
var r = parseInt(h.substring(0,2), 16);
|
||||
var g = parseInt(h.substring(2,4), 16);
|
||||
var b = parseInt(h.substring(4,6), 16);
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
||||
}
|
||||
|
||||
function renderTocTag(container) {
|
||||
const byCode = {};
|
||||
navSections.forEach(s => { byCode[s.code] = s; });
|
||||
|
||||
const sortedSections = navSections
|
||||
.filter(s => !isDraftSection(s.code, byCode))
|
||||
.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || (a.code || '').localeCompare(b.code || ''));
|
||||
|
||||
const visiblePages = navData.filter(p => {
|
||||
if (p.file === currentPage) return false;
|
||||
if (!pageShouldDisplay(p)) return false;
|
||||
const sid = p['section-id'];
|
||||
if (sid && isDraftSection(sid, byCode)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const bySection = {};
|
||||
const unsectioned = [];
|
||||
visiblePages.forEach(p => {
|
||||
const sid = p['section-id'] || null;
|
||||
if (sid) { (bySection[sid] = bySection[sid] || []).push(p); }
|
||||
else unsectioned.push(p);
|
||||
});
|
||||
|
||||
function sortPages(pages) {
|
||||
return [...pages].sort((a, b) =>
|
||||
((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
function makeList(pages) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'mdcms-toc-list';
|
||||
pages.forEach(p => {
|
||||
const a = el('a', { href: '#' + p.file, textContent: pageDisplayTitle(p) });
|
||||
a.addEventListener('click', e => { e.preventDefault(); navigateTo(p.file); });
|
||||
ul.appendChild(el('li', {}, a));
|
||||
});
|
||||
return ul;
|
||||
}
|
||||
|
||||
const div = el('div', { className: 'mdcms-toc' });
|
||||
|
||||
if (unsectioned.length) div.appendChild(makeList(sortPages(unsectioned)));
|
||||
|
||||
sortedSections.forEach(section => {
|
||||
const pages = bySection[section.code];
|
||||
if (!pages || !pages.length) return;
|
||||
div.appendChild(el('h3', { className: 'mdcms-toc-section', textContent: sectionDisplayName(section) }));
|
||||
div.appendChild(makeList(sortPages(pages)));
|
||||
});
|
||||
|
||||
if (!div.children.length) div.textContent = 'No pages found.';
|
||||
|
||||
container.replaceWith(div);
|
||||
}
|
||||
|
||||
function hydrateMdcmsTags() {
|
||||
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
|
||||
try {
|
||||
var cfg = JSON.parse(tagEl.getAttribute('data-config'));
|
||||
renderPostTag(tagEl, cfg);
|
||||
if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
|
||||
renderCalloutTag(tagEl, cfg);
|
||||
} else if (cfg.tagName === 'toc') {
|
||||
renderTocTag(tagEl);
|
||||
} else {
|
||||
renderPostTag(tagEl, cfg);
|
||||
}
|
||||
} catch (e) {
|
||||
tagEl.textContent = 'Error rendering tag.';
|
||||
}
|
||||
|
|
@ -1668,7 +1979,8 @@ body {
|
|||
|
||||
const mobileHeader = el('div', { className: 'mobile-header' });
|
||||
mobileHeader.style.display = 'none';
|
||||
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu', innerHTML: ICONS.menu });
|
||||
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu' });
|
||||
hamburger.appendChild(iconEl('menu'));
|
||||
const mobileName = el('span', { className: 'sidebar-sitename', textContent: config.sitename || 'MD-CMS' });
|
||||
mobileHeader.appendChild(hamburger);
|
||||
if (config.logo) {
|
||||
|
|
@ -1733,12 +2045,12 @@ body {
|
|||
hamburger.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('active');
|
||||
hamburger.innerHTML = sidebar.classList.contains('open') ? ICONS.close : ICONS.menu;
|
||||
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
|
||||
});
|
||||
function closeMobileMenu() {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('active');
|
||||
hamburger.innerHTML = ICONS.menu;
|
||||
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
|
||||
}
|
||||
window._closeMobileMenu = closeMobileMenu;
|
||||
}
|
||||
|
|
@ -1759,7 +2071,8 @@ body {
|
|||
brand.appendChild(el('span', { className: 'topbar-sitename', textContent: config.sitename || 'MD-CMS' }));
|
||||
topbar.appendChild(brand);
|
||||
|
||||
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu', innerHTML: ICONS.menu });
|
||||
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu' });
|
||||
hamburger.appendChild(iconEl('menu'));
|
||||
const navLinksEl = el('div', { className: 'topbar-nav', id: 'navLinks' });
|
||||
topbar.appendChild(navLinksEl);
|
||||
|
||||
|
|
@ -1800,19 +2113,23 @@ body {
|
|||
hamburger.addEventListener('click', () => {
|
||||
const panel = document.getElementById('mobileNavPanel');
|
||||
panel.classList.toggle('open');
|
||||
hamburger.innerHTML = panel.classList.contains('open') ? ICONS.close : ICONS.menu;
|
||||
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
|
||||
});
|
||||
window._closeMobileMenu = function() {
|
||||
const panel = document.getElementById('mobileNavPanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
hamburger.innerHTML = ICONS.menu;
|
||||
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
|
||||
};
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.topbar-nav .nav-group.open').forEach(g => g.classList.remove('open'));
|
||||
});
|
||||
}
|
||||
|
||||
function buildSearchWidget() {
|
||||
const container = el('div', { className: 'search-container' });
|
||||
const wrapper = el('div', { className: 'search-wrapper' });
|
||||
const icon = el('span', { className: 'search-icon', innerHTML: ICONS.search });
|
||||
const icon = iconEl('search', 'search-icon');
|
||||
const input = el('input', { className: 'search-box', type: 'text', placeholder: 'Search...' });
|
||||
const results = el('div', { className: 'search-results' });
|
||||
wrapper.appendChild(icon);
|
||||
|
|
@ -1843,7 +2160,7 @@ body {
|
|||
const bar = el('div', { className: 'category-bar' });
|
||||
|
||||
if (config['categories-selecticon']) {
|
||||
bar.appendChild(el('span', { className: 'category-icon material-icons', textContent: config['categories-selecticon'] }));
|
||||
bar.appendChild(iconEl(config['categories-selecticon'], 'category-icon'));
|
||||
}
|
||||
if (config['categories-selecttext']) {
|
||||
bar.appendChild(el('span', { className: 'category-label', textContent: config['categories-selecttext'] }));
|
||||
|
|
@ -1853,7 +2170,7 @@ body {
|
|||
const trigger = el('button', { className: 'category-trigger', type: 'button' });
|
||||
const triggerLabel = el('span', { id: 'categoryTriggerLabel' });
|
||||
trigger.appendChild(triggerLabel);
|
||||
trigger.appendChild(el('span', { className: 'caret', textContent: '▾' }));
|
||||
trigger.appendChild(iconEl('arrow_drop_down', 'caret'));
|
||||
dropdown.appendChild(trigger);
|
||||
|
||||
const panel = el('div', { className: 'category-panel' });
|
||||
|
|
@ -2070,8 +2387,9 @@ body {
|
|||
|
||||
if (isHidden) {
|
||||
const expanded = sectionExpanded(section.code);
|
||||
heading.innerHTML = `<span class="toggle-icon">${expanded ? '−' : '+'}</span><span></span>`;
|
||||
heading.querySelector('span:last-child').textContent = name;
|
||||
heading.innerHTML = '';
|
||||
heading.appendChild(iconEl(expanded ? 'arrow_drop_down' : 'arrow_right', 'toggle-icon'));
|
||||
heading.appendChild(el('span', { textContent: name }));
|
||||
heading.addEventListener('click', () => {
|
||||
toggleSection(section.code);
|
||||
renderNav();
|
||||
|
|
@ -2107,18 +2425,141 @@ body {
|
|||
tree.forEach(root => renderTreeSection(container, root, 0, groups));
|
||||
}
|
||||
|
||||
function renderFlat(container) {
|
||||
// Topbar inline: pages only, sorted by global sort, draft-section pages excluded.
|
||||
function buildTopbarNavItems() {
|
||||
const byCode = {};
|
||||
navSections.forEach(s => { if (s.code) byCode[s.code] = s; });
|
||||
const visible = navData.filter(p => {
|
||||
const sid = p['section-id'];
|
||||
return !sid || !isDraftSection(sid, byCode);
|
||||
const items = [];
|
||||
|
||||
// Sections (non-draft), each becomes a dropdown trigger
|
||||
navSections.forEach(s => {
|
||||
if (!s.code || isDraftSection(s.code, byCode)) return;
|
||||
const pages = navData.filter(p => p['section-id'] === s.code && pageShouldDisplay(p));
|
||||
pages.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
|
||||
if (!pages.length) return;
|
||||
items.push({ type: 'section', sort: s.sort ?? 999, section: s, pages });
|
||||
});
|
||||
visible.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
|
||||
visible.forEach(p => {
|
||||
const item = makeNavItem(p, 0);
|
||||
if (item) container.appendChild(item);
|
||||
|
||||
// Unsectioned pages (or pages whose section isn't in nav), grouped by sort century
|
||||
const unsectioned = navData.filter(p => {
|
||||
if (!pageShouldDisplay(p)) return false;
|
||||
const sid = p['section-id'];
|
||||
return !sid || !byCode[sid];
|
||||
});
|
||||
unsectioned.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
|
||||
const centuryMap = new Map();
|
||||
unsectioned.forEach(p => {
|
||||
const c = Math.floor((p.sort ?? 999) / 100);
|
||||
if (!centuryMap.has(c)) centuryMap.set(c, []);
|
||||
centuryMap.get(c).push(p);
|
||||
});
|
||||
for (const [, pgs] of centuryMap) {
|
||||
items.push({ type: 'group', sort: pgs[0].sort ?? 999, primary: pgs[0], children: pgs.slice(1) });
|
||||
}
|
||||
|
||||
items.sort((a, b) => a.sort - b.sort);
|
||||
return items;
|
||||
}
|
||||
|
||||
function makeTopbarPageGroup({ primary, children }, isMobile) {
|
||||
const group = el('div', { className: 'nav-group' });
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
if (isMobile) {
|
||||
const row = el('div', { className: 'nav-group-row' });
|
||||
const link = makeNavItem(primary, 0);
|
||||
if (link) row.appendChild(link);
|
||||
if (hasChildren) {
|
||||
const childrenEl = el('div', { className: 'nav-group-children' });
|
||||
children.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); });
|
||||
const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: '+' });
|
||||
btn.addEventListener('click', () => {
|
||||
const open = childrenEl.classList.toggle('open');
|
||||
btn.textContent = open ? '−' : '+';
|
||||
});
|
||||
row.appendChild(btn);
|
||||
group.appendChild(row);
|
||||
group.appendChild(childrenEl);
|
||||
} else {
|
||||
group.appendChild(row);
|
||||
}
|
||||
} else {
|
||||
const title = pageDisplayTitle(primary);
|
||||
const trigger = el('a', { className: 'nav-trigger', href: '#' + primary.file, 'data-file': primary.file });
|
||||
trigger.appendChild(el('span', { textContent: title }));
|
||||
if (hasChildren) trigger.appendChild(iconEl('arrow_drop_down', 'nav-caret'));
|
||||
group.appendChild(trigger);
|
||||
|
||||
if (hasChildren) {
|
||||
const dropdown = el('div', { className: 'nav-dropdown' });
|
||||
children.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); });
|
||||
group.appendChild(dropdown);
|
||||
group.addEventListener('mouseenter', () => group.classList.add('open'));
|
||||
group.addEventListener('mouseleave', () => group.classList.remove('open'));
|
||||
trigger.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
group.classList.toggle('open');
|
||||
navigateTo(primary.file);
|
||||
if (window._closeMobileMenu) window._closeMobileMenu();
|
||||
});
|
||||
} else {
|
||||
trigger.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
navigateTo(primary.file);
|
||||
if (window._closeMobileMenu) window._closeMobileMenu();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function makeTopbarSection({ section, pages }, isMobile) {
|
||||
const group = el('div', { className: 'nav-group' });
|
||||
const isHidden = section.pagesvisibility === 'hidden';
|
||||
const name = sectionDisplayName(section);
|
||||
|
||||
if (isMobile) {
|
||||
const row = el('div', { className: 'nav-group-row' });
|
||||
row.appendChild(el('span', { className: 'nav-section-label', textContent: name }));
|
||||
const childrenEl = el('div', { className: 'nav-group-children' + (isHidden ? '' : ' open') });
|
||||
pages.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); });
|
||||
const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: isHidden ? '+' : '−' });
|
||||
btn.addEventListener('click', () => {
|
||||
const open = childrenEl.classList.toggle('open');
|
||||
btn.textContent = open ? '−' : '+';
|
||||
});
|
||||
row.appendChild(btn);
|
||||
group.appendChild(row);
|
||||
group.appendChild(childrenEl);
|
||||
} else {
|
||||
const trigger = el('button', { className: 'nav-trigger', type: 'button' });
|
||||
trigger.appendChild(el('span', { textContent: name }));
|
||||
trigger.appendChild(iconEl('arrow_drop_down', 'nav-caret'));
|
||||
trigger.addEventListener('click', e => { e.stopPropagation(); group.classList.toggle('open'); });
|
||||
group.appendChild(trigger);
|
||||
|
||||
const dropdown = el('div', { className: 'nav-dropdown' });
|
||||
pages.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); });
|
||||
group.appendChild(dropdown);
|
||||
|
||||
if (!isHidden) {
|
||||
group.addEventListener('mouseenter', () => group.classList.add('open'));
|
||||
group.addEventListener('mouseleave', () => group.classList.remove('open'));
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderTopbarGrouped(container, isMobile) {
|
||||
const items = buildTopbarNavItems();
|
||||
items.forEach(item => {
|
||||
container.appendChild(
|
||||
item.type === 'group'
|
||||
? makeTopbarPageGroup(item, isMobile)
|
||||
: makeTopbarSection(item, isMobile)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2128,19 +2569,23 @@ body {
|
|||
const mobile = document.getElementById('mobileNavLinks');
|
||||
if (main) {
|
||||
main.innerHTML = '';
|
||||
if (topbar) renderFlat(main);
|
||||
if (topbar) renderTopbarGrouped(main, false);
|
||||
else renderTree(main);
|
||||
}
|
||||
if (mobile) {
|
||||
mobile.innerHTML = '';
|
||||
renderTree(mobile);
|
||||
if (topbar) renderTopbarGrouped(mobile, true);
|
||||
else renderTree(mobile);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightNav(file) {
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
document.querySelectorAll('.nav-item, .nav-trigger[data-file]').forEach(item => {
|
||||
item.classList.toggle('active', item.getAttribute('data-file') === file);
|
||||
});
|
||||
document.querySelectorAll('.topbar-nav .nav-group').forEach(group => {
|
||||
group.classList.toggle('has-active', !!group.querySelector('[data-file].active'));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Page loading ─────────────────────────────────────────
|
||||
|
|
@ -2187,12 +2632,12 @@ body {
|
|||
hydrateMdcmsTags();
|
||||
|
||||
const firstH = contentEl.querySelector('.md-content h1, .md-content h2');
|
||||
if (firstH && (meta.author || meta.created || meta.date || meta.datetime)) {
|
||||
if (firstH && (meta.author || meta.created)) {
|
||||
const metaEl = document.createElement('div');
|
||||
metaEl.className = 'page-meta';
|
||||
let metaText = '';
|
||||
if (meta.author) metaText += meta.author;
|
||||
const displayDate = meta.datetime || meta.date || meta.created;
|
||||
const displayDate = meta.created;
|
||||
if (displayDate) {
|
||||
if (metaText) metaText += ' | ';
|
||||
metaText += 'Published ' + formatDate(displayDate);
|
||||
|
|
@ -2254,14 +2699,26 @@ body {
|
|||
if (link) link.href = `assets/images/${config.logo}`;
|
||||
}
|
||||
|
||||
loadFonts();
|
||||
if (config.theme) {
|
||||
try {
|
||||
const themeResp = await fetch(config.theme);
|
||||
if (themeResp.ok) themeConfig = jsyaml.load(await themeResp.text()) || {};
|
||||
} catch (e) { /* fall back to hardcoded CSS defaults */ }
|
||||
}
|
||||
|
||||
loadFonts(themeConfig);
|
||||
initCategories();
|
||||
|
||||
const iconsToPreload = [...STANDARD_ICONS];
|
||||
if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']);
|
||||
await Promise.all(iconsToPreload.map(name => loadIcon(name)));
|
||||
|
||||
const navMode = config.navigation || 'sidebar';
|
||||
if (navMode === 'topbar') buildTopbar();
|
||||
else buildSidebar();
|
||||
|
||||
applyConfigTheme();
|
||||
if (config.theme) applyThemeYml(themeConfig);
|
||||
else applyConfigTheme();
|
||||
applyTheme(getInitialTheme());
|
||||
|
||||
// nav.yml — phase 2 expects `sections:` + `pages:` blocks; phase 1 flat
|
||||
50
sample-sites/kitchen-table/nav.yml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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
|
||||
56
sample-sites/kitchen-table/pages/about.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
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.
|
||||
44
sample-sites/kitchen-table/pages/home.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
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
|
||||
83
sample-sites/kitchen-table/pages/kitchen-notes.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
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.
|
||||
102
sample-sites/kitchen-table/pages/pantry.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
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.
|
||||
58
sample-sites/kitchen-table/pages/recipe-index.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
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
|
||||
```
|
||||
82
sample-sites/kitchen-table/pages/techniques.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
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.*
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
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.
|
||||
89
sample-sites/kitchen-table/posts/2024-03-22-bread-starter.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
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.
|
||||
61
sample-sites/kitchen-table/posts/2024-05-05-roast-chicken.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
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.
|
||||
71
sample-sites/kitchen-table/posts/2024-09-12-braised-lamb.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
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.
|
||||
63
sample-sites/kitchen-table/posts/2025-01-15-knife-skills.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
title: "Knife Skills: The One Investment Worth Making in Your Cooking"
|
||||
created: 2025-01-15 10:00
|
||||
author: Amelia Fontaine
|
||||
keywords: knife skills, chopping, chef's knife, sharpening, technique
|
||||
description: Which knife to buy, how to sharpen it, and five essential cuts explained with technique — including why a sharp knife is not just convenient but fundamental.
|
||||
---
|
||||
|
||||
# Knife Skills: The One Investment Worth Making in Your Cooking
|
||||
|
||||
If someone asked me where to spend their first serious kitchen investment, I would not say a stand mixer, a cast-iron pan, or a sous vide machine. I would say: one good knife and the time to learn to use it.
|
||||
|
||||
A skilled cook with a sharp knife and a wooden board can do almost anything. The average home cook with a drawer full of mediocre, dull knives is constantly fighting their ingredients, which is why cooking sometimes feels exhausting.
|
||||
|
||||
## Which Knife
|
||||
|
||||
You need one knife primarily: an 8-inch (20cm) chef's knife. Everything else is supplementary. A paring knife for small work, a serrated bread knife for bread — but the chef's knife is the tool you will use for 90% of kitchen tasks.
|
||||
|
||||
**What to look for:**
|
||||
- **Full tang**: The metal should extend all the way through the handle. Partial-tang knives are less balanced and more likely to fail.
|
||||
- **Weight**: A matter of preference. Heavier knives (German-style, like Wüsthof or Henckels) power through root vegetables. Lighter knives (Japanese-style, like Global or MAC) are more agile for precise work. Try them in your hand before buying if possible.
|
||||
- **Steel hardness**: Measured in Rockwell hardness (HRC). Japanese knives are typically HRC 60–65 (harder, holds an edge longer, more brittle). German knives are typically HRC 56–58 (softer, easier to resharpen, less likely to chip). Both work excellently.
|
||||
|
||||
You do not need to spend a fortune. A reliable £60–80 chef's knife from Victorinox or similar will outperform a poorly maintained £300 knife. What matters more than the knife is the sharpening.
|
||||
|
||||
## Sharpening: The Non-Negotiable
|
||||
|
||||
A dull knife is dangerous. It requires force, which causes slipping and loss of control. A sharp knife does the work; your job is merely to guide it.
|
||||
|
||||
**Whetstone** (recommended): The best method. Use a double-sided stone, 1000 grit (medium) on one side for regular maintenance, 3000–6000 grit (fine) for polishing. Hold the blade at 15–20 degrees to the stone (German knives: 20 degrees; Japanese: 15 degrees). Move the blade across the stone as if slicing a thin layer off the top, heel to tip. Ten strokes per side, then switch. Finish on the fine grit.
|
||||
|
||||
This takes practice. The first few times are imperfect. Within a month of weekly sessions, you will feel the difference.
|
||||
|
||||
**Honing steel**: Not sharpening — honing realigns the edge between sharpenings. Use before every significant cooking session. The edge of a knife bends microscopically with use; honing straightens it.
|
||||
|
||||
**Electric sharpener**: Convenient but aggressive. Removes more metal than a whetstone. Use occasionally as a backup, not as a primary method.
|
||||
|
||||
Test sharpness: a sharp knife should glide through a sheet of paper cleanly, or shave arm hair without dragging.
|
||||
|
||||
## The Five Essential Cuts
|
||||
|
||||
**1. The Chop (rough cut)**
|
||||
For onions, root vegetables, and anything that doesn't require precision. Curl your fingers so the knuckles act as a guide for the flat of the blade. The knife never leaves contact with the board — you rock from the tip forward. This technique protects your fingertips.
|
||||
|
||||
**2. The Slice**
|
||||
For meat, fish, bread, soft vegetables. A single, fluid motion from heel to tip. Never press or saw; draw the knife through the material. Pressure causes crushing; motion causes cutting.
|
||||
|
||||
**3. The Julienne**
|
||||
Cut the vegetable into planks, then stack and cut into matchsticks. Requires a very sharp knife to avoid crushing soft vegetables. Essential for stir-fries and salads.
|
||||
|
||||
**4. The Brunoise (fine dice)**
|
||||
Julienne first, then cut across the matchsticks to produce tiny cubes (2–3mm). The gold standard for mirepoix, garnishes, and anything where you want the vegetable to disappear into a sauce.
|
||||
|
||||
**5. The Chiffonade**
|
||||
Stack leafy herbs or greens, roll tightly, and slice crosswise into thin ribbons. Minimises bruising compared to chopping.
|
||||
|
||||
## The Onion, Properly
|
||||
|
||||
The onion is the test of knife skill. Make two horizontal cuts parallel to the board, stopping before the root end. Make multiple vertical cuts from top to root end, again stopping before the root. Then slice crosswise to produce a fine dice that holds together until the last cut because the root end acts as the anchor. The whole thing should take 20 seconds with practice.
|
||||
|
||||
Achieving this requires a sharp knife and the technique above. With a dull knife, onions do not cut — they crush, releasing their sulphurous compounds and causing significantly more eye irritation. Another reason sharpening is not optional.
|
||||
|
||||
The investment in knife skill repays itself immediately and permanently. It is the one thing in cooking that, once learned, never stops saving you time.
|
||||
63
sample-sites/kitchen-table/posts/2025-02-08-slow-food.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
title: "On Slow Food: Why I Stopped Making Quick Dinners"
|
||||
created: 2025-02-08 11:00
|
||||
author: Amelia Fontaine
|
||||
keywords: ribollita, Tuscan soup, slow cooking, beans, bread soup, philosophy
|
||||
description: A personal essay on unhurried cooking, what it teaches, and a recipe for ribollita — the classic Tuscan bean and bread soup that rewards patience.
|
||||
---
|
||||
|
||||
# On Slow Food: Why I Stopped Making Quick Dinners
|
||||
|
||||
There is a cooking genre that has dominated food media for years: the thirty-minute meal. I understand its appeal. Most evenings, after work, a long recipe feels like a burden rather than a pleasure. And yet I have become increasingly resistant to optimising everything in my kitchen for speed, because the things I cook quickly are consistently the things I care about least.
|
||||
|
||||
This is not an argument against efficiency. It is an argument for noticing what the efficient mode costs you.
|
||||
|
||||
When I make ribollita — the Tuscan bean and bread soup that requires at minimum a day of preparation and ideally two — I am in contact with something different from when I assemble a weeknight pasta. The process demands attention at intervals: checking the beans as they soak, tasting the soup as it reduces, deciding whether it needs more kale, more bread, more time. The cooking is a form of thinking, and the thinking changes how I relate to what I'm eating.
|
||||
|
||||
Carlo Petrini, who founded the Slow Food movement in 1989, was arguing partly about sourcing — buying from small producers, preserving food cultures — but the underlying idea is also about tempo: that the pace at which we engage with food shapes what the food means to us.
|
||||
|
||||
I am not evangelical about this. Quick meals have their place. But I notice that the meals I remember, the ones that feel genuinely nourishing rather than merely functional, are almost always the ones that took time.
|
||||
|
||||
## Ribollita
|
||||
|
||||
The name means "reboiled." This is a soup that is made one day and eaten the next, when the bread has fully absorbed the broth and the whole pot needs to be reheated — reboiled — before serving. It is cheap, warming, and in its complexity of flavour, as satisfying as any elaborate preparation.
|
||||
|
||||
**Ingredients (serves 6):**
|
||||
- 400g dried cannellini beans, soaked overnight
|
||||
- 1 head cavolo nero (black kale), stalks removed, roughly chopped
|
||||
- ½ savoy cabbage, roughly shredded
|
||||
- 1 large onion, roughly chopped
|
||||
- 3 stalks celery, chopped
|
||||
- 3 carrots, chopped
|
||||
- 4 cloves garlic, sliced
|
||||
- 400g tin whole plum tomatoes
|
||||
- 4 tbsp olive oil, plus more for serving
|
||||
- 1 sprig rosemary
|
||||
- 2 bay leaves
|
||||
- 200g day-old Tuscan or sourdough bread, roughly torn
|
||||
- Salt and black pepper
|
||||
- Parmigiano Reggiano rind (if you have it — adds considerable depth)
|
||||
|
||||
**Day One:**
|
||||
|
||||
Drain the soaked beans and cover with fresh cold water in a large pot. Add the Parmigiano rind if using. Bring to a simmer and cook for 1–1.5 hours until completely tender. Do not add salt until the last 10 minutes — it toughens the skins. Drain, reserving the cooking liquid. Mash or blend about a third of the beans until smooth; leave the rest whole.
|
||||
|
||||
In the same pot, warm the olive oil over medium heat. Cook the onion, celery, and carrot for 12 minutes until soft. Add the garlic, rosemary, and bay. Cook for 2 minutes. Add the tomatoes, crushing them with your hand as you add them. Cook for 10 minutes.
|
||||
|
||||
Add the whole and puréed beans, the cabbage, and the cavolo nero. Pour in the reserved bean cooking liquid and enough water to make a thick, substantial soup. Bring to a simmer and cook for 30 minutes.
|
||||
|
||||
Add the torn bread and stir it in. The bread will absorb the liquid and disintegrate partially, thickening the soup into something between a soup and a stew. Season generously. Remove the rosemary sprig and bay leaves.
|
||||
|
||||
Cool, cover, and refrigerate overnight.
|
||||
|
||||
**Day Two:**
|
||||
|
||||
Reheat gently, adding a little water if needed — it will have thickened further. Bring back to a simmer and cook for 15–20 minutes. Taste and adjust seasoning.
|
||||
|
||||
Serve in deep bowls with a generous pour of your best olive oil over the top, a grinding of black pepper, and Parmigiano if you like. In Florence they sometimes add a drizzle of new-season olive oil and nothing else.
|
||||
|
||||
## What Slow Cooking Teaches
|
||||
|
||||
It teaches you that the most important ingredient in cooking is often time, which money cannot substitute for. It teaches patience, because you cannot make the beans cook faster without a pressure cooker, and even then the texture changes in ways that are less interesting. It teaches attention, because slow-cooked food needs checking and tasting as it develops.
|
||||
|
||||
And it teaches proportion — that a Sunday afternoon in the kitchen is not time lost but time spent. The ribollita that arrives on Monday evening required no effort that day at all. It is simply there, waiting, improved by its rest, ready to give you something back.
|
||||
66
sample-sites/kitchen-table/posts/2025-03-28-eggs-benedict.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
title: "Eggs Benedict and the Science of Hollandaise"
|
||||
created: 2025-03-28 09:30
|
||||
author: Amelia Fontaine
|
||||
keywords: eggs benedict, hollandaise, poaching eggs, brunch, sauce
|
||||
description: The emulsion science behind hollandaise, why it breaks and how to fix it, perfect poached eggs, and classic eggs Benedict assembly.
|
||||
---
|
||||
|
||||
# Eggs Benedict and the Science of Hollandaise
|
||||
|
||||
Hollandaise has a fearsome reputation, and the fear is understandable: it is an emulsified butter sauce that breaks easily, cannot be made far in advance, and requires you to pay attention during service — the moment when you least want another technical demand. The reputation is somewhat deserved.
|
||||
|
||||
But understanding *why* hollandaise behaves as it does makes it dramatically more manageable. It is, at its core, chemistry. The chemistry is not complicated once you know it.
|
||||
|
||||
## The Emulsion
|
||||
|
||||
An emulsion is a stable mixture of two liquids that would normally separate: in hollandaise, fat (butter) and water (lemon juice, the water in egg yolks). These two phases resist mixing because fat molecules are nonpolar and water molecules are polar. Left alone, they separate.
|
||||
|
||||
The stabiliser is lecithin, found in egg yolks at high concentrations. Lecithin molecules have a nonpolar end (attracted to fat) and a polar end (attracted to water). They position themselves at the boundary between fat and water droplets, surrounding the fat droplets and preventing them from coalescing.
|
||||
|
||||
This works only within a temperature range: warm enough to keep the butter fluid and to partially cook the egg proteins (which helps stabilise the emulsion), but not so hot that the proteins cook fully and coagulate, which causes the sauce to "break" — separate into greasy curds.
|
||||
|
||||
The ideal temperature for hollandaise is 60–70°C. Below this range it is too thin; above it breaks.
|
||||
|
||||
## The Method
|
||||
|
||||
**Ingredients (serves 4):**
|
||||
- 4 egg yolks
|
||||
- 250g unsalted butter, clarified (or just very good quality, melted and warm)
|
||||
- 2 tbsp water
|
||||
- 1 tbsp white wine vinegar
|
||||
- Juice of half a lemon
|
||||
- Salt and white pepper
|
||||
|
||||
**Clarifying butter** removes the milk solids and water, leaving pure butterfat. This gives a more stable emulsion and a cleaner flavour, though whole butter (used at room temperature) also works and is easier.
|
||||
|
||||
**Method:**
|
||||
1. In a small saucepan, reduce the white wine vinegar with the water by half. Set aside to cool slightly.
|
||||
2. In a heatproof bowl, whisk the egg yolks with the reduced liquid until pale and thickened — they should leave a trail (a "ribbon") when the whisk is lifted.
|
||||
3. Set the bowl over a pan of barely simmering water (the bowl should not touch the water). Continue whisking while the mixture warms, 3–4 minutes, until it thickens further and holds a ribbon. This is the *sabayon* — the base.
|
||||
4. Remove from the heat. Begin adding the warm clarified butter in a very thin stream while whisking constantly. The first few tablespoons are critical — add them very slowly, building the emulsion. Once the emulsion is established, you can add the butter more quickly.
|
||||
5. When all the butter is incorporated, adjust with lemon juice, salt, and white pepper. The sauce should coat the back of a spoon heavily.
|
||||
|
||||
**If it breaks:** If you see greasy, curdled separation, it is usually because the temperature was too high or the butter was added too quickly. To rescue: start with a clean bowl and a fresh egg yolk whisked with a tablespoon of warm water. Very slowly whisk the broken sauce into this new base, treating it as the butter in the original recipe.
|
||||
|
||||
Keep hollandaise warm by setting the bowl over warm (not hot) water, whisking occasionally. Use within 30–45 minutes.
|
||||
|
||||
## Poaching Eggs
|
||||
|
||||
Use the freshest eggs possible — older eggs spread more because the white becomes more liquid. Room temperature is preferable to cold from the fridge.
|
||||
|
||||
Bring a wide pan of water to a bare simmer. Add a splash of white wine vinegar (it helps the white cohere, though this is debated). Create a gentle swirl in the water with a spoon. Crack the egg into a small cup, lower the cup to the water surface, and slide the egg in gently. The swirl wraps the white around the yolk. Poach for 3 minutes for a runny yolk, 4 minutes for a more set result.
|
||||
|
||||
Lift with a slotted spoon, drain on kitchen paper. Trim any ragged white edges with scissors for a clean presentation.
|
||||
|
||||
## Assembly
|
||||
|
||||
**Eggs Benedict:** Split, toast, and butter English muffins. Top each half with a slice of back bacon (or Canadian bacon) that has been briefly warmed. Set a poached egg on top. Pour hollandaise generously over. Finish with a small amount of cayenne or smoked paprika.
|
||||
|
||||
**Eggs Royale:** As above but with smoked salmon instead of bacon.
|
||||
|
||||
**Eggs Florentine:** As above but with wilted spinach, squeezed very dry, instead of bacon.
|
||||
|
||||
The whole assembly takes about 15 minutes once you have the hollandaise made. The eggs can be poached ahead of time and kept in cold water, then reheated by placing in warm water for 60 seconds.
|
||||
|
||||
Eggs Benedict is weekend cooking at its best: technically interesting, visually impressive, and deeply satisfying to eat.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
title: "A Year of Eating Seasonally: What I Learned"
|
||||
created: 2025-05-10 10:00
|
||||
author: Amelia Fontaine
|
||||
keywords: seasonal eating, produce, UK seasons, local food, vegetables
|
||||
description: A month-by-month guide to seasonal produce in Northern Europe, what eating with the seasons changed about cooking, and the real cost comparison.
|
||||
---
|
||||
|
||||
# A Year of Eating Seasonally: What I Learned
|
||||
|
||||
Three years ago I made an experiment: for one year, I would cook primarily with whatever was in season in the UK, buying from farmers' markets when possible and from supermarkets when necessary but choosing seasonal produce. No tomatoes in January, no asparagus in October. I kept a food diary.
|
||||
|
||||
What I expected: virtuous inconvenience, occasional genuine pleasure, and a sense of moral superiority.
|
||||
|
||||
What I found: far better food than I had been eating, a dramatically changed relationship with the kitchen calendar, and — this surprised me most — lower grocery bills.
|
||||
|
||||
## Month-by-Month: Northern European Seasonal Guide
|
||||
|
||||
**January / February**
|
||||
Root vegetables at their peak after frost (parsnips, celeriac, beetroot, swede). Leeks, Brussels sprouts, kale, Savoy cabbage. Forced rhubarb arrives in late January — pink and tender. Blood oranges from Sicily and Spain. Bergamot lemons briefly. Game birds if you eat them.
|
||||
|
||||
**March / April**
|
||||
The hungry gap — the hardest time. Purple sprouting broccoli bridges it magnificently. Spring greens, wild garlic (from hedgerows and woods), radishes, early spinach. Jersey Royal new potatoes appear in late April — small, earthy, best boiled and eaten with good butter. Nothing else required.
|
||||
|
||||
**May / June**
|
||||
The garden wakes up. Asparagus season (May–June, approximately six weeks — eat it every day). Peas and broad beans, best eaten young and raw or barely cooked. Strawberries from mid-June. Early courgettes and their flowers. Elderflower for cordial and fritters. Wet garlic — young, soft-skinned, sweet, milder than cured.
|
||||
|
||||
**July / August**
|
||||
The abundance. Tomatoes, courgettes, cucumbers, French beans. Sweetcorn. Raspberries, blueberries, gooseberries. Plums and early apples. Fennel. Aubergines in a good summer.
|
||||
|
||||
**September / October**
|
||||
The transition into autumn is the most dramatic flavour shift of the year. Wild mushrooms. Autumn raspberries continue. Quince — underused and extraordinary in paste and as an accompaniment to cheese. Cobnuts. Main crop apples and pears at their best. Butternut squash and pumpkins. Jerusalem artichokes begin.
|
||||
|
||||
**November / December**
|
||||
Roots again, and brassicas at their best after frost (a frost improves both Brussels sprouts and parsnips by converting starch to sugar). Chestnuts. Seville oranges arrive in December for marmalade. Celery.
|
||||
|
||||
## What Changed
|
||||
|
||||
**Cooking became easier.** When you stop trying to make out-of-season ingredients taste like themselves, you stop fighting your food. A parsnip in January is magnificent. A parsnip in July is pointless.
|
||||
|
||||
**The same vegetables, prepared differently, stopped boring me.** By February I had been eating celeriac for three months and had remoulade, dauphinoise, roasted, puréed, raw, in soup, and with preserved lemon. The constraint produced creativity I would not have found otherwise.
|
||||
|
||||
**I discovered vegetables I had ignored.** Swede. Salsify. Jerusalem artichokes (marvellous despite their intestinal reputation, which is somewhat exaggerated). Purple sprouting broccoli, which I now grow in my own small garden because the window of freshness before it reaches shops is part of its quality.
|
||||
|
||||
## The Cost Comparison
|
||||
|
||||
Seasonal, locally produced vegetables at farmers' markets are sometimes more expensive per unit than supermarket imports. But the yield is different. A genuinely ripe July tomato is twice the tomato of a January hothouse import — you use fewer, you eat more slowly, it satisfies better.
|
||||
|
||||
Across the year, my food costs were slightly lower, primarily because I stopped throwing away vegetables that had been disappointing enough to not eat.
|
||||
|
||||
## The One Compromise
|
||||
|
||||
I kept buying citrus fruit year-round. The lemons that are fundamental to most of my cooking have no British equivalent, and I was not prepared to become a purist at the expense of most of my sauces. I also kept tinned tomatoes for winter — at their best they are superior to fresh winter tomatoes and I feel no guilt about this at all.
|
||||
|
||||
Within those limits, the experiment became permanent. I cook this way now not because I decided to continue the experiment but because it simply became how I cook.
|
||||
65
sample-sites/kitchen-table/posts/2025-06-25-focaccia.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: "The Focaccia That Changed How I Think About Bread"
|
||||
created: 2025-06-25 09:00
|
||||
author: Amelia Fontaine
|
||||
keywords: focaccia, Ligurian, bread, olive oil, high hydration, overnight
|
||||
description: Ligurian focaccia with rosemary — the high-hydration dough science, dimple technique, olive oil pools, and an overnight cold proof that transforms the texture.
|
||||
---
|
||||
|
||||
# The Focaccia That Changed How I Think About Bread
|
||||
|
||||
I had made focaccia a dozen times before I went to Liguria, and each time it had been perfectly acceptable: flat, dimpled, herbed, slightly oily. Fine. The focaccia I ate at a bakery in Recco on a Monday morning in October was something else entirely — thin, blistered, puffy at the edges and nearly hollow in the centre, utterly saturated with local olive oil and sea salt, the texture something between bread and a cloud.
|
||||
|
||||
I stood outside eating it from a paper bag and immediately began trying to understand what had happened to it.
|
||||
|
||||
## What Makes Ligurian Focaccia Different
|
||||
|
||||
Standard focaccia is about 70–75% hydration (water weight as a percentage of flour weight). Ligurian focaccia — *focaccia al formaggio* and the simpler *focaccia classica* — runs at 80–85% or higher. More water means a more open crumb structure, lighter texture, and larger air pockets. It also means the dough is harder to handle: it spreads and sticks and refuses to behave like normal bread dough.
|
||||
|
||||
The solution is not more flour. The solution is understanding that this dough is not meant to be shaped the way a boule or a baguette is shaped. It is poured into the pan. Handled with wet hands. Stretched gently by gravity. This is a fundamentally different relationship with the dough.
|
||||
|
||||
The olive oil is not a finishing touch. It is a structural element. An absurd quantity of olive oil goes into the bottom of the pan before the dough, and a generous pour goes on top after dimpling. As the focaccia bakes, this oil fries the bottom of the bread while the steam from the high-water dough creates the airy interior. The result is a bread that is crisp underneath, soft and pillowy within, and absolutely soaked with oil throughout.
|
||||
|
||||
## The Recipe
|
||||
|
||||
**Ingredients:**
|
||||
- 500g strong bread flour (or 00 flour)
|
||||
- 430ml warm water (86% hydration)
|
||||
- 10g fine salt
|
||||
- 7g instant dried yeast (or 14g fresh)
|
||||
- 1 tsp honey
|
||||
- 8 tbsp (120ml) good quality olive oil, divided
|
||||
- Flaky sea salt
|
||||
- Fresh rosemary sprigs
|
||||
|
||||
**Equipment:** A 30×40cm baking tray (rimmed), or two smaller trays.
|
||||
|
||||
**Method:**
|
||||
|
||||
Dissolve the honey in the warm water. Add the yeast and let stand for 5 minutes. Combine flour and salt in a large bowl. Add the liquid and 4 tablespoons of the olive oil. Mix until combined — the dough will be sticky and shaggy. Do not add more flour.
|
||||
|
||||
Cover the bowl and leave at room temperature for 30 minutes. Then perform three sets of stretch-and-folds at 30-minute intervals: reach underneath the dough, stretch upward, fold over the top, rotate the bowl a quarter turn, repeat four times per set.
|
||||
|
||||
After the final fold, cover tightly and refrigerate overnight (8–16 hours). Cold fermentation develops flavour dramatically and makes the dough much easier to handle.
|
||||
|
||||
**The next day:**
|
||||
|
||||
Remove from the fridge and allow to warm for 1 hour. Pour 3 tablespoons of olive oil into the baking tray, coating the base entirely. Tip the dough onto the tray and gently stretch it toward the corners — do not force it; let it rest 5 minutes and stretch again. With a very wet or oiled hand, prod the dough to dimple it all over: press firmly, all the way to the base of the pan, every centimetre.
|
||||
|
||||
Mix the remaining olive oil with 4 tablespoons of water and pour this emulsion over the surface. The dimples will fill with oil-water pools — this is correct and desirable. Scatter generously with flaky salt and press rosemary sprigs into the dimples.
|
||||
|
||||
Leave to prove at room temperature for 45–60 minutes until noticeably puffed.
|
||||
|
||||
Bake at 230°C (fan 210°C) for 20–25 minutes until deep golden and blistered. The top should have some very dark patches — this is part of the character, not burning.
|
||||
|
||||
Cool for at least 10 minutes before eating, though it is extraordinarily good still warm.
|
||||
|
||||
## The Oil-Water Emulsion
|
||||
|
||||
The poured emulsion on top — oil and water together — is the technique I learned from reading about the Ligurian focaccerie. By the time it goes into the oven, the oil and water have separated into their constituent phases. The water steams in the oven, helping the surface bubble and blister, while the olive oil prevents the surface from drying and enables the characteristic golden-speckled finish. This is the technique that produces the texture I was eating in Recco.
|
||||
|
||||
## What It Taught Me
|
||||
|
||||
Focaccia taught me that hydration is not just a technical variable but a choice about what kind of bread you want to make. More water means more open texture means more delicacy. The trade-off is handling difficulty. Once I stopped trying to handle high-hydration dough like regular dough — stopped treating it as a problem to be solved — it became significantly more interesting to work with.
|
||||
|
||||
Bread, I think, rewards an attitude of curiosity more than one of mastery. It changes with the flour, the season, the humidity, the particular wild microorganisms in your starter or your water. The focaccia you make in January is not quite the focaccia you make in July. This is not a flaw. It is the thing that makes it interesting to keep making.
|
||||
58
sample-sites/kitchen-table/posts/2025-08-14-olive-oil.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
title: "Everything I Know About Olive Oil (It Took 10 Years to Learn)"
|
||||
created: 2025-08-14 11:00
|
||||
author: Amelia Fontaine
|
||||
keywords: olive oil, extra virgin, cooking, polyphenols, sourcing guide
|
||||
description: Pressing methods, polyphenols, smoke points, fraudulent EVOO, which to cook with versus finish with, and a practical sourcing guide.
|
||||
---
|
||||
|
||||
# Everything I Know About Olive Oil (It Took 10 Years to Learn)
|
||||
|
||||
I have been thinking about olive oil for ten years and I am still not sure I understand it fully. It is, in the world of cooking ingredients, unusually complex — variable by region, variety, vintage, harvest date, pressing method, and storage — and the fraud rate in the industry is high enough that even careful shoppers are routinely misled.
|
||||
|
||||
What follows is what I have learned, mostly through buying, tasting, cooking, and occasionally ruining things.
|
||||
|
||||
## What "Extra Virgin" Actually Means
|
||||
|
||||
Extra virgin olive oil (EVOO) is produced by mechanically pressing fresh olives — no heat, no chemicals — and meeting specific quality standards: free acidity below 0.8%, and organoleptic standards requiring the oil to have positive attributes (fruitiness, bitterness, pungency) and no defects (rancidity, mustiness, winey notes).
|
||||
|
||||
The category below this is "virgin" (acidity up to 2%, some defects permitted), and below that is "olive oil" — a blend of refined oil (chemically treated to remove defects) and virgin oil. These are quite different products.
|
||||
|
||||
The problem is that "extra virgin" on a label guarantees very little in practice. The fraud issue is substantial: studies repeatedly find that 50–80% of olive oil sold as Italian EVOO in international markets either fails quality standards or has been diluted with other oils. The EU has certification systems, but enforcement is inconsistent.
|
||||
|
||||
## How to Buy Better
|
||||
|
||||
The indicators of genuine quality:
|
||||
- **Harvest date**: Look for it on the label. EVOO is perishable — it should ideally be used within 18 months of harvest and definitely within 2 years. "Best before" is a poor proxy; harvest date is what matters.
|
||||
- **Single origin, single estate**: Oil from a single producer in a specific region is more verifiable than blends.
|
||||
- **PDO/PGI designation**: Protected Designations of Origin (Tuscan EVOO, Kalamata PDO) have more rigorous controls.
|
||||
- **Tin rather than dark glass**: Light degrades oil. Opaque tins are the ideal container. Clear glass is the worst.
|
||||
- **Peppery burn**: Good EVOO — especially Tuscan and Sicilian varieties — should cause a noticeable burn at the back of the throat when tasted neat. This is the polyphenols, and it is a quality marker, not a flaw.
|
||||
|
||||
## Polyphenols
|
||||
|
||||
Polyphenols are the antioxidant compounds in olive oil, associated with most of its health benefits and responsible for the distinctive bitterness and pungency of high-quality oils. Early harvest oils (October-November, before full ripeness) contain more polyphenols but are more aggressive in flavour. Late harvest oils are milder and rounder.
|
||||
|
||||
High-polyphenol olive oils — which are increasingly available from specialist producers — have measurements above 250mg/kg on the label. These are robust, almost savoury, and can taste almost bitter neat. They are extraordinary for dressing strong-flavoured foods.
|
||||
|
||||
## Smoke Point and Cooking
|
||||
|
||||
The great misunderstanding: "olive oil has a low smoke point and shouldn't be used for high-heat cooking." This is largely wrong. **Extra virgin olive oil's actual smoke point is 190–210°C**, depending on quality and age. This is more than sufficient for sautéing, shallow frying, and roasting. The smoke point of cheap refined oils is often higher, but polyphenols in EVOO make it more oxidatively stable at high temperatures.
|
||||
|
||||
The practical advice: do not deep-fry in EVOO (the economics are prohibitive and the smoke point, while adequate, gives less margin). For everything else — sautéing, roasting, making dressings — extra virgin is fine and often produces better flavour than neutral oils.
|
||||
|
||||
## Finishing vs. Cooking
|
||||
|
||||
There is a meaningful distinction between oils used to cook with and oils used to finish dishes. Cooking oil gets hot; much of its aroma evaporates and its flavour integrates into the dish. Finishing oil goes on cold or room-temperature food and its full character is experienced directly.
|
||||
|
||||
For cooking: a mid-range EVOO (£8–12 for 500ml) is excellent and economical. The heat will integrate rather than present its flavour.
|
||||
|
||||
For finishing: this is where a genuinely exceptional oil earns its price. A Sicilian *Nocellara del Belice* (full, fruity, grassy) or a Tuscan *Moraiolo* (pungent, bitter, peppery) drizzled over bruschetta, soup, fish, or legumes transforms the dish in a way that a cooking oil cannot.
|
||||
|
||||
## Storage
|
||||
|
||||
Away from heat, away from light, used within 6 months of opening. Not next to the stove; not on a windowsill; not in a clear bottle on a kitchen counter. In a cool cupboard, in a tin or dark bottle, used regularly. Rancid oil — which smells of crayons or old butter — is the most common quality problem and entirely preventable.
|
||||
|
||||
## The Practical Upshot
|
||||
|
||||
Buy from a specialist importer or directly from a producer if you can. Look for a harvest date. Spend more on the finishing oil you taste directly; spend less on the cooking oil the heat will transform. Keep it in the dark and use it promptly. Taste it out of the bottle sometimes — you will learn more about it that way than any other. It should make you want to eat something.
|
||||
70
sample-sites/kitchen-table/posts/2025-09-30-autumnal-soup.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
title: "Roasted Butternut Squash Soup with Crispy Sage and Brown Butter"
|
||||
created: 2025-09-30 10:00
|
||||
author: Amelia Fontaine
|
||||
keywords: butternut squash, soup, brown butter, sage, autumn, roasting
|
||||
description: Roasting vs steaming for depth, brown butter science, sage frying technique, and a full recipe with variations for a perfect autumn soup.
|
||||
---
|
||||
|
||||
# Roasted Butternut Squash Soup with Crispy Sage and Brown Butter
|
||||
|
||||
The question with any squash soup is whether to roast the squash first or to steam or boil it. The answer, if you want the best-tasting soup, is always to roast. Steaming produces a pale, sweet, slightly watery result. Roasting produces caramelisation and depth that dramatically change what the soup can be.
|
||||
|
||||
The science is the Maillard reaction again: sugars in the squash combine with amino acids at high heat to produce hundreds of new flavour compounds. Squash is particularly susceptible to this because of its high sugar content. Roasted at 200°C until the cut surfaces are deeply golden and sticky, a butternut squash is a fundamentally different ingredient from a boiled one.
|
||||
|
||||
The brown butter amplifies this in a direction that seems designed for this vegetable specifically.
|
||||
|
||||
## Brown Butter
|
||||
|
||||
*Beurre noisette* — noisette meaning hazelnut, for the colour and aroma — is butter that has been cooked until the milk solids brown. The transformation happens in three stages:
|
||||
|
||||
1. Butter melts and the water begins to evaporate (you hear it fizzing).
|
||||
2. The milk solids separate and begin to colour. The butter goes from opaque and milky to clear and golden.
|
||||
3. The milk solids turn brown and the butter smells of toasted hazelnuts and toffee. This is the goal.
|
||||
|
||||
The danger: stage 3 transitions to stage 4 (burning) in about 30 seconds. Watch it constantly and remove from the heat the moment it smells right — it will continue to colour from the residual heat of the pan.
|
||||
|
||||
Brown butter adds a toasted, nutty complexity to anything that contains cream or dairy. On soup, drizzled over at serving, it is remarkable.
|
||||
|
||||
## Sage Crisps
|
||||
|
||||
Sage fried in butter or oil becomes a completely different ingredient: the volatile aromatic compounds that can make fresh sage taste slightly medicinal transform into something woody and resinous and addictive. Fried sage crisps are one of the best things you can put on soup, pasta, risotto, or gnocchi.
|
||||
|
||||
Heat a shallow layer of olive oil or clarified butter in a small pan until a sage leaf sizzles immediately on contact. Fry the leaves in batches for 30–45 seconds until darkened and crisp — they continue to crisp as they drain. Remove to kitchen paper immediately. They keep for several hours at room temperature.
|
||||
|
||||
## The Recipe (serves 4)
|
||||
|
||||
**Ingredients:**
|
||||
- 1 large butternut squash (about 1.2kg), halved, seeds removed
|
||||
- 1 large onion, roughly chopped
|
||||
- 4 cloves garlic, unpeeled
|
||||
- 750ml vegetable or chicken stock, warm
|
||||
- 100ml double cream
|
||||
- 3 tbsp olive oil
|
||||
- Salt, pepper, nutmeg
|
||||
|
||||
**For the brown butter:**
|
||||
- 80g unsalted butter
|
||||
- A handful of fresh sage leaves
|
||||
|
||||
**Method:**
|
||||
|
||||
Brush the squash halves with 2 tablespoons of olive oil. Season generously with salt and pepper. Place cut-side down on a baking tray with the unpeeled garlic cloves and roast at 200°C for 45–55 minutes until the cut surface is deeply golden and the flesh is completely tender. Cool slightly.
|
||||
|
||||
Meanwhile, soften the onion in 1 tablespoon of olive oil in a large pot over medium heat until translucent and sweet, about 12 minutes.
|
||||
|
||||
Scoop the squash flesh from the skin. Squeeze the roasted garlic from its skins. Add both to the pot with the onion. Add the stock and bring to a simmer for 5 minutes.
|
||||
|
||||
Blend until completely smooth — a high-powered blender produces the best result; use a hand blender if that's all you have. Add the cream, a generous grating of nutmeg, and more salt and pepper. Taste carefully.
|
||||
|
||||
**To serve:** Reheat the soup gently if needed. Ladle into warm bowls. Make the brown butter in a small pan, watching carefully, and pull off the heat at hazelnut colour. Add the sage leaves to crisp in the same pan (the butter will sizzle vigorously). Drizzle brown butter over each bowl and top with crispy sage.
|
||||
|
||||
## Variations
|
||||
|
||||
**Spiced version**: Add 1 tsp ground cumin, ½ tsp smoked paprika, and a pinch of chilli to the onion as it softens. Finish with a swirl of yoghurt instead of cream, and toasted pumpkin seeds instead of sage.
|
||||
|
||||
**Thai-inspired**: Replace the cream with coconut milk. Add a stalk of lemongrass and a slice of galangal (or ginger) while blending, then strain. Finish with lime juice and fresh coriander.
|
||||
|
||||
**The bread bowl**: Hollow out a small round sourdough loaf and serve the soup inside it. Theatrical and more satisfying than it has any right to be.
|
||||
|
||||
The plain version, with brown butter and sage, is my preferred autumn lunch: made on a Sunday, reheated during the week, always excellent. The depth from the roasting means it doesn't need elaborate garnishes — the soup itself is doing most of the work.
|
||||
85
sample-sites/kitchen-table/posts/2025-11-05-cassoulet.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
title: "Cassoulet: A Two-Day Recipe Worth Every Minute"
|
||||
created: 2025-11-05 09:00
|
||||
author: Amelia Fontaine
|
||||
keywords: cassoulet, confit duck, Toulouse sausage, beans, French, slow cooking
|
||||
description: History of cassoulet, the Toulouse vs Castelnaudary debate, confit duck, Toulouse sausages, haricot tarbais beans — the full two-day recipe.
|
||||
---
|
||||
|
||||
# Cassoulet: A Two-Day Recipe Worth Every Minute
|
||||
|
||||
Cassoulet is the greatest argument for unhurried cooking that I know. It is a Languedoc bean casserole made with confit duck, Toulouse sausages, and slow-cooked pork — a dish that takes two days and rewards the effort with something that no quick version can approximate.
|
||||
|
||||
There is a formal, quasi-legal dispute about its origins that I find charming in its intensity. The towns of Carcassonne, Toulouse, and Castelnaudary all claim cassoulet as their own, and the specific composition of the "authentic" version differs by town. Carcassonne includes lamb; Toulouse includes lamb and preserved goose; Castelnaudary uses pork, pork rind, and duck confit only. The Academy of Cassoulet in Toulouse publishes official rules.
|
||||
|
||||
My version is closer to Toulouse. I do not have official standing.
|
||||
|
||||
## The Components
|
||||
|
||||
**Haricot Tarbais beans** are the traditional choice — grown in the Bigorre region of the Pyrénées since the 17th century, with a thin skin and mealy, creamy interior. They are available online from specialist suppliers and are genuinely worth seeking out. Dried haricot blanc or cannellini are acceptable alternatives.
|
||||
|
||||
**Confit duck legs** can be made at home (the method follows), or bought from a good butcher or online supplier. The home-made version is superior.
|
||||
|
||||
**Toulouse sausages** are coarse-ground pork with salt, pepper, nutmeg, and wine — nothing more. They are available from French specialist butchers. Do not substitute ordinary sausages; the flavour and texture are fundamentally different.
|
||||
|
||||
## Day One: Confit Duck and Bean Preparation
|
||||
|
||||
### Confit Duck Legs
|
||||
|
||||
- 4 duck legs
|
||||
- 30g coarse salt
|
||||
- 4 bay leaves
|
||||
- 10 peppercorns
|
||||
- 4 sprigs fresh thyme
|
||||
- 4 cloves garlic, crushed
|
||||
- About 600g duck fat (or a combination of duck fat and lard)
|
||||
|
||||
Combine the salt, bay, peppercorns, thyme, and garlic. Coat the duck legs and refrigerate for 12–24 hours. Rinse, pat dry.
|
||||
|
||||
Melt the duck fat in a deep casserole. Add the duck legs — they should be submerged. Cook at 90°C (just barely simmering) for 2.5–3 hours until the meat is tender when pierced. Remove and reserve. Store in the fat if making ahead — this is the principle of confit, and the duck keeps for weeks this way.
|
||||
|
||||
### Beans
|
||||
|
||||
Soak 500g dried haricot tarbais overnight in plenty of cold water. Drain, cover with fresh cold water by 5cm. Add a carrot, an onion, and a bouquet garni. Simmer for 45 minutes until tender but not quite done — they will cook more in the cassoulet. Reserve with their cooking liquid. Season with salt only in the last 10 minutes.
|
||||
|
||||
## Day Two: Assembly
|
||||
|
||||
**The base:**
|
||||
- 200g pork belly, cut into thick pieces
|
||||
- 200g pork rind, blanched for 5 minutes and cut into strips
|
||||
- 2 onions, chopped
|
||||
- 4 cloves garlic, sliced
|
||||
- 400g tinned whole plum tomatoes
|
||||
- 200ml white wine
|
||||
- Reserved bean cooking liquid
|
||||
- Salt, pepper
|
||||
|
||||
Brown the pork belly in a large pot until deeply coloured. Remove. Soften the onions in the rendered fat. Add the garlic, then the white wine; reduce by half. Add the tomatoes, crushing them. Add the bean cooking liquid (about 500ml) and the pork rind. Simmer for 20 minutes.
|
||||
|
||||
**Building the cassoulet:**
|
||||
|
||||
You need a large, wide casserole — traditional earthenware cassoles are ideal; a large Dutch oven works.
|
||||
|
||||
Layer one: one-third of the beans, some of the pork belly, pork rind, and tomato base.
|
||||
|
||||
Layer two: the duck legs and Toulouse sausages (4–6 sausages, depending on size, slightly browned in a pan first).
|
||||
|
||||
Layer three: the remaining beans, the remaining pork, more base sauce. The liquid should just reach the top of the beans — add more bean cooking liquid or stock if needed.
|
||||
|
||||
**Breadcrumbs**: Strew a generous layer of fine dried breadcrumbs over the top. Drizzle with duck fat or olive oil.
|
||||
|
||||
Bake at 150°C for at least 2 hours. At intervals, break the crust that forms on the surface with a spoon and push it into the cassoulet. Add liquid if it looks dry. The traditional instruction is to break the crust seven times; the practical instruction is to break it whenever you walk past.
|
||||
|
||||
The cassoulet is done when the top is deep golden-brown, the interior is thick and bubbling, and the whole kitchen smells of Gascony.
|
||||
|
||||
## Serving
|
||||
|
||||
Bring the cassoulet to the table in its dish. Serve with nothing more than good bread and a simple green salad dressed only with vinaigrette. The cassoulet is the meal; it needs nothing.
|
||||
|
||||
Leftovers — there will be leftovers — are better still the next day. This is one of the dishes that improves with every reheating.
|
||||
|
||||
## On the Investment
|
||||
|
||||
Two days is a lot. The answer to this objection is that very little of those two days involves active cooking: the confit sits in the oven unattended, the beans soak overnight, the cassoulet bakes slowly. The active time is perhaps two hours spread across both days.
|
||||
|
||||
What you get for this investment is a dish that feeds six to eight people generously, that improves overnight, that contains more depth and complexity than almost anything you could make in an hour, and that marks the meal as an occasion — which is sometimes exactly what cooking should do.
|
||||
92
sample-sites/kitchen-table/posts/2026-01-08-fermentation.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
title: "My Year of Fermentation: Kimchi, Kraut, and Lacto-Pickles"
|
||||
created: 2026-01-08 10:00
|
||||
author: Amelia Fontaine
|
||||
keywords: fermentation, kimchi, sauerkraut, lacto-fermentation, pickles
|
||||
description: Lacto-fermentation science, basic kimchi recipe, simple sauerkraut, quick lacto-pickles, equipment needed, and safety notes.
|
||||
---
|
||||
|
||||
# My Year of Fermentation: Kimchi, Kraut, and Lacto-Pickles
|
||||
|
||||
Fermentation feels like the opposite of modern cooking: it is slow, unpredictable, invisible, and yields results that are difficult to specify in advance. You cannot ferment something in thirty minutes. You cannot reliably repeat results across batches. What you can do, with basic understanding and some patience, is create a range of preserved, probiotic-rich, complex-flavoured foods from very simple ingredients.
|
||||
|
||||
I spent much of last year making things in jars. This is what I learned.
|
||||
|
||||
## The Science of Lacto-Fermentation
|
||||
|
||||
Lacto-fermentation is not about dairy. It refers to *Lactobacillus* bacteria, which are present naturally on the surface of most vegetables. When vegetables are salted and submerged in brine, these bacteria produce lactic acid as they metabolise the sugars in the vegetables. The lactic acid lowers the pH, creating an environment inhospitable to pathogens but hospitable to more Lactobacillus bacteria.
|
||||
|
||||
This is why lacto-fermentation is safe without refrigeration or sterilisation: the lactic acid is the preservative. The pH drops quickly enough in the first 24–48 hours to prevent pathogenic bacteria from establishing themselves. As long as the vegetables remain submerged below the brine — where anaerobic conditions prevail — the process is reliable and safe.
|
||||
|
||||
Salt concentration matters: 2–2.5% salt by weight of the vegetables (plus added water if making a brine) is the standard range for most lacto-fermented vegetables. Too little salt and unwanted bacteria can establish themselves before the pH drops; too much and the Lactobacillus bacteria are inhibited.
|
||||
|
||||
## Equipment
|
||||
|
||||
You need:
|
||||
- **Glass jars** (wide-mouth mason jars or Kilner jars) — 1 litre or larger
|
||||
- **A clean weight** to keep vegetables submerged — a small jar filled with water, a zip-lock bag filled with water and brine, or commercial fermentation weights
|
||||
- **A kitchen scale** — weight measurements are essential for correct salt concentration
|
||||
- **Patience** — most ferments take 3–7 days at room temperature before they are ready
|
||||
|
||||
You do not need: special airlocks (useful but optional), canning equipment, expensive fermentation crocks (though they are excellent), or a vacuum sealer.
|
||||
|
||||
## Sauerkraut
|
||||
|
||||
The simplest lacto-ferment. One ingredient, plus salt.
|
||||
|
||||
- 1kg white or green cabbage, finely shredded (about 2mm)
|
||||
- 20g fine sea salt (2% by weight)
|
||||
|
||||
Combine in a large bowl and massage vigorously for 5–10 minutes until the cabbage has released significant liquid — it will reduce in volume by half and the brine should be sufficient to submerge the cabbage when pressed.
|
||||
|
||||
Pack tightly into a 1-litre jar, pressing down hard after each handful. The brine should rise to cover the cabbage. Place your weight on top to keep the cabbage submerged. Cover with a cloth and secure with a rubber band.
|
||||
|
||||
Leave at room temperature (18–22°C is ideal) for 5–7 days. "Burp" the jar daily by pressing the cabbage down if it has floated. Taste after day 3 — it should be slightly sour. Continue until it reaches your preferred sourness, then seal and refrigerate.
|
||||
|
||||
Sauerkraut keeps refrigerated for months.
|
||||
|
||||
## Simple Kimchi
|
||||
|
||||
Kimchi is more complex than sauerkraut but follows the same principles.
|
||||
|
||||
**For the cabbage:**
|
||||
- 1 medium napa (Chinese) cabbage (about 1kg)
|
||||
- 60g coarse salt
|
||||
|
||||
Quarter the cabbage lengthwise. Dissolve the salt in enough water to submerge the cabbage. Soak for 1–2 hours until pliable. Rinse thoroughly and squeeze as dry as possible.
|
||||
|
||||
**The paste (yangnyeom):**
|
||||
- 4 tbsp gochugaru (Korean red pepper flakes — not chilli flakes)
|
||||
- 1 tbsp fish sauce (or soy sauce for vegan version)
|
||||
- 4 cloves garlic, grated
|
||||
- 1 tsp fresh ginger, grated
|
||||
- 1 tsp sugar
|
||||
- 3 spring onions, cut into 5cm pieces
|
||||
|
||||
Mix the paste ingredients. Cut the cabbage quarters crosswise into 5cm pieces. Combine with the paste and spring onions, wearing gloves (the gochugaru stains). Mix thoroughly until all the cabbage is coated.
|
||||
|
||||
Pack into jars. There should be little to no brine visible initially — it will develop within 24 hours. Leave at room temperature for 24–48 hours, then refrigerate. The kimchi is ready to eat but improves over two to four weeks as it continues to ferment slowly in the fridge.
|
||||
|
||||
## Quick Lacto-Pickles
|
||||
|
||||
These use a salt brine rather than the vegetable's own liquid, which makes them work for firmer vegetables and more varied ingredients.
|
||||
|
||||
**Brine**: Dissolve 20g salt in 1 litre of water (2% brine).
|
||||
|
||||
**Suitable vegetables**: Cucumber (cut into spears or coins), carrot, radish, green beans, cauliflower florets, garlic.
|
||||
|
||||
**Additions**: Fresh dill, bay leaf, peppercorns, coriander seeds, garlic, chilli.
|
||||
|
||||
Pack the vegetables and aromatics tightly into a jar. Pour brine over to cover. Weight and cover as with sauerkraut. At room temperature for 3–5 days, then refrigerate. These are lighter and more delicate in flavour than sauerkraut — a bridge between vinegar pickles and full fermentation.
|
||||
|
||||
## Safety
|
||||
|
||||
Lacto-fermentation has an excellent safety record when done correctly. The key rules:
|
||||
- Keep vegetables fully submerged throughout fermentation
|
||||
- Use the correct salt concentration
|
||||
- Ferment at room temperature, not in the warmth of the oven or near a heat source
|
||||
- If you see pink or black mould (not the white kahm yeast, which is harmless), discard and start again
|
||||
|
||||
The strong smell of fermentation can be alarming — sauerkraut at day two smells aggressively sour. This is normal. Trust the science.
|
||||
|
||||
Fermentation changed how I think about food preservation: not as the avoidance of microbial activity, but as the selective cultivation of it. The bacteria doing the work are on the vegetables already; you are just creating conditions in which they thrive and others don't. There is something deeply satisfying about that collaboration.
|
||||