Lucas Galdino50 downloadsResolve GFM-style kebab-case heading links (e.g. #my-heading) at runtime — no export hacks, works in Live Preview and Reading view.
Resolve GFM-style kebab-case heading links at runtime inside Obsidian — no export hacks, no file modification.
Obsidian uses its own heading slug format (case-sensitive, spaces preserved): a heading ## My Heading: Part 1 produces #My Heading: Part 1. GitHub Flavored Markdown (GFM) uses a different standard: the same heading becomes #my-heading-part-1.
This plugin bridges that gap. Links written in GFM format resolve correctly at runtime:
| Without plugin | With plugin |
|---|---|
[test](#red-hat-based-distributions-centos-fedora) → ❌ dead link |
→ ✅ navigates to ## Red Hat-Based Distributions (CentOS, Fedora) |
[[Note#my-heading]] → ❌ unresolved |
→ ✅ resolves to the correct heading in Note.md |
Autocomplete inserts #My Heading |
→ inserts #my-heading with |My Heading alias |
Both clicks and Ctrl+hover previews work. Cross-file links resolve seamlessly. The autocomplete dropdown automatically produces GFM slugs when you type [[#.
Instead of DOM mutation observers or CodeMirror 6 ViewPlugin extensions (which break native behaviors like Ctrl+Hover), this plugin intercepts links at Obsidian's core routing layer:
openLinkText): Monkeypatches app.workspace.openLinkText. When any link is clicked (Live Preview, Source Mode, or Reading View), the slug is looked up in a lightweight DocumentIndex. A temporary virtual block (#^gfm-click-<slug>) is injected into Obsidian's metadata cache, triggering native scroll + highlight — even for duplicate headings.hover-link): Monkeypatches app.workspace.trigger. When Obsidian fires the "hover-link" event, the linktext property is mutated mid-air before the Page Preview plugin processes it.EditorSuggest.selectSuggestion): When you select a heading from the [[# dropdown, the inserted link uses the GFM slug format — including correct collision suffixes for duplicate headings. The original heading text is preserved as the display alias (e.g., [[#my-heading\|My Heading]]).flowchart LR
subgraph Click Flow
click[User Clicks GFM Link] --> patch_open[openLinkText Interceptor]
patch_open --> lookup[Lookup slug in DocumentIndex]
lookup -->|Found| inject[Inject ephemeral Virtual Block<br/>#^gfm-click-slug]
lookup -->|Not Found| pass_click[Pass to Native Obsidian]
inject --> modify_click[Rewrite link to block subpath]
modify_click --> pass_click
pass_click --> native_scroll[Native Scroll & Highlight]
native_scroll -.-> cleanup[Cleanup Virtual Block]
end
subgraph Hover Flow
hover[User Hovers GFM Link] --> patch_trigger[workspace.trigger Interceptor]
patch_trigger --> resolve_hover[Translate slug to Obsidian format]
resolve_hover --> modify_hover[Mutate hover-link payload]
modify_hover --> native_preview[Native Page Preview]
end
Because the routing layer is patched, 100% of native behavior is preserved:
Ctrl + Hover works perfectly without manual coordinate positioning.[Link](file-2.md#slug)) resolve seamlessly.The plugin adds a settings tab under Settings → GFM Heading Links:
| Setting | Default | Description |
|---|---|---|
| Link prefix | "" (empty) |
Character prepended to the heading display text in autocomplete output. Example: § → [[Note#my-heading|§My Heading]]. |
| Link suffix | "" (empty) |
Character appended to the heading display text in autocomplete output. Example: ¶ → [[Note#my-heading|My Heading¶]]. |
| Enable wikilink alias | true |
When using wikilinks ([[), automatically appends |Original Heading after the GFM slug. Disable for bare [[#slug]]. |
Affixes are cosmetic only — they are stripped during link resolution so navigation still works regardless of what prefix/suffix you configure.
npm install # install dependencies
npm run dev # watch mode for development (DEBUG_ENABLED=true)
npm run build # production build: tsc type-check + esbuild bundle
npm test # run unit tests (vitest)
npm run lint # run ESLint with eslint-plugin-obsidianmd
Before submitting to the Obsidian Community Directory, run npm run lint to catch issues that would fail the automated source code review:
minAppVersion must use three-segment semver (x.y.z, not x.y).minAppVersion are allowed.Powered by eslint-plugin-obsidianmd.
main — production (DEBUG_ENABLED=false). Tag releases here.dev — development (DEBUG_ENABLED=true). Feature branches from here.<a id="..."> and <a name="..."> targets resolve correctly on click (all view modes — handled by revealTargetInView fallback in patch-link-click.ts), but hover preview does not yet support HTML anchors. The hover interceptor uses the synchronous resolveGfmTargetSync() path which only consults the in-memory metadata cache.chooser.values internals (6 fallback paths, confirmed via runtime probe 2026-07-26). If Obsidian restructures its autocomplete internals, duplicate headings may briefly lose collision suffixes until the probe paths are updated. All undocumented type surfaces are centralized in src/types.ts under the UNDOCUMENTED header.