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

56
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@intlify/unplugin-vue-i18n': '@intlify/unplugin-vue-i18n':
specifier: ^6.0.8 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)) 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': '@octokit/core':
specifier: ^7.0.6 specifier: ^7.0.6
version: 7.0.6 version: 7.0.6
@@ -74,12 +77,18 @@ importers:
fontfaceobserver: fontfaceobserver:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0 version: 2.3.0
github-slugger:
specifier: ^2.0.0
version: 2.0.0
isomorphic-fetch: isomorphic-fetch:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
markdown-it: markdown-it:
specifier: ^14.1.0 specifier: ^14.1.0
version: 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: markdown-it-block-embed:
specifier: ^0.0.3 specifier: ^0.0.3
version: 0.0.3 version: 0.0.3
@@ -1307,6 +1316,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
@@ -3871,6 +3898,9 @@ packages:
getpass@0.1.7: getpass@0.1.7:
resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-base@0.3.0: glob-base@0.3.0:
resolution: {integrity: sha512-ab1S1g1EbO7YzauaJLkgLp7DZVAqj9M/dvKlTt8DkXA2tiOIcSMrlVI2J1RZyB5iJVccEscjGn+kpOG9788MHA==} resolution: {integrity: sha512-ab1S1g1EbO7YzauaJLkgLp7DZVAqj9M/dvKlTt8DkXA2tiOIcSMrlVI2J1RZyB5iJVccEscjGn+kpOG9788MHA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4848,6 +4878,12 @@ packages:
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
engines: {node: '>=0.10.0'} 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: markdown-it-block-embed@0.0.3:
resolution: {integrity: sha512-coWuC/uZY6Z1Gp3wthhJo5yjkG3/gHErNF/emaiEvD98fKzEHNP6GCYGfJfk5o0n31xiaYjbDgef+XtabKOZzA==} resolution: {integrity: sha512-coWuC/uZY6Z1Gp3wthhJo5yjkG3/gHErNF/emaiEvD98fKzEHNP6GCYGfJfk5o0n31xiaYjbDgef+XtabKOZzA==}
@@ -7880,6 +7916,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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': '@mermaid-js/parser@0.6.3':
dependencies: dependencies:
langium: 3.3.1 langium: 3.3.1
@@ -10560,6 +10609,8 @@ snapshots:
dependencies: dependencies:
assert-plus: 1.0.0 assert-plus: 1.0.0
github-slugger@2.0.0: {}
glob-base@0.3.0: glob-base@0.3.0:
dependencies: dependencies:
glob-parent: 2.0.0 glob-parent: 2.0.0
@@ -11597,6 +11648,11 @@ snapshots:
dependencies: dependencies:
object-visit: 1.0.1 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-block-embed@0.0.3: {}
markdown-it-checkbox@1.1.0: markdown-it-checkbox@1.1.0:

View File

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

View File

@@ -30,8 +30,13 @@ export const useLinks = (
return 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({ noteEventBus.emit({
path: href, path,
hash,
currentNoteSHA: toValue(sha), currentNoteSHA: toValue(sha),
user: store.user, user: store.user,
repo: store.repo repo: store.repo

View File

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

View File

@@ -25,7 +25,7 @@ export const useNoteView = () => {
) )
const unsubscribeLink = noteEventBus.addEventBusListener( const unsubscribeLink = noteEventBus.addEventBusListener(
({ path, currentNoteSHA }) => { ({ path, hash, currentNoteSHA }) => {
const currentFile = store.files.find( const currentFile = store.files.find(
(file) => file.sha === currentNoteSHA (file) => file.sha === currentNoteSHA
) )
@@ -38,7 +38,7 @@ export const useNoteView = () => {
return 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 { 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 = ( const scrollToFocusedNote = (
noteId: string | null = null, noteId: string | null = null,
notes: string[] = stackedNotes.value notes: string[] = stackedNotes.value,
hash?: string
) => { ) => {
nextTick(() => { nextTick(() => {
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0 const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
@@ -47,16 +70,21 @@ export const useRouteQueryStackedNotes = () => {
scrollToNote(0) scrollToNote(0)
} }
} }
if (hash && noteId) {
scrollToHashInNote(noteId.replaceAll(":", "-"), hash)
}
}) })
} }
const addStackedNote = ( const addStackedNote = (
currentSha: string, currentSha: string,
sha: string, sha: string,
selector?: string selector?: string,
hash?: string
) => { ) => {
if (stackedNotes.value.includes(sha)) { if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote(selector ?? sha) scrollToFocusedNote(selector ?? sha, stackedNotes.value, hash)
return return
} }
@@ -76,7 +104,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes stackedNotes.value = newStackedNotes
} }
scrollToFocusedNote(selector ?? sha, stackedNotes.value) scrollToFocusedNote(selector ?? sha, stackedNotes.value, hash)
} }
return { return {