Skip to content

feat(ui): render markdown reference links - #1168

Open
rNoz wants to merge 5 commits into
backnotprop:mainfrom
rNoz:rnoz/markdown-reference-links
Open

feat(ui): render markdown reference links#1168
rNoz wants to merge 5 commits into
backnotprop:mainfrom
rNoz:rnoz/markdown-reference-links

Conversation

@rNoz

@rNoz rNoz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #923.

The simplified markdown parser (parseMarkdownToBlocks) only understood inline links [text](url). CommonMark reference links rendered as raw text: both the reference [text][id] and its [id]: url definition showed literally in the document. This adds reference-link support by resolving references into inline links before the block split, so the existing inline renderer draws them.

Approach

A single pure function, resolveReferenceLinks(markdown), runs at the top of parseMarkdownToBlocks, right after frontmatter extraction. It does two passes:

  1. Collect link reference definitions ([label]: destination "optional title", up to three leading spaces, <url> or bare destination, first definition wins, labels normalized case-insensitively with collapsed internal whitespace, ^-prefixed labels rejected so GFM footnotes are left alone). A line only counts as a definition when it can start a block (the previous line is blank, a code fence, another definition, or the document start) and its trailing content is empty or a valid quoted or parenthesized title, so ordinary prose is never mistaken for a definition and a definition can never interrupt a paragraph.
  2. Rewrite references into inline links: full [text][id], collapsed [text][], and shortcut [text], plus the image forms ![alt][id]. Only definitions that are actually referenced somewhere are blanked; an unreferenced definition (or one whose only reference sits inside a protected region) is left as-is. Blanked lines are replaced with an empty line, not removed, so line count, and therefore every block's startLine, stays accurate.

Fenced code blocks and HTML blocks share the exact block-boundary detection used by the rest of the parser, so a reference-looking pattern inside either is never rewritten and a [id]: url inside either is never collected as a definition. Because the output is ordinary [text](url) markdown, no renderer change is needed.

Correctness and safety

  • Code-aware and HTML-aware: content inside fenced code blocks, inline code spans, and raw HTML blocks (<pre>, <details>, and similar) is never rewritten, and a reference-shaped line inside any of them is never collected as a definition.
  • GFM footnote definitions ([^1]: ...) are left untouched, not consumed as reference definitions.
  • A shortcut [text] is left literal when an inline (...) destination follows it and when it does not name a definition, so bracketed prose like [TODO] or an array index [0] never becomes a false link. A checkbox marker (- [x]) at a list-item start is never treated as a shortcut reference.
  • An unknown full or collapsed reference stays literal, matching CommonMark.
  • The document's line endings (LF or CRLF) do not affect resolution.
  • Scanning is linear in document length, so an adversarial or very large document (for example a converted web page) does not cause a slowdown, and a shared linear close index between the reference resolver and the pre-existing HTML-block parser means a long but well-formed HTML block (for example a large <details> section) still renders as a single block rather than being truncated for performance.
  • A document that defines no references, or none that are actually referenced, is returned unchanged (fast path), so the common case is untouched.

Validation

226 targeted parser tests pass, including full/collapsed/shortcut/image resolution, case- and whitespace-insensitive labels, the angle-bracket destination, first-definition-wins, unknown references left literal, protected fenced code, inline code, HTML blocks and footnotes, CRLF handling, consumed-only definition blanking, and linear-time scanning on large documents including long well-formed HTML blocks. A full UI and typecheck pass (including the strict consumer) is also clean. No node: imports were added, so the browser-safe boundary holds.

Known limitations

  • An unbalanced ) inside a destination can still truncate the resulting href; a nested reference like [see [1]][id] can still produce an extra link (its href is still sanitized); and a definition placed directly under a heading with no blank line in between is not recognized, matching the parser's existing prose-continuation handling.
  • Migration note: a draft annotation or share link created before this change, whose highlighted text spans raw [text][id] reference markup, will not auto-restore its position after upgrading; the annotation is preserved but appears unanchored, with a console warning. This is a one-time transition case for pre-existing data, not an ongoing limitation.

Compatibility

Additive. Documents without reference definitions are unchanged (identical block output). No public API, prop, or payload changes. The function is exported so it can be unit-tested and reused.

Related work

None. This is a self-contained parser feature.

