@peaceroad/markdown-it-figure-with-p-caption 0.16.0 → 0.17.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/README.md CHANGED
@@ -15,10 +15,10 @@ Optionally, you can auto-number image and table caption paragraphs starting from
15
15
  - Pure image paragraphs (`![...](...)`) become `<figure class="f-img">` blocks as soon as a caption paragraph (previous or next) or an auto-detected caption exists.
16
16
  - Auto detection runs per image paragraph when `autoCaptionDetection` is `true` (default). The priority is:
17
17
  1. Caption paragraphs immediately before or after the image (standard syntax).
18
- 2. Image `alt` text that already matches p7d-markdown-it-p-captions label formats (`Figure. `, `Figure 1. `, `図 `,`図1 `, etc.).
18
+ 2. Image `alt` text that `p7d-markdown-it-p-captions` recognizes as an image caption start (`Figure. `, `Figure 1. `, `図 `, `図1 `, etc.).
19
19
  3. Image `title` attribute that matches the same labels.
20
20
  4. Optional fallbacks (`autoAltCaption`, `autoTitleCaption`) that inject the label when the alt/title lacks one.
21
- - `autoAltCaption`: `false` (default), `true`, or a string label. `true` inspects the first sentence of the caption text and picks `Figure` / `図` based on detected language; a string uses that label verbatim.
21
+ - `autoAltCaption`: `false` (default), `true`, or a string label. `true` uses locale-aware generated-label defaults from `p7d-markdown-it-p-captions`, so the label text and punctuation stay aligned with the upstream caption language data. A string uses that label verbatim. Empty alt text does not generate a fallback caption.
22
22
  - `autoTitleCaption`: same behavior but sourced from the image `title`. It stays off by default so other plugins can keep using the `title` attribute for metadata.
23
23
  - Set `autoCaptionDetection: false` to disable the auto-caption workflow entirely.
24
24
  - Multi-image paragraphs are still wrapped as one figure when `multipleImages: true` (default). Layout-specific classes help with styling:
@@ -50,9 +50,9 @@ Optionally, you can auto-number image and table caption paragraphs starting from
50
50
 
51
51
  ### Embedded content by iframe
52
52
 
53
- - Inline HTML `<iframe>` elements become `<figure class="f-video">` when they point to known video hosts (YouTube `youtube.com` / `youtube-nocookie.com`, Vimeo `player.vimeo.com`).
53
+ - Inline HTML `<iframe>` elements become `<figure class="f-video">` when they point to known video hosts (YouTube `youtube-nocookie.com`, Vimeo `player.vimeo.com`).
54
54
  - `<div>` wrappers are treated as iframe-type embeds only when the same HTML block contains an `<iframe ...>` tag (for example common video wrapper markup).
55
- - Blockquote-based social embeds (Twitter/X `twitter-tweet`, Mastodon `mastodon-embed`, Bluesky `bluesky-embed`, Instagram `instagram-media`, Tumblr `text-post-media`) are treated like iframe-type embeds when their `class` matches those providers. By default they become `<figure class="f-img">` so the caption label behaves like an image label (Labels can also use quote labels). You can override that figure class with `figureClassThatWrapsIframeTypeBlockquote` or the global `allIframeTypeFigureClassName`.
55
+ - Blockquote-based social embeds (Twitter/X `twitter-tweet`, Mastodon `mastodon-embed`, Bluesky `bluesky-embed`, Instagram `instagram-media`, Tumblr `text-post-media`) are treated like iframe-type embeds when their class list contains one of those provider classes. Extra classes on the same blockquote do not block detection. By default they become `<figure class="f-img">` so the caption label behaves like an image label (Labels can also use quote labels). You can override that figure class with `figureClassThatWrapsIframeTypeBlockquote` or the global `allIframeTypeFigureClassName`.
56
56
  - `p7d-markdown-it-p-captions` ships with a `Slide.` label. When you use it (for example with Speaker Deck or SlideShare iframes), the `<figure>` wrapper automatically switches to `f-slide` (or whatever you set via `figureClassThatWrapsSlides`) so slides can get their own layout. If `allIframeTypeFigureClassName` is also configured, that class takes precedence even for slides, so you get a uniform embed wrapper without touching the slide option.
57
57
  - All other iframes fall back to `<figure class="f-iframe">` unless you override the class via `allIframeTypeFigureClassName`.
58
58
 
@@ -60,7 +60,7 @@ Optionally, you can auto-number image and table caption paragraphs starting from
60
60
 
61
61
  - The label inside the figcaption (the `span` element used for the label) is generated by `p7d-markdown-it-p-captions`, not by this plugin. By default the class name is formed by combining `classPrefix` with the mark name, producing names such as `f-img-label`, `f-video-label`, `f-blockquote-label`, and `f-slide-label`.
62
62
  - With `markdown-it-attrs`, attributes attached to image-only paragraphs (for example `![...](...) {.foo #bar}`) are forwarded to the generated `<figure>`.
63
- - `styleProcess` controls parsing of trailing `{...}` from inline text in this plugin's own image scanner, but attributes already attached to paragraph tokens by `markdown-it-attrs` are still forwarded.
63
+ - `styleProcess` controls parsing of a trailing `{...}` block from the last text token of an image-only paragraph in this plugin's own scanner. It is a narrow fallback parser, not full `markdown-it-attrs` parity, and attributes already attached to paragraph tokens by `markdown-it-attrs` are still forwarded.
64
64
  - Attributes attached to caption paragraphs stay on the converted `<figcaption>` token after paragraph-to-figcaption conversion.
65
65
 
66
66
  ## Behavior Customization
@@ -71,6 +71,7 @@ Optionally, you can auto-number image and table caption paragraphs starting from
71
71
  - `figureClassThatWrapsIframeTypeBlockquote`: override the class used when blockquote-based embeds (Twitter, Mastodon, Bluesky) are wrapped.
72
72
  - `figureClassThatWrapsSlides`: override the class assigned when a caption paragraph uses the `Slide.` label.
73
73
  - `classPrefix` (default `f`) controls the CSS namespace for every figure (`f-img`, `f-table`, etc.) so you can align with existing styles.
74
+ - Wrapper/class-prefix options are trimmed during setup; whitespace-only values fall back to the default class for that option.
74
75
 
75
76
  ### Wrapping without captions
76
77
 
@@ -84,13 +85,15 @@ Every option below is forwarded verbatim to `p7d-markdown-it-p-captions`, which
84
85
  - `strongFilename` / `dquoteFilename`: pull out filenames from captions using `**filename**` or `"filename"` syntax and wrap them in `<strong class="f-*-filename">`.
85
86
  - `jointSpaceUseHalfWidth`: replace full-width space between Japanese labels and caption body with half-width space.
86
87
  - `bLabel` / `strongLabel`: emphasize the label span itself.
87
- - `removeUnnumberedLabel`: drop the leading “Figure. Etext entirely when no label number is present. Use `removeUnnumberedLabelExceptMarks` to keep specific labels (e.g., `['blockquote']` keeps `Quote. `).
88
+ - `removeUnnumberedLabel`: drop the leading label entirely when no label number is present. Use `removeUnnumberedLabelExceptMarks` to keep specific labels (e.g., `['blockquote']` keeps `Quote. `).
88
89
  - `removeMarkNameInCaptionClass`: replace `.f-img-label` / `.f-table-label` with the generic `.f-label`.
89
90
  - `wrapCaptionBody`: wrap the non-label caption text in a span element.
90
91
  - `hasNumClass`: add a class attribute to label span element if it has a label number.
91
92
  - `labelClassFollowsFigure`: mirror the resolved `<figure>` class onto the `figcaption` spans (`f-embed-label`, `f-embed-label-joint`, `f-embed-body`, etc.) when you want captions styled alongside the wrapper.
92
93
  - `figureToLabelClassMap`: extend `labelClassFollowsFigure` by mapping specific figure classes (e.g., `f-embed`) to custom caption label classes such as `caption-embed caption-social` for fine-grained control. When this map is provided and `labelClassFollowsFigure` is not set explicitly, figure-following mode is enabled automatically.
93
94
  - `labelPrefixMarker`: allow a leading marker before labels (string or array, e.g., `*Figure. ...`). Arrays are limited to two markers; extras are ignored.
95
+ - Automatic image-label fallback text and punctuation (`Figure. `, `図 `, etc.) are generated from `p7d-markdown-it-p-captions` locale metadata, not from a local hardcoded map in this plugin.
96
+ - `preferredLanguages`: optional tie-break order for generated fallback labels. When omitted, this plugin derives the order once per render from `env.preferredLanguages`, `env.lang` / `env.locale`, then a cheap document-script heuristic that skips a leading hyphen-fenced frontmatter block (`---` or longer, spaces allowed before newline), and finally the raw `languages` order.
94
97
 
