Skip to content
Merged
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
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { getSchema, type JSONContent } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { splitBlockImageParagraph } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph'
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
import {
Expand All @@ -9,8 +9,6 @@ import {
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'

const schema = getSchema(createMarkdownContentExtensions())
const cleanups: Array<() => void> = []
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))

describe('block images within Markdown paragraphs', () => {
it('retains a whitespace-only code span beside an image', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterAll, describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
import {
parseMarkdownToDoc,
Expand Down Expand Up @@ -255,3 +255,24 @@ describe('chunked parse — property test over randomized documents', () => {
// the file, and adding it is what pushed both past 30s under whole-suite parallelism.
}, 60000)
})

describe('serializeMarkdownBody', () => {
/**
* Serializing is synchronous, so any timer it leaves behind outlives the call — and a DOM-touching
* one fires after a jsdom environment is torn down, failing whichever suite finished first. A fresh
* module instance makes the shared editor get built under the fake clock, whatever ran before.
*/
it('leaves no deferred work behind, including when it builds the shared editor', async () => {
vi.resetModules()
vi.useFakeTimers()
try {
const fresh = await import(
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
)
fresh.serializeMarkdownBody('# Before ![Logo](/logo.png "Title") after\n\n- a\n- b')
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@ import {
* `setContent`. Both are safe because all access is synchronous and single-threaded — each call fully
* completes before the next — so no call ever observes another's partial state. One bounded instance
* for the session, not a per-call allocation.
*
* It is never mounted: a mounted view renders every document into the DOM and its mutation observer
* schedules flush timers nothing can cancel, which fire after a jsdom test environment is torn down.
* TipTap installs extension plugins only when it mounts, so they are installed here the same way, keeping
* normalization such as the trailing paragraph identical to the live editor.
*/
let parser: Editor | null = null

function parserEditor(): Editor {
if (!parser) parser = new Editor({ extensions: createMarkdownContentExtensions() })
if (!parser) {
parser = new Editor({ element: null, extensions: createMarkdownContentExtensions() })
parser.view.updateState(parser.state.reconfigure({ plugins: parser.extensionManager.plugins }))
Comment thread
waleedlatif1 marked this conversation as resolved.
}
return parser
}

Expand Down Expand Up @@ -344,10 +352,11 @@ export function serializeMarkdownBody(body: string): string {
* editor applies — output identical to `editor.getMarkdown()`. The server-side collab-doc converter
* uses this to project a Yjs doc back to markdown through the exact client engine (parity by
* construction), so it must stay the single serialize path (do not inline `getMarkdown` elsewhere).
* Nothing listens to the scratchpad, so it emits no update, which would only defer core bookkeeping.
*/
export function serializeDocToMarkdown(doc: JSONContent): string {
const editor = parserEditor()
editor.commands.setContent(doc, { contentType: 'json' })
editor.commands.setContent(doc, { contentType: 'json', emitUpdate: false })
return editor.getMarkdown()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'

let editor: Editor | null = null
const editors: Editor[] = []

afterEach(() => {
editor?.destroy()
editor = null
for (const editor of editors.splice(0)) editor.destroy()
})

/** Every editor is destroyed after its test, including a second one a single test mounts. */
function mount(editable = true): Editor {
return new Editor({
const editor = new Editor({
extensions: [...createMarkdownContentExtensions(), MarkdownPaste],
enablePasteRules: false,
editable,
})
editors.push(editor)
return editor
}

function dispatchPaste(
Expand Down Expand Up @@ -71,29 +73,29 @@ function transformHtml(ed: Editor, html: string): string {

describe('markdown paste', () => {
it('keeps pasted markdown literal inside a code block', () => {
editor = mount()
const editor = mount()
editor.commands.setContent('```js\ncode here\n```', { contentType: 'markdown' })
editor.commands.setTextSelection(5)
expect(editor.isActive('codeBlock')).toBe(true)
expect(paste(editor, '[link](https://example.com)')).toBe(false)
})

it('keeps pasted markdown literal inside inline code', () => {
editor = mount()
const editor = mount()
editor.commands.setContent('a `codehere` b', { contentType: 'markdown' })
editor.commands.setTextSelection(6)
expect(editor.isActive('code')).toBe(true)
expect(paste(editor, '*italic*')).toBe(false)
})

it('rejects the paste entirely in a read-only editor', () => {
editor = mount(false)
const editor = mount(false)
expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false)
expect(editor.getText()).toBe('')
})

it('does not flatten a rich table containing literal Markdown-shaped cell text', () => {
editor = mount()
const editor = mount()
dispatchPaste(
editor,
'Label\tValue\n**literal**\t42',
Expand All @@ -105,7 +107,7 @@ describe('markdown paste', () => {
})

it('preserves the structural blocks of a multi-block document, in order, on paste', () => {
editor = mount()
const editor = mount()
expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true)
const structural = (editor.getJSON().content ?? [])
.map((node) => node.type)
Expand All @@ -114,7 +116,7 @@ describe('markdown paste', () => {
})

it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
editor = mount()
const editor = mount()
const gsheets =
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
const cleaned = transformHtml(editor, gsheets)
Expand All @@ -125,14 +127,14 @@ describe('markdown paste', () => {
})

it('strips nested/repeated <script> tags in a single pass, even deeply nested', () => {
editor = mount()
const editor = mount()
expect(transformHtml(editor, 'a<script>x<script>y</script></script>b')).toBe('ab')
const deeplyNested = `a${'<script>'.repeat(50)}x${'</script>'.repeat(50)}b`
expect(transformHtml(editor, deeplyNested)).toBe('ab')
})

it('drops an unterminated <script>/<style> and everything after it, without duplicating the prefix', () => {
editor = mount()
const editor = mount()
expect(transformHtml(editor, 'abc<script>never-closes')).toBe('abc')
expect(transformHtml(editor, 'abc<style>never-closes')).toBe('abc')
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
Expand All @@ -145,7 +147,7 @@ describe('linkify a selection on URL paste', () => {
from = 1,
to = 10
): { handled: boolean; href?: string; text: string } {
editor = mount()
const editor = mount()
editor.commands.setContent('select me here', { contentType: 'markdown' })
editor.commands.setTextSelection({ from, to })
const handled = paste(editor, pasted)
Expand Down
Loading