Skip to content

ADR 0006 — Page addresses: what identifies a page, and what moving or copying one may do

  • Status: Accepted
  • Date: 2026-09-12
  • Context repos: cratly.io site (this repo), editor, scavold

Problem

A live site stopped building, and stayed broken for every commit that followed.

pages/de/footer/kontakt.md declared url: de/kontakt — a footer entry served at the contact page's address. Months later the editor created pages/de/kontakt.md. Two files now claimed one address. VitePress identifies a page by its path flattened into a single name, so the two overwrote each other's bundle entry, and the build died at render time in pageChunk.imports naming no file at all.

Three things about that failure decided this ADR:

  1. It took one file in one commit. The editor writes one file per commit, and that is no protection: the conflict is not inside the change, it is between the change and something that was already there. Whoever added the page had no way to see the declaration that made it a duplicate — it sits in another file's front matter, not in a path.
  2. Nothing could explain it to the author. The adapter refuses the pair when it reads the site configuration, which is the right moment — but that is before anything is built, so no build report exists, and the reason survives only in a CI log the author cannot read and would not understand.
  3. The address, not the file path, is what collides. A rule about file names would have missed it entirely.

Moving and copying pages raise the same question in sharper form, and the editor is about to offer both.

Decision

1. An address identifies a page, and belongs to exactly one

A page's effective address is its url front matter when it declares one, and the address derived from its file path otherwise. No two pages may claim the same effective address. This holds regardless of the framework rendering the site, and it is the rule the editor enforces while the author is typing — when creating a page, and when writing a url alias.

An adapter may identify pages more coarsely than their addresses: VitePress flattens / to _ and folds case, so de/kontakt.md and de_kontakt.md are one page to it. That is the adapter's rule, the adapter states it, and it must be checkable before the build — not as a build failure. The editor applies it where it knows it; where it does not, it says so rather than passing silently.

2. The check runs over the resulting set, never over one path

Creating a page compares one new address against the existing ones. Moving or copying a folder changes many pages at once, and a per-path check against the current state gets both halves wrong: it reports conflicts with pages that are themselves moving, and it misses conflicts that arise between two moved pages.

So the operation is projected first — the page set as it will be once the change is applied — and the whole set is checked at once. Every colliding pair is named, both sides of it.

This is not a matter of degree. A collision breaks the build for every commit that follows, not only the one that caused it, so it is refused outright. There is no dialog and nothing to acknowledge.

3. References follow a move

Moving a page changes its address, and every reference to the old one dies. Two directions, and the second is the one that gets forgotten: references to the moved pages from anywhere in the site, and relative references inside the moved pages, whose targets stayed where they were while the referring file moved. For a folder, the second set is usually the larger one.

Both are rewritten as part of the move. This is mechanical rather than heuristic, because references are declared: a link in prose, or a field the section-type manifest types as page-ref or media-file (see ADR 0001). What cannot be resolved is listed; what was rewritten is counted.

Leaving the references broken and letting the build enumerate them is not an acceptable default. It converts one deliberate action into a list of failures to be worked through one by one, to arrive at the state the move should have produced.

4. A copy inherits no address

A copy carries over no url, no declared legacy address and no redirect. Every one of them can belong to only one page, and a copy that inherits one collides on the next build — the failure described above, reproduced deliberately.

The copy is therefore served at the address derived from its path, which the uniqueness check already covers. Moving an address to the copy is a different operation: it has to be given up on the original first, and that is an explicit second step.

5. What becomes of the old address is the author's decision

An address that a page gives up may be one the site must keep answering. That is a content decision, not a technical one, and it is taken where the move is triggered — along the tiers of ADR 0005: keep the address with url, declare it and let the server redirect, or give it up knowingly.

6. A refusal the check could not prevent still arrives as data

Where an adapter rejects a site before building it, it writes its findings to .cratly/build-report.json before it throws, in the shape the editor already reads. A failure the author cannot be shown is a failure they cannot fix.

Consequences

  • The editor needs a set-level check next to its existing per-path one — the counterpart to the adapter's own, applied to a projected page set.
  • The adapter's page-identity rule has to reach the editor as data rather than as reimplemented code. Today it is written out a second time in the editor; that is tolerable while scavold is the only adapter and is the first thing to break when it is not.
  • Moving a page is no longer a file operation. It rewrites other files, and it asks one question, so it cannot be a drag and drop that silently succeeds.
  • Copying is cheap and safe, because the addresses are exactly what it does not take along.

See also