@peaceroad/markdown-it-numbering-ul-regarded-as-ol 0.2.0 → 0.2.1

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.
@@ -1,744 +1,1043 @@
1
- // Phase 2: Token Conversion
2
- // Convert bullet_list to ordered_list based on Phase1 analysis, simplify nesting in default mode
3
-
4
- import { findMatchingClose } from './list-helpers.js'
5
-
6
- /**
7
- * Convert tokens based on list information
8
- * @param {Array} tokens - Token array
9
- * @param {Array} listInfos - List information analyzed in Phase1
10
- * @param {Object} opt - Options
11
- */
12
- export function convertLists(tokens, listInfos, opt) {
13
- // Convert bullet_list to ordered_list
14
- // listInfos already collected in depth-first order in Phase 1, no sorting needed
15
- for (const listInfo of listInfos) {
16
- if (listInfo.shouldConvert) {
17
- convertBulletToOrdered(tokens, listInfo)
18
- }
19
- }
20
-
21
- // In default mode (unremoveUlNest=false), simplify ul>li>ol structure
22
- // Runs after conversion, so already-converted ordered_lists are also processed
1
+ // Phase 2: Token Conversion
2
+ // Convert bullet_list to ordered_list based on Phase1 analysis, simplify nesting in default mode
3
+
4
+ import { findMatchingClose } from './list-helpers.js'
5
+
6
+ /**
7
+ * Convert tokens based on list information
8
+ * @param {Array} tokens - Token array
9
+ * @param {Array} listInfos - List information analyzed in Phase1
10
+ * @param {Object} opt - Options
11
+ */
12
+ export function convertLists(tokens, listInfos, opt) {
13
+ const listInfoMap = buildListInfoMap(listInfos)
14
+ // Convert bullet_list to ordered_list
15
+ // listInfos already collected in depth-first order in Phase 1, no sorting needed
16
+ for (const listInfo of listInfos) {
17
+ if (listInfo.shouldConvert) {
18
+ convertBulletToOrdered(tokens, listInfo)
19
+ }
20
+ }
21
+
22
+ // In default mode (unremoveUlNest=false), simplify ul>li>ol structure
23
+ // Runs after conversion, so already-converted ordered_lists are also processed
23
24
  if (!opt.unremoveUlNest) {
24
25
  const bulletListInfos = listInfos.filter(info => info.originalType === 'bullet_list_open')
25
26
  if (bulletListInfos.length > 0) {
26
- simplifyNestedBulletLists(tokens, bulletListInfos, opt)
27
+ simplifyNestedBulletLists(tokens, bulletListInfos, opt, listInfoMap)
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Convert bullet_list to ordered_list
34
+ */
35
+ function convertBulletToOrdered(tokens, listInfo) {
36
+ const { startIndex, endIndex, markerInfo, items } = listInfo
37
+
38
+ // Tokens may have been deleted by simplifyNestedBulletLists,
39
+ // check index validity
40
+ if (startIndex >= tokens.length || endIndex >= tokens.length) {
41
+ // This listInfo points to already deleted list
42
+ return
43
+ }
44
+
45
+ // Convert start token
46
+ if (tokens[startIndex].type === 'bullet_list_open') {
47
+ tokens[startIndex].type = 'ordered_list_open'
48
+ tokens[startIndex].tag = 'ol'
49
+ tokens[startIndex]._convertedFromBullet = true
50
+ // Save marker info (used in Phase3)
51
+ tokens[startIndex]._markerInfo = markerInfo
52
+ }
53
+
54
+ // Convert end token
55
+ if (tokens[endIndex] && tokens[endIndex].type === 'bullet_list_close') {
56
+ tokens[endIndex].type = 'ordered_list_close'
57
+ tokens[endIndex].tag = 'ol'
58
+ }
59
+
60
+ // If item's first paragraph is loose, set _parentIsLoose flag to child lists
61
+ // Only when all following conditions are met:
62
+ // 1. Item has only single paragraph (exclude if multiple paragraphs)
63
+ // 2. Parent item's marker type matches child list's marker type
64
+ // (if different, not propagated as it will be flattened with `-1.` pattern)
65
+ if (items && items.length > 0) {
66
+ for (const item of items) {
67
+ if (item.firstParagraphIsLoose && item.hasNestedList && item.nestedLists && item.markerInfo) {
68
+ // Check paragraph count in item
69
+ // Don't propagate if multiple paragraphs before first nested list
70
+ let paragraphCount = 0
71
+ let reachedNestedList = false
72
+
73
+ for (let i = item.startIndex + 1; i < item.endIndex && !reachedNestedList; i++) {
74
+ const token = tokens[i]
75
+ if (token.type === 'paragraph_open') {
76
+ paragraphCount++
77
+ } else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
78
+ reachedNestedList = true
79
+ }
80
+ }
81
+
82
+ // Propagate to child lists only if single paragraph
83
+ if (paragraphCount === 1) {
84
+ for (const nestedList of item.nestedLists) {
85
+ if (nestedList && nestedList.startIndex < tokens.length) {
86
+ // Compare parent item's marker type with child list's marker type
87
+ // Don't propagate if different (will be flattened)
88
+ const childMarkerInfo = nestedList.items && nestedList.items[0] && nestedList.items[0].markerInfo
89
+ if (!childMarkerInfo || item.markerInfo.markerType !== childMarkerInfo.markerType) {
90
+ continue // Skip if marker types differ
91
+ }
92
+
93
+ const nestedListToken = tokens[nestedList.startIndex]
94
+ if (nestedListToken && (nestedListToken.type === 'bullet_list_open' || nestedListToken.type === 'ordered_list_open')) {
95
+ // Set flag directly
96
+ nestedListToken._parentIsLoose = true
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // Remove marker text from inline tokens
106
+ if (markerInfo && markerInfo.markers) {
107
+ removeMarkersFromContent(tokens, startIndex, endIndex, markerInfo, listInfo)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Remove markers from inline token content
113
+ */
114
+ function removeMarkersFromContent(tokens, startIndex, endIndex, markerInfo, listInfo) {
115
+ const listToken = tokens[startIndex]
116
+ const targetLevel = (listToken.level || 0) + 3 // list_open(0) -> list_item(1) -> paragraph(2) -> inline(3)
117
+
118
+ // Pre-generate all marker regexes (avoid repeated regex creation in loop)
119
+ const markerPatterns = markerInfo.markers.map(marker =>
120
+ marker && marker.marker ? new RegExp(`^${escapeRegExp(marker.marker)}\\s*`) : null
121
+ )
122
+
123
+ let markerIndex = 0
124
+ for (let i = startIndex + 1; i < endIndex && markerIndex < markerPatterns.length; i++) {
125
+ const token = tokens[i]
126
+
127
+ if (token.type === 'inline' && token.content && token.level === targetLevel) {
128
+ const markerPattern = markerPatterns[markerIndex]
129
+ if (markerPattern) {
130
+ // Remove marker and trailing spaces
131
+ const newContent = token.content.replace(markerPattern, '')
132
+
133
+ // Only advance to next marker if actually removed
134
+ if (newContent !== token.content) {
135
+ token.content = newContent
136
+ markerIndex++
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 正規表現の特殊文字をエスケープ
145
+ */
146
+ function escapeRegExp(string) {
147
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
148
+ }
149
+
150
+ /**
151
+ * Simplify nested ul>li>ul and ul>li>ol structures.
152
+ *
153
+ * Pattern 1: bullet_list_open → list_item_open → bullet_list_open → ...
154
+ * Pattern 2: bullet_list_open → list_item_open → ordered_list_open → ... (repeated)
155
+ *
156
+ * When the middle list_item is empty (contains only the inner list),
157
+ * remove the outer ul and the intermediate li.
158
+ * @param {Array} tokens - Token array
159
+ * @param {Array} listInfos - List information analyzed in Phase 1
160
+ * @param {Object} opt - Options
161
+ * @param {Map} listInfoMap - Map of listInfo keyed by startIndex
162
+ */
163
+ function simplifyNestedBulletLists(tokens, listInfos, opt, listInfoMap = null) {
164
+ let modified = true
165
+
166
+ // ===== Phase 1: Simplify ul>li>ol structure (multiple passes needed) =====
167
+ while (modified) {
168
+ modified = false
169
+
170
+ for (let i = 0; i < tokens.length; i++) {
171
+ const token = tokens[i]
172
+
173
+ // Only detect bullet_list_open (ordered_list processing will be done later)
174
+ if (token.type !== 'bullet_list_open') {
175
+ continue
176
+ }
177
+
178
+ // Check if this bullet_list is all ul>li>ol/ul pattern
179
+ const listCloseIdx = findMatchingClose(tokens, i, 'bullet_list_open', 'bullet_list_close')
180
+ if (listCloseIdx === -1) continue
181
+
182
+ // Check all list_items in bullet_list
183
+ const itemIndices = []
184
+ let totalItems = 0
185
+ let idx = i + 1
186
+
187
+ while (idx < listCloseIdx) {
188
+ if (tokens[idx].type === 'list_item_open') {
189
+ totalItems++
190
+ const itemCloseIdx = findMatchingClose(tokens, idx, 'list_item_open', 'list_item_close')
191
+
192
+
193
+ // Find inner list within list_item
194
+ let innerListOpen = -1
195
+ let innerListType = null
196
+ let innerListCloseIdx = -1
197
+ for (let j = idx + 1; j < itemCloseIdx; j++) {
198
+ if (tokens[j].type === 'bullet_list_open' || tokens[j].type === 'ordered_list_open') {
199
+ const candidateType = tokens[j].type
200
+ const candidateClose = findMatchingClose(tokens, j, candidateType, candidateType.replace('_open', '_close'))
201
+ if (tokens[j]._literalList) {
202
+ if (candidateClose === -1) {
203
+ break
204
+ }
205
+ j = candidateClose
206
+ continue
207
+ }
208
+ innerListOpen = j
209
+ innerListType = candidateType
210
+ innerListCloseIdx = candidateClose
211
+ break
212
+ }
213
+ }
214
+
215
+ if (innerListOpen !== -1) {
216
+ const innerListCloseType = innerListType === 'bullet_list_open' ? 'bullet_list_close' : 'ordered_list_close'
217
+ if (innerListCloseIdx === -1) {
218
+ innerListCloseIdx = findMatchingClose(tokens, innerListOpen, innerListType, innerListCloseType)
219
+ }
220
+
221
+ // Check if there's extra content before/after ol (whether it's only ol)
222
+ const beforeContent = innerListOpen - (idx + 1) // Token count from after list_item_open to ol
223
+ const afterContent = itemCloseIdx - (innerListCloseIdx + 1) // Token count from after ol to list_item_close
224
+ const hasExtraContent = beforeContent > 0 || afterContent > 0
225
+ const literalNumber = extractFirstListItemNumber(tokens, innerListOpen, innerListCloseIdx)
226
+
227
+ const innerListInfo = listInfoMap?.get(innerListOpen)
228
+ const isSimpleMarkerParagraph = (() => {
229
+ const precedingCount = innerListOpen - (idx + 1)
230
+ if (precedingCount !== 3) return false
231
+ const paraOpen = tokens[idx + 1]
232
+ const inlineToken = tokens[idx + 2]
233
+ const paraClose = tokens[idx + 3]
234
+ return paraOpen?.type === 'paragraph_open' &&
235
+ inlineToken?.type === 'inline' &&
236
+ paraClose?.type === 'paragraph_close'
237
+ })()
238
+
239
+ itemIndices.push({
240
+ outerItemOpen: idx,
241
+ outerItemClose: itemCloseIdx,
242
+ innerListOpen: innerListOpen,
243
+ innerListClose: innerListCloseIdx,
244
+ innerListType: innerListType,
245
+ hasExtraContent: hasExtraContent,
246
+ extraContentStart: innerListCloseIdx + 1,
247
+ extraContentEnd: itemCloseIdx,
248
+ innerListInfo,
249
+ literalNumber,
250
+ flattenFirstParagraph: isSimpleMarkerParagraph
251
+ })
252
+ }
253
+
254
+ idx = itemCloseIdx + 1
255
+ } else {
256
+ idx++
257
+ }
258
+ }
259
+
260
+ // Check if outer ul is convertible (has markers)
261
+ const outerListInfo = listInfos?.find(info => info.startIndex === i && info.shouldConvert)
262
+ const outerListHasMarkers = outerListInfo?.shouldConvert && outerListInfo.markerInfo
263
+
264
+ if (outerListInfo?.shouldConvert) {
265
+ for (const flattenedItem of itemIndices) {
266
+ const closeIdx = flattenedItem.innerListOpen - 1
267
+ const inlineIdx = closeIdx - 1
268
+ const openIdx = closeIdx - 2
269
+ if (openIdx >= flattenedItem.outerItemOpen + 1) {
270
+ const paraOpen = tokens[openIdx]
271
+ const inlineToken = tokens[inlineIdx]
272
+ const paraClose = tokens[closeIdx]
273
+ if (paraOpen?.type === 'paragraph_open' &&
274
+ inlineToken?.type === 'inline' &&
275
+ paraClose?.type === 'paragraph_close') {
276
+ const innerListToken = tokens[flattenedItem.innerListOpen]
277
+ if (innerListToken) {
278
+ innerListToken._convertedFromFlatten = true
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Simplification conditions:
286
+ // 1. All list_items have inner lists of the same type (traditional logic)
287
+ // 2. Outer list has markers and at least one item has inner list (new logic)
288
+ const allItemsHaveInnerList = itemIndices.length > 0 && itemIndices.length === totalItems
289
+ const shouldSimplify = allItemsHaveInnerList || outerListHasMarkers
290
+
291
+ if (shouldSimplify && itemIndices.length > 0) {
292
+ const allSameType = itemIndices.every(item => item.innerListType === itemIndices[0].innerListType)
293
+ const firstInnerType = itemIndices[0].innerListType
294
+ const hasExtraContent = itemIndices.some(item => item.hasExtraContent)
295
+
296
+ // Don't simplify if inner lists are mixed types
297
+ if (!allSameType) {
298
+ continue
299
+ }
300
+
301
+ // Merge marker info from each inner list
302
+ const allMarkers = []
303
+
304
+ // Use outer listInfo marker info if available
305
+ if (outerListInfo && outerListInfo.markerInfo && outerListInfo.markerInfo.markers) {
306
+ allMarkers.push(...outerListInfo.markerInfo.markers)
307
+ }
308
+
309
+ for (const item of itemIndices) {
310
+ const innerListToken = tokens[item.innerListOpen]
311
+
312
+ // If went through Phase2 convertBulletToOrdered
313
+ if (innerListToken._literalList) {
314
+ continue
315
+ }
316
+
317
+ if (innerListToken._markerInfo && innerListToken._markerInfo.markers) {
318
+ allMarkers.push(...innerListToken._markerInfo.markers)
319
+ }
320
+ // If originally ordered_list, get marker info from start attribute and markup
321
+ else if (!outerListInfo || !outerListInfo.markerInfo) {
322
+ // Only get from inner if outer has no marker info
323
+ // Get start attribute and markup
324
+ const startAttr = innerListToken.attrs?.find(attr => attr[0] === 'start')
325
+ let startNumber = startAttr ? parseInt(startAttr[1], 10) : 1
326
+ const suffix = innerListToken.markup || '.' // markup is suffix
327
+ let itemCount = 0
328
+
329
+ // Get info from list_item_open (number set by markdown-it)
330
+ for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
331
+ if (tokens[j].type === 'list_item_open' && tokens[j].level === innerListToken.level + 1) {
332
+ // Get number from info
333
+ const number = tokens[j].info ? parseInt(tokens[j].info, 10) : startNumber + itemCount
334
+ allMarkers.push({
335
+ type: 'decimal',
336
+ marker: number + suffix,
337
+ number: number,
338
+ originalNumber: number,
339
+ prefix: '',
340
+ suffix: suffix
341
+ })
342
+ itemCount++
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // Build new token array
349
+ const newTokens = []
350
+
351
+ // Tokens before bullet_list_open
352
+ for (let j = 0; j < i; j++) {
353
+ newTokens.push(tokens[j])
354
+ }
355
+
356
+ // Add first inner list open token and save merged marker info
357
+ const firstListToken = tokens[itemIndices[0].innerListOpen]
358
+ if (firstListToken.attrs) {
359
+ const startAttr = firstListToken.attrs.find(attr => attr[0] === 'start')
360
+ if (startAttr) {
361
+ firstListToken._startOverride = startAttr[1]
362
+ }
363
+ }
364
+
365
+ // If outer ul is convertible, convert inner list to ordered_list
366
+ // (Don't limit to bullet_list as inner list might already be ordered_list)
367
+ if (outerListInfo && outerListInfo.shouldConvert) {
368
+ if (firstListToken.type === 'bullet_list_open') {
369
+ firstListToken.type = 'ordered_list_open'
370
+ firstListToken.tag = 'ol'
371
+ firstListToken._convertedFromBullet = true
372
+ }
373
+ // If already ordered_list, use as is (already converted)
374
+ }
375
+
376
+ newTokens.push(firstListToken)
377
+
378
+ // Merge and save marker info
379
+ if (allMarkers.length > 0) {
380
+ normalizeParentMarkers(allMarkers, itemIndices)
381
+ const firstMarkerType = allMarkers[0]?.type
382
+ const literalNumbers = allMarkers.map(m => (typeof m.originalNumber === 'number' ? m.originalNumber : undefined))
383
+ const hasLiteralNumbers = literalNumbers.length === allMarkers.length &&
384
+ literalNumbers.every(n => typeof n === 'number')
385
+ let allNumbersIdentical = false
386
+ if (hasLiteralNumbers) {
387
+ const firstLiteral = literalNumbers[0]
388
+ if (literalNumbers.every(n => n === firstLiteral) && firstLiteral === 1) {
389
+ allNumbersIdentical = true
390
+ }
391
+ }
392
+ firstListToken._markerInfo = {
393
+ markers: allMarkers,
394
+ type: firstMarkerType,
395
+ isConsistent: allMarkers.every(m => m.type === firstMarkerType),
396
+ count: allMarkers.length,
397
+ allNumbersIdentical
398
+ }
399
+ const firstNumber = allMarkers[0]?.originalNumber ?? allMarkers[0]?.number
400
+ if (typeof firstNumber === 'number' && firstNumber !== 1) {
401
+ if (!firstListToken.attrs) firstListToken.attrs = []
402
+ const startIndex = firstListToken.attrs.findIndex(attr => attr[0] === 'start')
403
+ if (startIndex >= 0) {
404
+ firstListToken.attrs[startIndex][1] = String(firstNumber)
405
+ } else {
406
+ firstListToken.attrs.push(['start', String(firstNumber)])
407
+ }
408
+ } else if (firstListToken.attrs) {
409
+ const idx = firstListToken.attrs.findIndex(attr => attr[0] === 'start')
410
+ if (idx >= 0) {
411
+ firstListToken.attrs.splice(idx, 1)
412
+ if (firstListToken.attrs.length === 0) firstListToken.attrs = null
413
+ }
414
+ }
415
+ }
416
+
417
+ // ===== Outer UL (Level 0 after conversion) loose/tight determination =====
418
+ // In flattened pattern (`- 1.` etc), no direct paragraph in outer list_item,
419
+ // so cannot detect with markdown-it's paragraph.hidden
420
+ // Instead, use map info of outer list_item to detect blank lines between items
421
+ let outerUlIsLoose = false
422
+
423
+ // Single item is always tight
424
+ if (itemIndices.length === 1) {
425
+ outerUlIsLoose = false
426
+ } else {
427
+ // For multiple items, check blank lines with map info
428
+ for (let itemIdx = 0; itemIdx < itemIndices.length - 1; itemIdx++) {
429
+ const currentItem = itemIndices[itemIdx]
430
+ const nextItem = itemIndices[itemIdx + 1]
431
+
432
+ // Get currentItem end line: list_item_close map or last token map
433
+ let currentEndLine = null
434
+ // Find list_item_close map (usually null, so use last token instead)
435
+ for (let k = currentItem.outerItemClose - 1; k > currentItem.outerItemOpen; k--) {
436
+ if (tokens[k].map && tokens[k].map[1]) {
437
+ currentEndLine = tokens[k].map[1]
438
+ break
439
+ }
440
+ }
441
+
442
+ const nextMap = tokens[nextItem.outerItemOpen].map
443
+
444
+ if (currentEndLine !== null && nextMap) {
445
+ // Compare currentItem end line and nextItem start line
446
+ // If line numbers differ, there's blank line
447
+ const lineGap = nextMap[0] - currentEndLine
448
+ if (lineGap > 0) {
449
+ outerUlIsLoose = true
450
+ break
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ // ===== Nested list overall loose/tight determination =====
457
+ // Inner list (converted ol) loose/tight determination
458
+ // If parent item is loose (_parentIsLoose flag), or
459
+ // if outer ul is loose, this inner list is also loose
460
+
461
+ // First, check if there are blank lines between converted ol list_items (independent of outerUlIsLoose)
462
+ // This is used for propagation to child lists
463
+ let innerListIsLooseDueToBlankLines = false
464
+
465
+ // In flattened pattern (`- 1.` etc), each outer list_item has one inner ol
466
+ // Check if there are blank lines between list_items in inner ol
467
+ // (Blank lines between outer list_items already checked by outerUlIsLoose)
468
+ for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
469
+ const item = itemIndices[itemIdx]
470
+
471
+ // Collect list_items in inner list
472
+ const innerListItems = []
473
+ for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
474
+ if (tokens[j].type === 'list_item_open' &&
475
+ tokens[j].level === tokens[item.innerListOpen].level + 1) {
476
+ const itemOpen = j
477
+ const itemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
478
+ innerListItems.push({ open: itemOpen, close: itemClose })
479
+ }
480
+ }
481
+
482
+ // Check blank lines between list_items in inner list
483
+ if (innerListItems.length > 1) {
484
+ for (let k = 0; k < innerListItems.length - 1; k++) {
485
+ const currentItem = innerListItems[k]
486
+ const nextItem = innerListItems[k + 1]
487
+
488
+ // Get currentItem end line
489
+ let currentEndLine = null
490
+ for (let m = currentItem.close - 1; m > currentItem.open; m--) {
491
+ if (tokens[m].map && tokens[m].map[1]) {
492
+ currentEndLine = tokens[m].map[1]
493
+ break
494
+ }
495
+ }
496
+
497
+ const nextMap = tokens[nextItem.open].map
498
+
499
+ if (currentEndLine !== null && nextMap) {
500
+ const lineGap = nextMap[0] - currentEndLine
501
+ if (lineGap > 0) {
502
+ innerListIsLooseDueToBlankLines = true
503
+ break
504
+ }
505
+ }
506
+ }
507
+ if (innerListIsLooseDueToBlankLines) break
508
+ }
509
+ }
510
+
511
+ // Check markdown-it's paragraph.hidden
512
+ // If inner list has paragraph with hidden=false, it's loose
513
+ if (!innerListIsLooseDueToBlankLines) {
514
+ for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
515
+ const item = itemIndices[itemIdx]
516
+
517
+ let innerListItemOpen = -1
518
+ let innerListItemClose = -1
519
+
520
+ for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
521
+ if (tokens[j].type === 'list_item_open' &&
522
+ tokens[j].level === tokens[item.innerListOpen].level + 1) {
523
+ innerListItemOpen = j
524
+ innerListItemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
525
+ break
526
+ }
527
+ }
528
+
529
+ if (innerListItemOpen !== -1 && innerListItemClose !== -1) {
530
+ // Check hidden of first paragraph in inner list item
531
+ let nestedListDepth = 0
532
+ for (let j = innerListItemOpen + 1; j < innerListItemClose; j++) {
533
+ const t = tokens[j]
534
+ if (t.type === 'bullet_list_open' || t.type === 'ordered_list_open') {
535
+ nestedListDepth++
536
+ } else if (t.type === 'bullet_list_close' || t.type === 'ordered_list_close') {
537
+ nestedListDepth--
538
+ } else if (nestedListDepth === 0 && t.type === 'paragraph_open') {
539
+ if (t.hidden === false) {
540
+ innerListIsLooseDueToBlankLines = true
541
+ }
542
+ break
543
+ }
544
+ }
545
+ if (innerListIsLooseDueToBlankLines) break
546
+ }
547
+ }
548
+ }
549
+
550
+ // If outerUlIsLoose, force first paragraph of each inner list item to loose (hidden=false)
551
+ // markdown-it judges tight/loose by original ul structure, but after flattening to ol structure
552
+ // should reflect parent ul's loose state
553
+ // However, exclude case where both parent ul and each inner ol have single item
554
+ // This must be executed AFTER innerListIsLooseDueToBlankLines determination
555
+ if (outerUlIsLoose && !(itemIndices.length === 1)) {
556
+ for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
557
+ const item = itemIndices[itemIdx]
558
+ // Find list_items in inner list
559
+ for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
560
+ if (tokens[j].type === 'list_item_open' &&
561
+ tokens[j].level === tokens[item.innerListOpen].level + 1) {
562
+ // Find first paragraph in list_item
563
+ const listItemOpen = j
564
+ const listItemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
565
+ for (let k = listItemOpen + 1; k < listItemClose; k++) {
566
+ if (tokens[k].type === 'paragraph_open' &&
567
+ tokens[k].level === tokens[listItemOpen].level + 1) {
568
+ if (!tokens[k]._literalTight) {
569
+ tokens[k].hidden = false
570
+ }
571
+ break // Only first paragraph
572
+ }
573
+ }
574
+ break // Only first list_item of inner list
575
+ }
576
+ }
577
+ }
578
+ }
579
+
580
+ // Track loose state inherited from parent structures (outer lists or ancestors)
581
+ const parentStructureIsLoose = token._parentIsLoose || outerUlIsLoose
582
+
583
+ // ===== Merge each inner list's contents and place extra content appropriately =====
584
+ for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
585
+ const item = itemIndices[itemIdx]
586
+
587
+ // Count list_items in this inner list (ordered_list)
588
+ let innerListItemCount = 0
589
+ for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
590
+ if (tokens[j].type === 'list_item_open' &&
591
+ tokens[j].level === tokens[item.innerListOpen].level + 1) {
592
+ innerListItemCount++
593
+ }
594
+ }
595
+
596
+ const innerListToken = tokens[item.innerListOpen]
597
+ const listItemRanges = collectListItemRanges(tokens, item.innerListOpen, item.innerListClose)
598
+ if (listItemRanges.length === 0) {
599
+ continue
600
+ }
601
+ const parentRange = listItemRanges[0]
602
+ const childRanges = listItemRanges.slice(1)
603
+ const innerListItemOpen = parentRange.open
604
+ const innerListItemClose = parentRange.close
605
+
606
+ if (innerListItemOpen !== -1 && innerListItemClose !== -1) {
607
+ // Add list_item_open (use original token to preserve info)
608
+ // Note: value attribute is added in Phase3 (not during simplification)
609
+ newTokens.push(tokens[innerListItemOpen])
610
+
611
+ // This item's loose/tight determination
612
+ // If entire list is loose, this item is also loose
613
+ // Determination when extraContent has block elements:
614
+ // - extraContent has paragraph(hidden=false) → loose
615
+ // - Has heading(heading_open) or HTML block(html_block) → loose
616
+ // - markdown-it sets paragraph.hidden=true → maintain tight
617
+ let hasBlockElement = false
618
+ if (item.hasExtraContent) {
619
+ for (let k = item.extraContentStart; k < item.extraContentEnd; k++) {
620
+ const tokenType = tokens[k].type
621
+ // Detect block elements:
622
+ // - paragraph(hidden=false): multiple paragraphs
623
+ // - heading_open: heading
624
+ // - html_block: HTML block
625
+ if ((tokenType === 'paragraph_open' && !tokens[k].hidden) ||
626
+ tokenType === 'heading_open' ||
627
+ tokenType === 'html_block') {
628
+ hasBlockElement = true
629
+ break
630
+ }
631
+ }
632
+ }
633
+
634
+ // Whether this item's first paragraph is loose
635
+ // is obtained from Phase1-analyzed listInfo.items[itemIdx]
636
+ const listItem = outerListInfo?.items?.[itemIdx]
637
+ const firstParagraphIsLoose = listItem?.firstParagraphIsLoose || false
638
+
639
+ // Make loose when parent structure is loose or this item has extra block elements
640
+ // - However, consider cases with multiple paragraphs in item (firstParagraphIsLoose) or
641
+ // block elements (hasBlockElement)
642
+ //
643
+ // Single item determination in flattened pattern (`- 1.` etc):
644
+ // - Parent ul single item AND each inner ol single item → exclude outerUlIsLoose
645
+ // - If parent ul has multiple items, consider outerUlIsLoose (reflect parent's blank lines)
646
+ let structuralLoose = parentStructureIsLoose || firstParagraphIsLoose
647
+ if (itemIndices.length === 1 && innerListItemCount === 1) {
648
+ // Exclude outerUlIsLoose if both parent ul and inner ol only have a single item
649
+ structuralLoose = (token._parentIsLoose || firstParagraphIsLoose)
650
+ }
651
+ const shouldBeLoose = structuralLoose || hasBlockElement
652
+
653
+ // Determine whether to propagate _parentIsLoose flag to child lists
654
+ // - innerListIsLooseDueToBlankLines: blank lines between converted ol list_items
655
+ // - token._parentIsLoose: already propagated from parent
656
+ // - firstParagraphIsLoose: blank line after parent item's first paragraph (same marker type only)
657
+ // Note: hasExtraContent makes parent item loose but doesn't affect child lists
658
+ // Note: outerUlIsLoose excluded (Test 25: handle case where parent is loose but child is tight)
659
+ const shouldPropagateLooseToChildren = innerListIsLooseDueToBlankLines || token._parentIsLoose || firstParagraphIsLoose || false
660
+
661
+ // Copy tokens in inner list item (exclude nested list paragraphs)
662
+ let nestedListDepth = 0
663
+ let firstParagraphInItem = true // Track first paragraph in each list_item
664
+ let shouldFlattenParagraph = !!item.flattenFirstParagraph
665
+ for (let j = innerListItemOpen + 1; j < innerListItemClose; j++) {
666
+ const tokenToPush = tokens[j]
667
+
668
+ // Track nested list depth
669
+ if (tokenToPush.type === 'bullet_list_open' || tokenToPush.type === 'ordered_list_open') {
670
+ nestedListDepth++
671
+ // If parent list is loose (due to blank lines), set _parentIsLoose flag to child lists
672
+ // Don't set if only outerUlIsLoose
673
+ // Propagate to all nest levels (support level >= 2)
674
+ if (shouldPropagateLooseToChildren) {
675
+ // Set _parentIsLoose flag (optimize with direct property assignment)
676
+ tokenToPush._parentIsLoose = true
677
+ newTokens.push(tokenToPush)
678
+ } else {
679
+ newTokens.push(tokenToPush)
680
+ }
681
+ } else if (tokenToPush.type === 'bullet_list_close' || tokenToPush.type === 'ordered_list_close') {
682
+ nestedListDepth--
683
+ newTokens.push(tokenToPush)
684
+ } else if (nestedListDepth === 0 && (tokenToPush.type === 'paragraph_open' || tokenToPush.type === 'paragraph_close')) {
685
+ // Update paragraph_open/close hidden state (only outside nested lists)
686
+
687
+ if (tokenToPush.type === 'paragraph_open') {
688
+ // Process first paragraph
689
+ if (firstParagraphInItem) {
690
+ // If tight list (shouldBeLoose = false), hide first paragraph
691
+ if (shouldFlattenParagraph) {
692
+ tokenToPush.hidden = true
693
+ tokenToPush._literalTight = true
694
+ shouldFlattenParagraph = false
695
+ } else if (!tokenToPush._literalTight) {
696
+ tokenToPush.hidden = !shouldBeLoose
697
+ }
698
+ firstParagraphInItem = false
699
+ } else {
700
+ // Always show second and subsequent paragraphs
701
+ if (!tokenToPush._literalTight) {
702
+ tokenToPush.hidden = false
703
+ }
704
+ }
705
+ } else {
706
+ // Match paragraph_close hidden state to corresponding paragraph_open
707
+ // Reference preceding paragraph_open's hidden state
708
+ const prevToken = newTokens[newTokens.length - 2] // Skip preceding inline, get paragraph_open before it
709
+ if (prevToken && prevToken.type === 'paragraph_open') {
710
+ tokenToPush.hidden = prevToken.hidden
711
+ } else {
712
+ // Fallback: judge based on shouldBeLoose
713
+ tokenToPush.hidden = !shouldBeLoose && firstParagraphInItem
714
+ }
715
+ }
716
+
717
+ newTokens.push(tokenToPush)
718
+ } else {
719
+ newTokens.push(tokenToPush)
720
+ }
721
+ }
722
+
723
+ // Add extra content (content in ul>li, outside ol) to this list_item
724
+ if (item.hasExtraContent) {
725
+ // Adjust level: ul list_item's level → ol list_item's level
726
+ const levelDiff = tokens[innerListItemOpen].level - tokens[item.outerItemOpen].level
727
+
728
+ // Track nested lists in extraContent
729
+ let extraNestedListDepth = 0
730
+ for (let k = item.extraContentStart; k < item.extraContentEnd; k++) {
731
+ const tokenToPush = tokens[k]
732
+ const originalLevel = tokenToPush.level
733
+ tokenToPush.level = originalLevel + levelDiff
734
+
735
+ // Track nested list depth
736
+ if (tokenToPush.type === 'bullet_list_open' || tokenToPush.type === 'ordered_list_open') {
737
+ extraNestedListDepth++
738
+ // If parent item is loose, set _parentIsLoose flag to child lists
739
+ // Don't set if only outerUlIsLoose
740
+ if (shouldPropagateLooseToChildren && extraNestedListDepth === 1) {
741
+ tokenToPush._parentIsLoose = true
742
+ }
743
+ } else if (tokenToPush.type === 'bullet_list_close' || tokenToPush.type === 'ordered_list_close') {
744
+ extraNestedListDepth--
745
+ }
746
+
747
+ // If shouldBeLoose, set paragraph hidden to false (only outside nested lists)
748
+ if (shouldBeLoose && extraNestedListDepth === 0 && (tokenToPush.type === 'paragraph_open' || tokenToPush.type === 'paragraph_close')) {
749
+ if (!tokenToPush._literalTight) {
750
+ tokenToPush.hidden = false
751
+ }
752
+ }
753
+
754
+ newTokens.push(tokenToPush)
755
+ }
756
+ }
757
+
758
+ if (childRanges.length > 0) {
759
+ const parentLevel = tokens[innerListItemOpen].level || 0
760
+ const targetNestedLevel = parentLevel + 1
761
+ const originalListLevel = tokens[item.innerListOpen].level || 0
762
+ const levelShift = targetNestedLevel - originalListLevel
763
+ const markerStartIndex = childRanges[0].markerIndex
764
+ const nestedTokens = buildNestedListTokens(
765
+ tokens,
766
+ childRanges,
767
+ item.innerListOpen,
768
+ item.innerListClose,
769
+ levelShift,
770
+ markerStartIndex,
771
+ item.innerListInfo
772
+ )
773
+ for (const nestedToken of nestedTokens) {
774
+ newTokens.push(nestedToken)
775
+ }
776
+ }
777
+
778
+ // Add list_item_close
779
+ newTokens.push(tokens[innerListItemClose])
780
+ }
781
+ }
782
+
783
+ // Add last inner list's close token
784
+ const lastItem = itemIndices[itemIndices.length - 1]
785
+ const lastListCloseToken = tokens[lastItem.innerListClose]
786
+
787
+ // If outer ul is convertible, also convert close token
788
+ if (outerListInfo && outerListInfo.shouldConvert) {
789
+ if (lastListCloseToken.type === 'bullet_list_close') {
790
+ lastListCloseToken.type = 'ordered_list_close'
791
+ lastListCloseToken.tag = 'ol'
792
+ }
793
+ // If already ordered_list_close, use as is
794
+ }
795
+
796
+ newTokens.push(lastListCloseToken)
797
+
798
+ // Tokens after bullet_list_close
799
+ for (let j = listCloseIdx + 1; j < tokens.length; j++) {
800
+ newTokens.push(tokens[j])
801
+ }
802
+
803
+ // Replace token array
804
+ tokens.length = 0
805
+ tokens.push(...newTokens)
806
+
807
+ // Remove markers from merged list
808
+ if (firstListToken._markerInfo && firstListToken._markerInfo.markers) {
809
+ let listStartIdx = 0
810
+ while (listStartIdx < tokens.length && tokens[listStartIdx] !== firstListToken) {
811
+ listStartIdx++
812
+ }
813
+ if (listStartIdx < tokens.length) {
814
+ const listEndIdx = findMatchingClose(tokens, listStartIdx,
815
+ firstListToken.type,
816
+ firstListToken.type.replace('_open', '_close'), false)
817
+ if (listEndIdx !== -1) {
818
+ removeMarkersFromContent(tokens, listStartIdx, listEndIdx, firstListToken._markerInfo, null)
819
+ }
820
+ }
821
+ }
822
+
823
+ modified = true
824
+ break
825
+ }
826
+ }
827
+ }
828
+
829
+ // ===== Propagate ordered_list's _parentIsLoose flag to child lists =====
830
+ // Check all ordered_list_open, and if _parentIsLoose flag exists,
831
+ // propagate to child lists
832
+ for (let i = 0; i < tokens.length; i++) {
833
+ const token = tokens[i]
834
+
835
+ if ((token.type === 'ordered_list_open' || token.type === 'bullet_list_open') && token._parentIsLoose) {
836
+ // Find this list's close token
837
+ const listCloseIdx = findMatchingClose(tokens, i, token.type, token.type.replace('_open', '_close'))
838
+ if (listCloseIdx === -1) continue
839
+
840
+ // Search list_items in this list and set _parentIsLoose flag to child lists in those list_items
841
+ for (let j = i + 1; j < listCloseIdx; j++) {
842
+ if (tokens[j].type === 'list_item_open' && tokens[j].level === token.level + 1) {
843
+ const itemCloseIdx = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
844
+
845
+ // Search child lists in this list_item
846
+ for (let k = j + 1; k < itemCloseIdx; k++) {
847
+ if ((tokens[k].type === 'bullet_list_open' || tokens[k].type === 'ordered_list_open') &&
848
+ tokens[k].level === token.level + 2) {
849
+ // Set _parentIsLoose flag to child list
850
+ tokens[k]._parentIsLoose = true
851
+ }
852
+ }
853
+
854
+ j = itemCloseIdx // Jump to next list_item
855
+ }
856
+ }
857
+ }
858
+ }
859
+ }
860
+
861
+ function buildListInfoMap(listInfos) {
862
+ const map = new Map()
863
+ if (!Array.isArray(listInfos)) {
864
+ return map
865
+ }
866
+ for (const info of listInfos) {
867
+ if (info && typeof info.startIndex === 'number') {
868
+ map.set(info.startIndex, info)
869
+ }
870
+ }
871
+ return map
872
+ }
873
+
874
+ function collectListItemRanges(tokens, listOpenIdx, listCloseIdx) {
875
+ const ranges = []
876
+ if (listOpenIdx === -1 || listCloseIdx === -1 || listCloseIdx <= listOpenIdx) {
877
+ return ranges
878
+ }
879
+ const baseLevel = tokens[listOpenIdx]?.level ?? 0
880
+ let markerIndex = 0
881
+ for (let i = listOpenIdx + 1; i < listCloseIdx; i++) {
882
+ const token = tokens[i]
883
+ if (token.type === 'list_item_open' && token.level === baseLevel + 1) {
884
+ const closeIdx = findMatchingClose(tokens, i, 'list_item_open', 'list_item_close')
885
+ if (closeIdx === -1) {
886
+ break
887
+ }
888
+ ranges.push({ open: i, close: closeIdx, markerIndex })
889
+ markerIndex++
890
+ i = closeIdx
891
+ }
892
+ }
893
+ return ranges
894
+ }
895
+
896
+ function buildNestedListTokens(tokens, childRanges, innerListOpenIdx, innerListCloseIdx, levelShift, markerStartIndex, innerListInfo) {
897
+ if (!Array.isArray(childRanges) || childRanges.length === 0) {
898
+ return []
899
+ }
900
+ const nestedTokens = []
901
+ const nestedOpen = cloneToken(tokens[innerListOpenIdx], { levelShift })
902
+ const markerInfoSlice = createMarkerInfoSlice(innerListInfo?.markerInfo, markerStartIndex)
903
+ if (markerInfoSlice) {
904
+ nestedOpen._markerInfo = markerInfoSlice
905
+ const firstMarker = markerInfoSlice.markers?.[0]
906
+ const firstNumber = firstMarker ? (firstMarker.originalNumber ?? firstMarker.number) : undefined
907
+ if (typeof firstNumber === 'number') {
908
+ nestedOpen._startOverride = firstNumber
909
+ }
910
+ }
911
+ nestedTokens.push(nestedOpen)
912
+ for (const range of childRanges) {
913
+ for (let i = range.open; i <= range.close; i++) {
914
+ nestedTokens.push(cloneToken(tokens[i], { levelShift, deep: true }))
915
+ }
916
+ }
917
+ const nestedClose = cloneToken(tokens[innerListCloseIdx], { levelShift })
918
+ nestedTokens.push(nestedClose)
919
+ return nestedTokens
920
+ }
921
+
922
+ function cloneToken(token, options = {}) {
923
+ const { levelShift = 0, deep = false } = options
924
+ const TokenClass = token.constructor
925
+ const cloned = new TokenClass(token.type, token.tag, token.nesting)
926
+ cloned.attrs = token.attrs ? token.attrs.map(([name, value]) => [name, value]) : null
927
+ cloned.map = token.map ? [...token.map] : null
928
+ cloned.level = (token.level || 0) + levelShift
929
+ cloned.content = token.content
930
+ cloned.markup = token.markup
931
+ cloned.info = token.info
932
+ cloned.meta = token.meta ? { ...token.meta } : null
933
+ cloned.block = token.block
934
+ cloned.hidden = token.hidden
935
+ if (Array.isArray(token.children)) {
936
+ cloned.children = token.children.length > 0
937
+ ? token.children.map(child => cloneToken(child, { levelShift, deep: true }))
938
+ : []
939
+ } else {
940
+ cloned.children = token.children ?? null
941
+ }
942
+ if (token._markerInfo) {
943
+ cloned._markerInfo = cloneMarkerInfo(token._markerInfo)
944
+ }
945
+ if (token._convertedFromBullet) {
946
+ cloned._convertedFromBullet = token._convertedFromBullet
947
+ }
948
+ if (token._parentIsLoose) {
949
+ cloned._parentIsLoose = token._parentIsLoose
950
+ }
951
+ if (token._startOverride !== undefined) {
952
+ cloned._startOverride = token._startOverride
953
+ }
954
+ return cloned
955
+ }
956
+
957
+ function cloneMarkerInfo(markerInfo) {
958
+ if (!markerInfo) {
959
+ return null
960
+ }
961
+ const cloned = { ...markerInfo }
962
+ cloned.markers = Array.isArray(markerInfo.markers)
963
+ ? markerInfo.markers.map(marker => ({ ...marker }))
964
+ : null
965
+ return cloned
966
+ }
967
+
968
+ function createMarkerInfoSlice(markerInfo, startIndex) {
969
+ if (!markerInfo || !Array.isArray(markerInfo.markers)) {
970
+ return null
971
+ }
972
+ const markers = markerInfo.markers.slice(startIndex)
973
+ if (markers.length === 0) {
974
+ return null
975
+ }
976
+ const clonedMarkers = markers.map(marker => ({ ...marker }))
977
+ const literalNumbers = clonedMarkers.map(m => (typeof m.originalNumber === 'number' ? m.originalNumber : m.number))
978
+ let allNumbersIdentical = false
979
+ if (literalNumbers.length === clonedMarkers.length && literalNumbers.every(n => typeof n === 'number')) {
980
+ const firstNumber = literalNumbers[0]
981
+ if (literalNumbers.every(n => n === firstNumber) && firstNumber === 1) {
982
+ allNumbersIdentical = true
983
+ }
984
+ }
985
+ const slicedInfo = { ...markerInfo }
986
+ slicedInfo.markers = clonedMarkers
987
+ slicedInfo.count = clonedMarkers.length
988
+ slicedInfo.allNumbersIdentical = allNumbersIdentical
989
+ return slicedInfo
990
+ }
991
+
992
+ function extractFirstListItemNumber(tokens, listOpenIdx, listCloseIdx) {
993
+ if (listOpenIdx === -1 || listCloseIdx === -1) {
994
+ return null
995
+ }
996
+ const baseLevel = tokens[listOpenIdx]?.level ?? 0
997
+ for (let i = listOpenIdx + 1; i < listCloseIdx; i++) {
998
+ const token = tokens[i]
999
+ if (token.type === 'list_item_open' && token.level === baseLevel + 1) {
1000
+ if (token.info) {
1001
+ const parsed = parseInt(token.info, 10)
1002
+ if (!Number.isNaN(parsed)) {
1003
+ return parsed
1004
+ }
1005
+ }
1006
+ break
1007
+ }
1008
+ }
1009
+ return null
1010
+ }
1011
+
1012
+ function normalizeParentMarkers(allMarkers, itemIndices) {
1013
+ if (!Array.isArray(itemIndices) || itemIndices.length === 0) {
1014
+ return
1015
+ }
1016
+ const literalNumbers = []
1017
+ for (const item of itemIndices) {
1018
+ literalNumbers.push(typeof item.literalNumber === 'number' ? item.literalNumber : null)
1019
+ }
1020
+ if (literalNumbers.some(n => n === null)) {
1021
+ return
1022
+ }
1023
+ const firstNumber = literalNumbers[0]
1024
+ const allSame = literalNumbers.every(n => n === firstNumber)
1025
+ if (!allSame) {
1026
+ return
1027
+ }
1028
+ if (firstNumber !== 1) {
1029
+ return
1030
+ }
1031
+ for (let i = 0; i < itemIndices.length; i++) {
1032
+ const marker = allMarkers[i]
1033
+ if (!marker) {
1034
+ continue
27
1035
  }
1036
+ const adjustedNumber = (firstNumber ?? 1) + i
1037
+ marker.originalNumber = literalNumbers[i]
1038
+ marker.number = adjustedNumber
1039
+ const prefix = marker.prefix || ''
1040
+ const suffix = marker.suffix || ''
1041
+ marker.marker = `${prefix}${adjustedNumber}${suffix}`
28
1042
  }
29
1043
  }
30
-
31
- /**
32
- * Convert bullet_list to ordered_list
33
- */
34
- function convertBulletToOrdered(tokens, listInfo) {
35
- const { startIndex, endIndex, markerInfo, items } = listInfo
36
-
37
- // Tokens may have been deleted by simplifyNestedBulletLists,
38
- // check index validity
39
- if (startIndex >= tokens.length || endIndex >= tokens.length) {
40
- // This listInfo points to already deleted list
41
- return
42
- }
43
-
44
- // Convert start token
45
- if (tokens[startIndex].type === 'bullet_list_open') {
46
- tokens[startIndex].type = 'ordered_list_open'
47
- tokens[startIndex].tag = 'ol'
48
- tokens[startIndex]._convertedFromBullet = true
49
- // Save marker info (used in Phase3)
50
- tokens[startIndex]._markerInfo = markerInfo
51
- }
52
-
53
- // Convert end token
54
- if (tokens[endIndex] && tokens[endIndex].type === 'bullet_list_close') {
55
- tokens[endIndex].type = 'ordered_list_close'
56
- tokens[endIndex].tag = 'ol'
57
- }
58
-
59
- // If item's first paragraph is loose, set _parentIsLoose flag to child lists
60
- // Only when all following conditions are met:
61
- // 1. Item has only single paragraph (exclude if multiple paragraphs)
62
- // 2. Parent item's marker type matches child list's marker type
63
- // (if different, not propagated as it will be flattened with `-1.` pattern)
64
- if (items && items.length > 0) {
65
- for (const item of items) {
66
- if (item.firstParagraphIsLoose && item.hasNestedList && item.nestedLists && item.markerInfo) {
67
- // Check paragraph count in item
68
- // Don't propagate if multiple paragraphs before first nested list
69
- let paragraphCount = 0
70
- let reachedNestedList = false
71
-
72
- for (let i = item.startIndex + 1; i < item.endIndex && !reachedNestedList; i++) {
73
- const token = tokens[i]
74
- if (token.type === 'paragraph_open') {
75
- paragraphCount++
76
- } else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
77
- reachedNestedList = true
78
- }
79
- }
80
-
81
- // Propagate to child lists only if single paragraph
82
- if (paragraphCount === 1) {
83
- for (const nestedList of item.nestedLists) {
84
- if (nestedList && nestedList.startIndex < tokens.length) {
85
- // Compare parent item's marker type with child list's marker type
86
- // Don't propagate if different (will be flattened)
87
- const childMarkerInfo = nestedList.items && nestedList.items[0] && nestedList.items[0].markerInfo
88
- if (!childMarkerInfo || item.markerInfo.markerType !== childMarkerInfo.markerType) {
89
- continue // Skip if marker types differ
90
- }
91
-
92
- const nestedListToken = tokens[nestedList.startIndex]
93
- if (nestedListToken && (nestedListToken.type === 'bullet_list_open' || nestedListToken.type === 'ordered_list_open')) {
94
- // Set flag directly
95
- nestedListToken._parentIsLoose = true
96
- }
97
- }
98
- }
99
- }
100
- }
101
- }
102
- }
103
-
104
- // Remove marker text from inline tokens
105
- if (markerInfo && markerInfo.markers) {
106
- removeMarkersFromContent(tokens, startIndex, endIndex, markerInfo, listInfo)
107
- }
108
- }
109
-
110
- /**
111
- * Remove markers from inline token content
112
- */
113
- function removeMarkersFromContent(tokens, startIndex, endIndex, markerInfo, listInfo) {
114
- const listToken = tokens[startIndex]
115
- const targetLevel = (listToken.level || 0) + 3 // list_open(0) -> list_item(1) -> paragraph(2) -> inline(3)
116
-
117
- // Pre-generate all marker regexes (avoid repeated regex creation in loop)
118
- const markerPatterns = markerInfo.markers.map(marker =>
119
- marker && marker.marker ? new RegExp(`^${escapeRegExp(marker.marker)}\\s*`) : null
120
- )
121
-
122
- let markerIndex = 0
123
- for (let i = startIndex + 1; i < endIndex && markerIndex < markerPatterns.length; i++) {
124
- const token = tokens[i]
125
-
126
- if (token.type === 'inline' && token.content && token.level === targetLevel) {
127
- const markerPattern = markerPatterns[markerIndex]
128
- if (markerPattern) {
129
- // Remove marker and trailing spaces
130
- const newContent = token.content.replace(markerPattern, '')
131
-
132
- // Only advance to next marker if actually removed
133
- if (newContent !== token.content) {
134
- token.content = newContent
135
- markerIndex++
136
- }
137
- }
138
- }
139
- }
140
- }
141
-
142
- /**
143
- * 正規表現の特殊文字をエスケープ
144
- */
145
- function escapeRegExp(string) {
146
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
147
- }
148
-
149
- /**
150
- * Simplify nested ul>li>ul and ul>li>ol structures.
151
- *
152
- * Pattern 1: bullet_list_open → list_item_open → bullet_list_open → ...
153
- * Pattern 2: bullet_list_open → list_item_open → ordered_list_open → ... (repeated)
154
- *
155
- * When the middle list_item is empty (contains only the inner list),
156
- * remove the outer ul and the intermediate li.
157
- * @param {Array} tokens - Token array
158
- * @param {Array} listInfos - List information analyzed in Phase 1
159
- * @param {Object} opt - Options
160
- * @param {Map} listInfoMap - Map of listInfo keyed by startIndex
161
- */
162
- function simplifyNestedBulletLists(tokens, listInfos, opt, listInfoMap = null) {
163
- let modified = true
164
-
165
- // ===== Phase 1: Simplify ul>li>ol structure (multiple passes needed) =====
166
- while (modified) {
167
- modified = false
168
-
169
- for (let i = 0; i < tokens.length; i++) {
170
- const token = tokens[i]
171
-
172
- // Only detect bullet_list_open (ordered_list processing will be done later)
173
- if (token.type !== 'bullet_list_open') {
174
- continue
175
- }
176
-
177
- // Check if this bullet_list is all ul>li>ol/ul pattern
178
- const listCloseIdx = findMatchingClose(tokens, i, 'bullet_list_open', 'bullet_list_close')
179
- if (listCloseIdx === -1) continue
180
-
181
- // Check all list_items in bullet_list
182
- const itemIndices = []
183
- let totalItems = 0
184
- let idx = i + 1
185
-
186
- while (idx < listCloseIdx) {
187
- if (tokens[idx].type === 'list_item_open') {
188
- totalItems++
189
- const itemCloseIdx = findMatchingClose(tokens, idx, 'list_item_open', 'list_item_close')
190
-
191
-
192
- // Find inner list within list_item
193
- let innerListOpen = -1
194
- let innerListType = null
195
- for (let j = idx + 1; j < itemCloseIdx; j++) {
196
- if (tokens[j].type === 'bullet_list_open' || tokens[j].type === 'ordered_list_open') {
197
- innerListOpen = j
198
- innerListType = tokens[j].type
199
- break
200
- }
201
- }
202
-
203
- if (innerListOpen !== -1) {
204
- const innerListCloseType = innerListType === 'bullet_list_open' ? 'bullet_list_close' : 'ordered_list_close'
205
- const innerListCloseIdx = findMatchingClose(tokens, innerListOpen, innerListType, innerListCloseType)
206
-
207
- // Check if there's extra content before/after ol (whether it's only ol)
208
- const beforeContent = innerListOpen - (idx + 1) // Token count from after list_item_open to ol
209
- const afterContent = itemCloseIdx - (innerListCloseIdx + 1) // Token count from after ol to list_item_close
210
- const hasExtraContent = beforeContent > 0 || afterContent > 0
211
-
212
- itemIndices.push({
213
- outerItemOpen: idx,
214
- outerItemClose: itemCloseIdx,
215
- innerListOpen: innerListOpen,
216
- innerListClose: innerListCloseIdx,
217
- innerListType: innerListType,
218
- hasExtraContent: hasExtraContent,
219
- extraContentStart: innerListCloseIdx + 1,
220
- extraContentEnd: itemCloseIdx
221
- })
222
- }
223
-
224
- idx = itemCloseIdx + 1
225
- } else {
226
- idx++
227
- }
228
- }
229
-
230
- // Check if outer ul is convertible (has markers)
231
- const outerListInfo = listInfos?.find(info => info.startIndex === i && info.shouldConvert)
232
- const outerListHasMarkers = outerListInfo?.shouldConvert && outerListInfo.markerInfo
233
-
234
- // Simplification conditions:
235
- // 1. All list_items have inner lists of the same type (traditional logic)
236
- // 2. Outer list has markers and at least one item has inner list (new logic)
237
- const allItemsHaveInnerList = itemIndices.length > 0 && itemIndices.length === totalItems
238
- const shouldSimplify = allItemsHaveInnerList || outerListHasMarkers
239
-
240
- if (shouldSimplify && itemIndices.length > 0) {
241
- const allSameType = itemIndices.every(item => item.innerListType === itemIndices[0].innerListType)
242
- const firstInnerType = itemIndices[0].innerListType
243
- const hasExtraContent = itemIndices.some(item => item.hasExtraContent)
244
-
245
- // Don't simplify if inner lists are mixed types
246
- if (!allSameType) {
247
- continue
248
- }
249
-
250
- // Merge marker info from each inner list
251
- const allMarkers = []
252
-
253
- // Use outer listInfo marker info if available
254
- if (outerListInfo && outerListInfo.markerInfo && outerListInfo.markerInfo.markers) {
255
- allMarkers.push(...outerListInfo.markerInfo.markers)
256
- }
257
-
258
- for (const item of itemIndices) {
259
- const innerListToken = tokens[item.innerListOpen]
260
-
261
- // If went through Phase2 convertBulletToOrdered
262
- if (innerListToken._markerInfo && innerListToken._markerInfo.markers) {
263
- allMarkers.push(...innerListToken._markerInfo.markers)
264
- }
265
- // If originally ordered_list, get marker info from start attribute and markup
266
- else if (!outerListInfo || !outerListInfo.markerInfo) {
267
- // Only get from inner if outer has no marker info
268
- // Get start attribute and markup
269
- const startAttr = innerListToken.attrs?.find(attr => attr[0] === 'start')
270
- let startNumber = startAttr ? parseInt(startAttr[1], 10) : 1
271
- const suffix = innerListToken.markup || '.' // markup is suffix
272
- let itemCount = 0
273
-
274
- // Get info from list_item_open (number set by markdown-it)
275
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
276
- if (tokens[j].type === 'list_item_open' && tokens[j].level === innerListToken.level + 1) {
277
- // Get number from info
278
- const number = tokens[j].info ? parseInt(tokens[j].info, 10) : startNumber + itemCount
279
- allMarkers.push({
280
- type: 'decimal',
281
- marker: number + suffix,
282
- number: number,
283
- prefix: '',
284
- suffix: suffix
285
- })
286
- itemCount++
287
- }
288
- }
289
- }
290
- }
291
-
292
- // Build new token array
293
- const newTokens = []
294
-
295
- // Tokens before bullet_list_open
296
- for (let j = 0; j < i; j++) {
297
- newTokens.push(tokens[j])
298
- }
299
-
300
- // Add first inner list open token and save merged marker info
301
- const firstListToken = tokens[itemIndices[0].innerListOpen]
302
-
303
- // If outer ul is convertible, convert inner list to ordered_list
304
- // (Don't limit to bullet_list as inner list might already be ordered_list)
305
- if (outerListInfo && outerListInfo.shouldConvert) {
306
- if (firstListToken.type === 'bullet_list_open') {
307
- firstListToken.type = 'ordered_list_open'
308
- firstListToken.tag = 'ol'
309
- firstListToken._convertedFromBullet = true
310
- }
311
- // If already ordered_list, use as is (already converted)
312
- }
313
-
314
- newTokens.push(firstListToken)
315
-
316
- // Merge and save marker info
317
- if (allMarkers.length > 0) {
318
- const firstMarkerType = allMarkers[0]?.type
319
- firstListToken._markerInfo = {
320
- markers: allMarkers,
321
- type: firstMarkerType,
322
- isConsistent: allMarkers.every(m => m.type === firstMarkerType),
323
- count: allMarkers.length
324
- }
325
- }
326
-
327
- // ===== Outer UL (Level 0 after conversion) loose/tight determination =====
328
- // In flattened pattern (`- 1.` etc), no direct paragraph in outer list_item,
329
- // so cannot detect with markdown-it's paragraph.hidden
330
- // Instead, use map info of outer list_item to detect blank lines between items
331
- let outerUlIsLoose = false
332
-
333
- // Single item is always tight
334
- if (itemIndices.length === 1) {
335
- outerUlIsLoose = false
336
- } else {
337
- // For multiple items, check blank lines with map info
338
- for (let itemIdx = 0; itemIdx < itemIndices.length - 1; itemIdx++) {
339
- const currentItem = itemIndices[itemIdx]
340
- const nextItem = itemIndices[itemIdx + 1]
341
-
342
- // Get currentItem end line: list_item_close map or last token map
343
- let currentEndLine = null
344
- // Find list_item_close map (usually null, so use last token instead)
345
- for (let k = currentItem.outerItemClose - 1; k > currentItem.outerItemOpen; k--) {
346
- if (tokens[k].map && tokens[k].map[1]) {
347
- currentEndLine = tokens[k].map[1]
348
- break
349
- }
350
- }
351
-
352
- const nextMap = tokens[nextItem.outerItemOpen].map
353
-
354
- if (currentEndLine !== null && nextMap) {
355
- // Compare currentItem end line and nextItem start line
356
- // If line numbers differ, there's blank line
357
- const lineGap = nextMap[0] - currentEndLine
358
- if (lineGap > 0) {
359
- outerUlIsLoose = true
360
- break
361
- }
362
- }
363
- }
364
- }
365
-
366
- // ===== Nested list overall loose/tight determination =====
367
- // Inner list (converted ol) loose/tight determination
368
- // If parent item is loose (_parentIsLoose flag), or
369
- // if outer ul is loose, this inner list is also loose
370
-
371
- // First, check if there are blank lines between converted ol list_items (independent of outerUlIsLoose)
372
- // This is used for propagation to child lists
373
- let innerListIsLooseDueToBlankLines = false
374
-
375
- // In flattened pattern (`- 1.` etc), each outer list_item has one inner ol
376
- // Check if there are blank lines between list_items in inner ol
377
- // (Blank lines between outer list_items already checked by outerUlIsLoose)
378
- for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
379
- const item = itemIndices[itemIdx]
380
-
381
- // Collect list_items in inner list
382
- const innerListItems = []
383
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
384
- if (tokens[j].type === 'list_item_open' &&
385
- tokens[j].level === tokens[item.innerListOpen].level + 1) {
386
- const itemOpen = j
387
- const itemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
388
- innerListItems.push({ open: itemOpen, close: itemClose })
389
- }
390
- }
391
-
392
- // Check blank lines between list_items in inner list
393
- if (innerListItems.length > 1) {
394
- for (let k = 0; k < innerListItems.length - 1; k++) {
395
- const currentItem = innerListItems[k]
396
- const nextItem = innerListItems[k + 1]
397
-
398
- // Get currentItem end line
399
- let currentEndLine = null
400
- for (let m = currentItem.close - 1; m > currentItem.open; m--) {
401
- if (tokens[m].map && tokens[m].map[1]) {
402
- currentEndLine = tokens[m].map[1]
403
- break
404
- }
405
- }
406
-
407
- const nextMap = tokens[nextItem.open].map
408
-
409
- if (currentEndLine !== null && nextMap) {
410
- const lineGap = nextMap[0] - currentEndLine
411
- if (lineGap > 0) {
412
- innerListIsLooseDueToBlankLines = true
413
- break
414
- }
415
- }
416
- }
417
- if (innerListIsLooseDueToBlankLines) break
418
- }
419
- }
420
-
421
- // Check markdown-it's paragraph.hidden
422
- // If inner list has paragraph with hidden=false, it's loose
423
- if (!innerListIsLooseDueToBlankLines) {
424
- for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
425
- const item = itemIndices[itemIdx]
426
-
427
- let innerListItemOpen = -1
428
- let innerListItemClose = -1
429
-
430
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
431
- if (tokens[j].type === 'list_item_open' &&
432
- tokens[j].level === tokens[item.innerListOpen].level + 1) {
433
- innerListItemOpen = j
434
- innerListItemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
435
- break
436
- }
437
- }
438
-
439
- if (innerListItemOpen !== -1 && innerListItemClose !== -1) {
440
- // Check hidden of first paragraph in inner list item
441
- let nestedListDepth = 0
442
- for (let j = innerListItemOpen + 1; j < innerListItemClose; j++) {
443
- const t = tokens[j]
444
- if (t.type === 'bullet_list_open' || t.type === 'ordered_list_open') {
445
- nestedListDepth++
446
- } else if (t.type === 'bullet_list_close' || t.type === 'ordered_list_close') {
447
- nestedListDepth--
448
- } else if (nestedListDepth === 0 && t.type === 'paragraph_open') {
449
- if (t.hidden === false) {
450
- innerListIsLooseDueToBlankLines = true
451
- }
452
- break
453
- }
454
- }
455
- if (innerListIsLooseDueToBlankLines) break
456
- }
457
- }
458
- }
459
-
460
- // If outerUlIsLoose, force first paragraph of each inner list item to loose (hidden=false)
461
- // markdown-it judges tight/loose by original ul structure, but after flattening to ol structure
462
- // should reflect parent ul's loose state
463
- // However, exclude case where both parent ul and each inner ol have single item
464
- // This must be executed AFTER innerListIsLooseDueToBlankLines determination
465
- if (outerUlIsLoose && !(itemIndices.length === 1)) {
466
- for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
467
- const item = itemIndices[itemIdx]
468
- // Find list_items in inner list
469
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
470
- if (tokens[j].type === 'list_item_open' &&
471
- tokens[j].level === tokens[item.innerListOpen].level + 1) {
472
- // Find first paragraph in list_item
473
- const listItemOpen = j
474
- const listItemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
475
- for (let k = listItemOpen + 1; k < listItemClose; k++) {
476
- if (tokens[k].type === 'paragraph_open' &&
477
- tokens[k].level === tokens[listItemOpen].level + 1) {
478
- tokens[k].hidden = false
479
- break // Only first paragraph
480
- }
481
- }
482
- break // Only first list_item of inner list
483
- }
484
- }
485
- }
486
- }
487
-
488
- // Final innerListIsLoose: _parentIsLoose, outerUlIsLoose, or blank line determination
489
- let innerListIsLoose = token._parentIsLoose || outerUlIsLoose || innerListIsLooseDueToBlankLines
490
-
491
- // ===== Merge each inner list's contents and place extra content appropriately =====
492
- for (let itemIdx = 0; itemIdx < itemIndices.length; itemIdx++) {
493
- const item = itemIndices[itemIdx]
494
-
495
- // Count list_items in this inner list (ordered_list)
496
- let innerListItemCount = 0
497
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
498
- if (tokens[j].type === 'list_item_open' &&
499
- tokens[j].level === tokens[item.innerListOpen].level + 1) {
500
- innerListItemCount++
501
- }
502
- }
503
-
504
- // Process list_items in this inner list (ordered_list)
505
- // Inner list usually contains only one list_item (ul>li>ol>li structure)
506
- let innerListItemOpen = -1
507
- let innerListItemClose = -1
508
-
509
- for (let j = item.innerListOpen + 1; j < item.innerListClose; j++) {
510
- if (tokens[j].type === 'list_item_open' &&
511
- tokens[j].level === tokens[item.innerListOpen].level + 1) {
512
- innerListItemOpen = j
513
- innerListItemClose = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
514
- break
515
- }
516
- }
517
-
518
- if (innerListItemOpen !== -1 && innerListItemClose !== -1) {
519
- // Add list_item_open (use original token to preserve info)
520
- // Note: value attribute is added in Phase3 (not during simplification)
521
- newTokens.push(tokens[innerListItemOpen])
522
-
523
- // This item's loose/tight determination
524
- // If entire list is loose, this item is also loose
525
- // Determination when extraContent has block elements:
526
- // - extraContent has paragraph(hidden=false) → loose
527
- // - Has heading(heading_open) or HTML block(html_block) → loose
528
- // - markdown-it sets paragraph.hidden=true → maintain tight
529
- let hasBlockElement = false
530
- if (item.hasExtraContent) {
531
- for (let k = item.extraContentStart; k < item.extraContentEnd; k++) {
532
- const tokenType = tokens[k].type
533
- // Detect block elements:
534
- // - paragraph(hidden=false): multiple paragraphs
535
- // - heading_open: heading
536
- // - html_block: HTML block
537
- if ((tokenType === 'paragraph_open' && !tokens[k].hidden) ||
538
- tokenType === 'heading_open' ||
539
- tokenType === 'html_block') {
540
- hasBlockElement = true
541
- break
542
- }
543
- }
544
- }
545
-
546
- // Whether this item's first paragraph is loose
547
- // is obtained from Phase1-analyzed listInfo.items[itemIdx]
548
- const listItem = outerListInfo?.items?.[itemIdx]
549
- const firstParagraphIsLoose = listItem?.firstParagraphIsLoose || false
550
-
551
- // Make loose if outer ul, entire inner list, or this item has extraContent
552
- // - However, consider cases with multiple paragraphs in item (innerListIsLoose) or
553
- // block elements (hasBlockElement)
554
- //
555
- // Single item determination in flattened pattern (`- 1.` etc):
556
- // - Parent ul single item AND each inner ol single item → exclude outerUlIsLoose
557
- // - If parent ul has multiple items, consider outerUlIsLoose (reflect parent's blank lines)
558
- let shouldBeLoose
559
- if (itemIndices.length === 1 && innerListItemCount === 1) {
560
- // Exclude outerUlIsLoose if both parent ul and each inner ol are single item
561
- // Consider innerListIsLoose and hasBlockElement (block elements)
562
- shouldBeLoose = innerListIsLoose || hasBlockElement
563
- } else {
564
- // Normal determination if multiple items or parent ul has multiple items
565
- // Consider outerUlIsLoose (reflect parent list's blank lines)
566
- shouldBeLoose = outerUlIsLoose || innerListIsLoose || hasBlockElement
567
- }
568
-
569
- // Determine whether to propagate _parentIsLoose flag to child lists
570
- // - innerListIsLooseDueToBlankLines: blank lines between converted ol list_items
571
- // - token._parentIsLoose: already propagated from parent
572
- // - firstParagraphIsLoose: blank line after parent item's first paragraph (same marker type only)
573
- // Note: hasExtraContent makes parent item loose but doesn't affect child lists
574
- // Note: outerUlIsLoose excluded (Test 25: handle case where parent is loose but child is tight)
575
- const shouldPropagateLooseToChildren = innerListIsLooseDueToBlankLines || token._parentIsLoose || firstParagraphIsLoose || false
576
-
577
- // Copy tokens in inner list item (exclude nested list paragraphs)
578
- let nestedListDepth = 0
579
- let firstParagraphInItem = true // Track first paragraph in each list_item
580
- for (let j = innerListItemOpen + 1; j < innerListItemClose; j++) {
581
- const tokenToPush = tokens[j]
582
-
583
- // Track nested list depth
584
- if (tokenToPush.type === 'bullet_list_open' || tokenToPush.type === 'ordered_list_open') {
585
- nestedListDepth++
586
- // If parent list is loose (due to blank lines), set _parentIsLoose flag to child lists
587
- // Don't set if only outerUlIsLoose
588
- // Propagate to all nest levels (support level >= 2)
589
- if (shouldPropagateLooseToChildren) {
590
- // Set _parentIsLoose flag (optimize with direct property assignment)
591
- tokenToPush._parentIsLoose = true
592
- newTokens.push(tokenToPush)
593
- } else {
594
- newTokens.push(tokenToPush)
595
- }
596
- } else if (tokenToPush.type === 'bullet_list_close' || tokenToPush.type === 'ordered_list_close') {
597
- nestedListDepth--
598
- newTokens.push(tokenToPush)
599
- } else if (nestedListDepth === 0 && (tokenToPush.type === 'paragraph_open' || tokenToPush.type === 'paragraph_close')) {
600
- // Update paragraph_open/close hidden state (only outside nested lists)
601
-
602
- if (tokenToPush.type === 'paragraph_open') {
603
- // Process first paragraph
604
- if (firstParagraphInItem) {
605
- // If tight list (shouldBeLoose = false), hide first paragraph
606
- tokenToPush.hidden = !shouldBeLoose
607
- firstParagraphInItem = false
608
- } else {
609
- // Always show second and subsequent paragraphs
610
- tokenToPush.hidden = false
611
- }
612
- } else {
613
- // Match paragraph_close hidden state to corresponding paragraph_open
614
- // Reference preceding paragraph_open's hidden state
615
- const prevToken = newTokens[newTokens.length - 2] // Skip preceding inline, get paragraph_open before it
616
- if (prevToken && prevToken.type === 'paragraph_open') {
617
- tokenToPush.hidden = prevToken.hidden
618
- } else {
619
- // Fallback: judge based on shouldBeLoose
620
- tokenToPush.hidden = !shouldBeLoose && firstParagraphInItem
621
- }
622
- }
623
-
624
- newTokens.push(tokenToPush)
625
- } else {
626
- newTokens.push(tokenToPush)
627
- }
628
- }
629
-
630
- // Add extra content (content in ul>li, outside ol) to this list_item
631
- if (item.hasExtraContent) {
632
- // Adjust level: ul list_item's level → ol list_item's level
633
- const levelDiff = tokens[innerListItemOpen].level - tokens[item.outerItemOpen].level
634
-
635
- // Track nested lists in extraContent
636
- let extraNestedListDepth = 0
637
- for (let k = item.extraContentStart; k < item.extraContentEnd; k++) {
638
- const tokenToPush = tokens[k]
639
- const originalLevel = tokenToPush.level
640
- tokenToPush.level = originalLevel + levelDiff
641
-
642
- // Track nested list depth
643
- if (tokenToPush.type === 'bullet_list_open' || tokenToPush.type === 'ordered_list_open') {
644
- extraNestedListDepth++
645
- // If parent item is loose, set _parentIsLoose flag to child lists
646
- // Don't set if only outerUlIsLoose
647
- if (shouldPropagateLooseToChildren && extraNestedListDepth === 1) {
648
- tokenToPush._parentIsLoose = true
649
- }
650
- } else if (tokenToPush.type === 'bullet_list_close' || tokenToPush.type === 'ordered_list_close') {
651
- extraNestedListDepth--
652
- }
653
-
654
- // If shouldBeLoose, set paragraph hidden to false (only outside nested lists)
655
- if (shouldBeLoose && extraNestedListDepth === 0 && (tokenToPush.type === 'paragraph_open' || tokenToPush.type === 'paragraph_close')) {
656
- tokenToPush.hidden = false
657
- }
658
-
659
- newTokens.push(tokenToPush)
660
- }
661
- }
662
-
663
- // Add list_item_close
664
- newTokens.push(tokens[innerListItemClose])
665
- }
666
- }
667
-
668
- // Add last inner list's close token
669
- const lastItem = itemIndices[itemIndices.length - 1]
670
- const lastListCloseToken = tokens[lastItem.innerListClose]
671
-
672
- // If outer ul is convertible, also convert close token
673
- if (outerListInfo && outerListInfo.shouldConvert) {
674
- if (lastListCloseToken.type === 'bullet_list_close') {
675
- lastListCloseToken.type = 'ordered_list_close'
676
- lastListCloseToken.tag = 'ol'
677
- }
678
- // If already ordered_list_close, use as is
679
- }
680
-
681
- newTokens.push(lastListCloseToken)
682
-
683
- // Tokens after bullet_list_close
684
- for (let j = listCloseIdx + 1; j < tokens.length; j++) {
685
- newTokens.push(tokens[j])
686
- }
687
-
688
- // Replace token array
689
- tokens.length = 0
690
- tokens.push(...newTokens)
691
-
692
- // Remove markers from merged list
693
- if (firstListToken._markerInfo && firstListToken._markerInfo.markers) {
694
- let listStartIdx = 0
695
- while (listStartIdx < tokens.length && tokens[listStartIdx] !== firstListToken) {
696
- listStartIdx++
697
- }
698
- if (listStartIdx < tokens.length) {
699
- const listEndIdx = findMatchingClose(tokens, listStartIdx,
700
- firstListToken.type,
701
- firstListToken.type.replace('_open', '_close'), false)
702
- if (listEndIdx !== -1) {
703
- removeMarkersFromContent(tokens, listStartIdx, listEndIdx, firstListToken._markerInfo, null)
704
- }
705
- }
706
- }
707
-
708
- modified = true
709
- break
710
- }
711
- }
712
- }
713
-
714
- // ===== Propagate ordered_list's _parentIsLoose flag to child lists =====
715
- // Check all ordered_list_open, and if _parentIsLoose flag exists,
716
- // propagate to child lists
717
- for (let i = 0; i < tokens.length; i++) {
718
- const token = tokens[i]
719
-
720
- if ((token.type === 'ordered_list_open' || token.type === 'bullet_list_open') && token._parentIsLoose) {
721
- // Find this list's close token
722
- const listCloseIdx = findMatchingClose(tokens, i, token.type, token.type.replace('_open', '_close'))
723
- if (listCloseIdx === -1) continue
724
-
725
- // Search list_items in this list and set _parentIsLoose flag to child lists in those list_items
726
- for (let j = i + 1; j < listCloseIdx; j++) {
727
- if (tokens[j].type === 'list_item_open' && tokens[j].level === token.level + 1) {
728
- const itemCloseIdx = findMatchingClose(tokens, j, 'list_item_open', 'list_item_close')
729
-
730
- // Search child lists in this list_item
731
- for (let k = j + 1; k < itemCloseIdx; k++) {
732
- if ((tokens[k].type === 'bullet_list_open' || tokens[k].type === 'ordered_list_open') &&
733
- tokens[k].level === token.level + 2) {
734
- // Set _parentIsLoose flag to child list
735
- tokens[k]._parentIsLoose = true
736
- }
737
- }
738
-
739
- j = itemCloseIdx // Jump to next list_item
740
- }
741
- }
742
- }
743
- }
744
- }