95
98
  ### Automatic numbering
96
99
 
@@ -437,13 +440,13 @@ A paragraph.
437
440
 
438
441
  ### Styles
439
442
 
440
- This example uses `classPrefix: 'custom'` and leaves `styleProcess: true` so a trailing `{.notice}` block moves onto the `<figure>` wrapper.
443
+ This example uses `classPrefix: 'custom'` and leaves `styleProcess: true` so a trailing `{.notice}` block moves onto the `<figure>` wrapper. This fallback only handles the final trailing attrs block on an image-only paragraph; for broader attrs syntax support, keep using `markdown-it-attrs`.
441
444
 
442
445
  ```
443
446
  [Markdown]
444
- Figure. Highlighted cat. {.notice}
447
+ Figure. Highlighted cat.
445
448
 
446
- ![Highlighted cat](cat.jpg)
449
+ ![Highlighted cat](cat.jpg) {.notice}
447
450
  [HTML]
448
451
  <figure class="custom-img notice">
449
452
  <figcaption><span class="custom-img-label">Figure<span class="custom-img-label-joint">.</span></span> Highlighted cat.</figcaption>
@@ -453,7 +456,7 @@ Figure. Highlighted cat. {.notice}
453
456
 
454
457
  ### Automatic detection fallbacks
455
458
 
456
- `autoCaptionDetection` combined with `autoAltCaption` / `autoTitleCaption` can still generate caption text even when the original alt/title lacks labels. The corresponding attributes are cleared after conversion so the figcaption becomes the canonical source.
459
+ `autoCaptionDetection` combined with `autoAltCaption` / `autoTitleCaption` can still generate caption text even when the original alt/title lacks labels, as long as the alt/title body is non-empty. The corresponding attributes are cleared after conversion so the figcaption becomes the canonical source. When these fallbacks are `true`, the generated label text and punctuation come from `p7d-markdown-it-p-captions` locale metadata rather than a local hardcoded map.
457
460
 
458
461
  ```
459
462
  [Markdown]
@@ -0,0 +1,178 @@
1
+ import {
2
+ BLOCKQUOTE_EMBED_CLASS_NAMES,
3
+ HTML_EMBED_CANDIDATES,
4
+ VIDEO_IFRAME_HOSTS,
5
+ } from './providers.js'
6
+
7
+ const htmlRegCache = new Map()
8
+ const openingClassAttrReg = /^<[^>]*?\bclass=(?:"([^"]*)"|'([^']*)')/i
9
+ const openingSrcAttrReg = /^<[^>]*?\bsrc=(?:"([^"]*)"|'([^']*)')/i
10
+ const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/
11
+ const iframeTagReg = /<iframe(?=[\s>])/i
12
+
13
+ const getHtmlReg = (tag) => {
14
+ const cached = htmlRegCache.get(tag)
15
+ if (cached) return cached
16
+ const regexStr = `^<${tag} ?[^>]*?>[\\s\\S]*?<\\/${tag}>(\\n| *?)(<script [^>]*?>(?:<\\/script>)?)? *(\\n|$)`
17
+ const reg = new RegExp(regexStr)
18
+ htmlRegCache.set(tag, reg)
19
+ return reg
20
+ }
21
+
22
+ const getHtmlDetectionHints = (content) => {
23
+ const hasBlueskyHint = content.indexOf('bluesky-embed') !== -1
24
+ const hasVideoHint = content.indexOf('<video') !== -1
25
+ const hasAudioHint = content.indexOf('<audio') !== -1
26
+ const hasIframeHint = content.indexOf('<iframe') !== -1
27
+ const hasBlockquoteHint = content.indexOf('<blockquote') !== -1
28
+ const hasDivHint = content.indexOf('<div') !== -1
29
+ return {
30
+ hasBlueskyHint,
31
+ hasVideoHint,
32
+ hasAudioHint,
33
+ hasIframeHint,
34
+ hasBlockquoteHint,
35
+ hasDivHint,
36
+ hasIframeTag: hasIframeHint || (hasDivHint && iframeTagReg.test(content)),
37
+ }
38
+ }
39
+
40
+ const hasAnyHtmlDetectionHint = (hints) => {
41
+ return !!(
42
+ hints.hasBlueskyHint ||
43
+ hints.hasVideoHint ||
44
+ hints.hasAudioHint ||
45
+ hints.hasIframeHint ||
46
+ hints.hasBlockquoteHint ||
47
+ hints.hasDivHint
48
+ )
49
+ }
50
+
51
+ const appendHtmlBlockNewlineIfNeeded = (token, hasTag) => {
52
+ if ((hasTag[2] && hasTag[3] !== '\n') || (hasTag[1] !== '\n' && hasTag[2] === undefined)) {
53
+ token.content += '\n'
54
+ }
55
+ }
56
+
57
+ const consumeBlockquoteEmbedScript = (tokens, token, startIndex) => {
58
+ let addedContent = ''
59
+ let i = startIndex + 1
60
+ while (i < tokens.length) {
61
+ const nextToken = tokens[i]
62
+ if (nextToken.type === 'inline' && endBlockquoteScriptReg.test(nextToken.content)) {
63
+ addedContent += nextToken.content + '\n'
64
+ if (tokens[i + 1] && tokens[i + 1].type === 'paragraph_close') {
65
+ tokens.splice(i + 1, 1)
66
+ }
67
+ nextToken.content = ''
68
+ if (nextToken.children) {
69
+ for (let j = 0; j < nextToken.children.length; j++) {
70
+ nextToken.children[j].content = ''
71
+ }
72
+ }
73
+ break
74
+ }
75
+ if (nextToken.type === 'paragraph_open') {
76
+ addedContent += '\n'
77
+ tokens.splice(i, 1)
78
+ continue
79
+ }
80
+ i++
81
+ }
82
+ token.content += addedContent
83
+ }
84
+
85
+ const getOpeningAttrValue = (content, reg) => {
86
+ if (typeof content !== 'string' || content.charCodeAt(0) !== 0x3c) return ''
87
+ const match = content.match(reg)
88
+ if (!match) return ''
89
+ return match[1] || match[2] || ''
90
+ }
91
+
92
+ const hasKnownBlockquoteEmbedClass = (content) => {
93
+ const classAttr = getOpeningAttrValue(content, openingClassAttrReg)
94
+ if (!classAttr) return false
95
+ let start = 0
96
+ while (start < classAttr.length) {
97
+ while (start < classAttr.length && classAttr.charCodeAt(start) <= 0x20) start++
98
+ if (start >= classAttr.length) break
99
+ let end = start + 1
100
+ while (end < classAttr.length && classAttr.charCodeAt(end) > 0x20) end++
101
+ if (BLOCKQUOTE_EMBED_CLASS_NAMES.has(classAttr.slice(start, end))) return true
102
+ start = end + 1
103
+ }
104
+ return false
105
+ }
106
+
107
+ const isKnownVideoIframe = (content) => {
108
+ const src = getOpeningAttrValue(content, openingSrcAttrReg)
109
+ if (!src || src.slice(0, 8).toLowerCase() !== 'https://') return false
110
+ const slashIndex = src.indexOf('/', 8)
111
+ const host = (slashIndex === -1 ? src.slice(8) : src.slice(8, slashIndex)).toLowerCase()
112
+ return VIDEO_IFRAME_HOSTS.has(host)
113
+ }
114
+
115
+ const detectHtmlTagCandidate = (tokens, token, startIndex, detector, hints, result) => {
116
+ if (detector.requiresIframeTag && !hints.hasIframeTag) return ''
117
+ const hasTagHint = !!(detector.hintKey && hints[detector.hintKey])
118
+ const allowBlueskyFallback = detector.candidate === 'blockquote' && hints.hasBlueskyHint
119
+ if (!hasTagHint && !allowBlueskyFallback) return ''
120
+ const hasTag = hasTagHint ? token.content.match(getHtmlReg(detector.lookupTag)) : null
121
+ const isBlueskyFallback = detector.candidate === 'blockquote' && !hasTag && hints.hasBlueskyHint
122
+ if (!hasTag && !isBlueskyFallback) return ''
123
+ if (hasTag) {
124
+ appendHtmlBlockNewlineIfNeeded(token, hasTag)
125
+ if (detector.treatAsVideoIframe) {
126
+ result.isVideoIframe = true
127
+ }
128
+ return detector.matchedTag || detector.candidate
129
+ }
130
+ consumeBlockquoteEmbedScript(tokens, token, startIndex)
131
+ return 'blockquote'
132
+ }
133
+
134
+ const resolveHtmlWrapWithoutCaption = (matchedTag, result, htmlWrapWithoutCaption) => {
135
+ if (!htmlWrapWithoutCaption) return false
136
+ if (matchedTag === 'blockquote') {
137
+ return !!(result.isIframeTypeBlockquote && htmlWrapWithoutCaption.iframeTypeBlockquote)
138
+ }
139
+ return !!htmlWrapWithoutCaption[matchedTag]
140
+ }
141
+
142
+ export const detectHtmlFigureCandidate = (tokens, token, startIndex, htmlWrapWithoutCaption) => {
143
+ if (!token || token.type !== 'html_block') return null
144
+ const hints = getHtmlDetectionHints(token.content)
145
+ if (!hasAnyHtmlDetectionHint(hints)) return null
146
+
147
+ const result = {
148
+ isVideoIframe: false,
149
+ isIframeTypeBlockquote: false,
150
+ }
151
+
152
+ let matchedTag = ''
153
+ for (let i = 0; i < HTML_EMBED_CANDIDATES.length; i++) {
154
+ matchedTag = detectHtmlTagCandidate(tokens, token, startIndex, HTML_EMBED_CANDIDATES[i], hints, result)
155
+ if (matchedTag) break
156
+ }
157
+ if (!matchedTag) return null
158
+
159
+ if (matchedTag === 'blockquote') {
160
+ if (!hasKnownBlockquoteEmbedClass(token.content)) return null
161
+ result.isIframeTypeBlockquote = true
162
+ }
163
+
164
+ if (matchedTag === 'iframe' && isKnownVideoIframe(token.content)) {
165
+ result.isVideoIframe = true
166
+ }
167
+
168
+ return {
169
+ type: 'html',
170
+ tagName: matchedTag,
171
+ en: startIndex,
172
+ replaceInsteadOfWrap: false,
173
+ wrapWithoutCaption: resolveHtmlWrapWithoutCaption(matchedTag, result, htmlWrapWithoutCaption),
174
+ canWrap: true,
175
+ isVideoIframe: result.isVideoIframe,
176
+ isIframeTypeBlockquote: result.isIframeTypeBlockquote,
177
+ }
178
+ }
@@ -0,0 +1,27 @@
1
+ export const HTML_EMBED_CANDIDATES = Object.freeze([
2
+ { candidate: 'video', lookupTag: 'video', hintKey: 'hasVideoHint' },
3
+ { candidate: 'audio', lookupTag: 'audio', hintKey: 'hasAudioHint' },
4
+ { candidate: 'iframe', lookupTag: 'iframe', hintKey: 'hasIframeHint' },
5
+ { candidate: 'blockquote', lookupTag: 'blockquote', hintKey: 'hasBlockquoteHint' },
6
+ {
7
+ candidate: 'div',
8
+ lookupTag: 'div',
9
+ hintKey: 'hasDivHint',
10
+ requiresIframeTag: true,
11
+ matchedTag: 'iframe',
12
+ treatAsVideoIframe: true,
13
+ },
14
+ ])
15
+
16
+ export const BLOCKQUOTE_EMBED_CLASS_NAMES = new Set([
17
+ 'twitter-tweet',
18
+ 'instagram-media',
19
+ 'text-post-media',
20
+ 'bluesky-embed',
21
+ 'mastodon-embed',
22
+ ])
23
+
24
+ export const VIDEO_IFRAME_HOSTS = new Set([
25
+ 'www.youtube-nocookie.com',
26
+ 'player.vimeo.com',
27
+ ])
package/index.js CHANGED
@@ -1,62 +1,176 @@
1
1
  import {
2
+ analyzeCaptionStart,
3
+ buildLabelClassLookup,
4
+ buildLabelPrefixMarkerRegFromMarkers,
5
+ getGeneratedLabelDefaults,
6
+ normalizeLabelPrefixMarkers,
2
7
  setCaptionParagraph,
3
8
  getMarkRegStateForLanguages,
9
+ stripLabelPrefixMarker,
4
10
  } from 'p7d-markdown-it-p-captions'
