From 4ce8c3064985379983baa9d80d1bed835ac07473 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 26 Apr 2026 09:40:26 +0200 Subject: [PATCH] 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) --- package.json | 3 ++ pnpm-lock.yaml | 56 +++++++++++++++++++++ src/bus/noteEventBus.ts | 1 + src/hooks/useLinks.hook.ts | 7 ++- src/hooks/useMarkdown.hook.ts | 20 +++++++- src/hooks/useNoteView.hook.ts | 4 +- src/hooks/useRouteQueryStackedNotes.hook.ts | 36 +++++++++++-- 7 files changed, 118 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 0d39c67..a2015ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5a60dc..312cfe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/bus/noteEventBus.ts b/src/bus/noteEventBus.ts index 62698a4..5a882f8 100644 --- a/src/bus/noteEventBus.ts +++ b/src/bus/noteEventBus.ts @@ -4,6 +4,7 @@ interface EventBusParams { user: string repo: string path: string + hash?: string currentNoteSHA?: string } diff --git a/src/hooks/useLinks.hook.ts b/src/hooks/useLinks.hook.ts index 62cdcbf..f5a6b3d 100644 --- a/src/hooks/useLinks.hook.ts +++ b/src/hooks/useLinks.hook.ts @@ -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 diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts index 473ddd8..d9e8109 100644 --- a/src/hooks/useMarkdown.hook.ts +++ b/src/hooks/useMarkdown.hook.ts @@ -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) => { + slugger.reset() + return env ? md.render(content, env) : md.render(content) +} + export const markdownBuilder = (defaultPrefix?: Ref | 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) => { return { toHTML: (content: string) => - content ? md.render(stripFrontmatter(content)) : "", + content ? renderMarkdown(stripFrontmatter(content)) : "", render: (content: string, prefix?: string) => renderFromUTF8(decodeBase64ToUTF8(content), prefix), renderFromUTF8, diff --git a/src/hooks/useNoteView.hook.ts b/src/hooks/useNoteView.hook.ts index 942905a..7dd8886 100644 --- a/src/hooks/useNoteView.hook.ts +++ b/src/hooks/useNoteView.hook.ts @@ -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) } ) diff --git a/src/hooks/useRouteQueryStackedNotes.hook.ts b/src/hooks/useRouteQueryStackedNotes.hook.ts index 4808f56..fdd79db 100644 --- a/src/hooks/useRouteQueryStackedNotes.hook.ts +++ b/src/hooks/useRouteQueryStackedNotes.hook.ts @@ -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 {