You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:398 — HorizontalRule, 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:867 — Header, 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.
Problem
As of #13962, a
#fragmentlink 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 textscan over the buffer (
find_matching_anchor_tag,app/src/notebooks/editor/model.rs) — nocontent-model change was needed for resolution because the phase-1 inline parser only
recognizes
<a href>as a token (it requires anhrefattribute to match); a bare<a id>/<a name>tag with nohreffalls through to literal text, so its raw markup — including the idvalue — 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:398—HorizontalRule,Embedded,Image) is a whole-block replacement type: each variant occupies an entire block/line viaBlockType::Item. It's exclusive, not additive — wrong shape for "metadata riding alongsidethe next block's own style."
BufferBlockStyle(text.rs:867—Header,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 naturalplace 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. AgrepforBufferBlockStyle::Header {alone turns upmatches 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, plusroughly 40 individual match/construct sites in
buffer_tests.rsalone) — and that's onevariant of seven.
BufferText::BlockMarkeritself is the alternative attachment point (a field alongsidemarker_type, wrapping all block kinds at once — smaller conceptually since it's one typeinstead of seven). But the
BlockMarker { marker_type }shorthand pattern is used at roughly60 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..oran added field, since Rust's struct-pattern matching requires it.
markdown_parser'sFormattedTextLine(one variant per line/block kind —Heading,Line,CodeBlock,Table,UnorderedList, …) has no dedicated variant for "this line is a bareanchor tag, attach it to whatever follows." An own-line
<a id="x"></a>currently parses as anordinary
FormattedTextLine::Line(a plain paragraph). Block-attachment therefore also needseither a new
FormattedTextLinevariant plus a lowering rule in thecore.rsedit-applicationloop (~
core.rs:567+, where eachFormattedTextLineis turned intoBufferTextpushes), or apost-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-modelmigration, 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 walkseach block's style and re-emits its prefix —
#,*,```, etc. — before the block'stext). 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 intoto_markdown's output. Any hiding mechanism must be paired with a serialization path thatre-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 commentstripping 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
BufferText::BlockMarker(e.g.anchor_ids: Vec<String>alongsidemarker_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 awide, low-value diff to review), and every future
BlockMarkerconstruction site mustremember the field exists.
BufferBlockStylevariant. Pro: keeps the field conceptually scoped to "thisspecific block's own style," possibly with different semantics per kind if that ever matters.
Con: 7× the sites of the
BlockMarkeroption, since each variant's construction/match sitesneed updating independently; no shared default via
..unless every arm opts in. Notrecommended given the multiplier cost with no clear semantic benefit over the previous option.
CharOffset -> Vec<String>ofanchor ids) stored on
Bufferalongside the sum-tree content, updated on edit via the sameinvalidation hooks other derived state uses. Pro: zero changes to
BufferBlockStyle/BlockMarkercall sites; isolates the new concept entirely. Con: reintroduces exactly thecache-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
<a href>and<a id>anchor links #13725 — original anchor-links feature request (headline issue this hiding work serves)its explicitly deferred follow-up)
serialization" constraint; worth coordinating design if both proceed)