The simplified markdown parser only understood inline links `[text](url)`,
so CommonMark reference links rendered as raw text: `[text][id]` and the
`[id]: url` definition both showed literally (backnotprop#923).

Add `resolveReferenceLinks`, a pure pass run at the top of
`parseMarkdownToBlocks` that rewrites full (`[text][id]`), collapsed
(`[text][]`), and shortcut (`[text]`) references, plus their image forms,
into inline `[text](url)` links, so the existing inline renderer draws
them. Link reference definitions are collected first (first definition
wins, labels matched case-insensitively with collapsed whitespace, `<url>`
and quoted-title forms supported) and then blanked in place, so a
definition never renders and every block keeps its original source line
number.

Resolution is code-aware: references and definitions inside fenced code
blocks and inline code spans are left verbatim, a shortcut is skipped when
an inline `(...)` destination follows it or when it is a task-list checkbox
marker at the start of a list item, and an unknown reference stays literal
so bracketed prose like `[TODO]` or `[0]` never becomes a false link. A
definition-shaped line is only collected when it can start a block (after a
blank line, a code fence, another definition, or the document start), so a
`[word]: token` line that continues a paragraph is left as text rather than
deleted (CommonMark: a definition cannot interrupt a paragraph). A document
with no definitions is returned unchanged.
@backnotprop

Copy link
Copy Markdown
Owner

In-depth review done. First: the core design call is exactly right. Blanking definition lines in place instead of deleting them preserves startLine, which the checkbox reporter and section grouping depend on, and that was verified downstream. Security also checks out end to end: resolved URLs go through sanitizeLinkUrl (javascript:/data:/vbscript:/file: blocked, verified), hrefs travel as React props so attribute smuggling is impossible, and a reference definition carries exactly the trust of an inline link. Idempotence confirmed. Full suite and typecheck clean, 142 new tests pass.

That said, several content-corruption paths are reachable with ordinary plan markdown, so this needs a round of fixes:

  1. Blocking: indented fences are rewritten. markFencedLines strips only up to 3 leading spaces before testing for a fence, but the block loop uses line.trim(), so every fence nested under a list item (4+ spaces, extremely common in plans) is code to the renderer but unprotected in the resolver. Verified: const v = ref[a][b]; inside a fence under a numbered step gets a URL substituted into the highlighted code block. Fix: share the block loop's trim-based fence detection.

  2. HTML blocks are unprotected. Content inside

     gets rewritten, and a definition line inside 
    Details gets blanked, deleting a line out of the HTML block body.

  3. GFM footnotes are eaten. The definition regex accepts ^ in labels, so [^1]: https://source turns Claim[^1] into a link labelled ^1 and deletes the footnote. One-line fix: reject labels starting with ^.

  4. Definitions are blanked unconditionally, even when nothing consumes them. "[owner]: alice" under an Owners heading vanishes from the rendered plan; a definition whose only reference sits inside a fence is deleted while the reference stays literal. Suggested fix that also softens 2, 3, and 5: track which labels resolveRefsInLine actually consumed and blank only those definition lines.

  5. Plan diff (default clean mode) can show an empty modified block. MarkdownChunk parses each diff chunk independently while computePlanDiff diffs raw markdown, so a version change that only edits a definition URL renders as a +2/-2 badge with an empty block; the user sees nothing changed. The consumed-labels fix above makes this much rarer; acceptable as a documented follow-up if you prefer.

  6. CRLF documents silently get nothing. The definition regex ends [ \t]*$ so a trailing \r kills the match; the rest of the parser is CRLF-tolerant. Windows-authored annotate files never see the feature.

  7. Quadratic backtracking guard needed. A long unterminated bracket run is O(n squared): measured 6.3s at 128k chars, extrapolating to tens of minutes at the 2MB annotate cap, reachable via annotate on a hostile or Jina-converted page. Cheap guard: skip lines with no ], or bound the label length in the regex. Realistic docs are fine (+12ms on a 1.6MB doc with refs).

Nits, take or leave: the resolver knows ~~~ fences but the block loop does not, so a stray ~~~~ silently disables the feature for the rest of the document; destinations are not escaped when re-inlined so a ) in a URL truncates the href and leaks stray text; nested brackets ([see [1]][id]) produce a spurious shortcut link; backslash-escaped brackets still resolve; definitions directly under a heading with no blank line are not recognized (degrades safely, but a ## References section written tightly will not work).

One release-notes item rather than a code change: drafts and share URLs created before this PR whose anchor text spans raw [text][id] markup will fail text-search restore after upgrade (annotation survives unanchored with a console warning). Narrow and one-time.

The new tests are good on everything the implementation considered; the fix round should add cases for indented fences, HTML blocks, footnotes, unreferenced definitions, CRLF, and an explicit idempotence assertion. Happy to re-review quickly on push.

rNoz added 2 commits July 31, 2026 21:29
…link resolution

Owner review round for reference-style link resolution (backnotprop#923):

- Fence detection now mirrors the block parser's own naive rule exactly
  (full .trim() + startsWith('```'), any indentation, backtick-only —
  no ~~~ support) instead of a looser 0-3-space approximation, so
  indented and list-nested fences the block parser treats as code can
  never be rewritten. Aligns tilde-fence behavior the same way: since
  the block parser has no ~~~ support, the resolver no longer protects
  ~~~ blocks either.
- Raw HTML blocks (<details>, <pre>, etc.) are now protected using the
  same HTML_BLOCK_TAGS/HTML_BLOCK_OPEN_RE/VOID_HTML_TAGS the block
  parser itself uses, with the same three termination rules
  (blank-line, void single-line, balanced-depth).
- GFM footnote definitions ([^label]: ...) are excluded from
  collection entirely, so they and their [^label] references are
  never rewritten into inline links.
- A definition-shaped line is now only blanked when its label was
  actually consumed by a resolved reference outside a protected
  region. Unused definitions, and definitions referenced only from
  inside code/HTML, stay visible. This also fixes a plan-diff bug: a
  URL-only edit to a definition line used to blank to nothing on both
  sides of the diff (a real change rendering as empty); now the
  isolated diff chunk keeps the definition visible and diffs normally.
- CRLF lines are now recognized (definition regex tolerates a
  trailing \r) and preserved (a blanked line keeps its own \r).
- Bound the label/text capture groups (999 chars, CommonMark's own
  label limit) and the code-span alternative (5000 chars) so a long
  run of unmatched brackets/backticks can no longer cause quadratic
  backtracking within the 2MB annotate cap; added a defense-in-depth
  cap on the number of definitions tracked per document.
- Added coverage for nested brackets, backslash-escaped brackets,
  parenthesized destinations, idempotence, and confirmed dangerous
  destinations still flow through the existing sanitizeLinkUrl path
  unchanged.

Added a migration-caveat note to the existing annotation-anchor
section of packages/ui/HANDOFF.md: documents using reference-style
links render differently now, which can shift position-based anchors
captured before a host upgrades past this change.
…er case

markProtectedLines and parseMarkdownToBlocks each independently scanned
line-by-line from a multi-line HTML opener until its balanced open/close
depth returned to zero, giving up only at end-of-document. That scan
never advanced the outer index on failure, so a document with many
consecutive unclosed openers (e.g. thousands of bare <div> lines with
no </div> anywhere) made every one of them re-run the same O(N) tail
scan — O(N^2) total, a real hazard well within the 2MB annotate cap.

Extract the scan into one shared helper, findHtmlBlockEnd, used by both
call sites so they can't drift apart:

- closeExistsFromLine lazily builds (once per tag name, cached per
  document) a suffix array answering whether a closing tag exists at
  or after a given line, so an opener that can never close is rejected
  in O(1) instead of scanning to EOF.
- MAX_HTML_BLOCK_SCAN_LINES bounds the residual case (a closing tag
  exists far away but depth never actually reaches zero before it) to
  a constant amount of work per start position — a documented, safe
  degradation: a block whose true close sits beyond the cap is treated
  as unclosed, identically to today's 'no close ever found' case.

Added a failing-before-fix perf test (many unclosed <div> lines took
~2.3-3.4s and blew a 800ms bound; now ~12-15ms) for both
parseMarkdownToBlocks and resolveReferenceLinks, plus a parity test
proving a real <details>...</details> block stays intact and
identically protected/parsed among thousands of decoy unclosed <div>
lines.
@rNoz

rNoz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the review fixes. Protected regions, footnotes, unused definitions, CRLF, bounded scanning, and clean diff visibility are covered. Checks are passing.

@backnotprop

Copy link
Copy Markdown
Owner

Re-verified 9440be0 by re-running every original reproduction plus a 20,000-document differential fuzz (zero protected-region leaks, zero line-count changes, zero idempotence failures). All seven findings are genuinely fixed, and two of your choices are better than what the review suggested: sharing the block parser's exact conditions (single findHtmlBlockEnd used by both passes, byte-identical void-tag branches) and blanking only consumed labels, which also fixed the clean-diff invisibility with no diff-engine change needed. The quadratic scan is now clean linear: 128k chars went from 6.3s to 95ms, and the 2MB worst case from an extrapolated ~27 minutes to 1.4s. CRLF, footnotes, indented fences, HTML blocks, unused definitions: all confirmed against the original repro inputs. 25 new tests, full suite 2631 pass, typecheck clean. Removing ~~~ protection to agree with the block parser was also the right call.

One new regression from the perf fix, and it is the only thing to hold for: MAX_HTML_BLOCK_SCAN_LINES = 2000 now applies to the pre-existing parseMarkdownToBlocks HTML scan. A

Details (or raw ) whose closing tag sits more than ~2000 lines below the opener no longer renders as one block: measured, a 1999-line body splits into opener-alone, body-as-paragraphs, close-alone, so a plan wrapping a long log in
loses its collapsible and leaks raw HTML as text. Mitigating facts, both measured: the same commit fixes a pre-existing O(N squared) hang (40k unclosed divs: 40.5s on main, 26ms here), and the closeExistsFromLine suffix array already handles the no-close-anywhere pathological shape without any cap. The cap only guards the rare close-exists-far-away case, which is still slow at 2000 anyway. Suggested: raise it substantially or scale with document length, e.g. Math.max(20000, lines.length).

Residual nits, all acceptable as-is: unbalanced ) in a destination still truncates the href (your new test covers only the balanced case); mixed backslash-escape [not a ref][id] still resolves; [see [1]][id] still yields a spurious link (explicitly documented and sanitization-guard-tested, so the invariant that matters holds); tight-heading definitions still degrade to old behavior; the clean-diff view is now consistently source-level, which disagrees with the rendered view but defensibly so. The HANDOFF note on pre-upgrade draft re-anchoring covers the migration caveat.

Bump the cap and this is ready.

rNoz added 2 commits July 31, 2026 22:31
MAX_HTML_BLOCK_SCAN_LINES (2000) fixed the O(N^2) unclosed-opener case
but as a side effect also truncated genuinely valid, longer HTML
blocks: a <details> or raw <table> block whose closing tag sits beyond
2000 lines got cut off mid-block, with its remaining content and the
real closing tag falling through as separate, incorrect blocks.

closeExistsFromLine already rejects an opener that can never close in
O(1) (no closing tag anywhere in the document) without scanning a
single line — that already eliminates the pathological 'many failing
scans' case on its own. A cap on top of that only ever hurt the
opposite case: a scan that DOES succeed, which happens once per
document and costs O(L) for an L-line block exactly like reading any
other block's content once. So the cap bought nothing further and
could silently corrupt valid parsing for any block longer than it,
however generous its value. Removed it; the scan now runs unbounded to
its real end once closeExistsFromLine confirms a close exists at all.

findHtmlBlockEnd is still the single shared implementation used by both
markProtectedLines and parseMarkdownToBlocks, so both stay in parity.

Added regression tests: a >2000-line <details> and a >2000-line raw
<table> block each stay one whole html block (previously truncated); a
link definition inside a >2000-line <details> block stays protected
and never wins over a real definition outside it; a valid long
<details> block survives even preceded by thousands of unclosed <div>
decoys. Re-verified the 40k-unclosed-opener perf/parity case (already
handled by closeExistsFromLine alone) stays fast and unaffected.
Removing the fixed line-count cap fixed truncation of valid long HTML
blocks, but reopened a closely related O(N^2) case: N unclosed <div>
openers followed by a single trailing </div> all still pass the
'does a close exist anywhere' pre-check, so every one of them
independently scanned forward (mostly to end-of-document) before
giving up. Measured before this fix: 5000 openers ~1.0s, 10000 ~4.1s,
40000 timed out past 69s.

Replaced the scan entirely with a per-tag-name prefix-sum index
(buildTagCloseIndex): the running open-minus-close count for a tag
name, plus a classic 'next element at or below this one' index over
that prefix sum (an O(N) monotonic-stack construction, each position
pushed/popped at most once). Finding where (if anywhere) a block
starting at a given line closes is exactly that classic query, so it
is now an O(1) lookup with zero scanning per opener, whether the block
never closes, closes after 3 lines, or closes 3000 lines away. Both
markProtectedLines and parseMarkdownToBlocks still share the single
findHtmlBlockEnd implementation, so they stay in parity.

40000 unclosed <div> openers + one trailing </div> now resolve in
~30-45ms (parser and resolver both), with block-boundary parity
between them. Re-verified: no truncating cap reintroduced (>2000-line
<details>/<table> blocks still stay whole), nested same-tag blocks
still balance on true depth (not just tag presence), and independent
tag types (e.g. a <table> nested inside a <details>) don't cross-talk
between their separate per-tag indices.
@rNoz

rNoz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the final HTML-block fix. Long valid blocks are preserved, while both unclosed-opener adversarial shapes now use a shared linear close index. Checks are passing. If you want a tiny palate cleanser after this parser one, #1154 is a small Vim HUD scroll fix.

@backnotprop

Copy link
Copy Markdown
Owner

Verified fe017f1 empirically. The prefix-sum close index is a better answer than the raised cap the review suggested: long valid details blocks at 1999, 5000, and 30000 lines all render as a single HTML block (regression gone), 40k unclosed divs parse in 19ms, and the residual adversarial shape that previously cost 8.7s even under the cap (100k openers plus one trailing close) now parses in 51ms. Resolver idempotence holds on protected content, and all 176 parser tests pass. Nothing left from my side: merge-ready.

@rNoz
rNoz marked this pull request as ready for review July 31, 2026 21:04
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.

Markdown Reference Links should be supported

2 participants