@peaceroad/markdown-it-figure-with-p-caption 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,15 +1,316 @@
1
- import { setCaptionParagraph } from 'p7d-markdown-it-p-captions'
2
- import { imgAttrToPCaption, setAltToLabel, setTitleToLabel } from './imgAttrToPCaption.js'
1
+ import { setCaptionParagraph, markReg } from 'p7d-markdown-it-p-captions'
3
2
 
4
- const htmlRegCache = {}
3
+ const htmlRegCache = new Map()
4
+ const cleanCaptionRegCache = new Map()
5
5
  const classReg = /^f-(.+)$/
6
6
  const blueskyEmbedReg = /^<blockquote class="bluesky-embed"[^]*?>[\s\S]*?$/
7
+ const videoIframeReg = /^<[^>]*? src="https:\/\/(?:www.youtube-nocookie.com|player.vimeo.com)\//i
8
+ const classNameReg = /^<[^>]*? class="(twitter-tweet|instagram-media|text-post-media|bluesky-embed|mastodon-embed)"/
9
+ const imageAttrsReg = /^ *\{(.*?)\} *$/
10
+ const classAttrReg = /^\./
11
+ const idAttrReg = /^#/
12
+ const attrParseReg = /^(.*?)="?(.*)"?$/
13
+ const sampLangReg = /^ *(?:samp|shell|console)(?:(?= )|$)/
14
+ const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/
15
+ const imgCaptionMarkReg = markReg && markReg.img ? markReg.img : null
16
+ const asciiLabelReg = /^[A-Za-z]/
17
+ const trailingDigitsReg = /(\d+)\s*$/
18
+ const CHECK_TYPE_TOKEN_MAP = {
19
+ table_open: 'table',
20
+ pre_open: 'pre',
21
+ blockquote_open: 'blockquote',
22
+ }
23
+ const HTML_TAG_CANDIDATES = ['video', 'audio', 'iframe', 'blockquote', 'div']
24
+ const fallbackLabelDefaults = {
25
+ img: { en: 'Figure', ja: '図' },
26
+ table: { en: 'Table', ja: '表' },
27
+ }
28
+
29
+ const normalizeSetLabelNumbers = (value) => {
30
+ const normalized = { img: false, table: false }
31
+ if (!value) return normalized
32
+ if (Array.isArray(value)) {
33
+ for (const entry of value) {
34
+ if (normalized.hasOwnProperty(entry)) normalized[entry] = true
35
+ }
36
+ return normalized
37
+ }
38
+ return normalized
39
+ }
40
+
41
+ const buildLabelClassLookup = (opt) => {
42
+ const classPrefix = opt.classPrefix ? opt.classPrefix + '-' : ''
43
+ const defaultClasses = [classPrefix + 'label']
44
+ const withType = (type) => {
45
+ if (opt.removeMarkNameInCaptionClass) return defaultClasses
46
+ return [classPrefix + type + '-label', ...defaultClasses]
47
+ }
48
+ return {
49
+ img: withType('img'),
50
+ table: withType('table'),
51
+ default: defaultClasses,
52
+ }
53
+ }
54
+
55
+ const shouldApplyLabelNumbering = (captionType, opt) => {
56
+ const setting = opt.setLabelNumbers
57
+ if (!setting) return false
58
+ return !!setting[captionType]
59
+ }
60
+
61
+ const isOnlySpacesText = (token) => {
62
+ if (!token || token.type !== 'text') return false
63
+ const content = token.content
64
+ if (typeof content !== 'string') return false
65
+ for (let i = 0; i < content.length; i++) {
66
+ if (content.charCodeAt(i) !== 0x20) return false
67
+ }
68
+ return true
69
+ }
70
+
71
+ const getTokenAttr = (token, attrName) => {
72
+ if (!token || !token.attrs) return ''
73
+ for (let i = 0; i < token.attrs.length; i++) {
74
+ if (token.attrs[i][0] === attrName) return token.attrs[i][1] || ''
75
+ }
76
+ return ''
77
+ }
78
+
79
+ const setTokenAttr = (token, attrName, value) => {
80
+ if (!token) return
81
+ if (!token.attrs) token.attrs = []
82
+ for (let i = 0; i < token.attrs.length; i++) {
83
+ if (token.attrs[i][0] === attrName) {
84
+ token.attrs[i][1] = value
85
+ return
86
+ }
87
+ }
88
+ token.attrs.push([attrName, value])
89
+ }
90
+
91
+ const removeTokenAttr = (token, attrName) => {
92
+ if (!token || !token.attrs) return
93
+ for (let i = token.attrs.length - 1; i >= 0; i--) {
94
+ if (token.attrs[i][0] === attrName) {
95
+ token.attrs.splice(i, 1)
96
+ }
97
+ }
98
+ }
99
+
100
+ const clearImageAltAttr = (token) => {
101
+ if (!token) return
102
+ setTokenAttr(token, 'alt', '')
103
+ token.content = ''
104
+ if (token.children) {
105
+ for (let i = 0; i < token.children.length; i++) {
106
+ token.children[i].content = ''
107
+ }
108
+ }
109
+ }
110
+
111
+ const clearImageTitleAttr = (token) => {
112
+ removeTokenAttr(token, 'title')
113
+ }
114
+
115
+ const getImageAltText = (token) => {
116
+ let alt = getTokenAttr(token, 'alt')
117
+ if (alt) return alt
118
+ if (typeof token.content === 'string' && token.content !== '') return token.content
119
+ if (token.children && token.children.length > 0) {
120
+ return token.children.map(child => child.content || '').join('')
121
+ }
122
+ return ''
123
+ }
124
+
125
+ const getImageTitleText = (token) => getTokenAttr(token, 'title')
126
+
127
+ const detectCaptionLanguage = (text) => {
128
+ const target = (text || '').trim()
129
+ if (!target) return 'en'
130
+ for (let i = 0; i < target.length; i++) {
131
+ const char = target[i]
132
+ const code = target.charCodeAt(i)
133
+ if (isJapaneseCharCode(code)) return 'ja'
134
+ if (isSentenceBoundaryChar(char) || char === '\n') break
135
+ }
136
+ return 'en'
137
+ }
138
+
139
+ const isJapaneseCharCode = (code) => {
140
+ return (
141
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
142
+ (code >= 0x31f0 && code <= 0x31ff) || // Katakana extensions
143
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
144
+ (code >= 0xff66 && code <= 0xff9f) // Half-width Katakana
145
+ )
146
+ }
147
+
148
+ const isSentenceBoundaryChar = (char) => {
149
+ return char === '.' || char === '!' || char === '?' || char === '。' || char === '!' || char === '?'
150
+ }
151
+
152
+ const getAutoFallbackLabel = (text, captionType) => {
153
+ const type = captionType === 'table' ? 'table' : 'img'
154
+ const lang = detectCaptionLanguage(text)
155
+ const defaults = fallbackLabelDefaults[type] || fallbackLabelDefaults.img
156
+ if (lang === 'ja') return defaults.ja || defaults.en || ''
157
+ return defaults.en || defaults.ja || ''
158
+ }
159
+
160
+ const getPersistedFallbackLabel = (text, captionType, fallbackState) => {
161
+ const type = captionType === 'table' ? 'table' : 'img'
162
+ if (!fallbackState) return getAutoFallbackLabel(text, type)
163
+ if (fallbackState[type]) return fallbackState[type]
164
+ const resolved = getAutoFallbackLabel(text, type)
165
+ fallbackState[type] = resolved
166
+ return resolved
167
+ }
168
+
169
+ const buildCaptionWithFallback = (text, fallbackOption, captionType = 'img', fallbackState) => {
170
+ const trimmedText = (text || '').trim()
171
+ if (!fallbackOption) return ''
172
+ let label = ''
173
+ if (typeof fallbackOption === 'string') {
174
+ label = fallbackOption.trim()
175
+ } else if (fallbackOption === true) {
176
+ label = getPersistedFallbackLabel(trimmedText, captionType, fallbackState)
177
+ }
178
+ if (!label) return trimmedText
179
+ const isAsciiLabel = asciiLabelReg.test(label)
180
+ if (!trimmedText) {
181
+ return isAsciiLabel ? label + '.' : label
182
+ }
183
+ return label + (isAsciiLabel ? '. ' : ' ') + trimmedText
184
+ }
185
+
186
+ const createAutoCaptionParagraph = (captionText, TokenConstructor) => {
187
+ const paragraphOpen = new TokenConstructor('paragraph_open', 'p', 1)
188
+ paragraphOpen.block = true
189
+ const inlineToken = new TokenConstructor('inline', '', 0)
190
+ inlineToken.block = true
191
+ inlineToken.content = captionText
192
+ const textToken = new TokenConstructor('text', '', 0)
193
+ textToken.content = captionText
194
+ inlineToken.children = [textToken]
195
+ const paragraphClose = new TokenConstructor('paragraph_close', 'p', -1)
196
+ paragraphClose.block = true
197
+ return [paragraphOpen, inlineToken, paragraphClose]
198
+ }
199
+
200
+ const getCaptionInlineToken = (tokens, range, caption) => {
201
+ if (caption.isPrev) {
202
+ const inlineIndex = range.start - 2
203
+ if (inlineIndex >= 0) return tokens[inlineIndex]
204
+ } else if (caption.isNext) {
205
+ return tokens[range.end + 2]
206
+ }
207
+ return null
208
+ }
209
+
210
+ const getInlineLabelTextToken = (inlineToken, type, opt) => {
211
+ if (!inlineToken || !inlineToken.children) return null
212
+ const children = inlineToken.children
213
+ const classNames = opt.labelClassLookup[type] || opt.labelClassLookup.default
214
+ for (let i = 0; i < children.length; i++) {
215
+ const child = children[i]
216
+ if (!child || !child.attrs) continue
217
+ const classAttr = getTokenAttr(child, 'class')
218
+ if (!classAttr) continue
219
+ const classes = classAttr.split(/\s+/)
220
+ const matched = classNames.some(className => classes.includes(className))
221
+ if (!matched) continue
222
+ const textToken = children[i + 1]
223
+ if (textToken && textToken.type === 'text') {
224
+ return textToken
225
+ }
226
+ }
227
+ return null
228
+ }
229
+
230
+ const updateInlineTokenContent = (inlineToken, originalText, newText) => {
231
+ if (!inlineToken || typeof inlineToken.content !== 'string') return
232
+ if (!originalText) return
233
+ const index = inlineToken.content.indexOf(originalText)
234
+ if (index === -1) return
235
+ inlineToken.content =
236
+ inlineToken.content.slice(0, index) +
237
+ newText +
238
+ inlineToken.content.slice(index + originalText.length)
239
+ }
240
+
241
+ const ensureAutoFigureNumbering = (tokens, range, caption, figureNumberState, opt) => {
242
+ const captionType = caption.name === 'img' ? 'img' : (caption.name === 'table' ? 'table' : '')
243
+ if (!captionType) return
244
+ if (!shouldApplyLabelNumbering(captionType, opt)) return
245
+ const inlineToken = getCaptionInlineToken(tokens, range, caption)
246
+ if (!inlineToken) return
247
+ const labelTextToken = getInlineLabelTextToken(inlineToken, captionType, opt)
248
+ if (!labelTextToken || typeof labelTextToken.content !== 'string') return
249
+ const existingMatch = labelTextToken.content.match(trailingDigitsReg)
250
+ if (existingMatch && existingMatch[1]) {
251
+ const explicitValue = parseInt(existingMatch[1], 10)
252
+ if (!Number.isNaN(explicitValue) && explicitValue > (figureNumberState[captionType] || 0)) {
253
+ figureNumberState[captionType] = explicitValue
254
+ }
255
+ return
256
+ }
257
+ figureNumberState[captionType] = (figureNumberState[captionType] || 0) + 1
258
+ const baseLabel = labelTextToken.content.replace(/\s*\d+$/, '').trim() || labelTextToken.content.trim()
259
+ if (!baseLabel) return
260
+ const joint = asciiLabelReg.test(baseLabel) ? ' ' : ''
261
+ const newLabelText = baseLabel + joint + figureNumberState[captionType]
262
+ const originalText = labelTextToken.content
263
+ labelTextToken.content = newLabelText
264
+ updateInlineTokenContent(inlineToken, originalText, newLabelText)
265
+ }
266
+
267
+ const getAutoCaptionFromImage = (imageToken, opt, fallbackLabelState) => {
268
+ if (!opt.autoCaptionDetection) return ''
269
+ const tryMatch = (text) => {
270
+ if (!text) return ''
271
+ const trimmed = text.trim()
272
+ if (trimmed && imgCaptionMarkReg && trimmed.match(imgCaptionMarkReg)) {
273
+ return trimmed
274
+ }
275
+ return ''
276
+ }
277
+
278
+ const altText = getImageAltText(imageToken)
279
+ let caption = tryMatch(altText)
280
+ if (caption) {
281
+ clearImageAltAttr(imageToken)
282
+ return caption
283
+ }
284
+ if (!caption && opt.autoAltCaption) {
285
+ const altForFallback = altText || ''
286
+ caption = buildCaptionWithFallback(altForFallback, opt.autoAltCaption, 'img', fallbackLabelState)
287
+ if (imageToken) {
288
+ clearImageAltAttr(imageToken)
289
+ }
290
+ }
291
+ if (caption) return caption
292
+
293
+ const titleText = getImageTitleText(imageToken)
294
+ caption = tryMatch(titleText)
295
+ if (caption) {
296
+ clearImageTitleAttr(imageToken)
297
+ return caption
298
+ }
299
+ if (!caption && opt.autoTitleCaption) {
300
+ const titleForFallback = titleText || ''
301
+ caption = buildCaptionWithFallback(titleForFallback, opt.autoTitleCaption, 'img', fallbackLabelState)
302
+ if (imageToken) {
303
+ clearImageTitleAttr(imageToken)
304
+ }
305
+ }
306
+ return caption
307
+ }
7
308
 
