Skip to content

feat(editor): resolve markdown images with note-relative or absolute paths#543

Closed
trapias wants to merge 3 commits into
refactoringhq:mainfrom
trapias:feat/note-relative-image-paths
Closed

feat(editor): resolve markdown images with note-relative or absolute paths#543
trapias wants to merge 3 commits into
refactoringhq:mainfrom
trapias:feat/note-relative-image-paths

Conversation

@trapias

@trapias trapias commented May 5, 2026

Copy link
Copy Markdown

Problem

Markdown image embeds with note-relative paths (![](./img.png), ![](../shared/x.png)) or absolute paths (![](/Users/me/x.png)) were not resolved by the renderer, leaving the embed broken. This is the default shape Obsidian and most markdown editors emit, so importing content from those tools left images visibly missing in Tolaria.

Solution

Teach vaultImages.ts to resolve these forms in resolveImageUrls, and to emit note-relative paths back in portableImageUrls so saved markdown stays portable across machines. URLs with a scheme (https:, data:, …) are explicitly guarded so they keep flowing through to the browser unchanged.

The note path is threaded from the active tab through editorBlockResolution, editorRawModeSync, and useEditorTabSwap so resolution has the context it needs.

URL form Before After
attachments/x.png resolved resolved (unchanged)
![[x.png]] (wikilink) resolved resolved (unchanged)
./x.png, x.png broken resolved against note directory
../shared/x.png broken resolved with .. traversal
/Users/me/x.png broken resolved as-is
https://…, data:… passed through passed through (explicitly guarded)

The image regex was also made non-greedy ([^)"]+? instead of [^)\s"]+) so URLs with raw spaces (![](my photo.png)) match correctly when followed by an optional title.

Files changed

  • src/utils/vaultImages.ts — resolution + reverse-mapping helpers (noteDirectoryPath, joinNoteRelativePath, relativeFromNoteDirectory, hasUrlScheme, isAbsolutePath)
  • src/utils/vaultImages.test.ts — +14 test cases
  • src/hooks/editorBlockResolution.ts — thread notePath through preProcessEditorMarkdown
  • src/components/editorRawModeSync.ts — thread the active tab path through serializeEditorDocumentToMarkdown
  • src/hooks/useEditorTabSwap.ts — pass note path to portableImageUrls on editor changes

