Moving pages
October 21, 2026 · Claude
The page editor has a new button next to Delete: Move or rename. It
asks for the page the page goes below and the last part of its address.
Moving /about to /company does four things:
pages/about.mdbecomespages/company.md, andpages/about/team.mdbecomespages/company/team.md. Pages below a moved page move along.- Each moved page gets its old address in
redirect_from, so the old links from elsewhere on the web keep working. - Links to the moved pages are updated in every page, as are links
from the moved pages that are relative (
../contact.mdmay need another../from the new place). - If another page had
/companyin itsredirect_from, that entry goes: the address now belongs to a real page.
The code is in crates/qed-core/src/moving.rs.
First the plan, then the files
A move touches many files: the moved pages, and every page that links to
them. If something goes wrong halfway, the site is half moved. So
move_page works in three steps:
let moves = plan?; // check everything
// then: rewrite contents in the old places
// then: rename the files
plan builds a list of Move { old_path, new_path, old_file, new_file }
and fails before anything is written if a new address belongs to
another page, or a file is in the way. The ? returns the error at once.
Only when the plan stands does q.e.d. write anything.
Elixir has a tool for this shape of work:
Ecto.Multi collects steps and
runs them in one database transaction. A file system has no transactions,
so q.e.d. does the next best thing: all the checks that can fail come first,
and the writes come after. What's left is a disk error halfway, which
would name the file. For that case, the app closes the open version
before a move ("Before moving /about"), as it does before deleting a page,
so the Versions tab has the site as it was.
Rewriting links without rewriting the text
The links are in Markdown that people wrote by hand. q.e.d. reads Markdown with comrak, which could also write it back out, but in its own style: different list markers, different line breaks. That would change text the user didn't touch, the same problem as with the TOML (see Saving without breaking).
So comrak only finds the links (markdown::link_targets), and a second
function, markdown::replace_link_targets, changes only those targets in
the original text. It walks the text and looks for the three places a
Markdown link target can start:
let found = if bytes == b']' && bytes.get == Some else if bytes == b'<' else if i == 0 || bytes == b'\n' else ;
When the target at that place is in the map of changes, the new one goes in; everything between stays as it was, byte for byte.
The map is applied in one pass. With two changes, /about → /company
and /company → /never, replacing one after the other would turn
/about into /never. In one pass each place is looked at once, and what
was just written is never looked at again. The test
replaces_only_link_targets checks exactly that.
Why cutting at bytes is safe: UTF-8
A Rust String
is UTF-8: an "ü" takes two bytes, an emoji four. Indexing a string by
position is therefore not allowed in Rust (text[3] doesn't compile), and
slicing with byte positions, &text[a..b], panics if a or b falls in
the middle of a character.
The scan above looks at
as_bytes(),
the raw bytes, and later slices the string at the positions it found. That
is safe because of how UTF-8 is built: every byte of a multi-byte
character is 128 or higher, and every ASCII character (], (, <,
newline) is below 128. So an ASCII byte is always a whole character, and
a position right before or after one is always a character boundary. The
code compares only ASCII bytes, so every cut it makes is clean. The test
feeds it "Ünïcode" to make sure.
Elixir strings are UTF-8 binaries too, and pattern matching on bytes
(<<"](", rest::binary>>) relies on the same property. See
Binaries, strings, and charlists.
Where a position might not be a boundary, Rust offers
str::get,
which returns None instead of panicking; destination uses it for the
start position it gets passed.
A relative path in five lines
A moved page that links with ../contact.md needs a new relative path
from its new folder. relative(from, to) splits both paths into their
parts, counts how many parts they share at the start, and climbs up for
the rest:
let common = from.iter.zip.take_while.count;
let mut result = vec!;
result.extend;
zip
walks two lists side by side,
take_while
stops at the first difference, and vec![x; n] makes a list of n
copies. From pages/about to pages/contact.md they share pages, so
the result is ../contact.md.
One list of where Markdown lives
Blocks keep Markdown in several fields: a Text block's text, a Hero's
text, an image caption, FAQ answers. Moving assets from media/ to
assets/ already had a function listing all of them,
for_each_markdown. It now lives in block.rs and both features use it,
so a new block type with Markdown in it is added in one place, and the
compiler's exhaustive match (see
Media, and the compiler as a checklist)
points there.