> For the complete documentation index, see [llms.txt](https://ascribed-dev.com/docs/llms.txt). This documentation describes Ascribe 0.4.4. Anything marked in main, not yet released isn't in that release yet.

# Keep the addresses you've published

> How-to · Record where a page went when it moves or goes, so a link to its old address still leads somewhere: ascribe.addresses, the check that compares with the base, and the redirects the build writes.

A link to a page outlives the page's place in the docs: a bookmark, a search result, a product's help menu, an answer on a forum. Rename a folder or merge two pages, and every one of those links stops working unless someone decided where it should lead. `ascribe.addresses` is where that decision is written. With it, a move records itself, `ascribe check` reports an [address](https://ascribed-dev.com/docs/glossary.md#address) the docs published that nothing accounts for, and the build writes the redirects the host needs.

## Turn it on

A project [`ascribe init`](https://ascribed-dev.com/docs/install.md#ascribe-init) started has it already: init writes `ascribe.addresses` beside `ascribe.toml`, so commit it with the docs, and its addresses are kept from the first commit.

A project started before that, or without `init`, makes the file beside `ascribe.toml`, and commits it with the docs:

```console
$ ascribe addresses init
Wrote ascribe.addresses. Moves are recorded from now on.
```

A project without the file records nothing, and the check compares nothing, so deleting it is the decision to stop keeping the addresses.

The editor offers it too. After you move a page of a project without the file, in the Explorer or in Pages, VS Code asks once for the project whether to **Keep the addresses you've published**. Choose it, and the extension runs `ascribe addresses init`, then `ascribe addresses move` for each page that moved, so the move that prompted it is recorded. Dismissed, it isn't asked again for the project.

## `ascribe.addresses`

The file says where each address the docs published went. Ascribe writes it, each table sorted, one entry per line; you edit only a retired entry's reason:

```toml
# Where addresses the docs published went. Written by `ascribe rename`, the
# editor, `ascribe addresses`, and `ascribe check --fix`; edit only
# [retired]'s reasons.
version = 1

[moved]
"/docs/intro.html" = "getting-started.md"
"guides/install.md#install-with-npm" = "guides/install.md#install-npm"
"guides/keys.md" = "security/keys.md"
"go:agent-settings" = "go:settings"
"go:rotate-keys" = "security/keys.md#rotate-keys"

[retired]
"go:legacy-sync" = "The sync agent was removed in 4.0"
"reference/legacy-sync.md" = "The sync agent was removed in 4.0"
```

- **`[moved]`:** where each key's addresses lead now. A key is a page's path from the content root, which stands for every address the page was published at, its Markdown version's too; a file's path, `#`, and a heading's id, with the file the `@id` is written in, a [fragment](https://ascribed-dev.com/docs/glossary.md#fragment)'s once for every page that includes it; `go:` and a [permalink](https://ascribed-dev.com/docs/permalinks.md)'s name, for one no heading or page declares any more; or an address from the host's root, starting with `/`, from before the project used Ascribe. A value is a page's path, and optionally `#` and a heading's id, or, for a `go:` key, `go:` and another [permalink](https://ascribed-dev.com/docs/glossary.md#permalink)'s name, which makes the key an alias of it ([when a section moves or goes away](https://ascribed-dev.com/docs/permalinks.md#when-a-section-moves-or-goes-away)).
- **`[retired]`:** the addresses removed on purpose, each key as `[moved]`'s, with why. A reader who follows an old link gets [a page that says what was removed and why](https://ascribed-dev.com/docs/publishing/redirects.md#a-retired-addresss-page), answering 404; a retired permalink's address shows the reason.
- **`[former]`:** the routing settings the docs were published under before, each oldest first: `base-path`, `permalink-path`, and `agents`, each a list of values, and `builds`, a table from a build's old name to its new one. [`ascribe check --fix` writes it](https://ascribed-dev.com/docs/publishing/redirects.md#a-settings-change) when a setting changes.

Its format is in [SPEC §7.10](https://github.com/ascribed-dev/ascribe/blob/main/SPEC.md#710-the-addresses-file).

## What a move records

`ascribe rename`, and moving a page in the editor, record the move in the same change as the links they update:

```console
$ ascribe rename content/guides/my-setup.md content/guides/setup.md
moved content/guides/my-setup.md to content/guides/setup.md
recorded the move in ascribe.addresses
```

```toml
[moved]
"guides/my-setup.md" = "guides/setup.md"
```

Moving a page back to where an entry says it was removes the entry. A page moved another way, as with `git mv`, is recorded with [`ascribe addresses move`](https://ascribed-dev.com/docs/publishing/redirects.md#ascribe-addresses-move), and a page removed on purpose with [`ascribe addresses retire`](https://ascribed-dev.com/docs/publishing/redirects.md#ascribe-addresses-retire).

## What the check reports

With the file, `ascribe check` lists every address the base published, in each build, and every address the working tree publishes, and reports what the base had that nothing accounts for. A build's addresses are each page's, its Markdown version's, each heading's explicit `@id`, each [permalink](https://ascribed-dev.com/docs/permalinks.md)'s under the permalinks' path, and each `llms.txt` file's. An address is accounted for when the working tree still publishes it, when an entry of `[moved]` leads it to a page a build publishes, when `[retired]` has it, or when an [availability](https://ascribed-dev.com/docs/glossary.md#availability) mark takes its page out of one build while another build publishes it.

- **[`address-lost`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc255-address-lost)**, a warning: a page's address, or its Markdown version's, once for each page, listing its addresses and, when it isn't every build, the builds. Forward it with `ascribe addresses move`, or retire it with `ascribe addresses retire`.
- **[`anchor-lost`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc256-anchor-lost)**, advice: a heading's explicit `@id` the base published on a page still published, which the page doesn't have now. A link to it lands at the top of the page, so it's advice for now. A generated id isn't kept: it changes with the heading's text.
- **[`addresses-build-gone`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc258-addresses-build-gone)**, advice: a build the base has and `ascribe.toml` doesn't, whose addresses aren't compared. A line's build isn't reported once `[releases]` archives the line: it left on purpose, and [`ascribe versions retire`](https://ascribed-dev.com/docs/versions/archive.md#4-retire-the-line) records where its addresses go.

```console
$ git rm content/guides/teams/share-a-loom.md && ascribe check --base main --deny-warnings
[ASC255] Warning: guides/teams/share-a-loom.md was published at /docs/guides/teams/share-a-loom and /docs/guides/teams/share-a-loom.md, and nothing publishes them now
```

Each is reported at `ascribe.addresses`. The comparison runs when `ascribe check` checks the whole project, not for a page or a file named. In the editor, see [below](https://ascribed-dev.com/docs/publishing/redirects.md#in-the-editor).

## In the editor

The language server compares the addresses with the base's between checks, not as you type in a page: when the project loads, after a save, after a change on disk, as a page deleted in the Explorer or a `git checkout` is, and as you edit `ascribe.addresses`. Delete a page, and within seconds its lost addresses are in the Problems panel at `ascribe.addresses`, as [Check Every Build](https://ascribed-dev.com/docs/editor/problems.md#the-problems-panel) would put them, with their quick fixes, and the Project view's Health counts them under [Against the base](https://ascribed-dev.com/docs/editor/sidebar.md#addresses-lost). Check Every Build replaces them with its own when it runs. The server keeps the base's addresses for each base commit and version of Ascribe, so a comparison after the first doesn't read the base again.

Moving a page in the editor puts its entry in the same edit as the links it updates, unsaved in `ascribe.addresses` until you save: an entry you haven't saved counts, and **Undo** before you save takes back the move and its entry together. A page moved back after its entry is saved, as `git mv` would, leaves the entry, which [`addresses-entry-live`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc266-addresses-entry-live) reports. On a page that old addresses lead to, This page's About lists each under [Also answers at](https://ascribed-dev.com/docs/editor/sidebar.md#old-addresses).

## Fix a lost address

Each lost address has a fix, an edit to `ascribe.addresses`, offered by the check, by `ascribe check --fix` where it's safe, and as quick fixes on the problem in the editor:

- **Record git's renames,** when git sees the page renamed, as `git mv` leaves it: the move is recorded as `ascribe addresses move --from-git` records it. It's safe, since git's rename is the decision, so `ascribe check --fix` applies it.
- **Forward it to…** the page most like it, by its headings and words as the base had them, and the next two most like it: `ascribe addresses move <old> <new>`. For a heading's id, the same id on another page. It's a choice, so it's never applied for you.
- **Retire it, and write why:** the entry, with a placeholder for its reason, which is reported until you replace it.

```console
$ git mv content/guides/setup.md content/start/setup.md && git rm -q content/reference/options.md
$ ascribe check --base main
[ASC255] Warning: guides/setup.md was published at /docs/guides/setup and /docs/guides/setup.md, and nothing publishes them now
   │ Help: git sees it renamed to start/setup.md: record git's renames, as `ascribe addresses move --from-git` does
[ASC255] Warning: reference/options.md was published at /docs/reference/options and /docs/reference/options.md, and nothing publishes them now
   │ Help: Forward it to the page most like it, guides/configure.md: `ascribe addresses move reference/options.md guides/configure.md`
$ ascribe addresses move --from-git
Recorded 1 move git sees: guides/setup.md → start/setup.md.
```

`--format json` lists every fix of each problem, with its edits.

## The file's checks

With the base read, `ascribe check` also checks that each entry of `ascribe.addresses` still does what it says. Each is reported at its entry:

| Diagnostic | Level | When |
| --- | --- | --- |
| [`addresses-entry-not-a-page`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc265-addresses-entry-not-a-page) | Error | A `[moved]` value isn't a page, or a heading on one, or a build that published the key doesn't publish it |
| [`addresses-entry-live`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc266-addresses-entry-live) | Advice | The key's source is back, as after a page is restored; the fix removes the entry |
| [`addresses-entry-taken`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc267-addresses-entry-taken) | Error | Another page publishes an address the key had, so an old link lands on it |
| [`addresses-entry-unpublished`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc268-addresses-entry-unpublished) | Advice | The base never published the key, and its file doesn't have the entry, as moving a page made on the same branch leaves; the fix removes it |
| [`addresses-key-twice`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc269-addresses-key-twice) | Error | A key is in the file twice, as a merge can leave; the file isn't used until one goes |
| [`addresses-chain`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc270-addresses-chain) | Advice | A `[moved]` value is another entry's key; the build writes where the chain ends, and the fix forwards it there |
| [`addresses-anchor-collision`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc271-addresses-anchor-collision) | Error | An id from a page that's gone is live on the page its page's addresses lead to, so a link to it lands there |
| [`addresses-file-deleted`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc272-addresses-file-deleted) | Error | `ascribe.addresses` is gone, and the base has it; read only when `--base` names the base, as CI does |
| [`addresses-soft-404`](https://ascribed-dev.com/docs/checks/diagnostics/project-files.md#asc273-addresses-soft-404) | Advice | Three addresses or more are forwarded to one index page or the home page |

Retiring a page that entries lead to retires them with it, for the same reason, and moving a page takes the entries that lead to its headings with it.

To stop keeping the addresses on purpose, delete the file in a pull request of its own, whose check compares with its own head, `ascribe check --base HEAD`.

## A settings change

Changing a setting that every address is made with moves every address at once: `base-path`, `permalink-path`, or `agents` in `[consumer]`, or a build's name. The check says so once, as `address-lost`'s settings message, naming the setting, its old and new values, and how many addresses it moved, in place of a warning for each page. Its fix records the old value in `[former]`. It's safe, since changing the setting is the decision, so `ascribe check --fix` applies it:

```console
$ ascribe check --base main
[ASC255] Warning: base-path changed from "/docs/" to "/handbook/", so 14 addresses moved, 5 of them permalinks
   │ Help: Record the settings change: `ascribe check --fix`
$ ascribe check --fix && ascribe build
```

```toml
[former]
base-path = ["/docs/"]
```

The build then forwards every address under each former value to the same source's address today, from `ascribe.addresses` alone, so it keeps doing so once the base has moved past the change:

- **`base-path`:** each page's address, its Markdown version's, each `llms.txt` file's, and each permalink's while `permalink-path` is its default, `go/` under the base path. A `[moved]` entry's old addresses are forwarded under each former base path too.
- **`permalink-path`:** each permalink's address.
- **`agents`,** turned off: `agents = [true]` forwards each Markdown version to its page, and each `llms.txt` file to the page at the base path.
- **`builds`:** the build of the new name is compared with the base's build of the old one. A build is taken for renamed when the base's build is gone and a new build publishes more than half of its pages.

A second change composes with the first: under `base-path = ["/docs/", "/guide/"]`, an address under either forwards straight to today's, in one step. A page lost in the same change, as one deleted with it, is still a warning of its own. `trailing-slash` isn't kept: a host matches an address with or without its final `/`.

## The base

The base is what the docs published before: the merge base of the repository's default branch and `HEAD`, as a pull request shows its changes, or the revision `--base` names. `--base-exact` compares with that revision itself. It's read as [`ascribe diff`](https://ascribed-dev.com/docs/review/diff.md) reads it, so the checkout needs the history: in CI, `fetch-depth: 0`, as [Checks in CI](https://ascribed-dev.com/docs/checks/ci.md#keep-the-addresses-youve-published) sets up.

When the base can't be read, because the revision isn't there, as in a shallow clone, or there's history but no default branch to compare with, or `--base` names a base outside a repository, the rest of the check runs and [`addresses-base-unreadable`](https://ascribed-dev.com/docs/checks/diagnostics/project-checks.md#asc257-addresses-base-unreadable), a warning, says why, so a check that compared nothing never passes silently. A base with no project, as a project's first pull request has, published nothing to keep, and that's advice. Before the first commit, with no `--base`, nothing can have been published: a project outside a git repository, or in one with no commit yet, compares nothing, and the check doesn't say so, so a project `ascribe init` just started checks clean.

## What the build writes

Each build writes `_ascribe/redirects.json` in its site output, from `ascribe.addresses` alone: for each `[moved]` entry, a `301` from each address the key had, the page's and its Markdown version's, to where the value is now; for each `[former]` value, a `301` from each address under it to today's, as [a settings change](https://ascribed-dev.com/docs/publishing/redirects.md#a-settings-change) says; and for each `[retired]` entry, its addresses and the reason. The base isn't read, so a redirect stays once the base has moved past the move. Its format is in the [output-layout contract](https://ascribed-dev.com/docs/contracts/output-layout.md#18-the-redirects).

```console
$ ascribe addresses list
site:
  /docs/guides/my-setup -> /docs/guides/setup (301, guides/my-setup.md)
  /docs/guides/my-setup.md -> /docs/guides/setup.md (301, guides/my-setup.md)
```

**On Netlify,** with `host = "netlify"` in `[consumer]`, each build writes Netlify's `_redirects` too, and `@ascribed/astro` places it at the root of the built site, ahead of a site's own `public/_redirects` ([the host's files](https://ascribed-dev.com/docs/publishing/site.md#the-hosts-files)):

```text
/docs/guides/my-setup /docs/guides/setup 301
/docs/guides/my-setup.md /docs/guides/setup.md 301
```

The same file forwards each [permalink](https://ascribed-dev.com/docs/permalinks.md)'s address, as [below](https://ascribed-dev.com/docs/publishing/redirects.md#permalinks-on-netlify).

**On another host,** read `_ascribe/redirects.json` after the build and give each pair to the host's own configuration.

## A retired address's page

A reader who follows a link to a retired address gets a page that says what was there was removed, and why, with a link to the docs' home, answering 404, so search engines drop it. `@ascribed/astro` adds one for each address in `redirects.json`'s `gone`, marked `noindex`, in `_ascribe/gone/` under the build's base path, at the address from there, and the build's Netlify `_redirects` serves it at the old address:

```text
/docs/reference/legacy-sync /docs/_ascribe/gone/reference/legacy-sync 404
```

Another site generator reads `gone` from `_ascribe/redirects.json` and makes its own page for each.

## Permalinks on Netlify

Available: Ascribe (in main, not yet released, 0.4.5)

With `host = "netlify"`, the `_redirects` the build writes forwards each permalink's address with a `301`, straight to its page and heading, so a fetcher that doesn't follow the forwarding page's refresh, as `curl` and most agents' tools don't, still lands there in one step. Netlify keeps the heading's anchor. The forwarding page stays, for a host without redirects:

```text
/docs/go/connect/ pm=:pm /docs/go/connect/ 200!
/docs/go/connect/ /docs/install-agent/#connect 301!
/docs/go/legacy-sync/ /docs/go/legacy-sync/ 410!
```

- **An address with a [dimension](https://ascribed-dev.com/docs/content-model/variation.md#11-dimensionsname)'s parameter,** such as `?pm=pnpm`, gets the forwarding page instead, whose script sets the reader's choice before it forwards: a line for each [dimension](https://ascribed-dev.com/docs/glossary.md#dimension), ahead of the `301`, since Netlify uses the first rule that matches.
- **A retired permalink's address** serves its page, which says why it was removed, with a `410`, which tells a fetcher it's gone on purpose.
- **`!`** forces each rule past the forwarding page at the address, which Netlify would serve otherwise.

The lines come after the moves and the retired addresses, and the [output-layout contract](https://ascribed-dev.com/docs/contracts/output-layout.md#19-the-hosts-files) has their format. On another host, read `_ascribe/permalinks.json` and write the same rules for it.

## `ascribe addresses`

Keeps `ascribe.addresses`. Each takes `--format json`, and writes the document its [schema](https://ascribed-dev.com/docs/contracts/json-reports/addresses.md#ascribe-addresses-init) describes. Each exits with `0` when it's done, and `2` when it couldn't run: the project doesn't load, or the file can't be read or written.

### `ascribe addresses init`

Makes `ascribe.addresses`, with its header, its `version`, and empty tables. A file already there is left as it is, and it says so.

In a project whose `ascribe.toml` still has `[permalinks]`, from before `ascribe.addresses` recorded permalinks, it moves the table's entries into the file, made or already there, and takes the table out of `ascribe.toml`, keeping the rest of the file as written: each of `[permalinks.retired]` as `go:<name>` in `[retired]`, or in `[moved]` to its `to` when it has one, and each of `[permalinks.aliases]` as `go:<name>` in `[moved]` to `go:` and the name it leads to. Run `ascribe permalinks sync` after, when the project has `ascribe.permalinks`. A base whose `ascribe.toml` still has the table is read as if it had been moved, so the move itself loses no address.

- `--dry-run`: Say what it would change, and change nothing.
- `--force`: Change files even when git couldn't put them back as they are: ones with changes not committed, ones git doesn't track, or ones in no repository.
- `--format <FORMAT>`: How to show what was done.
  - `text` (the default): Short text, for people and agents to read.
  - `json`: One JSON document, for tools.

### `ascribe addresses move`

Records that a page, or a heading's id, moved, after a move made without Ascribe, as with `git mv`. The old path is gone; the new one is a page of the project, or a heading on one. A permalink no heading declares any more, `go:<name>`, moves to a page or a heading, or to another permalink, `go:<name>`.

```console
$ ascribe addresses move guides/keys.md security/keys.md
Recorded in ascribe.addresses: guides/keys.md moved to security/keys.md.
```

Each page's path is from the current folder or from the content root, and the entry is from the content root. So, from a repository's root whose project is in `docs/` with its content in `docs/content/`, `ascribe addresses move --config docs docs/content/guides/keys.md docs/content/security/keys.md` records the same entry, and so does `ascribe addresses retire` given such a path.

**`--from-git`** records every move git sees between the base and the working tree whose old path is gone, one entry for each page, so a reorganization made with `git mv` is recorded at once. The base is chosen as `ascribe check` chooses it, with `--base` and `--base-exact`. Git sees a file that's renamed and added, as `git mv` leaves it, and only when the old and new files are mostly the same: a page rewritten as it moved is forwarded with `ascribe addresses move <old> <new>`.

```console
$ ascribe addresses move --from-git
Recorded 1 move git sees: guides/setup.md → start/setup.md.
```

- `[OLD]`: Where it was: a page's path, from the current folder or from the content root; a file's path, `#`, and a heading's id; an address from the host's root, starting with `/`, from before the project used Ascribe; or a permalink, `go:<name>`, nothing declares now.
- `[NEW]`: Where it is now: a page's path, from the current folder or from the content root, and optionally `#` and a heading's id; or, for a permalink, another permalink, `go:<name>`.
- `--from-git`: Record every move git sees between the base and the working tree whose old path is gone, one entry for each page, as after a reorganization made with `git mv`. Git sees a file renamed and added, and only when the old and new files are mostly the same.
- `--base <REV>`: With `--from-git`, the revision git's renames are seen from: anything git accepts. By default, the repository's default branch, from its merge base with `HEAD`, as `ascribe check` chooses it.
- `--base-exact`: With `--from-git`, see the renames from the base revision itself instead of its merge base with `HEAD`.
- `--dry-run`: Say what it would change, and change nothing.
- `--force`: Change files even when git couldn't put them back as they are: ones with changes not committed, ones git doesn't track, or ones in no repository.
- `--format <FORMAT>`: How to show what was recorded.
  - `text` (the default): Short text, for people and agents to read.
  - `json`: One JSON document, for tools.

### `ascribe addresses retire`

Records that a page, or a heading's id, or a permalink, `go:<name>`, was removed on purpose, with the reason, which is for whoever reads the file later.

```console
$ ascribe addresses retire reference/legacy-sync.md --reason "The sync agent was removed in 4.0"
Recorded in ascribe.addresses: reference/legacy-sync.md was retired.
```

- `OLD`: What was removed: a page's path, from the current folder or from the content root; a file's path, `#`, and a heading's id; an address from the host's root, starting with `/`; or a permalink, `go:<name>`, nothing declares now.
- `--reason <TEXT>`: Why it was removed.
- `--dry-run`: Say what it would change, and change nothing.
- `--force`: Change files even when git couldn't put them back as they are: ones with changes not committed, ones git doesn't track, or ones in no repository.
- `--format <FORMAT>`: How to show what was recorded.
  - `text` (the default): Short text, for people and agents to read.
  - `json`: One JSON document, for tools.

### `ascribe addresses list`

Shows the redirects and retired addresses each build writes, as its `_ascribe/redirects.json` has them, and the entry of `ascribe.addresses` each comes from. It changes nothing.

- `--build <NAME>`: Show only this build. By default, every build in `ascribe.toml`.
- `--format <FORMAT>`: How to show the redirects.
  - `text` (the default): Short text, for people and agents to read.
  - `json`: One JSON document, for tools.