Test plan

  • pnpm vitest run src/utils/vaultImages.test.ts src/hooks/useEditorTabSwap.test.ts — 89/89 pass
  • Full Vitest suite: 3742/3744 — the 2 failures (blockNoteSuggestionWrapper.regression.test.tsx, blockNoteTableHandles.regression.test.ts) are pre-existing on eb6b58eb and unrelated to this change (verified via stash-and-rerun)
  • npx tsc --noEmit passes
  • pnpm lint passes
  • Manual: open a note with ![](./img/foo.png) and a real file next to it → image renders
  • Manual: open a note with ![](../shared/x.png) → image renders, traversal honoured
  • Manual: paste a clipboard image → still saved to attachments/ and round-trips correctly
  • Manual: a remote ![](https://…) reference → still loads from network, untouched

Notes

  • Helps close a gap under the Obsidian migration / coexistence Canny entry (status: planned)
  • Complementary to feat: configurable default folders for media and per-type new notes #521 (configurable media folders): that PR decides where new media is written; this PR decides how existing markdown images are read and round-tripped. Disjoint files.
  • Pure frontend change — no Rust, no Tauri command surface changes, no settings.
  • Happy to back out and post on Canny first if reviewers prefer that route per CONTRIBUTING.md.

…paths

Teach the editor to resolve standard markdown image embeds whose URL
is a note-relative path (./foo.png, ../shared/x.png) or an absolute
filesystem path, in addition to the already-supported attachments/
and wikilink forms. This is the shape Obsidian and most markdown
editors emit by default.

portableImageUrls now emits a note-relative path when the image is
inside the vault but outside attachments/, so saved markdown stays
portable across machines.

URLs with a scheme (https:, data:, etc.) are explicitly guarded and
passed through unchanged. The image regex is made non-greedy so URLs
containing raw spaces match correctly when followed by an optional
title.

14 new tests cover note-relative resolution, parent traversal,
%-decoding, Windows separators, round-trip stability, and spaces.
@codacy-production

codacy-production Bot commented May 5, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 16 complexity · 0 duplication

Metric Results
Complexity 16
Duplication 0

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codacy-production codacy-production Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR successfully introduces support for note-relative and absolute image paths, improving compatibility with tools like Obsidian. However, there is a logic error in the path extraction utility that will cause incorrect image resolution for notes located at the root of the filesystem or vault.

Furthermore, the serialization logic in portableImageUrls returns null for non-relativizable assets, which could lead to data loss or broken links in the saved Markdown. There is also a lack of test coverage for absolute path resolution and remote scheme (HTTPS) pass-through. These issues should be addressed to ensure robust handling of filesystem-linked assets.

About this PR

  • While the code contains logic for absolute path resolution and remote scheme guarding, these paths are not covered in src/utils/vaultImages.test.ts. Please add unit tests for these cases.

Test suggestions

  • Resolve a note-relative image path using './' prefix
  • Resolve a note-relative image path using '../' for parent directory traversal
  • Resolve a bare relative image path (no leading dot) against the note directory
  • Resolve an absolute filesystem path
  • Resolve a Markdown image URL containing spaces with and without a title
  • Round-trip an image from a resolved asset URL back to a note-relative path
  • Pass through remote HTTPS image URLs without modification
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Resolve an absolute filesystem path
2. Pass through remote HTTPS image URLs without modification
Low confidence findings
  • The implementation treats paths starting with '/' as system-absolute. Note that in some Markdown editors (e.g., Obsidian), a leading slash can represent the vault root. If this tool aims for full Obsidian compatibility, consider if vault-root resolution should be supported.

TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback

Comment thread src/utils/vaultImages.ts
Comment on lines +74 to +78
function noteDirectoryPath(notePath: NotePath): AbsolutePath {
const idx = Math.max(notePath.lastIndexOf('/'), notePath.lastIndexOf('\\'))
if (idx <= 0) return notePath
return notePath.slice(0, idx)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

The noteDirectoryPath function incorrectly returns the full path of the note when a file is in the root directory (idx === 0). This causes subsequent path joining to append the image filename to the note's filename.

Suggested change
function noteDirectoryPath(notePath: NotePath): AbsolutePath {
const idx = Math.max(notePath.lastIndexOf('/'), notePath.lastIndexOf('\\'))
if (idx <= 0) return notePath
return notePath.slice(0, idx)
}
function noteDirectoryPath(notePath: NotePath): AbsolutePath {
const idx = Math.max(notePath.lastIndexOf('/'), notePath.lastIndexOf('\\'))
if (idx === -1) return '.'
if (idx === 0) return notePath.charAt(0)
return notePath.slice(0, idx)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da5f8f4. noteDirectoryPath now splits the three real cases explicitly: idx === -1 returns '.', idx === 0 returns the separator itself ('/' or '\\'), and idx > 0 returns the prefix slice. Two regression tests added in vaultImages.test.ts covering both edge cases (note at filesystem root, note path without separator).

Comment thread src/utils/vaultImages.ts
Comment on lines 223 to 234
return rewriteMarkdownImages(markdown, (url) => {
if (!isAssetUrl(url)) return null

return currentVaultAttachmentPath(url, vaultPath)
const attachmentPath = currentVaultAttachmentPath(url, vaultPath)
if (attachmentPath) return attachmentPath

if (notePath) {
return noteRelativeFromAssetUrl(url, notePath)
}

return null
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: To ensure the markdown file remains portable across different environments, URLs that cannot be made relative should be converted back to their absolute filesystem paths instead of returning null or keeping the internal asset:// scheme. Use decodeAssetPath(url) as a fallback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da5f8f4. portableImageUrls now falls back to decodeAssetPath(url) when the asset URL cannot be mapped to attachments/ or to a note-relative form, so the internal asset://localhost scheme never survives into saved markdown. One existing test updated to assert the new unwrap-to-absolute-path behavior, two new tests added including a round-trip check.

github-actions Bot and others added 2 commits May 5, 2026 15:02
…set URLs

noteDirectoryPath previously returned the full notePath whenever the
last separator sat at index 0 or was missing, causing path joins to
treat the note's filename as a directory (e.g. /note.md + img.png
became /note.md/img.png). Split the branch into the three real cases:
no separator -> '.', leading-only separator -> the separator itself,
otherwise the prefix.

portableImageUrls now decodes asset URLs that cannot be mapped to
attachments/ or to a note-relative form back to their absolute
filesystem path, so the internal asset://localhost scheme never
survives into saved markdown.

Addresses Codacy review on PR refactoringhq#543.
@trapias

trapias commented May 5, 2026

Copy link
Copy Markdown
Author

Both Codacy findings addressed in da5f8f4:

🔴 HIGH RISKnoteDirectoryPath edge cases at idx === -1 and idx === 0 (see review reply on vaultImages.ts:79).
🟡 MEDIUM RISKportableImageUrls fallback to absolute filesystem path so asset:// never persists in saved markdown (see review reply on vaultImages.ts:239).

Tests: pnpm vitest run src/utils/vaultImages.test.ts — 40/40 passing (2 new tests for HIGH RISK, 2 new tests + 1 updated for MEDIUM RISK).

@LucaRonin

Copy link
Copy Markdown
Collaborator

Merged after some tweaks — closing this now. Thanks again for the contribution!

@LucaRonin

Copy link
Copy Markdown
Collaborator

Merged after some tweaks — closing this now.

@LucaRonin LucaRonin closed this May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants