feat(editor): offer this document's headings as link targets - #520
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
idonto 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
Both are worth having — the wikilink spelling is what
Copy Referenceproduces, 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" finds11. Mermaid Diagramswhatever 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_anchorsparses withmarkdown_options(), the renderer's own configuration, and anchorizes with comrak'sAnchorizer.Parsed, not scanned for
#:samples/stress-test-hard.mdis 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:
## Wiki [[note#Setup]] herewiki-note--setup-herewiki-notesetup-here## See $[a](b)$ inlinesee-ab-inlinesee-a-inlineA 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$becomesx--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_steprenders each of twelve headings and asserts the slug against theid=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:scripts/headingLinkCompletion.test.tscovers 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 insideconvert_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 oversamples/stress-test-hard.mdin both syntaxes.