6.1 KiB
Zettelclean
Keep a Zettelkasten filename's slug in sync with the note's H1 heading.
Zettelclean resolves the "three sources of truth for a title" problem (filename vs H1 vs
YAML title:) by separating concerns:
- The H1 heading in the note body is the single source of truth for the human title.
- The filename is a derived machine identity:
TIMEID-slug(H1).md. - No YAML is written. Files stay pure Markdown.
It is a small, one-way, prefix-gated renamer: edit the H1, and a moment later the filename's slug tail follows. Nothing else is touched.
How it works
- A note is a zettel when its filename starts with a TIMEID: a run of 12 to 18 digits
(a timestamp), for example
20260728145404-my-note.md. Files without that prefix are ignored, so plain notes, daily notes, Excalidraw and Kanban files are never renamed. - The TIMEID is minted once and never changes. Only the slug tail is regenerated.
- When you edit the H1, zettelclean waits about 5 seconds of idle, then renames the file to
TIMEID-slug(H1).mdusing Obsidian's own rename (so backlinks are updated). A 10 second cooldown prevents a just-renamed file from being renamed again. - Sync is strictly one-way, H1 to filename. Renaming a file by hand is never fought.
- Delete the H1 and the filename collapses back to the bare
TIMEID.md.
The slug is lowercase, ASCII, dash-separated: accents are folded (Ciências becomes
ciencias), runs of punctuation and whitespace collapse to a single dash, and leading and
trailing dashes are trimmed.
Creating notes
Use Obsidian's built-in Unique note creator core plugin to mint the TIMEID:
- Settings -> Core plugins -> enable "Unique note creator".
- Set "Unique note format" to
YYYYMMDDHHmmss(14 digits, second precision - avoids same-minute collisions). - Click the "Create new unique note" ribbon icon. You get
20260728145404.md; type your# Title, and zettelclean takes over from there.
Zettelclean also accepts an existing 12-digit (YYYYMMDDHHmm) vault if you prefer not to
change the setting.
Retrofitting existing notes
Notes created before you adopted this workflow have no TIMEID. Right-click such a note in the file explorer and choose "Prefix timestamp to filename". It mints a fresh timestamp, prepends it, and the note is now a zettel that syncs on future H1 edits.
Pretty sidebar (optional)
By default the file explorer shows the slugged filename (with dashes). If you want the sidebar, tabs and graph to show the pretty H1 instead - with no YAML in your files - install the Front Matter Title community plugin and set its feature templates to:
#heading | _basename
That reads the first H1 directly and falls back to the filename when there is none.
Settings
None. Zettelclean is zero-config in this version. The 5 second settle and 10 second cooldown are fixed.
Development
Dependencies are managed with pnpm (see "Why pnpm, not npm" below). Node 22+ ships
corepack, so you do not need to install pnpm globally, and the packageManager field in
package.json pins the exact version.
corepack pnpm install # first time, or after a dependency change
corepack pnpm run dev # compile in watch mode
corepack pnpm run build # typecheck and produce main.js
corepack pnpm test # run the slug unit tests (Node's built-in runner)
corepack pnpm run lint # eslint with the Obsidian ruleset
The pure string logic lives in src/slug.ts and is unit-tested in isolation; the Obsidian
wiring lives in src/main.ts. Tests run on Node's built-in test runner (node --test) - there
is no vitest or jest dependency.
Why pnpm, not npm
Do not run npm install or npm ci in this repo. This project is developed on a ZFS
filesystem, and npm's installer renames many directories in parallel (to hoist and dedupe
packages). That races ZFS's directory-metadata handling and aborts with:
npm error code ENOTEMPTY
npm error syscall rename
pnpm avoids the problem entirely: it hard-links packages from a global content-addressable store instead of renaming directories into place, so it installs cleanly on the same disk.
Notes for contributors:
- Only
npm install/npm ciare affected. Running scripts through npm (npm run build) still works, because that just spawns tsc/esbuild and installs nothing. Still, preferpnpmeverywhere for consistency. - The lockfile is
pnpm-lock.yaml(committed). There is nopackage-lock.json; do not create one.
License
GNU General Public License v3.0 or later (GPL-3.0-or-later).
Copyright (C) 2026 Ruben Carlo Benante (Dr. Béco) rcb@beco.cc.
See LICENSE for the full text.