diff --git a/src/lib/utils/checklist.test.ts b/src/lib/utils/checklist.test.ts
index c3905b3..8171468 100644
--- a/src/lib/utils/checklist.test.ts
+++ b/src/lib/utils/checklist.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js';
+import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
describe('parseChecklist', () => {
it('parses flat checklist items', () => {
@@ -220,3 +220,96 @@ describe('outdentItem', () => {
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
});
});
+
+describe('linkifyText', () => {
+ it('converts https URLs to anchor tags', () => {
+ expect(linkifyText('visit https://example.com today')).toBe(
+ 'visit https://example.com today'
+ );
+ });
+
+ it('converts http URLs to anchor tags', () => {
+ expect(linkifyText('go to http://example.com')).toBe(
+ 'go to http://example.com'
+ );
+ });
+
+ it('converts bare www URLs with https href', () => {
+ expect(linkifyText('check www.example.com')).toBe(
+ 'check www.example.com'
+ );
+ });
+
+ it('handles multiple URLs in one string', () => {
+ const result = linkifyText('see https://a.com and https://b.com');
+ expect(result).toContain('href="https://a.com"');
+ expect(result).toContain('href="https://b.com"');
+ });
+
+ it('returns plain text unchanged when no URLs present', () => {
+ expect(linkifyText('just some text')).toBe('just some text');
+ });
+
+ it('HTML-escapes user content to prevent XSS', () => {
+ expect(linkifyText('')).toBe(
+ '<script>alert("xss")</script>'
+ );
+ });
+
+ it('HTML-escapes text around URLs', () => {
+ expect(linkifyText('bold https://example.com')).toContain('<b>bold</b>');
+ expect(linkifyText('bold https://example.com')).toContain('href="https://example.com"');
+ });
+
+ it('handles URL at start of string', () => {
+ expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
+ });
+
+ it('handles URL at end of string', () => {
+ expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
+ });
+
+ it('does not linkify partial matches without protocol or www', () => {
+ expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
+ });
+
+ it('handles URLs with paths and query strings', () => {
+ const url = 'https://example.com/path?q=1&b=2';
+ const result = linkifyText(url);
+ expect(result).toContain('href="https://example.com/path?q=1&b=2"');
+ });
+
+ it('strips trailing punctuation from URLs', () => {
+ expect(linkifyText('see https://example.com.')).toBe(
+ 'see https://example.com.'
+ );
+ });
+
+ it('returns empty string for empty input', () => {
+ expect(linkifyText('')).toBe('');
+ });
+});
+
+describe('unlinkifyHtml', () => {
+ it('strips anchor tags and returns text content', () => {
+ expect(unlinkifyHtml('visit https://example.com today'))
+ .toBe('visit https://example.com today');
+ });
+
+ it('returns plain text unchanged', () => {
+ expect(unlinkifyHtml('just text')).toBe('just text');
+ });
+
+ it('handles multiple anchor tags', () => {
+ expect(unlinkifyHtml('https://a.com and https://b.com'))
+ .toBe('https://a.com and https://b.com');
+ });
+
+ it('returns empty string for empty input', () => {
+ expect(unlinkifyHtml('')).toBe('');
+ });
+
+ it('handles div and br tags from contenteditable', () => {
+ expect(unlinkifyHtml('line1
line2')).toBe('line1line2');
+ });
+});
diff --git a/src/lib/utils/checklist.ts b/src/lib/utils/checklist.ts
index 9843ad8..cd5db59 100644
--- a/src/lib/utils/checklist.ts
+++ b/src/lib/utils/checklist.ts
@@ -150,3 +150,44 @@ export function serializeChecklist(items: ChecklistItem[]): string {
}
return lines.join('\n');
}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
+
+export function linkifyText(text: string): string {
+ if (!text) return '';
+
+ const matches: { index: number; match: string; href: string }[] = [];
+ let m: RegExpExecArray | null;
+ const regex = new RegExp(URL_REGEX.source, 'g');
+
+ while ((m = regex.exec(text)) !== null) {
+ const match = m[0];
+ const href = match.startsWith('www.') ? `https://${match}` : match;
+ matches.push({ index: m.index, match, href });
+ }
+
+ if (matches.length === 0) return escapeHtml(text);
+
+ let result = '';
+ let lastIndex = 0;
+ for (const { index, match, href } of matches) {
+ result += escapeHtml(text.slice(lastIndex, index));
+ result += `${escapeHtml(match)}`;
+ lastIndex = index + match.length;
+ }
+ result += escapeHtml(text.slice(lastIndex));
+ return result;
+}
+
+export function unlinkifyHtml(html: string): string {
+ if (!html) return '';
+ return html.replace(/<[^>]*>/g, '');
+}