Skip to content

Markdown viewer: hide own-line <a id>/<a name> anchor tags (currently rendered as literal text) #13982

Description

@fbartho

Problem

As of #13962, a #fragment link can resolve and scroll to an explicit <a id="x"></a>/<a name="x"></a> anchor target, in addition to heading slugs. This works via a click-time text
scan over the buffer (find_matching_anchor_tag, app/src/notebooks/editor/model.rs) — no
content-model change was needed for resolution because the phase-1 inline parser only
recognizes <a href> as a token (it requires an href attribute to match); a bare <a id>/<a name> tag with no href falls through to literal text, so its raw markup — including the id
value — survives verbatim in the buffer and is scannable at click time.

That same fact is why the tag is currently visible. GitHub and browsers render an anchor tag
with no text content as nothing at all — it's metadata, not content. Warp's Markdown viewer
currently renders <a id="custom-anchor"></a> as the literal string <a id="custom-anchor"></a>
inline in the document. This is functionally harmless (resolution still works) but reads as a
visible authoring artifact where GitHub shows nothing — worse for the exact
hand-built-table-of-contents authoring pattern (#13725) that motivated the anchor feature in the
first place, since users would see raw tag soup at every anchor point.

Why it's not small

Making the tag disappear from the rendered view means removing it from the block's own inline
text, which means promoting it out of "ordinary paragraph text" into some new first-class
concept — but nothing in the current content model has a slot for "attaches to a block, renders
nothing, but must round-trip on save." Concretely:

  • BufferBlockItem (crates/editor/src/content/text.rs:398HorizontalRule, Embedded,
    Image) is a whole-block replacement type: each variant occupies an entire block/line via
    BlockType::Item. It's exclusive, not additive — wrong shape for "metadata riding alongside
    the next block's own style."
  • BufferBlockStyle (text.rs:867Header, PlainText, UnorderedList, OrderedList,
    TaskList, Table, CodeBlock) is the enum actually carried by every block's marker
    (BufferText::BlockMarker { marker_type: BufferBlockStyle }, text.rs:564). It's the natural
    place to conceptually attach "this block also has anchor ids," but every variant is
    destructured by named field literal (e.g. BufferBlockStyle::Header { header_size }), not
    .., at its construction/match sites. A grep for BufferBlockStyle::Header { alone turns up
    matches across a dozen files (core.rs, edit.rs, markdown.rs ×4, buffer.rs,
    render/element/header.rs, render/model/mod.rs, app/src/notebooks/editor/model.rs, plus
    roughly 40 individual match/construct sites in buffer_tests.rs alone) — and that's one
    variant of seven.
  • BufferText::BlockMarker itself is the alternative attachment point (a field alongside
    marker_type, wrapping all block kinds at once — smaller conceptually since it's one type
    instead of seven). But the BlockMarker { marker_type } shorthand pattern is used at roughly
    60 call sites across core.rs, buffer.rs, outline.rs, cursor.rs, find.rs,
    validation.rs, and every hand-built test fixture that constructs a buffer by hand
    (find_tests.rs, cursor_tests.rs, validation_tests.rs). Every one breaks without .. or
    an added field, since Rust's struct-pattern matching requires it.
  • Upstream of storage, there's no parser-side concept of an anchor-only line yet either.
    markdown_parser's FormattedTextLine (one variant per line/block kind — Heading, Line,
    CodeBlock, Table, UnorderedList, …) has no dedicated variant for "this line is a bare
    anchor tag, attach it to whatever follows." An own-line <a id="x"></a> currently parses as an
    ordinary FormattedTextLine::Line (a plain paragraph). Block-attachment therefore also needs
    either a new FormattedTextLine variant plus a lowering rule in the core.rs edit-application
    loop (~core.rs:567+, where each FormattedTextLine is turned into BufferText pushes), or a
    post-parse stitching pass that recognizes an anchor-only line and merges it into the next real
    block before storage.

Rough estimate: 70-130+ call sites touched across core.rs, edit.rs, buffer.rs,
markdown.rs, render/, plus every hand-built test fixture. This is a genuine content-model
migration, not a follow-up-sized patch — hence a ticket for design discussion, not a quick PR.

The core constraint

The Markdown viewer's buffer is editable and serializes back to Markdown on save
(BufferMarkdownParser::to_markdown, crates/editor/src/content/markdown.rs:60, which walks
each block's style and re-emits its prefix — #, * , ```, etc. — before the block's
text). Anything that's supposed to render as invisible metadata but isn't represented in the
content model doesn't survive that round-trip: type the tag, load the document, it renders
nothing (because it was never parsed into anything to render), the user saves, and the original
<a id="x"></a> text is now gone from the file, because nothing carried it forward into
to_markdown's output. Any hiding mechanism must be paired with a serialization path that
re-emits the hidden markup, or it's silent data loss on first edit+save
— this is the hard
requirement any design here has to satisfy, not an optional nice-to-have.

This is the same shape of problem raw HTML comments (<!-- ... -->) will face if/when comment
stripping is proposed (tracked separately, referenced here as #13977) — "render nothing, but
don't lose it on save" is a generic content-model gap, not something specific to anchor tags.
Worth solving with one shared mechanism (e.g. a general "opaque/invisible markup" block or
inline concept) rather than a bespoke one-off for anchors, if the two land close together.

Candidate approaches

  • Field on BufferText::BlockMarker (e.g. anchor_ids: Vec<String> alongside
    marker_type). Pro: touches the block-kind-agnostic wrapper once instead of per-variant. Con:
    still ~60 call sites to update (most just need .. or an empty default, but that's still a
    wide, low-value diff to review), and every future BlockMarker construction site must
    remember the field exists.
  • Field per BufferBlockStyle variant. Pro: keeps the field conceptually scoped to "this
    specific block's own style," possibly with different semantics per kind if that ever matters.
    Con: 7× the sites of the BlockMarker option, since each variant's construction/match sites
    need updating independently; no shared default via .. unless every arm opts in. Not
    recommended given the multiplier cost with no clear semantic benefit over the previous option.
  • Dedicated metadata "sidecar" — a side-channel map (e.g. CharOffset -> Vec<String> of
    anchor ids) stored on Buffer alongside the sum-tree content, updated on edit via the same
    invalidation hooks other derived state uses. Pro: zero changes to BufferBlockStyle/
    BlockMarker call sites; isolates the new concept entirely. Con: reintroduces exactly the
    cache-invalidation-on-edit lifecycle question phase-1 of the anchors feature explicitly
    avoided by doing click-time live walks instead of building an index (see Markdown viewer: support <a href> and <a id> anchor links #13725's tech spec,
    "no anchor index" section) — every edit that shifts offsets would need to keep this sidecar
    in sync, or it drifts silently. Would need a concrete edit-hook design before this is credible.

No recommendation is made here — this ticket exists specifically to get maintainer judgment on
which trade-off is acceptable (or a fourth option not listed) before anyone spends the 70-130
call-site effort on the wrong one.

Related

Metadata

Metadata

Assignees

Labels

area:editor-notebooksEditors, notebooks, markdown rendering, LSP, and code display.bugSomething isn't working.repro:highThe report includes enough evidence that the issue appears highly reproducible.triagedIssue has received an initial automated triage pass.warp:auto-triage-review

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions