fix(navigation): support anchor fragments in note links

Links like `path/to/note.md#heading` previously errored with "Note not
found" because the full href (including `#hash`) was matched against
file paths. Split the fragment off in the link handler, plumb it through
the event bus, and scroll the matching heading into view once the
target note is in place. Headings now get GitHub-style ids via
markdown-it-anchor + github-slugger so the anchors actually exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julien Calixte
2026-04-26 09:40:26 +02:00
parent d098b3b404
commit 4ce8c30649
7 changed files with 118 additions and 9 deletions

View File

@@ -22,6 +22,7 @@
"@better-fetch/fetch": "^1.1.21",
"@better-fetch/logger": "^1.1.21",
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@mdit/plugin-tab": "^0.24.2",
"@octokit/core": "^7.0.6",
"@octokit/rest": "^22.0.1",
"@openpanel/web": "^1.3.0",
@@ -40,8 +41,10 @@
"events": "^3.3.0",
"font-color-contrast": "^11.1.0",
"fontfaceobserver": "^2.3.0",
"github-slugger": "^2.0.0",
"isomorphic-fetch": "^3.0.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-block-embed": "^0.0.3",
"markdown-it-checkbox": "^1.1.0",
"markdown-it-github-alerts": "^1.0.0",

56
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.8
version: 6.0.8(@vue/compiler-dom@3.5.28)(eslint@8.57.1)(rollup@2.79.2)(typescript@5.9.3)(vue-i18n@11.1.11(vue@3.5.18(typescript@5.9.3)))(vue@3.5.18(typescript@5.9.3))
'@mdit/plugin-tab':
specifier: ^0.24.2
version: 0.24.2(markdown-it@14.1.0)
'@octokit/core':
specifier: ^7.0.6
version: 7.0.6
@@ -74,12 +77,18 @@ importers:
fontfaceobserver:
specifier: ^2.3.0
version: 2.3.0
github-slugger:
specifier: ^2.0.0
version: 2.0.0
isomorphic-fetch:
specifier: ^3.0.0
version: 3.0.0
markdown-it:
specifier: ^14.1.0
version: 14.1.0
markdown-it-anchor:
specifier: ^9.2.0
version: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.0)
markdown-it-block-embed:
specifier: ^0.0.3
version: 0.0.3
@@ -1307,6 +1316,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mdit/helper@0.23.2':
resolution: {integrity: sha512-w4oja7kZYnkSiodfn4Neg1gmlIkvQtmCBJTLvLFOaET7xt8KomDNPQeumpGobQ9dWkXFqBKHlxjTYgroPH+CvA==}
engines: {node: '>= 20'}
peerDependencies:
markdown-it: ^14.1.0
peerDependenciesMeta:
markdown-it:
optional: true
'@mdit/plugin-tab@0.24.2':
resolution: {integrity: sha512-9rN23SP4beO0shBOuSGLGR+Ia7fminVSH6xl5Rb6rh6rRYQ6R3NR2KkIfLZvoMCRiN2uDwhXT/R9LyXHOdRMUQ==}
engines: {node: '>= 20'}
peerDependencies:
markdown-it: ^14.1.0
peerDependenciesMeta:
markdown-it:
optional: true
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
@@ -3871,6 +3898,9 @@ packages:
getpass@0.1.7:
resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-base@0.3.0:
resolution: {integrity: sha512-ab1S1g1EbO7YzauaJLkgLp7DZVAqj9M/dvKlTt8DkXA2tiOIcSMrlVI2J1RZyB5iJVccEscjGn+kpOG9788MHA==}
engines: {node: '>=0.10.0'}
@@ -4848,6 +4878,12 @@ packages:
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
engines: {node: '>=0.10.0'}
markdown-it-anchor@9.2.0:
resolution: {integrity: sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==}
peerDependencies:
'@types/markdown-it': '*'
markdown-it: '*'
markdown-it-block-embed@0.0.3:
resolution: {integrity: sha512-coWuC/uZY6Z1Gp3wthhJo5yjkG3/gHErNF/emaiEvD98fKzEHNP6GCYGfJfk5o0n31xiaYjbDgef+XtabKOZzA==}
@@ -7880,6 +7916,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mdit/helper@0.23.2(markdown-it@14.1.0)':
dependencies:
'@types/markdown-it': 14.1.2
optionalDependencies:
markdown-it: 14.1.0
'@mdit/plugin-tab@0.24.2(markdown-it@14.1.0)':
dependencies:
'@mdit/helper': 0.23.2(markdown-it@14.1.0)
'@types/markdown-it': 14.1.2
optionalDependencies:
markdown-it: 14.1.0
'@mermaid-js/parser@0.6.3':
dependencies:
langium: 3.3.1
@@ -10560,6 +10609,8 @@ snapshots:
dependencies:
assert-plus: 1.0.0
github-slugger@2.0.0: {}
glob-base@0.3.0:
dependencies:
glob-parent: 2.0.0
@@ -11597,6 +11648,11 @@ snapshots:
dependencies:
object-visit: 1.0.1
markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.0):
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
markdown-it-block-embed@0.0.3: {}
markdown-it-checkbox@1.1.0:

