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 = '
Cell
' + + 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: '
Cell
', + }) + + 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 = '
Cell
' + + 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 = '
helloworld
' 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 = '
Cell
' + + 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: '
Cell
', + }) + + 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 = '
Cell
' + + 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( - '

', + '

', ) })