Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.
Sveltia CMS
A git-based CMS gives a static site an editing screen without giving it a database. It runs in the browser, reads the repo’s markdown files through the GitHub API, shows their front matter as a form, and saves an edit as a commit, which then triggers the normal deploy. Netlify CMS started this kind of tool. It was renamed Decap CMS in 2023, and Sveltia CMS is a rewrite that its author offers as the successor: the same config.yml format, a much better phone interface, and direct uploads to Cloudflare R2. Sveltia is still in beta before 1.0 and mostly one developer’s work, so its behavior can change between versions. Pin the version in the script address.
Setting it up takes two files in an admin/ folder:
index.html, which holds a single<script>tag and nothing else. The docs point out that AI assistants tend to add a stylesheet<link>ortype="module", and neither belongs there.config.yml, which lists collections (each maps to a content folder) and each collection’s fields (each maps to a front matter key).
The question that decides whether you can use one
What does the CMS do with front matter keys that aren’t in its config? A site with a real data model has many keys nobody edits on a phone: media byte counts, syndication links, keys only one post type uses. A CMS that rebuilds the file from its field list deletes them all on the first save. A CMS that keeps them lets the config stay partial, so you declare only what you want to edit.
The docs rarely answer this, so check the source. Sveltia keeps them. Opening a post starts from a full copy of the parsed file, and saving writes the configured fields first, then “the remainder”, every other key sorted alphabetically (draft/create/index.js → buildDraft, draft/save/serialize.js → finalizeContent, checked 2026-09-10). Pages CMS still deleted undeclared keys in 2.1.8. For this site that difference decided between the two.
What a first save changes
Even when nothing is deleted, the first save of a file rewrites its shape. None of this loses data, and all of it shows in the git diff:
- Key order. Declared fields go first, in config order, and the rest follow alphabetically. Sveltia sorts undeclared keys at every depth: it flattens them to paths like
photo.downloads.0.bytesand sorts those, so the inside of a nested object gets reordered too. - Quote marks around values are dropped or changed from double to single, which makes no difference to YAML.
- Empty keys get added for optional fields a post doesn’t have, unless the config sets
output: { omit_empty_optional_fields: true }. - Whitespace: a blank line after the closing
---, and a final newline if the file had none.
Declaring fields in the order most files already use keeps the first diff small.
In jedee
Live at /admin/ since 2026-09-10 (admin/index.html and admin/config.yml, copied through unchanged, noindex), pinned to @sveltia/cms@0.209.1, saving straight to main. It is the third way content gets into the site: Micropub creates posts from a phone, the Web Clipper captures them from a source page, and Sveltia edits existing ones. It is also the only phone route for the post types Micropub doesn’t handle (audio, video, event, recipe). Neither Eleventy Excellent nor indiee ships a CMS, so all of this is jedee’s own.
All 16 post types are configured. Each shows a shared set of fields (title, description, date, tags, “Also on” links, draft, body), response types add their link field, and photos, jams, films, books and events have a few more (see “Fields for one type” below). New posts can only be created in notes and articles; everything else starts from Micropub or the clipper, which already know each type’s shape (see The authoring tool decides the data model). category is never declared, because each folder’s data file sets it and posts don’t carry it.
backend:
name: github
repo: pjedlund/jedee
branch: main
auth_scope: public_repo
output:
omit_empty_optional_fields: true
slug: # keep Title-Case filenames for Obsidian
lowercase: false
sanitize_replacement: ' 'The post body uses the plain markdown mode (widget: markdown, modes: [raw]), so wikilinks, footnotes and {:attrs} are never rewritten. The rich-text mode once escaped **bold** on saves that only touched the title (issue #556, fixed 2025-12). Plain mode avoids that whole kind of bug.
Five traps, each found by a real save
- The datetime field drops seconds. A stored
2026-06-15T21:02:11+02:00came back as21:02:00on save, even withstep: 1. The picker builds its value from hour and minute only (date-time/helpers.js→formatDateTimeValue), so every stored time with seconds reads back as:00, and saving writes that over the original. No option fixes it.dateis therefore a plain text field with a^\d{4}-\d{2}-\d{2}pattern, and a new post’s date is typed by hand. - A collection lists only the files directly in its folder. Posts in subfolders don’t show up at all. Articles live in year folders, but Micropub writes new ones at the top level, so a
path: '{{year}}/{{slug}}'pattern would have hidden the new ones. The fix isnested: { depth: 2, subfolders: false }, where the depth counts path segments including the filename. That reaches the top level and the year folders and skips anything deeper, such as the gitignored-drafts/and the starter’s demo post. Jams use the same setting, so the 111 This Is My Jam posts injams/thisismyjam/show as a folder inside Jams (at first they had a collection of their own). One book in its own folder moved up intoreading/, and its URL stayed the same because it comes from the file name (see Permalinks and Obsidian-friendly filenames). - The body is required by default. A post without text (many jams, likes, photos) couldn’t be saved until
bodygotrequired: false. Withomit_empty_optional_fields, an empty body is written as nothing after the closing---. - Object fields keep their undeclared subkeys. A photo’s
photo:is anobjectfield with alt, caption and the film details declared.photo.srcandphoto.downloadsare deliberately left out, and a save keeps them, sorted after the declared ones. Declaring them would rewrite the image path or retype the R2 byte counts, which must match the served file (see Hosting large originals off-repo). - Filenames of new posts. The default slug is lowercase with hyphens, and
lowercase: falseplus a space as the replacement keeps Title-Case names. Punctuation (commas, colons, apostrophes,?,&) still becomes a space, though, so a title with punctuation won’t match its filename. Existing files are never renamed.
Single files, pickers and filters
Besides the post folders, the sidebar has three single-file entries, which Sveltia calls singletons (singletons: in the config, one file: each): Site settings (src/_data/settings.yaml), Featured (src/_data/featured.yaml) and Now (src/pages/now.md), plus a Pages collection. Building them turned up six things:
- ⚠️ A save drops every comment in the file. Sveltia parses the file to plain values and writes it out fresh with the
yamllibrary (file/parse.js,file/format.js), so nothing but values survives.settings.yamltherefore has no comments at all, and it’s kept in the exact shape a save produces, so a first save changes nothing. Its notes became fieldhints inadmin/config.yml, and its@until 1.0.0markers moved there too, wherenpm run flagsstill finds them. The two pages that had a comment in their front matter lost it the same way, into the date field’s hint. - Two entries must not share one file. Each save rewrites the whole file and re-sorts every key it doesn’t manage, so a second entry pointed at
settings.yamlwould reorder the first one’s values, including the footer’s profile order. That’s why Featured moved to a file of its own. - A picker (
widget: relation) reaches one collection only. There’s no wildcard and no list of collections. To pick “any post”, Featured is a list withtypes:: pressing Add asks for the type first, and each type is an object holding one relation to its own collection. An item is saved as{ type: jams, post: thisismyjam/Dracula Mountian }./nowuses three plain relations withmultiple: true(jams, watching, reading). - What a relation saves for a post in a subfolder is its path inside the collection’s folder, without
.md:thisismyjam/BBC Live Session, or2024/<title>for an article. When the collection has nopathtemplate, Sveltia’sgetSlugfalls back to the whole sub-path, so the build can find the file assrc/posts/<type>/<post>.md. One function,resolvePicksincollections.js, turns picks into posts for both pages. It stops the build when a pick’s file is missing, so a renamed post can’t quietly disappear, and it leaves drafts out of production. - A picker lists drafts unless told not to. Picking one gives an empty section on the live page, because the build leaves it out. Every picker on
/nowand Featured carriesfilters: [{ field: draft, values: [true], exclude: true }]. It has to be written as “exclude true” and not “only false”, because many published posts have nodraftline at all, andvalues: [false]would hide them (fields/relation/helpers/filters.js, checked in 0.209.1). filter: { field: layout, value: page }limits the Pages collection to ordinary pages, sonow.md(layoutnow) only appears under its own entry.
Fields for one type
The shared fields are YAML anchors (&title, &syndication, …), so a type with extra fields lists the shared ones by name (*title) and adds its own between them. Three things came up adding a film rating, covers and event details:
- A dropdown can save numbers. Sveltia has no star-rating field, but a
selectwhose option values are numbers saves the number itself, while the labels can be anything. The film rating (scoreMy) shows ½ to ★★★★★ and writesscoreMy: 3.5, unquoted (checked with a real save on 2026-09-11). Anumberfield would work too, but it shows a bare box to type in.
Before adding a number field, make the stored values agree. The films had- { name: scoreMy, label: My rating, widget: select, required: false, options: [{ label: '★★★', value: 3 }, { label: '★★★½', value: 3.5 }, …] }7,"4"and"3.5"side by side, a 10-point and a 5-point scale mixed, and a quoted number is text to YAML. They were brought onto one scale first. - A nested date needs the same plain text field as
date. An event’sevent.startandevent.endarestringfields inside anobjectfield, with the same pattern asdate, because the datetime field would drop their seconds too. The status is aselectof the values the status badge’s CSS already knows. - An image field per type. Film and book covers use
widget: imagewith their ownmedia_folder/public_folder, so an upload lands beside that type’s other local covers. A pasted remote URL is kept as it is and self-hosted at build (see Self-hosting remote images at build time).
An empty undeclared key (myUrl:) comes back from a save as myUrl: null. Eleventy reads both as null, so nothing on the site changes.
Signing in
There are two ways in, both through GitHub:
- An access token is the narrowest: a fine-grained GitHub token limited to
pjedlund/jedee, with read and write access to its contents, pasted once into the browser. - “Sign In with GitHub” uses Netlify as the go-between, so the site needs no server of its own. It needs a GitHub OAuth App (callback
https://api.netlify.com/auth/done) installed as a provider in Netlify, and it works on the live site only. Sveltia sends the page’s host name as Netlify’s site ID. The scope defaults torepo, which grants write access to every repository including private ones, so the config narrows it topublic_repo. That still covers every public repository, not just this one.
How changes are checked
Adding a field is checked before anything is saved for real. _local/tests/sveltia-roundtrip.mjs reproduces Sveltia’s first save for every post, using the same YAML library and settings and reading the field lists from admin/config.yml. It then checks what Eleventy reads back. It matched real saves from the admin byte for byte: a note, a photo, an RSVP, a jam and a year-folder article. Its --write-all mode rewrites every post for a full before/after build, and on the first run no page changed. Rerun it after adding any field. It handles number fields and number-valued dropdowns, and still only warns on lists of objects.
Raw source: src/_raw/Getting Started Sveltia CMS.md, and the setup record _local/design/Reference - Sveltia CMS as the edit layer.md (§2, §4, §6), read on 2026-09-11.