Skip to content

feat(editor): offer this document's headings as link targets - #520

Merged
PathGao merged 1 commit into
masterfrom
feat/heading-link-completion
Aug 7, 2026
Merged

feat(editor): offer this document's headings as link targets#520
PathGao merged 1 commit into
masterfrom
feat/heading-link-completion

Conversation

@PathGao

@PathGao PathGao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #200@lolalalol asked for "some function which slugify titles and purpose those as #targets for URL of link".

Half of it already worked: comrak renders an id onto every heading, and […](#that-id) jumps to it. What was missing is that nothing ever offered those ids, so writing one meant doing comrak's anchorizer by hand — lowercase it, drop the punctuation comrak drops, hyphenate the spaces, and remember that a repeated heading is numbered. Get it wrong and you have a link that looks right and lands nowhere.

Two syntaxes, two different insertions

[text](#      →  suggestions insert the SLUG            11-mermaid-diagrams
[[#           →  suggestions insert the HEADING          11. Mermaid Diagrams
[[note#       →  nothing: those are another file's headings

Both are worth having — the wikilink spelling is what Copy Reference produces, so a reader who pastes one and then wants another writes it in that form. Entries are labelled by the heading and detailed with the slug, so typing "mermaid" finds 11. Mermaid Diagrams whatever the slug turned that into, and they are sorted in document order rather than alphabetically: a heading's neighbours are what the writer is thinking in.

The list comes from Rust, and that is the whole design

A second anchorizer written in TypeScript would drift from comrak's without anything failing — the links would simply stop landing. list_heading_anchors parses with markdown_options(), the renderer's own configuration, and anchorizes with comrak's Anchorizer.

Parsed, not scanned for #: samples/stress-test-hard.md is full of shell examples whose comments start with one.

Two consequences of taking the list from where the renderer takes it:

One anchorizer for the document, unlike heading_anchor_id, which is deliberately fresh per lookup. comrak numbers repeated headings, and the stress document has sixty ### Objectives — offering the bare slug for the second would quietly link to the first. (A [[#…]] wikilink still cannot address the second; it names a heading by text. Pre-existing, not introduced here.)

The same preprocessing chain. comrak never sees the buffer — four steps run first, and two of them rewrite what a heading reads as. This is the part I got wrong first and only found by measuring: I had written a comment claiming inline math was the gap. Math was fine. These were not:

heading renderer wrote parsing the raw buffer gives
## Wiki [[note#Setup]] here wiki-note--setup-here wiki-notesetup-here
## See $[a](b)$ inline see-ab-inline see-a-inline

A wikilink is a link by the time comrak parses it, so the heading reads "note > Setup". Running the same chain fixes both, and the steps are line-preserving (line_preserving_transforms), so sourcepos still addresses the caller's buffer.

Math is masked before the parse and put back after it. That works for the id because anchorizing is a per-character map with no collapsing — $x + 1 = 2$ becomes x--1--2, the double hyphens intact — so doing it to the pieces and doing it to the whole give the same string.

Tests

heading_anchors_match_the_renderer_through_every_preprocessing_step renders each of twelve headings and asserts the slug against the id= the renderer actually wrote, rather than against a hand-written expectation. It cannot drift with a change to the chain. Falsified — parse the raw buffer instead and it fails with the real numbers:

✖ completion would offer "see-a-inline" for "## See $[a](b)$ inline",
  but the renderer wrote "see-ab-inline"

scripts/headingLinkCompletion.test.ts covers the other half: where a heading is the answer (and where it is not — a finished destination, an alias after |, prose that merely contains #), and that each context writes the text its own syntax resolves.

One guard test also fired during this work and was right to: markdown_options() is a new call inside convert_markdown, and the line-contract registry demands every call there be classified. It is registered as not-a-transform, with the reason.

Cost

Completion fires per keystroke while the dropdown is open, so the anchor list is cached on the model's version id — the headings cannot have moved between two keystrokes of one edit.

Verification

npm run check (0 errors), npm test (823 passing), cargo test (137 passing). Driven by hand over samples/stress-test-hard.md in both syntaxes.

Closes #200.

The slugs existed — comrak renders an `id` onto every heading and
`[…](#that-id)` already jumps to it — but nothing offered them, so writing
one meant doing comrak's anchorizer by hand: lowercase, drop the punctuation
it drops, hyphenate the spaces, and remember that a repeated heading is
numbered. Getting it wrong produces a link that looks right and lands
nowhere.

Two syntaxes take a heading here, and they take DIFFERENT text:

    [text](#11-mermaid-diagrams)   the slug the renderer wrote
    [[#11. Mermaid Diagrams]]      the heading, anchorized at render time

So the context decides what a completion inserts, not merely whether to
offer one. `[[note#` is deliberately not a context: those are another file's
headings and this buffer does not have them.

The list comes from Rust, from the renderer's own parse and anchorizer —
a second implementation in TypeScript would drift from comrak silently, and
the failure mode is a link that looks correct. Two consequences of taking it
from there rather than from the buffer:

* ONE anchorizer for the document, unlike `heading_anchor_id`, which is
  deliberately fresh per lookup. comrak numbers repeated headings, and the
  stress document has sixty `### Objectives`; offering the bare slug for the
  second would link to the first. A wikilink still cannot address it — it
  names a heading by text — and that is pre-existing.

* The SAME preprocessing chain the renderer runs. comrak never sees the
  buffer: `[[note#Setup]]` is a link by the time it is parsed, so the
  heading reads "note > Setup". Measured, before this was fixed:

      ## Wiki [[note#Setup]] here   rendered wiki-note--setup-here
                                    raw      wiki-notesetup-here
      ## See $[a](b)$ inline        rendered see-ab-inline
                                    raw      see-a-inline

  The steps preserve line numbers, so sourcepos still addresses the caller's
  buffer.

The Rust test renders each case and asserts against the `id=` the renderer
actually wrote, so it cannot drift with a change to the chain. The frontend
side is cached on the model version: completion fires per keystroke while
the dropdown is open, and the headings cannot have moved between two
keystrokes of one edit.

Verification: npm run check (0 errors), npm test (823 passing), cargo test
(137 passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 2ffbe09 into master Aug 7, 2026
4 checks passed
@PathGao
PathGao deleted the feat/heading-link-completion branch August 7, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] improve internal links

1 participant