View File

@@ -4,6 +4,7 @@ interface EventBusParams {
user: string
repo: string
path: string
hash?: string
currentNoteSHA?: string
}

View File

@@ -30,8 +30,13 @@ export const useLinks = (
return
}
const hashIndex = href.indexOf("#")
const path = hashIndex === -1 ? href : href.slice(0, hashIndex)
const hash = hashIndex === -1 ? undefined : href.slice(hashIndex + 1)
noteEventBus.emit({
path: href,
path,
hash,
currentNoteSHA: toValue(sha),
user: store.user,
repo: store.repo

View File

@@ -1,7 +1,10 @@
import { tab } from "@mdit/plugin-tab"
import markdownItKatex from "@vscode/markdown-it-katex"
import GithubSlugger from "github-slugger"
import MarkdownIt, { Options } from "markdown-it"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
import type Token from "markdown-it/lib/token.mjs"
import markdownItAnchor from "markdown-it-anchor"
import blockEmbedPlugin from "markdown-it-block-embed"
import markdownItCheckbox from "markdown-it-checkbox"
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
@@ -45,6 +48,8 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
}
}
const slugger = new GithubSlugger()
const md = new MarkdownIt({
typographer: true,
quotes: ["«\xA0", "\xA0»", "\xA0", "\xA0"]
@@ -64,6 +69,12 @@ const md = new MarkdownIt({
})
.use(MarkdownItGitHubAlerts)
.use(markdownItTablerIcons)
.use(tab, {
name: "tabs"
})
.use(markdownItAnchor, {
slugify: (s: string) => slugger.slug(s)
})
let shikijiInitialized = false
@@ -123,11 +134,16 @@ const stripFrontmatter = (content: string): string => {
return match ? content.slice(match[0].length) : content
}
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
slugger.reset()
return env ? md.render(content, env) : md.render(content)
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
const renderFromUTF8 = (content: string, prefix?: string) => {
return content
? md.render(stripFrontmatter(content), {
? renderMarkdown(stripFrontmatter(content), {
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
})
: ""
@@ -135,7 +151,7 @@ export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
return {
toHTML: (content: string) =>
content ? md.render(stripFrontmatter(content)) : "",
content ? renderMarkdown(stripFrontmatter(content)) : "",
render: (content: string, prefix?: string) =>
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
renderFromUTF8,

View File

@@ -25,7 +25,7 @@ export const useNoteView = () => {
)
const unsubscribeLink = noteEventBus.addEventBusListener(
({ path, currentNoteSHA }) => {
({ path, hash, currentNoteSHA }) => {
const currentFile = store.files.find(
(file) => file.sha === currentNoteSHA
)
@@ -38,7 +38,7 @@ export const useNoteView = () => {
return
}
addStackedNote(currentNoteSHA ?? "", file.sha)
addStackedNote(currentNoteSHA ?? "", file.sha, undefined, hash)
}
)

View File

@@ -20,9 +20,32 @@ export const useRouteQueryStackedNotes = () => {
const { scrollToNote, isMobile } = useOverlay(false)
const scrollToHashInNote = (
cleanSha: string,
hash: string,
attempts = 30
) => {
if (attempts <= 0) {
return
}
const heading = document.querySelector(
`.note-${cleanSha} #${CSS.escape(hash)}`
)
if (heading) {
heading.scrollIntoView({ block: "start", inline: "nearest" })
return
}
requestAnimationFrame(() => {
scrollToHashInNote(cleanSha, hash, attempts - 1)
})
}
const scrollToFocusedNote = (
noteId: string | null = null,
notes: string[] = stackedNotes.value
notes: string[] = stackedNotes.value,
hash?: string
) => {
nextTick(() => {
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
@@ -47,16 +70,21 @@ export const useRouteQueryStackedNotes = () => {
scrollToNote(0)
}
}
if (hash && noteId) {
scrollToHashInNote(noteId.replaceAll(":", "-"), hash)
}
})
}
const addStackedNote = (
currentSha: string,
sha: string,
selector?: string
selector?: string,
hash?: string
) => {
if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote(selector ?? sha)
scrollToFocusedNote(selector ?? sha, stackedNotes.value, hash)
return
}
@@ -76,7 +104,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes
}
scrollToFocusedNote(selector ?? sha, stackedNotes.value)
scrollToFocusedNote(selector ?? sha, stackedNotes.value, hash)
}
return {