fix: notes — in-note section links, deep-note wiki resolution, file links (#571)

* fix discord users feedback

* fix: drop duplicate Link extension and scroll headings to viewport top

* fix: collapse ../ segments in note-relative file links

---------

Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
gagan 2026-05-24 23:48:20 +05:30 committed by GitHub
parent 566f4553b4
commit a59c42e22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 34 deletions

View file

@ -69,7 +69,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog" import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event' import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding' import { OnboardingModal } from '@/components/onboarding'
@ -4776,8 +4776,30 @@ function App() {
return () => window.removeEventListener('email-block:draft-with-assistant', handler) return () => window.removeEventListener('email-block:draft-with-assistant', handler)
}, []) }, [])
const resolveWikiFilePath = useCallback((wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath) return null
const targetPath = ensureMarkdownExtension(basePath)
const targetKey = targetPath.toLowerCase()
const exactMatch = knowledgeFiles.find((filePath) => normalizeWikiPath(filePath).toLowerCase() === targetKey)
if (exactMatch) return toKnowledgePath(exactMatch)
if (!basePath.includes('/')) {
const targetBaseName = targetPath.split('/').pop()?.toLowerCase()
const basenameMatches = knowledgeFiles.filter((filePath) => {
const normalizedFile = normalizeWikiPath(filePath)
return normalizedFile.split('/').pop()?.toLowerCase() === targetBaseName
})
if (basenameMatches.length === 1) return toKnowledgePath(basenameMatches[0])
}
return toKnowledgePath(basePath)
}, [knowledgeFiles])
const ensureWikiFile = useCallback(async (wikiPath: string) => { const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath) const resolvedPath = resolveWikiFilePath(wikiPath)
if (!resolvedPath) return null if (!resolvedPath) return null
try { try {
const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath }) const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath })
@ -4794,9 +4816,11 @@ function App() {
console.error('Failed to ensure wiki link target:', err) console.error('Failed to ensure wiki link target:', err)
return null return null
} }
}, []) }, [resolveWikiFilePath])
const openWikiLink = useCallback(async (wikiPath: string) => { const openWikiLink = useCallback(async (wikiPath: string) => {
const { path: basePath } = splitWikiFragment(normalizeWikiPath(wikiPath))
if (!basePath) return
const resolvedPath = await ensureWikiFile(wikiPath) const resolvedPath = await ensureWikiFile(wikiPath)
if (resolvedPath) { if (resolvedPath) {
navigateToFile(resolvedPath) navigateToFile(resolvedPath)

View file

@ -500,7 +500,7 @@ function ComposeBox({
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }), Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({ Placeholder.configure({
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…', placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',

View file

@ -1,5 +1,5 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state' import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
@ -83,7 +83,8 @@ function nodeToText(node: JsonNode): string {
return text return text
} else if (child.type === 'wikiLink') { } else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || '' const path = (child.attrs?.path as string) || ''
return path ? `[[${path}]]` : '' const label = (child.attrs?.label as string | null | undefined) || ''
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
} else if (child.type === 'hardBreak') { } else if (child.type === 'hardBreak') {
return '\n' return '\n'
} }
@ -189,7 +190,8 @@ function blockToMarkdown(node: JsonNode): string {
return '---' return '---'
case 'wikiLink': { case 'wikiLink': {
const path = (node.attrs?.path as string) || '' const path = (node.attrs?.path as string) || ''
return `[[${path}]]` const label = (node.attrs?.label as string | null | undefined) || ''
return `[[${path}${label ? `|${label}` : ''}]]`
} }
case 'image': { case 'image': {
const src = (node.attrs?.src as string) || '' const src = (node.attrs?.src as string) || ''
@ -297,7 +299,7 @@ import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link' import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover' import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css' import '@/styles/editor.css'
@ -523,6 +525,106 @@ const TabIndentExtension = Extension.create({
}, },
}) })
const slugifyHeading = (text: string) =>
text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const decodeLinkTarget = (target: string) => {
try {
return decodeURIComponent(target)
} catch {
return target
}
}
const scrollToHeading = (view: EditorView, rawTarget: string) => {
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
if (!target) return false
const targetSlug = slugifyHeading(target)
let foundPos: number | null = null
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return true
const headingText = node.textContent.trim()
if (
headingText.toLowerCase() === target.toLowerCase()
|| slugifyHeading(headingText) === targetSlug
) {
foundPos = pos
return false
}
return true
})
if (foundPos === null) return false
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
view.dispatch(
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
)
view.focus()
const domAtPos = view.domAtPos(foundPos + 1)
const node = domAtPos.node
const headingEl = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
return true
}
const stripMarkdownExtension = (path: string) =>
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
const isSameNotePath = (linkPath: string, notePath?: string) => {
if (!notePath) return false
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
return normalizedLink === normalizedNote
}
const isExternalHref = (href: string) =>
/^(https?:|mailto:|tel:)/i.test(href)
const collapseRelativeSegments = (relPath: string) => {
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
const stack: string[] = []
for (const part of parts) {
if (part === '..') {
if (stack.length === 0) return null
stack.pop()
} else {
stack.push(part)
}
}
return stack.join('/')
}
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
const withoutHash = href.split('#')[0]
const withoutQuery = withoutHash.split('?')[0]
const decoded = decodeLinkTarget(withoutQuery)
if (!decoded) return null
if (/^file:\/\//i.test(decoded)) {
try {
return decodeURIComponent(new URL(decoded).pathname)
} catch {
return decoded
}
}
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
const noteDir = notePath.split('/').slice(0, -1).join('/')
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
}
export interface MarkdownEditorHandle { export interface MarkdownEditorHandle {
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */ /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
getCursorContext: () => { path: string; lineNumber: number } | null getCursorContext: () => { path: string; lineNumber: number } | null
@ -644,6 +746,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
heading: { heading: {
levels: [1, 2, 3], levels: [1, 2, 3],
}, },
link: false,
}), }),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
@ -804,17 +907,57 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
handleClickOn: (_view, _pos, node, _nodePos, event) => { handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') { if (node.type.name === 'wikiLink') {
event.preventDefault() event.preventDefault()
const wikiPath = String(node.attrs.path ?? '')
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
return scrollToHeading(_view, heading)
}
wikiLinks?.onOpen?.(node.attrs.path) wikiLinks?.onOpen?.(node.attrs.path)
return true return true
} }
return false return false
}, },
handleDOMEvents: {
click: (view, event) => {
const target = event.target as Element | null
const link = target?.closest('a[href]') as HTMLAnchorElement | null
if (!link) return false
if (link.dataset.type === 'wiki-link') return false
const href = link.getAttribute('href') ?? ''
if (!href) return false
if (href.startsWith('#')) {
event.preventDefault()
return scrollToHeading(view, href)
}
if (isExternalHref(href)) {
event.preventDefault()
window.open(href, '_blank')
return true
}
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
if (!workspacePath) return false
event.preventDefault()
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
if (result.error) console.error('Failed to open linked file:', result.error)
}).catch((err) => {
console.error('Failed to open linked file:', err)
})
return true
},
},
}, },
}, [ }, [
editorSessionKey, editorSessionKey,
maybeCommitPrimaryHeading, maybeCommitPrimaryHeading,
notePath,
preventTitleHeadingDemotion, preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading, promoteFirstParagraphToTitleHeading,
wikiLinks,
]) ])
const orderedFiles = useMemo(() => { const orderedFiles = useMemo(() => {

View file

@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) {
heading: { heading: {
levels: [1, 2, 3], levels: [1, 2, 3],
}, },
link: false,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,

View file

@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react" import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react" import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import { import {
Dialog, Dialog,
@ -110,6 +110,19 @@ function HelpSettings() {
<h4 className="text-sm font-medium">Help &amp; Support</h4> <h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p> <p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div> </div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
</div>
</Button>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-3 h-auto py-3" className="w-full justify-start gap-3 h-auto py-3"

View file

@ -1,5 +1,5 @@
import { InputRule, Node, mergeAttributes } from '@tiptap/core' import { InputRule, Node, mergeAttributes } from '@tiptap/core'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/ const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
@ -25,9 +25,12 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
for (const match of matches) { for (const match of matches) {
const matchIndex = match.index ?? 0 const matchIndex = match.index ?? 0
const matchText = match[0] ?? '' const matchText = match[0] ?? ''
const rawPath = match[1]?.trim() ?? '' const rawLink = match[1]?.trim() ?? ''
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : '' const { label } = splitWikiAlias(rawLink)
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..') const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
if (matchIndex > lastIndex) { if (matchIndex > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex))) fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
@ -35,7 +38,8 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
if (isValidPath) { if (isValidPath) {
const el = document.createElement('wiki-link') const el = document.createElement('wiki-link')
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath)) el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
if (label) el.setAttribute('data-label', label)
fragment.appendChild(el) fragment.appendChild(el)
} else { } else {
fragment.appendChild(document.createTextNode(matchText)) fragment.appendChild(document.createTextNode(matchText))
@ -80,6 +84,9 @@ export const WikiLink = Node.create<WikiLinkOptions>({
path: { path: {
default: '', default: '',
}, },
label: {
default: null,
},
} }
}, },
@ -89,28 +96,34 @@ export const WikiLink = Node.create<WikiLinkOptions>({
tag: 'wiki-link[data-path]', tag: 'wiki-link[data-path]',
getAttrs: (element: Element) => ({ getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '', path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}), }),
}, },
{ {
tag: 'a[data-type="wiki-link"]', tag: 'a[data-type="wiki-link"]',
getAttrs: (element: Element) => ({ getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '', path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}), }),
}, },
] ]
}, },
renderHTML({ node, HTMLAttributes }) { renderHTML({ node, HTMLAttributes }) {
const label = wikiLabel(node.attrs.path) || node.attrs.path const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
return [ return [
'a', 'a',
mergeAttributes(HTMLAttributes, { mergeAttributes(
'data-type': 'wiki-link', HTMLAttributes,
'data-path': node.attrs.path, {
'href': '#', 'data-type': 'wiki-link',
'class': 'wiki-link', 'data-path': node.attrs.path,
'aria-label': node.attrs.path, 'href': '#',
}), 'class': 'wiki-link',
'aria-label': node.attrs.path,
},
node.attrs.label ? { 'data-label': node.attrs.label } : {}
),
label, label,
] ]
}, },
@ -120,7 +133,8 @@ export const WikiLink = Node.create<WikiLinkOptions>({
markdown: { markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) { serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
const path = node.attrs.path ?? '' const path = node.attrs.path ?? ''
state.write(`[[${path}]]`) const label = (node.attrs as { label?: string }).label
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
}, },
parse: { parse: {
updateDOM(element: HTMLElement) { updateDOM(element: HTMLElement) {
@ -137,14 +151,20 @@ export const WikiLink = Node.create<WikiLinkOptions>({
new InputRule({ new InputRule({
find: wikiLinkInputRegex, find: wikiLinkInputRegex,
handler: ({ state, range, match }) => { handler: ({ state, range, match }) => {
const rawPath = match[1]?.trim() const rawLink = match[1]?.trim()
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : '' const { label } = splitWikiAlias(rawLink ?? '')
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
if (
!normalizedPath
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
) return null
if (state.selection.$from.parent.type.spec.code) return null if (state.selection.$from.parent.type.spec.code) return null
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
const finalPath = ensureMarkdownExtension(normalizedPath) const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath })) state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
onCreate?.(finalPath) onCreate?.(finalPath)
}, },
}), }),

View file

@ -3,24 +3,50 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
export const stripKnowledgePrefix = (path: string) => export const stripKnowledgePrefix = (path: string) =>
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
export const splitWikiAlias = (input: string) => {
const separatorIndex = input.indexOf('|')
if (separatorIndex === -1) return { target: input, label: undefined }
const target = input.slice(0, separatorIndex)
const label = input.slice(separatorIndex + 1).trim()
return { target, label: label || undefined }
}
export const splitWikiFragment = (path: string) => {
const hashIndex = path.indexOf('#')
if (hashIndex === -1) return { path: path, heading: undefined }
const basePath = path.slice(0, hashIndex)
const heading = path.slice(hashIndex + 1).trim()
return { path: basePath, heading: heading || undefined }
}
export const normalizeWikiPath = (input: string) => { export const normalizeWikiPath = (input: string) => {
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '') const { target } = splitWikiAlias(input)
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
return stripKnowledgePrefix(trimmed) return stripKnowledgePrefix(trimmed)
} }
export const ensureMarkdownExtension = (path: string) => { export const ensureMarkdownExtension = (path: string) => {
if (path.toLowerCase().endsWith('.md')) return path const { path: basePath, heading } = splitWikiFragment(path)
return `${path}.md` if (!basePath) return heading ? `#${heading}` : path
const filePath = basePath.toLowerCase().endsWith('.md') ? basePath : `${basePath}.md`
return heading ? `${filePath}#${heading}` : filePath
} }
export const toKnowledgePath = (wikiPath: string) => { export const toKnowledgePath = (wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath) const normalized = normalizeWikiPath(wikiPath)
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null const { path: basePath } = splitWikiFragment(normalized)
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}` if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
} }
export const wikiLabel = (wikiPath: string) => { export const wikiLabel = (wikiPath: string) => {
const { label } = splitWikiAlias(wikiPath)
if (label) return label
const normalized = normalizeWikiPath(wikiPath) const normalized = normalizeWikiPath(wikiPath)
const name = normalized.split('/').pop() || normalized const { path: basePath, heading } = splitWikiFragment(normalized)
if (!basePath && heading) return heading
const name = (basePath || normalized).split('/').pop() || normalized
return name.replace(/\.md$/i, '') return name.replace(/\.md$/i, '')
} }