# Link following and deep links
> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).
- **A local link's `#fragment` is split off at classification time, not at dispatch.** `LinkTarget::LocalFile` carries `{ path, fragment }`, and `LinkTarget::parse` splits on the first `#` for both the bare-path and `file://` forms — every downstream question is asked of the path, and with the fragment attached `other.md#a-heading` has extension `md#a-heading`, so `is_markdown_path` fails and the link goes to `open::that` (issue #38). A remote `Url` keeps its fragment inline.
- **One resolver answers "which heading is this fragment", it matches the GFM slug exactly, and both link paths use it.** `ParsedDoc::heading_line` (behind `App::heading_line_for_fragment`) is a lookup in `ParsedDoc::heading_anchors`, used by the in-document `#anchor` jump, the cross-file deep link and the startup anchor. **Do not add a normalizing fallback** (one that resolves a hand-written `#Getting Started`): documents written in edamame travel, and a fragment that resolves only here ships broken to GitHub and every browser without ever failing locally (`only_the_gfm_slug_resolves_a_fragment`). `tests/heading_anchors.rs` pins the slugs, in-app and exported, to anchors GitHub rendered; extend its cases from GitHub's output, never edamame's. The one normalization is percent-decoding, because a browser does it too: a link copied from GitHub spells `# 日本語` as `#%E6%97%A5…` (`a_percent_encoded_fragment_resolves_a_non_ascii_heading`). It can't widen what resolves, since no slug contains a `%`. The doc-link tests (`tips`, `config_warning`) call it too, so they check what a click does. An empty fragment (`#`) is the top of the document, as in a browser; a heading whose slug is empty takes its duplicate count but no `heading_anchors` entry, so `#` can't land on it (`an_empty_anchor_goes_to_the_top`).
- **A deep link records one nav entry, not two.** `navigate_to_file` already pushes the origin as a `NavDest::File`, so the heading jump that follows calls `scroll_to_rendered_line` directly and deliberately does *not* `record_in_doc_jump` — otherwise one `NavigateBack` lands the reader at the top of a document they never saw.
- **The fragment must survive the dirty guard.** `DirtyGuardModal` must carry it alongside the pending destination (and through the save-as callback), or answering the guard opens the file at the top. Back/forward call sites pass `None` — a restored `NavEntry` has its own recorded scroll.
- **`navigate_to_file_at` owns the new document's viewport, so a caller must not re-assert cursor visibility on top of it.** It returns whether the file loaded, and the guard's Save and Discard arms skip their trailing `ensure_cursor_visible` on `true`: those calls correct the document the modal was *covering*. A fresh `EditorState` starts in `Mode::Preview`, where `scroll_to_rendered_line` moves `scroll` without moving the cursor — so an `ensure_cursor_visible` behind the jump sees the cursor at byte 0 above the viewport and scrolls back to line 0, discarding the fragment (`a_deep_link_answered_through_the_dirty_guard_still_lands_on_the_section`).
- **The command line splits its own `#section`, outside the parser.** `#` is a legal character in a file name, so `cli::split_startup_anchor` asks the disk (the literal path wins; only a path that doesn't exist gives its `#` up) and therefore cannot live in `Invocation::parse`, which is pure and unit-tested without an environment. `main` calls it between argument parsing and `App::new`; the injected-predicate inner function keeps the rule testable. It splits on the **last** `#`, so a directory carrying one still resolves.
- **The startup anchor is applied on the first frame, not in `App::new`** — the jump needs live document dimensions. `App::with_startup_anchor` parks it and `prepare_viewport` consumes it through `apply_startup_anchor` (the update notice's park-and-tick shape). It clears itself, so a later frame can't yank a reader who has scrolled away, and it records no nav entry.
- **A fragment naming no heading is reported, not swallowed** — the file opens and `App::flash` says the section wasn't found, since the document did load. The in-document `#anchor` path stays a silent no-op: nothing happened there at all.