diff --git a/src/renderer/src/components/editor/markdown-internal-links.test.ts b/src/renderer/src/components/editor/markdown-internal-links.test.ts index 439fdf0635b..16de655899f 100644 --- a/src/renderer/src/components/editor/markdown-internal-links.test.ts +++ b/src/renderer/src/components/editor/markdown-internal-links.test.ts @@ -55,6 +55,20 @@ describe('resolveMarkdownLinkTarget', () => { }) }) + it('extracts hash line anchors from Windows drive-letter absolute .md links', () => { + const r = resolveMarkdownLinkTarget( + 'C:\\repo\\docs\\guide.md#L10', + 'C:\\repo\\docs\\note.md', + 'C:\\repo' + ) + expect(r).toMatchObject({ + kind: 'markdown', + absolutePath: 'C:/repo/docs/guide.md', + relativePath: 'docs/guide.md', + line: 10 + }) + }) + it('extracts line from #L10', () => { const r = resolveMarkdownLinkTarget('./guide.md#L10', SOURCE, ROOT) expect(r).toMatchObject({ kind: 'markdown', line: 10, column: undefined }) diff --git a/src/renderer/src/components/editor/markdown-internal-links.ts b/src/renderer/src/components/editor/markdown-internal-links.ts index 2ebf1365d44..21ca7740784 100644 --- a/src/renderer/src/components/editor/markdown-internal-links.ts +++ b/src/renderer/src/components/editor/markdown-internal-links.ts @@ -1,4 +1,8 @@ -import { filesystemPathToFileUri, fileUriToFilesystemPath } from '../../../../shared/file-uri-path' +import { + filesystemPathHrefToFileUri, + filesystemPathToFileUri, + fileUriToFilesystemPath +} from '../../../../shared/file-uri-path' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' // Pure classifier for markdown link targets. Called by the link-activation @@ -112,7 +116,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL | if (isWindowsAbsolutePathLike(rawHref)) { // Why: URL treats `C:\...` as a custom `c:` scheme unless the Windows // absolute path is first converted to the file URL form used downstream. - return new URL(filesystemPathToFileUri(rawHref)) + return new URL(filesystemPathHrefToFileUri(rawHref)) } return new URL(rawHref, toFileUrl(sourceFilePath)) } catch { diff --git a/src/renderer/src/components/editor/markdown-preview-links.test.ts b/src/renderer/src/components/editor/markdown-preview-links.test.ts index f7cb032e22b..c529da696c0 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.test.ts @@ -16,6 +16,12 @@ describe('getMarkdownPreviewLinkTarget', () => { ) }) + it('preserves hash fragments on Windows drive-letter absolute links', () => { + expect( + getMarkdownPreviewLinkTarget('C:\\repo\\docs\\guide.md#L10', '/repo/docs/README.md') + ).toBe('file:///C:/repo/docs/guide.md#L10') + }) + it('preserves external links', () => { expect(getMarkdownPreviewLinkTarget('https://example.com/docs', '/repo/docs/README.md')).toBe( 'https://example.com/docs' diff --git a/src/renderer/src/components/editor/markdown-preview-links.ts b/src/renderer/src/components/editor/markdown-preview-links.ts index d54500fb31a..4833b6f9d1d 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.ts @@ -1,4 +1,8 @@ -import { filesystemPathToFileUri, fileUriToFilesystemPath } from '../../../../shared/file-uri-path' +import { + filesystemPathHrefToFileUri, + filesystemPathToFileUri, + fileUriToFilesystemPath +} from '../../../../shared/file-uri-path' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' function toFileUrl(filePath: string): string { @@ -14,7 +18,7 @@ export function resolveMarkdownPreviewHref(rawUrl: string, filePath: string): UR if (isWindowsAbsolutePathLike(rawUrl)) { // Why: URL treats `C:\...` as a custom `c:` scheme unless we first // normalize the drive path into the file URL form markdown previews use. - return new URL(filesystemPathToFileUri(rawUrl)) + return new URL(filesystemPathHrefToFileUri(rawUrl)) } return new URL(rawUrl, toFileUrl(filePath)) } catch { diff --git a/src/shared/file-uri-path.ts b/src/shared/file-uri-path.ts index 5c29f9604f5..adbc88deffb 100644 --- a/src/shared/file-uri-path.ts +++ b/src/shared/file-uri-path.ts @@ -28,6 +28,30 @@ export function filesystemPathToFileUri(filePath: string): string { return normalizedPath.startsWith('/') ? `file://${encodedPath}` : `file:///${encodedPath}` } +export function filesystemPathHrefToFileUri(filePathHref: string): string { + const suffixIndex = filePathHref.search(/[?#]/) + if (suffixIndex === -1) { + return filesystemPathToFileUri(filePathHref) + } + + const pathPart = filePathHref.slice(0, suffixIndex) + const suffix = filePathHref.slice(suffixIndex) + const url = new URL(filesystemPathToFileUri(pathPart)) + if (suffix.startsWith('#')) { + // Why: markdown href fragments like `#L10` should stay URL fragments, + // not become `%23L10` inside the Windows filesystem path. + url.hash = suffix + return url.toString() + } + + const hashIndex = suffix.indexOf('#') + url.search = hashIndex === -1 ? suffix : suffix.slice(0, hashIndex) + if (hashIndex !== -1) { + url.hash = suffix.slice(hashIndex) + } + return url.toString() +} + export function fileUriToFilesystemPath(url: URL): string | null { if (url.protocol !== 'file:') { return null