Support [[note#heading]] wiki-link anchors (discussion #105)#748
Conversation
[[note#heading]] now lands on the matching heading on the target page. The fragment matches Pandoc's auto-generated heading id (the same form regular Markdown anchor links already accept). The work happens upstream in commonmark-wikilink (PR #10) — the parser now preserves the anchor instead of dropping it, so the B.Link URL carries 'note#heading' end-to-end. Emanote's URL splice already calls WL.anchorSuffix and now just sees an anchor where it always saw 'Nothing'. Same-file [[#heading]] is deferred — the regular Markdown form [heading](#heading) continues to work for that case. Resolves #105.
…r wikilink anchors
Auto-id is the default but pandoc's {#custom-id} header attribute
overrides it. Wiki-link fragments must match whichever id the heading
actually carries.
Before this commit, the MCP resolve_wikilink tool treated 'note#heading' as a slug containing a literal '#', so a query that the HTML renderer correctly resolved to 'note' came back as UnresolvedMissing in MCP. Route parseWikiLinkText through WL.dropUrlAnchor (newly exported upstream) so both paths agree on what the user meant.
The embed renderer parses an anchor on `![[note#section]]` but currently inlines the whole note. Reword the existing TODO into a #105-anchored note that also points at the two sibling _mAnchor discards in the inline-wikilink and regular-link splices, so the deferred axis (section-scoped embed) is grep-able from one place.
… irrefutable Right An irrefutable 'let Right res = ...' crashes with a bare PatternMatchFail when the resolver unexpectedly returns Left. Use a 'case ... of Left -> expectationFailure' so a regression surfaces with the actual error value in the test report.
Static mode renders heading-target.html#alpha-section; live/morph may differ on the .html suffix. The regression we want to catch is 'is the #alpha-section fragment present at all', so check the anchor substring only and let link existence prove the note resolution.
Hickey/Lowy Analysis
Hickey rationaleTwo findings, both around duplicated derivations. A flagged that Cross-validation refined both. A → No-op: Lowy's lens identified that the four discards are not the same shape — Lowy rationaleThree findings, all around encapsulating one volatility axis (anchor support) cleanly across the codebase. 1: the MCP Cross-validation confirmed all three. Hickey's audit pushed back on the original Lowy-1 framing of "inline Commits in this PR
|
EvidenceShot A — the Shot B — the result after clicking that link. The browser landed on |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 7m 19s | Mapped wikilink path; identified upstream parser fix in commonmark-wikilink as the single fix point. |
| branch | ✓ | 17s | Both Emanote and ~/code/commonmark-wikilink on heading-link branch. |
| implement | ✓ | 10m 39s | Upstream commonmark-wikilink fix shipped to PR #10; Emanote points at heading-link branch; tests + e2e fixture + docs + CHANGELOG added. |
| check | ✓ | 35s | cabal build all (with tests) succeeded |
| docs | ✓ | 21s | wikilinks.md anchor section rewritten with live demo; CHANGELOG updated. |
| fmt | ✓ | 11s | just fmt clean (cabal-fmt, fourmolu, hlint, nixpkgs-fmt all passed) |
| commit | ✓ | 20s | Primary commit pushed to origin/heading-link |
| hickey+lowy | ✓ | 17m 13s | 5 findings (H-A, H-B, L-1, L-2, L-3); cross-validation: H-A No-op, others Fix in PR. 4 follow-up commits. |
| police | ✓ | 7m 5s | code-police flagged irrefutable Right match in MCP test; replaced with explicit case. |
| test | ✓ | 4m 1s | Haskell 141/141; e2e static 80/80 (61 ran), live 80/80, morph 80/80. |
| create-pr | ✓ | 1m 12s | Draft PR #748 opened with forge-pr body; hickey/lowy analysis posted. |
| ci | ✓ | 1m 50s | vira ci passed (FullBuild). |
| evidence | ✓ | 2m 1s | 2 screenshots uploaded to evidence-assets release and posted. |
| Total | 53m 15s |
Slowest step: hickey+lowy (17m 13s)
Optimization suggestions
- hickey+lowy dominated at 32% of total time — the cross-repo briefing (Emanote diff + upstream PR diff) made each reviewer's read longer than usual. Future cross-repo PRs could brief reviewers on one artifact at a time, or skip the cross-validation phase when the diff is small enough that conflicts are unlikely.
- research at 7m 19s was front-loaded by the Explore subagent mapping the wikilink+heading-id landscape. Pre-reading
commonmark-wikilinksource before invoking/do(the library already had twoIgnore anchors until #105TODOs pointing at the fix point) would have saved a few minutes. - police caught a real defect (irrefutable
Rightpattern in the new MCP test) — keep/code-policeenabled, but write tests with explicitcasefrom the start when the value isEither e a. - CI ran once and passed on the final SHA — no re-runs needed. The check/fmt/test gates upstream of CI kept the loop tight.
Workflow completed at 2026-05-27T02:44:51Z.
| parseWikiLinkText :: Text -> Maybe WL.WikiLink | ||
| parseWikiLinkText s | ||
| | T.null s = Nothing | ||
| | otherwise = viaNonEmpty WL.mkWikiLinkFromSlugs (Slug.decodeSlug <$> T.splitOn "/" s) | ||
| parseWikiLinkText raw | ||
| | T.null wlPart = Nothing | ||
| | otherwise = viaNonEmpty WL.mkWikiLinkFromSlugs (Slug.decodeSlug <$> T.splitOn "/" wlPart) | ||
| where | ||
| (wlPart, _mAnchor) = WL.dropUrlAnchor raw |
There was a problem hiding this comment.
Shouldn't we upstream this to commonmark-wikilink?
There was a problem hiding this comment.
Upstreamed as WL.parseWikiLinkUrl :: Text -> Maybe (WikiLink, Maybe Anchor) in srid/commonmark-wikilink@a3e5938. It's the non-Pandoc counterpart to delineateLink — does the slash-split, slug-decode, and anchor-strip in one call.
Emanote MCP is now a one-liner that just discards the anchor (commit 7a1b242 on this PR), with a comment naming why the asymmetry from the HTML renderer (which keeps the anchor) is intentional.
Address PR #748 inline review: 'Shouldn't we upstream this to commonmark-wikilink?' The slash-split + slug-decode + anchor-strip combo that Emanote's MCP parseWikiLinkText was open-coding is now a single library helper (srid/commonmark-wikilink#10, commit a3e5938). MCP calls it directly and discards the anchor with a named pattern + comment so the asymmetry from the HTML renderer (which keeps the anchor) stays legible.
![Anchors section of the Wiki Links docs page showing [[markdown#wikilink]] rendered with fragment](https://github.com/srid/emanote/releases/download/evidence-assets/emanote-evidence-wikilink-anchor-docs.png)
![Markdown page scrolled to the Wiki Links section after following [[markdown#wikilink]]](https://github.com/srid/emanote/releases/download/evidence-assets/emanote-evidence-wikilink-anchor-jump.png)
Wiki-links now carry their
#anchorthrough to the rendered href, so[[note#heading]]lands on the matching heading on the target page. Previously the parser stripped the fragment — two TODO comments incommonmark-wikilinkexplicitly named discussion #105 as the intended fix point. This PR lands the fix upstream and wires Emanote to the new branch.The fragment matches Pandoc's auto-generated heading id (the slugified, lowercased form), the same as regular Markdown anchor links —
{#custom-id}header attributes override the auto-slug exactly as you'd expect.Cross-repo flow
Emanote's URL splice already did
<> WL.anchorSuffix mAnchorfor regular[link](url#frag)Markdown links (PR #268 from 2022). Once the upstream parser stops dropping the wikilink anchor, the wikilink path picks up the fragment at the same site, no Emanote rendering changes needed. The Emanote-side diff is mostly a flake bump, docs, tests, and fixtures.Upstream — srid/commonmark-wikilink#10
wikilinkInlinegainsMaybe Anchor#anchorto the URL field of the emittedB.LinkmkWikiLinkFromInlinereturns(WikiLink, Maybe Anchor, [Inline])wikiLinkInlineRenderedsource-form outputHasWikiLink.wikilinktypeclass gainsMaybe Anchor_anchor-- Ignore anchors until #105comments are gonedropUrlAnchorexportedparseWikiLinkUrladdeddelineateLinkfor raw URL input (MCP, CLI, JSON APIs)Version bumped 0.3 → 0.4 (breaking).
What discussion #105 asks for — and what this PR delivers
Discussion #105 enumerates four link forms. This PR delivers cross-file heading anchors fully and the cross-file block-link URL form (link clicks navigate correctly) — the remaining gaps are explicit follow-ups, not silent omissions.
[[note#heading]][[note#^565948]]#^565948fragment, navigation works^blockidsource marker that creates the target)Para ^565948[[#heading]],[[#^blockid]][h](#h)Deferred — same-file
[[#heading]]and[[#^blockid]]Both deferred for the same reason: parser-level handling of an empty wikilink slug. The regular Markdown form
[heading](#heading)continues to work for the same effect.Test coverage
^blockidanchor, plusparseWikiLinkUrlfor non-Pandoc callers.parseUnresolvedRelTargetreturns the anchor for wikilinks and regular links; MCPresolveWikilinkstrips and ignores the anchor (sonote#headingresolves tonote).wikilink-anchor.md+heading-target.md) plus a smoke scenario asserting the rendered href contains#alpha-section. Verified in static, live, and morph modes (80/80 each).Closes #105. Follow-up: #749 (Obsidian block-ID definition syntax).
Try it locally
Generated by
/doon Claude Code (modelclaude-opus-4-7).