Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-09-23-ordered-list-courtesy-titles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-list': patch
---

Courtesy titles such as "Ms." and "Dr." are no longer parsed as ordered-list markers. Numbered, lettered, and roman lists are unchanged.
5 changes: 5 additions & 0 deletions .changeset/omit-default-span-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': patch
---

Table cells and headers no longer render the default `colspan="1"` and `rowspan="1"` attributes. Cells that actually span still render them, matching how prosemirror-tables serializes spans.
15 changes: 12 additions & 3 deletions packages/core/__tests__/splitBlock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Editor, Node } 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 { afterEach, describe, expect, it } from 'vite-plus/test'

const Callout = Node.create({
name: 'callout',
Expand All @@ -21,12 +21,21 @@ const Callout = Node.create({
},
})

const createEditor = (content: string) =>
new Editor({
let editor: Editor

const createEditor = (content: string) => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Callout],
content,
})

return editor
}

afterEach(() => {
editor.destroy()
})

describe('splitBlock', () => {
it('splits a paragraph at the cursor', () => {
const editor = createEditor('<p>onetwo</p>')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { OrderedListItem } from './utils.js'
import { collectOrderedListItems, ORDERED_LIST_ITEM_REGEX } from './utils.js'
import { collectOrderedListItems, matchOrderedListItemLine } from './utils.js'

const ORDERED_LIST_SCAN_WINDOW = 64

Expand Down Expand Up @@ -39,7 +39,7 @@ export function collectOrderedListItemsFromSource(
const firstLine = firstLineEnd === -1 ? source : source.slice(0, firstLineEnd)

// Every block start is offered here, so reject non-lists before reading further.
if (!ORDERED_LIST_ITEM_REGEX.test(firstLine)) {
if (!matchOrderedListItemLine(firstLine)) {
return [[], 0, []]
}

Expand Down
24 changes: 20 additions & 4 deletions packages/extension-list/src/ordered-list/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ export const ORDERED_LIST_ITEM_REGEX = new RegExp(
`^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.*)$`,
)

// Mixed-case titles match the pattern but are not list markers.
export function matchOrderedListItemLine(line: string): RegExpMatchArray | undefined {
const match = line.match(ORDERED_LIST_ITEM_REGEX)

if (!match) {
return undefined
}

const [, , marker] = match

if (/^\d+$/.test(marker) || detectMarkerType(marker)) {
return match
}

return undefined
}

/**
* Matches any line that starts with whitespace (indented content).
* Used to identify continuation content that belongs to a list item.
Expand Down Expand Up @@ -57,7 +74,7 @@ export interface OrderedListItem {
}

function isOrderedListMarkerLine(line: string): boolean {
return ORDERED_LIST_ITEM_REGEX.test(line.trimStart())
return matchOrderedListItemLine(line.trimStart()) !== undefined
}

function isBlockContentLine(line: string): boolean {
Expand Down Expand Up @@ -131,7 +148,7 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu

while (currentLineIndex < lines.length) {
const line = lines[currentLineIndex]
const match = line.match(ORDERED_LIST_ITEM_REGEX)
const match = matchOrderedListItemLine(line)

if (!match) {
break
Expand All @@ -152,10 +169,9 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
// Collect continuation lines for this item (but NOT nested list items)
while (nextLineIndex < lines.length) {
const nextLine = lines[nextLineIndex]
const nextMatch = nextLine.match(ORDERED_LIST_ITEM_REGEX)

// If it's another list item (nested or not), stop collecting
if (nextMatch) {
if (matchOrderedListItemLine(nextLine)) {
break
}

Expand Down
14 changes: 14 additions & 0 deletions packages/extension-list/tests/integration/orderedListType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,20 @@ describe('OrderedList type attribute', () => {
expect(json.content[0].type).not.toBe('orderedList')
})

it.each(['Ms. Smith arrived today.', 'Mr. Jones arrived today.', 'Dr. House said hello.'])(
'does not parse a courtesy title as an ordered list (%s)',
input => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})

const json = markdownManager.parse(input)

expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content?.[0]).toMatchObject({ type: 'text', text: input })
},
)

it('round-trips multi-letter alpha markers beyond 26 items', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
Expand Down
60 changes: 60 additions & 0 deletions packages/extension-table/src/cell/table-cell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,66 @@ describe('extension table cell', () => {
getEditorEl()?.remove()
})

it('should not serialize the default colspan and rowspan', () => {
const content = '<table><tbody><tr><td>Cell</td></tr></tbody></table>'

editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
content,
})

const html = editor.getHTML()

expect(html).toContain('<td><p>Cell</p></td>')
expect(html).not.toContain('colspan="1"')
expect(html).not.toContain('rowspan="1"')

editor?.destroy()
getEditorEl()?.remove()
})

it('should not render configured spans for a cell with default spans', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell.configure({ HTMLAttributes: { colspan: 2, rowspan: 2 } }),
TableHeader,
TableRow,
Table,
],
content: '<table><tr><td>Cell</td></tr></table>',
})

expect(editor.getHTML()).toContain('<td><p>Cell</p></td>')
expect(editor.view.dom.querySelector('td')?.hasAttribute('colspan')).toBe(false)
expect(editor.view.dom.querySelector('td')?.hasAttribute('rowspan')).toBe(false)

editor.destroy()
getEditorEl()?.remove()
})

it('should keep colspan and rowspan when a cell actually spans', () => {
const content = '<table><tbody><tr><td colspan="2" rowspan="3">Cell</td></tr></tbody></table>'

editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
content,
})

const html = editor.getHTML()

expect(html).toContain('colspan="2"')
expect(html).toContain('rowspan="3"')

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>'
Expand Down
8 changes: 7 additions & 1 deletion packages/extension-table/src/cell/table-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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'
import { omitDefaultSpanAttribute } from '../utils/omitDefaultSpanAttribute.js'

export interface TableCellOptions {
/**
Expand Down Expand Up @@ -63,6 +64,11 @@ export const TableCell = Node.create<TableCellOptions>({
},

renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)

omitDefaultSpanAttribute(attributes, HTMLAttributes, 'colspan')
omitDefaultSpanAttribute(attributes, HTMLAttributes, 'rowspan')

return ['td', attributes, 0]
},
})
60 changes: 60 additions & 0 deletions packages/extension-table/src/header/table-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,64 @@ describe('extension table header', () => {
editor?.destroy()
getEditorEl()?.remove()
})

it('should not serialize the default colspan and rowspan', () => {
const content = '<table><tbody><tr><th>Cell</th></tr></tbody></table>'

editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
content,
})

const html = editor.getHTML()

expect(html).toContain('<th><p>Cell</p></th>')
expect(html).not.toContain('colspan="1"')
expect(html).not.toContain('rowspan="1"')

editor?.destroy()
getEditorEl()?.remove()
})

it('should not render configured spans for a header with default spans', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader.configure({ HTMLAttributes: { colspan: 2, rowspan: 2 } }),
TableRow,
Table,
],
content: '<table><tr><th>Cell</th></tr></table>',
})

expect(editor.getHTML()).toContain('<th><p>Cell</p></th>')
expect(editor.view.dom.querySelector('th')?.hasAttribute('colspan')).toBe(false)
expect(editor.view.dom.querySelector('th')?.hasAttribute('rowspan')).toBe(false)

editor.destroy()
getEditorEl()?.remove()
})

it('should keep colspan and rowspan when a header actually spans', () => {
const content = '<table><tbody><tr><th colspan="2" rowspan="3">Cell</th></tr></tbody></table>'

editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
content,
})

const html = editor.getHTML()

expect(html).toContain('colspan="2"')
expect(html).toContain('rowspan="3"')

editor?.destroy()
getEditorEl()?.remove()
})
})
8 changes: 7 additions & 1 deletion packages/extension-table/src/header/table-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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'
import { omitDefaultSpanAttribute } from '../utils/omitDefaultSpanAttribute.js'

export interface TableHeaderOptions {
/**
Expand Down Expand Up @@ -63,6 +64,11 @@ export const TableHeader = Node.create<TableHeaderOptions>({
},

renderHTML({ HTMLAttributes }) {
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)

omitDefaultSpanAttribute(attributes, HTMLAttributes, 'colspan')
omitDefaultSpanAttribute(attributes, HTMLAttributes, 'rowspan')

return ['th', attributes, 0]
},
})
15 changes: 15 additions & 0 deletions packages/extension-table/src/utils/omitDefaultSpanAttribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function omitDefaultSpanAttribute(
attributes: Record<string, unknown>,
renderedAttributes: Record<string, unknown>,
name: 'colspan' | 'rowspan',
): void {
if (!(name in renderedAttributes)) {
return
}

const value = renderedAttributes[name]

if (value === 1 || value === '1' || value === null || value === undefined) {
delete attributes[name]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,15 @@ describe('Table commands', () => {
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>',
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td><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>',
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><th><p></p></th></tr></tbody></table>',
)
})

Expand Down
Loading