snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,628 @@
# @tiptap/markdown
## 3.30.3
### Patch Changes
- Updated dependencies [965a880]
- @tiptap/core@3.30.3
- @tiptap/pm@3.30.3
## 3.30.2
### Patch Changes
- Updated dependencies [3dffed5]
- Updated dependencies [214a140]
- @tiptap/core@3.30.2
- @tiptap/pm@3.30.2
## 3.30.1
### Patch Changes
- Updated dependencies [abc8828]
- @tiptap/core@3.30.1
- @tiptap/pm@3.30.1
## 3.30.0
### Patch Changes
- 03ba40e: Markdown with inline HTML such as an unclosed `<b>` tag no longer parses into an invalid document. The tag is dropped and its text is kept.
- Updated dependencies [0247d39]
- Updated dependencies [58a8953]
- Updated dependencies [51909d3]
- Updated dependencies [3099eef]
- @tiptap/core@3.30.0
- @tiptap/pm@3.30.0
## 3.29.2
### Patch Changes
- @tiptap/core@3.29.2
- @tiptap/pm@3.29.2
## 3.29.1
### Patch Changes
- @tiptap/core@3.29.1
- @tiptap/pm@3.29.1
## 3.29.0
### Patch Changes
- 9af2f2b: Fixed blank lines being dropped after block elements (headings, tables, etc.) when parsing markdown. Blank lines were being absorbed into the block token instead of being preserved, causing content to lose a blank line on each parse/serialize cycle.
- Updated dependencies [d26840f]
- Updated dependencies [e150ee0]
- Updated dependencies [935e63f]
- Updated dependencies [b4c5a2d]
- Updated dependencies [a963d48]
- Updated dependencies [51f45b6]
- Updated dependencies [0f63969]
- Updated dependencies [9acaa65]
- @tiptap/core@3.29.0
- @tiptap/pm@3.29.0
## 3.28.0
### Patch Changes
- @tiptap/core@3.28.0
- @tiptap/pm@3.28.0
## 3.27.4
### Patch Changes
- @tiptap/core@3.27.4
- @tiptap/pm@3.27.4
## 3.27.3
### Patch Changes
- Updated dependencies [023f98c]
- @tiptap/core@3.27.3
- @tiptap/pm@3.27.3
## 3.27.2
### Patch Changes
- Updated dependencies [ceebb31]
- @tiptap/pm@3.27.2
- @tiptap/core@3.27.2
## 3.27.1
### Patch Changes
- @tiptap/core@3.27.1
- @tiptap/pm@3.27.1
## 3.27.0
### Patch Changes
- 33687f1: Fix parsing with injected Marked instances so custom markdown tokenizers registered via `marked.use(...)` are respected.
- 852ced3: Fix unrecognized HTML detection during markdown parsing to work without `window.DOMParser` or `HTMLUnknownElement`, so angle-bracket placeholders are preserved as literal text in SSR and Node environments.
- Updated dependencies [0d0094d]
- Updated dependencies [795033c]
- Updated dependencies [0e0c4f9]
- Updated dependencies [6d12bb9]
- @tiptap/core@3.27.0
- @tiptap/pm@3.27.0
## 3.26.1
### Patch Changes
- @tiptap/core@3.26.1
- @tiptap/pm@3.26.1
## 3.26.0
### Patch Changes
- @tiptap/core@3.26.0
- @tiptap/pm@3.26.0
## 3.25.0
### Patch Changes
- 9cf8db0: Fix backslash-escape handling in the Markdown parser and serializer. Parsing a backslash-escaped markdown character (e.g. `\*`, `\_`, `\\`) now correctly produces a literal text node, instead of silently dropping the character. On serialization, characters that have special meaning in markdown inline syntax (`*`, `_`, `` ` ``, `[`, `]`, `\`, `~`) are now backslash-escaped in non-code text nodes to prevent them from being misinterpreted as formatting delimiters when the output is parsed again.
- Updated dependencies [ec291dd]
- Updated dependencies [454e9b8]
- Updated dependencies [9cf8db0]
- Updated dependencies [c1a2ce8]
- Updated dependencies [3d4f94c]
- @tiptap/core@3.25.0
- @tiptap/pm@3.25.0
## 3.24.0
### Patch Changes
- db47c91: Fix text inside unknown angle-bracket tags being silently swallowed during markdown parsing. HTML-like content such as `<enter existing CID here if available>` that does not map to any known element is now preserved as literal text instead of disappearing, which also prevents downstream `contentMatchAt` errors when complex schemas (Mention, Variable, Table) are in use.
- Updated dependencies [7c0499b]
- @tiptap/pm@3.24.0
- @tiptap/core@3.24.0
## 3.23.6
### Patch Changes
- Updated dependencies [d168376]
- @tiptap/core@3.23.6
- @tiptap/pm@3.23.6
## 3.23.5
### Patch Changes
- 6abd10c: Fix extra mark tokens after inline atom nodes during Markdown serialization
- 7bf0e73: Fix adjacent marks of the same type with different attributes being merged during Markdown serialization
- Updated dependencies [835caf5]
- Updated dependencies [95e138c]
- @tiptap/core@3.23.5
- @tiptap/pm@3.23.5
## 3.23.4
### Patch Changes
- @tiptap/core@3.23.4
- @tiptap/pm@3.23.4
## 3.23.3
### Patch Changes
- @tiptap/core@3.23.3
- @tiptap/pm@3.23.3
## 3.23.2
### Patch Changes
- Updated dependencies [f98eaaf]
- @tiptap/core@3.23.2
- @tiptap/pm@3.23.2
## 3.23.1
### Patch Changes
- @tiptap/core@3.23.1
- @tiptap/pm@3.23.1
## 3.23.0
### Patch Changes
- 0e6a6e4: Fix editor becoming unresponsive when initialized with empty markdown content and `contentType: 'markdown'`
- 57f8d66: Fix markdown parsing for bullet list items whose text looks like an ordered-list marker, such as `- 123.`, so extraction no longer loses the item content.
- 4796013: Fixed two Markdown serialization bugs: overlapping marks (e.g. bold+italic that start and end at different positions) now serialize with the correct delimiter order, and marks that all close on the same node now close in LIFO order to produce valid nesting.
- Updated dependencies [57f8d66]
- Updated dependencies [e64e5a7]
- Updated dependencies [207a2bc]
- @tiptap/core@3.23.0
- @tiptap/pm@3.23.0
## 3.22.5
### Patch Changes
- Updated dependencies [a375002]
- @tiptap/core@3.22.5
- @tiptap/pm@3.22.5
## 3.22.4
### Patch Changes
- 27ea931: Fix dependencies installation after packages updates producing peer dependency resolution conflicts
- Updated dependencies [27ea931]
- Updated dependencies [64f36b8]
- Updated dependencies [032f8f1]
- @tiptap/core@3.22.4
- @tiptap/pm@3.22.4
## 3.22.3
### Patch Changes
- Updated dependencies [cb28e7b]
- @tiptap/core@3.22.3
- @tiptap/pm@3.22.3
## 3.22.2
### Patch Changes
- Updated dependencies [f1d504c]
- Updated dependencies [404c683]
- @tiptap/core@3.22.2
- @tiptap/pm@3.22.2
## 3.22.1
### Patch Changes
- Updated dependencies [ee03ac0]
- Updated dependencies [b88f9ed]
- @tiptap/core@3.22.1
- @tiptap/pm@3.22.1
## 3.22.0
### Patch Changes
- 912a49b: Fix HTML character escaping in markdown roundtrip. HTML entities (`&lt;`, `&gt;`, `&amp;`, `&quot;`) are now decoded to literal characters when parsing markdown into the editor. `<`, `>`, and `&` are re-encoded when serializing back to markdown, while `"` is preserved as a literal character since double quotes are ordinary in markdown. Code detection for skipping encoding now uses the `code: true` extension spec instead of hardcoded type names. Literal characters inside code blocks and inline code are always preserved.
- Updated dependencies [912a49b]
- Updated dependencies [7d4fb9a]
- Updated dependencies [0c1c112]
- Updated dependencies [0c1c112]
- Updated dependencies [f99bdc2]
- @tiptap/core@3.22.0
- @tiptap/pm@3.22.0
## 3.21.0
### Patch Changes
- 75a912a: Fix custom markdown tokenizer helper lexing to use Marked's active lexer so ordered list parsing no longer breaks inline tokenization in following paragraphs.
- @tiptap/core@3.21.0
- @tiptap/pm@3.21.0
## 3.20.6
### Patch Changes
- @tiptap/core@3.20.6
- @tiptap/pm@3.20.6
## 3.20.5
### Patch Changes
- @tiptap/core@3.20.5
- @tiptap/pm@3.20.5
## 3.20.4
### Patch Changes
- Updated dependencies [0bcf3c2]
- @tiptap/core@3.20.4
- @tiptap/pm@3.20.4
## 3.20.3
### Patch Changes
- 6b9ea92: Fixed overlapping bold and italic markdown serialization and round-tripping.
- Updated dependencies [c94fac4]
- Updated dependencies [6b9ea92]
- @tiptap/core@3.20.3
- @tiptap/pm@3.20.3
## 3.20.2
### Patch Changes
- 269823d: Improved markdown empty-paragraph roundtripping across top-level and nested block content. Empty paragraphs now serialize with natural blank-line spacing for the first paragraph in a run and `&nbsp;` markers for subsequent empty paragraphs at the same level, while parsing preserves those empty paragraphs when converting markdown back to JSON.
- Updated dependencies [269823d]
- @tiptap/core@3.20.2
- @tiptap/pm@3.20.2
## 3.20.1
### Patch Changes
- Updated dependencies [25f57e4]
- @tiptap/core@3.20.1
- @tiptap/pm@3.20.1
## 3.20.0
### Patch Changes
- 6e3a0e8: Fixed getMarkdown() returning `&nbsp;` instead of empty string when editor is empty
- Updated dependencies [4b731e2]
- Updated dependencies [98546ac]
- Updated dependencies [57624a1]
- Updated dependencies [76ce47d]
- @tiptap/core@3.20.0
- @tiptap/pm@3.20.0
## 3.19.0
### Patch Changes
- @tiptap/core@3.19.0
- @tiptap/pm@3.19.0
## 3.18.0
### Patch Changes
- 9b388ef: Upgrade marked.js from v15.0.12 to v17.0.1. Note that `**)**` requires whitespace when adjacent to alphanumeric text per CommonMark specification.
- @tiptap/core@3.18.0
- @tiptap/pm@3.18.0
## 3.17.1
### Patch Changes
- 62c6f15: Fixed markdown serialization doubling newlines and parsing collapsing multiple blank lines
- 4bcacda: Fixed markdown HTML parsing when window object is unavailable in server-side environments
- 5d267cb: Fixed ordered list numbering when list has a non-default start value
- Updated dependencies [aa9709e]
- Updated dependencies [b46e66a]
- @tiptap/core@3.17.1
- @tiptap/pm@3.17.1
## 3.17.0
### Patch Changes
- Updated dependencies [eecf1c9]
- @tiptap/core@3.17.0
- @tiptap/pm@3.17.0
## 3.16.0
### Patch Changes
- d63518a: Fix incorrect Markdown output when underline is mixed with bold or italic and their ranges do not fully overlap.
- d63518a: Fix overlapping underline/bold/italic serialization and add tests
- @tiptap/core@3.16.0
- @tiptap/pm@3.16.0
## 3.15.3
### Patch Changes
- Updated dependencies [8f86f06]
- @tiptap/core@3.15.3
- @tiptap/pm@3.15.3
## 3.15.2
### Patch Changes
- @tiptap/core@3.15.2
- @tiptap/pm@3.15.2
## 3.15.1
### Patch Changes
- @tiptap/core@3.15.1
- @tiptap/pm@3.15.1
## 3.15.0
### Patch Changes
- Updated dependencies [ac8361c]
- @tiptap/core@3.15.0
- @tiptap/pm@3.15.0
## 3.14.0
### Patch Changes
- @tiptap/core@3.14.0
- @tiptap/pm@3.14.0
## 3.13.0
### Patch Changes
- 7725052: Fixed trailing and leading whitespace handling in markdown serialization for inline marks
- Updated dependencies [526365a]
- Updated dependencies [e3b4f68]
- @tiptap/core@3.13.0
- @tiptap/pm@3.13.0
## 3.12.1
### Patch Changes
- @tiptap/core@3.12.1
- @tiptap/pm@3.12.1
## 3.12.0
### Patch Changes
- Updated dependencies [f232c5a]
- @tiptap/core@3.12.0
- @tiptap/pm@3.12.0
## 3.11.1
### Patch Changes
- Updated dependencies [d0c4264]
- @tiptap/core@3.11.1
- @tiptap/pm@3.11.1
## 3.11.0
### Patch Changes
- Updated dependencies [541c93c]
- @tiptap/core@3.11.0
- @tiptap/pm@3.11.0
## 3.10.8
### Patch Changes
- f3bb5c1: Fixed CommonJS compatibility by downgrading `marked` dependency from v16 to v15.
- Updated dependencies [8375241]
- Updated dependencies [b7ead7c]
- Updated dependencies [95d3e80]
- Updated dependencies [fd479bd]
- @tiptap/core@3.10.8
- @tiptap/pm@3.10.8
## 3.10.7
### Patch Changes
- @tiptap/core@3.10.7
- @tiptap/pm@3.10.7
## 3.10.6
### Patch Changes
- @tiptap/core@3.10.6
- @tiptap/pm@3.10.6
## 3.10.5
### Patch Changes
- Updated dependencies [92fae18]
- @tiptap/core@3.10.5
- @tiptap/pm@3.10.5
## 3.10.4
### Patch Changes
- Updated dependencies [64561c4]
- @tiptap/core@3.10.4
- @tiptap/pm@3.10.4
## 3.10.3
### Patch Changes
- 13fdcb5: Fix markdown serialization to prevent marks from continuing after hard breaks. Previously, marks like bold would incorrectly persist across hard breaks in the markdown output.
- 5774fc2: Fixed a bug where marks were resolved in incorrect orders, breaking markdown rendering for nested marks.
- f80391b: Fix parsing of mixed bullet lists and task lists. Previously, Marked.js would group consecutive bullet list items and task list items into a single list token, causing incorrect parsing. Now the parser detects mixed lists and splits them into separate bulletList and taskList nodes.
- @tiptap/core@3.10.3
- @tiptap/pm@3.10.3
## 3.10.2
### Patch Changes
- 194af3b: Fix parsing of mixed inline HTML within Markdown content so that inline HTML fragments are parsed correctly.
- @tiptap/core@3.10.2
- @tiptap/pm@3.10.2
## 3.10.1
### Patch Changes
- Updated dependencies [3564e7c]
- @tiptap/core@3.10.1
- @tiptap/pm@3.10.1
## 3.10.0
### Patch Changes
- Updated dependencies [4aa9f57]
- Updated dependencies [4aa9f57]
- @tiptap/core@3.10.0
- @tiptap/pm@3.10.0
## 3.9.1
### Patch Changes
- @tiptap/core@3.9.1
- @tiptap/pm@3.9.1
## 3.9.0
### Patch Changes
- Updated dependencies [bbb8e16]
- @tiptap/core@3.9.0
- @tiptap/pm@3.9.0
## 3.8.0
### Patch Changes
- @tiptap/core@3.8.0
- @tiptap/pm@3.8.0
## 3.7.2
### Patch Changes
- @tiptap/core@3.7.2
- @tiptap/pm@3.7.2
## 3.7.1
### Patch Changes
- f1fc469: Editors will not throw an error anymore when `content` is an empty string and `contentType` is `markdown`
- c9036bd: Remove invalid server configuration from package.json
- @tiptap/core@3.7.1
- @tiptap/pm@3.7.1
## 3.7.0
### Minor Changes
- 35645d9: Add comprehensive bidirectional markdown support to Tiptap through a new `@tiptap/markdown` package and Markdown utilities in `@tiptap/core`.
**New Package: `@tiptap/markdown`** - A new official extension that provides full Markdown parsing and serialization capabilities using [MarkedJS](https://marked.js.org) as the underlying Markdown parser.
**Core Features:**
**Extension API**
- **`Markdown` Extension**: Main extension that adds Markdown support to your editor
- **`MarkdownManager`**: Core engine for parsing and serializing Markdown
- Parse Markdown strings to Tiptap JSON: `editor.markdown.parse(markdown)`
- Serialize Tiptap JSON to Markdown: `editor.markdown.serialize(json)`
- Access to underlying marked.js instance: `editor.markdown.instance`
#### Editor Methods
- **`editor.getMarkdown()`**: Serialize current editor content to Markdown string
- **`editor.markdown`**: Access to MarkdownManager instance for advanced operations
**Editor Options:**
- **`contentType`**: Control the type of content that is inserted into the editor. Can be `json`, `html` or `markdown` - defaults to `json` and will automatically detect invalid content types (like JSON when it is actually Markdown).
```typescript
new Editor({
content: "# Hello World",
contentType: "markdown",
});
```
**Command Options:** All content commands now support an `contentType` option:
- **`setContent(markdown, { contentType: 'markdown' })`**: Replace editor content with markdown
- **`insertContent(markdown, { contentType: 'markdown' })`**: Insert markdown at cursor position
- **`insertContentAt(position, markdown, { contentType: 'markdown' })`**: Insert Markdown at specific position
For more, check [the documentation](https://tiptap.dev/docs/editor/markdown).
### Patch Changes
- Updated dependencies [35645d9]
- Updated dependencies [35645d9]
- Updated dependencies [35645d9]
- @tiptap/core@3.7.0
- @tiptap/pm@3.7.0

View File

@@ -0,0 +1,18 @@
# @tiptap/markdown
[![Version](https://img.shields.io/npm/v/@tiptap/markdown.svg?label=version)](https://www.npmjs.com/package/@tiptap/markdown)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/markdown.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/markdown.svg)](https://www.npmjs.com/package/@tiptap/markdown)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,58 @@
{
"name": "@tiptap/markdown",
"version": "3.30.3",
"description": "markdown parser and serializer for tiptap",
"keywords": [
"tiptap",
"tiptap utility"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/markdown"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": {
"browser": "./dist/index.js",
"default": "./dist/index.js"
},
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"dependencies": {
"marked": "^17.0.1"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,225 @@
import {
type InsertContentAtOptions as MarkdownInsertContentAtOptions,
type InsertContentOptions as MarkdownInsertContentOptions,
type SetContentOptions as MarkdownSetContentOptions,
commands,
Extension,
} from '@tiptap/core'
import type { marked } from 'marked'
import MarkdownManager from './MarkdownManager.js'
import type { ContentType } from './types.js'
import { assumeContentType } from './utils.js'
declare module '@tiptap/core' {
interface Editor {
/**
* Get the content of the editor as markdown.
*/
getMarkdown: () => string
/**
* The markdown manager instance.
*/
markdown?: MarkdownManager
}
interface EditorOptions {
/**
* The content type the content is provided as.
*
* @default 'json'
*/
contentType?: ContentType
}
interface Storage {
markdown: MarkdownExtensionStorage
}
interface InsertContentOptions {
/**
* The content type the content is provided as.
*
* @default 'json'
*/
contentType?: ContentType
}
interface InsertContentAtOptions {
/**
* The content type the content is provided as.
*
* @default 'json'
*/
contentType?: ContentType
}
interface SetContentOptions {
/**
* The content type the content is provided as.
*
* @default 'json'
*/
contentType?: ContentType
}
}
export type MarkdownExtensionOptions = {
/**
* Configure the indentation style and size for lists and code blocks.
* - `style`: Choose between spaces or tabs. Default is 'space'.
* - `size`: Number of spaces or tabs for indentation. Default is 2.
*/
indentation?: { style?: 'space' | 'tab'; size?: number }
/**
* Use a custom version of `marked` for markdown parsing and serialization.
* If not provided, the default `marked` instance will be used.
*/
marked?: typeof marked
/**
* Options to pass to `marked.setOptions()`.
* See the [marked documentation](https://marked.js.org/using_advanced#options) for available options.
*/
markedOptions?: Parameters<typeof marked.setOptions>[0]
}
export type MarkdownExtensionStorage = {
manager: MarkdownManager
}
export const Markdown = Extension.create<MarkdownExtensionOptions, MarkdownExtensionStorage>({
name: 'markdown',
addOptions() {
return {
indentation: { style: 'space', size: 2 },
marked: undefined,
markedOptions: {},
}
},
addCommands() {
return {
setContent: (content, options?: MarkdownSetContentOptions) => {
// if no contentType is specified, we assume the content is in JSON format OR HTML format
if (!options?.contentType) {
return commands.setContent(content, options)
}
const actualContentType = assumeContentType(content, options?.contentType)
if (actualContentType !== 'markdown' || !this.editor.markdown) {
return commands.setContent(content, options)
}
const mdContent = this.editor.markdown.parse(content as string)
return commands.setContent(mdContent, options)
},
insertContent: (value, options?: MarkdownInsertContentOptions) => {
// if no contentType is specified, we assume the content is in JSON format OR HTML format
if (!options?.contentType) {
return commands.insertContent(value, options)
}
const actualContentType = assumeContentType(value, options?.contentType)
if (actualContentType !== 'markdown' || !this.editor.markdown) {
return commands.insertContent(value, options)
}
const mdContent = this.editor.markdown.parse(value as string)
return commands.insertContent(mdContent, options)
},
insertContentAt: (position, value, options?: MarkdownInsertContentAtOptions) => {
// if no contentType is specified, we assume the content is in JSON format OR HTML format
if (!options?.contentType) {
return commands.insertContentAt(position, value, options)
}
const actualContentType = assumeContentType(value, options?.contentType)
if (actualContentType !== 'markdown' || !this.editor.markdown) {
return commands.insertContentAt(position, value, options)
}
const mdContent = this.editor.markdown.parse(value as string)
return commands.insertContentAt(position, mdContent, options)
},
}
},
addStorage() {
return {
manager: new MarkdownManager({
indentation: this.options.indentation,
marked: this.options.marked,
markedOptions: this.options.markedOptions,
extensions: [],
}),
}
},
onBeforeCreate() {
if (this.editor.markdown) {
console.error(
'[tiptap][markdown]: There is already a `markdown` property on the editor instance. This might lead to unexpected behavior.',
)
return
}
this.storage.manager = new MarkdownManager({
indentation: this.options.indentation,
marked: this.options.marked,
markedOptions: this.options.markedOptions,
extensions: this.editor.extensionManager.baseExtensions,
})
this.editor.markdown = this.storage.manager
// add a `getMarkdown()` method to the editor
this.editor.getMarkdown = () => {
return this.storage.manager.serialize(this.editor.getJSON())
}
if (!this.editor.options.contentType) {
return
}
const assumedType = assumeContentType(
this.editor.options.content,
this.editor.options.contentType,
)
if (assumedType !== 'markdown') {
return
}
if (!this.editor.markdown) {
throw new Error(
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the Markdown extension is not added to the editor. Please add the Markdown extension to use this feature.',
)
}
if (
this.editor.options.content === undefined ||
typeof this.editor.options.content !== 'string'
) {
throw new Error(
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the initial content is not a string. Please provide the initial content as a markdown string.',
)
}
const json = this.editor.markdown.parse(this.editor.options.content as string)
// If the parsed markdown produced no content, leave options.content
// as-is (empty string) so that createDoc / DOMParser.parse can fill in
// the required block node via ProseMirror's ContentMatch.fillBefore.
if (json.content?.length) {
this.editor.options.content = json
}
},
})

View File

@@ -0,0 +1,879 @@
import {
type AnyExtension,
createBlockMarkdownSpec,
createInlineMarkdownSpec,
Extension,
Node,
} from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { BulletList, ListItem, OrderedList, TaskItem, TaskList } from '@tiptap/extension-list'
import { Mention } from '@tiptap/extension-mention'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import Underline from '@tiptap/extension-underline'
import { Youtube } from '@tiptap/extension-youtube'
import { MarkdownManager } from './MarkdownManager.js'
import { Marked } from 'marked'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
// Create test extensions
const TestCallout = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
defining: true,
addAttributes() {
return {
type: {
default: 'info',
},
}
},
...createBlockMarkdownSpec({
nodeName: 'callout',
allowedAttributes: ['type'],
}),
})
const TestTag = Node.create({
name: 'tag',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
label: {
type: null,
},
}
},
...createInlineMarkdownSpec({
nodeName: 'tag',
selfClosing: true,
allowedAttributes: ['label'],
}),
})
const TestProbe = Extension.create({
name: 'probeBlock',
markdownTokenName: 'probeBlock',
parseMarkdown: (token, helpers) => {
return {
type: 'paragraph',
content: helpers.parseInline(token.tokens || []),
}
},
markdownTokenizer: {
name: 'probeBlock',
level: 'block',
start: ':::probe ',
tokenize: (src, _tokens, lexer) => {
const match = src.match(/^:::probe\s+(.+?)\s+:::/)
if (!match) {
return undefined
}
return {
type: 'probeBlock',
raw: match[0],
text: match[1],
tokens: lexer.inlineTokens(match[1]),
}
},
},
})
describe('MarkdownManager Direct Tests', () => {
let markdownManager: MarkdownManager
let basicExtensions: AnyExtension[]
let extendedExtensions: AnyExtension[]
beforeEach(() => {
// Basic extensions for standard markdown
basicExtensions = [
Document,
Paragraph,
Text,
Heading,
Bold,
Italic,
Strike,
Link,
BulletList,
OrderedList,
ListItem,
Underline,
]
// Extended extensions with custom markdown features
extendedExtensions = [
...basicExtensions,
TaskList,
TaskItem,
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
}),
Youtube.configure({
width: 640,
height: 360,
}),
TestCallout,
TestTag,
]
})
describe('Basic Markdown Parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})
it('should parse simple text', () => {
const markdown = 'Hello world'
const doc = markdownManager.parse(markdown)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
expect(doc.content![0].type).toBe('paragraph')
expect(doc.content![0].content![0].text).toBe('Hello world')
})
it('should parse headings', () => {
const markdown = `# Heading 1
## Heading 2`
const doc = markdownManager.parse(markdown)
expect(doc.content).toHaveLength(2)
expect(doc.content![0].type).toBe('heading')
expect(doc.content![0].attrs!.level).toBe(1)
expect(doc.content![0].content![0].text).toBe('Heading 1')
expect(doc.content![1].type).toBe('heading')
expect(doc.content![1].attrs!.level).toBe(2)
expect(doc.content![1].content![0].text).toBe('Heading 2')
})
it('should parse multiple paragraphs', () => {
const markdown = `First paragraph.
Second paragraph.`
const doc = markdownManager.parse(markdown)
expect(doc.content).toHaveLength(2)
expect(doc.content![0].type).toBe('paragraph')
expect(doc.content![0].content![0].text).toBe('First paragraph.')
expect(doc.content![1].type).toBe('paragraph')
expect(doc.content![1].content![0].text).toBe('Second paragraph.')
})
it('keeps inline tokens after ordered list tokenization', () => {
const markdown = `1. Item 1
- Nested bullet item
**After** ordered list`
const doc = markdownManager.parse(markdown)
expect(doc.content).toHaveLength(2)
expect(doc.content![0].type).toBe('orderedList')
expect(doc.content![1]).toEqual({
type: 'paragraph',
content: [
{
type: 'text',
text: 'After',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' ordered list',
},
],
})
})
it('matches isolated Marked instance output for ordered-list parsing', () => {
const markdown = `1. Item 1
- Nested bullet item
**After** ordered list`
const isolatedManager = new MarkdownManager({
marked: new Marked() as unknown as typeof import('marked').marked,
extensions: basicExtensions,
})
expect(markdownManager.parse(markdown)).toEqual(isolatedManager.parse(markdown))
})
it('keeps later inline parsing stable after a custom tokenizer uses inlineTokens', () => {
const manager = new MarkdownManager({ extensions: [...basicExtensions, TestProbe] })
const markdown = `:::probe **Probe** text :::
**After** custom tokenizer`
const doc = manager.parse(markdown)
expect(doc.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Probe',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' text',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'After',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' custom tokenizer',
},
],
},
])
})
it('runs custom tokenizers registered on an injected Marked instance', () => {
// Regression: custom tokenizers must still fire when a dedicated `marked`
// instance is injected, not only on the default shared instance.
const manager = new MarkdownManager({
marked: new Marked() as unknown as typeof import('marked').marked,
extensions: [...basicExtensions, TestProbe],
})
const doc = manager.parse(':::probe **Probe** text :::')
expect(doc.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Probe',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' text',
},
],
},
])
})
})
describe('simple nested Marks parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})
const nestedMarkdown = `**...++abc++**`
const expectedJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '...',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: 'abc',
marks: [{ type: 'underline' }, { type: 'bold' }],
},
],
},
],
}
it('should convert nested markdown to expected JSON structure', () => {
const json = markdownManager.parse(nestedMarkdown)
expect(json).toEqual(expectedJSON)
})
it('should convert nested JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(expectedJSON)
expect(md).toEqual(nestedMarkdown)
})
})
describe('complex nested Marks parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})
const nestedMarkdown = `**...~~++abc*abc*++~~**`
const expectedJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '...',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: 'abc',
marks: [{ type: 'underline' }, { type: 'strike' }, { type: 'bold' }],
},
{
type: 'text',
text: 'abc',
marks: [
{ type: 'italic' },
{ type: 'underline' },
{ type: 'strike' },
{ type: 'bold' },
],
},
],
},
],
}
it('should convert nested markdown to expected JSON structure', () => {
const json = markdownManager.parse(nestedMarkdown)
expect(json).toEqual(expectedJSON)
})
it('should convert nested JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(expectedJSON)
expect(md).toEqual(nestedMarkdown)
})
})
describe('Extended Markdown Parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})
it('should parse mentions', () => {
const markdown = '[@ id="user123" label="John Doe"] said hello'
const doc = markdownManager.parse(markdown)
expect(doc.content![0].type).toBe('paragraph')
expect(doc.content![0].content![0].type).toBe('mention')
expect(doc.content![0].content![0].attrs!.id).toBe('user123')
expect(doc.content![0].content![0].attrs!.label).toBe('John Doe')
expect(doc.content![0].content![1].text).toBe(' said hello')
})
it('should parse YouTube videos', () => {
const markdown = ':::youtube {src="https://youtube.com/watch?v=test"} :::'
const doc = markdownManager.parse(markdown)
expect(doc.content![0].type).toBe('youtube')
expect(doc.content![0].attrs!.src).toBe('https://youtube.com/watch?v=test')
})
it('should handle complex documents with mixed content', () => {
const markdown = `# Project Title
This is a description with [@ id="user" label="User Name"].
:::youtube {src="https://youtube.com/watch?v=example"} :::
## Notes
Final paragraph.`
const doc = markdownManager.parse(markdown)
expect(doc.content).toHaveLength(5)
expect(doc.content![0].type).toBe('heading')
expect(doc.content![1].type).toBe('paragraph')
expect(doc.content![1].content![0].type).toBe('text')
expect(doc.content![1].content![1].type).toBe('mention')
expect(doc.content![2].type).toBe('youtube')
expect(doc.content![3].type).toBe('heading')
expect(doc.content![4].type).toBe('paragraph')
})
})
describe('Markdown Rendering', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})
it('should render simple text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello world' }],
},
],
}
const markdown = markdownManager.renderNodes(doc)
expect(markdown).toBe('Hello world')
})
it('should return empty string when serializing empty document', () => {
// When the editor is empty, it contains a doc with an empty paragraph
const emptyDoc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
}
const markdown = markdownManager.serialize(emptyDoc)
expect(markdown).toBe('')
})
it('should preserve blank lines between paragraphs with content', () => {
// Multiple paragraphs with content should preserve blank lines
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First paragraph' }],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Second paragraph' }],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('First paragraph\n\n\n\nSecond paragraph')
})
it('should move trailing whitespace outside of mark closing (Issue #7180)', () => {
// When text has trailing space and bold mark, output should be "**text** " not "**text **"
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'bold text ',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: 'continues here',
},
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
// The trailing space should be outside the ** markers
expect(markdown).toBe('**bold text** continues here')
})
it('should move leading whitespace outside of mark opening (Issue #7180)', () => {
// When text has leading space and bold mark, output should be " **text**" not "** text**"
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'normal text',
},
{
type: 'text',
text: ' bold text',
marks: [{ type: 'bold' }],
},
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
// The leading space should be outside the ** markers
expect(markdown).toBe('normal text **bold text**')
})
it('should handle both leading and trailing whitespace in marked text (Issue #7180)', () => {
// Text like " bold " with bold mark should output " **bold** " not "** bold **"
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'before',
},
{
type: 'text',
text: ' bold ',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: 'after',
},
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
// Both leading and trailing spaces should be outside the ** markers
expect(markdown).toBe('before **bold** after')
})
it('should handle trailing whitespace in italic marks (Issue #7180)', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'italic text ',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: 'normal',
},
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
expect(markdown).toBe('*italic text* normal')
})
it('should handle whitespace at end of paragraph with mark (Issue #7180)', () => {
// Bold text with trailing space at end of paragraph
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'some text ',
marks: [{ type: 'bold' }],
},
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
// Trailing space should be outside the marks
expect(markdown).toBe('**some text** ')
})
it('should render nested marks with correct tag order', () => {
// Marks come in ProseMirror's canonical order (rank ascending) — Bold is
// registered before Strike so it gets the lower rank. The serializer
// places lower-rank marks outermost, mirroring how a real editor would
// wrap the text: bold outer, strike inner.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Test: ',
marks: [],
},
{
type: 'text',
text: 'abcd',
marks: [
{ type: 'bold' }, // Opens first: **
{ type: 'strike' }, // Opens second: ~
],
},
{
type: 'text',
text: ' end.',
marks: [],
},
],
},
],
}
const docAtEnd = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'abcd',
marks: [
{ type: 'bold' }, // Opens first: **
{ type: 'strike' }, // Opens second: ~
],
},
],
},
],
}
expect(markdownManager.renderNodes(doc)).to.equal('Test: **~~abcd~~** end.')
expect(markdownManager.renderNodes(docAtEnd)).to.equal('**~~abcd~~**')
})
it('should render headings', () => {
const doc = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Main Title' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Subtitle' }],
},
],
}
const markdown = markdownManager.renderNodes(doc)
expect(markdown).toBe('# Main Title\n\n## Subtitle')
})
it('should render mentions', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'mention',
attrs: { id: 'user123', label: 'John Doe' },
},
{ type: 'text', text: ' said hello' },
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
expect(markdown).toBe('[@ id="user123" label="John Doe"] said hello')
})
it('should render YouTube videos', () => {
const doc = {
type: 'doc',
content: [
{
type: 'youtube',
attrs: { src: 'https://youtube.com/watch?v=test' },
},
],
}
const markdown = markdownManager.renderNodes(doc)
expect(markdown).toBe(':::youtube {src="https://youtube.com/watch?v=test"} :::')
})
})
it('does not reopen marks after inline atom that does not carry them', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Bold ', marks: [{ type: 'bold' }] },
{ type: 'tag', attrs: { label: 'node' } },
{ type: 'text', text: ' some text' },
],
},
],
}
const markdown = markdownManager.renderNodes(doc)
// Trailing space is moved outside bold markers (per Issue #7180 whitespace convention)
expect(markdown).toBe('**Bold** [tag label="node"] some text')
})
describe('Round-trip Tests', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})
const testCases = [
{
name: 'simple text',
markdown: 'Hello world',
},
{
name: 'headings',
markdown: '# Main Title\n\n## Subtitle',
},
{
name: 'mention',
markdown: '[@ id="user123" label="John Doe"] said hello',
},
{
name: 'youtube video',
markdown: ':::youtube {src="https://youtube.com/watch?v=test"} :::',
},
{
name: 'mixed content',
markdown:
'# Title\n\nHello [@ id="user" label="User"].\n\n:::youtube {src="https://youtube.com/watch?v=example"} :::',
},
]
testCases.forEach(({ name, markdown }) => {
it(`should round-trip ${name}`, () => {
const doc = markdownManager.parse(markdown)
const roundtripMarkdown = markdownManager.renderNodes(doc)
// Normalize whitespace for comparison
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
expect(normalizeWhitespace(roundtripMarkdown)).toBe(normalizeWhitespace(markdown))
})
})
})
describe('Extension Integration', () => {
it('should work with different extension combinations', () => {
// Test with minimal extensions
const minimalExtensions = [Document, Paragraph, Text]
const minimalManager = new MarkdownManager({ extensions: minimalExtensions })
const simpleDoc = minimalManager.parse('Hello world')
expect(simpleDoc.content![0].type).toBe('paragraph')
// Test with extended extensions
const extendedManager = new MarkdownManager({ extensions: extendedExtensions })
const complexDoc = extendedManager.parse('# Title\n\n[@ id="user" label="User"]')
expect(complexDoc.content![0].type).toBe('heading')
expect(complexDoc.content![1].content![0].type).toBe('mention')
})
it('should handle unknown markdown gracefully', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// Test various unknown markdown patterns that should be preserved as text
const testCases = [
'Hello [unknown syntax] world',
'Text with [custom element] here',
'Some [shortcode attr="value"] content',
'Using [component prop="test"] in text',
]
testCases.forEach(markdown => {
const doc = manager.parse(markdown)
// Should treat it as regular text since it's not recognized markdown
expect(doc.content![0].type).toBe('paragraph')
expect(doc.content![0].content![0].text).toBe(markdown)
})
})
})
describe('Performance Tests', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})
it('should handle large documents efficiently', () => {
// Generate a large markdown document
const sections = []
for (let i = 1; i <= 100; i += 1) {
sections.push(`## Section ${i}`)
sections.push(`This is paragraph ${i} with some **bold** text.`)
sections.push(`And mention [@ id="user${i}" label="User ${i}"].`)
}
const largeMarkdown = sections.join('\n\n')
const startTime = Date.now()
const doc = markdownManager.parse(largeMarkdown)
const parseTime = Date.now() - startTime
const renderStartTime = Date.now()
const rendered = markdownManager.renderNodes(doc)
const renderTime = Date.now() - renderStartTime
// Basic performance expectations (these would need to be tuned)
expect(parseTime).toBeLessThan(1000) // Less than 1 second
expect(renderTime).toBeLessThan(1000) // Less than 1 second
expect(doc.content!.length).toBeGreaterThan(200) // Should have parsed many nodes
expect(rendered).toContain('Section 1')
expect(rendered).toContain('Section 100')
})
it('should handle deeply nested content', () => {
const nestedMarkdown = `# Level 1
:::callout {type="info"}
## Level 2 inside callout
Some text with [@ id="user" label="User"].
:::youtube {src="https://youtube.com/watch?v=test"} :::
:::`
const doc = markdownManager.parse(nestedMarkdown)
const rendered = markdownManager.renderNodes(doc)
expect(doc.content!.length).toBeGreaterThan(1)
expect(rendered).toContain('Level 1')
expect(rendered).toContain('Level 2')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export * from './Extension.js'
export * from './MarkdownManager.js'
export * from './utils.js'

View File

@@ -0,0 +1 @@
export type ContentType = 'json' | 'html' | 'markdown'

View File

@@ -0,0 +1,236 @@
import { attrsEqual } from '@tiptap/core'
import type { Content, MarkdownToken } from '@tiptap/core'
import type { Fragment, Node as PMNode } from '@tiptap/pm/model'
import type { ContentType } from './types.js'
/**
* Matches a run of two or more consecutive line breaks (a blank line) at the
* end of a string, allowing horizontal whitespace between the breaks.
*
* @example
* TRAILING_BLANK_LINES.test('paragraph\n \n')
* // => true
*/
const TRAILING_BLANK_LINES = /\n[^\S\n]*(?:\n[^\S\n]*)+$/
/**
* Extracts blank lines absorbed into marked token `raw` back into explicit
* `space` tokens so they're handled uniformly by the reconstruction path.
* @param tokens The marked token stream to normalize.
* @returns A new token array with absorbed blank lines as `space` tokens.
*/
export function extractAbsorbedBlankLines(tokens: MarkdownToken[]): MarkdownToken[] {
return tokens.flatMap((token, index) => {
// A following `space` token already carries the blank lines for this gap.
if (token.type === 'space' || tokens[index + 1]?.type === 'space') {
return [token]
}
const trailingBlankLines = (token.raw || '').match(TRAILING_BLANK_LINES)
if (!trailingBlankLines) {
return [token]
}
return [
{ ...token, raw: (token.raw || '').slice(0, -trailingBlankLines[0].length) },
{ type: 'space', raw: trailingBlankLines[0] } as MarkdownToken,
]
})
}
/**
* Wraps each line of the content with the given prefix.
* @param prefix The prefix to wrap each line with.
* @param content The content to wrap.
* @returns The content with each line wrapped with the prefix.
*/
export function wrapInMarkdownBlock(prefix: string, content: string) {
// split content lines
const lines = content.split('\n')
// add empty strings between every line
const output = lines
// add empty lines between each block
.flatMap(line => [line, ''])
// add the prefix to each line
.map(line => `${prefix}${line}`)
.join('\n')
return output.slice(0, output.length - 1)
}
/**
* Determines which marks to close based on the next node's marks,
* treating same-type marks with different attributes as distinct.
*/
export function findMarksToClose(currentMarks: Map<string, any>, nextNode: any): string[] {
const marksToClose: string[] = []
Array.from(currentMarks.entries()).forEach(([markType, currentMark]) => {
if (!nextNode) {
marksToClose.push(markType)
return
}
// Check if the next node has a mark of the same type with matching attributes
const nextMark = (nextNode.marks || []).find(
(mark: any) => mark.type === markType && attrsEqual(mark.attrs, currentMark.attrs),
)
if (!nextMark) {
marksToClose.push(markType)
}
})
return marksToClose
}
/**
* Determines which marks need to open, treating same-type marks with
* different attributes as distinct (close + reopen).
*/
export function findMarksToOpen(
activeMarks: Map<string, any>,
currentMarks: Map<string, any>,
): Array<{ type: string; mark: any }> {
const marksToOpen: Array<{ type: string; mark: any }> = []
Array.from(currentMarks.entries()).forEach(([markType, mark]) => {
const activeMark = activeMarks.get(markType)
// Open if the mark type is not active, or if the attributes differ
if (!activeMark || !attrsEqual(activeMark.attrs, mark.attrs)) {
marksToOpen.push({ type: markType, mark })
}
})
return marksToOpen
}
/**
* Determines which marks to close at the node end, treating same-type marks
* with different attributes as distinct (close + reopen).
*/
export function findMarksToCloseAtEnd(
activeMarks: Map<string, any>,
currentMarks: Map<string, any>,
nextNode: any,
markSetsEqual: (a: Map<string, { type: string }>, b: Map<string, { type: string }>) => boolean,
): string[] {
const isLastNode = !nextNode
const nextNodeHasNoMarks = nextNode && (!nextNode.marks || nextNode.marks.length === 0)
const nextNodeHasDifferentMarks =
nextNode &&
nextNode.marks &&
!markSetsEqual(currentMarks, new Map(nextNode.marks.map((mark: any) => [mark.type, mark])))
const marksToCloseAtEnd: string[] = []
if (isLastNode || nextNodeHasNoMarks || nextNodeHasDifferentMarks) {
if (nextNode && nextNode.marks) {
Array.from(activeMarks.entries())
.reverse()
.forEach(([markType, activeMark]) => {
// Check if nextNode has a mark of the same type with matching attrs
const nextMark = nextNode.marks.find(
(m: any) => m.type === markType && attrsEqual(m.attrs, activeMark.attrs),
)
if (!nextMark) {
marksToCloseAtEnd.push(markType)
}
})
} else if (isLastNode || nextNodeHasNoMarks) {
// Close all active marks
marksToCloseAtEnd.push(...Array.from(activeMarks.keys()).reverse())
}
}
return marksToCloseAtEnd
}
/**
* Closes active marks before rendering a non-text node.
* Returns the closing markdown syntax and clears the active marks.
*/
export function closeMarksBeforeNode(
activeMarks: Map<string, any>,
getMarkClosing: (markType: string, mark: any) => string,
): string {
let beforeMarkdown = ''
Array.from(activeMarks.keys())
.reverse()
.forEach(markType => {
const mark = activeMarks.get(markType)
const closeMarkdown = getMarkClosing(markType, mark)
if (closeMarkdown) {
beforeMarkdown = closeMarkdown + beforeMarkdown
}
})
activeMarks.clear()
return beforeMarkdown
}
/**
* Reopens marks after rendering a non-text node.
* Returns the opening markdown syntax and updates the active marks.
*/
export function reopenMarksAfterNode(
marksToReopen: Map<string, any>,
activeMarks: Map<string, any>,
getMarkOpening: (markType: string, mark: any) => string,
): string {
let afterMarkdown = ''
Array.from(marksToReopen.entries()).forEach(([markType, mark]) => {
const openMarkdown = getMarkOpening(markType, mark)
if (openMarkdown) {
afterMarkdown += openMarkdown
}
activeMarks.set(markType, mark)
})
return afterMarkdown
}
/**
* Check if a markdown list item token is a task item and extract its state.
*
* @param item The list item token to check
* @returns Object containing isTask flag, checked state, and indentation level
*
* @example
* ```ts
* isTaskItem({ raw: '- [ ] Task' }) // { isTask: true, checked: false, indentLevel: 0 }
* isTaskItem({ raw: ' - [x] Done' }) // { isTask: true, checked: true, indentLevel: 2 }
* isTaskItem({ raw: '- Regular' }) // { isTask: false, indentLevel: 0 }
* ```
*/
export function isTaskItem(item: MarkdownToken): {
isTask: boolean
checked?: boolean
indentLevel: number
} {
const raw = item.raw || item.text || ''
// Match patterns like "- [ ] " or " - [x] "
const match = raw.match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/)
if (match) {
return { isTask: true, checked: match[2].toLowerCase() === 'x', indentLevel: match[1].length }
}
return { isTask: false, indentLevel: 0 }
}
/**
* Assumes the content type based off the content.
* @param content The content to assume the type for.
* @param contentType The content type that should be prioritized.
*/
export function assumeContentType(
content: (Content | Fragment | PMNode) | string,
contentType: ContentType,
): ContentType {
// if not a string, we assume it will be a json content object
if (typeof content !== 'string') {
return 'json'
}
// otherwise we let the content type be what it is
return contentType
}

View File

@@ -0,0 +1,87 @@
/**
* @vitest-environment node
*/
import {
extractHtmlTagNames,
htmlContainsUnrecognizedTag,
isHtmlUnknownTagName,
} from './htmlTagDetection.js'
import { describe, expect, it } from 'vite-plus/test'
describe('htmlTagDetection', () => {
describe('extractHtmlTagNames', () => {
it('extracts tag names from opening and closing tags', () => {
expect(extractHtmlTagNames('<em>hi</em>')).toEqual(['em', 'em'])
})
it('extracts the first token as tag name for unknown angle-bracket text', () => {
expect(extractHtmlTagNames('<enter foo bar>')).toEqual(['enter'])
})
it('returns an empty array when no tags are present', () => {
expect(extractHtmlTagNames('plain text')).toEqual([])
})
})
describe('isHtmlUnknownTagName', () => {
it('treats non-standard non-hyphenated tags as unknown', () => {
expect(isHtmlUnknownTagName('enter')).toBe(true)
})
it('treats standard HTML tags as known', () => {
expect(isHtmlUnknownTagName('em')).toBe(false)
expect(isHtmlUnknownTagName('br')).toBe(false)
expect(isHtmlUnknownTagName('span')).toBe(false)
})
it('treats hyphenated tag names as known custom elements', () => {
expect(isHtmlUnknownTagName('my-mention')).toBe(false)
expect(isHtmlUnknownTagName('my-el')).toBe(false)
})
it('treats common SVG child elements as known', () => {
expect(isHtmlUnknownTagName('circle')).toBe(false)
expect(isHtmlUnknownTagName('path')).toBe(false)
expect(isHtmlUnknownTagName('g')).toBe(false)
expect(isHtmlUnknownTagName('rect')).toBe(false)
expect(isHtmlUnknownTagName('linearGradient')).toBe(false)
expect(isHtmlUnknownTagName('clipPath')).toBe(false)
})
})
describe('htmlContainsUnrecognizedTag', () => {
const emptySchema = new Set<string>()
it('returns true for unknown angle-bracket placeholders', () => {
expect(htmlContainsUnrecognizedTag('<enter foo bar>', emptySchema)).toBe(true)
expect(htmlContainsUnrecognizedTag('<this is a placeholder>', emptySchema)).toBe(true)
})
it('returns false for standard HTML elements', () => {
expect(htmlContainsUnrecognizedTag('<em></em>', emptySchema)).toBe(false)
expect(htmlContainsUnrecognizedTag('<em>hi</em>', emptySchema)).toBe(false)
expect(htmlContainsUnrecognizedTag('<br>', emptySchema)).toBe(false)
})
it('returns false for hyphenated custom elements', () => {
expect(htmlContainsUnrecognizedTag('<my-el></my-el>', emptySchema)).toBe(false)
})
it('returns false when schema declares a non-standard tag', () => {
const schemaTags = new Set(['something'])
expect(htmlContainsUnrecognizedTag('<something>happy</something>', schemaTags)).toBe(false)
})
it('returns false when no tags are present', () => {
expect(htmlContainsUnrecognizedTag('no tags here', emptySchema)).toBe(false)
})
it('returns false for SVG elements', () => {
expect(
htmlContainsUnrecognizedTag('<svg><circle cx="10" cy="10" r="5"/></svg>', emptySchema),
).toBe(false)
expect(htmlContainsUnrecognizedTag('<svg><path d="M0 0"/></svg>', emptySchema)).toBe(false)
})
})
})

View File

@@ -0,0 +1,195 @@
/**
* Standard HTML and common SVG element names. Used to tell apart real
* (but possibly empty) elements like `<em></em>` from genuinely unknown
* angle-bracket text such as `<enter foo bar>` so the latter can be
* preserved as literal text.
*
* Non-hyphenated tags not in this set are treated as unknown unless
* declared in the schema's parseDOM rules. This does not claim full
* parity with browser `HTMLUnknownElement` classification across all
* namespaces (e.g. rare SVG filter elements, MathML).
*/
export const STANDARD_HTML_TAGS = new Set([
'a',
'abbr',
'address',
'area',
'article',
'aside',
'audio',
'b',
'base',
'bdi',
'bdo',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'cite',
'code',
'col',
'colgroup',
'data',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'embed',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hgroup',
'hr',
'html',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'label',
'legend',
'li',
'link',
'main',
'map',
'mark',
'menu',
'meta',
'meter',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'picture',
'pre',
'progress',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'script',
'search',
'section',
'select',
'slot',
'small',
'source',
'span',
'strong',
'style',
'sub',
'summary',
'sup',
'svg',
'circle',
'clippath',
'defs',
'ellipse',
'foreignobject',
'g',
'image',
'line',
'lineargradient',
'mask',
'path',
'polygon',
'polyline',
'radialgradient',
'rect',
'stop',
'switch',
'symbol',
'textpath',
'tspan',
'use',
'table',
'tbody',
'td',
'template',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'video',
'wbr',
])
const HTML_TAG_NAME_PATTERN = /<\/?([a-zA-Z][\w-]*)/g
/**
* Extract lower-cased tag names from an HTML fragment (opening and closing tags).
*/
export function extractHtmlTagNames(html: string): string[] {
const tagNames: string[] = []
let match: RegExpExecArray | null
while ((match = HTML_TAG_NAME_PATTERN.exec(html)) !== null) {
tagNames.push(match[1].toLowerCase())
}
return tagNames
}
/**
* Returns true when the tag name is non-hyphenated and not in the
* STANDARD_HTML_TAGS allowlist (i.e. would be treated as unrecognized).
*/
export function isHtmlUnknownTagName(tagName: string): boolean {
const lower = tagName.toLowerCase()
if (lower.includes('-')) {
return false
}
return !STANDARD_HTML_TAGS.has(lower)
}
/**
* Returns true when the HTML contains a tag that is neither a standard HTML
* element nor declared in the schema's parseDOM rules.
*/
export function htmlContainsUnrecognizedTag(html: string, schemaTags: Set<string>): boolean {
const tagNames = extractHtmlTagNames(html)
return tagNames.some(tagName => {
if (!isHtmlUnknownTagName(tagName)) {
return false
}
return !schemaTags.has(tagName)
})
}

View File

@@ -0,0 +1,70 @@
export const name = 'Bullet List with Numeric Punctuation'
export const expectedInput = `
- 123
- 123
- 123
- 123
- 123.
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '123' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '123' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '123' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '123' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '123.' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,70 @@
export const name = 'Bullet List'
export const expectedInput = `
- Item 1
- Item 2
- Subitem 1
- Subitem 2
- Item 3
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 2' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 2' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 3' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,46 @@
import { createAtomBlockMarkdownSpec, Node } from '@tiptap/core'
export const name = 'Custom Atom'
export const expectedInput = `
:::custom-atom {label="Example"} :::
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'customAtom',
attrs: { label: 'Example' },
content: [],
},
],
}
export const extensions = [
Node.create({
name: 'customAtom',
group: 'block',
atom: true,
...createAtomBlockMarkdownSpec({
nodeName: 'customAtom',
name: 'custom-atom',
allowedAttributes: ['label'],
}),
parseHTML() {
return [
{
tag: 'div[data-type="custom-atom"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'custom-atom', ...HTMLAttributes }]
},
}),
]

View File

@@ -0,0 +1,57 @@
import { createBlockMarkdownSpec, Node } from '@tiptap/core'
export const name = 'Custom Block'
export const expectedInput = `
:::custom-block
This is a node with **bold text**.
:::`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'customBlock',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'This is a node with ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold text' },
{ type: 'text', text: '.' },
],
},
],
},
],
}
export const extensions = [
Node.create({
name: 'customBlock',
group: 'block',
content: 'block+',
...createBlockMarkdownSpec({
nodeName: 'customBlock',
name: 'custom-block',
content: 'block',
}),
parseHTML() {
return [
{
tag: 'div[data-type="custom-block"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'custom-block', ...HTMLAttributes }, 0]
},
}),
]

View File

@@ -0,0 +1,96 @@
import { createInlineMarkdownSpec, Node } from '@tiptap/core'
export const name = 'Custom Inline'
export const expectedInput = `
This is a [custom-inline type="bug" id="1234"] ticket.
This is a [custom-inline-tag icon="house"]Home[/custom-inline-tag] tag.
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'This is a ' },
{
type: 'customInline',
attrs: { type: 'bug', id: '1234' },
},
{ type: 'text', text: ' ticket.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'This is a ' },
{
type: 'customInlineTag',
attrs: { icon: 'house' },
content: [{ type: 'text', text: 'Home' }],
},
{ type: 'text', text: ' tag.' },
],
},
],
}
export const extensions = [
Node.create({
name: 'customInline',
group: 'inline',
atom: true,
markdownTokenName: 'custom-inline',
...createInlineMarkdownSpec({
nodeName: 'customInline',
name: 'custom-inline',
selfClosing: true,
allowedAttributes: ['type', 'id'],
}),
parseHTML() {
return [
{
tag: 'span[data-type="custom-inline"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', { 'data-type': 'custom-inline', ...HTMLAttributes }]
},
}),
Node.create({
name: 'customInlineTag',
group: 'inline',
content: 'inline*',
markdownTokenName: 'custom-inline-tag',
...createInlineMarkdownSpec({
nodeName: 'customInlineTag',
name: 'custom-inline-tag',
allowedAttributes: ['icon'],
}),
parseHTML() {
return [
{
tag: 'span[data-type="custom-inline-tag"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', { 'data-type': 'custom-inline-tag', ...HTMLAttributes }, 0]
},
}),
]

View File

@@ -0,0 +1,31 @@
export const name = 'Hard Break with Marks'
export const expectedInput = `**Speaker:**
John Doe.
**Speaker:**
**John Doe**`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' },
{ type: 'hardBreak' },
{ type: 'text', text: 'John Doe.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' },
{ type: 'hardBreak' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'John Doe' },
],
},
],
}
export const extensions = []

View File

@@ -0,0 +1,17 @@
export * as bulletList from './bullet-list.js'
export * as bulletListNumericPunctuation from './bullet-list-numeric-punctuation.js'
export * as customAtom from './custom-atom.js'
export * as customBlock from './custom-block.js'
export * as customInline from './custom-inline.js'
export * as hardBreakMarks from './hard-break-marks.js'
export * as linkWithTitle from './link-with-title.js'
export * as linkWithoutTitle from './link-without-title.js'
export * as mixedListTypes from './mixed-list-types.js'
export * as nestedNodes from './nested-nodes.js'
export * as orderedList from './ordered-list.js'
export * as orderedListSeparatedByBullet from './ordered-list-separated-by-bullet.js'
export * as orderedListWideMarker from './ordered-list-wide-marker.js'
export * as orderedListWithBulletList from './ordered-list-with-bullet-list.js'
export * as softBreakMarks from './soft-break-marks.js'
export * as taskList from './task-list.js'
export * as trailingWhitespaceMarks from './trailing-whitespace-marks.js'

View File

@@ -0,0 +1,29 @@
export const name = 'Link with Title'
export const expectedInput = `
[click here](https://example.com "Example Site")
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'click here',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
title: 'Example Site',
},
},
],
},
],
},
],
}

View File

@@ -0,0 +1,29 @@
export const name = 'Link without Title'
export const expectedInput = `
[click here](https://example.com)
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'click here',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
title: null,
},
},
],
},
],
},
],
}

