Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export function maybeAddProtocol(text) {
return /^www\./i.test(text) ? `https://${text}` : text;
}

/**
* Detect explicit intent to paste a URL and checks for common URL patterns:
* - Starts with `www.`
* - Starts with `http://` or `https://`
* - Starts with `mailto:`, `tel:`, or `sms:`
*/
function hasExplicitUrlIntent(text) {
if (/^www\./i.test(text)) return true;
if (/^(mailto|tel|sms):/i.test(text)) return true;

return /^https?:\/\//i.test(text);
}

/**
* Detect whether a pasted plain-text string is a single URL.
*
Expand All @@ -33,8 +46,10 @@ export function detectPasteUrl(text, protocols = []) {
// A bare URL has no internal whitespace
if (/\s/.test(trimmed)) return null;

const withProtocol = maybeAddProtocol(trimmed);
const allowedProtocols = buildAllowedProtocols(protocols);
if (!hasExplicitUrlIntent(trimmed)) return null;

const withProtocol = maybeAddProtocol(trimmed);
const result = sanitizeHref(withProtocol, { allowedProtocols });

return result ? { href: result.href } : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ vi.mock('@superdoc/url-validation', () => {

// Must have a known protocol
const match = trimmed.match(/^([a-z]+):/i);
if (!match) return null;
if (!match) {
if (/^\/\//.test(trimmed) || /\s/.test(trimmed)) return null;
return { href: new URL(trimmed, 'http://localhost:9094/').href, protocol: null, isExternal: false };
}

const protocol = match[1].toLowerCase();
const allowed = config?.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS;
Expand Down Expand Up @@ -197,10 +200,29 @@ describe('detectPasteUrl', () => {
expect(result).toEqual({ href: 'mailto:user@example.com' });
});

it('detects tel and sms links', () => {
expect(detectPasteUrl('tel:5551234567')).toEqual({ href: 'tel:5551234567' });
expect(detectPasteUrl('sms:5551234567')).toEqual({ href: 'sms:5551234567' });
});

it('returns null for plain text', () => {
expect(detectPasteUrl('just some text')).toBeNull();
});

it('returns null for single-word plain text even when sanitizeHref accepts relative paths', () => {
expect(detectPasteUrl('dog')).toBeNull();
});

it('returns null for template tokens even when sanitizeHref accepts relative paths', () => {
expect(detectPasteUrl('{project_number}')).toBeNull();
});

it('returns null for relative paths in plain text', () => {
expect(detectPasteUrl('/docs/page')).toBeNull();
expect(detectPasteUrl('./docs/page')).toBeNull();
expect(detectPasteUrl('docs/page')).toBeNull();
});

it('returns null for URL with trailing text', () => {
expect(detectPasteUrl('https://example.com extra text')).toBeNull();
});
Expand All @@ -223,17 +245,10 @@ describe('detectPasteUrl', () => {
expect(detectPasteUrl(undefined)).toBeNull();
});

it('normalizes protocol config entries (case and {scheme} objects)', () => {
// Mock sanitizeHref to accept FTP protocol when configured
sanitizeHref.mockImplementationOnce((raw, config) => {
if (raw === 'ftp://files.example.com' && config?.allowedProtocols?.includes('ftp')) {
return { href: 'ftp://files.example.com', protocol: 'ftp', isExternal: true };
}
return null;
});

it('does not allow configured protocols to expand plain-text auto-link detection', () => {
const result = detectPasteUrl('ftp://files.example.com', [{ scheme: 'FTP' }]);
expect(result).toEqual({ href: 'ftp://files.example.com' });
expect(result).toBeNull();
expect(sanitizeHref).not.toHaveBeenCalled();
});
});

Expand Down
Loading