@tiptap/extension-list 3.26.1 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ const ROMAN_NUMERALS: [number, string][] = [
2
+ [1000, 'm'],
3
+ [900, 'cm'],
4
+ [500, 'd'],
5
+ [400, 'cd'],
6
+ [100, 'c'],
7
+ [90, 'xc'],
8
+ [50, 'l'],
9
+ [40, 'xl'],
10
+ [10, 'x'],
11
+ [9, 'ix'],
12
+ [5, 'v'],
13
+ [4, 'iv'],
14
+ [1, 'i'],
15
+ ]
16
+
17
+ const ALPHA_NUMERALS = 'abcdefghijklmnopqrstuvwxyz'
18
+
19
+ /** Alpha list markers support at most 2 letters (a–z, aa–zz), matching {@link fromAlpha}. */
20
+ export const ORDERED_LIST_ALPHA_MARKER_PATTERN = '[a-zA-Z]{1,2}'
21
+
22
+ /**
23
+ * Marker segment for ordered list lines: numeric, roman, or 1–2 letter alpha.
24
+ * Roman is matched before alpha so "iii" is roman; invalid romans like "aa" fall through to alpha.
25
+ */
26
+ export const ORDERED_LIST_MARKER_PATTERN = String.raw`\d+|[ivxlcdmIVXLCDM]+|${ORDERED_LIST_ALPHA_MARKER_PATTERN}`
27
+
28
+ /**
29
+ * Convert a number to lowercase roman numerals.
30
+ * @example toRoman(1) // 'i'
31
+ * @example toRoman(4) // 'iv'
32
+ */
33
+ export function toRoman(num: number): string {
34
+ let remaining = num
35
+ let result = ''
36
+
37
+ for (const [value, numeral] of ROMAN_NUMERALS) {
38
+ while (remaining >= value) {
39
+ result += numeral
40
+ remaining -= value
41
+ }
42
+ }
43
+
44
+ return result
45
+ }
46
+
47
+ /**
48
+ * Convert a number to uppercase roman numerals.
49
+ * @example toRomanUpper(1) // 'I'
50
+ * @example toRomanUpper(4) // 'IV'
51
+ */
52
+ export function toRomanUpper(num: number): string {
53
+ return toRoman(num).toUpperCase()
54
+ }
55
+
56
+ function fromRoman(roman: string): number {
57
+ const lower = roman.toLowerCase()
58
+ let index = 0
59
+ let result = 0
60
+
61
+ while (index < lower.length) {
62
+ let matched = false
63
+
64
+ for (const [value, numeral] of ROMAN_NUMERALS) {
65
+ if (lower.startsWith(numeral, index)) {
66
+ result += value
67
+ index += numeral.length
68
+ matched = true
69
+ break
70
+ }
71
+ }
72
+
73
+ if (!matched) {
74
+ return 0
75
+ }
76
+ }
77
+
78
+ return result
79
+ }
80
+
81
+ function isValidRoman(marker: string): boolean {
82
+ if (!/^[ivxlcdmIVXLCDM]+$/.test(marker)) {
83
+ return false
84
+ }
85
+
86
+ const value = fromRoman(marker)
87
+
88
+ if (value <= 0) {
89
+ return false
90
+ }
91
+
92
+ const expected = marker === marker.toLowerCase() ? toRoman(value) : toRomanUpper(value)
93
+
94
+ return expected === marker
95
+ }
96
+
97
+ function fromAlpha(marker: string): number {
98
+ const lower = marker.toLowerCase()
99
+
100
+ if (lower.length === 1) {
101
+ return lower.charCodeAt(0) - 'a'.charCodeAt(0) + 1
102
+ }
103
+
104
+ if (lower.length === 2) {
105
+ const first = lower.charCodeAt(0) - 'a'.charCodeAt(0)
106
+ const second = lower.charCodeAt(1) - 'a'.charCodeAt(0)
107
+
108
+ return (first + 1) * 26 + second + 1
109
+ }
110
+
111
+ return 0
112
+ }
113
+
114
+ function toRomanAlpha(num: number): string {
115
+ if (num <= 26) {
116
+ return ALPHA_NUMERALS[num - 1]
117
+ }
118
+
119
+ const first = Math.floor((num - 1) / 26) - 1
120
+ const second = (num - 1) % 26
121
+
122
+ if (first < 0) {
123
+ return ALPHA_NUMERALS[second]
124
+ }
125
+
126
+ return ALPHA_NUMERALS[first] + ALPHA_NUMERALS[second]
127
+ }
128
+
129
+ /**
130
+ * Extract the list marker type from a marker string.
131
+ * Supports "1", "a", "A", "i", "I" marker styles.
132
+ *
133
+ * @param marker The text content of the list marker (e.g. "a", "1", "iii", "b")
134
+ * @returns The normalized type string, or undefined for default numeric type
135
+ */
136
+ export function detectMarkerType(marker: string): string | undefined {
137
+ if (!marker || /^\d+$/.test(marker)) {
138
+ return undefined
139
+ }
140
+
141
+ if (isValidRoman(marker)) {
142
+ return marker === marker.toLowerCase() ? 'i' : 'I'
143
+ }
144
+
145
+ if (/^[a-z]{1,2}$/.test(marker)) {
146
+ return 'a'
147
+ }
148
+
149
+ if (/^[A-Z]{1,2}$/.test(marker)) {
150
+ return 'A'
151
+ }
152
+
153
+ return undefined
154
+ }
155
+
156
+ /**
157
+ * Convert a list marker string to its numeric start position.
158
+ *
159
+ * @param marker The text content of the list marker (e.g. "3", "b", "II")
160
+ * @returns The 1-based start value for the ordered list
161
+ */
162
+ export function markerToStart(marker: string): number {
163
+ if (/^\d+$/.test(marker)) {
164
+ return parseInt(marker, 10)
165
+ }
166
+
167
+ const type = detectMarkerType(marker)
168
+
169
+ if (type === 'i' || type === 'I') {
170
+ return fromRoman(marker)
171
+ }
172
+
173
+ if (type === 'a' || type === 'A') {
174
+ const start = fromAlpha(marker)
175
+
176
+ return start > 0 ? start : 1
177
+ }
178
+
179
+ const parsed = parseInt(marker, 10)
180
+
181
+ return Number.isNaN(parsed) ? 1 : parsed
182
+ }
183
+
184
+ function startToMarker(type: string, start: number): string {
185
+ if (type === 'numeric') {
186
+ return String(start)
187
+ }
188
+
189
+ switch (type) {
190
+ case 'a':
191
+ return toRomanAlpha(start)
192
+ case 'A':
193
+ return toRomanAlpha(start).toUpperCase()
194
+ case 'i':
195
+ return toRoman(start)
196
+ case 'I':
197
+ return toRomanUpper(start)
198
+ default:
199
+ return String(start)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Returns true when all markers share the same style and increment by 1.
205
+ * Style is inferred from the first marker so ambiguous letters (e.g. "c", "i")
206
+ * are not re-classified differently on later lines.
207
+ */
208
+ export function areOrderedListMarkersSequential(markers: string[]): boolean {
209
+ if (markers.length === 0) {
210
+ return false
211
+ }
212
+
213
+ const firstType = detectMarkerType(markers[0]) ?? 'numeric'
214
+ const firstStart = markerToStart(markers[0])
215
+
216
+ if (firstStart < 1) {
217
+ return false
218
+ }
219
+
220
+ for (let i = 0; i < markers.length; i++) {
221
+ const expected = startToMarker(firstType, firstStart + i)
222
+
223
+ if (markers[i] !== expected) {
224
+ return false
225
+ }
226
+ }
227
+
228
+ return true
229
+ }
230
+
231
+ export interface ParsedListMarker {
232
+ type?: string
233
+ start: number
234
+ }
235
+
236
+ /**
237
+ * Parse a list marker into HTML ordered-list attrs (type + start).
238
+ */
239
+ export function parseListMarker(marker: string): ParsedListMarker {
240
+ return {
241
+ type: detectMarkerType(marker),
242
+ start: markerToStart(marker),
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Build orderedList node attrs from the first list item marker.
248
+ */
249
+ export function buildOrderedListAttrsFromMarker(marker: string): Record<string, string | number> {
250
+ const { type, start } = parseListMarker(marker)
251
+ const attrs: Record<string, string | number> = {}
252
+
253
+ if (type) {
254
+ attrs.type = type
255
+ }
256
+
257
+ if (start !== 1) {
258
+ attrs.start = start
259
+ }
260
+
261
+ return attrs
262
+ }
263
+
264
+ /**
265
+ * Returns the list marker prefix for a given item at a given index.
266
+ *
267
+ * @param type The list type attribute (e.g. "a", "A", "i", "I", null/undefined for default)
268
+ * @param index The zero-based index of the list item
269
+ * @param separator The separator to use (default: ". ")
270
+ * @returns The marker string (e.g. "a. ", "I. ", "1. ")
271
+ */
272
+ export function getListMarker(
273
+ type: string | null | undefined,
274
+ index: number,
275
+ separator = '. ',
276
+ ): string {
277
+ const position = index + 1
278
+
279
+ if (!type || type === '1') {
280
+ return `${position}${separator}`
281
+ }
282
+
283
+ switch (type) {
284
+ case 'a':
285
+ return `${toRomanAlpha(position)}${separator}`
286
+ case 'A':
287
+ return `${toRomanAlpha(position).toUpperCase()}${separator}`
288
+ case 'i':
289
+ return `${toRoman(position)}${separator}`
290
+ case 'I':
291
+ return `${toRomanUpper(position)}${separator}`
292
+ default:
293
+ return `${position}${separator}`
294
+ }
295
+ }
@@ -5,12 +5,33 @@ import type {
5
5
  MarkdownToken,
6
6
  } from '@tiptap/core'
7
7
 
8
+ import {
9
+ areOrderedListMarkersSequential,
10
+ buildOrderedListAttrsFromMarker,
11
+ detectMarkerType,
12
+ markerToStart,
13
+ ORDERED_LIST_MARKER_PATTERN,
14
+ } from './roman.js'
15
+
16
+ export { ORDERED_LIST_MARKER_PATTERN }
17
+
8
18
  /**
9
19
  * Matches an ordered list item line with optional leading whitespace.
10
- * Captures: (1) indentation spaces, (2) item number, (3) content after marker
11
- * Example matches: "1. Item", " 2. Nested item", " 3. Deeply nested"
20
+ * Captures: (1) indentation spaces, (2) item marker (number, letter, or roman numeral),
21
+ * (3) separator (. or )), (4) content after marker
22
+ *
23
+ * Examples: "1. Item", " a) Nested item", " I. Roman item", "iii. Another", "aa. Item 27"
12
24
  */
13
- const ORDERED_LIST_ITEM_REGEX = /^(\s*)(\d+)\.\s+(.*)$/
25
+ export const ORDERED_LIST_ITEM_REGEX = new RegExp(
26
+ `^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.*)$`,
27
+ )
28
+
29
+ /**
30
+ * Matches the start of an ordered list line (used by markdown tokenizer).
31
+ */
32
+ export const ORDERED_LIST_LINE_START_REGEX = new RegExp(
33
+ `^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+`,
34
+ )
14
35
 
15
36
  /**
16
37
  * Matches any line that starts with whitespace (indented content).
@@ -24,19 +45,23 @@ const INDENTED_LINE_REGEX = /^\s/
24
45
  export interface OrderedListItem {
25
46
  indent: number
26
47
  number: number
48
+ type?: string
27
49
  content: string
28
50
  contentLines: string[]
29
51
  raw: string
30
52
  }
31
53
 
54
+ function isOrderedListMarkerLine(line: string): boolean {
55
+ return ORDERED_LIST_ITEM_REGEX.test(line.trimStart())
56
+ }
57
+
32
58
  function isBlockContentLine(line: string): boolean {
33
59
  const trimmedLine = line.trimStart()
34
60
 
35
61
  return (
36
62
  // oxlint-disable-next-line prefer-string-starts-ends-with
37
63
  /^[-+*]\s+/.test(trimmedLine) ||
38
- // oxlint-disable-next-line prefer-string-starts-ends-with
39
- /^\d+\.\s+/.test(trimmedLine) ||
64
+ isOrderedListMarkerLine(trimmedLine) ||
40
65
  // oxlint-disable-next-line prefer-string-starts-ends-with
41
66
  /^>\s?/.test(trimmedLine) ||
42
67
  // oxlint-disable-next-line prefer-string-starts-ends-with
@@ -102,8 +127,13 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
102
127
  break
103
128
  }
104
129
 
105
- const [, indent, number, content] = match
130
+ const [, indent, marker, _separator, content] = match
106
131
  const indentLevel = indent.length
132
+ const number = parseInt(marker, 10)
133
+
134
+ const markerType = isNaN(number) ? detectMarkerType(marker) : undefined
135
+ const itemNumber = isNaN(number) ? markerToStart(marker) : number
136
+
107
137
  const itemContentLines = [content]
108
138
  let nextLineIndex = currentLineIndex + 1
109
139
  const itemLines = [line]
@@ -144,7 +174,8 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
144
174
 
145
175
  listItems.push({
146
176
  indent: indentLevel,
147
- number: parseInt(number, 10),
177
+ number: itemNumber,
178
+ type: markerType,
148
179
  content: itemContentLines.join('\n').trim(),
149
180
  contentLines: itemContentLines,
150
181
  raw: itemLines.join('\n'),
@@ -157,6 +188,58 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
157
188
  return [listItems, consumed]
158
189
  }
159
190
 
191
+ const PLAIN_TEXT_ORDERED_LIST_LINE_REGEX = new RegExp(
192
+ `^(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.+)$`,
193
+ )
194
+
195
+ /**
196
+ * Parse plain-text pasted ordered list lines into JSONContent, or null if not a typed list.
197
+ */
198
+ export function parsePlainTextOrderedListPaste(text: string): JSONContent | null {
199
+ const lines = text.split('\n').filter(l => l.trim().length > 0)
200
+
201
+ if (lines.length === 0) {
202
+ return null
203
+ }
204
+
205
+ const parsedItems: Array<{ marker: string; content: string }> = []
206
+
207
+ for (const line of lines) {
208
+ const match = line.trim().match(PLAIN_TEXT_ORDERED_LIST_LINE_REGEX)
209
+
210
+ if (!match) {
211
+ return null
212
+ }
213
+
214
+ parsedItems.push({
215
+ marker: match[1],
216
+ content: match[3],
217
+ })
218
+ }
219
+
220
+ const markers = parsedItems.map(item => item.marker)
221
+
222
+ if (!areOrderedListMarkersSequential(markers)) {
223
+ return null
224
+ }
225
+
226
+ const attrs = buildOrderedListAttrsFromMarker(parsedItems[0].marker)
227
+
228
+ return {
229
+ type: 'orderedList',
230
+ attrs,
231
+ content: parsedItems.map(item => ({
232
+ type: 'listItem',
233
+ content: [
234
+ {
235
+ type: 'paragraph',
236
+ content: [{ type: 'text', text: item.content }],
237
+ },
238
+ ],
239
+ })),
240
+ }
241
+ }
242
+
160
243
  /**
161
244
  * Recursively builds a nested structure from a flat array of list items
162
245
  * based on their indentation levels. Creates proper markdown tokens with
@@ -223,6 +306,7 @@ export function buildNestedStructure(
223
306
  type: 'list',
224
307
  ordered: true,
225
308
  start: nestedItems[0].number,
309
+ typeMarker: nestedItems[0].type,
226
310
  items: nestedListItems,
227
311
  raw: nestedItems.map(nestedItem => nestedItem.raw).join('\n'),
228
312
  })