utopia-ui/lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts

500 lines
18 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
preprocessMarkdown,
preprocessVideoLinks,
preprocessHashtags,
preprocessItemMentions,
removeMarkdownSyntax,
truncateMarkdown,
} from './preprocessMarkdown'
// ============================================================================
// convertNakedUrls (tested via preprocessMarkdown)
// ============================================================================
describe('convertNakedUrls (via preprocessMarkdown)', () => {
describe('Happy Path', () => {
it('converts a naked URL to markdown link', () => {
const result = preprocessMarkdown('Check https://example.com out')
expect(result).toContain('[example.com](https://example.com)')
})
it('removes www from display text', () => {
const result = preprocessMarkdown('Visit https://www.example.com')
expect(result).toContain('[example.com](https://www.example.com)')
})
it('converts multiple naked URLs', () => {
const result = preprocessMarkdown('See https://a.com and https://b.com')
expect(result).toContain('[a.com](https://a.com)')
expect(result).toContain('[b.com](https://b.com)')
})
it('preserves query parameters in URL', () => {
const result = preprocessMarkdown('Link https://example.com?a=1&b=2 here')
expect(result).toContain('](https://example.com?a=1&b=2)')
})
it('converts http URLs', () => {
const result = preprocessMarkdown('Old http://example.com link')
expect(result).toContain('[example.com](http://example.com)')
})
})
describe('Skip - Already Linked', () => {
it('does not convert URL already in markdown link', () => {
const input = '[my link](https://example.com)'
const result = preprocessMarkdown(input)
// Should not double-wrap
expect(result).toBe(input)
})
it('does not convert URL in autolink syntax', () => {
const input = '<https://example.com>'
const result = preprocessMarkdown(input)
// Autolinks are preserved (may be converted to video embeds if matching)
expect(result).not.toContain('](https://example.com)')
})
})
describe('Edge Cases', () => {
it('handles URL at end of sentence with period', () => {
const result = preprocessMarkdown('Visit https://example.com.')
// Note: Current implementation includes trailing period in URL
// This documents actual behavior - may be improved later
expect(result).toContain('[example.com.](https://example.com.)')
})
it('handles URL in parentheses', () => {
const result = preprocessMarkdown('(https://example.com)')
expect(result).toContain('[example.com](https://example.com)')
})
it('handles URL at line start', () => {
const result = preprocessMarkdown('https://example.com is great')
expect(result).toContain('[example.com](https://example.com)')
})
it('handles URL with path', () => {
const result = preprocessMarkdown('See https://example.com/path/to/page')
expect(result).toContain('[example.com/path/to/page](https://example.com/path/to/page)')
})
})
})
// ============================================================================
// preprocessVideoLinks
// ============================================================================
describe('preprocessVideoLinks', () => {
describe('YouTube - Happy Path', () => {
it('converts YouTube autolink (standard)', () => {
const result = preprocessVideoLinks('<https://www.youtube.com/watch?v=abc123def45>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
})
it('converts YouTube autolink (short URL)', () => {
const result = preprocessVideoLinks('<https://youtu.be/abc123def45>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
})
it('converts YouTube markdown link (standard)', () => {
const result = preprocessVideoLinks('[Video](https://youtube.com/watch?v=abc123)')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
})
it('converts YouTube markdown link (short URL)', () => {
const result = preprocessVideoLinks('[Watch](https://youtu.be/xyz789)')
expect(result).toBe('<video-embed provider="youtube" video-id="xyz789"></video-embed>')
})
it('handles YouTube without www', () => {
const result = preprocessVideoLinks('<https://youtube.com/watch?v=test123>')
expect(result).toBe('<video-embed provider="youtube" video-id="test123"></video-embed>')
})
})
describe('YouTube - Edge Cases', () => {
it('extracts only video-id, ignores extra params', () => {
const result = preprocessVideoLinks('<https://youtube.com/watch?v=abc123&t=120&list=xyz>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
})
it('handles http (non-https) YouTube links', () => {
const result = preprocessVideoLinks('<http://youtube.com/watch?v=test>')
expect(result).toBe('<video-embed provider="youtube" video-id="test"></video-embed>')
})
it('handles short URL with query params', () => {
const result = preprocessVideoLinks('<https://youtu.be/abc?t=30>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc"></video-embed>')
})
})
describe('Rumble - Happy Path', () => {
it('converts Rumble autolink', () => {
const result = preprocessVideoLinks('<https://rumble.com/embed/v1abc>')
expect(result).toBe('<video-embed provider="rumble" video-id="v1abc"></video-embed>')
})
it('converts Rumble markdown link', () => {
const result = preprocessVideoLinks('[Rumble Video](https://rumble.com/embed/xyz123)')
expect(result).toBe('<video-embed provider="rumble" video-id="xyz123"></video-embed>')
})
})
describe('Non-Video Links', () => {
it('does not convert non-video autolinks', () => {
const input = '<https://example.com>'
expect(preprocessVideoLinks(input)).toBe(input)
})
it('does not convert non-video markdown links', () => {
const input = '[Example](https://example.com)'
expect(preprocessVideoLinks(input)).toBe(input)
})
})
describe('Mixed Content', () => {
it('converts video in mixed content', () => {
const result = preprocessVideoLinks('Check this: <https://youtu.be/abc> and more text')
expect(result).toContain('<video-embed provider="youtube" video-id="abc"></video-embed>')
expect(result).toContain('Check this:')
expect(result).toContain('and more text')
})
it('converts multiple videos', () => {
const result = preprocessVideoLinks('<https://youtu.be/a> and <https://rumble.com/embed/b>')
expect(result).toContain('video-id="a"')
expect(result).toContain('video-id="b"')
})
})
})
// ============================================================================
// preprocessHashtags
// ============================================================================
describe('preprocessHashtags', () => {
describe('Happy Path', () => {
it('converts simple hashtag', () => {
const result = preprocessHashtags('Hello #world')
expect(result).toBe('Hello <span data-hashtag data-label="world">#world</span>')
})
it('converts multiple hashtags', () => {
const result = preprocessHashtags('#one #two #three')
expect(result).toContain('data-label="one"')
expect(result).toContain('data-label="two"')
expect(result).toContain('data-label="three"')
})
it('converts hashtag with numbers', () => {
const result = preprocessHashtags('#test123')
expect(result).toContain('data-label="test123"')
})
it('converts hashtag with underscore', () => {
const result = preprocessHashtags('#my_tag')
expect(result).toContain('data-label="my_tag"')
})
it('converts hashtag with hyphen', () => {
const result = preprocessHashtags('#my-tag')
expect(result).toContain('data-label="my-tag"')
})
it('converts hashtag with German umlauts', () => {
const result = preprocessHashtags('#München')
expect(result).toContain('data-label="München"')
})
it('converts hashtag with French accents', () => {
const result = preprocessHashtags('#café')
expect(result).toContain('data-label="café"')
})
})
describe('Skip - Inside Links', () => {
it('does not convert hashtag in link text', () => {
const input = '[#tag](#anchor)'
const result = preprocessHashtags(input)
expect(result).not.toContain('data-hashtag')
})
it('does not convert hashtag in link URL', () => {
const input = '[section](#section-heading)'
const result = preprocessHashtags(input)
expect(result).not.toContain('data-hashtag')
})
})
describe('Edge Cases', () => {
it('handles concurrent hashtags without space', () => {
const result = preprocessHashtags('#tag1#tag2')
// First should convert, second might not (depends on lookbehind)
expect(result).toContain('data-label="tag1"')
})
it('does not convert lone # symbol', () => {
const result = preprocessHashtags('Just #')
expect(result).not.toContain('data-hashtag')
expect(result).toBe('Just #')
})
it('handles hashtag at start of text', () => {
const result = preprocessHashtags('#first thing')
expect(result).toContain('data-label="first"')
})
it('handles hashtag at end of text', () => {
const result = preprocessHashtags('last is #final')
expect(result).toContain('data-label="final"')
})
})
})
// ============================================================================
// preprocessItemMentions
// ============================================================================
describe('preprocessItemMentions', () => {
describe('Happy Path', () => {
it('converts standard mention format', () => {
const result = preprocessItemMentions('Hello [@Alice](/item/abc-123)')
expect(result).toBe(
'Hello <span data-item-mention data-label="Alice" data-id="abc-123">@Alice</span>',
)
})
it('converts mention with layer (legacy format)', () => {
const result = preprocessItemMentions('[@Bob](/item/people/def-456)')
expect(result).toContain('data-id="def-456"')
expect(result).toContain('data-label="Bob"')
})
it('converts relative path format with leading slash', () => {
// Note: Relative paths without leading slash are not supported by regex
// The regex requires /item/ or item/ with UUID pattern (hex + dashes only)
const result = preprocessItemMentions('[@Name](/item/abc-def-123)')
expect(result).toContain('data-id="abc-def-123"')
})
it('converts multiple mentions', () => {
const result = preprocessItemMentions('[@A](/item/1) and [@B](/item/2)')
expect(result).toContain('data-id="1"')
expect(result).toContain('data-id="2"')
})
it('handles UUID with uppercase letters', () => {
const result = preprocessItemMentions('[@Name](/item/ABC-DEF-123)')
expect(result).toContain('data-id="ABC-DEF-123"')
})
it('handles label with spaces', () => {
// Note: UUID must be hex chars + dashes only (no letters like 'uuid')
const result = preprocessItemMentions('[@Max Müller](/item/abc-def-123)')
expect(result).toContain('data-label="Max Müller"')
})
})
describe('Skip - Non-Matching', () => {
it('does not convert non-item links', () => {
const input = '[@Name](/other/path)'
const result = preprocessItemMentions(input)
expect(result).toBe(input)
})
it('does not convert regular links (no @)', () => {
const input = '[Name](/item/123)'
const result = preprocessItemMentions(input)
expect(result).toBe(input)
})
})
})
// ============================================================================
// removeMarkdownSyntax
// ============================================================================
describe('removeMarkdownSyntax', () => {
describe('Happy Path', () => {
it('removes bold syntax', () => {
expect(removeMarkdownSyntax('**bold**')).toBe('bold')
})
it('removes italic syntax (asterisk)', () => {
expect(removeMarkdownSyntax('*italic*')).toBe('italic')
})
it('removes italic syntax (underscore)', () => {
expect(removeMarkdownSyntax('_italic_')).toBe('italic')
})
it('removes headers', () => {
expect(removeMarkdownSyntax('# Heading')).toBe('Heading')
expect(removeMarkdownSyntax('## Subheading')).toBe('Subheading')
})
it('removes links, keeps text', () => {
expect(removeMarkdownSyntax('[text](https://example.com)')).toBe('text')
})
it('removes images completely', () => {
expect(removeMarkdownSyntax('![alt](image.png)')).toBe('')
})
it('removes inline code', () => {
expect(removeMarkdownSyntax('`code`')).toBe('code')
})
it('removes blockquotes', () => {
expect(removeMarkdownSyntax('> quote')).toBe('quote')
})
})
describe('Preserve Special Elements', () => {
it('preserves @mentions', () => {
const result = removeMarkdownSyntax('Hello [@Alice](/item/123)')
expect(result).toContain('[@Alice](/item/123)')
})
it('preserves hashtags', () => {
const result = removeMarkdownSyntax('Hello #world')
expect(result).toContain('#world')
})
})
})
// ============================================================================
// truncateMarkdown
// ============================================================================
describe('truncateMarkdown', () => {
describe('Happy Path', () => {
it('returns unchanged if under limit', () => {
expect(truncateMarkdown('Short text', 100)).toBe('Short text')
})
it('truncates and adds ellipsis if over limit', () => {
const text = 'A'.repeat(150)
const result = truncateMarkdown(text, 100)
expect(result).toHaveLength(103) // 100 chars + '...'
expect(result.endsWith('...')).toBe(true)
})
it('respects exact limit', () => {
const result = truncateMarkdown('Hello World', 5)
expect(result).toBe('Hello...')
})
})
describe('Atomic Token Preservation', () => {
it('preserves complete hashtag', () => {
const result = truncateMarkdown('A'.repeat(95) + ' #tag', 100)
// Should either include complete #tag or cut before it
if (result.includes('#')) {
expect(result).toContain('#tag')
}
})
it('preserves complete mention', () => {
const result = truncateMarkdown('A'.repeat(90) + ' [@Alice](/item/123)', 100)
// Should either include complete mention or cut before it
if (result.includes('@')) {
expect(result).toContain('[@Alice](/item/123)')
}
})
it('preserves complete link', () => {
const result = truncateMarkdown('See [link](url) more text here', 8)
// Visible text "See link" is 8 chars
expect(result).toContain('[link](url)')
})
})
describe('Edge Cases', () => {
it('does not count newlines toward limit', () => {
const result = truncateMarkdown('Line1\n\nLine2', 10)
expect(result).toContain('Line1')
expect(result).toContain('Line2')
})
it('handles empty text', () => {
expect(truncateMarkdown('', 100)).toBe('')
})
it('handles limit of 0', () => {
expect(truncateMarkdown('Text', 0)).toBe('...')
})
it('handles negative limit gracefully', () => {
// Should not throw
expect(() => truncateMarkdown('Text', -1)).not.toThrow()
})
})
})
// ============================================================================
// preprocessMarkdown (Full Pipeline)
// ============================================================================
describe('preprocessMarkdown', () => {
describe('Happy Path', () => {
it('processes complete content with all features', () => {
const input = 'Check https://example.com #tag [@Alice](/item/123)'
const result = preprocessMarkdown(input)
// URL converted
expect(result).toContain('[example.com](https://example.com)')
// Hashtag converted
expect(result).toContain('data-hashtag')
// Mention converted
expect(result).toContain('data-item-mention')
})
it('processes video links', () => {
const result = preprocessMarkdown('<https://youtu.be/abc>')
expect(result).toContain('video-embed')
})
it('converts email addresses to mailto links', () => {
const result = preprocessMarkdown('Contact test@example.com')
expect(result).toContain('[test@example.com](mailto:test@example.com)')
})
})
describe('Edge Cases', () => {
it('handles empty string', () => {
expect(preprocessMarkdown('')).toBe('')
})
it('handles null input', () => {
// @ts-expect-error testing null input
expect(preprocessMarkdown(null)).toBe('')
})
it('handles undefined input', () => {
// @ts-expect-error testing undefined input
expect(preprocessMarkdown(undefined)).toBe('')
})
it('preserves whitespace', () => {
expect(preprocessMarkdown(' ')).toBe(' ')
})
it('handles very long text without timeout', () => {
const longText = 'A'.repeat(10000)
const start = performance.now()
preprocessMarkdown(longText)
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(1000) // Should complete in <1s
})
})
describe('Error Handling', () => {
it('handles malformed markdown without throwing', () => {
expect(() => preprocessMarkdown('[unclosed link')).not.toThrow()
expect(() => preprocessMarkdown('**unclosed bold')).not.toThrow()
})
it('handles malformed URLs without throwing', () => {
expect(() => preprocessMarkdown('http:/broken')).not.toThrow()
})
})
})