Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
const linkRegex = /(\[([^\[]+)\]\((https?:\/\/[^\)]+)\))|(\!\[([^\[]+)\]\((https?:\/\/[^\)]+)\))/g;
// Matches markdown links and images in a string.
// Group 1 (full link): [text](url) e.g. [Click here](https://example.com)
// Group 2: link text e.g. "Click here"
// Group 3: link url e.g. "https://example.com"
// Group 4 (full image): ![alt](url) e.g. ![Logo](https://example.com/logo.png)
// Group 5: alt text e.g. "Logo"
// Group 6: image url e.g. "https://example.com/logo.png"
const linkRegex = /(\[([^\[]+)\]\(([^\)]+)\))|(\!\[([^\[]+)\]\(([^\)]+)\))/g;

/**
* @internal
Expand All @@ -10,14 +17,38 @@ interface MarkdownSegment {
}

const isValidUrl = (url: string) => {
try {
new URL(url);
if (!url) {
return false;
}

// Accept common non-http schemes and relative paths
if (
url.startsWith('data:') ||
url.startsWith('blob:') ||
url.startsWith('/') ||
url.startsWith('./') ||
url.startsWith('../')
) {
return true;
}

try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch (_) {
return false;
}
};

function pushText(result: MarkdownSegment[], text: string) {
const last = result[result.length - 1];
if (last && last.type === 'text') {
last.text += text;
} else {
result.push({ type: 'text', text, url: '' });
}
}

/**
* @internal
*/
Expand All @@ -28,28 +59,28 @@ export function splitParagraphSegments(text: string): MarkdownSegment[] {

while ((match = linkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ type: 'text', text: text.slice(lastIndex, match.index), url: '' });
pushText(result, text.slice(lastIndex, match.index));
}

if (match[2] && match[3]) {
result.push(
isValidUrl(match[3])
? { type: 'link', text: match[2], url: match[3] }
: { type: 'text', text: match[0], url: '' }
);
if (isValidUrl(match[3])) {
result.push({ type: 'link', text: match[2], url: match[3] });
} else {
pushText(result, match[0]);
}
} else if (match[5] && match[6]) {
result.push(
isValidUrl(match[6])
? { type: 'image', text: match[5], url: match[6] }
: { type: 'text', text: match[0], url: '' }
);
if (isValidUrl(match[6])) {
result.push({ type: 'image', text: match[5], url: match[6] });
} else {
pushText(result, match[0]);
}
}

lastIndex = linkRegex.lastIndex;
}

if (lastIndex < text.length) {
result.push({ type: 'text', text: text.slice(lastIndex), url: '' });
pushText(result, text.slice(lastIndex));
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,110 @@ describe('splitLinksAndImages', () => {
]
);
});

it('should treat invalid link as text but still render valid image', () => {
runTest('[link](ht3tps://www.example.com) and ![image](https://www.example.com)', [
{ text: '[link](ht3tps://www.example.com) and ', type: 'text', url: '' },
{ text: 'image', type: 'image', url: 'https://www.example.com' },
]);
});

it('should render valid link but treat invalid image as text', () => {
runTest('[link](https://www.example.com) and ![image](http3s://www.example.com)', [
{ text: 'link', type: 'link', url: 'https://www.example.com' },
{ text: ' and ![image](http3s://www.example.com)', type: 'text', url: '' },
]);
});

it('should accept data: URL for image', () => {
runTest('![image](data:image/png;base64,abc123)', [
{ text: 'image', type: 'image', url: 'data:image/png;base64,abc123' },
]);
});

it('should accept blob: URL for image', () => {
runTest('![image](blob:https://example.com/some-id)', [
{ text: 'image', type: 'image', url: 'blob:https://example.com/some-id' },
]);
});

it('should accept absolute path for link', () => {
runTest('[link](/path/to/page)', [{ text: 'link', type: 'link', url: '/path/to/page' }]);
});

it('should accept relative path with ./ for link', () => {
runTest('[link](./relative/path)', [
{ text: 'link', type: 'link', url: './relative/path' },
]);
});

it('should accept relative path with ../ for link', () => {
runTest('[link](../parent/path)', [{ text: 'link', type: 'link', url: '../parent/path' }]);
});

it('should handle text before and after a link', () => {
runTest('before [link](https://www.example.com) after', [
{ text: 'before ', type: 'text', url: '' },
{ text: 'link', type: 'link', url: 'https://www.example.com' },
{ text: ' after', type: 'text', url: '' },
]);
});

it('should accept http: URL', () => {
runTest('[link](http://www.example.com)', [
{ text: 'link', type: 'link', url: 'http://www.example.com' },
]);
});

it('should accept URL with query string and fragment', () => {
runTest('[link](https://www.example.com/page?q=1&r=2#section)', [
{
text: 'link',
type: 'link',
url: 'https://www.example.com/page?q=1&r=2#section',
},
]);
});

it('should handle two adjacent links with no text between', () => {
runTest('[first](https://www.example.com/1)[second](https://www.example.com/2)', [
{ text: 'first', type: 'link', url: 'https://www.example.com/1' },
{ text: 'second', type: 'link', url: 'https://www.example.com/2' },
]);
});

it('should treat a single invalid link as plain text', () => {
runTest('[link](ht3tps://www.example.com)', [
{ text: '[link](ht3tps://www.example.com)', type: 'text', url: '' },
]);
});

it('should treat a single invalid image as plain text', () => {
runTest('![image](http3s://www.example.com)', [
{ text: '![image](http3s://www.example.com)', type: 'text', url: '' },
]);
});

it('should treat partial markdown syntax as plain text', () => {
runTest('[not a link] and (not a url)', [
{ text: '[not a link] and (not a url)', type: 'text', url: '' },
]);
});

it('should accept relative path for image', () => {
runTest('![image](./images/photo.png)', [
{ text: 'image', type: 'image', url: './images/photo.png' },
]);
});

it('should handle multiple images in a row', () => {
runTest(
'![first](https://www.example.com/1.png) ![second](https://www.example.com/2.png)',
[
{ text: 'first', type: 'image', url: 'https://www.example.com/1.png' },
{ text: ' ', type: 'text', url: '' },
{ text: 'second', type: 'image', url: 'https://www.example.com/2.png' },
]
);
});
});
Loading