View File

@@ -0,0 +1,53 @@
export const name = 'Mixed List Types'
export const expectedInput = `
- Test
- [ ] Test
- [ ] Test 2
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test' }],
},
],
},
],
},
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test 2' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,72 @@
import { createBlockMarkdownSpec, Node } from '@tiptap/core'
export const name = 'Nested Nodes'
export const expectedInput = `
:::custom-block
This is a node with **bold text**.
:::custom-block
And this is a nested block.
:::
:::`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'customBlock',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'This is a node with ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold text' },
{ type: 'text', text: '.' },
],
},
{
type: 'customBlock',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'And this is a nested block.' }],
},
],
},
],
},
],
}
export const extensions = [
Node.create({
name: 'customBlock',
group: 'block',
content: 'block+',
...createBlockMarkdownSpec({
nodeName: 'customBlock',
name: 'custom-block',
content: 'block',
}),
parseHTML() {
return [
{
tag: 'div[data-type="custom-block"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'custom-block', ...HTMLAttributes }, 0]
},
}),
]

View File

@@ -0,0 +1,60 @@
export const name = 'Ordered List Separated by Bullet List'
export const expectedInput = `
1. one
- inner
2. two
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'one' }],
},
],
},
],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'inner' }],
},
],
},
],
},
{
type: 'orderedList',
attrs: {
start: 2,
},
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'two' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,53 @@
export const name = 'Ordered List with a Wide Marker'
// `10. ` is four characters, so the nested list has to start at column four to
// stay a child of it.
export const expectedInput = `
10. ten
1. inner
11. eleven
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 10 },
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'ten' }],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'inner' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'eleven' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,50 @@
export const name = 'Ordered List with Nested Bullet List'
export const expectedInput = `
1. one
- inner
2. two
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'one' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'inner' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'two' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,145 @@
export const name = 'Ordered List'
export const expectedInput = `
1. Item 1
2. Item 2
1. Subitem 1
2. Subitem 2
1. Subsubitem 1
2. Subsubitem 2
3. Item 3
1. Subitem 1
2. Subitem 2
1. Subsubitem 1
2. Subsubitem 2
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 2' }],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 2' }],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subsubitem 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subsubitem 2' }],
},
],
},
],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 3' }],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subitem 2' }],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subsubitem 1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subsubitem 2' }],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,30 @@
export const name = 'Soft Break with Marks'
export const expectedInput = `**Speaker:**
John Doe.
**Speaker:**
**John Doe**`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' },
{ type: 'text', text: '\nJohn Doe.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' },
{ type: 'text', text: '\n' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'John Doe' },
],
},
],
}
export const extensions = []

View File

@@ -0,0 +1,113 @@
export const name = 'Task List'
export const expectedInput = `
- [ ] Task 1
- [x] Task 2
- [ ] Task 3
- [ ] Subtask 1
- [x] Subtask 2
- [ ] Subtask 2a
- [x] Subtask 2b
- [x] Task 4
`.trim()
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 1' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 2' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 3' }],
},
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subtask 1' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subtask 2' }],
},
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subtask 2a' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Subtask 2b' }],
},
],
},
],
},
],
},
],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 4' }],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,53 @@
export const name = 'Trailing Whitespace in Marks'
// The expected input is the correct markdown output (no spaces inside marks)
export const expectedInput = `**text** continues here
and **more text** here
also *italic text* works
and **bold** *italic* mixed`.trim()
// The JSON content simulates text with trailing/leading spaces inside marks
// which should be moved outside the mark delimiters in the output
export const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'text' },
{ type: 'text', text: ' continues here' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'and ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'more text' },
{ type: 'text', text: ' here' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'also ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic text' },
{ type: 'text', text: ' works' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'and ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ' ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ' mixed' },
],
},
],
}
export const extensions = []

View File

@@ -0,0 +1,88 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Blank lines after a block token', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
Heading,
Blockquote,
BulletList,
OrderedList,
ListItem,
Table,
TableRow,
TableHeader,
TableCell,
],
})
})
// Heading tokenizer absorbs trailing blank lines into the token's raw,
// so we recover them here to prevent losing a blank line per parse cycle.
it('preserves a blank line between a heading and the following text', () => {
const doc = markdownManager.parse('# Title\n\n\n\nBody')
expect(doc.content).toEqual([
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
])
})
it('does not create an empty paragraph when a heading is directly followed by text', () => {
const doc = markdownManager.parse('# Title\n\nBody')
expect(doc.content).toEqual([
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
])
})
it('round-trips blank lines after a heading without eroding them', () => {
const markdown = '# Title\n\n\n\nBody'
const once = markdownManager.serialize(markdownManager.parse(markdown))
const twice = markdownManager.serialize(markdownManager.parse(once))
expect(twice).toBe(once)
})
it('normalizes an explicit &nbsp; paragraph without eroding the blank line', () => {
const once = markdownManager.serialize(markdownManager.parse('# Title\n\n&nbsp;\n\nBody'))
const twice = markdownManager.serialize(markdownManager.parse(once))
expect(once).toBe('# Title\n\n\n\nBody')
expect(twice).toBe(once)
})
// Tables absorb trailing blank lines the same way.
it('preserves a blank line between a table and the following text', () => {
const doc = markdownManager.parse('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n\n\nAfter')
const kinds = (doc.content ?? []).map(node => node.type)
expect(kinds).toEqual(['table', 'paragraph', 'paragraph'])
expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] })
})
// HTML blocks absorb trailing blank lines the same way.
it('preserves a blank line after a raw HTML block (parsed as paragraph)', () => {
const doc = markdownManager.parse('<div>test</div>\n\n\n\nAfter')
const kinds = (doc.content ?? []).map(node => node.type)
expect(kinds).toEqual(['paragraph', 'paragraph', 'paragraph'])
expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] })
})
})

View File

@@ -0,0 +1,94 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Blockquote Markdown Conversion', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Blockquote, Heading],
})
})
it('should convert a single-level blockquote to markdown', () => {
const markdown = '> This is a blockquote'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert a single-level blockquote with multiple paragraphs to markdown', () => {
const markdown = '> First paragraph\n>\n> Second paragraph'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert nested blockquotes to markdown', () => {
const markdown = '> Level 1\n>\n> > Level 2\n> >\n> > > Level 3'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with varying nesting depths', () => {
const markdown =
'> Level 1\n>\n> > Level 2\n> >\n> > > Level 3\n> >\n> > Back to level 2\n>\n> Back to level 1'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert nested blockquotes with multiple paragraphs', () => {
const markdown =
'> First paragraph\n>\n> Second paragraph\n>\n> > Nested paragraph\n> >\n> > > Deeply nested'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle a blockquote that jumps multiple levels deep', () => {
const markdown = '> Level 1\n>\n> > > Level 3 (jumped from 1 to 3)'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes going back to shallower nesting', () => {
const markdown =
'> Level 1\n>\n> > Level 2\n> >\n> > > Level 3\n> >\n> > Back to level 2\n>\n> Back to level 1'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with text content at different levels', () => {
const markdown =
'> Outer quote with some text\n>\n> > Inner quote with more text\n> >\n> > > Innermost quote'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with mixed content including headings', () => {
const markdown =
'> This is a block quote\n>\n> With more paragraphs\n>\n> # And a heading\n>\n> > And nested\n> >\n> > ## And another heading'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
})

View File

@@ -0,0 +1,831 @@
import type { Extension } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Code } from '@tiptap/extension-code'
import { CodeBlock } from '@tiptap/extension-code-block'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { BulletList, ListItem, OrderedList, TaskItem, TaskList } from '@tiptap/extension-list'
import { Mention } from '@tiptap/extension-mention'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { Youtube } from '@tiptap/extension-youtube'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import * as conversionfiles from '../fixtures/markdown/index.js'
describe('Markdown Conversion Tests', () => {
const extensions = [
Document,
Paragraph,
Text,
Bold,
Italic,
Code,
Link,
Heading,
HardBreak,
CodeBlock,
BulletList,
OrderedList,
ListItem,
TaskList,
TaskItem.configure({
nested: true,
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: undefined,
}),
Youtube.configure({
HTMLAttributes: {
class: 'youtube-video',
},
}),
]
const conversionExtensions: Extension[] = []
Object.values(conversionfiles).forEach((file: any) => {
if (!file?.extensions) {
return
}
conversionExtensions.push(...file.extensions)
})
const markdownManager = new MarkdownManager({
extensions: [...extensions, ...conversionExtensions],
})
describe('convert simple taskList from and to markdown', () => {
const simpleMarkdown = `
- [ ] Task 1
- [x] Task 2
- [ ] Task 3
`.trim()
const simpleJSON = {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 1' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 2' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 3' }],
},
],
},
],
},
],
}
it('should convert simple markdown to expected JSON structure', () => {
const json = markdownManager.parse(simpleMarkdown)
expect(json).toEqual(simpleJSON)
})
it('should convert simple JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(simpleJSON)
expect(md.trim()).toBe(simpleMarkdown.trim())
})
})
describe('convert taskList with leading paragraph from and to markdown', () => {
const markdownWithLeadingParagraph = `Leading paragraph
- [ ] Task`
const expectedJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Leading paragraph' }],
},
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task' }],
},
],
},
],
},
],
}
it('should convert markdown with leading paragraph to expected JSON structure', () => {
const json = markdownManager.parse(markdownWithLeadingParagraph)
expect(json).to.deep.equal(expectedJSON)
})
it('should convert JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(expectedJSON)
expect(md).to.equal(markdownWithLeadingParagraph)
})
})
Object.values(conversionfiles).forEach(file => {
describe(`convert ${file.name} from and to markdown`, () => {
it(`should convert ${file.name} markdown to expected JSON structure`, () => {
const json = markdownManager.parse(file.expectedInput)
const normalizedActual = JSON.parse(JSON.stringify(json))
const normalizedExpected = JSON.parse(JSON.stringify(file.expectedOutput))
expect(normalizedActual).toEqual(normalizedExpected)
})
it(`should convert ${file.name} JSON structure back to expected markdown`, () => {
const md = markdownManager.serialize(file.expectedOutput)
expect(md.trim()).toBe(file.expectedInput.trim())
})
})
})
describe('multiple empty paragraphs', () => {
// The first top-level empty paragraph stays empty markdown spacing.
// Additional consecutive empty paragraphs are preserved with &nbsp; markers.
it('should preserve two empty paragraphs with blank spacing plus one &nbsp; marker when converting to markdown', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\nLine2')
})
it('should parse markdown with one preserved blank paragraph back correctly', () => {
const markdown = 'Line1\n\n&nbsp;\n\nLine2'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(3)
expect(content[0].type).toBe('paragraph')
expect(content[0].content![0].text).toBe('Line1')
expect(content[1].type).toBe('paragraph')
expect(content[1].content).toEqual([])
expect(content[2].type).toBe('paragraph')
expect(content[2].content![0].text).toBe('Line2')
})
it('should reserialize parsed empty paragraphs using blank spacing plus preserved markers', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
const parsed = markdownManager.parse(markdown)
const content = parsed.content!
expect(content).toHaveLength(4)
expect(content[1].content).toEqual([])
expect(content[2].content).toEqual([])
const remarked = markdownManager.serialize(parsed)
expect(remarked).toBe(markdown)
})
it('should roundtrip five consecutive empty paragraphs without losing one', () => {
const json = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Line1' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Line2' }] },
],
}
const markdown = markdownManager.serialize(json)
const parsed = markdownManager.parse(markdown)
const content = parsed.content!
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\n&nbsp;\n\n&nbsp;\n\n&nbsp;\n\nLine2')
expect(content).toHaveLength(7)
expect(
content.slice(1, 6).every(node => node.type === 'paragraph' && node.content?.length === 0),
).toBe(true)
})
it('should handle empty paragraphs without content field (real editor output)', () => {
// Real editor output may omit the content field for empty paragraphs
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
// No content field - this is what the real editor might output
},
{
type: 'paragraph',
// No content field
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\nLine2')
})
it('should parse literal NBSP character (\\u00A0) as empty paragraph', () => {
// Some markdown parsers may convert &nbsp; entity to the literal NBSP character
const markdown = 'Line1\n\n\u00A0\n\nLine2'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(3)
expect(content[0].type).toBe('paragraph')
expect(content[0].content![0].text).toBe('Line1')
expect(content[1].type).toBe('paragraph')
expect(content[1].content).toEqual([])
expect(content[2].type).toBe('paragraph')
expect(content[2].content![0].text).toBe('Line2')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('Line1\n\n\n\nLine2')
})
})
describe('tilde fenced code blocks', () => {
const codeBlockJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'code block' }],
},
],
}
const codeBlockWithLangJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [{ type: 'text', text: 'console.log("hello")' }],
},
],
}
it('should parse tilde fenced code blocks', () => {
const markdown = '~~~\ncode block\n~~~'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockJSON)
})
it('should parse tilde fenced code blocks with language', () => {
const markdown = '~~~js\nconsole.log("hello")\n~~~'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockWithLangJSON)
})
it('should parse backtick fenced code blocks', () => {
const markdown = '```\ncode block\n```'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockJSON)
})
it('should parse backtick fenced code blocks with language', () => {
const markdown = '```js\nconsole.log("hello")\n```'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockWithLangJSON)
})
it('should produce the same result for tilde and backtick fenced code blocks', () => {
const tildeMarkdown = '~~~\ncode block\n~~~'
const backtickMarkdown = '```\ncode block\n```'
const tildeJSON = markdownManager.parse(tildeMarkdown)
const backtickJSON = markdownManager.parse(backtickMarkdown)
expect(tildeJSON).toEqual(backtickJSON)
})
})
describe('HTML character escaping', () => {
it('should decode &lt; and &gt; entities to literal < and > when parsing', () => {
const markdown = 'foo &lt;bar&gt; baz'
const json = markdownManager.parse(markdown)
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toHaveLength(1)
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
})
it('should decode &amp; entity to literal & when parsing', () => {
const markdown = 'foo &amp; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo & bar')
})
it('should encode < and > back to entities when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo <bar> baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo &lt;bar&gt; baz')
})
it('should encode & back to &amp; when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo & bar' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo &amp; bar')
})
it('should roundtrip &lt;bar&gt; correctly', () => {
const markdown = 'foo &lt;bar&gt; baz'
const json = markdownManager.parse(markdown)
// Editor should show literal <bar>
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
// Serialize back should produce the entity form
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &lt;bar&gt; baz')
})
it('should roundtrip &amp; correctly', () => {
const markdown = 'foo &amp; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo & bar')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &amp; bar')
})
it('should decode &quot; entity to literal " when parsing', () => {
const markdown = 'foo &quot;bar&quot; baz'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
})
it('should not encode " when serializing (quotes are valid markdown)', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo "bar" baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo "bar" baz')
})
it('should decode &quot; when parsing but serialize as literal "', () => {
const markdown = 'foo &quot;bar&quot; baz'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo "bar" baz')
})
it('should not encode entities inside code blocks', () => {
const json = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'foo <bar> & baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('```\nfoo <bar> & baz\n```')
})
it('should not encode entities inside inline code marks', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '<tag>',
marks: [{ type: 'code' }],
},
],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('`<tag>`')
})
it('should handle doubly-encoded entities correctly', () => {
// &amp;lt; should decode to &lt; (not to <)
const markdown = 'foo &amp;lt; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo &lt; bar')
// Serializing should re-encode the & in &lt;
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &amp;lt; bar')
})
it('should preserve &nbsp; empty paragraph behavior', () => {
const markdown = 'Line1\n\n&nbsp;\n\nLine2'
const json = markdownManager.parse(markdown)
// Empty paragraph check should still work
expect(json.content).toHaveLength(3)
expect(json.content[1].type).toBe('paragraph')
expect(json.content[1].content).toEqual([])
// A single empty paragraph between content paragraphs uses blank-line
// spacing (the first empty paragraph doesn't need an &nbsp; marker).
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('Line1\n\n\n\nLine2')
})
it('should roundtrip literal &amp;nbsp; without it being treated as an empty paragraph marker', () => {
// A user writing &amp;nbsp; in markdown intends for the text "&nbsp;" to display.
// decodeHtmlEntities decodes &amp; → &, producing text content "&nbsp;".
// On serialization, encodeHtmlEntities re-encodes & → &amp;, restoring &amp;nbsp;.
// The intermediate "&nbsp;" text must NOT be confused with the empty-paragraph marker.
const markdown = 'before &amp;nbsp; after'
const json = markdownManager.parse(markdown)
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content[0].text).toBe('before &nbsp; after')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('before &amp;nbsp; after')
})
it('should preserve a standalone literal &amp;nbsp; paragraph as text', () => {
const markdown = '&amp;nbsp;'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(1)
expect(content[0].type).toBe('paragraph')
expect(content[0].content).toEqual([{ type: 'text', text: '&nbsp;' }])
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('&amp;nbsp;')
})
})
describe('markdown escape character handling', () => {
describe('parsing: escape tokens in markdown → text nodes', () => {
it('should parse a backslash-escaped asterisk as a literal asterisk', () => {
const json = markdownManager.parse('\\*text\\*')
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toHaveLength(1)
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '*text*' })
})
it('should parse a backslash-escaped underscore as a literal underscore', () => {
const json = markdownManager.parse('\\_text\\_')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '_text_' })
})
it('should parse a backslash-escaped backtick as a literal backtick', () => {
const json = markdownManager.parse('\\`')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '`' })
})
it('should parse a double-backslash as a literal backslash', () => {
const json = markdownManager.parse('\\\\')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '\\' })
})
it('should parse escaped double-asterisks as literal double-asterisks', () => {
const json = markdownManager.parse('\\*\\*bold\\*\\*')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '**bold**' })
})
it('should parse a backslash-escaped tilde as a literal tilde', () => {
const json = markdownManager.parse('\\~text\\~')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '~text~' })
})
it('should parse escaped brackets as literal brackets', () => {
const json = markdownManager.parse('\\[link\\]')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '[link]' })
})
})
describe('parsing: mixed escape tokens and real formatting', () => {
it('should preserve real italic marks alongside escaped asterisks', () => {
const json = markdownManager.parse('\\*escaped\\* and *real*')
expect(json.content).toHaveLength(1)
const content = json.content[0].content!
expect(content).toHaveLength(2)
// The unmarked escaped text gets merged into a single node
expect(content[0].type).toBe('text')
expect(content[0].text).toBe('*escaped* and ')
// The real italic is still separate
expect(content[1]).toEqual({
type: 'text',
text: 'real',
marks: [{ type: 'italic' }],
})
})
})
describe('serializing: text with special chars → backslash-escaped markdown', () => {
it('should escape asterisks in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '*literal*' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\*literal\\*')
})
it('should escape underscores in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '_underscore_' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\_underscore\\_')
})
it('should escape backticks in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '`code`' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\`code\\`')
})
it('should escape backslashes in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '\\' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\\\')
})
it('should escape brackets in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '[bracket]' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\[bracket\\]')
})
it('should escape tildes in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '~strike~' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\~strike\\~')
})
})
describe('round-trip: parse → serialize → parse stability', () => {
it('should round-trip a simple escaped asterisk string', () => {
const input = '\\*hello\\*'
const json = markdownManager.parse(input)
const md = markdownManager.serialize(json)
const json2 = markdownManager.parse(md)
expect(json2.content[0].content[0].text).toBe('*hello*')
})
it('should round-trip JSON with literal asterisk through serialize → parse', () => {
const input = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '*literal*' }],
},
],
}
const md = markdownManager.serialize(input)
const parsed = markdownManager.parse(md)
expect(parsed.content[0].content[0].text).toBe('*literal*')
})
it('should round-trip a double-backslash', () => {
const input = '\\\\\\\\'
const json = markdownManager.parse(input)
const md = markdownManager.serialize(json)
const json2 = markdownManager.parse(md)
expect(json2.content[0].content[0].text).toBe('\\\\')
})
})
describe('edge cases: code blocks and nested syntax', () => {
it('should NOT backslash-escape inside inline code marks (parsing)', () => {
const json = markdownManager.parse('`\\*not italic\\*`')
expect(json.content[0].content[0]).toEqual({
type: 'text',
text: '\\*not italic\\*',
marks: [{ type: 'code' }],
})
})
it('should NOT backslash-escape inside code blocks when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: '*not bold*' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('```\n*not bold*\n```')
})
it('should NOT backslash-escape inside inline code marks when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '*not bold*',
marks: [{ type: 'code' }],
},
],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('`*not bold*`')
})
})
})
})

