Content Mutation System
Content Mutation is a build-time tool for changing content files as a reviewable transaction. It creates, replaces, moves, renames, and deletes files, and it can set or remove Frontmatter keys. It is meant for two groups:
- Site authors who change content from the CLI, including in scripts and CI.
- Plugin and tool authors who need the same safety guarantees from TypeScript.
The problem it solves is not "writing a file". A plain editor or mv command
does not know which WikiLinks and Markdown links point at a moved note, does not
notice that someone else edited the file since you planned the change, and leaves
no recovery record if a batch fails halfway. Content Mutation plans the change
first, shows every file that would change, rewrites the links it can prove are
affected, and writes through a journal with backups.
It is not a browser editor. Visitors cannot edit a published site with it. It runs on the author's machine or in CI, before a build, and it only writes inside the content directory.
Content Mutation owns how to write safely. The Content Migration system owns what to change and uses Content Mutation as its write layer.
What you can do
| Task | CLI | TypeScript API |
|---|---|---|
| Create a note or asset | content create |
create, asset-create |
| Replace a file body | content edit |
update, asset-update |
| Delete a file | content delete |
delete, asset-delete |
| Move or rename a file | content move |
move, rename, asset-move |
| Set or remove Frontmatter keys | — (API only) | frontmatter |
| Apply several changes as one plan | — (API only) | batch |
| Preview without writing | omit --apply |
plan() |
| Save a plan for review | content plan ... --out |
savePlan() / loadPlan() |
| Recover an interrupted write | content recover |
recover() |
| Roll back a completed write | — (API only) | rollback() |
Content Mutation never infers intent from Markdown. You state the operation, and Mutation either proves it is safe or reports a conflict.
Before and after: move a note
Start with two files:
notes/
index.md
old.md
notes/index.md contains a WikiLink:
See [[old]].
notes/old.md contains # Old note.
Preview the move. Without --apply, nothing is written:
riebeckite content move notes/old.md notes/new.md
Plan <plan-id>
update notes/index.md
create notes/new.md
delete notes/old.md
Dry run. Re-run with --apply to write changes.
Apply the same operation:
riebeckite content move notes/old.md notes/new.md --apply
After the write:
notes/
index.md
new.md
notes/index.md now contains:
See [[notes/new]].
The WikiLink is rewritten to the content path without .md. A relative Markdown
link destination such as old.md would be rewritten to new.md. The old file no
longer exists.
Link rewriting covers Markdown links, Markdown images, reference links and definitions, WikiLinks, and Wiki embeds. It does not rewrite arbitrary text, code blocks, or external URLs. If a rewrite would become ambiguous or stop resolving, the plan fails before any write.
Quick start
Every command is a dry run until you add --apply. This sequence creates,
rewrites, moves, and deletes one note:
riebeckite content create notes/hello.md "# Hello" --apply
riebeckite content edit notes/hello.md "# Hello, world" --apply
riebeckite content move notes/hello.md notes/welcome.md --apply
riebeckite content delete notes/welcome.md --apply
edit replaces the whole body, not just a fragment. There is no partial text
replacement command; use the TypeScript API to compute the new body.
To review a plan before writing, save it:
riebeckite content plan move notes/hello.md notes/welcome.md --out move-hello.json
riebeckite content apply move-hello.json --apply
Saved plans live under <content>/.riebeckite/mutations/plans, where <content>
is the configured content directory. The --out value is a name inside that
directory, not an arbitrary filesystem path.
If a plan conflicts, the CLI prints the conflict and exits non-zero:
Conflict: source.md: The link target is ambiguous around the mutation: ...
The plan cannot be applied.
Deleting a file does not remove links that point to it. The plan succeeds with a warning instead (shown in the dry run and the saved plan):
Warning: A link to deleted content remains: old.md
How it works
Diagram source
flowchart TD
Caller["CLI or Content Migration"]
Plan["ContentMutationOperation[]"]
Planner["FileSystemContentMutation.plan"]
Preconditions["Fingerprints, link rewrites, conflicts"]
Lock["Exclusive mutation lock"]
Journal["Journal and backups"]
Apply["Atomic per-file writes"]
Result["ContentMutationResult"]
Caller --> Plan --> Planner --> Preconditions
Preconditions -->|"no conflicts"| Lock --> Journal --> Apply --> Result
Preconditions -->|"conflicts"| Result
plan() reads the content tree, applies the operations in memory, rewrites
affected links, and compares the result with the current files. It writes no
content files. apply() revalidates the plan, takes the lock, writes the journal
and backups, then writes each file atomically.
The contracts are split by responsibility:
| Layer | Package | Responsibility |
|---|---|---|
| Operation and plan types | @riebeckite/core |
ContentMutationOperation, ContentMutationPlan, issue and result types |
| Link retargeting | @riebeckite/core |
planContentLinkUpdates rewrites affected Markdown links |
| File executor | @riebeckite/node |
FileSystemContentMutation plans, applies, recovers, rolls back, saves and loads plans |
| CLI | @riebeckite/cli |
riebeckite content maps shell arguments to operations |
Core does not touch the filesystem. The Node executor is the only component that writes content files.
Internal state is not configuration. It lives under the content root:
| Path | Contents |
|---|---|
<content>/.riebeckite/mutations/lock |
Exclusive lock while a write or recovery runs |
<content>/.riebeckite/mutations/<planId>/journal.json |
Transaction state and per-file fingerprints |
<content>/.riebeckite/mutations/<planId>/backups/ |
Previous file contents, named by a Base64URL path |
<content>/.riebeckite/mutations/plans/ |
Saved plans from content plan --out |
There is no riebeckite.config.ts option for Content Mutation. .riebeckite is
build-time state and is ignored by Git in this repository. The journal and backup
directory contain content bodies, so treat it as private data.
Relationship to other features
- Content Migration is the only bundled producer of migration operations. It never writes files directly; it builds a mutation plan, verifies the future manifest, then delegates the write and rollback to Content Mutation.
-
The Frontmatter Transformer plugin
adds named presets and a
frontmatterCLI namespace on top of Content Migration. It does not add a second writer. - Content system and the link index define which link targets are unambiguous. Mutation reuses the same resolution rules instead of a second Markdown parser.
-
CLI exposes
contentas a raw command. It parses its own arguments and then uses the same executor as programmatic callers. - The build system reads the resulting files on the next build. Mutation does not update build state or emit output.
- Content Mutation does not deploy, change publication state, or touch Git history.
Limitations
- It is a build-time tool, not a browser or public-site editing feature.
-
The
frontmatteroperation requires existing YAML Frontmatter in a Markdown file. A file without Frontmatter fails withThe Markdown file has no YAML frontmatter. -
Frontmatter is re-serialized. Comments and key order are kept where YAML
permits, but formatting can change. A
frontmatter-formattingwarning is expected. -
Deleting a file leaves inbound links in place. The warning code is
broken-link; no link is removed automatically. -
Writes are atomic per file, not across the whole batch. A crash can leave a
partially applied batch until
recoverruns. - There is no automatic rollback after a failed
apply. Recovery is explicit. - There is no merge or three-way conflict resolution. A changed precondition is always a conflict; re-plan the operation.
- Rollback requires a completed journal and unchanged current files. It is not a general undo for edits made outside the journal.
- Link rewriting covers the content model's supported Markdown and WikiLink destinations. It does not rewrite arbitrary text, code blocks, or external URLs.
- The lock is intended for local filesystems. It is not a distributed lock for multiple machines sharing a network volume.
- Journal and backup directories are not garbage-collected. Remove old plan directories manually when they are no longer needed for recovery.
- Content Mutation does not validate publication readiness, migration state, or the rendered site. Those belong to the build, migration, and publication systems.
CLI reference
riebeckite content <create|edit|delete|move> ... [--apply] [--json]
riebeckite content plan <create|edit|delete|move> ... [--out <name>] [--json]
riebeckite content apply <plan> --apply [--json]
riebeckite content recover <journal> --apply [--json]
| Action | Arguments | Behavior |
|---|---|---|
create |
<path> [body] |
Creates a file. The body defaults to an empty string. |
edit |
<path> <body> |
Replaces the whole file body. |
delete |
<path> |
Deletes the file. Inbound links remain and produce a warning. |
move |
<from> <to> |
Moves or renames a file and retargets affected links. |
plan prefix |
same as above | Saves the plan instead of applying it. --out names the file; otherwise the plan ID is used. |
apply |
<plan> |
Applies a saved plan. --apply is mandatory. |
recover |
<journal> |
Restores an interrupted journal. --apply is mandatory. |
--body <text> is also accepted and is equivalent to the second positional
argument. --json prints the plan or result as JSON. Without --apply, a
mutation command prints the plan and writes nothing. apply and recover print
their result as JSON even without --json.
API reference
Operations
ContentMutationOperation is a discriminated union:
type ContentMutationOperation =
| { kind: "create" | "asset-create"; path: string; content: string | Uint8Array }
| { kind: "update" | "asset-update"; path: string; content: string | Uint8Array }
| { kind: "delete" | "asset-delete"; path: string }
| { kind: "move" | "rename" | "asset-move"; from: string; to: string }
| {
kind: "frontmatter";
path: string;
set?: Readonly<Record<string, unknown>>;
remove?: readonly string[];
}
| { kind: "batch"; operations: readonly ContentMutationOperation[] };
create and update accept text or bytes. The asset-* variants are aliases
that make the binary intent explicit; all variants share the same validation.
frontmatter accepts only Markdown paths and permits nested keys such as
"meta.added". batch is flattened before planning, so nested batches run in
source order.
flattenContentMutationOperations(operations) removes batch nodes and is
exported for callers that need the effective operation list.
Plan and result types
type ContentMutationPlan = {
readonly schemaVersion: 1;
readonly id: string;
readonly operations: readonly ContentMutationOperation[];
readonly changes: readonly ContentMutationFileChange[];
readonly preconditions: readonly ContentMutationPrecondition[];
readonly warnings: readonly ContentMutationIssue[];
readonly conflicts: readonly ContentMutationIssue[];
};
type ContentMutationResult = {
readonly planId: string;
readonly applied: boolean;
readonly journalPath?: string;
readonly appliedPaths: readonly string[];
readonly conflicts: readonly ContentMutationIssue[];
};
ContentMutationFileChange records beforeFingerprint, afterFingerprint, and
the final bytes. preconditions is the same set as
{ path, fingerprint: beforeFingerprint }. An issue has severity, code,
message, and an optional path.
recover and rollback return ContentMutationRecoveryResult and
ContentMutationRollbackResult. Both report conflicts; rollback also reports
skipped when the journal is already rolled back.
FileSystemContentMutation
import { FileSystemContentMutation } from "@riebeckite/node";
const mutation = new FileSystemContentMutation(contentRoot);
const plan = await mutation.plan([
{ kind: "frontmatter", path: "notes/hello.md", set: { draft: false } },
]);
if (plan.conflicts.length === 0) {
const result = await mutation.apply(plan);
}
| Method | Behavior |
|---|---|
plan(operations) |
Reads the content tree, applies the operations in memory, rewrites affected links, and returns a plan. Writes no content files. |
apply(plan) |
Revalidates the plan, takes the lock, writes the journal and backups, then applies each file atomically. |
recover(journalPath) |
Rolls an interrupted journal back, or reports a conflict if a file changed after the interruption. |
rollback(journalPath) |
Restores a completed mutation only after every current file still matches its applied fingerprint. |
savePlan(plan, path?) |
Writes a serialized plan under <content>/.riebeckite/mutations/plans. |
loadPlan(path) |
Reads and validates a saved plan. |
The constructor accepts an optional second argument:
beforeWrite, beforeRecoveryRestore, and afterRecoveryCapture callbacks for
tests and fault injection. They are not needed for normal use.
More examples
Move a note and update its links:
const plan = await mutation.plan([
{ kind: "move", from: "notes/old.md", to: "notes/new.md" },
]);
const result = await mutation.apply(plan);
A move retargets resolving WikiLinks and Markdown links in every Markdown file that pointed at the old path. The same fingerprint checks apply to the rewritten files, so a concurrent edit is a conflict instead of a silent overwrite.
Batch a rename with frontmatter changes:
const plan = await mutation.plan([
{
kind: "batch",
operations: [
{ kind: "frontmatter", path: "notes/a.md", set: { category: "guide" } },
{ kind: "rename", from: "notes/a.md", to: "guides/a.md" },
],
},
]);
The final bytes are computed before writing. The journal records one change per affected path, including link updates.
Review, then apply:
const saved = await mutation.savePlan(plan);
const loaded = await mutation.loadPlan(saved);
const result = await mutation.apply(loaded);
apply re-plans the operations and compares the canonical result. If the content
tree changed after savePlan, the result is a stale-plan conflict.
Error handling
plan never throws for a user-level conflict. It returns errors in conflicts
and continues collecting other issues. apply returns applied: false when the
plan is stale, a precondition changed, or the plan already contains conflicts.
Conflict codes include:
| Code | Meaning |
|---|---|
invalid-operation |
The operation targets a missing file, an existing destination, or an unsupported kind |
unsafe-path |
Absolute, traversal, reserved, non-normalized, symlinked, or non-directory path |
case-collision |
Two paths differ only by case |
invalid-markdown-encoding |
A Markdown file is not valid UTF-8 |
link-target-ambiguous |
A link would resolve to more than one target after the mutation |
link-target-missing / link-broken |
A rewritten link no longer resolves |
link-target-changed / link-rewrite-conflict |
The link target moved or could not be rewritten deterministically |
concurrent-modification |
A file no longer matches its planned fingerprint |
stale-plan |
A saved plan no longer matches a fresh plan of the same operations |
unsupported-plan |
The saved plan has an unknown schema version |
Warnings do not block a write. frontmatter-formatting warns that serializing
the YAML document may reorder or reformat keys where YAML permits it.
broken-link warns that a deleted file still has inbound links.
A failed apply writes state: "failed" to the journal and throws. Files
written before the failure remain on disk until recover restores them. There is
no automatic rollback on failure; recovery is explicit.
Security and trust boundaries
-
All paths are relative logical content paths. Absolute paths,
.., empty segments, trailing dots or spaces, Windows device names, and paths that escape the content root are rejected. -
Existing parent directories are checked with
lstat. A symlink or non-directory in the path is anunsafe-pathconflict. Symlinks are also skipped when the content tree is scanned. - Planning is read-only. The lock, journal, backups, and plans are written only by explicit apply, save, recover, or rollback calls.
-
A single
<content>/.riebeckite/mutations/lockserializes writers. A lock whose owning process is gone is recovered; a malformed lock older than five seconds is also treated as stale. - Every write is atomic per file. This is not a cross-file filesystem transaction: a crash can leave a partially applied batch until recovery.
- Content is data. Mutation never evaluates Markdown, Frontmatter, or file content as JavaScript.
- The journal and backups contain file contents. They are local, not published, and must not be committed or served.
- Mutation only writes inside the content directory. It does not deploy, update publication state, or change Git history.
Troubleshooting
-
"Another content mutation is already applying or requires recovery": a
previous process died. Run
riebeckite content recover <journal> --applywith the journal reported by the failed run, or remove the lock only after verifying that no process owns it. - "The file no longer matches the planned fingerprint": edit and plan again. Do not apply an old plan to newly edited content.
- "The saved plan no longer matches a fresh validation": regenerate the plan. Saved plans are snapshots, not patches that can be rebased.
- "Content path traverses a symbolic link": replace the symlink with a real directory or choose another path. Mutation deliberately does not follow it.
- "The link target is ambiguous": disambiguate the link in the source note, or rename to a path that does not collide with another target.
-
"The Markdown file has no YAML frontmatter": add a
---block before using thefrontmatteroperation, or useupdatewith a complete new body. - Frontmatter keys changed order: YAML serialization is not byte-preserving. The warning is expected; review the diff before committing.