From c753c4b5ceef7f3b69d008f875d30861cc00b443 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Wed, 8 Apr 2026 21:51:33 +0200 Subject: [PATCH] feat: add linkifyText and unlinkifyHtml utilities for checklist hyperlinks --- src/lib/utils/checklist.test.ts | 95 ++++++++++++++++++++++++++++++++- src/lib/utils/checklist.ts | 41 ++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) 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, ''); +}