View File

@@ -0,0 +1,88 @@
import { Editor } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { afterEach, describe, expect, it } from 'vite-plus/test'
describe('empty markdown content', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('should create a valid document when initial content is an empty string', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
const json = editor.getJSON()
expect(json.type).toBe('doc')
expect(json.content).toBeDefined()
expect(json.content).toHaveLength(1)
expect(json.content![0].type).toBe('paragraph')
})
it('should allow inserting text after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContent('Hello')
const json = editor.getJSON()
expect((json.content![0].content![0] as any).text).toBe('Hello')
})
it('should allow inserting text with a paragraph command after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContentAt(0, { type: 'text', text: 'Hello' })
const json = editor.getJSON()
expect((json.content![0].content![0] as any).text).toBe('Hello')
})
it('should allow inserting markdown-formatted content after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContent('**bold**', { contentType: 'markdown' })
const json = editor.getJSON()
const textNode = json.content![0].content![0] as any
expect(textNode.type).toBe('text')
expect(textNode.text).toBe('bold')
expect(textNode.marks?.[0]?.type).toBe('bold')
})
it('should allow inserting markdown-formatted content at a specific position after empty initialization', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContentAt(0, '**bold**', { contentType: 'markdown' })
const json = editor.getJSON()
const textNode = json.content![0].content![0] as any
expect(textNode.type).toBe('text')
expect(textNode.text).toBe('bold')
expect(textNode.marks?.[0]?.type).toBe('bold')
})
})

View File

@@ -0,0 +1,329 @@
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Inline marks containing only punctuation', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic]
const markdownManager = new MarkdownManager({ extensions })
describe('Bold marks with punctuation - whitespace-separated (CommonMark compliant)', () => {
it('should parse standalone bold punctuation marks', () => {
const markdown = '**)**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
it('should parse bold punctuation when preceded by whitespace', () => {
const markdown = 'text **)**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
it('should parse bold punctuation when followed by whitespace', () => {
const markdown = '**)** text'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' text',
},
],
},
],
})
})
it('should parse bold punctuation when surrounded by whitespace', () => {
const markdown = 'text **)** more'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' more',
},
],
},
],
})
})
it('should parse bold punctuation when preceded by other punctuation', () => {
const markdown = '[**)**]'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '[',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ']',
},
],
},
],
})
})
it('should parse multiple bold punctuation marks', () => {
const markdown = '**,** **.**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ',',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
text: '.',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
})
describe('Bold marks without required whitespace (CommonMark limitation)', () => {
it('should NOT parse bold punctuation when directly preceded by alphanumeric characters', () => {
const markdown = 'text**)**'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text**)**',
},
],
},
],
})
})
it('should NOT parse bold punctuation when directly followed by alphanumeric characters', () => {
const markdown = '**)**text'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '**)**text',
},
],
},
],
})
})
it('should NOT parse bold punctuation when surrounded by alphanumeric characters', () => {
const markdown = 'text**)**text'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text**)**text',
},
],
},
],
})
})
})
describe('Italic marks with punctuation', () => {
it('should parse standalone italic punctuation', () => {
const markdown = '*)*'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'italic' }],
},
],
},
],
})
})
it('should parse italic punctuation with whitespace', () => {
const markdown = 'text *,* more'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ',',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: ' more',
},
],
},
],
})
})
it('should NOT parse italic punctuation without whitespace', () => {
const markdown = 'text*,*text'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text*,*text',
},
],
},
],
})
})
})
describe('Roundtrip serialization', () => {
it('should maintain bold punctuation through parse-serialize cycle', () => {
const markdown = 'text **)** more'
const json = markdownManager.parse(markdown)
const serialized = markdownManager.serialize(json)
expect(serialized).toBe(markdown)
})
it('should maintain italic punctuation through parse-serialize cycle', () => {
const markdown = 'text *,* more'
const json = markdownManager.parse(markdown)
const serialized = markdownManager.serialize(json)
expect(serialized).toBe(markdown)
})
})
})

View File

@@ -0,0 +1,52 @@
import { Document } from '@tiptap/extension-document'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
const manager = (indentation?: { style?: 'space' | 'tab'; size?: number }) =>
new MarkdownManager({
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
...(indentation ? { indentation } : {}),
} as any)
const roundTrip = (mm: MarkdownManager, markdown: string) => mm.serialize(mm.parse(markdown) as any)
const ordered = ['1. one', ' 1. inner', '2. two'].join('\n')
const orderedWide = ['10. ten', ' 1. inner', '11. eleven'].join('\n')
const bullet = ['- one', ' - inner', '- two'].join('\n')
describe('list indentation on serialize', () => {
it('indents a nested ordered list to the marker width', () => {
expect(roundTrip(manager(), ordered)).toBe(ordered)
})
it('widens the indent for a marker wider than the indent size', () => {
expect(roundTrip(manager({ style: 'space', size: 2 }), orderedWide)).toBe(orderedWide)
})
it('keeps a configured indent that is already past the marker', () => {
expect(roundTrip(manager({ style: 'space', size: 4 }), ordered)).toBe(
['1. one', ' 1. inner', '2. two'].join('\n'),
)
})
it('keeps tab indentation on an ordered list, since a tab clears the marker', () => {
expect(roundTrip(manager({ style: 'tab', size: 1 }), orderedWide)).toBe(
['10. ten', '\t1. inner', '11. eleven'].join('\n'),
)
})
it('leaves bullet lists on the configured indent size', () => {
expect(roundTrip(manager({ style: 'space', size: 4 }), bullet)).toBe(
['- one', ' - inner', '- two'].join('\n'),
)
})
it('leaves bullet lists on tab indentation', () => {
expect(roundTrip(manager({ style: 'tab', size: 1 }), bullet)).toBe(
['- one', '\t- inner', '- two'].join('\n'),
)
})
})

View File

@@ -0,0 +1,127 @@
/**
* @vitest-environment happy-dom
*/
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('MarkdownManager Mixed Markdown + HTML', () => {
let manager: MarkdownManager
const basicExtensions = [Document, Paragraph, Text, Heading, Italic, HardBreak]
beforeEach(() => {
manager = new MarkdownManager({ extensions: basicExtensions })
})
it('parses heading with inline HTML <em> as italic', () => {
const md = '## hello <em>world</em>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
const heading = doc.content![0]
expect(heading.type).toBe('heading')
// Find the text node that contains 'world'
const textNodes = heading.content!.flatMap((n: any) =>
n.type === 'text' ? [n] : n.content || [],
)
const worldNode = textNodes.find((n: any) => n.text && n.text.includes('world'))
// Use a function-call assertion to avoid the "no-unused-expressions" lint error
expect(worldNode).not.toBe(undefined)
// The italic mark should be present
expect(Array.isArray(worldNode!.marks)).toBe(true)
const hasItalic = worldNode!.marks.some((m: any) => m.type === 'italic')
expect(hasItalic).toBe(true)
})
it('parses standalone inline HTML <em>world</em> as italic', () => {
const md = '<em>world</em>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
// Inline HTML typically produces a paragraph wrapper
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
const textNode = paragraph.content![0]
expect(textNode.text).toBe('world')
expect(Array.isArray(textNode.marks)).toBe(true)
const hasItalic = (textNode.marks || []).some((m: any) => m.type === 'italic')
expect(hasItalic).toBe(true)
})
it('parses markdown italic next to HTML italic correctly', () => {
const md = '*a* <em>b</em> *c*'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
const para = doc.content![0]
expect(para.type).toBe('paragraph')
// Collect texts and their mark states
const runs = para.content!.map((n: any) => ({ text: n.text, marks: n.marks || [] }))
// Expect there to be runs containing a, b, c
const texts = runs.map(r => (r.text || '').trim())
expect(texts).toContain('a')
expect(texts).toContain('b')
expect(texts).toContain('c')
;['a', 'b', 'c'].forEach(letter => {
const node = runs.find(r => (r.text || '').trim() === letter)
expect(node).not.toBe(undefined)
const hasItalic = ((node as any).marks || []).some((m: any) => m.type === 'italic')
expect(hasItalic).toBe(true)
})
})
it('parses an unclosed inline HTML tag without nesting a paragraph', () => {
const md = '<b>123'
const doc = manager.parse(md)
expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: '123' }] }])
})
it('parses an unclosed inline HTML tag and keeps the surrounding text', () => {
const md = 'a <b> b'
const doc = manager.parse(md)
// The dropped tag leaves both of the spaces around it.
expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: 'a b' }] }])
})
it('parses an empty inline HTML element without nesting a paragraph', () => {
const md = 'a <span></span> b'
const doc = manager.parse(md)
expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: 'a b' }] }])
})
it('parses inline HTML for a block element as its inline content', () => {
const md = 'a <h1>title</h1> b'
const doc = manager.parse(md)
expect(doc.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'a title b' }] },
])
})
it('parses inline HTML for an inline node and keeps the node', () => {
const md = 'a <br> b'
const doc = manager.parse(md)
expect(doc.content).toEqual([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'a ' },
{ type: 'hardBreak' },
{ type: 'text', text: ' b' },
],
},
])
})
})

View File

@@ -0,0 +1,73 @@
import { Mark } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
// Adjacent text nodes sharing a mark type but differing in attributes must each
// render their own mark, instead of collapsing into one continuous run.
const TextStyle = Mark.create({
name: 'textStyle',
addAttributes() {
return {
color: { default: null },
}
},
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node)
const color = node.attrs?.color
if (!color) {
return content
}
return `<span style="color: ${color}">${content}</span>`
},
})
describe('serializing adjacent same-type marks with different attributes', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TextStyle],
})
it('renders each distinct textStyle attribute set instead of collapsing them', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Test ',
marks: [{ type: 'textStyle', attrs: { color: '#9bbb59' } }],
},
{
type: 'text',
text: 'nested',
marks: [{ type: 'textStyle', attrs: { color: '#ff0000' } }],
},
{
type: 'text',
text: ' style',
marks: [{ type: 'textStyle', attrs: { color: '#9bbb59' } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// renderMarkdown must run once per distinct segment: three spans total,
// with the middle color present (proving the nested run rendered on its own).
const spanCount = (result.match(/<span /g) || []).length
expect(spanCount).toBe(3)
expect(result).toContain('color: #ff0000')
expect(result).toContain('Test')
expect(result).toContain('nested')
expect(result).toContain('style')
})
})

View File

@@ -0,0 +1,610 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { CodeBlock } from '@tiptap/extension-code-block'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { BlockMath } from '@tiptap/extension-mathematics'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Ordered list lazy continuation parsing', () => {
const extensions = [Document, Paragraph, Text, HardBreak, BulletList, OrderedList, ListItem]
it('keeps non-indented lazy continuation lines inside the current list item when breaks are enabled', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = [
'1. First item',
'Continuation for the first item.',
'',
'2. Second item',
'Continuation for the second item.',
].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'First item' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Continuation for the first item.' },
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Second item' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Continuation for the second item.' },
],
},
],
},
],
},
],
})
})
it('keeps nested bullet lists as block content inside ordered list items', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', ' - inner', '2. two'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'one' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'inner' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'two' }],
},
],
},
],
},
],
})
})
it('stops lazy continuation at a heading line', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Bold,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. Plain text search', '### **Plain text search**'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Plain text search' }],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'Plain text search', marks: [{ type: 'bold' }] }],
},
],
})
})
it('stops lazy continuation at heading lines of any level, for any item', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. one', '2. two', '###### six'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }],
},
],
},
{
type: 'heading',
attrs: { level: 6 },
content: [{ type: 'text', text: 'six' }],
},
],
})
})
it('keeps hashtag-like lines that are not headings as lazy continuation', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
markedOptions: {
breaks: true,
},
})
// "#hashtag" (no space) and "####### seven" (7 hashes) are not headings
// per CommonMark, so they must remain lazy continuation text.
const markdown = ['1. one', '#hashtag', '####### seven'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '#hashtag' },
{ type: 'hardBreak' },
{ type: 'text', text: '####### seven' },
],
},
],
},
],
},
],
})
})
it('parses indented heading lines as block content inside the list item', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. one', ' ### inner heading'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'inner heading' }],
},
],
},
],
},
],
})
})
const blockExtensions = [
Document,
Paragraph,
Text,
HardBreak,
Heading,
HorizontalRule,
CodeBlock,
Blockquote,
BulletList,
OrderedList,
ListItem,
BlockMath,
]
const listWithSingleItem = (text: string) => ({
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
},
],
})
it.each(['---', '***', '___', '* * *', '- - -', '_ _ _'])(
'stops lazy continuation at a "%s" thematic break',
thematicBreak => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', thematicBreak, 'Outside paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{ type: 'horizontalRule' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Outside paragraph' }] },
],
})
},
)
it.each(['```', '~~~'])('stops lazy continuation at a "%s" code fence', fence => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', fence, 'const x = 1', fence].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'const x = 1' }],
},
],
})
})
it('stops lazy continuation at a "$$" block math fence', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '$$', 'x = 1', '$$'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [listWithSingleItem('one'), { type: 'blockMath', attrs: { latex: 'x = 1' } }],
})
})
it('parses an indented "$$" block math fence as block content inside the list item', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', ' $$', ' x = 1', ' $$'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'blockMath', attrs: { latex: 'x = 1' } },
],
},
],
},
],
})
})
it('stops lazy continuation at an unindented bullet list marker', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '- bullet'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'bullet' }] }],
},
],
},
],
})
})
it('parses indented non-dash thematic breaks as block content inside the list item', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', ' ***'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'horizontalRule' },
],
},
],
},
],
})
})
it('keeps an indented "---" directly below item text as paragraph text (setext ambiguity)', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', ' ---'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '---' },
],
},
],
},
],
},
],
})
})
it('keeps dash-prefixed text that is not a thematic break as lazy continuation', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', '---foo'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '---foo' },
],
},
],
},
],
},
],
})
})
it('keeps a setext-style "===" line as lazy continuation text', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', '==='].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '===' },
],
},
],
},
],
},
],
})
})
it('keeps unindented blockquote lines inside the list item (matches marked 17.0.1)', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '> quoted'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'quoted' }] }],
},
],
},
],
},
],
})
})
it('stops lazy continuation after a blank line', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', 'lazy continuation', '', 'Outside paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: 'lazy continuation' },
],
},
],
},
],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Outside paragraph' }],
},
],
})
})
})

View File

@@ -0,0 +1,667 @@
import { Editor } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
/**
* Normalize marks order for deterministic comparison. Marks in the same position
* can be stored in any order by ProseMirror; sorting by type ensures
* deep-equality assertions don't flake on array ordering.
*/
const normalizeMarks = (node: any): any => {
if (Array.isArray(node)) {
return node.map(normalizeMarks)
}
if (!node || typeof node !== 'object') {
return node
}
return {
...node,
marks: node.marks
? [...node.marks].sort((a: any, b: any) => a.type.localeCompare(b.type))
: node.marks,
content: node.content ? node.content.map(normalizeMarks) : node.content,
}
}
describe('Overlapping marks serialization', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic, Link]
const markdownManager = new MarkdownManager({ extensions })
/**
* Regression test for the original bug report.
*
* Steps to reproduce:
* 1. Toggle bold on
* 2. Type "123"
* 3. Toggle italic on
* 4. Type "456"
* 5. Toggle bold off
* 6. Type "789"
* 7. Toggle italic off
*
* Expected: **123*456***<em>789</em>
* Actual (buggy): **123*456**789*
*/
it('should correctly serialize overlapping bold and italic marks', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '123',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: '456',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
{
type: 'text',
text: '789',
marks: [{ type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// The output **123*456**789* is invalid markdown because bold closes
// while italic is still open, breaking the nesting order.
// The valid markdown closes the nested italic inside bold first, then
// reopens the remaining italic segment with inline HTML to avoid ambiguous `***` parsing.
expect(result).toBe('**123*456***<em>789</em>')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Mirror of the original bug: italic starts first, bold is added, then italic ends.
*
* Steps: italic on → "abc" → bold on → "def" → italic off → "ghi" → bold off
*/
it('should correctly serialize italic starting before bold', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'abc',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: 'def',
marks: [{ type: 'italic' }, { type: 'bold' }],
},
{
type: 'text',
text: 'ghi',
marks: [{ type: 'bold' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// italic opens first with *, bold opens inside it, then the remaining bold span
// reopens with inline HTML after italic closes so the markdown remains unambiguous.
expect(result).toBe('*abc**def***<strong>ghi</strong>')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Bold and italic both start and end together on the same node (no overlap).
*/
it('should correctly serialize bold and italic on the same text', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Both marks open and close on the same node — ***hello*** is valid CommonMark for bold+italic
expect(result).toBe('***hello***')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
// Mark order matches what a real editor emits: Link has priority 1000, so
// ProseMirror assigns it the lowest rank and stores it first in the marks
// array. The serializer must still place link as the outermost wrapper.
it('serializes italic inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'italic' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google*](https://google.com)')
expect(result).not.toBe('*[google](https://google.com)*')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'bold' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google**](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('keeps html-reopened italic open across later text nodes before closing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('**123*456***<em>789xyz</em> plain')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(
normalizeMarks({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}),
)
})
it('closes html-reopened italic correctly before a hard break', () => {
const markdownManagerWithBreak = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, HardBreak],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'hardBreak' },
{ type: 'text', text: 'after' },
],
},
],
}
const result = markdownManagerWithBreak.serialize(json)
expect(result).toBe('**123*456***<em>789</em> \nafter')
expect(normalizeMarks(markdownManagerWithBreak.parse(result))).toEqual(normalizeMarks(json))
})
it('does not switch non-bold-italic marks to html reopen mode', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'def', marks: [{ type: 'italic' }, { type: 'strike' }] },
{ type: 'text', text: 'ghi', marks: [{ type: 'strike' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('*abc~~def~~*~~ghi~~')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes italic on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'bold' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google** search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes partial italic in link text correctly from editor commands', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Italic, Link, Markdown],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'google search' }],
},
],
},
})
editor.commands.selectAll()
editor.commands.setLink({ href: 'https://google.com' })
editor.commands.setTextSelection({ from: 1, to: 7 })
editor.commands.setItalic()
const json = editor.getJSON()
const result = editor.getMarkdown()
const directResult = markdownManager.serialize(json)
const expectedRoundtripJson = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(directResult).toBe(result)
expect(normalizeMarks(editor.markdown?.parse(result))).toEqual(
normalizeMarks(expectedRoundtripJson),
)
editor.destroy()
})
/**
* Regression test for issue #7728.
* When bold, italic, and strike all close on the same node they must close
* LIFO (last-opened first-closed) to produce valid nesting.
*/
it('closes multiple simultaneously-ending marks in LIFO order (simple path)', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'bold ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'italics ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{
type: 'text',
text: 'strike',
marks: [{ type: 'bold' }, { type: 'italic' }, { type: 'strike' }],
},
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('**bold *italics ~~strike~~***')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Regression test for the crossed-boundary LIFO bug.
* Bold (outer) and strike (inner) are both active; italic opens while both
* close at the same boundary. Previously-active marks must still close in
* LIFO order (strike first, bold last) even though a new mark is opening.
*
* Buggy output: **~~abc*def***~~*ghi* (bold closes before strike — invalid)
* Correct output: **~~abc*def*~~***ghi* (strike closes before bold — valid)
*/
it('closes previously-active marks in LIFO order on a crossed boundary', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'bold' }, { type: 'strike' }] },
{
type: 'text',
text: 'def',
marks: [{ type: 'bold' }, { type: 'strike' }, { type: 'italic' }],
},
{ type: 'text', text: 'ghi', marks: [{ type: 'italic' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
// Strike must close before bold (LIFO). Bold then closes, and italic for
// "ghi" reopens with <em> to avoid the ambiguous *** sequence.
expect(result).toBe('**~~abc*def*~~**<em>ghi</em>')
// Buggy output (bold closes before strike): '**~~abc*def***~~<em>ghi</em>'
expect(result).not.toBe('**~~abc*def***~~<em>ghi</em>')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
})
/**
* Regression tests for issue #7682.
*
* Adjacent marks of the same type (e.g. links) with different attributes must
* be serialized as separate marks instead of being merged into one.
*/
describe('adjacent marks with different attributes', () => {
const extensions = [Document, Paragraph, Text, Link]
const markdownManager = new MarkdownManager({ extensions })
it('should serialize adjacent links with different href values as separate links', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'example',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: 'github',
marks: [{ type: 'link', attrs: { href: 'https://github.com', title: null } }],
},
{
type: 'text',
text: 'tiptap',
marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Each adjacent link must be rendered with its own URL — not merged into one
expect(result).toBe(
'[example](https://example.com)[github](https://github.com)[tiptap](https://tiptap.dev)',
)
// Verify round-trip fidelity
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('should keep links with the same href and attributes merged', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'the same',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: ' link',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Links with the same href and attributes are merged into one
expect(result).toBe('[the same link](https://example.com)')
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
it('should handle adjacent links with titles alongside links without titles', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'with title',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: 'Example' } }],
},
{
type: 'text',
text: ' no title',
marks: [{ type: 'link', attrs: { href: 'https://example.org', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Note: the leading space in " no title" is extracted outside the link
// brackets by the serializer's whitespace handling, resulting in a space
// between the two link markdown fragments.
expect(result).toBe(
'[with title](https://example.com "Example") [no title](https://example.org)',
)
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
})

View File

@@ -0,0 +1,267 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Paragraph Markdown Rendering', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
Heading,
Blockquote,
BulletList,
OrderedList,
ListItem,
],
})
})
describe('empty paragraphs at doc level', () => {
it('should omit a lone empty paragraph at doc root', () => {
const doc = {
type: 'doc',
content: [{ type: 'paragraph', content: [] }],
}
const raw = markdownManager.renderNodes(doc)
expect(raw).toBe('')
})
it('should preserve a single empty paragraph between two content paragraphs as blank markdown lines', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('First\n\n\n\nSecond')
})
it('should use &nbsp; to preserve additional blank paragraphs between two content paragraphs', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('First\n\n\n\n&nbsp;\n\nSecond')
})
it('should keep a preserved blank paragraph marker after the first blank paragraph', () => {
const input = 'First\n\n&nbsp;\n\nSecond'
const json = markdownManager.parse(input)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe('First\n\n\n\nSecond')
})
it('should preserve a trailing empty paragraph as trailing markdown spacing', () => {
const doc = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Markdown Test' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Click "Parse Markdown" to load content from the left panel.' },
],
},
{ type: 'paragraph', content: [] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe(
'# Markdown Test\n\nClick "Parse Markdown" to load content from the left panel.\n\n',
)
})
})
describe('empty paragraphs inside nested nodes', () => {
it('should not emit &nbsp; for an empty paragraph inside a bullet list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('- ')
})
it('should not emit &nbsp; for an empty paragraph inside an ordered list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('1. ')
})
it('should keep the first empty paragraph inside a blockquote empty', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [] }],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('>')
})
it('should preserve multiple empty paragraphs inside a blockquote with &nbsp; after the first', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toContain('> First')
expect(markdown).toContain('> Second')
expect(markdown).toContain('> &nbsp;')
})
it('should roundtrip multiple empty paragraphs inside a blockquote without losing any', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
}
const markdown = markdownManager.serialize(doc)
const parsed = markdownManager.parse(markdown)
const blockquote = parsed.content![0]
expect(markdown).toBe('> First\n>\n>\n>\n> &nbsp;\n>\n> &nbsp;\n>\n> Second')
expect(blockquote.type).toBe('blockquote')
expect(blockquote.content).toHaveLength(5)
expect(
blockquote
.content!.slice(1, 4)
.every(node => node.type === 'paragraph' && node.content?.length === 0),
).toBe(true)
})
it('should render content paragraphs inside bullet list items normally', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'World' }] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('- Hello\n- World')
})
it('should roundtrip consecutive empty paragraphs inside a bullet list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
const parsed = markdownManager.parse(markdown)
const listItem = parsed.content![0].content![0]
expect(markdown).toBe('- First\n\n \n\n &nbsp;\n\n Second')
expect(listItem.type).toBe('listItem')
expect(listItem.content).toHaveLength(4)
expect(listItem.content![1].content).toEqual([])
expect(listItem.content![2].content).toEqual([])
})
})
})

View File

@@ -0,0 +1,212 @@
/**
* @vitest-environment node
*/
import { Code } from '@tiptap/extension-code'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('MarkdownManager Server-side Parsing', () => {
let manager: MarkdownManager
const basicExtensions = [Document, Paragraph, Text, Strike, Code, Heading]
beforeEach(() => {
manager = new MarkdownManager({ extensions: basicExtensions })
})
it('parses inline code with HTML-like tags and strike marks without error', () => {
const md = '`<h1>`~`<h6>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// Find text nodes with code marks (inline code)
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThan(0)
// At least one code segment should contain an HTML-like tag
const hasHtmlTag = codeTextNodes.some(
(n: any) => n.text && (n.text.includes('<h1>') || n.text.includes('<h6>')),
)
expect(hasHtmlTag).toBe(true)
// All text content combined should include both HTML tags and the tilde
const allTextNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const allText = allTextNodes.map((n: any) => n.text).join('')
expect(allText).toContain('h1')
expect(allText).toContain('h6')
expect(allText).toMatch(/~/)
})
it('parses inline code with HTML-like tags and double strike marks without error', () => {
const md = '`<h1>`~~`<h6>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
// Find text nodes with code marks
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThan(0)
// At least one code segment should contain an HTML-like tag
const hasHtmlTag = codeTextNodes.some(
(n: any) => n.text && (n.text.includes('<h1>') || n.text.includes('<h6>')),
)
expect(hasHtmlTag).toBe(true)
// All text should contain both HTML tags
const allTextNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const allText = allTextNodes.map((n: any) => n.text).join('')
expect(allText).toContain('h1')
expect(allText).toContain('h6')
})
it('parses strike marks in normal text', () => {
const md = 'a~b'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
})
it('parses HTML heading tags with strike marks', () => {
const md = '<h1>~~test~~</h1>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// In Node.js environment, HTML tags should be treated as literal text
const allText = paragraph
.content!.map((n: any) =>
n.text ? n.text : n.content?.map((c: any) => c.text).join('') || '',
)
.join('')
// Should contain the HTML tags as literal text
expect(allText).toContain('<h1>')
expect(allText).toContain('</h1>')
// Should contain 'test' text
expect(allText).toContain('test')
})
it('parses multiple inline code segments with HTML-like content and strike', () => {
const md = 'text `<h1>` more ~~text~~ and `<h2>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
// Find text nodes with code marks (inline code)
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThanOrEqual(2)
// Code text should contain literal HTML-like tags
expect(codeTextNodes[0].text).toBe('<h1>')
expect(codeTextNodes[1].text).toBe('<h2>')
// Find text nodes with strike marks
const textNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const strikeTextNode = textNodes.find(
(n: any) =>
n.text && n.text.includes('text') && n.marks?.some((m: any) => m.type === 'strike'),
)
expect(strikeTextNode).toBeDefined()
})
it('preserves unknown angle-bracket placeholders as literal text in server environment', () => {
const md = '<enter existing CID here if available>\n\nother text after'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
expect(doc.content).toHaveLength(2)
// angle-bracket placeholder preserved as literal text
const firstPara = doc.content![0]
expect(firstPara.type).toBe('paragraph')
expect(firstPara.content).toHaveLength(1)
expect(firstPara.content![0].type).toBe('text')
expect(firstPara.content![0].text).toBe('<enter existing CID here if available>')
// trailing text after the blank line
const secondPara = doc.content![1]
expect(secondPara.type).toBe('paragraph')
expect(secondPara.content).toHaveLength(1)
expect(secondPara.content![0].type).toBe('text')
expect(secondPara.content![0].text).toBe('other text after')
})
it('parses HTML tags as plain text in server environment', () => {
const md = '<p>Hello</p>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
expect(Array.isArray(doc.content)).toBe(true)
// The document should have a paragraph
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// The HTML should be preserved as literal text (not parsed)
const textContent = paragraph.content!.map((n: any) => n.text).join('')
expect(textContent).toContain('<p>Hello</p>')
})
it('still parses markdown syntax correctly alongside HTML', () => {
const md = '<div>test</div> ~~world~~'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
// Find all text nodes
const textNodes = doc.content!.flatMap((node: any) =>
node.content ? node.content.filter((n: any) => n.type === 'text') : [],
)
// Should have HTML as literal text
const hasHtmlText = textNodes.some((n: any) => n.text && n.text.includes('<div>test</div>'))
expect(hasHtmlText).toBe(true)
// Should have 'world' text (with or without strike mark depending on parsing)
const hasWorldText = textNodes.some((n: any) => n.text && n.text.includes('world'))
expect(hasWorldText).toBe(true)
})
})

View File

@@ -0,0 +1,182 @@
import { Document } from '@tiptap/extension-document'
import { BulletList, ListItem, TaskItem, TaskList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
const paragraph = (text: string) => ({
type: 'paragraph',
content: [{ type: 'text', text }],
})
const listItem = (text: string) => ({
type: 'listItem',
content: [paragraph(text)],
})
const taskItem = (text: string, ...nested: object[]) => ({
type: 'taskItem',
attrs: { checked: false },
content: [paragraph(text), ...nested],
})
describe('Task list with non-checkbox siblings in nested lists', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
BulletList,
ListItem,
TaskList,
TaskItem.configure({ nested: true }),
],
})
it('keeps a plain bullet that follows a checkbox item in a nested list', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain child'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('checkbox child')] },
{ type: 'bulletList', content: [listItem('plain child')] },
),
],
},
],
})
})
it('keeps multiple trailing plain bullets in one bullet list', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain 1', ' - plain 2'].join(
'\n',
)
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('checkbox child')] },
{ type: 'bulletList', content: [listItem('plain 1'), listItem('plain 2')] },
),
],
},
],
})
})
it('keeps a plain bullet in a deeply nested task list', () => {
const markdown = [
'- [ ] parent',
' - [ ] level2',
' - [ ] level3',
' - deep plain',
].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem('parent', {
type: 'taskList',
content: [
taskItem(
'level2',
{ type: 'taskList', content: [taskItem('level3')] },
{ type: 'bulletList', content: [listItem('deep plain')] },
),
],
}),
],
},
],
})
})
it('keeps a trailing paragraph that follows a nested checkbox item', () => {
const markdown = ['- [ ] parent', ' - [ ] child', ' trailing paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('child')] },
paragraph('trailing paragraph'),
),
],
},
],
})
})
it('keeps a plain bullet that precedes a checkbox item in a nested list', () => {
const markdown = ['- [ ] parent', ' - plain first', ' - [ ] checkbox after'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'bulletList', content: [listItem('plain first')] },
{ type: 'taskList', content: [taskItem('checkbox after')] },
),
],
},
],
})
})
it('still parses a purely-checkbox nested list as a single task list', () => {
const markdown = ['- [ ] parent', ' - [ ] child 1', ' - [x] child 2'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem('parent', {
type: 'taskList',
content: [
taskItem('child 1'),
{
type: 'taskItem',
attrs: { checked: true },
content: [paragraph('child 2')],
},
],
}),
],
},
],
})
})
it('round-trips the mixed nested list back to the original markdown', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain child'].join('\n')
const json = markdownManager.parse(markdown)
expect(markdownManager.serialize(json).trim()).toBe(markdown)
})
})

View File

@@ -0,0 +1,156 @@
/**
* @vitest-environment happy-dom
*/
import { Node } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
/**
* When markdown content contains text inside angle brackets that looks
* like an HTML tag but is not a recognized HTML element (and is not in
* the schema), marked passes it through as raw HTML. The browser's DOM
* parser then creates an unknown element with no text content, which is
* silently swallowed during schema-aware parsing.
*
* The expected behavior is that unrecognized HTML-like text should be
* preserved as literal plain text instead of being lost.
*/
describe('MarkdownManager unrecognized HTML tags', () => {
const basicExtensions = [Document, Paragraph, Text, Heading, Italic]
const collectText = (node: any): string => {
if (!node) {
return ''
}
if (typeof node === 'string') {
return node
}
if (Array.isArray(node)) {
return node.map(collectText).join('')
}
if (node.type === 'text' && typeof node.text === 'string') {
return node.text
}
if (Array.isArray(node.content)) {
return node.content.map(collectText).join('')
}
return ''
}
it('does not silently swallow text inside an unknown opening tag at block level', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = '<enter existing CID here if available>\n\nother text after'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
// The original "enter existing CID here if available" content must survive
// somewhere in the parsed document - it must not be silently dropped.
const text = collectText(doc)
expect(text).toContain('enter existing CID here if available')
expect(text).toContain('other text after')
})
it('does not silently swallow text inside an unknown opening tag inline', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = 'before <enter existing CID here> after'
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).toContain('before')
expect(text).toContain('enter existing CID here')
expect(text).toContain('after')
})
it('still parses recognized inline HTML elements normally', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = 'hello <em>world</em>'
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).toContain('hello')
expect(text).toContain('world')
})
it('does not render raw markup for recognized but empty HTML elements', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// Empty <em></em> / <span></span> have no text content and no void element,
// but they are still real HTML – they must not be rendered as literal text.
;['<em></em>', '<em> </em>', '<span></span>'].forEach(md => {
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).not.toContain('<em>')
expect(text).not.toContain('</em>')
expect(text).not.toContain('<span>')
expect(text).not.toContain('</span>')
})
})
it('does not flag hyphenated custom elements as unrecognized', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// Hyphenated tag names are valid custom elements per the HTML spec – the
// browser constructs them as HTMLElement, not HTMLUnknownElement.
const doc = manager.parse('<my-mention></my-mention>')
const text = collectText(doc)
expect(text).not.toContain('<my-mention>')
expect(text).not.toContain('</my-mention>')
})
it('preserves angle-bracket text with spaces as literal text', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// `<this is a placeholder>` contains spaces, so the tag name is invalid.
// The browser creates an HTMLUnknownElement and the manager should
// preserve the whole string as literal text instead of silently dropping it.
const doc = manager.parse('<this is a placeholder>')
const text = collectText(doc)
expect(text).toContain('<this is a placeholder>')
})
it('recognizes non-hyphenated custom tags declared in schema parseDOM', () => {
const Something = Node.create({
name: 'something',
group: 'inline',
inline: true,
content: 'text*',
renderHTML: () => ['something', 0],
parseHTML: () => [{ tag: 'something' }],
})
const SomethingWithHyphen = Node.create({
name: 'something-with-hyphen',
group: 'inline',
inline: true,
content: 'text*',
renderHTML: () => ['something-with-hyphen', 0],
parseHTML: () => [{ tag: 'something-with-hyphen' }],
})
const manager = new MarkdownManager({
extensions: [...basicExtensions, Something, SomethingWithHyphen],
})
// `<something>` is not a standard HTML tag (no hyphen), so the browser would
// normally create an HTMLUnknownElement. However, because the schema has
// a registered extension declaring parseDOM `{ tag: 'something' }`, the
// manager should treat it as a recognized element.
const doc = manager.parse(
'<something>happy</something><something attribute="a">happy</something><something-with-hyphen attribute="a">joyful</something-with-hyphen>',
)
const text = collectText(doc)
expect(text).toContain('happy')
expect(text).toContain('joyful')
expect(text).not.toContain('<something>')
expect(text).not.toContain('</something>')
expect(text).not.toContain('<something-with-hyphen>')
expect(text).not.toContain('</something-with-hyphen>')
})
})

View File

@@ -0,0 +1,523 @@
import type { JSONContent } from '@tiptap/core'
import {
createAtomBlockMarkdownSpec,
createBlockMarkdownSpec,
createInlineMarkdownSpec,
} from '@tiptap/core'
import { describe, expect, it } from 'vite-plus/test'
function measure(run: () => unknown): number {
const startedAt = performance.now()
run()
return performance.now() - startedAt
}
function expectLinearTokenization(run: (attributes: string) => unknown, attackUnit: string) {
run('warmup="true"')
const sizes = [768, 1536]
sizes.forEach(size => {
const attack = attackUnit.repeat(size)
const control = '-'.repeat(attack.length)
const controlDuration = measure(() => run(control))
const attackDuration = measure(() => run(attack))
expect(attackDuration).toBeLessThan(controlDuration * 20 + 100)
})
}
describe('Markdown Utilities', () => {
describe('createInlineMarkdownSpec', () => {
it('should create a valid spec for self-closing inline nodes', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label'],
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('inline')
})
it('should create a valid spec for inline nodes with content', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
allowedAttributes: ['color'],
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('inline')
})
it('should handle tokenizer start detection', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
})
const src = '[mention id="test"] some other text'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no mention here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize self-closing shortcodes correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
})
const token = spec.markdownTokenizer.tokenize(
'[mention id="test" label="Test User"]',
[],
null as any,
)
expect(token).toMatchObject({
type: 'mention',
raw: '[mention id="test" label="Test User"]',
content: '',
attributes: {
id: 'test',
label: 'Test User',
},
})
})
it('should tokenize shortcodes with content correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
})
const token = spec.markdownTokenizer.tokenize(
'[highlight color="yellow"]highlighted text[/highlight]',
[],
null as any,
)
expect(token).toMatchObject({
type: 'highlight',
raw: '[highlight color="yellow"]highlighted text[/highlight]',
content: 'highlighted text',
attributes: {
color: 'yellow',
},
})
})
it('should parse malformed shortcode attributes in linear time', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'probe',
selfClosing: true,
})
expectLinearTokenization(
attributes => spec.markdownTokenizer.tokenize(`[probe ${attributes}]`, [], null as any),
'0000000000000000',
)
})
it('should only parse shortcode attributes at attribute boundaries', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'probe',
selfClosing: true,
})
const token = spec.markdownTokenizer.tokenize(
'[probe prefix:id="ignored" label="kept"]',
[],
null as any,
)
expect(token).toMatchObject({ attributes: { label: 'kept' } })
})
it('should render self-closing shortcodes correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label'],
})
const node = {
attrs: {
id: 'test',
label: 'Test User',
internalId: 'should-be-filtered', // This should not appear
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe('[mention id="test" label="Test User"]')
})
it('should render shortcodes with content correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
allowedAttributes: ['color'],
})
const node: JSONContent = {
content: [{ type: 'text', text: 'highlighted text' }],
attrs: {
color: 'yellow',
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe('[highlight color="yellow"]highlighted text[/highlight]')
})
it('should skip attributes with default values when skipIfDefault is specified', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
})
// When mentionSuggestionChar equals the default '@', it should be omitted
const nodeWithDefault = {
attrs: {
id: 'test',
label: 'Test User',
mentionSuggestionChar: '@',
},
}
const renderedWithDefault = spec.renderMarkdown(nodeWithDefault)
expect(renderedWithDefault).toBe('[mention id="test" label="Test User"]')
// When mentionSuggestionChar differs from default, it should be included
const nodeWithNonDefault = {
attrs: {
id: 'bug',
label: 'Bug',
mentionSuggestionChar: '#',
},
}
const renderedWithNonDefault = spec.renderMarkdown(nodeWithNonDefault)
expect(renderedWithNonDefault).toBe(
'[mention id="bug" label="Bug" mentionSuggestionChar="#"]',
)
})
it('should support custom parseAttributes and serializeAttributes for attribute name mapping', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
name: '@',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
parseAttributes: (attrString: string) => {
const attrs: Record<string, any> = {}
const regex = /(?:^|\s)(\w+)=(?:"([^"]*)"|'([^']*)')/g
let match = regex.exec(attrString)
while (match !== null) {
const [, key, doubleQuoted, singleQuoted] = match
const value = doubleQuoted ?? singleQuoted
attrs[key === 'char' ? 'mentionSuggestionChar' : key] = value
match = regex.exec(attrString)
}
return attrs
},
serializeAttributes: (attrs: Record<string, any>) => {
return Object.entries(attrs)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const serializedKey = key === 'mentionSuggestionChar' ? 'char' : key
return `${serializedKey}="${value}"`
})
.join(' ')
},
})
// Test serialization: mentionSuggestionChar should be serialized as 'char'
const nodeWithHashChar = {
attrs: {
id: 'bug',
mentionSuggestionChar: '#',
},
}
const rendered = spec.renderMarkdown(nodeWithHashChar)
expect(rendered).toBe('[@ id="bug" char="#"]')
// Test serialization: default '@' should be omitted
const nodeWithDefaultChar = {
attrs: {
id: 'Madonna',
mentionSuggestionChar: '@',
},
}
const renderedDefault = spec.renderMarkdown(nodeWithDefaultChar)
expect(renderedDefault).toBe('[@ id="Madonna"]')
// Test parsing: 'char' should be mapped to 'mentionSuggestionChar'
const token = spec.markdownTokenizer.tokenize('[@ id="feature" char="#"]', [], null as any)
expect(token).toMatchObject({
type: 'mention',
attributes: {
id: 'feature',
mentionSuggestionChar: '#',
},
})
// Test parsing: without char attribute
const tokenNoChar = spec.markdownTokenizer.tokenize('[@ id="Tom Cruise"]', [], null as any)
expect(tokenNoChar).toMatchObject({
type: 'mention',
attributes: {
id: 'Tom Cruise',
},
})
})
})
describe('createBlockMarkdownSpec', () => {
it('should create a valid spec for block nodes', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('block')
})
it('should handle tokenizer start detection', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
const src = ':::callout\ncontent\n:::'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no callout here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize block syntax correctly', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
const mockLexer = {
blockTokens: (src: string) => [{ type: 'paragraph', text: src.trim() }],
inlineTokens: (src: string) => [{ type: 'text', text: src.trim() }],
}
const token = spec.markdownTokenizer.tokenize(
':::callout {type="info"}\nThis is a callout\n:::',
[],
mockLexer,
)
expect(token).toMatchObject({
type: 'callout',
attributes: { type: 'info' },
content: 'This is a callout',
})
expect(token).toHaveProperty('tokens')
expect(Array.isArray((token as any).tokens)).toBe(true)
expect((token as any).tokens).toHaveLength(1)
})
it('should parse malformed block attributes in linear time', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'probe',
})
const lexer = {
blockTokens: () => [],
inlineTokens: () => [],
}
expectLinearTokenization(
attributes =>
spec.markdownTokenizer.tokenize(`:::probe {${attributes}}\ncontent\n:::`, [], lexer),
'__QUOTED_0',
)
})
it('should render block syntax correctly', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
allowedAttributes: ['type'],
})
const mockHelpers = {
renderChildren: () => 'This is a callout',
wrapInBlock: (content: string) => content,
indent: (content: string) => content,
}
const node = {
attrs: { type: 'info' },
content: [],
}
const rendered = spec.renderMarkdown(node, mockHelpers)
expect(rendered).toBe(':::callout {type="info"}\n\nThis is a callout\n\n:::')
})
})
describe('createAtomBlockMarkdownSpec', () => {
it('should create a valid spec for atomic block nodes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('block')
})
it('should handle tokenizer start detection', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
const src = ':::youtube {src="test"} :::'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no youtube here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize atom block syntax correctly', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
const token = spec.markdownTokenizer.tokenize(
':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
[],
null as any,
)
expect(token).toMatchObject({
type: 'youtube',
raw: ':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
attributes: {
src: 'https://youtube.com/watch?v=test',
},
})
})
it('should parse malformed atom block attributes in linear time', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'probe',
})
expectLinearTokenization(
attributes =>
spec.markdownTokenizer.tokenize(`:::probe {${attributes}} :::\n`, [], null as any),
'__QUOTED_0',
)
})
it('should validate required attributes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
requiredAttributes: ['src'],
})
// Should fail without required attribute
const tokenWithoutSrc = spec.markdownTokenizer.tokenize(
':::youtube {width="400"} :::\n',
[],
null as any,
)
expect(tokenWithoutSrc).toBe(undefined)
// Should succeed with required attribute
const tokenWithSrc = spec.markdownTokenizer.tokenize(
':::youtube {src="test"} :::\n',
[],
null as any,
)
expect(tokenWithSrc).not.toBe(undefined)
expect(tokenWithSrc).toHaveProperty('attributes')
expect((tokenWithSrc as any).attributes).toHaveProperty('src', 'test')
})
it('should render atom block syntax correctly', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
allowedAttributes: ['src', 'width'],
})
const node = {
attrs: {
src: 'https://youtube.com/watch?v=test',
width: '400',
internalId: 'should-be-filtered', // This should not appear
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe(':::youtube {src="https://youtube.com/watch?v=test" width="400"} :::')
})
it('should apply default attributes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
defaultAttributes: { start: '0', width: '640' },
})
const mockHelpers = {
createNode: (name: string, attrs: any, content: any) => ({ type: name, attrs, content }),
parseInline: () => [],
parseChildren: () => [],
createTextNode: (text: string) => ({ type: 'text', text }),
applyMark: (mark: string, content: any) => ({ mark, content }),
}
const token = { attributes: { src: 'test' } }
const result = spec.parseMarkdown(token as any, mockHelpers as any)
expect((result as any).attrs).toMatchObject({
src: 'test',
start: '0',
width: '640',
})
})
})
})

View File

@@ -0,0 +1,178 @@
import { Bold } from '@tiptap/extension-bold'
import { Code } from '@tiptap/extension-code'
import { Document } from '@tiptap/extension-document'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Whitespace-only marked text nodes', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic, Code, Strike]
const markdownManager = new MarkdownManager({ extensions })
const paragraph = (content: Array<Record<string, any>>) => ({
type: 'doc',
content: [{ type: 'paragraph', content }],
})
it.each([
['bold', 'a b'],
['italic', 'a b'],
['code', 'a b'],
['strike', 'a b'],
])('emits no delimiters for a whitespace-only %s node', (markType, expected) => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: markType }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe(expected)
})
it.each([
['tab', '\t'],
['non-breaking space', '\u00a0'],
['newline', '\n'],
])('emits no delimiters for a whitespace-only node containing a %s', (_name, whitespace) => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: whitespace, marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe(`a${whitespace}b`)
})
it('emits no delimiters for a multi-space whitespace-only node', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('a b')
})
it('emits no delimiters when a whitespace-only node carries nested marks', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('a b')
})
it('opens a continuing mark after the whitespace (Issue #8212)', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('a **b**')
})
it('keeps a continuing mark and drops the transient one', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('a **b**')
})
it('keeps a continuing italic when bold is the transient mark', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'italic' }] },
]),
)
expect(markdown).toBe('a *b*')
})
it('spans a mark across the whitespace when it opened on an earlier node', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('**a b**')
})
it('closes an already-open mark before a whitespace-only node that ends it', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('**a** b')
})
it('keeps expelling trailing whitespace out of a mark that has text', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'Label: ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'value' },
]),
)
expect(markdown).toBe('**Label:** value')
})
it.each([
['a single mark', [{ type: 'bold' }]],
['nested marks', [{ type: 'bold' }, { type: 'italic' }]],
])('survives repeated serialize/parse cycles with %s', (_name, marks) => {
// Parsing `****` reads it as literal asterisks, which the next serialize escapes.
// Assert the value on every cycle, not that it settles: the broken output settles too.
let markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks },
{ type: 'text', text: 'b' },
]),
)
for (let cycle = 0; cycle < 3; cycle += 1) {
expect(markdown).toBe('a b')
markdown = markdownManager.serialize(markdownManager.parse(markdown))
}
expect(markdown).toBe('a b')
})
it('still emphasises a node that is only punctuation', () => {
const markdown = markdownManager.serialize(
paragraph([{ type: 'text', text: ')', marks: [{ type: 'bold' }] }]),
)
expect(markdown).toBe('**)**')
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})