fix: make starter vault images portable

This commit is contained in:
lucaronin
2026-04-23 16:31:36 +02:00
parent 6adf7fd53b
commit fb0d550eb2
8 changed files with 378 additions and 80 deletions

View File

@@ -514,6 +514,27 @@ describe('raw-mode sync content guards', () => {
expect(rawLatestContentRef.current).toBe(result)
})
it('keeps raw-mode serialization portable for vault attachment images', () => {
const rawLatestContentRef = { current: null as string | null }
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce(
'# Test Project\n\n![shot](asset://localhost/%2Fvault%2Fattachments%2Fshot.png)\n',
)
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: mockEntry.path,
activeTabContent: mockContent,
rawLatestContentRef,
vaultPath: '/vault',
})
expect(result).toBe(
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\n![shot](attachments/shot.png)\n',
)
expect(rawLatestContentRef.current).toBe(result)
})
it('does not emit a content change when leaving raw mode without user edits', () => {
const onContentChange = vi.fn()
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'

View File

@@ -184,6 +184,7 @@ function useEditorSetup({
activeTabPath,
activeTab?.content ?? null,
onContentChange,
vaultPath,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
@@ -197,7 +198,7 @@ function useEditorSetup({
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEditorFocus(editor, editorMountedRef)

View File

@@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { portableImageUrls } from '../utils/vaultImages'
interface Tab {
entry: VaultEntry
@@ -24,10 +25,12 @@ export function buildPendingRawExitContent(
export function serializeEditorDocumentToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
tabContent: string,
vaultPath?: string,
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
}
@@ -74,11 +77,12 @@ export function syncActiveTabIntoRawBuffer(options: {
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
vaultPath?: string
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef } = options
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, vaultPath } = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
rawLatestContentRef.current = syncedContent
return syncedContent
}

View File

@@ -135,6 +135,7 @@ function useHandleFlushPending({
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
vaultPath,
}: {
editor: ReturnType<typeof useCreateBlockNote>
activeTabPath: string | null
@@ -145,6 +146,7 @@ function useHandleFlushPending({
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
vaultPath?: string
}) {
return useCallback(async () => {
rawSourceContentRef.current = activeTabContent
@@ -153,6 +155,7 @@ function useHandleFlushPending({
activeTabPath,
activeTabContent,
rawLatestContentRef,
vaultPath,
})
rawInitialContentRef.current = syncedContent ?? activeTabContent
pendingRawRestoreRef.current = buildPendingRawRestore({
@@ -176,6 +179,7 @@ function useHandleFlushPending({
rawLatestContentRef,
rawSourceContentRef,
setRawModeContentOverride,
vaultPath,
])
}
@@ -246,21 +250,16 @@ function useSyncRawModeContentOverride({
rawSourceContentRef: React.MutableRefObject<string | null>
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
}) {
const syncRawModeContentOverride = (
current: PendingRawExitContent | null,
nextContent: string,
) => {
if (!current) return current
if (current.path !== activeTabPath || current.content === nextContent) return current
return { path: activeTabPath, content: nextContent }
}
useLayoutEffect(() => {
if (!activeTabPath || activeTabContent === null) return
if (rawSourceContentRef.current === null || activeTabContent === rawSourceContentRef.current) return
const nextContent = activeTabContent
setRawModeContentOverride((current) => syncRawModeContentOverride(current, nextContent))
setRawModeContentOverride((current) => {
if (!current) return current
if (current.path !== activeTabPath || current.content === nextContent) return current
return { path: activeTabPath, content: nextContent }
})
}, [activeTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride])
}
@@ -269,6 +268,7 @@ export function useRawModeWithFlush(
activeTabPath: string | null,
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
vaultPath?: string,
) {
const rawLatestContentRef = useRef<string | null>(null)
const rawInitialContentRef = useRef<string | null>(null)
@@ -304,6 +304,7 @@ export function useRawModeWithFlush(
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
vaultPath,
})
const handleBeforeRawEnd = useHandleBeforeRawEnd({
activeTabPath,

View File

@@ -0,0 +1,59 @@
import { splitFrontmatter } from '../utils/wikilinks'
type MarkdownContent = string
type FilePath = string
type Frontmatter = string
type NoteTitle = string
type PathStem = string
type HeadingTextInline = { type?: string; text?: string }
export function extractEditorBody(rawFileContent: MarkdownContent): MarkdownContent {
const [, rawBody] = splitFrontmatter(rawFileContent)
return rawBody.trimStart()
}
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: HeadingTextInline[]
} | undefined
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
export function getH1TextFromBlocks(blocks: unknown[]): NoteTitle | null {
const content = extractH1Content(blocks)
if (!content) return null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
export function replaceTitleInFrontmatter(frontmatter: Frontmatter, newTitle: NoteTitle): Frontmatter {
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
export function pathStem(path: FilePath): PathStem {
const filename = path.split('/').pop() ?? path
return filename.replace(/\.md$/, '')
}
export function slugifyPathStem(title: NoteTitle): PathStem {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
export function isUntitledPath(path: FilePath): boolean {
return pathStem(path).startsWith('untitled-')
}

View File

@@ -4,6 +4,15 @@ import type { VaultEntry } from '../types'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import {
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
pathStem,
slugifyPathStem,
} from './editorTabContent'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
interface Tab {
entry: VaultEntry
@@ -32,6 +41,7 @@ interface UseEditorTabSwapOptions {
onContentChange?: (path: string, content: string) => void
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
rawMode?: boolean
vaultPath?: string
}
function signalEditorTabSwapped(path: string): void {
@@ -41,63 +51,6 @@ function signalEditorTabSwapped(path: string): void {
finishNoteOpenTrace(path)
}
/** Strip the YAML frontmatter from raw file content, returning the body
* (including any H1 heading) that should appear in the editor. */
export function extractEditorBody(rawFileContent: string): string {
const [, rawBody] = splitFrontmatter(rawFileContent)
return rawBody.trimStart()
}
type HeadingTextInline = { type?: string; text?: string }
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: HeadingTextInline[]
} | undefined
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
const content = extractH1Content(blocks)
if (!content) return null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
/** Replace the title: line in YAML frontmatter with a new title value. */
export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string {
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
function pathStem(path: string): string {
const filename = path.split('/').pop() ?? path
return filename.replace(/\.md$/, '')
}
function slugifyPathStem(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
function isUntitledPath(path: string): boolean {
return pathStem(path).startsWith('untitled-')
}
function readEditorScrollTop(): number {
const scrollEl = document.querySelector('.editor__blocknote-container')
return scrollEl?.scrollTop ?? 0
@@ -179,16 +132,21 @@ async function parseMarkdownBlocks(
}
async function resolveBlocksForTarget(
editor: ReturnType<typeof useCreateBlockNote>,
cache: Map<string, CachedTabState>,
targetPath: string,
content: string,
options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
content: string
vaultPath?: string
},
): Promise<CachedTabState> {
const { editor, cache, targetPath, content, vaultPath } = options
const cached = cache.get(targetPath)
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const withImages = vaultPath ? resolveImageUrls(body, vaultPath) : body
const preprocessed = preProcessWikilinks(withImages)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
@@ -375,6 +333,7 @@ function useEditorChangeHandler(options: {
suppressChangeRef: MutableRefObject<boolean>
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const {
editor,
@@ -384,6 +343,7 @@ function useEditorChangeHandler(options: {
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
} = options
return useCallback(() => {
@@ -396,7 +356,10 @@ function useEditorChangeHandler(options: {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const bodyMarkdown = vaultPathRef.current
? portableImageUrls(rawBodyMarkdown, vaultPathRef.current)
: rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tab.content)
const nextContent = `${frontmatter}${bodyMarkdown}`
pendingLocalContentRef.current = { path, content: nextContent }
@@ -406,7 +369,7 @@ function useEditorChangeHandler(options: {
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef])
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef])
}
function consumeRawModeTransition(
@@ -781,6 +744,7 @@ function scheduleEmptyHeadingSwap(options: {
content: string
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
vaultPath?: string
}) {
const {
editor,
@@ -822,9 +786,10 @@ function scheduleParsedBlockSwap(options: {
content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
} = options
void resolveBlocksForTarget(editor, cache, targetPath, content)
void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath })
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
@@ -846,6 +811,7 @@ function scheduleTabSwap(options: {
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
vaultPath?: string
}) {
const {
editor,
@@ -856,6 +822,7 @@ function scheduleTabSwap(options: {
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
vaultPath,
} = options
suppressChangeRef.current = true
@@ -892,6 +859,7 @@ function scheduleTabSwap(options: {
content: activeTab.content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
})
}
@@ -991,6 +959,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPath?: string
}) {
const {
tabs,
@@ -1005,6 +974,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPath,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
@@ -1040,6 +1010,7 @@ function runTabSwapEffect(options: {
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
vaultPath,
})
}
@@ -1056,6 +1027,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const {
tabs,
@@ -1070,6 +1042,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
} = options
useEffect(() => {
@@ -1086,6 +1059,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPath: vaultPathRef.current,
})
}, [
activeTabPath,
@@ -1100,6 +1074,7 @@ function useTabSwapEffect(options: {
tabCacheRef,
tabs,
pendingLocalContentRef,
vaultPathRef,
])
}
@@ -1114,7 +1089,7 @@ function useTabSwapEffect(options: {
*
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) {
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
const prevActivePathRef = useRef<string | null>(null)
@@ -1125,6 +1100,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const suppressChangeRef = useRef(false)
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const vaultPathRef = useLatestRef(vaultPath)
const handleEditorChange = useEditorChangeHandler({
editor,
tabsRef,
@@ -1133,6 +1109,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
})
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
@@ -1149,6 +1126,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
})
return { handleEditorChange, editorMountedRef }

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest'
import { resolveImageUrls, portableImageUrls } from './vaultImages'
let tauriMode = false
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => tauriMode,
}))
function assetUrl(path: string): string {
return `asset://localhost/${encodeURIComponent(path)}`
}
describe('resolveImageUrls', () => {
it('is a no-op outside Tauri', () => {
tauriMode = false
const markdown = '![alt](attachments/file.png)'
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
it('is a no-op when vaultPath is empty', () => {
tauriMode = true
const markdown = '![alt](attachments/file.png)'
expect(resolveImageUrls(markdown, '')).toBe(markdown)
})
it('converts relative attachment paths to asset URLs', () => {
tauriMode = true
const markdown = '![screenshot](attachments/1776369786040-CleanShot_2026-04-16.png)'
expect(resolveImageUrls(markdown, '/vault')).toBe(
`![screenshot](${assetUrl('/vault/attachments/1776369786040-CleanShot_2026-04-16.png')})`,
)
})
it('leaves already-correct asset URLs unchanged', () => {
tauriMode = true
const url = assetUrl('/vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
it('rewrites legacy asset URLs from a different vault', () => {
tauriMode = true
const legacyUrl = assetUrl('/Users/luca/Workspace/tolaria-getting-started/attachments/CleanShot.png')
const markdown = `![CleanShot](${legacyUrl})`
expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe(
`![CleanShot](${assetUrl('/Users/john/Documents/Getting Started/attachments/CleanShot.png')})`,
)
})
it('leaves external URLs unchanged', () => {
tauriMode = true
const httpImage = '![logo](https://example.com/logo.png)'
const dataImage = '![icon](data:image/png;base64,abc123)'
expect(resolveImageUrls(httpImage, '/vault')).toBe(httpImage)
expect(resolveImageUrls(dataImage, '/vault')).toBe(dataImage)
})
it('handles multiple images in one document', () => {
tauriMode = true
const markdown = `![a](${assetUrl('/old/attachments/a.png')})\n\n![b](attachments/b.png)`
const result = resolveImageUrls(markdown, '/vault')
expect(result).toContain(`![a](${assetUrl('/vault/attachments/a.png')})`)
expect(result).toContain(`![b](${assetUrl('/vault/attachments/b.png')})`)
})
it('preserves alt text and title attributes', () => {
tauriMode = true
const markdown = '![my screenshot](attachments/file.png "starter vault")'
expect(resolveImageUrls(markdown, '/vault')).toBe(
`![my screenshot](${assetUrl('/vault/attachments/file.png')} "starter vault")`,
)
})
it('skips unknown asset URLs without an attachments segment', () => {
tauriMode = true
const url = assetUrl('/some/other/path/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
})
describe('portableImageUrls', () => {
it('converts vault attachment asset URLs to relative paths', () => {
const url = assetUrl('/vault/attachments/1776369786040-CleanShot.png')
const markdown = `![screenshot](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(
'![screenshot](attachments/1776369786040-CleanShot.png)',
)
})
it('is a no-op when vaultPath is empty', () => {
const url = assetUrl('/vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(portableImageUrls(markdown, '')).toBe(markdown)
})
it('leaves asset URLs from other vaults unchanged', () => {
const url = assetUrl('/other-vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(markdown)
})
it('leaves relative and external paths unchanged', () => {
const relativeImage = '![alt](attachments/file.png)'
const httpImage = '![logo](https://example.com/logo.png)'
expect(portableImageUrls(relativeImage, '/vault')).toBe(relativeImage)
expect(portableImageUrls(httpImage, '/vault')).toBe(httpImage)
})
it('handles multiple images', () => {
const markdown = `![a](${assetUrl('/vault/attachments/a.png')})\n\n![b](${assetUrl('/vault/attachments/b.png')})`
const result = portableImageUrls(markdown, '/vault')
expect(result).toContain('![a](attachments/a.png)')
expect(result).toContain('![b](attachments/b.png)')
})
it('preserves title attributes when converting to portable paths', () => {
const markdown = `![shot](${assetUrl('/vault/attachments/a.png')} "starter vault")`
expect(portableImageUrls(markdown, '/vault')).toBe('![shot](attachments/a.png "starter vault")')
})
})
describe('resolveImageUrls / portableImageUrls round-trip', () => {
it('keeps relative attachment markdown stable', () => {
tauriMode = true
const markdown = '![shot](attachments/file.png)'
expect(portableImageUrls(resolveImageUrls(markdown, '/vault'), '/vault')).toBe(markdown)
})
})

82
src/utils/vaultImages.ts Normal file
View File

@@ -0,0 +1,82 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
const ASSET_URL_PREFIX = 'asset://localhost/'
const ATTACHMENTS_SEGMENT = '/attachments/'
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
type Markdown = string
type VaultPath = string
type AttachmentPath = string
type AbsolutePath = string
type MarkdownImageUrl = string
// Matches markdown image syntax: ![alt](url) or ![alt](url "title").
const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g
function assetUrl(path: AbsolutePath): MarkdownImageUrl {
return convertFileSrc(path)
}
function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath {
return `${vaultPath}/${attachmentPath}`
}
function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | null {
const index = absolutePath.lastIndexOf(ATTACHMENTS_SEGMENT)
if (index === -1) return null
const filename = absolutePath.slice(index + ATTACHMENTS_SEGMENT.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function decodeAssetPath(url: MarkdownImageUrl): AbsolutePath {
return decodeURIComponent(url.slice(ASSET_URL_PREFIX.length))
}
function isCurrentVaultAsset(url: MarkdownImageUrl, vaultPath: VaultPath): boolean {
const absolutePath = decodeAssetPath(url)
return absolutePath === vaultPath || absolutePath.startsWith(`${vaultPath}/`)
}
function rewriteMarkdownImages(
markdown: Markdown,
transformUrl: (url: MarkdownImageUrl) => MarkdownImageUrl | null,
): Markdown {
return markdown.replace(MD_IMAGE_PATTERN, (match, alt, url, title = '') => {
const nextUrl = transformUrl(url)
return nextUrl ? `![${alt}](${nextUrl}${title})` : match
})
}
export function resolveImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
if (!isTauri() || !vaultPath) return markdown
return rewriteMarkdownImages(markdown, (url) => {
if (url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) {
return assetUrl(vaultAttachmentPath(vaultPath, url))
}
if (!url.startsWith(ASSET_URL_PREFIX) || isCurrentVaultAsset(url, vaultPath)) {
return null
}
const attachmentPath = extractAttachmentPath(decodeAssetPath(url))
return attachmentPath ? assetUrl(vaultAttachmentPath(vaultPath, attachmentPath)) : null
})
}
export function portableImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
if (!vaultPath) return markdown
const attachmentsPrefix = `${vaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
return rewriteMarkdownImages(markdown, (url) => {
if (!url.startsWith(ASSET_URL_PREFIX)) return null
const absolutePath = decodeAssetPath(url)
if (!absolutePath.startsWith(attachmentsPrefix)) return null
return `${RELATIVE_ATTACHMENTS_PREFIX}${absolutePath.slice(attachmentsPrefix.length)}`
})
}