Skip to content

fix(links): make Copy Reference output a working link - #380

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/wikilink-anchors-and-inline-code
Aug 2, 2026
Merged

fix(links): make Copy Reference output a working link#380
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/wikilink-anchors-and-inline-code

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

The problem

Right-click a heading → Copy Reference → paste. All three call sites of that action emit [[Note#Heading]], and Markpad renders it as literal text. The app's own output is dead inside the app.

The wikilink pattern required # to follow [[ immediately, so only the same-document anchor form ([[#Heading]]) existed. There was no branch at all for a target with a file name — process_internal_embeds only handles the ![[…]] embed form.

The fix

[[path#heading]] and [[path#heading|alias]] are rewritten to an ordinary relative markdown link, in the same Rust preprocessing pass that already handles [[#heading]].

The href shape is dictated by the frontend that has to accept it, not chosen freely — I read the click path first (handleLinkClickgetMarkdownLinkTargetopenRelativeMarkdownTargetscrollToAnchorWhenReady) and emitted only what it already claims, so no frontend change is needed:

Frontend step What it forces on the Rust side
getMarkdownLinkTarget claims a link only if the pre-# path has a markdown extension .md is appended to a bare note name; the Rust extension list mirrors MARKDOWN_LINK_EXTENSIONS
decodeLinkPath = decodeURIComponent space/parens/angles/quote/? are percent-encoded, %%25 first so a literal percent round-trips
scrollToAnchor strips a leading ^ [[Note#^abc]] and [[Note#abc]] both land on the <a id="abc"> that BLOCK_ID_RE emits

Non-markdown targets ([[report.pdf#Intro]]) stay literal: the frontend would not claim them, and the click would fall through to openUrl() with a path resolved against the webview origin — which opens nothing. Literal text is the honest result.

Scope: only forms with a # in the target

This is deliberate and is the main thing to review.

Obsidian's bare [[Note]] link is a feature, not this defect. Claiming every [[…]] would also swallow:

  • [[1]] Author, Title. — bracketed citation numbering, common in READMEs and notes
  • [[foo]] where [foo]: https://… is defined — pre-empting CommonMark's reference link

Both are regressions in documents that render correctly today. Leaving bare wikilinks inert is merely the status quo — it was already inert before this change. The asymmetry decides it: "did not get better" is not the same cost as "got worse."

A # in the alias half ([[Notes|see #1]]) does not qualify either; the closure re-checks the target half after the pattern's cheap prefilter.

Alignment with Obsidian

Checked before settling the syntax. Obsidian documents exactly these forms — [[Note#Heading]], [[Note#Heading|Alias]], [[Note#^blockid]] — and renders an un-aliased heading link as Note > Heading, which this matches, so a reference pasted between the two apps reads the same.

Deliberate divergence: Obsidian resolves extension-less note names against a vault index. Markpad has no index, so the target is resolved relative to the current document — the same resolution a plain [text](../other.md) link already gets.

Tests

12 new Rust tests + a 6-case TypeScript contract pin.

Run Result
master, untouched 76 pass / 0 fail
master + these tests only 81 pass / 8 fail — the 8 that reproduce the defect
A broad implementation (claiming every [[…]]) + these tests 88 pass / 1 fail — the scope test is what makes the narrowing load-bearing
Final 89 pass / 0 fail

The other 4 Rust tests pass on master by design — they are regression guards (same-document form unchanged, embeds untouched, non-markdown targets literal, [[1]](url) untouched), not reproductions, and are counted separately rather than folded into the red number.

Two pre-existing tests were re-pointed at #-bearing inputs so they still test what their names claim: under the new scope [[log.txt]] and [[report.pdf]] would have passed on the # rule rather than on the extension rejection they exist to check.

The TypeScript file is a contract pin, not a reproduction — only 1 of its 6 cases can go red on master, since the frontend is unchanged. It asserts that every href the Rust rewriter emits is actually claimed by getMarkdownLinkTarget with the right path and anchor, and that the two extension lists still mirror each other. Labelled as such in its header.

npm run check   0 errors, 0 warnings
npm test        293 / 293
cargo test      89 / 89
cargo clippy    5 warnings before, 5 after — no new warnings

Known boundaries (documented in code, not fixed here)

  • Nested heading paths [[file#H1#H2]]: everything after the first # is one heading name. The same limitation already exists in the same-document form; flagging it rather than silently redefining it.
  • Duplicate headings: comrak suffixes the 2nd+ same-text heading -1, -2, …; a wikilink can only ever address the first. The preprocessor cannot see the target document's heading sequence at all — a hard boundary, already noted upstream in heading_anchor_id.
  • [[#Heading|]] (pipe present, alias blank) now falls back to the heading as display text instead of staying literal. Preserving the old behaviour would require adding a special case, and dead text is a poor outcome for someone who just deleted an alias.

🤖 Generated with Claude Code

Copy Reference is the app's own context-menu action; all three call
sites emit `[[Note#Heading]]`. Pasted back into Markpad it rendered as
literal text, because the wikilink pattern required `#` to follow `[[`
immediately - only the same-document anchor form existed, and there was
no branch at all for a file target.

`[[path#heading]]` and `[[path#heading|alias]]` are now rewritten to an
ordinary relative markdown link. The href shape is dictated by the
frontend path that has to accept it (`getMarkdownLinkTarget` ->
`decodeLinkPath` -> `scrollToAnchor`), so no frontend change is needed:
`.md` is appended to a bare note name because the frontend only claims
links whose pre-`#` path has a markdown extension, and the destination
is percent-encoded because the frontend decodes it.

Deliberately limited to forms that contain a `#` in the target half.
Obsidian's bare `[[Note]]` is a feature, not this defect, and claiming
every `[[...]]` would swallow bracketed citation numbering (`[[1]]`) and
pre-empt CommonMark reference links - a regression, where leaving bare
wikilinks inert is merely the status quo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit da50eba into sftwrdotdev:master Aug 2, 2026
4 checks passed
@PathGao
PathGao deleted the fix/wikilink-anchors-and-inline-code branch August 2, 2026 20:47
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.

1 participant