@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.
- package/dist/index.cjs +374 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -2
- package/dist/index.d.ts +67 -2
- package/dist/index.js +363 -16
- package/dist/index.js.map +1 -1
- package/dist/item/index.cjs +67 -2
- package/dist/item/index.cjs.map +1 -1
- package/dist/item/index.js +67 -2
- package/dist/item/index.js.map +1 -1
- package/dist/kit/index.cjs +352 -15
- package/dist/kit/index.cjs.map +1 -1
- package/dist/kit/index.js +352 -15
- package/dist/kit/index.js.map +1 -1
- package/dist/ordered-list/index.cjs +370 -15
- package/dist/ordered-list/index.cjs.map +1 -1
- package/dist/ordered-list/index.d.cts +67 -2
- package/dist/ordered-list/index.d.ts +67 -2
- package/dist/ordered-list/index.js +359 -14
- package/dist/ordered-list/index.js.map +1 -1
- package/package.json +8 -5
- package/src/item/list-item.ts +5 -1
- package/src/ordered-list/index.ts +12 -0
- package/src/ordered-list/ordered-list.ts +145 -10
- package/src/ordered-list/roman.ts +295 -0
- package/src/ordered-list/utils.ts +91 -7
|
@@ -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,
|
|
11
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
})
|