diff --git a/.changeset/2026-09-23-ordered-list-courtesy-titles.md b/.changeset/2026-09-23-ordered-list-courtesy-titles.md
new file mode 100644
index 0000000000..31efc6c5ad
--- /dev/null
+++ b/.changeset/2026-09-23-ordered-list-courtesy-titles.md
@@ -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.
diff --git a/.changeset/omit-default-span-attributes.md b/.changeset/omit-default-span-attributes.md
new file mode 100644
index 0000000000..c1099a83bd
--- /dev/null
+++ b/.changeset/omit-default-span-attributes.md
@@ -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.
diff --git a/packages/core/__tests__/splitBlock.spec.ts b/packages/core/__tests__/splitBlock.spec.ts
index 34352c87e0..ed526e102c 100644
--- a/packages/core/__tests__/splitBlock.spec.ts
+++ b/packages/core/__tests__/splitBlock.spec.ts
@@ -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',
@@ -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('
onetwo
')
diff --git a/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts b/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts
index 976085ebfb..962928b80c 100644
--- a/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts
+++ b/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts
@@ -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
@@ -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, []]
}
diff --git a/packages/extension-list/src/ordered-list/utils.ts b/packages/extension-list/src/ordered-list/utils.ts
index d0a3b84aa4..244c026124 100644
--- a/packages/extension-list/src/ordered-list/utils.ts
+++ b/packages/extension-list/src/ordered-list/utils.ts
@@ -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.
@@ -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 {
@@ -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
@@ -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
}
diff --git a/packages/extension-list/tests/integration/orderedListType.test.ts b/packages/extension-list/tests/integration/orderedListType.test.ts
index ba1439d093..a04cbdb8e2 100644
--- a/packages/extension-list/tests/integration/orderedListType.test.ts
+++ b/packages/extension-list/tests/integration/orderedListType.test.ts
@@ -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],
diff --git a/packages/extension-table/src/cell/table-cell.test.ts b/packages/extension-table/src/cell/table-cell.test.ts
index b2f9a04c07..26430f0d3e 100644
--- a/packages/extension-table/src/cell/table-cell.test.ts
+++ b/packages/extension-table/src/cell/table-cell.test.ts
@@ -131,6 +131,66 @@ describe('extension table cell', () => {
getEditorEl()?.remove()
})
+ it('should not serialize the default colspan and rowspan', () => {
+ const content = ''
+
+ editor = new Editor({
+ element: createEditorEl(),
+ extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
+ content,
+ })
+
+ const html = editor.getHTML()
+
+ expect(html).toContain('Cell | ')
+ 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: '',
+ })
+
+ expect(editor.getHTML()).toContain('Cell | ')
+ 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 = ''
+
+ 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 =
''
diff --git a/packages/extension-table/src/cell/table-cell.ts b/packages/extension-table/src/cell/table-cell.ts
index 2ead19ff19..97755d1804 100644
--- a/packages/extension-table/src/cell/table-cell.ts
+++ b/packages/extension-table/src/cell/table-cell.ts
@@ -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 {
/**
@@ -63,6 +64,11 @@ export const TableCell = Node.create({
},
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]
},
})
diff --git a/packages/extension-table/src/header/table-header.test.ts b/packages/extension-table/src/header/table-header.test.ts
index e9544b8429..aaa10f08c9 100644
--- a/packages/extension-table/src/header/table-header.test.ts
+++ b/packages/extension-table/src/header/table-header.test.ts
@@ -130,4 +130,64 @@ describe('extension table header', () => {
editor?.destroy()
getEditorEl()?.remove()
})
+
+ it('should not serialize the default colspan and rowspan', () => {
+ const content = ''
+
+ editor = new Editor({
+ element: createEditorEl(),
+ extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table],
+ content,
+ })
+
+ const html = editor.getHTML()
+
+ expect(html).toContain('Cell | ')
+ 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: '',
+ })
+
+ expect(editor.getHTML()).toContain('Cell | ')
+ 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 = ''
+
+ 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()
+ })
})
diff --git a/packages/extension-table/src/header/table-header.ts b/packages/extension-table/src/header/table-header.ts
index fb5ce90603..9cce9b08d8 100644
--- a/packages/extension-table/src/header/table-header.ts
+++ b/packages/extension-table/src/header/table-header.ts
@@ -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 {
/**
@@ -63,6 +64,11 @@ export const TableHeader = Node.create({
},
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]
},
})
diff --git a/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts b/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts
new file mode 100644
index 0000000000..f5b8296c59
--- /dev/null
+++ b/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts
@@ -0,0 +1,15 @@
+export function omitDefaultSpanAttribute(
+ attributes: Record,
+ renderedAttributes: Record,
+ 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]
+ }
+}
diff --git a/packages/extension-table/tests/integration/tableCommands.test.ts b/packages/extension-table/tests/integration/tableCommands.test.ts
index 4748849a55..0cb473d434 100644
--- a/packages/extension-table/tests/integration/tableCommands.test.ts
+++ b/packages/extension-table/tests/integration/tableCommands.test.ts
@@ -268,7 +268,7 @@ describe('Table commands', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
expect(editor.getHTML()).toBe(
- '',
+ '',
)
})
@@ -276,7 +276,7 @@ describe('Table commands', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: true })
expect(editor.getHTML()).toBe(
- '',
+ '',
)
})