11
+ import { detectHtmlFigureCandidate } from './embeds/detect.js'
5
12
 
6
- const htmlRegCache = new Map()
7
- const blueskyEmbedReg = /^<blockquote class="bluesky-embed"[^]*?>[\s\S]*?$/
8
- const videoIframeReg = /^<[^>]*? src="https:\/\/(?:www.youtube-nocookie.com|player.vimeo.com)\//i
9
- const classNameReg = /^<[^>]*? class="(twitter-tweet|instagram-media|text-post-media|bluesky-embed|mastodon-embed)"/
10
13
  const imageAttrsReg = /^ *\{(.*?)\} *$/
11
14
  const classAttrReg = /^\./
12
15
  const idAttrReg = /^#/
13
16
  const attrParseReg = /^(.*?)="?(.*)"?$/
14
17
  const sampLangReg = /^ *(?:samp|shell|console)(?:(?= )|$)/
15
- const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/
16
- const iframeTagReg = /<iframe(?=[\s>])/i
17
18
  const asciiLabelReg = /^[A-Za-z]/
18
19
  const CHECK_TYPE_TOKEN_MAP = {
19
20
  table_open: 'table',
20
21
  pre_open: 'pre',
21
22
  blockquote_open: 'blockquote',
22
23
  }
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
24
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
30
- const buildClassPrefix = (value) => (value ? value + '-' : '')
31
- const normalizeLanguages = (value) => {
32
- if (!Array.isArray(value)) return ['en', 'ja']
33
- const normalized = []
25
+ const normalizeLanguageCode = (value) => {
26
+ if (value === null || value === undefined) return ''
27
+ const normalized = String(value).trim().toLowerCase()
28
+ if (!normalized) return ''
29
+ const separatorIndex = normalized.search(/[-_]/)
30
+ return separatorIndex === -1 ? normalized : normalized.slice(0, separatorIndex)
31
+ }
32
+ const normalizePreferredLanguages = (value, availableLanguages) => {
33
+ if (!Array.isArray(availableLanguages) || availableLanguages.length === 0) return []
34
+ const source = typeof value === 'string' ? [value] : (Array.isArray(value) ? value : [])
35
+ if (source.length === 0) return []
36
+ const allowed = new Set(availableLanguages)
37
+ const languages = []
34
38
  const seen = new Set()
35
- for (let i = 0; i < value.length; i++) {
36
- const lang = value[i]
37
- if (typeof lang !== 'string') continue
38
- const trimmed = lang.trim()
39
- if (!trimmed || seen.has(trimmed)) continue
40
- seen.add(trimmed)
41
- normalized.push(trimmed)
39
+ for (let i = 0; i < source.length; i++) {
40
+ const lang = normalizeLanguageCode(source[i])
41
+ if (!lang || seen.has(lang) || !allowed.has(lang)) continue
42
+ seen.add(lang)
43
+ languages.push(lang)
42
44
  }
43
- if (normalized.length === 0) return ['en', 'ja']
44
- return normalized
45
+ return languages
45
46
  }
46
- const normalizeLabelPrefixMarkers = (value) => {
47
- if (typeof value === 'string') {
48
- return value ? [value] : []
47
+ const prioritizeLanguage = (languages, preferredLanguage) => {
48
+ if (!preferredLanguage || languages.length === 0) return languages.slice()
49
+ const prioritized = []
50
+ prioritized.push(preferredLanguage)
51
+ for (let i = 0; i < languages.length; i++) {
52
+ if (languages[i] === preferredLanguage) continue
53
+ prioritized.push(languages[i])
54
+ }
55
+ return prioritized
56
+ }
57
+ const isAsciiAlphaCode = (code) => {
58
+ return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)
59
+ }
60
+ const isJapaneseCharCode = (code) => {
61
+ return (
62
+ (code >= 0x3040 && code <= 0x30ff) ||
63
+ (code >= 0x31f0 && code <= 0x31ff) ||
64
+ (code >= 0x4e00 && code <= 0x9fff) ||
65
+ (code >= 0xff66 && code <= 0xff9f)
66
+ )
67
+ }
68
+ const isHyphenFenceLine = (src, lineStart) => {
69
+ if (typeof src !== 'string' || lineStart < 0 || lineStart >= src.length) return 0
70
+ let index = lineStart
71
+ let hyphenCount = 0
72
+ while (index < src.length && src.charCodeAt(index) === 0x2d) {
73
+ hyphenCount++
74
+ index++
75
+ }
76
+ if (hyphenCount < 3) return 0
77
+ while (index < src.length && src.charCodeAt(index) === 0x20) {
78
+ index++
79
+ }
80
+ if (index >= src.length || src.charCodeAt(index) !== 0x0a) return 0
81
+ return hyphenCount
82
+ }
83
+ const skipLeadingFrontmatter = (src) => {
84
+ if (typeof src !== 'string' || isHyphenFenceLine(src, 0) === 0) return src
85
+ let lineStart = src.indexOf('\n')
86
+ if (lineStart === -1) return src
87
+ lineStart++
88
+ while (lineStart < src.length) {
89
+ if (isHyphenFenceLine(src, lineStart) > 0) {
90
+ const nextLineStart = src.indexOf('\n', lineStart)
91
+ if (nextLineStart === -1) return ''
92
+ return src.slice(nextLineStart + 1)
93
+ }
94
+ const nextLineStart = src.indexOf('\n', lineStart)
95
+ if (nextLineStart === -1) break
96
+ lineStart = nextLineStart + 1
49
97
  }
50
- if (Array.isArray(value)) {
51
- const normalized = value.map(entry => String(entry)).filter(Boolean)
52
- return normalized.length > 2 ? normalized.slice(0, 2) : normalized
98
+ return src
99
+ }
100
+ const detectDocumentPrimaryLanguage = (src, availableLanguages) => {
101
+ if (!src || availableLanguages.indexOf('ja') === -1) return ''
102
+ const body = skipLeadingFrontmatter(src)
103
+ const limit = Math.min(body.length, 8192)
104
+ let japaneseCount = 0
105
+ let asciiAlphaCount = 0
106
+ for (let i = 0; i < limit; i++) {
107
+ const code = body.charCodeAt(i)
108
+ if (isJapaneseCharCode(code)) {
109
+ japaneseCount++
110
+ continue
111
+ }
112
+ if (isAsciiAlphaCode(code)) {
113
+ asciiAlphaCount++
114
+ }
53
115
  }
54
- return []
116
+ if (japaneseCount === 0) return ''
117
+ if (asciiAlphaCount === 0) return 'ja'
118
+ return japaneseCount * 2 >= asciiAlphaCount ? 'ja' : ''
119
+ }
120
+ const sourceMayNeedPreferredLanguages = (state) => {
121
+ const src = state && typeof state.src === 'string' ? state.src : ''
122
+ return src.indexOf('![') !== -1
123
+ }
124
+ const resolvePreferredLanguagesForState = (state, opt) => {
125
+ const availableLanguages = (
126
+ opt &&
127
+ opt.markRegState &&
128
+ Array.isArray(opt.markRegState.languages)
129
+ ) ? opt.markRegState.languages : []
130
+ if (availableLanguages.length === 0) return []
131
+
132
+ const explicitPreferred = opt && Array.isArray(opt.preferredLanguages)
133
+ ? opt.preferredLanguages
134
+ : []
135
+ if (explicitPreferred.length > 0) return explicitPreferred
136
+
137
+ const optionLanguages = opt && Array.isArray(opt.normalizedOptionLanguages)
138
+ ? opt.normalizedOptionLanguages
139
+ : []
140
+ const baseLanguages = optionLanguages.length > 0 ? optionLanguages : availableLanguages
141
+ const env = state && state.env ? state.env : null
142
+ const envPreferred = normalizePreferredLanguages(env && env.preferredLanguages, availableLanguages)
143
+ if (envPreferred.length > 0) return envPreferred
144
+
145
+ const envLanguage = normalizeLanguageCode(env && (env.preferredLanguage || env.lang || env.language || env.locale))
146
+ if (envLanguage && baseLanguages.indexOf(envLanguage) !== -1) {
147
+ return prioritizeLanguage(baseLanguages, envLanguage)
148
+ }
149
+
150
+ const detectedLanguage = detectDocumentPrimaryLanguage(state && state.src ? state.src : '', baseLanguages)
151
+ if (detectedLanguage) {
152
+ return prioritizeLanguage(baseLanguages, detectedLanguage)
153
+ }
154
+ return baseLanguages
155
+ }
156
+ const needsPreferredLanguagesResolution = (opt) => {
157
+ if (!opt || !opt.markRegState || !Array.isArray(opt.markRegState.languages)) return false
158
+ if (opt.markRegState.languages.length <= 1) return false
159
+ if (Array.isArray(opt.preferredLanguages) && opt.preferredLanguages.length > 0) return false
160
+ return opt.autoAltCaption === true || opt.autoTitleCaption === true
161
+ }
162
+ const normalizeOptionalClassName = (value) => {
163
+ if (value === null || value === undefined) return ''
164
+ const normalized = String(value).trim()
165
+ return normalized || ''
166
+ }
167
+ const buildClassPrefix = (value) => {
168
+ const normalized = normalizeOptionalClassName(value)
169
+ return normalized ? normalized + '-' : ''
55
170
  }
56
- const buildLabelPrefixMarkerRegFromList = (markers) => {
57
- if (!markers || markers.length === 0) return null
58
- const pattern = markers.map(escapeRegExp).join('|')
59
- return new RegExp('^(?:' + pattern + ')(?:[ \\t ]+)?')
171
+ const normalizeClassOptionWithFallback = (value, fallbackValue) => {
172
+ const normalized = normalizeOptionalClassName(value)
173
+ return normalized || fallbackValue
60
174
  }
61
175
  const resolveLabelPrefixMarkerPair = (markers) => {
62
176
  if (!markers || markers.length === 0) return { prev: [], next: [] }
@@ -65,26 +179,6 @@ const resolveLabelPrefixMarkerPair = (markers) => {
65
179
  }
66
180
  return { prev: [markers[0]], next: [markers[1]] }
67
181
  }
68
- const stripLeadingPrefix = (text, prefix) => {
69
- if (typeof text !== 'string' || !text || !prefix) return text
70
- if (text.startsWith(prefix)) return text.slice(prefix.length)
71
- return text
72
- }
73
- const stripLabelPrefixMarkerFromInline = (inlineToken, markerText) => {
74
- if (!inlineToken || !markerText) return
75
- if (typeof inlineToken.content === 'string') {
76
- inlineToken.content = stripLeadingPrefix(inlineToken.content, markerText)
77
- }
78
- if (inlineToken.children && inlineToken.children.length) {
79
- for (let i = 0; i < inlineToken.children.length; i++) {
80
- const child = inlineToken.children[i]
81
- if (child && child.type === 'text' && typeof child.content === 'string') {
82
- child.content = stripLeadingPrefix(child.content, markerText)
83
- break
84
- }
85
- }
86
- }
87
- }
88
182
  const getLabelPrefixMarkerMatch = (inlineToken, markerReg) => {
89
183
  if (!markerReg || !inlineToken || inlineToken.type !== 'inline') return null
90
184
  const content = typeof inlineToken.content === 'string' ? inlineToken.content : ''
@@ -127,20 +221,6 @@ const normalizeAutoLabelNumberSets = (value) => {
127
221
  return normalized
128
222
  }
129
223
 
130
- const buildLabelClassLookup = (opt) => {
131
- const classPrefix = opt.classPrefix ? opt.classPrefix + '-' : ''
132
- const defaultClasses = [classPrefix + 'label']
133
- const withType = (type) => {
134
- if (opt.removeMarkNameInCaptionClass) return defaultClasses
135
- return [classPrefix + type + '-label', ...defaultClasses]
136
- }
137
- return {
138
- img: withType('img'),
139
- table: withType('table'),
140
- default: defaultClasses,
141
- }
142
- }
143
-
144
224
  const shouldApplyLabelNumbering = (captionType, opt) => {
145
225
  const setting = opt.autoLabelNumberSets
146
226
  if (!setting) return false
@@ -219,63 +299,23 @@ const getImageAltText = (token) => {
219
299
 
220
300
  const getImageTitleText = (token) => getTokenAttr(token, 'title')
221
301
 
222
- const detectCaptionLanguage = (text) => {
223
- const target = (text || '').trim()
224
- if (!target) return 'en'
225
- for (let i = 0; i < target.length; i++) {
226
- const char = target[i]
227
- const code = target.charCodeAt(i)
228
- if (isJapaneseCharCode(code)) return 'ja'
229
- if (isSentenceBoundaryChar(char) || char === '\n') break
230
- }
231
- return 'en'
232
- }
233
-
234
- const isJapaneseCharCode = (code) => {
235
- return (
236
- (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
237
- (code >= 0x31f0 && code <= 0x31ff) || // Katakana extensions
238
- (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
239
- (code >= 0xff66 && code <= 0xff9f) // Half-width Katakana
240
- )
241
- }
242
-
243
- const isSentenceBoundaryChar = (char) => {
244
- return char === '.' || char === '!' || char === '?' || char === '。' || char === '!' || char === '?'
245
- }
246
-
247
- const getAutoFallbackLabel = (text, captionType) => {
248
- const type = captionType === 'table' ? 'table' : 'img'
249
- const lang = detectCaptionLanguage(text)
250
- const defaults = fallbackLabelDefaults[type] || fallbackLabelDefaults.img
251
- if (lang === 'ja') return defaults.ja || defaults.en || ''
252
- return defaults.en || defaults.ja || ''
253
- }
254
-
255
- const getPersistedFallbackLabel = (text, captionType, fallbackState) => {
256
- const type = captionType === 'table' ? 'table' : 'img'
257
- if (!fallbackState) return getAutoFallbackLabel(text, type)
258
- if (fallbackState[type]) return fallbackState[type]
259
- const resolved = getAutoFallbackLabel(text, type)
260
- fallbackState[type] = resolved
261
- return resolved
262
- }
263
-
264
- const buildCaptionWithFallback = (text, fallbackOption, captionType = 'img', fallbackState) => {
302
+ const buildCaptionWithFallback = (text, fallbackOption, mark, markRegState, preferredLanguages) => {
265
303
  const trimmedText = (text || '').trim()
266
304
  if (!fallbackOption) return ''
305
+ if (!trimmedText) return ''
267
306
  let label = ''
307
+ let generatedDefaults = null
268
308
  if (typeof fallbackOption === 'string') {
269
309
  label = fallbackOption.trim()
270
310
  } else if (fallbackOption === true) {
271
- label = getPersistedFallbackLabel(trimmedText, captionType, fallbackState)
311
+ generatedDefaults = getGeneratedLabelDefaults(mark, trimmedText, markRegState, preferredLanguages)
312
+ label = generatedDefaults && generatedDefaults.label ? generatedDefaults.label : ''
272
313
  }
273
- if (!label) return trimmedText
274
- const isAsciiLabel = asciiLabelReg.test(label)
275
- if (!trimmedText) {
276
- return isAsciiLabel ? label + '.' : label
314
+ if (!label) return fallbackOption === true ? '' : trimmedText
315
+ if (generatedDefaults) {
316
+ return label + (generatedDefaults.joint || '') + (generatedDefaults.space || '') + trimmedText
277
317
  }
278
- return label + (isAsciiLabel ? '. ' : ' ') + trimmedText
318
+ return label + (asciiLabelReg.test(label) ? '. ' : ' ') + trimmedText
279
319
  }
280
320
 
281
321
  const createAutoCaptionParagraph = (captionText, TokenConstructor) => {
@@ -401,73 +441,71 @@ const ensureAutoFigureNumbering = (tokens, range, caption, figureNumberState, op
401
441
  updateInlineTokenContent(inlineToken, originalText, newLabelText)
402
442
  }
403
443
 
404
- const matchAutoCaptionText = (text, reg) => {
405
- if (!text || !reg) return ''
444
+ const matchAutoCaptionText = (text, opt, preferredMark = 'img') => {
445
+ if (!text || !opt || !opt.markRegState) return ''
406
446
  const trimmed = text.trim()
407
- if (trimmed && reg.test(trimmed)) return trimmed
447
+ if (!trimmed) return ''
448
+ const analysis = analyzeCaptionStart(trimmed, {
449
+ markRegState: opt.markRegState,
450
+ preferredMark,
451
+ })
452
+ if (analysis) return trimmed
408
453
  return ''
409
454
  }
410
455
 
411
- const getAutoCaptionFromImage = (imageToken, opt, fallbackLabelState) => {
412
- const imgCaptionMarkReg = opt && opt.imgCaptionMarkReg ? opt.imgCaptionMarkReg : null
456
+ const getAutoCaptionFromImage = (imageToken, opt) => {
413
457
  if (!opt.autoCaptionDetection) return ''
414
- if (!imgCaptionMarkReg && !opt.autoAltCaption && !opt.autoTitleCaption) return ''
458
+ if (!opt.autoAltCaption && !opt.autoTitleCaption && !(opt.markRegState && opt.markRegState.markReg && opt.markRegState.markReg.img)) return ''
415
459
 
416
460
  const altText = getImageAltText(imageToken)
417
- let caption = matchAutoCaptionText(altText, imgCaptionMarkReg)
461
+ let caption = matchAutoCaptionText(altText, opt)
418
462
  if (caption) {
419
463
  clearImageAltAttr(imageToken)
420
464
  return caption
421
465
  }
422
466
  if (!caption && opt.autoAltCaption) {
423
467
  const altForFallback = altText || ''
424
- caption = buildCaptionWithFallback(altForFallback, opt.autoAltCaption, 'img', fallbackLabelState)
425
- if (imageToken) {
468
+ const fallbackCaption = buildCaptionWithFallback(altForFallback, opt.autoAltCaption, 'img', opt.markRegState, opt.preferredLanguages)
469
+ if (fallbackCaption && imageToken) {
426
470
  clearImageAltAttr(imageToken)
427
471
  }
472
+ caption = fallbackCaption
428
473
  }
429
474
  if (caption) return caption
430
475
 
431
476
  const titleText = getImageTitleText(imageToken)
432
- caption = matchAutoCaptionText(titleText, imgCaptionMarkReg)
477
+ caption = matchAutoCaptionText(titleText, opt)
433
478
  if (caption) {
434
479
  clearImageTitleAttr(imageToken)
435
480
  return caption
436
481
  }
437
482
  if (!caption && opt.autoTitleCaption) {
438
483
  const titleForFallback = titleText || ''
439
- caption = buildCaptionWithFallback(titleForFallback, opt.autoTitleCaption, 'img', fallbackLabelState)
440
- if (imageToken) {
484
+ const fallbackCaption = buildCaptionWithFallback(titleForFallback, opt.autoTitleCaption, 'img', opt.markRegState, opt.preferredLanguages)
485
+ if (fallbackCaption && imageToken) {
441
486
  clearImageTitleAttr(imageToken)
442
487
  }
488
+ caption = fallbackCaption
443
489
  }
444
490
  return caption
445
491
  }
446
492
 
447
- const getHtmlReg = (tag) => {
448
- if (htmlRegCache.has(tag)) return htmlRegCache.get(tag)
449
- const regexStr = `^<${tag} ?[^>]*?>[\\s\\S]*?<\\/${tag}>(\\n| *?)(<script [^>]*?>(?:<\\/script>)?)? *(\\n|$)`
450
- const reg = new RegExp(regexStr)
451
- htmlRegCache.set(tag, reg)
452
- return reg
453
- }
454
-
455
- const checkPrevCaption = (tokens, n, caption, fNum, sp, opt, captionState) => {
493
+ const checkPrevCaption = (tokens, n, caption, sp, opt, captionState) => {
456
494
  if(n < 3) return caption
457
495
  const captionStartToken = tokens[n-3]
458
496
  const captionInlineToken = tokens[n-2]
459
497
  const captionEndToken = tokens[n-1]
460
498
  if (captionStartToken === undefined || captionEndToken === undefined) return
461
499
  if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
462
- setCaptionParagraph(n-3, captionState, caption, fNum, sp, opt)
500
+ setCaptionParagraph(n-3, captionState, caption, null, sp, opt)
463
501
  const captionName = sp && sp.captionDecision ? sp.captionDecision.mark : ''
464
502
  if(!captionName) {
465
503
  if (opt.labelPrefixMarkerWithoutLabelPrevReg) {
466
- const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelPrevReg)
467
- if (markerMatch) {
468
- stripLabelPrefixMarkerFromInline(captionInlineToken, markerMatch)
469
- caption.isPrev = true
470
- }
504
+ const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelPrevReg)
505
+ if (markerMatch) {
506
+ stripLabelPrefixMarker(captionInlineToken, markerMatch)
507
+ caption.isPrev = true
508
+ }
471
509
  }
472
510
  return
473
511
  }
@@ -476,22 +514,22 @@ const checkPrevCaption = (tokens, n, caption, fNum, sp, opt, captionState) => {
476
514
  return
477
515
  }
478
516
 
479
- const checkNextCaption = (tokens, en, caption, fNum, sp, opt, captionState) => {
517
+ const checkNextCaption = (tokens, en, caption, sp, opt, captionState) => {
480
518
  if (en + 2 > tokens.length) return
481
519
  const captionStartToken = tokens[en+1]
482
520
  const captionInlineToken = tokens[en+2]
483
521
  const captionEndToken = tokens[en+3]
484
522
  if (captionStartToken === undefined || captionEndToken === undefined) return
485
523
  if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
486
- setCaptionParagraph(en+1, captionState, caption, fNum, sp, opt)
524
+ setCaptionParagraph(en+1, captionState, caption, null, sp, opt)
487
525
  const captionName = sp && sp.captionDecision ? sp.captionDecision.mark : ''
488
526
  if(!captionName) {
489
527
  if (opt.labelPrefixMarkerWithoutLabelNextReg) {
490
- const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelNextReg)
491
- if (markerMatch) {
492
- stripLabelPrefixMarkerFromInline(captionInlineToken, markerMatch)
493
- caption.isNext = true
494
- }
528
+ const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelNextReg)
529
+ if (markerMatch) {
530
+ stripLabelPrefixMarker(captionInlineToken, markerMatch)
531
+ caption.isNext = true
532
+ }
495
533
  }
496
534
  return
497
535
  }
@@ -613,18 +651,18 @@ const wrapWithFigure = (tokens, range, checkTokenTagName, caption, replaceInstea
613
651
  breakToken.content = '\n'
614
652
  return breakToken
615
653
  }
616
- if (opt.styleProcess && caption.isNext && sp.attrs.length > 0) {
617
- for (let i = 0; i < sp.attrs.length; i++) {
618
- const attr = sp.attrs[i]
619
- figureStartToken.attrJoin(attr[0], attr[1])
620
- }
621
- }
622
- // For vsce
623
- if (caption.name === 'img' && tokens[n].attrs) {
624
- for (let i = 0; i < tokens[n].attrs.length; i++) {
625
- const attr = tokens[n].attrs[i]
626
- figureStartToken.attrJoin(attr[0], attr[1])
654
+ if (caption.name === 'img') {
655
+ const joinAttrs = (attrs) => {
656
+ if (!attrs || attrs.length === 0) return
657
+ for (let i = 0; i < attrs.length; i++) {
658
+ const attr = attrs[i]
659
+ figureStartToken.attrJoin(attr[0], attr[1])
660
+ }
627
661
  }
662
+ // `styleProcess` should keep working even when markdown-it-attrs is absent.
663
+ if (opt.styleProcess) joinAttrs(sp.attrs)
664
+ // Forward attrs already materialized by markdown-it-attrs on the image paragraph.
665
+ joinAttrs(tokens[n].attrs)
628
666
  }
629
667
  if (replaceInsteadOfWrap) {
630
668
  tokens.splice(en, 1, createBreakToken(), figureEndToken, createBreakToken())
@@ -640,10 +678,10 @@ const wrapWithFigure = (tokens, range, checkTokenTagName, caption, replaceInstea
640
678
  return
641
679
  }
642
680
 
643
- const checkCaption = (tokens, n, en, caption, fNum, sp, opt, captionState) => {
644
- checkPrevCaption(tokens, n, caption, fNum, sp, opt, captionState)
681
+ const checkCaption = (tokens, n, en, caption, sp, opt, captionState) => {
682
+ checkPrevCaption(tokens, n, caption, sp, opt, captionState)
645
683
  if (caption.isPrev) return
646
- checkNextCaption(tokens, en, caption, fNum, sp, opt, captionState)
684
+ checkNextCaption(tokens, en, caption, sp, opt, captionState)
647
685
  return
648
686
  }
649
687
 
@@ -736,129 +774,38 @@ const detectFenceToken = (token, n, caption) => {
736
774
  }
737
775
  }
738
776
 
739
- const detectHtmlBlockToken = (tokens, token, n, caption, sp, opt) => {
740
- if (!token || token.type !== 'html_block') return null
741
- const content = token.content
742
- const hasBlueskyHint = content.indexOf('bluesky-embed') !== -1
743
- const hasVideoHint = content.indexOf('<video') !== -1
744
- const hasAudioHint = content.indexOf('<audio') !== -1
745
- const hasIframeHint = content.indexOf('<iframe') !== -1
746
- const hasBlockquoteHint = content.indexOf('<blockquote') !== -1
747
- const hasDivHint = content.indexOf('<div') !== -1
748
- const hasIframeTag = hasIframeHint || (hasDivHint && iframeTagReg.test(content))
749
- const hasBlueskyEmbed = hasBlueskyHint && blueskyEmbedReg.test(content)
750
- if (!hasBlueskyHint
751
- && !hasVideoHint
752
- && !hasAudioHint
753
- && !hasIframeHint
754
- && !hasBlockquoteHint
755
- && !hasDivHint) {
756
- return null
757
- }
758
- let matchedTag = ''
759
- for (let i = 0; i < HTML_TAG_CANDIDATES.length; i++) {
760
- const candidate = HTML_TAG_CANDIDATES[i]
761
- const treatDivAsIframe = candidate === 'div'
762
- const lookupTag = treatDivAsIframe ? 'div' : candidate
763
- let hasTagHint = false
764
- if (candidate === 'video') {
765
- hasTagHint = hasVideoHint
766
- } else if (candidate === 'audio') {
767
- hasTagHint = hasAudioHint
768
- } else if (candidate === 'iframe') {
769
- hasTagHint = hasIframeHint
770
- } else if (candidate === 'blockquote') {
771
- hasTagHint = hasBlockquoteHint
772
- } else {
773
- hasTagHint = hasDivHint
774
- }
775
- if (candidate === 'div' && !hasIframeTag) continue
776
- if (!hasTagHint && !(candidate === 'blockquote' && hasBlueskyEmbed)) continue
777
- const hasTag = hasTagHint ? content.match(getHtmlReg(lookupTag)) : null
778
- const isBlueskyBlockquote = !hasTag && hasBlueskyEmbed && candidate === 'blockquote'
779
- if (!(hasTag || isBlueskyBlockquote)) continue
780
- if (hasTag) {
781
- if ((hasTag[2] && hasTag[3] !== '\n') || (hasTag[1] !== '\n' && hasTag[2] === undefined)) {
782
- token.content += '\n'
783
- }
784
- matchedTag = treatDivAsIframe ? 'iframe' : candidate
785
- if (treatDivAsIframe) {
786
- sp.isVideoIframe = true
787
- }
788
- } else {
789
- let addedCont = ''
790
- let j = n + 1
791
- while (j < tokens.length) {
792
- const nextToken = tokens[j]
793
- if (nextToken.type === 'inline' && endBlockquoteScriptReg.test(nextToken.content)) {
794
- addedCont += nextToken.content + '\n'
795
- if (tokens[j + 1] && tokens[j + 1].type === 'paragraph_close') {
796
- tokens.splice(j + 1, 1)
797
- }
798
- nextToken.content = ''
799
- if (nextToken.children) {
800
- for (let k = 0; k < nextToken.children.length; k++) {
801
- nextToken.children[k].content = ''
802
- }
803
- }
804
- break
805
- }
806
- if (nextToken.type === 'paragraph_open') {
807
- addedCont += '\n'
808
- tokens.splice(j, 1)
809
- continue
810
- }
811
- j++
812
- }
813
- token.content += addedCont
814
- matchedTag = 'blockquote'
815
- }
816
- break
817
- }
818
- if (!matchedTag) return null
819
- if (matchedTag === 'blockquote') {
820
- if (token.content.indexOf('class="') !== -1 && classNameReg.test(token.content)) {
821
- sp.isIframeTypeBlockquote = true
822
- } else {
823
- return null
824
- }
825
- }
826
- if (matchedTag === 'iframe' && videoIframeReg.test(token.content)) {
827
- sp.isVideoIframe = true
828
- }
829
- caption.name = matchedTag
830
- let wrapWithoutCaption = false
831
- const htmlWrapWithoutCaption = opt.htmlWrapWithoutCaption
832
- if (matchedTag === 'blockquote') {
833
- wrapWithoutCaption = !!(sp.isIframeTypeBlockquote && htmlWrapWithoutCaption && htmlWrapWithoutCaption.iframeTypeBlockquote)
834
- } else if (htmlWrapWithoutCaption) {
835
- wrapWithoutCaption = !!htmlWrapWithoutCaption[matchedTag]
836
- }
837
- return {
838
- type: 'html',
839
- tagName: matchedTag,
840
- en: n,
841
- replaceInsteadOfWrap: false,
842
- wrapWithoutCaption,
843
- canWrap: true,
844
- }
777
+ const hasLeadingImageChild = (token) => {
778
+ return !!(token &&
779
+ token.type === 'inline' &&
780
+ token.children &&
781
+ token.children.length > 0 &&
782
+ token.children[0] &&
783
+ token.children[0].type === 'image')
845
784
  }
846
785
 
847
- const detectImageParagraph = (tokens, token, nextToken, n, caption, sp, opt) => {
848
- if (!token || token.type !== 'paragraph_open') return null
849
- if (!nextToken || nextToken.type !== 'inline' || !nextToken.children || nextToken.children.length === 0) return null
850
- if (nextToken.children[0].type !== 'image') return null
851
-
786
+ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
852
787
  const multipleImagesEnabled = !!opt.multipleImages
853
788
  const styleProcessEnabled = !!opt.styleProcess
854
789
  const allowSingleImageWithoutCaption = !!opt.oneImageWithoutCaption
790
+ const children = nextToken.children
791
+ const imageToken = children[0]
792
+ const childrenLength = children.length
855
793
  let imageNum = 1
856
794
  let isMultipleImagesHorizontal = true
857
795
  let isMultipleImagesVertical = true
858
796
  let isValid = true
859
797
  caption.name = 'img'
860
- const children = nextToken.children
861
- const childrenLength = children.length
798
+ if (childrenLength === 1) {
799
+ return {
800
+ type: 'image',
801
+ tagName: 'img',
802
+ en: n + 2,
803
+ replaceInsteadOfWrap: true,
804
+ wrapWithoutCaption: allowSingleImageWithoutCaption,
805
+ canWrap: true,
806
+ imageToken,
807
+ }
808
+ }
862
809
  if (!multipleImagesEnabled && childrenLength > 2) {
863
810
  return {
864
811
  type: 'image',
@@ -867,7 +814,7 @@ const detectImageParagraph = (tokens, token, nextToken, n, caption, sp, opt) =>
867
814
  replaceInsteadOfWrap: true,
868
815
  wrapWithoutCaption: false,
869
816
  canWrap: false,
870
- imageToken: children[0]
817
+ imageToken,
871
818
  }
872
819
  }
873
820
  for (let childIndex = 1; childIndex < childrenLength; childIndex++) {
@@ -882,6 +829,7 @@ const detectImageParagraph = (tokens, token, nextToken, n, caption, sp, opt) =>
882
829
  for (let i = 0; i < parsedAttrs.length; i++) {
883
830
  sp.attrs.push(parsedAttrs[i])
884
831
  }
832
+ child.content = ''
885
833
  }
886
834
  break
887
835
  }
@@ -936,31 +884,29 @@ const detectImageParagraph = (tokens, token, nextToken, n, caption, sp, opt) =>
936
884
  replaceInsteadOfWrap: true,
937
885
  wrapWithoutCaption: isValid && allowSingleImageWithoutCaption,
938
886
  canWrap: isValid,
939
- imageToken: children[0]
887
+ imageToken,
940
888
  }
941
889
  }
942
890
 
943
891
  const figureWithCaption = (state, opt) => {
944
- let fNum = {
945
- img: 0,
946
- table: 0,
947
- }
948
-
949
892
  const figureNumberState = {
950
893
  img: 0,
951
894
  table: 0,
952
895
  }
953
896
 
954
- const fallbackLabelState = {
955
- img: null,
956
- table: null,
957
- }
958
-
959
897
  const captionState = { tokens: state.tokens, Token: state.Token }
960
- figureWithCaptionCore(state.tokens, opt, fNum, figureNumberState, fallbackLabelState, state.Token, captionState, null, 0)
898
+ const shouldResolvePreferredLanguages = !!(
899
+ opt.shouldResolvePreferredLanguages &&
900
+ sourceMayNeedPreferredLanguages(state)
901
+ )
902
+ const renderOpt = shouldResolvePreferredLanguages ? Object.create(opt) : opt
903
+ if (shouldResolvePreferredLanguages) {
904
+ renderOpt.preferredLanguages = resolvePreferredLanguagesForState(state, opt)
905
+ }
906
+ figureWithCaptionCore(state.tokens, renderOpt, figureNumberState, state.Token, captionState, null, 0)
961
907
  }
962
908
 
963
- const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, captionState, parentType = null, startIndex = 0) => {
909
+ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor, captionState, parentType = null, startIndex = 0) => {
964
910
  const rRange = { start: startIndex, end: startIndex }
965
911
  const rCaption = {
966
912
  name: '', nameSuffix: '', isPrev: false, isNext: false
@@ -978,7 +924,7 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
978
924
  const containerType = getNestedContainerType(token)
979
925
 
980
926
  if (containerType && containerType !== 'blockquote') {
981
- const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, captionState, containerType, n + 1)
927
+ const closeIndex = figureWithCaptionCore(tokens, opt, figureNumberState, TokenConstructor, captionState, containerType, n + 1)
982
928
  n = (typeof closeIndex === 'number' ? closeIndex : n) + 1
983
929
  continue
984
930
  }
@@ -992,16 +938,23 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
992
938
  const tokenType = token.type
993
939
  const blockType = CHECK_TYPE_TOKEN_MAP[tokenType]
994
940
  if (tokenType === 'paragraph_open') {
995
- resetRangeState(rRange, n)
996
- resetCaptionState(rCaption)
997
- resetSpecialState(rSp)
998
941
  const nextToken = tokens[n + 1]
999
- detection = detectImageParagraph(tokens, token, nextToken, n, rCaption, rSp, opt)
942
+ if (hasLeadingImageChild(nextToken)) {
943
+ resetRangeState(rRange, n)
944
+ resetCaptionState(rCaption)
945
+ resetSpecialState(rSp)
946
+ detection = detectImageParagraph(nextToken, n, rCaption, rSp, opt)
947
+ }
1000
948
  } else if (tokenType === 'html_block') {
1001
949
  resetRangeState(rRange, n)
1002
950
  resetCaptionState(rCaption)
1003
951
  resetSpecialState(rSp)
1004
- detection = detectHtmlBlockToken(tokens, token, n, rCaption, rSp, opt)
952
+ detection = detectHtmlFigureCandidate(tokens, token, n, opt.htmlWrapWithoutCaption)
953
+ if (detection) {
954
+ rCaption.name = detection.tagName
955
+ rSp.isVideoIframe = !!detection.isVideoIframe
956
+ rSp.isIframeTypeBlockquote = !!detection.isIframeTypeBlockquote
957
+ }
1005
958
  } else if (tokenType === 'fence') {
1006
959
  resetRangeState(rRange, n)
1007
960
  resetCaptionState(rCaption)
@@ -1016,7 +969,7 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
1016
969
 
1017
970
  if (!detection) {
1018
971
  if (containerType === 'blockquote') {
1019
- const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, captionState, containerType, n + 1)
972
+ const closeIndex = figureWithCaptionCore(tokens, opt, figureNumberState, TokenConstructor, captionState, containerType, n + 1)
1020
973
  n = (typeof closeIndex === 'number' ? closeIndex : n) + 1
1021
974
  } else {
1022
975
  n++
@@ -1027,13 +980,13 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
1027
980
  rRange.end = detection.en
1028
981
 
1029
982
  rSp.figureClassName = resolveFigureClassName(detection.tagName, rSp, opt)
1030
- checkCaption(tokens, rRange.start, rRange.end, rCaption, fNum, rSp, opt, captionState)
983
+ checkCaption(tokens, rRange.start, rRange.end, rCaption, rSp, opt, captionState)
1031
984
  applyCaptionDrivenFigureClass(rCaption, rSp, opt)
1032
985
 
1033
986
  let hasCaption = rCaption.isPrev || rCaption.isNext
1034
987
  let pendingAutoCaption = ''
1035
988
  if (!hasCaption && detection.type === 'image' && opt.autoCaptionDetection) {
1036
- pendingAutoCaption = getAutoCaptionFromImage(detection.imageToken, opt, fallbackLabelState)
989
+ pendingAutoCaption = getAutoCaptionFromImage(detection.imageToken, opt)
1037
990
  if (pendingAutoCaption) {
1038
991
  hasCaption = true
1039
992
  }
@@ -1042,7 +995,7 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
1042
995
  if (detection.canWrap === false) {
1043
996
  let nextIndex = rRange.end + 1
1044
997
  if (containerType === 'blockquote') {
1045
- const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, captionState, containerType, rRange.start + 1)
998
+ const closeIndex = figureWithCaptionCore(tokens, opt, figureNumberState, TokenConstructor, captionState, containerType, rRange.start + 1)
1046
999
  nextIndex = Math.max(nextIndex, (typeof closeIndex === 'number' ? closeIndex : rRange.end) + 1)
1047
1000
  }
1048
1001
  n = nextIndex
@@ -1069,7 +1022,7 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
1069
1022
  rRange.start += insertedLength
1070
1023
  rRange.end += insertedLength
1071
1024
  n += insertedLength
1072
- checkCaption(tokens, rRange.start, rRange.end, rCaption, fNum, rSp, opt, captionState)
1025
+ checkCaption(tokens, rRange.start, rRange.end, rCaption, rSp, opt, captionState)
1073
1026
  applyCaptionDrivenFigureClass(rCaption, rSp, opt)
1074
1027
  }
1075
1028
  ensureAutoFigureNumbering(tokens, rRange, rCaption, figureNumberState, opt)
@@ -1097,7 +1050,7 @@ const figureWithCaptionCore = (tokens, opt, fNum, figureNumberState, fallbackLab
1097
1050
  }
1098
1051
 
1099
1052
  if (containerType === 'blockquote') {
1100
- const closeIndex = figureWithCaptionCore(tokens, opt, fNum, figureNumberState, fallbackLabelState, TokenConstructor, captionState, containerType, rRange.start + 1)
1053
+ const closeIndex = figureWithCaptionCore(tokens, opt, figureNumberState, TokenConstructor, captionState, containerType, rRange.start + 1)
1101
1054
  const fallbackIndex = rCaption.name ? rRange.end : n
1102
1055
  nextIndex = Math.max(nextIndex, (typeof closeIndex === 'number' ? closeIndex : fallbackIndex) + 1)
1103
1056
  }
@@ -1111,12 +1064,13 @@ const mditFigureWithPCaption = (md, option) => {
1111
1064
  let opt = {
1112
1065
  // Caption languages delegated to p-captions.
1113
1066
  languages: ['en', 'ja'],
1067
+ preferredLanguages: null, // optional tie-break order for generated fallback labels; defaults to inferred document order / languages
1114
1068
 
1115
1069
  // --- figure-wrapper behavior ---
1116
1070
  classPrefix: 'f',
1117
1071
  figureClassThatWrapsIframeTypeBlockquote: null,
1118
1072
  figureClassThatWrapsSlides: null,
1119
- styleProcess : true,
1073
+ styleProcess: true,
1120
1074
  oneImageWithoutCaption: false,
1121
1075
  iframeWithoutCaption: false,
1122
1076
  videoWithoutCaption: false,
@@ -1163,11 +1117,13 @@ const mditFigureWithPCaption = (md, option) => {
1163
1117
  if (!hasExplicitLabelClassFollowsFigure && opt.figureToLabelClassMap) {
1164
1118
  opt.labelClassFollowsFigure = true
1165
1119
  }
1166
- opt.languages = normalizeLanguages(opt.languages)
1120
+ opt.classPrefix = normalizeOptionalClassName(opt.classPrefix)
1121
+ opt.allIframeTypeFigureClassName = normalizeOptionalClassName(opt.allIframeTypeFigureClassName)
1167
1122
  opt.markRegState = getMarkRegStateForLanguages(opt.languages)
1168
- opt.imgCaptionMarkReg = opt.markRegState && opt.markRegState.markReg
1169
- ? opt.markRegState.markReg.img
1170
- : null
1123
+ opt.preferredLanguages = normalizePreferredLanguages(opt.preferredLanguages, opt.markRegState.languages)
1124
+ if (opt.preferredLanguages.length === 0) opt.preferredLanguages = null
1125
+ opt.normalizedOptionLanguages = normalizePreferredLanguages(opt.languages, opt.markRegState.languages)
1126
+ opt.shouldResolvePreferredLanguages = needsPreferredLanguagesResolution(opt)
1171
1127
  opt.htmlWrapWithoutCaption = {
1172
1128
  iframe: !!opt.iframeWithoutCaption,
1173
1129
  video: !!opt.videoWithoutCaption,
@@ -1183,21 +1139,33 @@ const mditFigureWithPCaption = (md, option) => {
1183
1139
  const classPrefix = buildClassPrefix(opt.classPrefix)
1184
1140
  opt.figureClassPrefix = classPrefix
1185
1141
  opt.captionClassPrefix = classPrefix
1142
+ const defaultIframeTypeBlockquoteClass = classPrefix + 'img'
1143
+ const defaultSlideFigureClass = classPrefix + 'slide'
1186
1144
  if (!hasExplicitFigureClassThatWrapsIframeTypeBlockquote) {
1187
- opt.figureClassThatWrapsIframeTypeBlockquote = classPrefix + 'img'
1145
+ opt.figureClassThatWrapsIframeTypeBlockquote = defaultIframeTypeBlockquoteClass
1146
+ } else {
1147
+ opt.figureClassThatWrapsIframeTypeBlockquote = normalizeClassOptionWithFallback(
1148
+ opt.figureClassThatWrapsIframeTypeBlockquote,
1149
+ defaultIframeTypeBlockquoteClass,
1150
+ )
1188
1151
  }
1189
1152
  if (!hasExplicitFigureClassThatWrapsSlides) {
1190
- opt.figureClassThatWrapsSlides = classPrefix + 'slide'
1153
+ opt.figureClassThatWrapsSlides = defaultSlideFigureClass
1154
+ } else {
1155
+ opt.figureClassThatWrapsSlides = normalizeClassOptionWithFallback(
1156
+ opt.figureClassThatWrapsSlides,
1157
+ defaultSlideFigureClass,
1158
+ )
1191
1159
  }
1192
1160
  // Precompute label-class permutations so numbering lookup doesn't rebuild arrays per caption.
1193
1161
  opt.labelClassLookup = buildLabelClassLookup(opt)
1194
1162
  const markerList = normalizeLabelPrefixMarkers(opt.labelPrefixMarker)
1195
- opt.labelPrefixMarkerReg = buildLabelPrefixMarkerRegFromList(markerList)
1163
+ opt.labelPrefixMarkerReg = buildLabelPrefixMarkerRegFromMarkers(markerList)
1196
1164
  opt.cleanCaptionRegCache = new Map()
1197
1165
  if (opt.allowLabelPrefixMarkerWithoutLabel === true) {
1198
1166
  const markerPair = resolveLabelPrefixMarkerPair(markerList)
1199
- opt.labelPrefixMarkerWithoutLabelPrevReg = buildLabelPrefixMarkerRegFromList(markerPair.prev)
1200
- opt.labelPrefixMarkerWithoutLabelNextReg = buildLabelPrefixMarkerRegFromList(markerPair.next)
1167
+ opt.labelPrefixMarkerWithoutLabelPrevReg = buildLabelPrefixMarkerRegFromMarkers(markerPair.prev)
1168
+ opt.labelPrefixMarkerWithoutLabelNextReg = buildLabelPrefixMarkerRegFromMarkers(markerPair.next)
1201
1169
  } else {
1202
1170
  opt.labelPrefixMarkerWithoutLabelPrevReg = null
1203
1171
  opt.labelPrefixMarkerWithoutLabelNextReg = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaceroad/markdown-it-figure-with-p-caption",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A markdown-it plugin. For a paragraph with only one image, a table or code block or blockquote, and by writing a caption paragraph immediately before or after, they are converted into the figure element with the figcaption element.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -20,19 +20,20 @@
20
20
  "url": "https://github.com/peaceroad/p7d-markdown-it-figure-with-p-caption/issues"
21
21
  },
22
22
  "devDependencies": {
23
- "@peaceroad/markdown-it-cjk-breaks-mod": "^0.1.6",
24
- "@peaceroad/markdown-it-renderer-fence": "^0.4.1",
25
- "@peaceroad/markdown-it-renderer-image": "^0.9.1",
26
- "@peaceroad/markdown-it-strong-ja": "^0.7.2",
23
+ "@peaceroad/markdown-it-cjk-breaks-mod": "^0.1.10",
24
+ "@peaceroad/markdown-it-renderer-fence": "^0.6.1",
25
+ "@peaceroad/markdown-it-renderer-image": "^0.13.0",
26
+ "@peaceroad/markdown-it-strong-ja": "^0.9.0",
27
27
  "highlight.js": "^11.11.1",
28
28
  "markdown-it": "^14.1.0",
29
29
  "markdown-it-attrs": "^4.3.1"
30
30
  },
31
31
  "dependencies": {
32
- "p7d-markdown-it-p-captions": "^0.21.0"
32
+ "p7d-markdown-it-p-captions": "0.22.0"
33
33
  },
34
34
  "files": [
35
35
  "index.js",
36
+ "embeds/",
36
37
  "README.md",
37
38
  "LICENSE"
38
39
  ]