8
309
  const getHtmlReg = (tag) => {
9
- if (htmlRegCache[tag]) return htmlRegCache[tag]
10
- let regexStr = '^<' + tag + ' ?[^>]*?>[\\s\\S]*?<\\/' + tag + '>(\\n| *?)(<script [^>]*?>(?:<\\/script>)?)? *(\\n|$)'
310
+ if (htmlRegCache.has(tag)) return htmlRegCache.get(tag)
311
+ const regexStr = `^<${tag} ?[^>]*?>[\\s\\S]*?<\\/${tag}>(\\n| *?)(<script [^>]*?>(?:<\\/script>)?)? *(\\n|$)`
11
312
  const reg = new RegExp(regexStr)
12
- htmlRegCache[tag] = reg
313
+ htmlRegCache.set(tag, reg)
13
314
  return reg
14
315
  }
15
316
 
@@ -26,13 +327,13 @@ const getCaptionName = (token) => {
26
327
  return ''
27
328
  }
28
329
 
29
- const checkPrevCaption = (state, n, caption, fNum, sp, opt) => {
330
+ const checkPrevCaption = (tokens, n, caption, fNum, sp, opt, TokenConstructor) => {
30
331
  if(n < 3) return caption
31
- const captionStartToken = state.tokens[n-3]
32
- const captionEndToken = state.tokens[n-1]
332
+ const captionStartToken = tokens[n-3]
333
+ const captionEndToken = tokens[n-1]
33
334
  if (captionStartToken === undefined || captionEndToken === undefined) return
34
- if (captionStartToken.type !== 'paragraph_open' && captionEndToken.type !== 'paragraph_close') return
35
- setCaptionParagraph(n-3, state, caption, fNum, sp, opt)
335
+ if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
336
+ setCaptionParagraph(n-3, { tokens, Token: TokenConstructor }, caption, fNum, sp, opt)
36
337
  const captionName = getCaptionName(captionStartToken)
37
338
  if(!captionName) return
38
339
  caption.name = captionName
@@ -40,13 +341,13 @@ const checkPrevCaption = (state, n, caption, fNum, sp, opt) => {
40
341
  return
41
342
  }
42
343
 
43
- const checkNextCaption = (state, en, caption, fNum, sp, opt) => {
44
- if (en + 2 > state.tokens.length) return
45
- const captionStartToken = state.tokens[en+1]
46
- const captionEndToken = state.tokens[en+3]
344
+ const checkNextCaption = (tokens, en, caption, fNum, sp, opt, TokenConstructor) => {
345
+ if (en + 2 > tokens.length) return
346
+ const captionStartToken = tokens[en+1]
347
+ const captionEndToken = tokens[en+3]
47
348
  if (captionStartToken === undefined || captionEndToken === undefined) return
48
- if (captionStartToken.type !== 'paragraph_open' && captionEndToken.type !== 'paragraph_close') return
49
- setCaptionParagraph(en+1, state, caption, fNum, sp, opt)
349
+ if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
350
+ setCaptionParagraph(en+1, { tokens, Token: TokenConstructor }, caption, fNum, sp, opt)
50
351
  const captionName = getCaptionName(captionStartToken)
51
352
  if(!captionName) return
52
353
  caption.name = captionName
@@ -55,8 +356,12 @@ const checkNextCaption = (state, en, caption, fNum, sp, opt) => {
55
356
  }
56
357
 
57
358
  const cleanCaptionTokenAttrs = (token, captionName) => {
58
- const reg = new RegExp(' *?f-' + captionName)
59
359
  if (!token.attrs) return
360
+ let reg = cleanCaptionRegCache.get(captionName)
361
+ if (!reg) {
362
+ reg = new RegExp(' *?f-' + captionName)
363
+ cleanCaptionRegCache.set(captionName, reg)
364
+ }
60
365
  for (let i = token.attrs.length - 1; i >= 0; i--) {
61
366
  if (token.attrs[i][0] === 'class') {
62
367
  token.attrs[i][1] = token.attrs[i][1].replace(reg, '').trim()
@@ -65,86 +370,81 @@ const cleanCaptionTokenAttrs = (token, captionName) => {
65
370
  }
66
371
  }
67
372
 
68
- const changePrevCaptionPosition = (state, n, caption, opt) => {
69
- const captionStartToken = state.tokens[n-3]
70
- const captionInlineToken = state.tokens[n-2]
71
- const captionEndToken = state.tokens[n-1]
72
-
73
- if (opt.imgAltCaption || opt.imgTitleCaption) {
74
- let isNoCaption = false
75
- if (captionInlineToken.attrs) {
76
- const attrs = captionInlineToken.attrs, len = attrs.length
77
- for (let i = 0; i < len; i++) {
78
- const attr = attrs[i]
79
- if (attr[0] === 'class' && attr[1] === 'nocaption') {
80
- isNoCaption = true
81
- break
82
- }
373
+ const resolveFigureClassName = (checkTokenTagName, caption, sp, opt) => {
374
+ let className = 'f-' + checkTokenTagName
375
+ if (opt.allIframeTypeFigureClassName === '') {
376
+ if (sp.isVideoIframe) {
377
+ className = 'f-video'
378
+ }
379
+ if (sp.isIframeTypeBlockquote) {
380
+ let figureClassThatWrapsIframeTypeBlockquote = opt.figureClassThatWrapsIframeTypeBlockquote
381
+ if ((caption.isPrev || caption.isNext) &&
382
+ figureClassThatWrapsIframeTypeBlockquote === 'f-img' &&
383
+ (caption.name === 'blockquote' || caption.name === 'img')) {
384
+ figureClassThatWrapsIframeTypeBlockquote = 'f-img'
83
385
  }
386
+ className = figureClassThatWrapsIframeTypeBlockquote
84
387
  }
85
- if (isNoCaption) {
86
- state.tokens.splice(n-3, 3)
87
- return false
388
+ } else {
389
+ if (checkTokenTagName === 'iframe' || sp.isIframeTypeBlockquote) {
390
+ className = opt.allIframeTypeFigureClassName
88
391
  }
89
392
  }
393
+ return className
394
+ }
395
+
396
+ const applyCaptionDrivenFigureClass = (caption, sp, opt) => {
397
+ if (!sp) return
398
+ const figureClassForSlides = opt.figureClassThatWrapsSlides
399
+ if (!figureClassForSlides) return
400
+ const detectedMark = (sp.captionDecision && sp.captionDecision.mark) || (caption && caption.name) || ''
401
+ if (detectedMark !== 'slide') return
402
+ if (opt.allIframeTypeFigureClassName && sp.figureClassName === opt.allIframeTypeFigureClassName) return
403
+ sp.figureClassName = figureClassForSlides
404
+ }
405
+
406
+
407
+ const changePrevCaptionPosition = (tokens, n, caption, opt) => {
408
+ const captionStartToken = tokens[n-3]
409
+ const captionInlineToken = tokens[n-2]
410
+ const captionEndToken = tokens[n-1]
90
411
 
91
412
  cleanCaptionTokenAttrs(captionStartToken, caption.name)
92
413
  captionStartToken.type = 'figcaption_open'
93
414
  captionStartToken.tag = 'figcaption'
94
415
  captionEndToken.type = 'figcaption_close'
95
416
  captionEndToken.tag = 'figcaption'
96
- state.tokens.splice(n + 2, 0, captionStartToken, captionInlineToken, captionEndToken)
97
- state.tokens.splice(n-3, 3)
417
+ tokens.splice(n + 2, 0, captionStartToken, captionInlineToken, captionEndToken)
418
+ tokens.splice(n-3, 3)
98
419
  return true
99
420
  }
100
421
 
101
- const changeNextCaptionPosition = (state, en, caption) => {
102
- const captionStartToken = state.tokens[en+2] // +1: text node for figure.
103
- const captionInlineToken = state.tokens[en+3]
104
- const captionEndToken = state.tokens[en+4]
422
+ const changeNextCaptionPosition = (tokens, en, caption, opt) => {
423
+ const captionStartToken = tokens[en+2] // +1: text node for figure.
424
+ const captionInlineToken = tokens[en+3]
425
+ const captionEndToken = tokens[en+4]
105
426
  cleanCaptionTokenAttrs(captionStartToken, caption.name)
106
427
  captionStartToken.type = 'figcaption_open'
107
428
  captionStartToken.tag = 'figcaption'
108
429
  captionEndToken.type = 'figcaption_close'
109
430
  captionEndToken.tag = 'figcaption'
110
- state.tokens.splice(en, 0, captionStartToken, captionInlineToken, captionEndToken)
111
- state.tokens.splice(en+5, 3)
431
+ tokens.splice(en, 0, captionStartToken, captionInlineToken, captionEndToken)
432
+ tokens.splice(en+5, 3)
112
433
  return true
113
434
  }
114
435
 
115
- const wrapWithFigure = (state, range, checkTokenTagName, caption, replaceInsteadOfWrap, sp, opt) => {
436
+ const wrapWithFigure = (tokens, range, checkTokenTagName, caption, replaceInsteadOfWrap, sp, opt, TokenConstructor) => {
116
437
  let n = range.start
117
438
  let en = range.end
118
- const figureStartToken = new state.Token('figure_open', 'figure', 1)
119
- figureStartToken.attrSet('class', 'f-' + checkTokenTagName)
120
-
121
- if (opt.allIframeTypeFigureClassName === '') {
122
- if (sp.isVideoIframe) {
123
- figureStartToken.attrSet('class', 'f-video')
124
- }
125
- if (sp.isIframeTypeBlockquote) {
126
- let figureClassThatWrapsIframeTypeBlockquote = 'i-frame'
127
- if (caption.isPrev || caption.isNext) {
128
- if (caption.name === 'blockquote' || caption.name === 'img') {
129
- figureClassThatWrapsIframeTypeBlockquote = 'f-img'
130
- }
131
- figureStartToken.attrSet('class', figureClassThatWrapsIframeTypeBlockquote)
132
- } else {
133
- figureClassThatWrapsIframeTypeBlockquote = opt.figureClassThatWrapsIframeTypeBlockquote
134
- figureStartToken.attrSet('class', figureClassThatWrapsIframeTypeBlockquote)
135
- }
136
- }
137
- } else {
138
- if (checkTokenTagName === 'iframe' || sp.isIframeTypeBlockquote) {
139
- figureStartToken.attrSet('class', opt.allIframeTypeFigureClassName)
140
- }
141
- }
439
+ const figureStartToken = new TokenConstructor('figure_open', 'figure', 1)
440
+ const figureClassName = sp.figureClassName || resolveFigureClassName(checkTokenTagName, caption, sp, opt)
441
+ figureStartToken.attrSet('class', figureClassName)
142
442
 
143
443
  if(/pre-(?:code|samp)/.test(checkTokenTagName) && opt.roleDocExample) {
144
444
  figureStartToken.attrSet('role', 'doc-example')
145
445
  }
146
- const figureEndToken = new state.Token('figure_close', 'figure', -1)
147
- const breakToken = new state.Token('text', '', 0)
446
+ const figureEndToken = new TokenConstructor('figure_close', 'figure', -1)
447
+ const breakToken = new TokenConstructor('text', '', 0)
148
448
  breakToken.content = '\n'
149
449
  if (opt.styleProcess && caption.isNext && sp.attrs.length > 0) {
150
450
  for (let attr of sp.attrs) {
@@ -152,18 +452,18 @@ const wrapWithFigure = (state, range, checkTokenTagName, caption, replaceInstead
152
452
  }
153
453
  }
154
454
  // For vsce
155
- if(state.tokens[n].attrs && caption.name === 'img') {
156
- for (let attr of state.tokens[n].attrs) {
455
+ if(tokens[n].attrs && caption.name === 'img') {
456
+ for (let attr of tokens[n].attrs) {
157
457
  figureStartToken.attrJoin(attr[0], attr[1])
158
458
  }
159
459
  }
160
460
  if (replaceInsteadOfWrap) {
161
- state.tokens.splice(en, 1, breakToken, figureEndToken, breakToken)
162
- state.tokens.splice(n, 1, figureStartToken, breakToken)
461
+ tokens.splice(en, 1, breakToken, figureEndToken, breakToken)
462
+ tokens.splice(n, 1, figureStartToken, breakToken)
163
463
  en = en + 2
164
464
  } else {
165
- state.tokens.splice(en+1, 0, figureEndToken, breakToken)
166
- state.tokens.splice(n, 0, figureStartToken, breakToken)
465
+ tokens.splice(en+1, 0, figureEndToken, breakToken)
466
+ tokens.splice(n, 0, figureStartToken, breakToken)
167
467
  en = en + 3
168
468
  }
169
469
  range.start = n
@@ -171,343 +471,493 @@ const wrapWithFigure = (state, range, checkTokenTagName, caption, replaceInstead
171
471
  return
172
472
  }
173
473
 
174
- const checkCaption = (state, n, en, caption, fNum, sp, opt) => {
175
- checkPrevCaption(state, n, caption, fNum, sp, opt)
474
+ const checkCaption = (tokens, n, en, caption, fNum, sp, opt, TokenConstructor) => {
475
+ checkPrevCaption(tokens, n, caption, fNum, sp, opt, TokenConstructor)
176
476
  if (caption.isPrev) return
177
- checkNextCaption(state, en, caption, fNum, sp, opt)
477
+ checkNextCaption(tokens, en, caption, fNum, sp, opt, TokenConstructor)
178
478
  return
179
479
  }
180
480
 
181
- const figureWithCaption = (state, opt) => {
182
- const tokens = state.tokens
183
- const checkTypes = ['table', 'pre', 'blockquote']
184
- const htmlTags = ['video', 'audio', 'iframe', 'blockquote', 'div']
481
+ const nestedContainers = ['blockquote', 'list_item', 'dd']
185
482
 
186
- let n = 0
187
- let fNum = {
188
- img: 0,
189
- table: 0,
483
+ const getNestedContainerType = (token) => {
484
+ if (!token || token.type.indexOf('_open') === -1) return null
485
+ const typeName = token.type.replace('_open', '')
486
+ if (nestedContainers.includes(typeName)) {
487
+ return typeName
190
488
  }
191
- while (n < tokens.length) {
192
- const token = tokens[n]
193
- const nextToken = tokens[n+1]
194
- let en = n
195
- let range = {
196
- start: n,
197
- end: en,
198
- }
199
- let checkToken = false
200
- let checkTokenTagName = ''
201
- let caption = {
202
- mark: '',
203
- name: '',
204
- nameSuffix: '',
205
- isPrev: false,
206
- isNext: false,
207
- }
208
- const sp = {
209
- attrs: [],
210
- isVideoIframe: false,
211
- isIframeTypeBlockquote: false,
212
- hasImgCaption: false,
213
- }
214
-
215
- let cti = 0
216
- while (cti < checkTypes.length) {
217
- if (token.type === checkTypes[cti] + '_open') {
218
- // for n-1 token is line-break
219
- if (n > 1 && tokens[n-2].type === 'figure_open') {
220
- cti++; continue
221
- }
222
- checkToken = true
223
- checkTokenTagName = token.tag
224
- caption.name = checkTypes[cti]
225
- if (checkTypes[cti] === 'pre') {
226
- if (tokens[n+1].tag === 'code') caption.mark = 'pre-code'
227
- if (tokens[n+1].tag === 'samp') caption.mark = 'pre-samp'
228
- caption.name = caption.mark
229
- }
230
- while (en < tokens.length) {
231
- if(tokens[en].type === checkTokenTagName + '_close') {
232
- break
489
+ return null
490
+ }
491
+
492
+ const resetRangeState = (range, start) => {
493
+ range.start = start
494
+ range.end = start
495
+ }
496
+
497
+ const resetCaptionState = (caption) => {
498
+ caption.mark = ''
499
+ caption.name = ''
500
+ caption.nameSuffix = ''
501
+ caption.isPrev = false
502
+ caption.isNext = false
503
+ }
504
+
505
+ const resetSpecialState = (sp) => {
506
+ sp.attrs.length = 0
507
+ sp.isVideoIframe = false
508
+ sp.isIframeTypeBlockquote = false
509
+ sp.hasImgCaption = false
510
+ sp.figureClassName = ''
511
+ sp.captionDecision = null
512
+ }
513
+
514
+ const findClosingTokenIndex = (tokens, startIndex, tag) => {
515
+ let depth = 1
516
+ let i = startIndex + 1
517
+ while (i < tokens.length) {
518
+ const token = tokens[i]
519
+ if (token.type === `${tag}_open`) depth++
520
+ if (token.type === `${tag}_close`) {
521
+ depth--
522
+ if (depth === 0) return i
523
+ }
524
+ i++
525
+ }
526
+ return startIndex
527
+ }
528
+
529
+ const detectCheckTypeOpen = (tokens, token, n, caption) => {
530
+ if (!token) return null
531
+ const baseType = CHECK_TYPE_TOKEN_MAP[token.type]
532
+ if (!baseType) return null
533
+ if (n > 1 && tokens[n - 2] && tokens[n - 2].type === 'figure_open') return null
534
+ let tagName = token.tag
535
+ caption.name = baseType
536
+ if (baseType === 'pre') {
537
+ if (tokens[n + 1] && tokens[n + 1].tag === 'code') caption.mark = 'pre-code'
538
+ if (tokens[n + 1] && tokens[n + 1].tag === 'samp') caption.mark = 'pre-samp'
539
+ caption.name = caption.mark
540
+ }
541
+ const en = findClosingTokenIndex(tokens, n, tagName)
542
+ return {
543
+ type: 'block',
544
+ tagName,
545
+ en,
546
+ replaceInsteadOfWrap: false,
547
+ wrapWithoutCaption: false,
548
+ canWrap: true,
549
+ }
550
+ }
551
+
552
+ const detectFenceToken = (token, n, caption) => {
553
+ if (!token || token.type !== 'fence' || token.tag !== 'code' || !token.block) return null
554
+ let tagName = 'pre-code'
555
+ if (sampLangReg.test(token.info)) {
556
+ token.tag = 'samp'
557
+ tagName = 'pre-samp'
558
+ }
559
+ caption.name = tagName
560
+ return {
561
+ type: 'fence',
562
+ tagName,
563
+ en: n,
564
+ replaceInsteadOfWrap: false,
565
+ wrapWithoutCaption: false,
566
+ canWrap: true,
567
+ }
568
+ }
569
+
570
+ const detectHtmlBlockToken = (tokens, token, n, caption, sp, opt) => {
571
+ if (!token || token.type !== 'html_block') return null
572
+ const blueskyContMatch = token.content.match(blueskyEmbedReg)
573
+ let matchedTag = ''
574
+ for (let i = 0; i < HTML_TAG_CANDIDATES.length; i++) {
575
+ const candidate = HTML_TAG_CANDIDATES[i]
576
+ const treatDivAsIframe = candidate === 'div'
577
+ const lookupTag = treatDivAsIframe ? 'div' : candidate
578
+ const hasTag = token.content.match(getHtmlReg(lookupTag))
579
+ const isBlueskyBlockquote = !hasTag && blueskyContMatch && candidate === 'blockquote'
580
+ if (!(hasTag || isBlueskyBlockquote)) continue
581
+ if (hasTag) {
582
+ if ((hasTag[2] && hasTag[3] !== '\n') || (hasTag[1] !== '\n' && hasTag[2] === undefined)) {
583
+ token.content += '\n'
584
+ }
585
+ matchedTag = treatDivAsIframe ? 'iframe' : candidate
586
+ if (treatDivAsIframe) {
587
+ sp.isVideoIframe = true
588
+ }
589
+ } else {
590
+ let addedCont = ''
591
+ const tokensLength = tokens.length
592
+ let j = n + 1
593
+ while (j < tokensLength) {
594
+ const nextToken = tokens[j]
595
+ if (nextToken.type === 'inline' && endBlockquoteScriptReg.test(nextToken.content)) {
596
+ addedCont += nextToken.content + '\n'
597
+ if (tokens[j + 1] && tokens[j + 1].type === 'paragraph_close') {
598
+ tokens.splice(j + 1, 1)
233
599
  }
234
- en++
600
+ nextToken.content = ''
601
+ nextToken.children.forEach((child) => {
602
+ child.content = ''
603
+ })
604
+ break
235
605
  }
236
- range.end = en
237
- checkCaption(state, n, en, caption, fNum, sp, opt)
238
- if (caption.isPrev || caption.isNext) {
239
- wrapWithFigure(state, range, checkTokenTagName, caption, false, sp, opt)
606
+ if (nextToken.type === 'paragraph_open') {
607
+ addedCont += '\n'
608
+ tokens.splice(j, 1)
609
+ continue
240
610
  }
241
- break
611
+ j++
242
612
  }
613
+ token.content += addedCont
614
+ matchedTag = 'blockquote'
615
+ }
616
+ break
617
+ }
618
+ if (!matchedTag) return null
619
+ if (matchedTag === 'blockquote') {
620
+ const isIframeTypeBlockquote = token.content.match(classNameReg)
621
+ if (isIframeTypeBlockquote) {
622
+ sp.isIframeTypeBlockquote = true
623
+ } else {
624
+ return null
625
+ }
626
+ }
627
+ if (matchedTag === 'iframe' && videoIframeReg.test(token.content)) {
628
+ sp.isVideoIframe = true
629
+ }
630
+ caption.name = matchedTag
631
+ let wrapWithoutCaption = false
632
+ if (matchedTag === 'iframe' && opt.iframeWithoutCaption) {
633
+ wrapWithoutCaption = true
634
+ } else if (matchedTag === 'video' && opt.videoWithoutCaption) {
635
+ wrapWithoutCaption = true
636
+ } else if (matchedTag === 'audio' && opt.audioWithoutCaption) {
637
+ wrapWithoutCaption = true
638
+ } else if (matchedTag === 'blockquote' && sp.isIframeTypeBlockquote && opt.iframeTypeBlockquoteWithoutCaption) {
639
+ wrapWithoutCaption = true
640
+ }
641
+ return {
642
+ type: 'html',
643
+ tagName: matchedTag,
644
+ en: n,
645
+ replaceInsteadOfWrap: false,
646
+ wrapWithoutCaption,
647
+ canWrap: true,
648
+ }
649
+ }
243
650
 
244
- if (token.type === 'fence') {
245
- if (token.tag === 'code' && token.block) {
246
- checkToken = true
247
- let isSamp = false
248
- if (/^ *(?:samp|shell|console)(?:(?= )|$)/.test(token.info)) {
249
- token.tag = 'samp'
250
- isSamp = true
251
- }
252
- if (isSamp) {
253
- checkTokenTagName = 'pre-samp'
254
- caption.name = 'pre-samp'
255
- } else {
256
- checkTokenTagName = 'pre-code'
257
- caption.name = 'pre-code'
651
+ const detectImageParagraph = (tokens, token, nextToken, n, caption, sp, opt) => {
652
+ if (!token || token.type !== 'paragraph_open') return null
653
+ if (!nextToken || nextToken.type !== 'inline' || !nextToken.children || nextToken.children.length === 0) return null
654
+ if (nextToken.children[0].type !== 'image') return null
655
+
656
+ let imageNum = 1
657
+ let isMultipleImagesHorizontal = true
658
+ let isMultipleImagesVertical = true
659
+ let isValid = true
660
+ caption.name = 'img'
661
+ const children = nextToken.children
662
+ const childrenLength = children.length
663
+ for (let childIndex = 1; childIndex < childrenLength; childIndex++) {
664
+ const child = children[childIndex]
665
+ if (childIndex === childrenLength - 1 && child.type === 'text') {
666
+ let imageAttrs = child.content && child.content.match(imageAttrsReg)
667
+ if (imageAttrs) {
668
+ imageAttrs = imageAttrs[1].split(/ +/)
669
+ for (let i = 0; i < imageAttrs.length; i++) {
670
+ if (classAttrReg.test(imageAttrs[i])) {
671
+ imageAttrs[i] = imageAttrs[i].replace(classAttrReg, 'class=')
258
672
  }
259
- checkCaption(state, n, en, caption, fNum, sp, opt)
260
- if (caption.isPrev || caption.isNext) {
261
- wrapWithFigure(state, range, checkTokenTagName, caption, false, sp, opt)
262
- break
673
+ if (idAttrReg.test(imageAttrs[i])) {
674
+ imageAttrs[i] = imageAttrs[i].replace(idAttrReg, 'id=')
263
675
  }
676
+ const imageAttr = imageAttrs[i].match(attrParseReg)
677
+ if (!imageAttr || !imageAttr[1]) continue
678
+ sp.attrs.push([imageAttr[1], imageAttr[2]])
264
679
  }
265
680
  break
266
681
  }
267
- cti++
268
- }
269
-
270
- if (token.type === 'html_block') {
271
- let ctj = 0
272
- let hasTag
273
- while (ctj < htmlTags.length) {
274
- if (htmlTags[ctj] === 'div') {
275
- // for vimeo
276
- hasTag = token.content.match(getHtmlReg('div'))
277
- htmlTags[ctj] = 'iframe'
278
- sp.isVideoIframe = true
279
- } else {
280
- hasTag = token.content.match(getHtmlReg(htmlTags[ctj]))
281
- }
282
- const blueskyContMatch = token.content.match(blueskyEmbedReg)
283
- if (!(hasTag || (blueskyContMatch && htmlTags[ctj] === 'blockquote'))) {
284
- ctj++
285
- continue
286
- }
287
- if (hasTag) {
288
- if ((hasTag[2] && hasTag[3] !== '\n') || (hasTag[1] !== '\n' && hasTag[2] === undefined)) {
289
- token.content += '\n'
290
- }
291
- } else if (blueskyContMatch) {
292
- let addedCont = ''
293
- const tokensChildren = tokens
294
- const tokensLength = tokensChildren.length
295
- let j = n + 1
296
- let hasEndBlockquote = true
297
- while (j < tokensLength) {
298
- const nextToken = tokens[j]
299
- if (nextToken.type === 'inline' && /<\/blockquote> *<script[^>]*?><\/script>$/.test(nextToken.content)) {
300
- addedCont += nextToken.content + '\n'
301
- if (tokens[j + 1] && tokens[j + 1].type === 'paragraph_close') {
302
- tokens.splice(j + 1, 1)
303
- }
304
- nextToken.content = ''
305
- nextToken.children.forEach((child) => {
306
- child.content = ''
307
- })
308
- break
309
- }
310
- if (nextToken.type === 'paragraph_open') {
311
- addedCont += '\n'
312
- tokens.splice(j, 1)
313
- continue
314
- }
315
- j++
316
- }
317
- token.content += addedCont
318
- if (!hasEndBlockquote) {
319
- ctj++
320
- continue
321
- }
322
- }
682
+ }
323
683
 
324
- checkTokenTagName = htmlTags[ctj]
325
- caption.name = htmlTags[ctj]
326
- checkToken = true
327
- if (checkTokenTagName === 'blockquote') {
328
- const classNameReg = /^<[^>]*? class="(twitter-tweet|instagram-media|text-post-media|bluesky-embed|mastodon-embed)"/
329
- const isIframeTypeBlockquote = token.content.match(classNameReg)
330
- if(isIframeTypeBlockquote) {
331
- sp.isIframeTypeBlockquote = true
332
- } else {
333
- ctj++
334
- continue
335
- }
336
- }
337
- break
338
- }
339
- if (!checkToken) {n++; continue;}
340
- if (checkTokenTagName === 'iframe') {
341
- if(/^<[^>]*? src="https:\/\/(?:www.youtube-nocookie.com|player.vimeo.com)\//i.test(token.content)) {
342
- sp.isVideoIframe = true
343
- }
684
+ if (!opt.multipleImages) {
685
+ isValid = false
686
+ break
687
+ }
688
+ if (child.type === 'image') {
689
+ imageNum++
690
+ continue
691
+ }
692
+ if (isOnlySpacesText(child)) {
693
+ isMultipleImagesVertical = false
694
+ continue
695
+ }
696
+ if (child.type === 'softbreak') {
697
+ isMultipleImagesHorizontal = false
698
+ continue
699
+ }
700
+ isValid = false
701
+ break
702
+ }
703
+ if (isValid && imageNum > 1 && opt.multipleImages) {
704
+ if (isMultipleImagesHorizontal) {
705
+ caption.nameSuffix = '-horizontal'
706
+ } else if (isMultipleImagesVertical) {
707
+ caption.nameSuffix = '-vertical'
708
+ } else {
709
+ caption.nameSuffix = '-multiple'
710
+ }
711
+ for (let i = 0; i < childrenLength; i++) {
712
+ const child = children[i]
713
+ if (isOnlySpacesText(child)) {
714
+ child.content = ''
344
715
  }
716
+ }
717
+ }
718
+ nextToken.children[0].type = 'image'
719
+ const en = n + 2
720
+ let tagName = 'img'
721
+ if (caption.nameSuffix) tagName += caption.nameSuffix
722
+ return {
723
+ type: 'image',
724
+ tagName,
725
+ en,
726
+ replaceInsteadOfWrap: true,
727
+ wrapWithoutCaption: isValid && !!opt.oneImageWithoutCaption,
728
+ canWrap: isValid,
729
+ imageToken: children[0]
730
+ }
731
+ }
345
732
 
346
- checkCaption(state, n, en, caption, fNum, sp, opt)
347
- if (caption.isPrev || caption.isNext) {
348
- wrapWithFigure(state, range, checkTokenTagName, caption, false, sp, opt)
349
- n = en + 2
350
- } else if ((opt.iframeWithoutCaption && (checkTokenTagName === 'iframe')) ||
351
- (opt.videoWithoutCaption && (checkTokenTagName === 'video')) ||
352
- (opt.iframeTypeBlockquoteWithoutCaption && (checkTokenTagName === 'blockquote'))) {
353
- wrapWithFigure(state, range, checkTokenTagName, caption, false, sp, opt)
354
- n = en + 2
733
+ const figureWithCaption = (state, opt) => {
734
+ let fNum = {
735
+ img: 0,
736
+ table: 0,
737
+ }
738
+
739
+ const figureNumberState = {
740
+ img: 0,
741
+ table: 0,
742
+ }
743
+
744
+ const fallbackLabelState = {
745
+ img: null,
746
+ table: null,
747
+ }
748
+
749
+ figureWithCaptionCore(state.tokens, opt, fNum, figureNumberState, fallbackLabelState, state.Token, null, 0)
750
+ }
751
+
752
+ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, parentType = null, startIndex = 0) => {
753
+ const rRange = { start: startIndex, end: startIndex }
754
+ const rCaption = {
755
+ mark: '', name: '', nameSuffix: '', isPrev: false, isNext: false
756
+ }
757
+ const rSp = {
758
+ attrs: [], isVideoIframe: false, isIframeTypeBlockquote: false, hasImgCaption: false, captionDecision: null
759
+ }
760
+
761
+ let n = startIndex
762
+ while (n < tokens.length) {
763
+ const token = tokens[n]
764
+ const containerType = getNestedContainerType(token)
765
+
766
+ if (containerType && containerType !== 'blockquote') {
767
+ const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, containerType, n + 1)
768
+ n = (typeof closeIndex === 'number' ? closeIndex : n) + 1
769
+ continue
770
+ }
771
+
772
+
773
+ if (parentType && token.type === `${parentType}_close`) {
774
+ return n
775
+ }
776
+
777
+ const nextToken = tokens[n + 1]
778
+ resetRangeState(rRange, n)
779
+ resetCaptionState(rCaption)
780
+ resetSpecialState(rSp)
781
+
782
+ const detection =
783
+ detectCheckTypeOpen(tokens, token, n, rCaption) ||
784
+ detectFenceToken(token, n, rCaption) ||
785
+ detectHtmlBlockToken(tokens, token, n, rCaption, rSp, opt) ||
786
+ detectImageParagraph(tokens, token, nextToken, n, rCaption, rSp, opt)
787
+
788
+ if (!detection) {
789
+ if (containerType === 'blockquote') {
790
+ const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, containerType, n + 1)
791
+ n = (typeof closeIndex === 'number' ? closeIndex : n) + 1
792
+ } else {
793
+ n++
355
794
  }
795
+ continue
356
796
  }
357
797
 
358
- if (token.type === 'paragraph_open' && nextToken.type === 'inline' && nextToken.children[0].type === 'image') {
359
- let ntChildTokenIndex = 1
360
- let imageNum = 1
361
- let isMultipleImagesHorizontal = true
362
- let isMultipleImagesVertical = true
363
- checkToken = true
364
- caption.name = 'img'
365
- const children = nextToken.children
366
- const childrenLength = children.length
367
- while (ntChildTokenIndex < childrenLength) {
368
- const ntChildToken = children[ntChildTokenIndex]
369
- if (ntChildTokenIndex === childrenLength - 1) {
370
- let imageAttrs = ntChildToken.content.match(/^ *\{(.*?)\} *$/)
371
- if(ntChildToken.type === 'text' && imageAttrs) {
372
- imageAttrs = imageAttrs[1].split(/ +/)
373
- let iai = 0
374
- const attrsLength = imageAttrs.length
375
- while (iai < attrsLength) {
376
- if (/^\./.test(imageAttrs[iai])) {
377
- imageAttrs[iai] = imageAttrs[iai].replace(/^\./, "class=")
378
- }
379
- if (/^#/.test(imageAttrs[iai])) {
380
- imageAttrs[iai] = imageAttrs[iai].replace(/^#/, "id=")
381
- }
382
- let imageAttr = imageAttrs[iai].match(/^(.*?)="?(.*)"?$/)
383
- if (!imageAttr || !imageAttr[1]) {
384
- iai++
385
- continue
386
- }
387
- sp.attrs.push([imageAttr[1], imageAttr[2]])
388
- iai++
389
- }
390
- break
391
- }
392
- }
798
+ rRange.end = detection.en
393
799
 
394
- if (!opt.multipleImages) {
395
- checkToken = false
396
- break
397
- }
398
- if (ntChildToken.type === 'image') {
399
- imageNum += 1
400
- } else if (ntChildToken.type === 'text' && /^ *$/.test(ntChildToken.content)) {
401
- isMultipleImagesVertical = false
402
- if (isMultipleImagesVertical) {
403
- isMultipleImagesHorizontal = false
404
- }
405
- } else if (ntChildToken.type === 'softbreak') {
406
- isMultipleImagesHorizontal = false
407
- if (isMultipleImagesHorizontal) {
408
- isMultipleImagesVertical = false
409
- }
410
- } else {
411
- checkToken = false
412
- break
413
- }
414
- ntChildTokenIndex++
800
+ rSp.figureClassName = resolveFigureClassName(detection.tagName, rCaption, rSp, opt)
801
+ checkCaption(tokens, rRange.start, rRange.end, rCaption, fNum, rSp, opt, TokenConstructor)
802
+ applyCaptionDrivenFigureClass(rCaption, rSp, opt)
803
+
804
+ let hasCaption = rCaption.isPrev || rCaption.isNext
805
+ let pendingAutoCaption = ''
806
+ if (!hasCaption && detection.type === 'image' && opt.autoCaptionDetection) {
807
+ pendingAutoCaption = getAutoCaptionFromImage(detection.imageToken, opt, fallbackLabelState)
808
+ if (pendingAutoCaption) {
809
+ hasCaption = true
415
810
  }
416
- if (checkToken && imageNum > 1 && opt.multipleImages) {
417
- if (isMultipleImagesHorizontal) {
418
- caption.nameSuffix = '-horizontal'
419
- } else if (isMultipleImagesVertical) {
420
- caption.nameSuffix = '-vertical'
421
- } else {
422
- caption.nameSuffix = '-multiple'
423
- }
424
- ntChildTokenIndex = 0
425
- while (ntChildTokenIndex < childrenLength) {
426
- const ccToken = children[ntChildTokenIndex]
427
- if (ccToken.type === 'text' && /^ *$/.test(ccToken.content)) {
428
- ccToken.content = ''
429
- }
430
- ntChildTokenIndex++
431
- }
811
+ }
812
+
813
+ if (detection.canWrap === false) {
814
+ let nextIndex = rRange.end + 1
815
+ if (containerType === 'blockquote') {
816
+ const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, containerType, rRange.start + 1)
817
+ nextIndex = Math.max(nextIndex, (typeof closeIndex === 'number' ? closeIndex : rRange.end) + 1)
432
818
  }
433
- en = n + 2
434
- range.end = en
435
- checkTokenTagName = 'img'
436
- nextToken.children[0].type = 'image'
819
+ n = nextIndex
820
+ continue
821
+ }
437
822
 
438
- if (opt.imgAltCaption) setAltToLabel(state, n)
439
- if (opt.imgTitleCaption) setTitleToLabel(state, n)
440
- checkCaption(state, n, en, caption, fNum, sp, opt)
823
+ let shouldWrap = false
824
+ if (detection.type === 'html') {
825
+ shouldWrap = detection.canWrap !== false && (hasCaption || detection.wrapWithoutCaption)
826
+ } else if (detection.type === 'image') {
827
+ shouldWrap = detection.canWrap !== false && (hasCaption || detection.wrapWithoutCaption)
828
+ if (parentType === 'list_item' || isInListItem(tokens, n)) {
829
+ const isInTightList = token.hidden === true
830
+ if (isInTightList) {
831
+ shouldWrap = false
832
+ } else if (!hasCaption && !opt.oneImageWithoutCaption) {
833
+ shouldWrap = false
834
+ }
835
+ }
836
+ } else {
837
+ shouldWrap = detection.canWrap !== false && hasCaption
838
+ }
441
839
 
442
- if (opt.oneImageWithoutCaption && tokens[n-1]) {
443
- if (tokens[n-1].type === 'list_item_open') checkToken = false
840
+ if (shouldWrap) {
841
+ if (pendingAutoCaption) {
842
+ const captionTokens = createAutoCaptionParagraph(pendingAutoCaption, TokenConstructor)
843
+ tokens.splice(rRange.start, 0, ...captionTokens)
844
+ const insertedLength = captionTokens.length
845
+ rRange.start += insertedLength
846
+ rRange.end += insertedLength
847
+ n += insertedLength
848
+ checkCaption(tokens, rRange.start, rRange.end, rCaption, fNum, rSp, opt, TokenConstructor)
849
+ applyCaptionDrivenFigureClass(rCaption, rSp, opt)
444
850
  }
445
- if (checkToken && (opt.oneImageWithoutCaption || caption.isPrev || caption.isNext)) {
446
- if (caption.nameSuffix) checkTokenTagName += caption.nameSuffix
447
- wrapWithFigure(state, range, checkTokenTagName, caption, true, sp, opt)
851
+ ensureAutoFigureNumbering(tokens, rRange, rCaption, figureNumberState, opt)
852
+ wrapWithFigure(tokens, rRange, detection.tagName, rCaption, detection.replaceInsteadOfWrap, rSp, opt, TokenConstructor)
853
+ }
854
+
855
+ let nextIndex
856
+ if (!rCaption.name) {
857
+ nextIndex = n + 1
858
+ } else {
859
+ const en = rRange.end
860
+ if (rCaption.isPrev) {
861
+ changePrevCaptionPosition(tokens, rRange.start, rCaption, opt)
862
+ nextIndex = en + 1
863
+ } else if (rCaption.isNext) {
864
+ changeNextCaptionPosition(tokens, en, rCaption)
865
+ nextIndex = en + 4
866
+ } else {
867
+ nextIndex = en + 1
448
868
  }
449
869
  }
450
870
 
451
- if (!checkToken || !caption.name) {n++; continue;}
871
+ if (containerType === 'blockquote') {
872
+ const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, containerType, rRange.start + 1)
873
+ const fallbackIndex = rCaption.name ? rRange.end : n
874
+ nextIndex = Math.max(nextIndex, (typeof closeIndex === 'number' ? closeIndex : fallbackIndex) + 1)
875
+ }
452
876
 
453
- n = range.start
454
- en = range.end
455
- if (caption.isPrev) {
456
- changePrevCaptionPosition(state, n, caption, opt)
457
- n = en + 1
458
- continue
877
+ n = nextIndex
878
+ }
879
+ return tokens.length
880
+ }
881
+
882
+ const isInListItem = (() => {
883
+ const cache = new WeakMap()
884
+ return (tokens, idx) => {
885
+ if (cache.has(tokens)) {
886
+ const cachedResult = cache.get(tokens)
887
+ if (cachedResult[idx] !== undefined) {
888
+ return cachedResult[idx]
889
+ }
890
+ } else {
891
+ cache.set(tokens, {})
459
892
  }
460
- if (caption.isNext) {
461
- changeNextCaptionPosition(state, en, caption)
462
- n = en + 4
463
- continue
893
+
894
+ const result = cache.get(tokens)
895
+
896
+ for (let i = idx - 1; i >= 0; i--) {
897
+ if (tokens[i].type === 'list_item_open') {
898
+ result[idx] = true
899
+ return true
900
+ }
901
+ if (tokens[i].type === 'list_item_close' || tokens[i].type === 'list_open') {
902
+ result[idx] = false
903
+ return false
904
+ }
464
905
  }
465
- n = en + 1
906
+
907
+ result[idx] = false
908
+ return false
466
909
  }
467
- return
468
- }
910
+ })()
469
911
 
470
912
  const mditFigureWithPCaption = (md, option) => {
471
913
  let opt = {
914
+ // --- figure-wrapper behavior ---
472
915
  classPrefix: 'f',
473
916
  figureClassThatWrapsIframeTypeBlockquote: 'f-img',
917
+ figureClassThatWrapsSlides: 'f-slide',
474
918
  styleProcess : true,
919
+ oneImageWithoutCaption: false,
920
+ iframeWithoutCaption: false,
921
+ videoWithoutCaption: false,
922
+ audioWithoutCaption: false,
923
+ iframeTypeBlockquoteWithoutCaption: false,
924
+ multipleImages: true,
925
+ roleDocExample: false,
926
+ allIframeTypeFigureClassName: '', // e.g. 'f-embed' to force a single class for iframe-like embeds (recommended)
927
+
928
+ // --- automatic caption detection heuristics ---
929
+ // Applies only to the first image within an image-only paragraph (even when multipleImages is true).
930
+ // Priority: caption paragraphs (before/after) > alt text > title attribute; auto detection only runs when no paragraph caption exists.
931
+ autoCaptionDetection: true,
932
+ autoAltCaption: false, // allow alt text (when matching markReg.img or fallback) to build captions automatically
933
+ autoTitleCaption: false, // same as above but reads from the title attribute when alt isn't usable
934
+
935
+ // --- numbering controls ---
936
+ autoLabelNumber: false, // shorthand for enabling numbering for both img/table unless setLabelNumbers is provided explicitly
937
+ setLabelNumbers: [], // preferred; supports ['img'], ['table'], or both
938
+
939
+ // --- caption text formatting (delegated to p7d-markdown-it-p-captions) ---
475
940
  hasNumClass: false,
476
- scaleSuffix: false,
477
941
  dquoteFilename: false,
478
942
  strongFilename: false,
479
943
  bLabel: false,
480
944
  strongLabel: false,
481
945
  jointSpaceUseHalfWidth: false,
482
- oneImageWithoutCaption: false,
483
- iframeWithoutCaption: false,
484
- videoWithoutCaption: false,
485
- iframeTypeBlockquoteWithoutCaption: false,
486
946
  removeUnnumberedLabel: false,
487
947
  removeUnnumberedLabelExceptMarks: [],
488
948
  removeMarkNameInCaptionClass: false,
489
- multipleImages: true,
490
- imgAltCaption: false,
491
- setFigureNumber: false,
492
- imgTitleCaption: false,
493
- roleDocExample: false,
494
- allIframeTypeFigureClassName: '',
949
+ wrapCaptionBody: false,
495
950
  }
951
+ const hasExplicitSetLabelNumbers = option && Object.prototype.hasOwnProperty.call(option, 'setLabelNumbers')
496
952
  if (option) Object.assign(opt, option)
497
-
498
- if (opt.imgAltCaption || opt.imgTitleCaption) {
499
- opt.oneImageWithoutCaption = true
500
- opt.multipleImages = false
501
- if (opt.setFigureNumber) {
502
- for (let mark of opt.removeUnnumberedLabelExceptMarks) {
503
- if (mark === 'img') opt.removeUnnumberedLabelExceptMarks.splice(opt.removeUnnumberedLabelExceptMarks.indexOf(mark), 1)
504
- if (mark === 'table') opt.removeUnnumberedLabelExceptMarks.splice(opt.removeUnnumberedLabelExceptMarks.indexOf(mark), 1)
505
- }
506
- }
507
- md.block.ruler.before('paragraph', 'img_attr_caption', (state) => {
508
- imgAttrToPCaption(state, state.line, opt)
509
- })
953
+ // Normalize option shorthands now so downstream logic works with a consistent { img, table } shape.
954
+ opt.setLabelNumbers = normalizeSetLabelNumbers(opt.setLabelNumbers)
955
+ if (opt.autoLabelNumber && !hasExplicitSetLabelNumbers) {
956
+ opt.setLabelNumbers.img = true
957
+ opt.setLabelNumbers.table = true
510
958
  }
959
+ // Precompute `.f-*-label` permutations so numbering lookup doesn't rebuild arrays per caption.
960
+ opt.labelClassLookup = buildLabelClassLookup(opt)
511
961
 
512
962
  //If nextCaption has `{}` style and `f-img-multipleImages`, when upgraded to markdown-it-attrs@4.2.0, the existing script will have `{}` style on nextCaption. Therefore, since markdown-it-attrs is md.core.ruler.before('linkify'), figure_with_caption will be processed after it.
513
963
  md.core.ruler.before('replacements', 'figure_with_caption', (state) => {
@@ -515,4 +965,4 @@ const mditFigureWithPCaption = (md, option) => {
515
965
  })
516
966
  }
517
967
 
518
- export default mditFigureWithPCaption
968
+ export default mditFigureWithPCaption