diff --git a/lib/cypress.config.ts b/lib/cypress.config.ts
index 5db7fe24..b0334c4e 100644
--- a/lib/cypress.config.ts
+++ b/lib/cypress.config.ts
@@ -6,6 +6,6 @@ export default defineConfig({
framework: 'react',
bundler: 'vite',
},
- specPattern: ['**/**/*.cy.{ts,tsx}'],
+ specPattern: 'cypress/component/**/*.cy.{ts,tsx}',
},
})
diff --git a/lib/cypress/component/TipTap/Hashtag.cy.tsx b/lib/cypress/component/TipTap/Hashtag.cy.tsx
new file mode 100644
index 00000000..83753c07
--- /dev/null
+++ b/lib/cypress/component/TipTap/Hashtag.cy.tsx
@@ -0,0 +1,172 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter, createTestTag } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+describe('Hashtag Extension', () => {
+ describe('Hashtag Parsing', () => {
+ it('parses simple hashtag in markdown', () => {
+ const content = 'Hello #world'
+
+ mount()
+
+ cy.get('.hashtag').should('exist')
+ cy.get('.hashtag').should('contain.text', '#world')
+ })
+
+ it('parses multiple hashtags', () => {
+ const content = '#one #two #three'
+
+ mount()
+
+ cy.get('.hashtag').should('have.length', 3)
+ })
+
+ it('parses hashtag with numbers', () => {
+ const content = 'Check out #tag123'
+
+ mount()
+
+ cy.get('.hashtag').should('contain.text', '#tag123')
+ })
+
+ // Note: The regex in Hashtag.tsx includes underscores: /^#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)/
+ it('parses hashtag with underscore', () => {
+ const content = 'See #my_tag here'
+
+ mount()
+
+ // decodeTag() converts underscores to non-breaking spaces (\u00A0) for display
+ cy.get('.hashtag').should('exist')
+ cy.get('.hashtag').should('contain.text', '#my\u00A0tag')
+ })
+
+ it('parses hashtag with hyphen', () => {
+ const content = 'Find #my-tag'
+
+ mount()
+
+ cy.get('.hashtag').should('contain.text', '#my-tag')
+ })
+
+ it('parses unicode hashtag (German umlauts)', () => {
+ const content = 'Visit #München'
+
+ mount()
+
+ cy.get('.hashtag').should('contain.text', '#München')
+ })
+ })
+
+ describe('Hashtag Styling', () => {
+ it('applies color from known tag', () => {
+ const tags = [createTestTag('nature', '#22C55E')]
+ const content = 'Love #nature'
+
+ mount()
+
+ cy.get('.hashtag')
+ .should('have.css', 'color')
+ .and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
+ })
+
+ it('uses inherit color for unknown tag', () => {
+ const content = 'Unknown #sometag'
+
+ mount()
+
+ cy.get('.hashtag').should('exist')
+ // Unknown tags should use inherit
+ cy.get('.hashtag').should('have.css', 'color')
+ })
+
+ it('has bold font weight', () => {
+ const content = '#test'
+
+ mount()
+
+ cy.get('.hashtag').should('have.class', 'tw:font-bold')
+ })
+ })
+
+ describe('Hashtag Click Behavior', () => {
+ it('calls onTagClick when clicked in view mode', () => {
+ const tags = [createTestTag('clickme', '#3B82F6')]
+ const onTagClick = cy.stub().as('tagClickHandler')
+ const content = '#clickme'
+
+ mount(
+ ,
+ )
+
+ cy.get('.hashtag').click()
+ cy.get('@tagClickHandler').should('have.been.calledOnce')
+ })
+
+ it('does NOT call onTagClick when clicked in edit mode', () => {
+ const tags = [createTestTag('clickme', '#3B82F6')]
+ const onTagClick = cy.stub().as('tagClickHandler')
+ const content = '#clickme'
+
+ mount(
+ ,
+ )
+
+ cy.get('.hashtag').click()
+ cy.get('@tagClickHandler').should('not.have.been.called')
+ })
+
+ it('shows pointer cursor in view mode', () => {
+ const content = '#test'
+
+ mount()
+
+ cy.get('.hashtag').should('have.css', 'cursor', 'pointer')
+ })
+
+ it('shows text cursor in edit mode', () => {
+ const content = '#test'
+
+ mount()
+
+ cy.get('.hashtag').should('have.css', 'cursor', 'text')
+ })
+ })
+
+ describe('Markdown Serialization (Roundtrip)', () => {
+ it('serializes hashtag back to markdown', () => {
+ const content = 'Hello #world'
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.hashtag')
+ .should('exist')
+ .then(() => {
+ // TipTap with Markdown extension exposes getMarkdown() directly on editor
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('#world')
+ return null
+ })
+ })
+ })
+})
diff --git a/lib/cypress/component/TipTap/ItemMention.cy.tsx b/lib/cypress/component/TipTap/ItemMention.cy.tsx
new file mode 100644
index 00000000..19599b4d
--- /dev/null
+++ b/lib/cypress/component/TipTap/ItemMention.cy.tsx
@@ -0,0 +1,168 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter, createTestItem } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+describe('ItemMention Extension', () => {
+ describe('Item Mention Parsing', () => {
+ // Note: Item IDs are UUIDs (hex format: [a-fA-F0-9-])
+ // Example real UUID: "144e379c-b719-4334-9f0e-de277d3b6d0f"
+
+ it('parses standard item mention with UUID', () => {
+ // Using realistic UUID format
+ const content = 'Thanks [@Alice](/item/144e379c-b719-4334-9f0e-de277d3b6d0f)'
+
+ mount()
+
+ cy.get('.item-mention').should('exist')
+ cy.get('.item-mention').should('contain.text', '@Alice')
+ })
+
+ it('parses item mention with layer path (legacy format)', () => {
+ // The regex: /^\[@([^\]]+?)\]\(\/item\/(?:[^/]+\/)?([a-fA-F0-9-]+)\)/
+ // Captures layer name as optional non-capturing group, then hex UUID
+ const content = 'See [@Bob](/item/people/efe00aaa-8b14-47b5-a032-3e0560980c1e)'
+
+ mount()
+
+ cy.get('.item-mention').should('exist')
+ cy.get('.item-mention').should('contain.text', '@Bob')
+ })
+
+ it('parses multiple item mentions', () => {
+ const content = '[@Alice](/item/aaa-111) and [@Bob](/item/bbb-222)'
+
+ mount()
+
+ cy.get('.item-mention').should('have.length', 2)
+ })
+
+ it('parses item mention with spaces in label', () => {
+ const content = 'Contact [@Max Müller](/item/abc-123-def)'
+
+ mount()
+
+ cy.get('.item-mention').should('contain.text', '@Max Müller')
+ })
+
+ it('parses item mention with uppercase UUID', () => {
+ // UUIDs are case-insensitive, regex uses [a-fA-F0-9-]
+ const content = '[@Name](/item/ABC-DEF-123)'
+
+ mount()
+
+ cy.get('.item-mention').should('exist')
+ })
+ })
+
+ describe('Item Mention Styling', () => {
+ it('applies color from known item', () => {
+ const items = [createTestItem('abc-123', 'Alice', '#EF4444')]
+ const content = '[@Alice](/item/abc-123)'
+
+ mount()
+
+ cy.get('.item-mention')
+ .should('have.css', 'color')
+ .and('match', /rgb\(239, 68, 68\)|#[eE][fF]4444/)
+ })
+
+ it('uses getItemColor function when provided', () => {
+ const items = [createTestItem('abc-123', 'Alice')]
+ const getItemColor = () => '#8B5CF6' // Purple
+ const content = '[@Alice](/item/abc-123)'
+
+ mount()
+
+ cy.get('.item-mention')
+ .should('have.css', 'color')
+ .and('match', /rgb\(139, 92, 246\)|#8[bB]5[cC][fF]6/)
+ })
+
+ it('uses fallback color for unknown item', () => {
+ // UUID must be hex characters only: [a-fA-F0-9-]
+ const content = '[@Unknown](/item/aaa-bbb-ccc-111)'
+
+ mount()
+
+ // Should still render, using fallback color
+ cy.get('.item-mention').should('exist')
+ })
+
+ it('has bold font weight', () => {
+ const content = '[@Test](/item/123)'
+
+ mount()
+
+ cy.get('.item-mention').should('have.class', 'tw:font-bold')
+ })
+ })
+
+ describe('Item Mention Click Behavior', () => {
+ it('shows pointer cursor in view mode', () => {
+ const content = '[@Alice](/item/123)'
+
+ mount()
+
+ cy.get('.item-mention').should('have.css', 'cursor', 'pointer')
+ })
+
+ it('shows text cursor in edit mode', () => {
+ const content = '[@Alice](/item/123)'
+
+ mount()
+
+ cy.get('.item-mention').should('have.css', 'cursor', 'text')
+ })
+
+ // Note: Navigation behavior uses react-router's useNavigate
+ // Full navigation testing would require more complex setup
+ })
+
+ describe('Markdown Serialization (Roundtrip)', () => {
+ it('serializes item mention back to markdown', () => {
+ const content = '[@Alice](/item/abc-123)'
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('.item-mention')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('[@Alice](/item/abc-123)')
+ return null
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('does not parse regular links without @', () => {
+ const content = '[Alice](/item/123)'
+
+ mount()
+
+ // Should be a regular link, not an item mention
+ cy.get('.item-mention').should('not.exist')
+ cy.get('a').should('contain.text', 'Alice')
+ })
+
+ it('does not parse @ links to non-item paths', () => {
+ const content = '[@Alice](/profile/123)'
+
+ mount()
+
+ // Should be a regular link, not an item mention
+ cy.get('.item-mention').should('not.exist')
+ })
+ })
+})
diff --git a/lib/cypress/component/TipTap/TestEditor.tsx b/lib/cypress/component/TipTap/TestEditor.tsx
new file mode 100644
index 00000000..391a97a5
--- /dev/null
+++ b/lib/cypress/component/TipTap/TestEditor.tsx
@@ -0,0 +1,106 @@
+import { Image } from '@tiptap/extension-image'
+import { Link } from '@tiptap/extension-link'
+import { Markdown } from '@tiptap/markdown'
+import { EditorContent, useEditor } from '@tiptap/react'
+import { StarterKit } from '@tiptap/starter-kit'
+import { useEffect } from 'react'
+import { MemoryRouter } from 'react-router-dom'
+
+import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
+import { createConfiguredMarked } from '#components/TipTap/utils/configureMarked'
+
+import type { Item } from '#types/Item'
+import type { Tag } from '#types/Tag'
+import type { Editor } from '@tiptap/core'
+
+const configuredMarked = createConfiguredMarked()
+
+export interface TestEditorProps {
+ content: string
+ editable?: boolean
+ tags?: Tag[]
+ onTagClick?: (tag: Tag) => void
+ items?: Item[]
+ getItemColor?: (item: Item | undefined, fallback?: string) => string
+ onReady?: (editor: Editor) => void
+ testId?: string
+}
+
+export function TestEditor({
+ content,
+ editable = false,
+ tags = [],
+ onTagClick,
+ items = [],
+ getItemColor,
+ onReady,
+ testId = 'test-editor',
+}: TestEditorProps) {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ bulletList: { keepMarks: true, keepAttributes: false },
+ orderedList: { keepMarks: true, keepAttributes: false },
+ }),
+ Markdown.configure({
+ marked: configuredMarked,
+ }),
+ Image,
+ Link,
+ VideoEmbed,
+ Hashtag.configure({
+ tags,
+ onTagClick,
+ }),
+ ItemMention.configure({
+ items,
+ getItemColor,
+ }),
+ ],
+ content,
+ contentType: 'markdown',
+ editable,
+ })
+
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- editor can be null initially
+ if (editor && onReady) {
+ onReady(editor)
+ }
+ }, [editor, onReady])
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- editor can be null initially
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
+export function TestEditorWithRouter(props: TestEditorProps) {
+ return (
+
+
+
+ )
+}
+
+export function createTestTag(name: string, color = '#3B82F6'): Tag {
+ return {
+ id: `tag-${name}`,
+ name,
+ color,
+ } as Tag
+}
+
+export function createTestItem(id: string, name: string, color = '#10B981'): Item {
+ return {
+ id,
+ name,
+ color,
+ } as Item
+}
diff --git a/lib/cypress/component/TipTap/VideoEmbed.cy.tsx b/lib/cypress/component/TipTap/VideoEmbed.cy.tsx
new file mode 100644
index 00000000..8f5cc05f
--- /dev/null
+++ b/lib/cypress/component/TipTap/VideoEmbed.cy.tsx
@@ -0,0 +1,288 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+describe('VideoEmbed Extension', () => {
+ describe('YouTube Video Parsing', () => {
+ it('parses YouTube standard URL autolink to video embed', () => {
+ const content = ''
+
+ mount()
+
+ // Should render as iframe, not as text
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed')
+ .should('have.attr', 'src')
+ .and('include', 'youtube-nocookie.com/embed/dQw4w9WgXcQ')
+ })
+
+ it('parses YouTube short URL autolink to video embed', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed')
+ .should('have.attr', 'src')
+ .and('include', 'youtube-nocookie.com/embed/dQw4w9WgXcQ')
+ })
+
+ it('handles YouTube URL with extra parameters', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
+ })
+ })
+
+ describe('Rumble Video Parsing', () => {
+ it('parses Rumble embed URL autolink to video embed', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed')
+ .should('have.attr', 'src')
+ .and('include', 'rumble.com/embed/v1abc23')
+ })
+ })
+
+ describe('Multiple Videos', () => {
+ it('renders multiple video embeds correctly', () => {
+ const content = `
+
+`
+
+ mount()
+
+ cy.get('iframe.video-embed').should('have.length', 2)
+ })
+ })
+
+ describe('Markdown Serialization (Roundtrip)', () => {
+ it('serializes video embed back to autolink markdown', () => {
+ const content = ''
+ let editorInstance: Editor
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('iframe.video-embed')
+ .should('exist')
+ .then(() => {
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('')
+ return null
+ })
+ })
+ })
+
+ describe('Non-Video URLs', () => {
+ it('does not convert regular URLs to video embeds', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('not.exist')
+ // Should render as a link instead
+ cy.get('a').should('contain.text', 'example.com')
+ })
+
+ it('does not convert non-embed Rumble URLs', () => {
+ // Regular Rumble video page, not embed URL
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('not.exist')
+ })
+ })
+
+ describe('Video Embed Attributes', () => {
+ it('renders with correct iframe attributes', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('have.attr', 'allowfullscreen').and('exist')
+ cy.get('iframe.video-embed').should('have.attr', 'allow').and('include', 'fullscreen')
+ })
+
+ it('wraps video in container div', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('.video-embed-wrapper').should('exist')
+ cy.get('.video-embed-wrapper').find('iframe.video-embed').should('exist')
+ })
+ })
+
+ describe('Adding Video Below Existing Video', () => {
+ it('user clicks after video and presses Enter - first video should remain', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('.ProseMirror').click()
+ cy.get('.ProseMirror').type('{enter}')
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
+ })
+
+ it('user clicks after video and types some text - first video should remain', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('.ProseMirror').click()
+ cy.get('.ProseMirror').type('{enter}Some text after the video')
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
+ })
+
+ it('user pastes a URL directly after video (no Enter) - first video should remain', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('.ProseMirror').click()
+ cy.get('.ProseMirror').trigger('paste', {
+ clipboardData: {
+ getData: () => 'https://www.youtube.com/watch?v=abc123xyz99',
+ },
+ })
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed').first().should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
+ })
+
+ it('user presses Enter then pastes a URL - first video should remain', () => {
+ const content = ''
+
+ mount()
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('.ProseMirror').click()
+ cy.get('.ProseMirror').type('{enter}')
+
+ cy.get('iframe.video-embed').should('exist')
+
+ cy.get('.ProseMirror').trigger('paste', {
+ clipboardData: {
+ getData: () => 'https://www.youtube.com/watch?v=abc123xyz99',
+ },
+ })
+
+ cy.get('iframe.video-embed').should('exist')
+ cy.get('iframe.video-embed').first().should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
+ })
+
+ it('preserves first video when setting content with two videos', () => {
+ const content = ''
+ let editorInstance: Editor | undefined
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('iframe.video-embed')
+ .should('exist')
+ .should(() => expect(editorInstance).to.exist)
+ .then(() => {
+ if (!editorInstance) return null
+ const newContent = `
+
+`
+ editorInstance.commands.setContent(newContent, { contentType: 'markdown' })
+ return null
+ })
+
+ cy.get('iframe.video-embed').should('have.length', 2)
+ })
+
+ it('verifies markdown roundtrip with two videos', () => {
+ const content = `
+
+`
+ let editorInstance: Editor | undefined
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('iframe.video-embed')
+ .should('have.length', 2)
+ .should(() => expect(editorInstance).to.exist)
+ .then(() => {
+ if (!editorInstance) return null
+ const markdown = editorInstance.getMarkdown()
+ expect(markdown).to.include('dQw4w9WgXcQ')
+ expect(markdown).to.include('second12345')
+ return null
+ })
+ })
+
+ it('handles keyboard navigation: Arrow down from video creates new paragraph', () => {
+ const content = ''
+ let editorInstance: Editor | undefined
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('iframe.video-embed')
+ .should('exist')
+ .should(() => expect(editorInstance).to.exist)
+ .then(() => {
+ if (!editorInstance) return null
+ editorInstance.commands.focus('end')
+ editorInstance
+ .chain()
+ .insertContentAt(editorInstance.state.doc.content.size, {
+ type: 'paragraph',
+ })
+ .run()
+ editorInstance.commands.setVideoEmbed({ provider: 'youtube', videoId: 'newvideo1234' })
+ return null
+ })
+
+ cy.get('iframe.video-embed').should('have.length', 2)
+ })
+ })
+})
diff --git a/lib/cypress/component/TipTap/roundtrip.cy.tsx b/lib/cypress/component/TipTap/roundtrip.cy.tsx
new file mode 100644
index 00000000..4533ceb6
--- /dev/null
+++ b/lib/cypress/component/TipTap/roundtrip.cy.tsx
@@ -0,0 +1,106 @@
+///
+import { mount } from 'cypress/react'
+
+import { TestEditorWithRouter } from './TestEditor'
+
+import type { Editor } from '@tiptap/core'
+
+/**
+ * Roundtrip tests verify that Markdown -> TipTap -> Markdown preserves
+ * key content elements. Note: TipTap may normalize markdown (whitespace,
+ * list markers, etc.) so we test for semantic preservation, not exact match.
+ */
+describe('Markdown Roundtrip Tests', () => {
+ function testRoundtripContains(originalMarkdown: string, expectedPatterns: string[]) {
+ let editorInstance: Editor | undefined
+
+ mount(
+ {
+ editorInstance = editor
+ }}
+ />,
+ )
+
+ cy.get('[data-testid="test-editor"]')
+ .should('exist')
+ .should(() => expect(editorInstance).to.exist)
+ .then(() => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const markdown = editorInstance!.getMarkdown()
+ for (const pattern of expectedPatterns) {
+ expect(markdown).to.include(pattern)
+ }
+ return null
+ })
+ }
+
+ describe('Custom Extensions Roundtrip', () => {
+ // These are the critical tests - our custom extensions must serialize correctly
+
+ it('hashtag: #tag is preserved', () => {
+ testRoundtripContains('Hello #world', ['#world'])
+ })
+
+ it('hashtag: multiple hashtags preserved', () => {
+ testRoundtripContains('#one #two #three', ['#one', '#two', '#three'])
+ })
+
+ it('item mention: preserved with correct format', () => {
+ // Using hex UUID as required by the regex
+ testRoundtripContains('Thanks [@Alice](/item/abc-123-def)', ['[@Alice](/item/abc-123-def)'])
+ })
+
+ it('item mention: with spaces in name', () => {
+ testRoundtripContains('Contact [@Max Müller](/item/abc-123)', [
+ '[@Max Müller](/item/abc-123)',
+ ])
+ })
+
+ it('video embed: YouTube autolink preserved', () => {
+ testRoundtripContains('', [
+ '',
+ ])
+ })
+ })
+
+ describe('Standard Markdown Roundtrip', () => {
+ it('plain text preserved', () => {
+ testRoundtripContains('Hello, world!', ['Hello, world!'])
+ })
+
+ it('bold text preserved', () => {
+ testRoundtripContains('This is **bold** text.', ['**bold**'])
+ })
+
+ it('italic text preserved', () => {
+ testRoundtripContains('This is *italic* text.', ['*italic*'])
+ })
+
+ it('inline code preserved', () => {
+ testRoundtripContains('Use `code` here.', ['`code`'])
+ })
+
+ it('links preserved', () => {
+ testRoundtripContains('Visit [Example](https://example.com).', [
+ '[Example](https://example.com)',
+ ])
+ })
+ })
+
+ describe('Complex Content Roundtrip', () => {
+ it('mixed content with all custom extensions', () => {
+ const content = 'Hello #world! Thanks [@Alice](/item/abc-123) for helping.'
+ testRoundtripContains(content, ['#world', '[@Alice](/item/abc-123)', 'Hello', 'for helping'])
+ })
+
+ it('formatting with hashtags', () => {
+ testRoundtripContains('**Bold** text with #hashtag and *italic*.', [
+ '**Bold**',
+ '#hashtag',
+ '*italic*',
+ ])
+ })
+ })
+})
diff --git a/lib/tsconfig.json b/lib/tsconfig.json
index 001999dc..8e4ac629 100644
--- a/lib/tsconfig.json
+++ b/lib/tsconfig.json
@@ -29,7 +29,8 @@
"setupTest.ts",
"cypress.config.ts",
"cypress/support/commands.ts",
- "cypress/support/component.ts"
+ "cypress/support/component.ts",
+ "cypress/component"
],
"exclude": ["node_modules", "dist", "example"],
"typeRoots": ["./src/types", "./node_modules/@types/"]