@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,654 +1,654 @@
1
- // Phase 0: Description List Processing
2
- // Converts bullet_list with **Term** pattern to description_list (dl/dt/dd)
3
- // This must run before Phase 1
4
-
5
- import { findMatchingClose, findListEnd as coreFindListEnd, findListItemEnd as coreFindListItemEnd } from './list-helpers.js'
6
-
7
- /**
8
- * Parse attribute string like ".class1 .class2 #id data-foo="bar""
9
- * Returns array of [key, value] pairs
10
- */
11
- const parseAttrString = (attrStr) => {
12
- const attrs = []
13
- const classMatches = attrStr.match(/\.[\w-]+/g)
14
- const idMatch = attrStr.match(/#([\w-]+)/)
15
- const dataMatches = attrStr.match(/([\w-]+)="([^"]+)"/g)
16
-
17
- // Collect all classes
18
- if (classMatches) {
19
- const classes = classMatches.map(c => c.substring(1)).join(' ')
20
- attrs.push(['class', classes])
21
- }
22
-
23
- // Add id
24
- if (idMatch) {
25
- attrs.push(['id', idMatch[1]])
26
- }
27
-
28
- // Add data attributes
29
- if (dataMatches) {
30
- dataMatches.forEach(match => {
31
- const [, key, value] = match.match(/([\w-]+)="([^"]+)"/)
32
- attrs.push([key, value])
33
- })
34
- }
35
-
36
- return attrs
37
- }
38
-
39
- /**
40
- * Process description list patterns in tokens
41
- * @param {Array} tokens - Token array
42
- * @param {Object} opt - Options object
43
- ```
44
- */
45
- export const processDescriptionList = (tokens, opt) => {
46
- if (!opt.descriptionList && !opt.descriptionListWithDiv) {
47
- return
48
- }
49
-
50
- // Find bullet_lists and check if they match DL pattern
51
- // Process in single pass: check and convert together to avoid duplicate scanning
52
- let i = 0
53
- while (i < tokens.length) {
54
- if (tokens[i].type === 'bullet_list_open') {
55
- const listEnd = findListEnd(tokens, i)
56
- const dlCheck = checkAndConvertToDL(tokens, i, listEnd, opt)
57
- i = dlCheck.nextIndex
58
- } else {
59
- i++
60
- }
61
- }
62
-
63
- // Note: moveParagraphAttributesToDL is called separately after inline parsing
64
- // via 'numbering_dl_attrs' rule (runs after any attribute plugins like markdown-it-attrs)
65
- }
66
-
67
- /**
68
- * Find matching list close token
69
- */
70
- const findListEnd = (tokens, startIndex) => {
71
- const result = coreFindListEnd(tokens, startIndex)
72
- return result === -1 ? tokens.length - 1 : result
73
- }
74
-
75
- /**
76
- * Find matching list_item close token
77
- */
78
- const findListItemEnd = (tokens, startIndex) => {
79
- const result = coreFindListItemEnd(tokens, startIndex)
80
- return result === -1 ? tokens.length - 1 : result
81
- }
82
-
83
- /**
84
- * Find matching dl_close token
85
- */
86
- const findDLEnd = (tokens, startIndex) => {
87
- const result = findMatchingClose(tokens, startIndex, 'dl_open', 'dl_close')
88
- return result === -1 ? tokens.length - 1 : result
89
- }
90
-
91
- /**
92
- * Find matching dd_close token
93
- */
94
- const findDDEnd = (tokens, startIndex) => {
95
- const result = findMatchingClose(tokens, startIndex, 'dd_open', 'dd_close')
96
- return result === -1 ? tokens.length - 1 : result
97
- }
98
-
99
- /**
100
- * Check if bullet_list matches DL pattern and convert if true (single pass)
101
- * Returns: { nextIndex: number } - index to continue processing from
102
- */
103
- const checkAndConvertToDL = (tokens, listStart, listEnd, opt) => {
104
- // First pass: validate all items match DL pattern
105
- let hasAnyDLItem = false
106
- let i = listStart + 1
107
-
108
- while (i < listEnd) {
109
- if (tokens[i].type === 'list_item_open') {
110
- const itemEnd = findListItemEnd(tokens, i)
111
-
112
- // Find first paragraph
113
- let firstPara = -1
114
- for (let j = i + 1; j < itemEnd; j++) {
115
- if (tokens[j].type === 'paragraph_open') {
116
- firstPara = j
117
- break
118
- }
119
- }
120
-
121
- if (firstPara !== -1) {
122
- const inlineToken = tokens[firstPara + 1]
123
- if (inlineToken && inlineToken.type === 'inline') {
124
- const dlCheck = isDLPattern(inlineToken.content)
125
- if (dlCheck.isMatch) {
126
- // Check if there's a description
127
- let hasDescription = false
128
-
129
- const afterStrong = dlCheck.afterStrong
130
-
131
- // Pattern 1: **Term** description (2+ spaces, including newlines)
132
- // Pattern 2: **Term**: description (colon)
133
- // Pattern 3: **Term**\ description (backslash escape)
134
- if (/^\s{2,}/.test(afterStrong) || /^\s*:/.test(afterStrong) || /^\\/.test(afterStrong)) {
135
- // Remove leading space/colon/backslash and check remaining text
136
- const cleaned = afterStrong.replace(/^[\s:]+/, '').replace(/^\\/, '').trim()
137
- if (cleaned) {
138
- hasDescription = true
139
- }
140
- }
141
-
142
- // Pattern 4: Description in next paragraph (only **Term** in first para)
143
- if (!hasDescription) {
144
- // Check for additional paragraphs/lists
145
- for (let k = firstPara + 3; k < itemEnd; k++) {
146
- if (tokens[k].type === 'paragraph_open' ||
147
- tokens[k].type === 'bullet_list_open' ||
148
- tokens[k].type === 'ordered_list_open') {
149
- hasDescription = true
150
- break
151
- }
152
- }
153
- }
154
-
155
- // If no description, not a DL item
156
- if (!hasDescription) {
157
- return false
158
- }
159
-
160
- hasAnyDLItem = true
161
- } else {
162
- // Not all items are DL pattern - not a description list
163
- return { nextIndex: listEnd + 1 }
164
- }
165
- }
166
- }
167
-
168
- i = itemEnd + 1
169
- } else {
170
- i++
171
- }
172
- }
173
-
174
- // If valid DL, convert immediately (avoid re-scanning)
175
- if (hasAnyDLItem) {
176
- convertBulletListToDL(tokens, listStart, listEnd, opt)
177
- // After conversion, tokens are replaced - continue from original listEnd position
178
- // Note: convertBulletListToDL may change token count, but we use original listEnd
179
- return { nextIndex: listStart + 1 } // Re-check from start since tokens changed
180
- }
181
-
182
- return { nextIndex: listEnd + 1 }
183
- }
184
-
185
- /**
186
- * Check if content matches DL pattern and return match details
187
- * Returns: { isMatch: boolean, afterStrong: string|null }
188
- */
189
- const isDLPattern = (content) => {
190
- if (!content) return { isMatch: false, afterStrong: null }
191
-
192
- // Match **Term** pattern (allow spaces inside for markdown-it-strong-ja compatibility)
193
- const match = content.match(/^\*\*(.*?)\*\*(.*)/s) // s flag for including newlines
194
- if (!match) return { isMatch: false, afterStrong: null }
195
-
196
- const afterStrong = match[2] // Text after closing **
197
-
198
- // Pattern 1: **Term** description (2+ spaces, including newlines)
199
- // Pattern 2: **Term**: description (colon)
200
- // Pattern 3: **Term**\ description (backslash escape)
201
- // Pattern 4: **Term** only (no content after)
202
- // Pattern 5: **Term** {.attrs} (markdown-it-attrs syntax, optionally with content after)
203
- const isMatch = /^\s{2,}/.test(afterStrong) ||
204
- /^\s*:/.test(afterStrong) ||
205
- /^\\/.test(afterStrong) ||
206
- /^\s*$/.test(afterStrong) ||
207
- /^\s*\{[^}]+\}/.test(afterStrong) // {.class} or {#id} etc
208
-
209
- return { isMatch, afterStrong }
210
- }
211
-
212
- /**
213
- * Convert bullet_list to dl/dt/dd structure using dl_open/dl_close tokens
214
- */
215
- const convertBulletListToDL = (tokens, listStart, listEnd, opt) => {
216
- const newTokens = []
217
- const listLevel = tokens[listStart].level
218
-
219
- // Create dl_open token
220
- const dlOpen = new tokens[listStart].constructor('dl_open', 'dl', 1)
221
- dlOpen.level = listLevel
222
- dlOpen.block = true
223
-
224
- // Copy attributes from bullet_list_open (e.g., {.class} from markdown-it-attrs)
225
- if (tokens[listStart].attrs && tokens[listStart].attrs.length > 0) {
226
- dlOpen.attrs = tokens[listStart].attrs.slice()
227
- }
228
-
229
- // Store pending attrs from list items (Pattern B: {.attrs} on last line of description)
230
- // These will be applied by moveParagraphAttributesToDL after markdown-it-attrs runs
231
- dlOpen._pendingListAttrs = []
232
-
233
- // Store DL metadata for later optimization in moveParagraphAttributesToDL
234
- dlOpen._dlMetadata = {
235
- itemCount: 0, // Will be updated during processing
236
- lastDdTokenIndex: -1 // Will be updated to point to last dd_open in newTokens
237
- }
238
-
239
- newTokens.push(dlOpen)
240
-
241
- // Collect attrs from list items
242
- const listAttrsFromItems = []
243
-
244
- // Process each list_item
245
- let i = listStart + 1
246
- while (i < listEnd) {
247
- if (tokens[i].type === 'list_item_open') {
248
- const itemEnd = findListItemEnd(tokens, i)
249
- const result = convertListItemToDtDd(tokens, i, itemEnd, listLevel, opt)
250
-
251
- // Update metadata
252
- dlOpen._dlMetadata.itemCount++
253
-
254
- // Check for list-level attrs returned from item
255
- if (result.listAttrs && result.listAttrs.length > 0) {
256
- listAttrsFromItems.push(...result.listAttrs)
257
- }
258
-
259
- if (result.tokens) {
260
- // Track last dd_open position (relative to newTokens)
261
- for (let j = 0; j < result.tokens.length; j++) {
262
- if (result.tokens[j].type === 'dd_open') {
263
- dlOpen._dlMetadata.lastDdTokenIndex = newTokens.length + j
264
- }
265
- }
266
- newTokens.push(...result.tokens)
267
- }
268
-
269
- i = itemEnd + 1
270
- } else {
271
- i++
272
- }
273
- }
274
-
275
- // Store pending list attrs for later processing
276
- if (listAttrsFromItems.length > 0) {
277
- dlOpen._pendingListAttrs = listAttrsFromItems
278
- }
279
-
280
- // Create dl_close token
281
- const dlClose = new tokens[listStart].constructor('dl_close', 'dl', -1)
282
- dlClose.level = listLevel
283
- dlClose.block = true
284
- newTokens.push(dlClose)
285
-
286
- // Replace tokens
287
- tokens.splice(listStart, listEnd - listStart + 1, ...newTokens)
288
- }
289
-
290
- /**
291
- * Convert list_item to dt/dd structure
292
- * Returns: { tokens: [...], listAttrs: [...] }
293
- */
294
- const convertListItemToDtDd = (tokens, itemStart, itemEnd, parentLevel, opt) => {
295
- const result = []
296
- const listAttrs = [] // Attrs to be applied to dl (not dt/dd)
297
-
298
- // Get attributes from list_item_open (markdown-it-attrs may put {.class} there)
299
- const listItemToken = tokens[itemStart]
300
- let dtAttrs = listItemToken.attrs ? [...listItemToken.attrs] : null
301
-
302
- // Find first paragraph
303
- let firstPara = -1
304
- for (let i = itemStart + 1; i < itemEnd; i++) {
305
- if (tokens[i].type === 'paragraph_open') {
306
- firstPara = i
307
- break
308
- }
309
- }
310
-
311
- if (firstPara === -1) return { tokens: result, listAttrs }
312
-
313
- const inlineToken = tokens[firstPara + 1]
314
- if (!inlineToken || inlineToken.type !== 'inline') return { tokens: result, listAttrs }
315
-
316
- // Extract term and description from inline token's content
317
- let term = ''
318
- let descStart = ''
319
-
320
- const content = inlineToken.content
321
- const match = content.match(/^\*\*(.*?)\*\*(.*)/s) // s flag for including newlines
322
-
323
- if (match) {
324
- term = match[1] // Keep spaces for now (will be processed by inline parser)
325
- let afterStrong = match[2]
326
-
327
- // Extract {.attrs} from afterStrong if present (markdown-it-attrs hasn't processed yet)
328
- // Pattern A: Inline {.attrs} immediately after **Term** like **Term** {.class}
329
- const inlineAttrsMatch = afterStrong.match(/^\s*\{([^}]+)\}/)
330
- if (inlineAttrsMatch) {
331
- // Parse attributes manually
332
- const attrString = inlineAttrsMatch[1]
333
- const parsedAttrs = parseAttrString(attrString)
334
- if (parsedAttrs.length > 0) {
335
- if (!dtAttrs) {
336
- dtAttrs = []
337
- }
338
- dtAttrs.push(...parsedAttrs)
339
- }
340
- // Remove {.attrs} from afterStrong (including trailing newline if attrs-only line)
341
- afterStrong = afterStrong.replace(/^\s*\{[^}]+\}\s*/, '')
342
- }
343
-
344
- // Pattern B: {.attrs} on last line (e.g., "Description\n{.attrs}")
345
- // This will be processed by markdown-it-attrs and applied to list, not paragraph
346
- // We need to remove it from description content and save for list-level attrs
347
- const lastLineAttrsMatch = afterStrong.match(/\n\s*\{([^}]+)\}\s*$/)
348
- if (lastLineAttrsMatch) {
349
- // Parse attributes
350
- const attrString = lastLineAttrsMatch[1]
351
- const parsedAttrs = parseAttrString(attrString)
352
- if (parsedAttrs.length > 0) {
353
- listAttrs.push(...parsedAttrs)
354
- }
355
- // Remove {.attrs} line from afterStrong
356
- afterStrong = afterStrong.replace(/\n\s*\{[^}]+\}\s*$/, '')
357
- }
358
-
359
- // Clean up afterStrong: remove leading spaces/colon/backslash, then trim each line
360
- let cleaned = afterStrong.replace(/^[\s:]+/, '').replace(/^\\/, '')
361
-
362
- // Remove leading whitespace from each line (remove list indent)
363
- cleaned = cleaned.split('\n').map(line => line.replace(/^\s+/, '')).join('\n').trim()
364
-
365
- descStart = cleaned
366
- }
367
-
368
- if (!term) return { tokens: result, listAttrs }
369
-
370
- // Create div_open if descriptionListWithDiv is enabled
371
- if (opt && opt.descriptionListWithDiv) {
372
- const divOpen = new tokens[firstPara].constructor('div_open', 'div', 1)
373
- divOpen.level = parentLevel + 1
374
- divOpen.block = true
375
- const divClass = typeof opt.descriptionListDivClass === 'string' ? opt.descriptionListDivClass : ''
376
- if (divClass) {
377
- divOpen.attrs = [['class', divClass]]
378
- }
379
- result.push(divOpen)
380
- }
381
-
382
- // Create dt_open token
383
- const dtOpen = new tokens[firstPara].constructor('dt_open', 'dt', 1)
384
- dtOpen.level = parentLevel + 1
385
- dtOpen.block = true
386
- // Add collected attributes to dt_open
387
- if (dtAttrs && dtAttrs.length > 0) {
388
- dtOpen.attrs = dtAttrs
389
- }
390
- result.push(dtOpen)
391
-
392
- // Create inline token for dt content
393
- const dtInline = new tokens[firstPara].constructor('inline', '', 0)
394
- dtInline.content = term // Keep original Markdown for inline parser to process
395
- dtInline.level = parentLevel + 2
396
- dtInline.children = [] // Will be populated by inline parser
397
- result.push(dtInline)
398
-
399
- // Create dt_close token
400
- const dtClose = new tokens[firstPara].constructor('dt_close', 'dt', -1)
401
- dtClose.level = parentLevel + 1
402
- dtClose.block = true
403
- result.push(dtClose)
404
-
405
- // Create dd_open token
406
- const ddOpen = new tokens[firstPara].constructor('dd_open', 'dd', 1)
407
- ddOpen.level = parentLevel + 1
408
- ddOpen.block = true
409
- result.push(ddOpen)
410
-
411
- // First paragraph in dd (if description exists)
412
- let hasFirstParagraph = false
413
- if (descStart.trim()) {
414
- const pOpen = new tokens[firstPara].constructor('paragraph_open', 'p', 1)
415
- pOpen.level = parentLevel + 2
416
- pOpen.block = true // IMPORTANT: Enable block mode for proper newline rendering
417
- result.push(pOpen)
418
-
419
- const pInline = new tokens[firstPara].constructor('inline', '', 0)
420
- pInline.content = '' // Leave empty, text token has the content
421
- pInline.level = parentLevel + 3
422
- pInline.block = true // IMPORTANT: Enable block mode
423
- const pText = new tokens[firstPara].constructor('text', '', 0)
424
- pText.content = descStart.trim()
425
- pInline.children = [pText]
426
- result.push(pInline)
427
-
428
- const pClose = new tokens[firstPara].constructor('paragraph_close', 'p', -1)
429
- pClose.level = parentLevel + 2
430
- pClose.block = true // IMPORTANT: Enable block mode for proper newline rendering
431
- result.push(pClose)
432
-
433
- hasFirstParagraph = true
434
- }
435
-
436
- // Add remaining content in dd (paragraphs, lists, etc.)
437
- let i = firstPara + 3
438
- while (i < itemEnd) {
439
- const token = tokens[i]
440
-
441
- // Handle paragraph
442
- if (token.type === 'paragraph_open') {
443
- hasFirstParagraph = true
444
-
445
- result.push(tokens[i]) // paragraph_open
446
- result.push(tokens[i + 1]) // inline
447
- result.push(tokens[i + 2]) // paragraph_close
448
-
449
- i += 3
450
- }
451
- // Handle bullet_list or ordered_list
452
- else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
453
- // Find matching close
454
- let depth = 1
455
- let j = i + 1
456
- while (j < itemEnd && depth > 0) {
457
- if (tokens[j].type === token.type) {
458
- depth++
459
- } else if (tokens[j].type === token.type.replace('_open', '_close')) {
460
- depth--
461
- }
462
- j++
463
- }
464
- // Copy all tokens from i to j-1 (inclusive)
465
- for (let k = i; k < j; k++) {
466
- result.push(tokens[k])
467
- }
468
- i = j
469
- }
470
- // Skip other tokens (shouldn't happen)
471
- else {
472
- i++
473
- }
474
- }
475
-
476
- // Create dd_close token
477
- const ddClose = new tokens[firstPara].constructor('dd_close', 'dd', -1)
478
- ddClose.level = parentLevel + 1
479
- ddClose.block = true
480
- result.push(ddClose)
481
-
482
- // Create div_close if descriptionListWithDiv is enabled
483
- if (opt && opt.descriptionListWithDiv) {
484
- const divClose = new tokens[firstPara].constructor('div_close', 'div', -1)
485
- divClose.level = parentLevel + 1
486
- divClose.block = true
487
- result.push(divClose)
488
- }
489
-
490
- return { tokens: result, listAttrs }
491
- }
492
-
493
- /**
494
- * Move attributes from paragraph immediately after dl_close to dl_open
495
- * This supports markdown-it-attrs syntax for lists: {.className}
496
- *
497
- * markdown-it-attrs applies attributes when there's a paragraph immediately
498
- * after the list containing only {.class} syntax.
499
- *
500
- * Note: Individual dt/dd attributes are handled during DL conversion in Phase 0
501
- */
502
- export const moveParagraphAttributesToDL = (tokens) => {
503
- let i = 0
504
- while (i < tokens.length) {
505
- if (tokens[i].type === 'dl_open') {
506
- const dlOpen = tokens[i]
507
-
508
- // Use cached metadata if available, otherwise fallback to search
509
- const dlMetadata = dlOpen._dlMetadata
510
- const dlClose = dlMetadata ? findDLEndFast(tokens, i, dlMetadata) : findDLEnd(tokens, i)
511
-
512
- // Apply pending list attrs from Phase 0
513
- if (dlOpen._pendingListAttrs && dlOpen._pendingListAttrs.length > 0) {
514
- if (!dlOpen.attrs) {
515
- dlOpen.attrs = []
516
- }
517
- dlOpen.attrs.push(...dlOpen._pendingListAttrs)
518
- delete dlOpen._pendingListAttrs // Clean up
519
- }
520
-
521
- // Pattern 1: Check if there's a paragraph immediately after dl_close
522
- // markdown-it-attrs puts list attributes on this paragraph
523
- if (dlClose + 1 < tokens.length && tokens[dlClose + 1].type === 'paragraph_open') {
524
- const paraOpen = tokens[dlClose + 1]
525
-
526
- if (paraOpen.attrs && paraOpen.attrs.length > 0) {
527
- // Move attributes to dl_open
528
- if (!dlOpen.attrs) {
529
- dlOpen.attrs = []
530
- }
531
- dlOpen.attrs.push(...paraOpen.attrs)
532
- paraOpen.attrs = []
533
-
534
- // Remove the paragraph tokens (paragraph_open, inline, paragraph_close)
535
- const paraClose = dlClose + 3
536
- if (tokens[paraClose] && tokens[paraClose].type === 'paragraph_close') {
537
- tokens.splice(dlClose + 1, 3)
538
- }
539
- }
540
- }
541
-
542
- // Pattern 2: Single-item DL with attrs on the last paragraph in dd
543
- // Use cached item count if available
544
- const isSingleItem = dlMetadata ? (dlMetadata.itemCount === 1) : (() => {
545
- let dtCount = 0
546
- for (let j = i + 1; j < dlClose; j++) {
547
- if (tokens[j].type === 'dt_open') {
548
- dtCount++
549
- }
550
- }
551
- return dtCount === 1
552
- })()
553
-
554
- // If single-item DL, check last paragraph in last dd
555
- if (isSingleItem) {
556
- // Use cached lastDdOpen if available
557
- let lastDdOpen = dlMetadata && dlMetadata.lastDdTokenIndex !== -1
558
- ? dlMetadata.lastDdTokenIndex
559
- : (() => {
560
- for (let j = dlClose - 1; j > i; j--) {
561
- if (tokens[j].type === 'dd_open') {
562
- return j
563
- }
564
- }
565
- return -1
566
- })()
567
-
568
- if (lastDdOpen !== -1) {
569
- const ddClose = findDDEnd(tokens, lastDdOpen)
570
-
571
- // Find last paragraph in this dd
572
- let lastParaOpen = -1
573
- for (let j = ddClose - 1; j > lastDdOpen; j--) {
574
- if (tokens[j].type === 'paragraph_open') {
575
- lastParaOpen = j
576
- break
577
- }
578
- }
579
-
580
- if (lastParaOpen !== -1) {
581
- const paraOpen = tokens[lastParaOpen]
582
- const paraInline = tokens[lastParaOpen + 1]
583
-
584
- // Check if paragraph has attrs and content is just whitespace or {.attrs} pattern
585
- // This indicates the attrs belong to the list, not the paragraph content
586
- if (paraOpen.attrs && paraOpen.attrs.length > 0) {
587
- let shouldMoveAttrs = false
588
-
589
- // Check if inline content is empty or only contains whitespace
590
- if (paraInline && paraInline.type === 'inline') {
591
- const content = paraInline.content || ''
592
- // If content is empty or only whitespace after removing {.attrs} patterns
593
- const cleanedContent = content.replace(/\{[^}]+\}/g, '').trim()
594
- if (!cleanedContent) {
595
- shouldMoveAttrs = true
596
- }
597
- }
598
-
599
- if (shouldMoveAttrs) {
600
- // Move attributes to dl_open
601
- if (!dlOpen.attrs) {
602
- dlOpen.attrs = []
603
- }
604
- dlOpen.attrs.push(...paraOpen.attrs)
605
- paraOpen.attrs = []
606
-
607
- // Clean up trailing newline from inline content
608
- // (markdown-it-attrs removes {.simple} but leaves the newline)
609
- if (paraInline && paraInline.content) {
610
- paraInline.content = paraInline.content.trimEnd()
611
- // Update child text nodes as well
612
- if (paraInline.children && paraInline.children.length > 0) {
613
- for (const child of paraInline.children) {
614
- if (child.type === 'text' && child.content) {
615
- child.content = child.content.trimEnd()
616
- }
617
- }
618
- }
619
- }
620
- }
621
- }
622
- }
623
- }
624
- }
625
-
626
- // Clean up metadata after processing
627
- if (dlMetadata) {
628
- delete dlOpen._dlMetadata
629
- }
630
-
631
- i = dlClose + 1
632
- } else {
633
- i++
634
- }
635
- }
636
- }
637
-
638
- /**
639
- * Fast DL end finder using metadata hint
640
- */
641
- const findDLEndFast = (tokens, startIndex, metadata) => {
642
- // Use hint from metadata to estimate end position
643
- // Start searching from lastDdTokenIndex instead of from beginning
644
- const searchStart = metadata.lastDdTokenIndex !== -1 ? metadata.lastDdTokenIndex : startIndex + 1
645
-
646
- for (let i = searchStart; i < tokens.length; i++) {
647
- if (tokens[i].type === 'dl_close' && tokens[i].level === tokens[startIndex].level) {
648
- return i
649
- }
650
- }
651
-
652
- // Fallback to full search if hint didn't work
653
- return findDLEnd(tokens, startIndex)
654
- }
1
+ // Phase 0: Description List Processing
2
+ // Converts bullet_list with **Term** pattern to description_list (dl/dt/dd)
3
+ // This must run before Phase 1
4
+
5
+ import { findMatchingClose, findListEnd as coreFindListEnd, findListItemEnd as coreFindListItemEnd } from './list-helpers.js'
6
+
7
+ /**
8
+ * Parse attribute string like ".class1 .class2 #id data-foo="bar""
9
+ * Returns array of [key, value] pairs
10
+ */
11
+ const parseAttrString = (attrStr) => {
12
+ const attrs = []
13
+ const classMatches = attrStr.match(/\.[\w-]+/g)
14
+ const idMatch = attrStr.match(/#([\w-]+)/)
15
+ const dataMatches = attrStr.match(/([\w-]+)="([^"]+)"/g)
16
+
17
+ // Collect all classes
18
+ if (classMatches) {
19
+ const classes = classMatches.map(c => c.substring(1)).join(' ')
20
+ attrs.push(['class', classes])
21
+ }
22
+
23
+ // Add id
24
+ if (idMatch) {
25
+ attrs.push(['id', idMatch[1]])
26
+ }
27
+
28
+ // Add data attributes
29
+ if (dataMatches) {
30
+ dataMatches.forEach(match => {
31
+ const [, key, value] = match.match(/([\w-]+)="([^"]+)"/)
32
+ attrs.push([key, value])
33
+ })
34
+ }
35
+
36
+ return attrs
37
+ }
38
+
39
+ /**
40
+ * Process description list patterns in tokens
41
+ * @param {Array} tokens - Token array
42
+ * @param {Object} opt - Options object
43
+ ```
44
+ */
45
+ export const processDescriptionList = (tokens, opt) => {
46
+ if (!opt.descriptionList && !opt.descriptionListWithDiv) {
47
+ return
48
+ }
49
+
50
+ // Find bullet_lists and check if they match DL pattern
51
+ // Process in single pass: check and convert together to avoid duplicate scanning
52
+ let i = 0
53
+ while (i < tokens.length) {
54
+ if (tokens[i].type === 'bullet_list_open') {
55
+ const listEnd = findListEnd(tokens, i)
56
+ const dlCheck = checkAndConvertToDL(tokens, i, listEnd, opt)
57
+ i = dlCheck.nextIndex
58
+ } else {
59
+ i++
60
+ }
61
+ }
62
+
63
+ // Note: moveParagraphAttributesToDL is called separately after inline parsing
64
+ // via 'numbering_dl_attrs' rule (runs after any attribute plugins like markdown-it-attrs)
65
+ }
66
+
67
+ /**
68
+ * Find matching list close token
69
+ */
70
+ const findListEnd = (tokens, startIndex) => {
71
+ const result = coreFindListEnd(tokens, startIndex)
72
+ return result === -1 ? tokens.length - 1 : result
73
+ }
74
+
75
+ /**
76
+ * Find matching list_item close token
77
+ */
78
+ const findListItemEnd = (tokens, startIndex) => {
79
+ const result = coreFindListItemEnd(tokens, startIndex)
80
+ return result === -1 ? tokens.length - 1 : result
81
+ }
82
+
83
+ /**
84
+ * Find matching dl_close token
85
+ */
86
+ const findDLEnd = (tokens, startIndex) => {
87
+ const result = findMatchingClose(tokens, startIndex, 'dl_open', 'dl_close')
88
+ return result === -1 ? tokens.length - 1 : result
89
+ }
90
+
91
+ /**
92
+ * Find matching dd_close token
93
+ */
94
+ const findDDEnd = (tokens, startIndex) => {
95
+ const result = findMatchingClose(tokens, startIndex, 'dd_open', 'dd_close')
96
+ return result === -1 ? tokens.length - 1 : result
97
+ }
98
+
99
+ /**
100
+ * Check if bullet_list matches DL pattern and convert if true (single pass)
101
+ * Returns: { nextIndex: number } - index to continue processing from
102
+ */
103
+ const checkAndConvertToDL = (tokens, listStart, listEnd, opt) => {
104
+ // First pass: validate all items match DL pattern
105
+ let hasAnyDLItem = false
106
+ let i = listStart + 1
107
+
108
+ while (i < listEnd) {
109
+ if (tokens[i].type === 'list_item_open') {
110
+ const itemEnd = findListItemEnd(tokens, i)
111
+
112
+ // Find first paragraph
113
+ let firstPara = -1
114
+ for (let j = i + 1; j < itemEnd; j++) {
115
+ if (tokens[j].type === 'paragraph_open') {
116
+ firstPara = j
117
+ break
118
+ }
119
+ }
120
+
121
+ if (firstPara !== -1) {
122
+ const inlineToken = tokens[firstPara + 1]
123
+ if (inlineToken && inlineToken.type === 'inline') {
124
+ const dlCheck = isDLPattern(inlineToken.content)
125
+ if (dlCheck.isMatch) {
126
+ // Check if there's a description
127
+ let hasDescription = false
128
+
129
+ const afterStrong = dlCheck.afterStrong
130
+
131
+ // Pattern 1: **Term** description (2+ spaces, including newlines)
132
+ // Pattern 2: **Term**: description (colon)
133
+ // Pattern 3: **Term**\ description (backslash escape)
134
+ if (/^\s{2,}/.test(afterStrong) || /^\s*:/.test(afterStrong) || /^\\/.test(afterStrong)) {
135
+ // Remove leading space/colon/backslash and check remaining text
136
+ const cleaned = afterStrong.replace(/^[\s:]+/, '').replace(/^\\/, '').trim()
137
+ if (cleaned) {
138
+ hasDescription = true
139
+ }
140
+ }
141
+
142
+ // Pattern 4: Description in next paragraph (only **Term** in first para)
143
+ if (!hasDescription) {
144
+ // Check for additional paragraphs/lists
145
+ for (let k = firstPara + 3; k < itemEnd; k++) {
146
+ if (tokens[k].type === 'paragraph_open' ||
147
+ tokens[k].type === 'bullet_list_open' ||
148
+ tokens[k].type === 'ordered_list_open') {
149
+ hasDescription = true
150
+ break
151
+ }
152
+ }
153
+ }
154
+
155
+ // If no description, not a DL item
156
+ if (!hasDescription) {
157
+ return false
158
+ }
159
+
160
+ hasAnyDLItem = true
161
+ } else {
162
+ // Not all items are DL pattern - not a description list
163
+ return { nextIndex: listEnd + 1 }
164
+ }
165
+ }
166
+ }
167
+
168
+ i = itemEnd + 1
169
+ } else {
170
+ i++
171
+ }
172
+ }
173
+
174
+ // If valid DL, convert immediately (avoid re-scanning)
175
+ if (hasAnyDLItem) {
176
+ convertBulletListToDL(tokens, listStart, listEnd, opt)
177
+ // After conversion, tokens are replaced - continue from original listEnd position
178
+ // Note: convertBulletListToDL may change token count, but we use original listEnd
179
+ return { nextIndex: listStart + 1 } // Re-check from start since tokens changed
180
+ }
181
+
182
+ return { nextIndex: listEnd + 1 }
183
+ }
184
+
185
+ /**
186
+ * Check if content matches DL pattern and return match details
187
+ * Returns: { isMatch: boolean, afterStrong: string|null }
188
+ */
189
+ const isDLPattern = (content) => {
190
+ if (!content) return { isMatch: false, afterStrong: null }
191
+
192
+ // Match **Term** pattern (allow spaces inside for markdown-it-strong-ja compatibility)
193
+ const match = content.match(/^\*\*(.*?)\*\*(.*)/s) // s flag for including newlines
194
+ if (!match) return { isMatch: false, afterStrong: null }
195
+
196
+ const afterStrong = match[2] // Text after closing **
197
+
198
+ // Pattern 1: **Term** description (2+ spaces, including newlines)
199
+ // Pattern 2: **Term**: description (colon)
200
+ // Pattern 3: **Term**\ description (backslash escape)
201
+ // Pattern 4: **Term** only (no content after)
202
+ // Pattern 5: **Term** {.attrs} (markdown-it-attrs syntax, optionally with content after)
203
+ const isMatch = /^\s{2,}/.test(afterStrong) ||
204
+ /^\s*:/.test(afterStrong) ||
205
+ /^\\/.test(afterStrong) ||
206
+ /^\s*$/.test(afterStrong) ||
207
+ /^\s*\{[^}]+\}/.test(afterStrong) // {.class} or {#id} etc
208
+
209
+ return { isMatch, afterStrong }
210
+ }
211
+
212
+ /**
213
+ * Convert bullet_list to dl/dt/dd structure using dl_open/dl_close tokens
214
+ */
215
+ const convertBulletListToDL = (tokens, listStart, listEnd, opt) => {
216
+ const newTokens = []
217
+ const listLevel = tokens[listStart].level
218
+
219
+ // Create dl_open token
220
+ const dlOpen = new tokens[listStart].constructor('dl_open', 'dl', 1)
221
+ dlOpen.level = listLevel
222
+ dlOpen.block = true
223
+
224
+ // Copy attributes from bullet_list_open (e.g., {.class} from markdown-it-attrs)
225
+ if (tokens[listStart].attrs && tokens[listStart].attrs.length > 0) {
226
+ dlOpen.attrs = tokens[listStart].attrs.slice()
227
+ }
228
+
229
+ // Store pending attrs from list items (Pattern B: {.attrs} on last line of description)
230
+ // These will be applied by moveParagraphAttributesToDL after markdown-it-attrs runs
231
+ dlOpen._pendingListAttrs = []
232
+
233
+ // Store DL metadata for later optimization in moveParagraphAttributesToDL
234
+ dlOpen._dlMetadata = {
235
+ itemCount: 0, // Will be updated during processing
236
+ lastDdTokenIndex: -1 // Will be updated to point to last dd_open in newTokens
237
+ }
238
+
239
+ newTokens.push(dlOpen)
240
+
241
+ // Collect attrs from list items
242
+ const listAttrsFromItems = []
243
+
244
+ // Process each list_item
245
+ let i = listStart + 1
246
+ while (i < listEnd) {
247
+ if (tokens[i].type === 'list_item_open') {
248
+ const itemEnd = findListItemEnd(tokens, i)
249
+ const result = convertListItemToDtDd(tokens, i, itemEnd, listLevel, opt)
250
+
251
+ // Update metadata
252
+ dlOpen._dlMetadata.itemCount++
253
+
254
+ // Check for list-level attrs returned from item
255
+ if (result.listAttrs && result.listAttrs.length > 0) {
256
+ listAttrsFromItems.push(...result.listAttrs)
257
+ }
258
+
259
+ if (result.tokens) {
260
+ // Track last dd_open position (relative to newTokens)
261
+ for (let j = 0; j < result.tokens.length; j++) {
262
+ if (result.tokens[j].type === 'dd_open') {
263
+ dlOpen._dlMetadata.lastDdTokenIndex = newTokens.length + j
264
+ }
265
+ }
266
+ newTokens.push(...result.tokens)
267
+ }
268
+
269
+ i = itemEnd + 1
270
+ } else {
271
+ i++
272
+ }
273
+ }
274
+
275
+ // Store pending list attrs for later processing
276
+ if (listAttrsFromItems.length > 0) {
277
+ dlOpen._pendingListAttrs = listAttrsFromItems
278
+ }
279
+
280
+ // Create dl_close token
281
+ const dlClose = new tokens[listStart].constructor('dl_close', 'dl', -1)
282
+ dlClose.level = listLevel
283
+ dlClose.block = true
284
+ newTokens.push(dlClose)
285
+
286
+ // Replace tokens
287
+ tokens.splice(listStart, listEnd - listStart + 1, ...newTokens)
288
+ }
289
+
290
+ /**
291
+ * Convert list_item to dt/dd structure
292
+ * Returns: { tokens: [...], listAttrs: [...] }
293
+ */
294
+ const convertListItemToDtDd = (tokens, itemStart, itemEnd, parentLevel, opt) => {
295
+ const result = []
296
+ const listAttrs = [] // Attrs to be applied to dl (not dt/dd)
297
+
298
+ // Get attributes from list_item_open (markdown-it-attrs may put {.class} there)
299
+ const listItemToken = tokens[itemStart]
300
+ let dtAttrs = listItemToken.attrs ? [...listItemToken.attrs] : null
301
+
302
+ // Find first paragraph
303
+ let firstPara = -1
304
+ for (let i = itemStart + 1; i < itemEnd; i++) {
305
+ if (tokens[i].type === 'paragraph_open') {
306
+ firstPara = i
307
+ break
308
+ }
309
+ }
310
+
311
+ if (firstPara === -1) return { tokens: result, listAttrs }
312
+
313
+ const inlineToken = tokens[firstPara + 1]
314
+ if (!inlineToken || inlineToken.type !== 'inline') return { tokens: result, listAttrs }
315
+
316
+ // Extract term and description from inline token's content
317
+ let term = ''
318
+ let descStart = ''
319
+
320
+ const content = inlineToken.content
321
+ const match = content.match(/^\*\*(.*?)\*\*(.*)/s) // s flag for including newlines
322
+
323
+ if (match) {
324
+ term = match[1] // Keep spaces for now (will be processed by inline parser)
325
+ let afterStrong = match[2]
326
+
327
+ // Extract {.attrs} from afterStrong if present (markdown-it-attrs hasn't processed yet)
328
+ // Pattern A: Inline {.attrs} immediately after **Term** like **Term** {.class}
329
+ const inlineAttrsMatch = afterStrong.match(/^\s*\{([^}]+)\}/)
330
+ if (inlineAttrsMatch) {
331
+ // Parse attributes manually
332
+ const attrString = inlineAttrsMatch[1]
333
+ const parsedAttrs = parseAttrString(attrString)
334
+ if (parsedAttrs.length > 0) {
335
+ if (!dtAttrs) {
336
+ dtAttrs = []
337
+ }
338
+ dtAttrs.push(...parsedAttrs)
339
+ }
340
+ // Remove {.attrs} from afterStrong (including trailing newline if attrs-only line)
341
+ afterStrong = afterStrong.replace(/^\s*\{[^}]+\}\s*/, '')
342
+ }
343
+
344
+ // Pattern B: {.attrs} on last line (e.g., "Description\n{.attrs}")
345
+ // This will be processed by markdown-it-attrs and applied to list, not paragraph
346
+ // We need to remove it from description content and save for list-level attrs
347
+ const lastLineAttrsMatch = afterStrong.match(/\n\s*\{([^}]+)\}\s*$/)
348
+ if (lastLineAttrsMatch) {
349
+ // Parse attributes
350
+ const attrString = lastLineAttrsMatch[1]
351
+ const parsedAttrs = parseAttrString(attrString)
352
+ if (parsedAttrs.length > 0) {
353
+ listAttrs.push(...parsedAttrs)
354
+ }
355
+ // Remove {.attrs} line from afterStrong
356
+ afterStrong = afterStrong.replace(/\n\s*\{[^}]+\}\s*$/, '')
357
+ }
358
+
359
+ // Clean up afterStrong: remove leading spaces/colon/backslash, then trim each line
360
+ let cleaned = afterStrong.replace(/^[\s:]+/, '').replace(/^\\/, '')
361
+
362
+ // Remove leading whitespace from each line (remove list indent)
363
+ cleaned = cleaned.split('\n').map(line => line.replace(/^\s+/, '')).join('\n').trim()
364
+
365
+ descStart = cleaned
366
+ }
367
+
368
+ if (!term) return { tokens: result, listAttrs }
369
+
370
+ // Create div_open if descriptionListWithDiv is enabled
371
+ if (opt && opt.descriptionListWithDiv) {
372
+ const divOpen = new tokens[firstPara].constructor('div_open', 'div', 1)
373
+ divOpen.level = parentLevel + 1
374
+ divOpen.block = true
375
+ const divClass = typeof opt.descriptionListDivClass === 'string' ? opt.descriptionListDivClass : ''
376
+ if (divClass) {
377
+ divOpen.attrs = [['class', divClass]]
378
+ }
379
+ result.push(divOpen)
380
+ }
381
+
382
+ // Create dt_open token
383
+ const dtOpen = new tokens[firstPara].constructor('dt_open', 'dt', 1)
384
+ dtOpen.level = parentLevel + 1
385
+ dtOpen.block = true
386
+ // Add collected attributes to dt_open
387
+ if (dtAttrs && dtAttrs.length > 0) {
388
+ dtOpen.attrs = dtAttrs
389
+ }
390
+ result.push(dtOpen)
391
+
392
+ // Create inline token for dt content
393
+ const dtInline = new tokens[firstPara].constructor('inline', '', 0)
394
+ dtInline.content = term // Keep original Markdown for inline parser to process
395
+ dtInline.level = parentLevel + 2
396
+ dtInline.children = [] // Will be populated by inline parser
397
+ result.push(dtInline)
398
+
399
+ // Create dt_close token
400
+ const dtClose = new tokens[firstPara].constructor('dt_close', 'dt', -1)
401
+ dtClose.level = parentLevel + 1
402
+ dtClose.block = true
403
+ result.push(dtClose)
404
+
405
+ // Create dd_open token
406
+ const ddOpen = new tokens[firstPara].constructor('dd_open', 'dd', 1)
407
+ ddOpen.level = parentLevel + 1
408
+ ddOpen.block = true
409
+ result.push(ddOpen)
410
+
411
+ // First paragraph in dd (if description exists)
412
+ let hasFirstParagraph = false
413
+ if (descStart.trim()) {
414
+ const pOpen = new tokens[firstPara].constructor('paragraph_open', 'p', 1)
415
+ pOpen.level = parentLevel + 2
416
+ pOpen.block = true // IMPORTANT: Enable block mode for proper newline rendering
417
+ result.push(pOpen)
418
+
419
+ const pInline = new tokens[firstPara].constructor('inline', '', 0)
420
+ pInline.content = '' // Leave empty, text token has the content
421
+ pInline.level = parentLevel + 3
422
+ pInline.block = true // IMPORTANT: Enable block mode
423
+ const pText = new tokens[firstPara].constructor('text', '', 0)
424
+ pText.content = descStart.trim()
425
+ pInline.children = [pText]
426
+ result.push(pInline)
427
+
428
+ const pClose = new tokens[firstPara].constructor('paragraph_close', 'p', -1)
429
+ pClose.level = parentLevel + 2
430
+ pClose.block = true // IMPORTANT: Enable block mode for proper newline rendering
431
+ result.push(pClose)
432
+
433
+ hasFirstParagraph = true
434
+ }
435
+
436
+ // Add remaining content in dd (paragraphs, lists, etc.)
437
+ let i = firstPara + 3
438
+ while (i < itemEnd) {
439
+ const token = tokens[i]
440
+
441
+ // Handle paragraph
442
+ if (token.type === 'paragraph_open') {
443
+ hasFirstParagraph = true
444
+
445
+ result.push(tokens[i]) // paragraph_open
446
+ result.push(tokens[i + 1]) // inline
447
+ result.push(tokens[i + 2]) // paragraph_close
448
+
449
+ i += 3
450
+ }
451
+ // Handle bullet_list or ordered_list
452
+ else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
453
+ // Find matching close
454
+ let depth = 1
455
+ let j = i + 1
456
+ while (j < itemEnd && depth > 0) {
457
+ if (tokens[j].type === token.type) {
458
+ depth++
459
+ } else if (tokens[j].type === token.type.replace('_open', '_close')) {
460
+ depth--
461
+ }
462
+ j++
463
+ }
464
+ // Copy all tokens from i to j-1 (inclusive)
465
+ for (let k = i; k < j; k++) {
466
+ result.push(tokens[k])
467
+ }
468
+ i = j
469
+ }
470
+ // Skip other tokens (shouldn't happen)
471
+ else {
472
+ i++
473
+ }
474
+ }
475
+
476
+ // Create dd_close token
477
+ const ddClose = new tokens[firstPara].constructor('dd_close', 'dd', -1)
478
+ ddClose.level = parentLevel + 1
479
+ ddClose.block = true
480
+ result.push(ddClose)
481
+
482
+ // Create div_close if descriptionListWithDiv is enabled
483
+ if (opt && opt.descriptionListWithDiv) {
484
+ const divClose = new tokens[firstPara].constructor('div_close', 'div', -1)
485
+ divClose.level = parentLevel + 1
486
+ divClose.block = true
487
+ result.push(divClose)
488
+ }
489
+
490
+ return { tokens: result, listAttrs }
491
+ }
492
+
493
+ /**
494
+ * Move attributes from paragraph immediately after dl_close to dl_open
495
+ * This supports markdown-it-attrs syntax for lists: {.className}
496
+ *
497
+ * markdown-it-attrs applies attributes when there's a paragraph immediately
498
+ * after the list containing only {.class} syntax.
499
+ *
500
+ * Note: Individual dt/dd attributes are handled during DL conversion in Phase 0
501
+ */
502
+ export const moveParagraphAttributesToDL = (tokens) => {
503
+ let i = 0
504
+ while (i < tokens.length) {
505
+ if (tokens[i].type === 'dl_open') {
506
+ const dlOpen = tokens[i]
507
+
508
+ // Use cached metadata if available, otherwise fallback to search
509
+ const dlMetadata = dlOpen._dlMetadata
510
+ const dlClose = dlMetadata ? findDLEndFast(tokens, i, dlMetadata) : findDLEnd(tokens, i)
511
+
512
+ // Apply pending list attrs from Phase 0
513
+ if (dlOpen._pendingListAttrs && dlOpen._pendingListAttrs.length > 0) {
514
+ if (!dlOpen.attrs) {
515
+ dlOpen.attrs = []
516
+ }
517
+ dlOpen.attrs.push(...dlOpen._pendingListAttrs)
518
+ delete dlOpen._pendingListAttrs // Clean up
519
+ }
520
+
521
+ // Pattern 1: Check if there's a paragraph immediately after dl_close
522
+ // markdown-it-attrs puts list attributes on this paragraph
523
+ if (dlClose + 1 < tokens.length && tokens[dlClose + 1].type === 'paragraph_open') {
524
+ const paraOpen = tokens[dlClose + 1]
525
+
526
+ if (paraOpen.attrs && paraOpen.attrs.length > 0) {
527
+ // Move attributes to dl_open
528
+ if (!dlOpen.attrs) {
529
+ dlOpen.attrs = []
530
+ }
531
+ dlOpen.attrs.push(...paraOpen.attrs)
532
+ paraOpen.attrs = []
533
+
534
+ // Remove the paragraph tokens (paragraph_open, inline, paragraph_close)
535
+ const paraClose = dlClose + 3
536
+ if (tokens[paraClose] && tokens[paraClose].type === 'paragraph_close') {
537
+ tokens.splice(dlClose + 1, 3)
538
+ }
539
+ }
540
+ }
541
+
542
+ // Pattern 2: Single-item DL with attrs on the last paragraph in dd
543
+ // Use cached item count if available
544
+ const isSingleItem = dlMetadata ? (dlMetadata.itemCount === 1) : (() => {
545
+ let dtCount = 0
546
+ for (let j = i + 1; j < dlClose; j++) {
547
+ if (tokens[j].type === 'dt_open') {
548
+ dtCount++
549
+ }
550
+ }
551
+ return dtCount === 1
552
+ })()
553
+
554
+ // If single-item DL, check last paragraph in last dd
555
+ if (isSingleItem) {
556
+ // Use cached lastDdOpen if available
557
+ let lastDdOpen = dlMetadata && dlMetadata.lastDdTokenIndex !== -1
558
+ ? dlMetadata.lastDdTokenIndex
559
+ : (() => {
560
+ for (let j = dlClose - 1; j > i; j--) {
561
+ if (tokens[j].type === 'dd_open') {
562
+ return j
563
+ }
564
+ }
565
+ return -1
566
+ })()
567
+
568
+ if (lastDdOpen !== -1) {
569
+ const ddClose = findDDEnd(tokens, lastDdOpen)
570
+
571
+ // Find last paragraph in this dd
572
+ let lastParaOpen = -1
573
+ for (let j = ddClose - 1; j > lastDdOpen; j--) {
574
+ if (tokens[j].type === 'paragraph_open') {
575
+ lastParaOpen = j
576
+ break
577
+ }
578
+ }
579
+
580
+ if (lastParaOpen !== -1) {
581
+ const paraOpen = tokens[lastParaOpen]
582
+ const paraInline = tokens[lastParaOpen + 1]
583
+
584
+ // Check if paragraph has attrs and content is just whitespace or {.attrs} pattern
585
+ // This indicates the attrs belong to the list, not the paragraph content
586
+ if (paraOpen.attrs && paraOpen.attrs.length > 0) {
587
+ let shouldMoveAttrs = false
588
+
589
+ // Check if inline content is empty or only contains whitespace
590
+ if (paraInline && paraInline.type === 'inline') {
591
+ const content = paraInline.content || ''
592
+ // If content is empty or only whitespace after removing {.attrs} patterns
593
+ const cleanedContent = content.replace(/\{[^}]+\}/g, '').trim()
594
+ if (!cleanedContent) {
595
+ shouldMoveAttrs = true
596
+ }
597
+ }
598
+
599
+ if (shouldMoveAttrs) {
600
+ // Move attributes to dl_open
601
+ if (!dlOpen.attrs) {
602
+ dlOpen.attrs = []
603
+ }
604
+ dlOpen.attrs.push(...paraOpen.attrs)
605
+ paraOpen.attrs = []
606
+
607
+ // Clean up trailing newline from inline content
608
+ // (markdown-it-attrs removes {.simple} but leaves the newline)
609
+ if (paraInline && paraInline.content) {
610
+ paraInline.content = paraInline.content.trimEnd()
611
+ // Update child text nodes as well
612
+ if (paraInline.children && paraInline.children.length > 0) {
613
+ for (const child of paraInline.children) {
614
+ if (child.type === 'text' && child.content) {
615
+ child.content = child.content.trimEnd()
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ // Clean up metadata after processing
627
+ if (dlMetadata) {
628
+ delete dlOpen._dlMetadata
629
+ }
630
+
631
+ i = dlClose + 1
632
+ } else {
633
+ i++
634
+ }
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Fast DL end finder using metadata hint
640
+ */
641
+ const findDLEndFast = (tokens, startIndex, metadata) => {
642
+ // Use hint from metadata to estimate end position
643
+ // Start searching from lastDdTokenIndex instead of from beginning
644
+ const searchStart = metadata.lastDdTokenIndex !== -1 ? metadata.lastDdTokenIndex : startIndex + 1
645
+
646
+ for (let i = searchStart; i < tokens.length; i++) {
647
+ if (tokens[i].type === 'dl_close' && tokens[i].level === tokens[startIndex].level) {
648
+ return i
649
+ }
650
+ }
651
+
652
+ // Fallback to full search if hint didn't work
653
+ return findDLEnd(tokens, startIndex)
654
+ }