@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.
- package/README.md +336 -335
- package/debug/dump_listinfos.mjs +65 -0
- package/debug/dump_tokens.mjs +72 -0
- package/index.js +5 -0
- package/package.json +5 -2
- package/src/phase0-description-list.js +654 -654
- package/src/phase1-analyze.js +90 -7
- package/src/phase2-convert.js +1037 -738
- package/src/phase3-attributes.js +260 -244
- package/src/phase5-spans.js +1 -1
- package/src/phase6-attrs-migration.js +1 -1
- package/src/preprocess-literal-lists.js +776 -0
- package/src/types-utility.js +995 -991
|
@@ -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
|
+
}
|