Skip to content

Commit 1c50fe2

Browse files
committed
Encode spaces in Markdown image URLs
Fix #514
1 parent 0e970bc commit 1c50fe2

4 files changed

Lines changed: 246 additions & 4 deletions

File tree

src/lib/components/contents/details/widgets/markdown/markdown-preview.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
1111
import { getMediaFieldURL } from '$lib/services/assets/info';
1212
import { entryDraft } from '$lib/services/contents/draft';
13+
import { GLOBAL_IMAGE_REGEX } from '$lib/services/contents/widgets/markdown/constants';
14+
import { encodeImageSrc } from '$lib/services/contents/widgets/markdown/helper';
1315
1416
/**
1517
* @import { WidgetPreviewProps } from '$lib/types/private';
@@ -36,6 +38,7 @@
3638
const collectionName = $derived($entryDraft?.collectionName ?? '');
3739
const fileName = $derived($entryDraft?.fileName);
3840
const { sanitize_preview: sanitize = true } = $derived(fieldConfig);
41+
const markdown = $derived((currentValue ?? '').replace(GLOBAL_IMAGE_REGEX, encodeImageSrc));
3942
4043
/** @type {import("marked").MarkedOptions} */
4144
const markedOptions = {
@@ -65,7 +68,7 @@
6568
6669
$effect(() => {
6770
(async () => {
68-
rawHTML = await marked.parse(currentValue ?? '', markedOptions);
71+
rawHTML = await marked.parse(markdown, markedOptions);
6972
})();
7073
});
7174
</script>

src/lib/services/contents/draft/save/changes.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { siteConfig } from '$lib/services/config';
1010
import { replaceBlobURL } from '$lib/services/contents/draft/save/assets';
1111
import { createEntryPath } from '$lib/services/contents/draft/save/entry-path';
1212
import { serializeContent } from '$lib/services/contents/draft/save/serialize';
13+
import { getField } from '$lib/services/contents/entry/fields';
1314
import { formatEntryFile } from '$lib/services/contents/file/format';
1415
import { getDefaultMediaLibraryOptions } from '$lib/services/integrations/media-libraries/default';
1516

@@ -21,6 +22,7 @@ import { getDefaultMediaLibraryOptions } from '$lib/services/integrations/media-
2122
* EntryDraft,
2223
* EntrySlugVariants,
2324
* FileChange,
25+
* GetFieldArgs,
2426
* InternalLocaleCode,
2527
* LocalizedEntryMap,
2628
* RepositoryFileInfo,
@@ -40,7 +42,17 @@ const createBaseSavingEntryData = async ({
4042
slugs: { defaultLocaleSlug, canonicalSlug, localizedSlugs },
4143
}) => {
4244
const _globalAssetFolder = get(globalAssetFolder);
43-
const { collection, currentLocales, collectionFile, currentValues, files } = draft;
45+
46+
const {
47+
collection,
48+
collectionName,
49+
collectionFile,
50+
fileName,
51+
isIndexFile,
52+
currentLocales,
53+
currentValues,
54+
files,
55+
} = draft;
4456

4557
const {
4658
_i18n: {
@@ -54,14 +66,15 @@ const createBaseSavingEntryData = async ({
5466
const savingAssets = [];
5567
const { slugify_filename: slugificationEnabled = false } = getDefaultMediaLibraryOptions().config;
5668
const { encode_file_path: encodingEnabled = false } = get(siteConfig)?.output ?? {};
69+
/** @type {GetFieldArgs} */
70+
const getFieldArgs = { collectionName, fileName, keyPath: '', valueMap: {}, isIndexFile };
5771

5872
const replaceBlobBaseArgs = {
5973
draft,
6074
defaultLocaleSlug,
6175
changes,
6276
savingAssets,
6377
slugificationEnabled,
64-
encodingEnabled,
6578
};
6679

6780
const localizedEntryMap = Object.fromEntries(
@@ -100,7 +113,15 @@ const createBaseSavingEntryData = async ({
100113
return;
101114
}
102115

103-
const replaceBlobArgs = { ...replaceBlobBaseArgs, keyPath, content };
116+
const field = getField({ ...getFieldArgs, valueMap: content, keyPath });
117+
118+
const replaceBlobArgs = {
119+
...replaceBlobBaseArgs,
120+
keyPath,
121+
content,
122+
// Enable encoding for markdown fields to support embedded images
123+
encodingEnabled: field?.widget === 'markdown' ? true : encodingEnabled,
124+
};
104125

105126
// Replace blob URLs in File/Image fields with asset paths
106127
await Promise.all(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Encode image URLs in Markdown to ensure spaces are properly handled.
3+
* E.g. `![alt](my image.png)` -> `![alt](my%20image.png)`.
4+
* @param {...any} args Arguments from the regex match.
5+
* @returns {string} The encoded image Markdown string.
6+
* @see https://github.com/markedjs/marked/issues/1639
7+
*/
8+
export const encodeImageSrc = (...args) => {
9+
const { alt, src, title } = args.at(-1);
10+
const eSrc = src.replaceAll(' ', '%20');
11+
12+
return title ? `![${alt}](${eSrc} "${title}")` : `![${alt}](${eSrc})`;
13+
};
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { encodeImageSrc } from './helper.js';
4+
5+
describe('encodeImageSrc', () => {
6+
it('should encode spaces in image URLs without title', () => {
7+
// Simulate regex match args with groups for alt and src
8+
const args = [
9+
'![alt text](my image.png)',
10+
'alt text',
11+
'my image.png',
12+
'',
13+
{
14+
alt: 'alt text',
15+
src: 'my image.png',
16+
title: undefined,
17+
},
18+
];
19+
20+
const result = encodeImageSrc(...args);
21+
22+
expect(result).toBe('![alt text](my%20image.png)');
23+
});
24+
25+
it('should encode spaces in image URLs with title', () => {
26+
// Simulate regex match args with groups for alt, src, and title
27+
const args = [
28+
'![alt text](my image.png "Image Title")',
29+
'alt text',
30+
'my image.png',
31+
'Image Title',
32+
{
33+
alt: 'alt text',
34+
src: 'my image.png',
35+
title: 'Image Title',
36+
},
37+
];
38+
39+
const result = encodeImageSrc(...args);
40+
41+
expect(result).toBe('![alt text](my%20image.png "Image Title")');
42+
});
43+
44+
it('should encode multiple spaces in image URLs', () => {
45+
const args = [
46+
'![test](folder name/sub folder/image file.jpg)',
47+
'test',
48+
'folder name/sub folder/image file.jpg',
49+
'',
50+
{
51+
alt: 'test',
52+
src: 'folder name/sub folder/image file.jpg',
53+
title: undefined,
54+
},
55+
];
56+
57+
const result = encodeImageSrc(...args);
58+
59+
expect(result).toBe('![test](folder%20name/sub%20folder/image%20file.jpg)');
60+
});
61+
62+
it('should handle URLs without spaces', () => {
63+
const args = [
64+
'![no spaces](image.png)',
65+
'no spaces',
66+
'image.png',
67+
'',
68+
{
69+
alt: 'no spaces',
70+
src: 'image.png',
71+
title: undefined,
72+
},
73+
];
74+
75+
const result = encodeImageSrc(...args);
76+
77+
expect(result).toBe('![no spaces](image.png)');
78+
});
79+
80+
it('should handle URLs without spaces but with title', () => {
81+
const args = [
82+
'![no spaces](image.png "Title")',
83+
'no spaces',
84+
'image.png',
85+
'Title',
86+
{
87+
alt: 'no spaces',
88+
src: 'image.png',
89+
title: 'Title',
90+
},
91+
];
92+
93+
const result = encodeImageSrc(...args);
94+
95+
expect(result).toBe('![no spaces](image.png "Title")');
96+
});
97+
98+
it('should handle empty alt text', () => {
99+
const args = [
100+
'![](my image.png)',
101+
'',
102+
'my image.png',
103+
'',
104+
{
105+
alt: '',
106+
src: 'my image.png',
107+
title: undefined,
108+
},
109+
];
110+
111+
const result = encodeImageSrc(...args);
112+
113+
expect(result).toBe('![](my%20image.png)');
114+
});
115+
116+
it('should handle URLs with already encoded spaces', () => {
117+
const args = [
118+
'![test](my%20image.png)',
119+
'test',
120+
'my%20image.png',
121+
'',
122+
{
123+
alt: 'test',
124+
src: 'my%20image.png',
125+
title: undefined,
126+
},
127+
];
128+
129+
const result = encodeImageSrc(...args);
130+
131+
expect(result).toBe('![test](my%20image.png)');
132+
});
133+
134+
it('should handle complex alt text with special characters', () => {
135+
const args = [
136+
'![Alt with "quotes" & symbols](my image.png)',
137+
'Alt with "quotes" & symbols',
138+
'my image.png',
139+
'',
140+
{
141+
alt: 'Alt with "quotes" & symbols',
142+
src: 'my image.png',
143+
title: undefined,
144+
},
145+
];
146+
147+
const result = encodeImageSrc(...args);
148+
149+
expect(result).toBe('![Alt with "quotes" & symbols](my%20image.png)');
150+
});
151+
152+
it('should handle title with special characters', () => {
153+
const args = [
154+
'![test](my image.png "Title with "quotes"")',
155+
'test',
156+
'my image.png',
157+
'Title with "quotes"',
158+
{
159+
alt: 'test',
160+
src: 'my image.png',
161+
title: 'Title with "quotes"',
162+
},
163+
];
164+
165+
const result = encodeImageSrc(...args);
166+
167+
expect(result).toBe('![test](my%20image.png "Title with "quotes"")');
168+
});
169+
170+
it('should handle absolute file paths with spaces', () => {
171+
const args = [
172+
'![test](/path/to/my image.png)',
173+
'test',
174+
'/path/to/my image.png',
175+
'',
176+
{
177+
alt: 'test',
178+
src: '/path/to/my image.png',
179+
title: undefined,
180+
},
181+
];
182+
183+
const result = encodeImageSrc(...args);
184+
185+
expect(result).toBe('![test](/path/to/my%20image.png)');
186+
});
187+
188+
it('should handle URLs with query parameters containing spaces', () => {
189+
const args = [
190+
'![test](image.png?param=value with space)',
191+
'test',
192+
'image.png?param=value with space',
193+
'',
194+
{
195+
alt: 'test',
196+
src: 'image.png?param=value with space',
197+
title: undefined,
198+
},
199+
];
200+
201+
const result = encodeImageSrc(...args);
202+
203+
expect(result).toBe('![test](image.png?param=value%20with%20space)');
204+
});
205+
});

0 commit comments

Comments
 (0)