mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
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:
parent
566f4553b4
commit
a59c42e22b
7 changed files with 261 additions and 34 deletions
|
|
@ -69,7 +69,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||
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 { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
|
|
@ -4776,8 +4776,30 @@ function App() {
|
|||
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 resolvedPath = toKnowledgePath(wikiPath)
|
||||
const resolvedPath = resolveWikiFilePath(wikiPath)
|
||||
if (!resolvedPath) return null
|
||||
try {
|
||||
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)
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
}, [resolveWikiFilePath])
|
||||
|
||||
const openWikiLink = useCallback(async (wikiPath: string) => {
|
||||
const { path: basePath } = splitWikiFragment(normalizeWikiPath(wikiPath))
|
||||
if (!basePath) return
|
||||
const resolvedPath = await ensureWikiFile(wikiPath)
|
||||
if (resolvedPath) {
|
||||
navigateToFile(resolvedPath)
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ function ComposeBox({
|
|||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({ link: false }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -83,7 +83,8 @@ function nodeToText(node: JsonNode): string {
|
|||
return text
|
||||
} else if (child.type === 'wikiLink') {
|
||||
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') {
|
||||
return '\n'
|
||||
}
|
||||
|
|
@ -189,7 +190,8 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '---'
|
||||
case 'wikiLink': {
|
||||
const path = (node.attrs?.path as string) || ''
|
||||
return `[[${path}]]`
|
||||
const label = (node.attrs?.label as string | null | undefined) || ''
|
||||
return `[[${path}${label ? `|${label}` : ''}]]`
|
||||
}
|
||||
case 'image': {
|
||||
const src = (node.attrs?.src as string) || ''
|
||||
|
|
@ -297,7 +299,7 @@ import { FrontmatterProperties } from './frontmatter-properties'
|
|||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
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 { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||
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 {
|
||||
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
|
||||
getCursorContext: () => { path: string; lineNumber: number } | null
|
||||
|
|
@ -644,6 +746,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
|
|
@ -804,17 +907,57 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||
if (node.type.name === 'wikiLink') {
|
||||
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)
|
||||
return true
|
||||
}
|
||||
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,
|
||||
maybeCommitPrimaryHeading,
|
||||
notePath,
|
||||
preventTitleHeadingDemotion,
|
||||
promoteFirstParagraphToTitleHeading,
|
||||
wikiLinks,
|
||||
])
|
||||
|
||||
const orderedFiles = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) {
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React 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 {
|
||||
Dialog,
|
||||
|
|
@ -110,6 +110,19 @@ function HelpSettings() {
|
|||
<h4 className="text-sm font-medium">Help & Support</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
||||
</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
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-auto py-3"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
||||
|
|
@ -25,9 +25,12 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
for (const match of matches) {
|
||||
const matchIndex = match.index ?? 0
|
||||
const matchText = match[0] ?? ''
|
||||
const rawPath = match[1]?.trim() ?? ''
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
|
||||
const rawLink = match[1]?.trim() ?? ''
|
||||
const { label } = splitWikiAlias(rawLink)
|
||||
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) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
||||
|
|
@ -35,7 +38,8 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
|
|||
|
||||
if (isValidPath) {
|
||||
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)
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(matchText))
|
||||
|
|
@ -80,6 +84,9 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
path: {
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -89,28 +96,34 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
tag: 'wiki-link[data-path]',
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a[data-type="wiki-link"]',
|
||||
getAttrs: (element: Element) => ({
|
||||
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||
label: (element as HTMLElement).getAttribute('data-label'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
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 [
|
||||
'a',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'wiki-link',
|
||||
'data-path': node.attrs.path,
|
||||
'href': '#',
|
||||
'class': 'wiki-link',
|
||||
'aria-label': node.attrs.path,
|
||||
}),
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
'data-type': 'wiki-link',
|
||||
'data-path': node.attrs.path,
|
||||
'href': '#',
|
||||
'class': 'wiki-link',
|
||||
'aria-label': node.attrs.path,
|
||||
},
|
||||
node.attrs.label ? { 'data-label': node.attrs.label } : {}
|
||||
),
|
||||
label,
|
||||
]
|
||||
},
|
||||
|
|
@ -120,7 +133,8 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
markdown: {
|
||||
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
|
||||
const path = node.attrs.path ?? ''
|
||||
state.write(`[[${path}]]`)
|
||||
const label = (node.attrs as { label?: string }).label
|
||||
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
|
||||
},
|
||||
parse: {
|
||||
updateDOM(element: HTMLElement) {
|
||||
|
|
@ -137,14 +151,20 @@ export const WikiLink = Node.create<WikiLinkOptions>({
|
|||
new InputRule({
|
||||
find: wikiLinkInputRegex,
|
||||
handler: ({ state, range, match }) => {
|
||||
const rawPath = match[1]?.trim()
|
||||
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||
const rawLink = match[1]?.trim()
|
||||
const { label } = splitWikiAlias(rawLink ?? '')
|
||||
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.marks().some((mark) => mark.type.spec.code)) return null
|
||||
|
||||
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
|
||||
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
|
||||
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
|
||||
onCreate?.(finalPath)
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,24 +3,50 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
|
|||
export const stripKnowledgePrefix = (path: string) =>
|
||||
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) => {
|
||||
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
|
||||
const { target } = splitWikiAlias(input)
|
||||
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
|
||||
return stripKnowledgePrefix(trimmed)
|
||||
}
|
||||
|
||||
export const ensureMarkdownExtension = (path: string) => {
|
||||
if (path.toLowerCase().endsWith('.md')) return path
|
||||
return `${path}.md`
|
||||
const { path: basePath, heading } = splitWikiFragment(path)
|
||||
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) => {
|
||||
const normalized = normalizeWikiPath(wikiPath)
|
||||
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
|
||||
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
|
||||
const { path: basePath } = splitWikiFragment(normalized)
|
||||
if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
|
||||
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
|
||||
}
|
||||
|
||||
export const wikiLabel = (wikiPath: string) => {
|
||||
const { label } = splitWikiAlias(wikiPath)
|
||||
if (label) return label
|
||||
|
||||
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, '')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue