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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-table
[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table)
[![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,92 @@
{
"name": "@tiptap/extension-table",
"version": "3.30.3",
"description": "table extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"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/extension-table"
},
"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": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./table": {
"types": {
"import": "./dist/table/index.d.ts",
"require": "./dist/table/index.d.cts"
},
"import": "./dist/table/index.js",
"require": "./dist/table/index.cjs"
},
"./cell": {
"types": {
"import": "./dist/cell/index.d.ts",
"require": "./dist/cell/index.d.cts"
},
"import": "./dist/cell/index.js",
"require": "./dist/cell/index.cjs"
},
"./header": {
"types": {
"import": "./dist/header/index.d.ts",
"require": "./dist/header/index.d.cts"
},
"import": "./dist/header/index.js",
"require": "./dist/header/index.cjs"
},
"./kit": {
"types": {
"import": "./dist/kit/index.d.ts",
"require": "./dist/kit/index.d.cts"
},
"import": "./dist/kit/index.js",
"require": "./dist/kit/index.cjs"
},
"./row": {
"types": {
"import": "./dist/row/index.d.ts",
"require": "./dist/row/index.d.cts"
},
"import": "./dist/row/index.js",
"require": "./dist/row/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1 @@
export * from './table-cell.js'

View File

@@ -0,0 +1,160 @@
import { Editor } 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 { TableHeader } from '../header/table-header.js'
import { TableRow } from '../row/table-row.js'
import { Table } from '../table/table.js'
import { TableCell } from './table-cell.js'
describe('extension table cell', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
it('should start with a Table', () => {
const content =
'<table style="width:100%"><tr><td>Firstname</td><td>Lastname</td><td>Age</td></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
expect(editor.getHTML()).toContain('Jackson')
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse a single colWidth', () => {
const content =
'<table><tbody><tr><td colwidth="200">Name</td><td>Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
// @ts-expect-error content is not guaranteed to be this shape
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).toBe(200)
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse multiple colWidths', () => {
const content =
'<table><tbody><tr><td colwidth="200">Name</td><td colspan="3" colwidth="150,100">Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
// @ts-expect-error content is not guaranteed to be this shape
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).toEqual([150, 100])
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse the colgroup col width for the first column', () => {
const content =
'<table><colgroup><col width="64" /><col /></colgroup><tbody><tr><td>hello</td><td>world</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
const firstRow = editor.getJSON().content?.[0].content?.[0]
expect(firstRow?.content?.[0].attrs?.colwidth).toEqual([64])
expect(firstRow?.content?.[1].attrs?.colwidth).toBeNull()
editor?.destroy()
getEditorEl()?.remove()
})
it('should prefer the colwidth attribute over the colgroup col width', () => {
const content =
'<table><colgroup><col width="64" /><col /></colgroup><tbody><tr><td colwidth="200">hello</td><td>world</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
// @ts-expect-error content is not guaranteed to be this shape
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth).toEqual([200])
editor?.destroy()
getEditorEl()?.remove()
})
})

View File

@@ -0,0 +1,68 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
import { createAlignAttribute } from '../utils/parseAlign.js'
import { parseColwidth } from '../utils/parseColwidth.js'
import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js'
export interface TableCellOptions {
/**
* The HTML attributes for a table cell node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table cells.
* @see https://www.tiptap.dev/api/nodes/table-cell
*/
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
addOptions() {
return {
HTMLAttributes: {},
}
},
content: 'block+',
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: null,
parseHTML: parseColwidth,
},
align: createAlignAttribute(),
}
},
tableRole: 'cell',
isolating: true,
parseHTML() {
return [
{
// Backfill empty cells; non-empty cells fall through to the rule below.
tag: 'td',
getAttrs: node => (isEmptyCellElement(node) ? {} : false),
getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]),
},
{ tag: 'td' },
]
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
})

View File

@@ -0,0 +1 @@
export * from './table-header.js'

View File

@@ -0,0 +1,133 @@
import { Editor } 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 { TableCell } from '../cell/table-cell.js'
import { TableRow } from '../row/table-row.js'
import { Table } from '../table/table.js'
import { TableHeader } from './table-header.js'
describe('extension table header', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
it('should start with a Table', () => {
const content =
'<table style="width:100%"><tr><th>Firstname</th><th>Lastname</th><th>Age</th></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
expect(editor.getHTML()).toContain('Jackson')
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse a single colWidth', () => {
const content =
'<table><tbody><tr><th colwidth="200">Name</th><th>Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
// @ts-expect-error content is not guaranteed to be this shape
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).toBe(200)
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse multiple colWidths', () => {
const content =
'<table><tbody><tr><th colwidth="200">Name</th><th colspan="3" colwidth="150,100">Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
// @ts-expect-error content is not guaranteed to be this shape
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).toEqual([150, 100])
editor?.destroy()
getEditorEl()?.remove()
})
it('should parse the colgroup col widths for a header row', () => {
const content =
'<table><colgroup><col width="64" /><col width="128" /></colgroup><tbody><tr><th>Name</th><th>Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td></tr></tbody></table>'
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content,
})
const headerRow = editor.getJSON().content?.[0].content?.[0]
expect(headerRow?.content?.[0].attrs?.colwidth).toEqual([64])
expect(headerRow?.content?.[1].attrs?.colwidth).toEqual([128])
editor?.destroy()
getEditorEl()?.remove()
})
})

View File

@@ -0,0 +1,68 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
import { createAlignAttribute } from '../utils/parseAlign.js'
import { parseColwidth } from '../utils/parseColwidth.js'
import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js'
export interface TableHeaderOptions {
/**
* The HTML attributes for a table header node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table headers.
* @see https://www.tiptap.dev/api/nodes/table-header
*/
export const TableHeader = Node.create<TableHeaderOptions>({
name: 'tableHeader',
addOptions() {
return {
HTMLAttributes: {},
}
},
content: 'block+',
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: null,
parseHTML: parseColwidth,
},
align: createAlignAttribute(),
}
},
tableRole: 'header_cell',
isolating: true,
parseHTML() {
return [
{
// Backfill empty cells; non-empty cells fall through to the rule below.
tag: 'th',
getAttrs: node => (isEmptyCellElement(node) ? {} : false),
getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]),
},
{ tag: 'th' },
]
},
renderHTML({ HTMLAttributes }) {
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
})

View File

@@ -0,0 +1,6 @@
export * from './cell/index.js'
export * from './header/index.js'
export * from './kit/index.js'
export * from './row/index.js'
export * from './table/index.js'
export * from './table/TableView.js'

View File

@@ -0,0 +1,64 @@
import { Extension } from '@tiptap/core'
import type { TableCellOptions } from '../cell/index.js'
import { TableCell } from '../cell/index.js'
import type { TableHeaderOptions } from '../header/index.js'
import { TableHeader } from '../header/index.js'
import type { TableRowOptions } from '../row/index.js'
import { TableRow } from '../row/index.js'
import type { TableOptions } from '../table/index.js'
import { Table } from '../table/index.js'
export interface TableKitOptions {
/**
* If set to false, the table extension will not be registered
* @example table: false
*/
table: Partial<TableOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableCell: false
*/
tableCell: Partial<TableCellOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableHeader: false
*/
tableHeader: Partial<TableHeaderOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableRow: false
*/
tableRow: Partial<TableRowOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* It’s a good starting point for building your own table in Tiptap.
*/
export const TableKit = Extension.create<TableKitOptions>({
name: 'tableKit',
addExtensions() {
const extensions = []
if (this.options.table !== false) {
extensions.push(Table.configure(this.options.table))
}
if (this.options.tableCell !== false) {
extensions.push(TableCell.configure(this.options.tableCell))
}
if (this.options.tableHeader !== false) {
extensions.push(TableHeader.configure(this.options.tableHeader))
}
if (this.options.tableRow !== false) {
extensions.push(TableRow.configure(this.options.tableRow))
}
return extensions
},
})

View File

@@ -0,0 +1 @@
export * from './table-row.js'

View File

@@ -0,0 +1,38 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
export interface TableRowOptions {
/**
* The HTML attributes for a table row node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table rows.
* @see https://www.tiptap.dev/api/nodes/table-row
*/
export const TableRow = Node.create<TableRowOptions>({
name: 'tableRow',
addOptions() {
return {
HTMLAttributes: {},
}
},
content: '(tableCell | tableHeader)*',
tableRole: 'row',
parseHTML() {
return [{ tag: 'tr' }]
},
renderHTML({ HTMLAttributes }) {
return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
})

View File

@@ -0,0 +1,154 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view'
import { getColStyleDeclaration } from './utilities/colStyle.js'
export function updateColumns(
node: PMNode,
colgroup: HTMLTableColElement, // <colgroup> has the same prototype as <col>
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number,
) {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild
const row = node.firstChild
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined)
const cssWidth = hasWidth ? `${hasWidth}px` : ''
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
if (!nextDOM) {
const colElement = document.createElement('col')
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
colElement.style.setProperty(propertyKey, propertyValue)
colgroup.appendChild(colElement)
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue)
}
nextDOM = nextDOM.nextSibling
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after
}
// Check if user has set a width style on the table node
const hasUserWidth =
node.attrs.style &&
typeof node.attrs.style === 'string' &&
/\bwidth\s*:/i.test(node.attrs.style)
if (fixedWidth && !hasUserWidth) {
table.style.width = `${totalWidth}px`
table.style.minWidth = ''
} else {
table.style.width = ''
table.style.minWidth = `${totalWidth}px`
}
}
export class TableView implements NodeView {
node: PMNode
cellMinWidth: number
dom: HTMLDivElement
table: HTMLTableElement
colgroup: HTMLTableColElement
contentDOM: HTMLTableSectionElement
constructor(
node: PMNode,
cellMinWidth: number,
_view?: EditorView,
HTMLAttributes: Record<string, any> = {},
) {
this.node = node
this.cellMinWidth = cellMinWidth
this.dom = document.createElement('div')
this.dom.className = 'tableWrapper'
this.table = this.dom.appendChild(document.createElement('table'))
// Apply extension-configured HTMLAttributes to the table element
// (e.g. class, data-* set via Table.configure({ HTMLAttributes }))
for (const [key, value] of Object.entries(HTMLAttributes)) {
if (value !== undefined && value !== null) {
if (key === 'style') {
this.table.style.cssText = String(value)
} else {
this.table.setAttribute(key, String(value))
}
}
}
// Apply user styles from the ProseMirror document node.
// This may come from HTML parsing (e.g. <table style="...">) and should
// override any style set via extension-configured HTMLAttributes above.
if (node.attrs.style) {
this.table.style.cssText = node.attrs.style
}
this.colgroup = this.table.appendChild(document.createElement('colgroup'))
updateColumns(node, this.colgroup, this.table, cellMinWidth)
this.contentDOM = this.table.appendChild(document.createElement('tbody'))
}
update(node: PMNode) {
if (node.type !== this.node.type) {
return false
}
this.node = node
updateColumns(node, this.colgroup, this.table, this.cellMinWidth)
return true
}
ignoreMutation(mutation: ViewMutationRecord) {
const target = mutation.target as Node
const isInsideWrapper = this.dom.contains(target)
const isInsideContent = this.contentDOM.contains(target)
if (isInsideWrapper && !isInsideContent) {
if (
mutation.type === 'attributes' ||
mutation.type === 'childList' ||
mutation.type === 'characterData'
) {
return true
}
}
return false
}
}

View File

@@ -0,0 +1,4 @@
export * from './table.js'
export * from './utilities/createColGroup.js'
export * from './utilities/createTable.js'
export * from './utilities/markdown.js'

View File

@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { TableCell } from '../cell/table-cell.js'
import { TableHeader } from '../header/table-header.js'
import { TableRow } from '../row/table-row.js'
import { Table } from './table.js'
describe('Table HTMLAttributes', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
it('applies HTMLAttributes to the <table> element when resizable is false (default)', () => {
const editorEl = document.createElement('div')
document.body.appendChild(editorEl)
editor = new Editor({
element: editorEl,
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
HTMLAttributes: { class: 'my-custom-table', 'data-test': 'table-test' },
resizable: false,
}),
],
})
editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: false })
const table = editor.view.dom.querySelector('table')!
expect(table.classList.contains('my-custom-table')).toBe(true)
expect(table.getAttribute('data-test')).toBe('table-test')
editorEl.remove()
})
it('applies HTMLAttributes to the <table> element when View is null (renderHTML fallback)', () => {
const editorEl = document.createElement('div')
document.body.appendChild(editorEl)
editor = new Editor({
element: editorEl,
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
HTMLAttributes: { class: 'fallback-class' },
View: null,
}),
],
})
editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: false })
const table = editor.view.dom.querySelector('table')!
expect(table.classList.contains('fallback-class')).toBe(true)
editorEl.remove()
})
})

View File

@@ -0,0 +1,646 @@
import '../types.js'
import {
type JSONContent,
type MarkdownToken,
callOrReturn,
findParentNodeClosestToPos,
getExtensionField,
mergeAttributes,
Node,
} from '@tiptap/core'
import type { DOMOutputSpec, Node as PMNode } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell,
} from '@tiptap/pm/tables'
import type { EditorView, NodeView } from '@tiptap/pm/view'
import { type TableCellAlign, normalizeTableCellAlign } from '../utils/parseAlign.js'
import { TableView } from './TableView.js'
import { createColGroup } from './utilities/createColGroup.js'
import { createTable } from './utilities/createTable.js'
import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js'
import { keepCursorInTable } from './utilities/keepCursorInTable.js'
import renderTableToMarkdown, { preprocessTablePipes } from './utilities/markdown.js'
type MarkdownTableToken = {
align?: Array<TableCellAlign | null>
header?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[]
rows?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[][]
} & MarkdownToken
export interface TableOptions {
/**
* HTML attributes for the table element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Enables the resizing of tables.
* @default false
* @example true
*/
resizable: boolean
/**
* Controls whether the table should be wrapped in a div with class "tableWrapper" when rendered.
* In editable mode with resizable tables, this wrapper is always present via TableView.
* @default false
* @example true
*/
renderWrapper: boolean
/**
* The width of the resize handle.
* @default 5
* @example 10
*/
handleWidth: number
/**
* The minimum width of a cell.
* @default 25
* @example 50
*/
cellMinWidth: number
/**
* The node view to render the table.
* @default TableView
*/
View:
| (new (
node: PMNode,
cellMinWidth: number,
view: EditorView,
HTMLAttributes?: Record<string, any>,
) => NodeView)
| null
/**
* Enables the resizing of the last column.
* @default true
* @example false
*/
lastColumnResizable: boolean
/**
* Allow table node selection.
* @default false
* @example true
*/
allowTableNodeSelection: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
table: {
/**
* Insert a table
* @param options The table attributes
* @returns True if the command was successful, otherwise false
* @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
*/
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
/**
* Add a column before the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnBefore()
*/
addColumnBefore: () => ReturnType
/**
* Add a column after the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnAfter()
*/
addColumnAfter: () => ReturnType
/**
* Delete the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteColumn()
*/
deleteColumn: () => ReturnType
/**
* Add a row before the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowBefore()
*/
addRowBefore: () => ReturnType
/**
* Add a row after the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowAfter()
*/
addRowAfter: () => ReturnType
/**
* Delete the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteRow()
*/
deleteRow: () => ReturnType
/**
* Delete the current table
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteTable()
*/
deleteTable: () => ReturnType
/**
* Merge the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeCells()
*/
mergeCells: () => ReturnType
/**
* Split the currently selected cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.splitCell()
*/
splitCell: () => ReturnType
/**
* Toggle the header column
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderColumn()
*/
toggleHeaderColumn: () => ReturnType
/**
* Toggle the header row
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderRow()
*/
toggleHeaderRow: () => ReturnType
/**
* Toggle the header cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderCell()
*/
toggleHeaderCell: () => ReturnType
/**
* Merge or split the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeOrSplit()
*/
mergeOrSplit: () => ReturnType
/**
* Set a cell attribute
* @param name The attribute name
* @param value The attribute value
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellAttribute('align', 'right')
*/
setCellAttribute: (name: string, value: any) => ReturnType
/**
* Moves the selection to the next cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToNextCell()
*/
goToNextCell: () => ReturnType
/**
* Moves the selection to the previous cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToPreviousCell()
*/
goToPreviousCell: () => ReturnType
/**
* Try to fix the table structure if necessary
* @returns True if the command was successful, otherwise false
* @example editor.commands.fixTables()
*/
fixTables: () => ReturnType
/**
* Set a cell selection inside the current table
* @param position The cell position
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
*/
setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType
}
}
}
/**
* This extension allows you to create tables.
* @see https://www.tiptap.dev/api/nodes/table
*/
export const Table = Node.create<TableOptions>({
name: 'table',
// @ts-ignore
addOptions() {
return {
HTMLAttributes: {},
resizable: false,
renderWrapper: false,
handleWidth: 5,
cellMinWidth: 25,
// TODO: fix
View: TableView,
lastColumnResizable: true,
allowTableNodeSelection: false,
}
},
content: 'tableRow+',
tableRole: 'table',
isolating: true,
group: 'block',
parseHTML() {
return [{ tag: 'table' }]
},
renderHTML({ node, HTMLAttributes }) {
const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth)
const userStyles = HTMLAttributes.style as string | undefined
function getTableStyle() {
if (userStyles) {
return userStyles
}
return tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`
}
const table: DOMOutputSpec = [
'table',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: getTableStyle(),
}),
colgroup,
['tbody', 0],
]
return this.options.renderWrapper ? ['div', { class: 'tableWrapper' }, table] : table
},
parseMarkdown: (token: MarkdownTableToken, h) => {
const rows = []
const alignments = Array.isArray(token.align) ? token.align : []
if (token.header) {
const headerCells: JSONContent[] = []
token.header.forEach((cell, index) => {
const align = normalizeTableCellAlign(alignments[index] ?? cell.align)
const attrs = align ? { align } : {}
headerCells.push(
h.createNode('tableHeader', attrs, [
{ type: 'paragraph', content: h.parseInline(cell.tokens) },
]),
)
})
rows.push(h.createNode('tableRow', {}, headerCells))
}
if (token.rows) {
token.rows.forEach(row => {
const bodyCells: JSONContent[] = []
row.forEach((cell, index) => {
const align = normalizeTableCellAlign(alignments[index] ?? cell.align)
const attrs = align ? { align } : {}
bodyCells.push(
h.createNode('tableCell', attrs, [
{ type: 'paragraph', content: h.parseInline(cell.tokens) },
]),
)
})
rows.push(h.createNode('tableRow', {}, bodyCells))
})
}
return h.createNode('table', undefined, rows)
},
renderMarkdown: (node, h) => {
return renderTableToMarkdown(node, h)
},
markdownTokenizer: {
name: 'table',
level: 'block' as const,
start: (src: string) => {
const lines = src.split('\n')
if (lines.length < 2) return -1
const sep = lines[1]
if (!/^[ \t|:]*-[ \t|:-]*$/.test(sep) || !sep.includes('|')) return -1
return lines[0].includes('|') ? 0 : -1
},
tokenize(src, _tokens, helper) {
// Marked terminates a table block at a blank line. Slicing to the first
// \n\n keeps helper.blockTokens isolated to the candidate table so it
// cannot consume content that follows. When no blank line exists the
// full remaining source is used and marked's own table regex determines
// the boundary (it excludes headings, fences, blockquotes, etc.).
const blankLineIndex = src.indexOf('\n\n')
const candidate = blankLineIndex >= 0 ? src.slice(0, blankLineIndex) : src
// A GFM table needs at least a header and a separator row (two lines).
const candidateLines = candidate.split('\n')
if (candidateLines.length < 2) return undefined
// Guard: the second line must match a GFM delimiter row (dashes, colons,
// pipes, spaces) AND must contain at least one '|'. A bare '---' matches
// the dash pattern but is a setext heading marker, not a table separator.
// Calling helper.blockTokens on non-table content contaminates the outer
// lexer's inline queue with preprocessed (backslash-modified) content.
const sep = candidateLines[1]
if (!/^[ \t|:]*-[ \t|:-]*$/.test(sep) || !sep.includes('|')) return undefined
const preprocessed = preprocessTablePipes(candidate)
// Nothing to fix — let marked's built-in handle it.
if (preprocessed === candidate) return undefined
// Re-lex only the preprocessed candidate. The recursive call to our
// tokenizer finds no unescaped pipes left inside code spans and returns
// undefined, so marked's built-in table tokenizer produces
// correctly-split cells.
const block = helper.blockTokens(preprocessed)
const tableToken = block[0]
if (tableToken?.type !== 'table' || !tableToken.raw) return undefined
// preprocessTablePipes only inserts backslashes (never adds or removes
// newlines), so line N in preprocessed maps 1-to-1 to line N in src.
// Use the token's line count to reconstruct raw from the original src so
// marked advances its cursor by the correct amount.
const lineCount = tableToken.raw.split('\n').length
const raw = src.split('\n').slice(0, lineCount).join('\n')
return { ...tableToken, raw }
},
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow)
if (dispatch) {
const offset = tr.selection.from + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)))
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => {
return addColumnBefore(state, dispatch)
},
addColumnAfter:
() =>
({ state, dispatch }) => {
return addColumnAfter(state, dispatch)
},
deleteColumn:
() =>
({ state, dispatch }) => {
const table = findParentNodeClosestToPos(
state.selection.$from,
node => node.type.name === 'table',
)
return deleteColumn(
state,
dispatch &&
(tr => {
if (table) {
keepCursorInTable(tr, table.pos)
}
dispatch(tr)
}),
)
},
addRowBefore:
() =>
({ state, dispatch }) => {
return addRowBefore(state, dispatch)
},
addRowAfter:
() =>
({ state, dispatch }) => {
return addRowAfter(state, dispatch)
},
deleteRow:
() =>
({ state, dispatch }) => {
const table = findParentNodeClosestToPos(
state.selection.$from,
node => node.type.name === 'table',
)
return deleteRow(
state,
dispatch &&
(tr => {
if (table) {
keepCursorInTable(tr, table.pos)
}
dispatch(tr)
}),
)
},
deleteTable:
() =>
({ state, dispatch }) => {
return deleteTable(state, dispatch)
},
mergeCells:
() =>
({ state, dispatch }) => {
return mergeCells(state, dispatch)
},
splitCell:
() =>
({ state, dispatch }) => {
return splitCell(state, dispatch)
},
toggleHeaderColumn:
() =>
({ state, dispatch }) => {
return toggleHeader('column')(state, dispatch)
},
toggleHeaderRow:
() =>
({ state, dispatch }) => {
return toggleHeader('row')(state, dispatch)
},
toggleHeaderCell:
() =>
({ state, dispatch }) => {
return toggleHeaderCell(state, dispatch)
},
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => {
return setCellAttr(name, value)(state, dispatch)
},
goToNextCell:
() =>
({ state, dispatch }) => {
return goToNextCell(1)(state, dispatch)
},
goToPreviousCell:
() =>
({ state, dispatch }) => {
return goToNextCell(-1)(state, dispatch)
},
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
position =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell)
// @ts-ignore
tr.setSelection(selection)
}
return true
},
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
'Mod-Backspace': deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
'Mod-Delete': deleteTableWhenAllCellsSelected,
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
return [
...(isResizable
? [
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
defaultCellMinWidth: this.options.cellMinWidth,
View: this.options.View,
lastColumnResizable: this.options.lastColumnResizable,
}),
]
: []),
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
]
},
addNodeView() {
// When resizable, the columnResizing plugin registers its own NodeView.
// We only register one here for the non-resizable case so that
// <colgroup> stays in sync with column changes (issue #7015).
const isResizable = this.options.resizable && this.editor.isEditable
const View = this.options.View
if (isResizable || !View) {
return null
}
return ({ node, view, HTMLAttributes }) => {
const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
return new View(node, this.options.cellMinWidth, view, mergedAttributes) as NodeView
}
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
}
return {
tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context)),
}
},
})

View File

@@ -0,0 +1,12 @@
export function getColStyleDeclaration(
minWidth: number,
width: number | undefined,
): [string, string] {
if (width) {
// apply the stored width unless it is below the configured minimum cell width
return ['width', `${Math.max(width, minWidth)}px`]
}
// set the minimum with on the column if it has no stored width
return ['min-width', `${minWidth}px`]
}

View File

@@ -0,0 +1,12 @@
import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model'
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}

View File

@@ -0,0 +1,69 @@
import type { DOMOutputSpec, Node as PMNode } from '@tiptap/pm/model'
import { getColStyleDeclaration } from './colStyle.js'
export type ColGroup =
| {
colgroup: DOMOutputSpec
tableWidth: string
tableMinWidth: string
}
| Record<string, never>
/**
* Creates a colgroup element for a table node in ProseMirror.
*
* @param node - The ProseMirror node representing the table.
* @param cellMinWidth - The minimum width of a cell in the table.
* @param overrideCol - (Optional) The index of the column to override the width of.
* @param overrideValue - (Optional) The width value to use for the overridden column.
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
*/
export function createColGroup(node: PMNode, cellMinWidth: number): ColGroup
export function createColGroup(
node: PMNode,
cellMinWidth: number,
overrideCol: number,
overrideValue: number,
): ColGroup
export function createColGroup(
node: PMNode,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number,
): ColGroup {
let totalWidth = 0
let fixedWidth = true
const cols: DOMOutputSpec[] = []
const row = node.firstChild
if (!row) {
return {}
}
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined)
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth)
cols.push(['col', { style: `${property}: ${value}` }])
}
}
const tableWidth = fixedWidth ? `${totalWidth}px` : ''
const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
return { colgroup, tableWidth, tableMinWidth }
}

View File

@@ -0,0 +1,40 @@
import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model'
import { createCell } from './createCell.js'
import { getTableNodeTypes } from './getTableNodeTypes.js'
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells))
}
return types.table.createChecked(null, rows)
}

View File

@@ -0,0 +1,37 @@
import type { KeyboardShortcutCommand } from '@tiptap/core'
import { findParentNodeClosestToPos } from '@tiptap/core'
import { isCellSelection } from './isCellSelection.js'
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(selection.ranges[0].$from, node => {
return node.type.name === 'table'
})
table?.node.descendants(node => {
if (node.type.name === 'table') {
return false
}
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}

View File

@@ -0,0 +1,21 @@
import type { NodeType, Schema } from '@tiptap/pm/model'
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach(type => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}

View File

@@ -0,0 +1,5 @@
import { CellSelection } from '@tiptap/pm/tables'
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}

View File

@@ -0,0 +1,29 @@
import { findParentNodeClosestToPos } from '@tiptap/core'
import { TextSelection, type Transaction } from '@tiptap/pm/state'
// prosemirror-tables does not move the selection after a deletion, so removing the
// last row or column can push the cursor out of the table into the content below.
export function keepCursorInTable(tr: Transaction, tablePos: number) {
const mappedTablePos = tr.mapping.map(tablePos)
// The delete can push the cursor into a following table, so compare the
// mapped position to confirm it is still the table we edited.
const stillInTable = findParentNodeClosestToPos(
tr.selection.$from,
node => node.type.name === 'table',
)
if (stillInTable?.pos === mappedTablePos) {
return
}
const tableNode = tr.doc.nodeAt(mappedTablePos)
if (!tableNode) {
return
}
const endOfTable = mappedTablePos + tableNode.nodeSize - 1
tr.setSelection(TextSelection.near(tr.doc.resolve(endOfTable), -1))
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vite-plus/test'
import { escapeTableCellPipes } from './markdown.js'
describe('escapeTableCellPipes', () => {
it('escapes a bare pipe inside a code span', () => {
expect(escapeTableCellPipes('| `a|b` |')).toBe('| `a\\|b` |')
})
it('escapes every pipe of a consecutive run', () => {
expect(escapeTableCellPipes('| `||` |')).toBe('| `\\|\\|` |')
})
it('keeps an already-escaped pipe untouched', () => {
expect(escapeTableCellPipes('| `a\\|b` |')).toBe('| `a\\|b` |')
})
it('keeps a pipe preceded by a backslash pair untouched', () => {
expect(escapeTableCellPipes('| `a\\\\|b` |')).toBe('| `a\\\\|b` |')
})
it('handles mixed escaped and bare pipes in one span', () => {
expect(escapeTableCellPipes('| `|\\||` |')).toBe('| `\\|\\|\\|` |')
})
})

View File

@@ -0,0 +1,223 @@
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core'
import {
type TableCellAlign as TableCellAlignType,
normalizeTableCellAlignFromAttributes,
TableCellAlign,
} from '../../utils/parseAlign.js'
export const DEFAULT_CELL_LINE_SEPARATOR = '\u001F'
/**
* Walk a single table-row line and escape any `|` characters that appear
* inside backtick code spans so marked's cell splitter ignores them.
* Backslash escape sequences outside code spans are passed through untouched.
* If a backtick run has no matching closer on the same line it is emitted
* as-is — no over-escaping for malformed input.
*/
export function escapeTableCellPipes(line: string): string {
let result = ''
let i = 0
while (i < line.length) {
if (line[i] === '\\' && i + 1 < line.length) {
result += line[i] + line[i + 1]
i += 2
continue
}
if (line[i] !== '`') {
result += line[i++]
continue
}
let runLen = 0
while (i + runLen < line.length && line[i + runLen] === '`') runLen += 1
let j = i + runLen
let found = false
while (j < line.length) {
if (line[j] !== '`') {
j += 1
continue
}
let closeLen = 0
while (j + closeLen < line.length && line[j + closeLen] === '`') closeLen += 1
if (closeLen === runLen) {
const spanContent = line.slice(i + runLen, j)
// Matches an already-escaped pipe (`\|`, kept as-is) or a bare pipe
// (escaped). Deliberately avoids a negative lookbehind assertion:
// WebKit before Safari 16.4 cannot parse lookbehind, and a regex
// literal fails the whole chunk at parse time, not at call time.
result +=
line.slice(i, i + runLen) +
spanContent.replace(/\\\||\|/g, match => (match === '|' ? '\\|' : match)) +
line.slice(j, j + runLen)
i = j + runLen
found = true
break
}
j += closeLen
}
if (!found) {
result += line.slice(i, i + runLen)
i += runLen
}
}
return result
}
/**
* Escape pipe characters inside backtick code spans on table-row lines.
* marked's `splitCells` only recognises backslash-escaped pipes (`\|`) and
* splits on every other `|`, so \`a || b\` in a table cell would be treated
* as multiple column delimiters. Escaping them here lets `splitCells` skip
* them; it already converts `\|` → `|` after splitting, so the cell content
* is restored correctly.
*/
export function preprocessTablePipes(src: string): string {
return src
.split('\n')
.map(line => {
if (!line.includes('|') || !line.includes('`')) return line
return escapeTableCellPipes(line)
})
.join('\n')
}
function collapseWhitespace(s: string) {
return (s || '').replace(/\s+/g, ' ').trim()
}
export function renderTableToMarkdown(
node: JSONContent,
h: MarkdownRendererHelpers,
options: { cellLineSeparator?: string } = {},
) {
const cellSep = options.cellLineSeparator ?? DEFAULT_CELL_LINE_SEPARATOR
if (!node || !node.content || node.content.length === 0) {
return ''
}
// Build rows: each cell is { text, isHeader, align }
const rows: { text: string; isHeader: boolean; align: TableCellAlignType | null }[][] = []
node.content.forEach(rowNode => {
const cells: { text: string; isHeader: boolean; align: TableCellAlignType | null }[] = []
if (rowNode.content) {
rowNode.content.forEach(cellNode => {
let raw = ''
if (cellNode.content && Array.isArray(cellNode.content) && cellNode.content.length > 1) {
// Render each direct child separately and join with separator so we can split again later
const parts = cellNode.content.map(child =>
h.renderChildren(child as unknown as JSONContent),
)
raw = parts.join(cellSep)
} else {
raw = cellNode.content
? h.renderChildren(cellNode.content as unknown as JSONContent[])
: ''
}
// Cells have to stay on a single line, so line breaks become <br> tags.
// The parser already turns <br> back into hard breaks, so this round trips.
const text = collapseWhitespace(
raw
.split(cellSep)
.join('\n')
.replace(/[ \t]*\r?\n[ \t]*/g, '<br>'),
)
const isHeader = cellNode.type === 'tableHeader'
const align = normalizeTableCellAlignFromAttributes(cellNode.attrs)
cells.push({ text, isHeader, align })
})
}
rows.push(cells)
})
const columnCount = rows.reduce((max, r) => Math.max(max, r.length), 0)
if (columnCount === 0) {
return ''
}
// Compute max width for each column
const colWidths = Array.from<number>({ length: columnCount }).fill(0)
rows.forEach(r => {
for (let i = 0; i < columnCount; i += 1) {
const cell = r[i]?.text || ''
const len = cell.length
if (len > colWidths[i]) {
colWidths[i] = len
}
if (colWidths[i] < 3) {
colWidths[i] = 3
}
}
})
const pad = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - s.length))
const headerRow = rows[0]
const hasHeader = headerRow.some(c => c.isHeader)
const colAlignments: Array<TableCellAlignType | null> = Array.from<TableCellAlignType | null>({
length: columnCount,
}).fill(null)
rows.forEach(r => {
for (let i = 0; i < columnCount; i += 1) {
if (!colAlignments[i] && r[i]?.align) {
colAlignments[i] = r[i].align
}
}
})
let out = '\n'
// Render header: if the document has a header row (tableHeader cells) use it,
// otherwise emit an empty header row so most Markdown parsers will recognize
// the table (this makes roundtripping to Markdown -> JSON more reliable).
const headerTexts = Array.from<number>({ length: columnCount }).map((_, i) =>
hasHeader ? (headerRow[i] && headerRow[i].text) || '' : '',
)
out += `| ${headerTexts.map((t, i) => pad(t, colWidths[i])).join(' | ')} |\n`
// Separator (use at least 3 dashes per column and include alignment markers)
out += `| ${colWidths
.map((w, index) => {
const dashCount = Math.max(3, w)
const alignment = colAlignments[index]
if (alignment === TableCellAlign.Left) {
return `:${'-'.repeat(dashCount)}`
}
if (alignment === TableCellAlign.Right) {
return `${'-'.repeat(dashCount)}:`
}
if (alignment === TableCellAlign.Center) {
return `:${'-'.repeat(dashCount)}:`
}
return '-'.repeat(dashCount)
})
.join(' | ')} |\n`
// Body rows: if we had a header, skip the first row; otherwise render all rows
const body = hasHeader ? rows.slice(1) : rows
body.forEach(r => {
out += `| ${Array.from<number>({ length: columnCount })
.fill(0)
.map((_, i) => pad((r[i] && r[i].text) || '', colWidths[i]))
.join(' | ')} |\n`
})
return out
}
export default renderTableToMarkdown

View File

@@ -0,0 +1,19 @@
import type { ParentConfig } from '@tiptap/core'
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
/**
* A string or function to determine the role of the table.
* @default 'table'
* @example () => 'table'
*/
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>['tableRole']
}) => string)
}
}

View File

@@ -0,0 +1,25 @@
import type { NodeType } from '@tiptap/pm/model'
import { Fragment } from '@tiptap/pm/model'
// ASCII whitespace only, so a non-breaking-space cell is not treated as empty.
const COLLAPSIBLE_WHITESPACE = /[ \t\r\n\f]+/g
/** Whether a cell/header element has no child elements and only collapsible whitespace. */
export function isEmptyCellElement(element: HTMLElement): boolean {
if (element.children.length > 0) {
return false
}
return (element.textContent ?? '').replace(COLLAPSIBLE_WHITESPACE, '') === ''
}
/** Builds a cell/header's minimal `block+` content, derived from the node type. */
export function fillEmptyCellContent(cellType: NodeType): Fragment {
const filled = cellType.createAndFill()
if (!filled) {
throw new Error(`[tiptap error]: "${cellType.name}" has no default content to backfill.`)
}
return filled.content
}

View File

@@ -0,0 +1,78 @@
import type { Attribute } from '@tiptap/core'
/**
* Supported table cell alignment values
*/
export enum TableCellAlign {
Left = 'left',
Right = 'right',
Center = 'center',
}
/**
* Normalize unknown input into a supported table alignment
*
* @param value - A potential alignment value
* @returns A valid TableCellAlign value or null
*/
export function normalizeTableCellAlign(value: unknown): TableCellAlign | null {
if (
value === TableCellAlign.Left ||
value === TableCellAlign.Right ||
value === TableCellAlign.Center
) {
return value
}
return null
}
/**
* Parse table cell alignment from an HTML element
*
* Prefers inline style (${"`"}text-align${"`"}) and falls back to the legacy
* ${"`"}align${"`"} attribute.
*
* @param element - The table cell/header DOM element
* @returns A valid TableCellAlign value or null
*/
export function parseAlign(element: HTMLElement): TableCellAlign | null {
const styleAlign = (element.style.textAlign || '').trim().toLowerCase()
const attrAlign = (element.getAttribute('align') || '').trim().toLowerCase()
const align = styleAlign || attrAlign
return normalizeTableCellAlign(align)
}
/**
* Normalize alignment from a generic attrs object that may include an align field
*
* @param attributes - A node attrs-like object with an optional align field
* @returns A valid TableCellAlign value or null.
*/
export function normalizeTableCellAlignFromAttributes(
attributes: { align?: TableCellAlign } | null | undefined,
): TableCellAlign | null {
return normalizeTableCellAlign(attributes?.align)
}
/**
* Create a reusable Tiptap attribute config for table alignment
*
* @returns A Tiptap Attribute definition that parses and renders table alignment
*/
export function createAlignAttribute(): Attribute {
return {
default: null,
parseHTML: (element: HTMLElement) => parseAlign(element),
renderHTML: (attributes: { align?: TableCellAlign | null }) => {
if (!attributes.align) {
return {}
}
return {
style: `text-align: ${attributes.align}`,
}
},
}
}

View File

@@ -0,0 +1,37 @@
/**
* reads the width of the `<col>` element matching a cell's column from the table's `<colgroup>`
*
* @param element - The table cell/header DOM element
* @returns - An array with the column width in pixels or null
*/
function parseColgroupWidth(element: HTMLElement): number[] | null {
const row = element.parentElement
const table = element.closest('table')
if (!row || !table) {
return null
}
const cellIndex = Array.from(row.children).indexOf(element)
const width = table.querySelectorAll('colgroup > col')[cellIndex]?.getAttribute('width')
return width ? [parseInt(width, 10)] : null
}
/**
* Parse the column width/s of a table cell/header from an HTML element.
* it prefers the `colwidth` attribute and if not-provided falls back to the `width` attribute
* of the matching `<col>` element in the table's `<colgroup>`.
*
* @param element - The table cell/header DOM element
* @returns - An array of column widths in pixels or null
*/
export function parseColwidth(element: HTMLElement): number[] | null {
const colwidth = element.getAttribute('colwidth')
if (colwidth) {
return colwidth.split(',').map(width => parseInt(width, 10))
}
return parseColgroupWidth(element)
}

View File

@@ -0,0 +1,196 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
describe('colgroup updates after column commands', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const createEditor = (resizable: boolean) =>
new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({ resizable }),
],
})
const countCols = (e: Editor) => e.view.dom.querySelectorAll('colgroup > col').length
const countCellsInFirstRow = (e: Editor) => {
const firstRow = e.view.dom.querySelector('tbody > tr')
return firstRow ? firstRow.querySelectorAll('td, th').length : 0
}
// Place cursor inside the first cell of the table so column commands have a target
const focusFirstCell = (e: Editor) => {
e.commands.focus()
e.commands.setTextSelection(3)
}
afterEach(() => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
})
describe('resizable: false (default — bug case)', () => {
it('updates <colgroup> when a column is deleted', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
expect(countCellsInFirstRow(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('updates <colgroup> when a column is added after', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnAfter()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
it('updates <colgroup> when a column is added before', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnBefore()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
})
describe('resizable: true (control — should already work)', () => {
it('updates <colgroup> when a column is deleted', () => {
editor = createEditor(true)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('updates <colgroup> when a column is added after', () => {
editor = createEditor(true)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnAfter()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
})
describe('edge cases (resizable: false)', () => {
const getTable = (e: Editor) => e.view.dom.querySelector('table') as HTMLTableElement
it('updates <table> min-width style when columns change', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
// Default cellMinWidth is 25px → 3 cols × 25 = 75px
expect(getTable(editor).style.minWidth).toBe('75px')
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(getTable(editor).style.minWidth).toBe('50px')
editor.commands.addColumnAfter()
expect(getTable(editor).style.minWidth).toBe('75px')
})
it('keeps <colgroup> consistent after mergeCells (no col change expected)', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
const initialCols = countCols(editor)
// select two adjacent cells then merge
editor.commands.focus()
editor.commands.setTextSelection({ from: 3, to: 8 })
editor.commands.mergeCells()
// mergeCells uses colspan; column count must not change
expect(countCols(editor)).toBe(initialCols)
})
it('updates <colgroup> in read-only mode when columns change programmatically', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
editor.setEditable(false)
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('preserves remaining colwidths when a column is deleted', () => {
editor = createEditor(false)
editor.commands.setContent(
'<table><tbody><tr><td colwidth="100">A</td><td colwidth="200">B</td><td colwidth="300">C</td></tr></tbody></table>',
)
const widthsOf = () =>
Array.from(editor!.view.dom.querySelectorAll<HTMLTableColElement>('colgroup > col')).map(
c => c.style.width,
)
expect(widthsOf()).toEqual(['100px', '200px', '300px'])
// place cursor in the first cell, then delete it
editor.commands.focus()
editor.commands.setTextSelection(3)
editor.commands.deleteColumn()
// remaining widths must be the original 200 and 300, not 100/200 (which would
// indicate widths got reassigned to the wrong columns after the deletion)
expect(widthsOf()).toEqual(['200px', '300px'])
})
})
})

View File

@@ -0,0 +1,64 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
const collectCellPositions = (editor: Editor): number[] => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
positions.push(pos)
}
})
return positions
}
const getCellContents = (editor: Editor): string[] => {
const contents: string[] = []
editor.state.doc.descendants(node => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
contents.push(node.textContent)
}
})
return contents
}
describe('deleteSelection with CellSelection', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content:
'<table><tbody><tr><td>A1</td><td>B1</td><td>C1</td></tr><tr><td>A2</td><td>B2</td><td>C2</td></tr></tbody></table>',
})
})
afterEach(() => {
editor.destroy()
})
it('clears every selected cell in a multi-cell CellSelection', () => {
const cells = collectCellPositions(editor)
// Row-major order: A1, B1, C1, A2, B2, C2
// Select the 2x2 block A1..B2, leaving C1 and C2 untouched.
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
const result = editor.commands.deleteSelection()
expect(result).toBe(true)
const contents = getCellContents(editor)
expect(contents[0]).toBe('') // A1
expect(contents[1]).toBe('') // B1
expect(contents[2]).toBe('C1')
expect(contents[3]).toBe('') // A2
expect(contents[4]).toBe('') // B2
expect(contents[5]).toBe('C2')
})
})

View File

@@ -0,0 +1,226 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
/** Regression tests for empty `<td>`/`<th>` insertion — https://github.com/ueberdosis/tiptap/issues/6237 */
describe('extension table empty cell/header parsing', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const createTableEditor = () =>
new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content: '<p></p>',
})
afterEach(() => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
})
it('inserts a table with an empty cell without throwing, backfilling an empty paragraph', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts a table with an empty header cell without throwing, backfilling an empty paragraph', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><th></th></tr></table>')
}).not.toThrow()
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.type).toBe('tableHeader')
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts an empty cell via insertContent without throwing', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContent('<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts an empty header cell via insertContent without throwing', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContent('<table><tr><th></th></tr></table>')
}).not.toThrow()
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.type).toBe('tableHeader')
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
})
it('backfills a whitespace-only cell when preserveWhitespace is false', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><td> </td></tr></table>', {
parseOptions: { preserveWhitespace: false },
})
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it.each([
['default (no parseOptions)', undefined],
['preserveWhitespace: true', { preserveWhitespace: true as const }],
['preserveWhitespace: "full"', { preserveWhitespace: 'full' as const }],
])('backfills a whitespace-only cell identically to setContent — %s', (_label, parseOptions) => {
const tableHTML = '<table><tr><td> </td></tr></table>'
// setContent collapses a spaces-only cell too, so no whitespace is lost.
const setContentEditor = createTableEditor()
setContentEditor.commands.setContent(tableHTML)
const reference = setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
setContentEditor.destroy()
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, tableHTML, parseOptions ? { parseOptions } : undefined)
}).not.toThrow()
const cellContent = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
expect(cellContent).toEqual(reference)
expect(cellContent).toEqual([{ type: 'paragraph' }])
})
it('produces the same empty cell as setContent does', () => {
const tableHTML = '<table><tr><td></td></tr></table>'
const setContentEditor = createTableEditor()
setContentEditor.commands.setContent(tableHTML)
const setContentCell =
setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
setContentEditor.destroy()
editor = createTableEditor()
editor.commands.insertContentAt(0, tableHTML)
const insertContentCell = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
expect(insertContentCell).toEqual(setContentCell)
expect(insertContentCell).toEqual([{ type: 'paragraph' }])
})
it('preserves a non-breaking-space cell instead of treating it as empty', () => {
editor = createTableEditor()
// trim() would strip the NBSP; it must survive as text, not be backfilled.
editor.commands.insertContentAt(0, '<table><tr><td>&nbsp;</td></tr></table>')
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
])
})
it('backfills using the schema block when no paragraph node exists', () => {
const el = createEditorEl()
// A valid schema whose only block is Heading (no Paragraph).
const headingOnlyEditor = new Editor({
element: el,
extensions: [Document, Text, Heading, TableCell, TableHeader, TableRow, Table],
content: '<h1>x</h1>',
})
expect(() => {
headingOnlyEditor.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = headingOnlyEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
// Fill uses the schema's block (heading here), not a hard-coded paragraph.
expect(cellNode?.content?.[0]?.type).toBe('heading')
headingOnlyEditor.destroy()
el.remove()
})
it('keeps non-empty cell content intact (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(0, '<table><tr><td>hello</td></tr></table>')
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
])
})
it('keeps non-empty header content intact (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(0, '<table><tr><th>hello</th></tr></table>')
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
])
})
it('still parses colspan/colwidth on a multi-cell table (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(
0,
'<table><tr><td colwidth="200">Name</td><td colspan="2">Description</td></tr></table>',
)
const row = editor.getJSON().content?.[0]?.content?.[0]
expect(row?.content?.[0]?.attrs?.colwidth).toEqual([200])
expect(row?.content?.[1]?.attrs?.colspan).toBe(2)
})
})

View File

@@ -0,0 +1,131 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
const callClipboardTextSerializer = (editor: Editor): string => {
const serializer = editor.view.someProp('clipboardTextSerializer') as
| ((slice: ReturnType<typeof editor.state.selection.content>) => string)
| undefined
if (!serializer) {
throw new Error('clipboardTextSerializer prop not registered')
}
return serializer(editor.state.selection.content())
}
const collectCellPositions = (editor: Editor): number[] => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
positions.push(pos)
}
})
return positions
}
describe('Table plain-text clipboard serialization', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('only serializes selected cells when CellSelection spans non-adjacent rows', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td></tr>
<tr><td>A2</td><td>B2</td></tr>
<tr><td>A3</td><td>B3</td></tr>
</tbody>
</table>
`,
})
// Document order: A1, B1, A2, B2, A3, B3
const cells = collectCellPositions(editor)
// Select column 0 across all three rows: A1 -> A3.
// ProseMirror's CellSelection turns this into a rectangular selection
// covering A1, A2, A3 only. The bug was that the plain-text serializer
// also pulled in B1 and B2 because they sit between A1 and A3 in doc order.
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
const result = callClipboardTextSerializer(editor)
expect(result).toContain('A1')
expect(result).toContain('A2')
expect(result).toContain('A3')
expect(result).not.toContain('B1')
expect(result).not.toContain('B2')
expect(result).not.toContain('B3')
})
it('emits cells in document order even when CellSelection is built bottom-up', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td></tr>
<tr><td>A2</td><td>B2</td></tr>
<tr><td>A3</td><td>B3</td></tr>
</tbody>
</table>
`,
})
const cells = collectCellPositions(editor)
// Reverse selection: anchor on the bottom cell, head on the top cell.
// Simulates a user dragging upward. ranges may not be in doc order.
editor.chain().focus().setCellSelection({ anchorCell: cells[4], headCell: cells[0] }).run()
const result = callClipboardTextSerializer(editor)
expect(result.indexOf('A1')).toBeLessThan(result.indexOf('A2'))
expect(result.indexOf('A2')).toBeLessThan(result.indexOf('A3'))
})
it('serializes adjacent cells in a row without dropping content', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td><td>C1</td></tr>
<tr><td>A2</td><td>B2</td><td>C2</td></tr>
</tbody>
</table>
`,
})
// Document order: A1, B1, C1, A2, B2, C2
const cells = collectCellPositions(editor)
// Select A1 and B1 (same row, adjacent).
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[1] }).run()
const result = callClipboardTextSerializer(editor)
expect(result).toContain('A1')
expect(result).toContain('B1')
expect(result).not.toContain('C1')
expect(result).not.toContain('A2')
})
})

View File

@@ -0,0 +1,306 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
const countCells = (editor: Editor, selector: string) =>
editor.view.dom.querySelectorAll(selector).length
const firstTableBounds = (editor: Editor) => {
let start = -1
let end = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'table' && start === -1) {
start = pos
end = pos + node.nodeSize
}
})
return { start, end }
}
const selectFirstTwoHeaderCells = (editor: Editor) => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableHeader') {
positions.push(pos)
}
})
editor
.chain()
.focus()
.setCellSelection({ anchorCell: positions[0], headCell: positions[1] })
.run()
}
describe('Table commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: '',
})
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
})
afterEach(() => {
editor.destroy()
})
it('adds a table with three columns and three rows', () => {
expect(countCells(editor, 'table')).toBe(1)
expect(countCells(editor, 'table tr')).toBe(3)
expect(countCells(editor, 'table th')).toBe(3)
expect(countCells(editor, 'table td')).toBe(6)
})
it('adds & deletes columns', () => {
editor.commands.addColumnBefore()
expect(countCells(editor, 'table th')).toBe(4)
editor.commands.addColumnAfter()
expect(countCells(editor, 'table th')).toBe(5)
editor.commands.deleteColumn()
editor.commands.deleteColumn()
expect(countCells(editor, 'table th')).toBe(3)
})
it('adds & deletes rows', () => {
editor.commands.addRowBefore()
expect(countCells(editor, 'table tr')).toBe(4)
editor.commands.addRowAfter()
expect(countCells(editor, 'table tr')).toBe(5)
editor.commands.deleteRow()
editor.commands.deleteRow()
expect(countCells(editor, 'table tr')).toBe(3)
})
it('keeps the cursor inside the table when deleting the last row', () => {
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell') {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteRow()
expect(countCells(editor, 'table tr')).toBe(2)
expect(editor.isActive('table')).toBe(true)
})
it('keeps the cursor inside the table when deleting the last column', () => {
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell') {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteColumn()
expect(countCells(editor, 'table th')).toBe(2)
expect(editor.isActive('table')).toBe(true)
})
it('keeps the cursor in the edited table when deleting the last row with a table below', () => {
editor.commands.insertContentAt(
editor.state.doc.content.size,
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
)
const { end: firstTableEnd } = firstTableBounds(editor)
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteRow()
const { start, end } = firstTableBounds(editor)
const cursor = editor.state.selection.from
expect(editor.isActive('table')).toBe(true)
expect(cursor).toBeGreaterThan(start)
expect(cursor).toBeLessThan(end)
})
it('keeps the cursor in the edited table when deleting the last column with a table below', () => {
editor.commands.insertContentAt(
editor.state.doc.content.size,
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
)
const { end: firstTableEnd } = firstTableBounds(editor)
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteColumn()
const { start, end } = firstTableBounds(editor)
const cursor = editor.state.selection.from
expect(editor.isActive('table')).toBe(true)
expect(cursor).toBeGreaterThan(start)
expect(cursor).toBeLessThan(end)
})
it('deletes the table', () => {
editor.commands.deleteTable()
expect(countCells(editor, 'table')).toBe(0)
})
it('merges cells', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(countCells(editor, 'table th')).toBe(2)
})
it('splits cells', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(countCells(editor, 'table th')).toBe(2)
editor.commands.splitCell()
expect(countCells(editor, 'table th')).toBe(3)
})
it('toggles header columns', () => {
editor.commands.toggleHeaderColumn()
expect(countCells(editor, 'table th')).toBe(5)
})
it('toggles header row', () => {
editor.commands.toggleHeaderRow()
expect(countCells(editor, 'table th')).toBe(0)
})
it('merges and splits via mergeOrSplit', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(editor.view.dom.querySelector('th[colspan="2"]')).not.toBeNull()
editor.commands.mergeOrSplit()
expect(editor.view.dom.querySelector('th[colspan="2"]')).toBeNull()
})
it('creates a 1x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(1)
expect(countCells(editor, 'tr')).toBe(1)
})
it('creates a 3x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(3)
expect(countCells(editor, 'tr')).toBe(1)
})
it('creates a 1x3 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(3)
expect(countCells(editor, 'tr')).toBe(3)
})
it('creates a 1x3 table with header row', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: true })
expect(countCells(editor, 'th')).toBe(1)
expect(countCells(editor, 'td')).toBe(2)
expect(countCells(editor, 'tr')).toBe(3)
})
it('creates a 3x3 table with defaults', () => {
editor.commands.clearContent()
editor.commands.insertTable()
expect(countCells(editor, 'th')).toBe(3)
expect(countCells(editor, 'td')).toBe(6)
expect(countCells(editor, 'tr')).toBe(3)
})
it('sets a minimum width on colgroups', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
const firstCol = editor.view.dom.querySelector('col')
expect(firstCol?.getAttribute('style')).toBe('min-width: 25px;')
})
it('generates correct markup for a 1x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
expect(editor.getHTML()).toBe(
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
)
})
it('generates correct markup for a 1x1 table with header', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: true })
expect(editor.getHTML()).toBe(
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
)
})
it('moves focus between cells with goToNextCell / goToPreviousCell', () => {
let firstHeaderPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableHeader' && firstHeaderPos === -1) {
firstHeaderPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(firstHeaderPos + 1)
.insertContent('Column 1')
.run()
editor.commands.goToNextCell()
editor.commands.insertContent('Column 2')
editor.commands.goToPreviousCell()
const ths = editor.view.dom.querySelectorAll('table th')
expect(ths[0].textContent).toContain('Column 1')
expect(ths[1].textContent).toContain('Column 2')
})
})

View File

@@ -0,0 +1,275 @@
import { Code } from '@tiptap/extension-code'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
describe('table markdown — inline code with pipe characters', () => {
const manager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Code, TableKit],
})
it('should parse `||` inside a code span as a single cell', () => {
const markdown = '| Header |\n| ------ |\n| `||` |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(1)
const textNode = bodyRow[0]?.content?.[0]?.content?.[0]
expect(textNode?.text).toBe('||')
expect(textNode?.marks?.[0]?.type).toBe('code')
})
it('should parse `a || b` inside a code span as a single cell', () => {
const markdown = '| H | H | H |\n| - | - | - |\n| `||` | or | `a || b` |'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const bodyRow = table?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(3)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('||')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
expect(cell2?.text).toBe('or')
expect(cell2?.marks).toBeUndefined()
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
expect(cell3?.text).toBe('a || b')
expect(cell3?.marks?.[0]?.type).toBe('code')
})
it('should parse `&&` inside a code span correctly (regression guard)', () => {
const markdown = '| H | H | H |\n| - | - | - |\n| `&&` | and | `a && b` |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(3)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('&&')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
expect(cell3?.text).toBe('a && b')
expect(cell3?.marks?.[0]?.type).toBe('code')
})
it('should roundtrip table with inline code containing pipes', () => {
const markdown = '| H |\n| - |\n| `a || b` |'
const parsed = manager.parse(markdown)
const serialized = manager.serialize(parsed)
const reparsed = manager.parse(serialized)
const cell = reparsed.content?.[0]?.content?.[1]?.content?.[0]?.content?.[0]?.content?.[0]
expect(cell?.text).toBe('a || b')
expect(cell?.marks?.[0]?.type).toBe('code')
})
it('should not affect table cells without backtick spans', () => {
const markdown = '| a | b |\n| - | - |\n| 1 | 2 |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(2)
expect(bodyRow[0]?.content?.[0]?.content?.[0]?.text).toBe('1')
expect(bodyRow[1]?.content?.[0]?.content?.[0]?.text).toBe('2')
})
it('should not corrupt content that follows a table with inline code containing pipes', () => {
const markdown = '| H |\n| - |\n| `a || b` |\n\nParagraph after.'
const parsed = manager.parse(markdown)
expect(parsed.content).toHaveLength(2)
expect(parsed.content?.[0]?.type).toBe('table')
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
})
it('should not affect inline code with pipes outside of tables', () => {
const markdown = 'Use `a || b` for logical or.'
const parsed = manager.parse(markdown)
// Must parse as a paragraph, not accidentally as a table
const block = parsed.content?.[0]
expect(block?.type).toBe('paragraph')
// The code mark must be present on one of the inline nodes
const codeNode = block?.content?.find(n => n.marks?.some(m => m.type === 'code'))
expect(codeNode).toBeDefined()
expect(codeNode?.marks?.[0]?.type).toBe('code')
})
it('should treat a setext heading with pipe in title as a heading, not a table', () => {
// '`a | b`\n---' is a setext h2, not a table. Our tokenizer must not
// activate on it and must not contaminate the inline queue via
// helper.blockTokens (which would turn '|' into '\|' in the output).
const markdown = '`a | b`\n---'
const parsed = manager.parse(markdown)
const block = parsed.content?.[0]
expect(block?.type).toBe('heading')
// The pipe must be preserved verbatim inside the code span.
const codeNode = block?.content?.find((n: any) => n.marks?.some((m: any) => m.type === 'code'))
expect(codeNode?.text).toBe('a | b')
})
it('should parse `||` inside a code span in a pipeless table as a single cell', () => {
const markdown = 'Expression | Meaning\n---------- | -------\n`||` | or'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const bodyRow = table?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(2)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('||')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
expect(cell2?.text).toBe('or')
})
it('should not corrupt content that follows a pipeless table with inline code containing pipes', () => {
const markdown = 'H1 | H2\n-- | --\n`a || b` | x\n\nParagraph after.'
const parsed = manager.parse(markdown)
expect(parsed.content).toHaveLength(2)
expect(parsed.content?.[0]?.type).toBe('table')
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
})
it('should include a no-pipe body row as a table row, matching marked native behavior', () => {
// marked treats any non-blank line before the blank line as a table body row,
// even if it contains no pipe. Our tokenizer must not cut it off into a paragraph.
const markdown = 'A | B\n--- | ---\n`a || b` | c\njust some text\n\nAfter'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
// 2 body rows: "`a || b` | c" and "just some text"
expect(table?.content).toHaveLength(3) // header row + 2 body rows
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('After')
})
})
describe('table markdown alignment', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TableKit],
})
it('should parse and serialize left/right/center table alignment', () => {
const markdown = `| left | right | center |
| :---- | ----: | :-----: |
| a | b | c |`
const parsed = markdownManager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const headerCells = table?.content?.[0]?.content || []
expect(headerCells[0]?.attrs?.align).toBe('left')
expect(headerCells[1]?.attrs?.align).toBe('right')
expect(headerCells[2]?.attrs?.align).toBe('center')
const serialized = markdownManager.serialize(parsed)
expect(serialized).toContain('| left | right | center |')
expect(serialized).toMatch(/\|\s*:[-]+\s*\|\s*[-]+:\s*\|\s*:[-]+:\s*\|/)
})
})
describe('table markdown line breaks', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, HardBreak, TableKit],
})
const cell = (content: Record<string, unknown>[]) => ({
type: 'tableCell',
content,
})
const tableDoc = (cells: ReturnType<typeof cell>[]) => ({
type: 'doc',
content: [
{
type: 'table',
content: [{ type: 'tableRow', content: cells }],
},
],
})
it('keeps hard breaks in cells as <br>', () => {
const doc = tableDoc([
cell([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'foo' },
{ type: 'hardBreak' },
{ type: 'text', text: 'bar' },
],
},
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('foo<br>bar')
})
it('keeps consecutive hard breaks as separate <br> tags', () => {
const doc = tableDoc([
cell([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'foo' },
{ type: 'hardBreak' },
{ type: 'hardBreak' },
{ type: 'text', text: 'bar' },
],
},
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('foo<br><br>bar')
})
it('round trips <br> inside cells', () => {
const markdown = `| desc |
| --- |
| foo<br>bar |`
const parsed = markdownManager.parse(markdown)
const parsedText = JSON.stringify(parsed)
expect(parsedText).toContain('"hardBreak"')
const serialized = markdownManager.serialize(parsed)
expect(serialized).toContain('foo<br>bar')
})
it('joins multiple paragraphs in a cell with <br>', () => {
const doc = tableDoc([
cell([
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('one<br>two')
expect(serialized).not.toContain('\u001F')
})
it('leaves cells without breaks alone', () => {
const doc = tableDoc([
cell([{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('| plain |')
expect(serialized).not.toContain('<br>')
})
})

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite-plus'
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
const entries = [
'src/table/index.ts',
'src/cell/index.ts',
'src/header/index.ts',
'src/kit/index.ts',
'src/row/index.ts',
'src/index.ts',
]
export default defineConfig({
pack: entries.map(entry => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: { sourcemap: true },
sourcemap: true,
target: 'es2019',
format: ['esm', 'cjs'],
deps: { neverBundle: [/^[^./]/] },
outExtensions: tsupCompatibleExtensions,
})),
})