Skip to content

Support [[note#heading]] wiki-link anchors (discussion #105)#748

Draft
srid wants to merge 9 commits into
masterfrom
heading-link
Draft

Support [[note#heading]] wiki-link anchors (discussion #105)#748
srid wants to merge 9 commits into
masterfrom
heading-link

Conversation

@srid

@srid srid commented May 27, 2026

Copy link
Copy Markdown
Owner

Wiki-links now carry their #anchor through 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 in commonmark-wikilink explicitly 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

[[note#heading]] commonmark-wikilink            emanote
                 ├── parser captures anchor    │
                 ├── wikilinkInline appends    │
                 │   "#heading" to B.Link URL  │
                 │                              │
                 └── delineateLink returns ────▶├── parseUnresolvedRelTarget
                     (WikiLink, Maybe Anchor)   │     yields Maybe Anchor
                                                 ├── urlResolvingSplice
                                                 │     <> WL.anchorSuffix mAnchor
                                                 ▼
                                                 <a href="note.html#heading">

Emanote's URL splice already did <> WL.anchorSuffix mAnchor for 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

Change Why
wikilinkInline gains Maybe Anchor Append #anchor to the URL field of the emitted B.Link
mkWikiLinkFromInline returns (WikiLink, Maybe Anchor, [Inline]) Round-trip for wikiLinkInlineRendered source-form output
HasWikiLink.wikilink typeclass gains Maybe Anchor Plumbing from parser to inline construction
Parser captures the anchor instead of _anchor The actual fix — the two -- Ignore anchors until #105 comments are gone
dropUrlAnchor exported Lets the MCP path strip anchors through the same primitive
parseWikiLinkUrl added Non-Pandoc counterpart to delineateLink for 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.

Form Example Status
Cross-file heading [[note#heading]] ✅ Fully working
Cross-file block link (URL side) [[note#^565948]] ✅ URL renders with #^565948 fragment, navigation works
Block-ID definition (the ^blockid source marker that creates the target) Para ^565948 ❌ Not implemented — tracked in #749
Same-file forms [[#heading]], [[#^blockid]] ❌ Parser-level deferral (empty wikilink slug); use Markdown [h](#h)

Block-ID definition is Obsidian's non-CommonMark trailing-^id marker that turns the preceding block into the anchor target. It's a separate parser/filter concern from anchor consumption in wikilinks. Filed as #749 with a proposed approach.

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

  • Upstream: parser cases for all four wikilink shapes, custom titles, spaces, Obsidian-style ^blockid anchor, plus parseWikiLinkUrl for non-Pandoc callers.
  • Emanote unit: parseUnresolvedRelTarget returns the anchor for wikilinks and regular links; MCP resolveWikilink strips and ignores the anchor (so note#heading resolves to note).
  • Emanote e2e: a new fixture (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

nix run github:srid/emanote/heading-link -- -L docs run

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 7 commits May 26, 2026 22:06
[[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.
@srid

srid commented May 27, 2026

Copy link
Copy Markdown
Owner Author

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey parseUnresolvedRelTarget_ helper for 4 anchor-discard sites No-op
2 Hickey "[[" <> … <> "]]" source-form rebuilt across wikiLinkInlineRendered + plainify' Fixed in this PR
3 Lowy MCP parseWikiLinkText bypasses delineateLink, treats note#heading as a literal-# slug Fixed in this PR
4 Lowy Embed.hs _mAnchor discards (deferred section-scoped embed) Fixed in this PR
5 Lowy Docs missing {#custom-id} override semantics Fixed in this PR

Hickey rationale

Two findings, both around duplicated derivations. A flagged that parseUnresolvedRelTarget returns (URTarget, Maybe Anchor) but four call sites — noteRels and three embed splices — discard the anchor as _mAnchor, while only urlResolvingSplice consumes it; the proposed fix was a parseUnresolvedRelTarget_ wrapper. B flagged that the source-form serialization "[[" <> wikilinkUrl wl <> anchorSuffix manc <> "]]" was open-coded in wikiLinkInlineRendered (already partly factored as target) and plainify' (still open-coded), so the two must stay in lockstep on anchor handling — proposed an exported wikilinkSourceForm helper.

Cross-validation refined both. A → No-op: Lowy's lens identified that the four discards are not the same shape — noteRels discards for graph-edge correctness (anchors would fragment backlink grouping); Embed.hs discards because section-scoped embed is a deferred feature. A uniform wrapper would erase that asymmetry and make the single load-bearing use harder to find. The _mAnchor naming idiom is already structural and right-sized. B → Fix selectively: the dedup is real but the formula is composition of two already-encapsulated primitives (wikilinkUrl, anchorSuffix), not an axis of its own; export would grow the module API around something that isn't volatile. Applied as a local let target = … inside plainify' to mirror wikiLinkInlineRendered's existing local binding. No exported helper.

Lowy rationale

Three findings, all around encapsulating one volatility axis (anchor support) cleanly across the codebase. 1: the MCP resolve_wikilink tool bypassed the renderer's anchor-stripping pipeline, so note#heading resolved to Missing while the HTML renderer resolved it to note — same input, two answers depending on output channel. Fix: route parseWikiLinkText through WL.dropUrlAnchor (added to upstream exports), so both channels share one anchor-stripping primitive. 2: the three _mAnchor discards in Embed.hs encode a deferred axis (section-scoped embed) that should be grep-able from one place — refined to a single updated TODO at the canonical site referencing #105 explicitly, rather than three parallel markers. 3: the docs claimed the fragment "matches Pandoc's auto-generated heading id" but omitted that ## Heading {#custom-id} overrides the auto-slug — added one sentence so the cross-repo protocol is explicit at the user-facing surface.

Cross-validation confirmed all three. Hickey's audit pushed back on the original Lowy-1 framing of "inline dropUrlAnchor" by asking us to treat it as the single point of decomposition — applied as upstream export + MCP call through the same primitive, not as a duplicated T.breakOn "#" snippet.

Commits in this PR

  • refactor(lowy): docs — note Pandoc {#custom-id} attribute behavior — Lowy-3
  • fix(lowy): MCP resolveWikilink strips #anchor so it agrees with renderer — Lowy-1 (paired with upstream feat: export dropUrlAnchor)
  • refactor(lowy): mark embed #anchor discard as a #105 follow-up — Lowy-2
  • refactor(hickey): pull commonmark-wikilink dedupe (local plainify' target binding) — Hickey-B (paired with upstream refactor(hickey): dedupe wikilink source-form into local 'target' binding)

@srid

srid commented May 27, 2026

Copy link
Copy Markdown
Owner Author

Evidence

Shot A — the /wikilinks docs page, Anchors section. The live example [[markdown#wikilink]] renders as a proper wiki-link with the #wikilink fragment intact in its href (/markdown#wikilink), and the prose explains that clicking it scrolls to the Wiki Links section of the Markdown page.

Anchors section of the Wiki Links docs page showing [[markdown#wikilink]] rendered with fragment

Shot B — the result after clicking that link. The browser landed on /markdown#wikilink and the page is scrolled to the "Wiki Links" heading — exactly the section the fragment targets. Before this PR the fragment was silently dropped and the link would have gone to the top of the Markdown page instead.

Markdown page scrolled to the Wiki Links section after following [[markdown#wikilink]]

@srid

srid commented May 27, 2026

Copy link
Copy Markdown
Owner Author

/do results

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-wikilink source before invoking /do (the library already had two Ignore anchors until #105 TODOs pointing at the fix point) would have saved a few minutes.
  • police caught a real defect (irrefutable Right pattern in the new MCP test) — keep /code-police enabled, but write tests with explicit case from the start when the value is Either 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.

Comment thread emanote/src/Emanote/MCP/ToolCatalog.hs Outdated
Comment on lines +171 to +176
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we upstream this to commonmark-wikilink?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
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