@peaceroad/markdown-it-figure-with-p-caption 0.17.0 → 0.19.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
@@ -18,7 +18,7 @@ Optionally, you can auto-number image and table caption paragraphs starting from
18
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` 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.
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 is treated as a label stem that must be recognized by `p7d-markdown-it-p-captions`; setup throws if it cannot be parsed as an image caption label. This plugin appends the default joint/space unless the string already ends with punctuation such as `.` / `。` / `:`. 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:
@@ -41,7 +41,7 @@ Optionally, you can auto-number image and table caption paragraphs starting from
41
41
 
42
42
  ### Blockquote
43
43
 
44
- - Captioned blockquotes (e.g., Source. A paragraph. Ewritten immediately before or after `> ...`) become `<figure class="f-blockquote">` while keeping the original blockquote intact.
44
+ - Captioned blockquotes (e.g., `Source. A paragraph.` written immediately before or after `> ...`) become `<figure class="f-blockquote">` while keeping the original blockquote intact.
45
45
 
46
46
  ### Video & Audio
47
47
 
@@ -50,8 +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-nocookie.com`, Vimeo `player.vimeo.com`).
53
+ - Inline HTML `<iframe>` elements become `<figure class="f-video">` when they point to known video hosts (YouTube `www.youtube.com`, `youtube.com`, `www.youtube-nocookie.com`, `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
+ - `videoWithoutCaption` classifies an iframe (including one nested in a `<div>` wrapper) as video only when its `src` host is one of the known YouTube/Vimeo hosts. A generic iframe wrapper still requires `iframeWithoutCaption`.
55
56
  - 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
57
  - `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
58
  - All other iframes fall back to `<figure class="f-iframe">` unless you override the class via `allIframeTypeFigureClassName`.
@@ -60,7 +61,8 @@ Optionally, you can auto-number image and table caption paragraphs starting from
60
61
 
61
62
  - 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
63
  - 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 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
+ - `styleProcess` controls parsing of a trailing `{...}` block from the last text token of an image-only paragraph in this plugin's own scanner. It supports simple `.class`, `#id`, bare attributes, and quoted `key="value with spaces"` / `key='value with spaces'` pairs. It is still a narrow fallback parser, not full `markdown-it-attrs` parity, and attributes already attached to paragraph tokens by `markdown-it-attrs` are still forwarded.
65
+ - Attribute forwarding is not sanitization. If you render untrusted Markdown, keep using an HTML sanitizer or a trusted-host policy appropriate for your application; this plugin only decides which already-parsed or narrowly parsed attributes move onto `<figure>`.
64
66
  - Attributes attached to caption paragraphs stay on the converted `<figcaption>` token after paragraph-to-figcaption conversion.
65
67
 
66
68
  ## Behavior Customization
@@ -75,7 +77,8 @@ Optionally, you can auto-number image and table caption paragraphs starting from
75
77
 
76
78
  ### Wrapping without captions
77
79
 
78
- - `oneImageWithoutCaption`: turn single-image paragraphs into `<figure>` elements even when no caption paragraph/auto caption is present. This is independent of automatic detection.
80
+ - `imageOnlyParagraphWithoutCaption`: turn valid image-only paragraphs into `<figure>` elements even when no caption paragraph/auto caption is present. This includes single-image paragraphs and, when `multipleImages` is enabled, multi-image paragraphs that receive classes such as `f-img-horizontal`, `f-img-vertical`, or `f-img-multiple`. This is independent of automatic detection.
81
+ - `oneImageWithoutCaption`: legacy alias for `imageOnlyParagraphWithoutCaption`. When both are provided, `imageOnlyParagraphWithoutCaption` wins.
79
82
  - `videoWithoutCaption`, `audioWithoutCaption`, `iframeWithoutCaption`, `iframeTypeBlockquoteWithoutCaption`: wrap the respective media blocks without caption.
80
83
 
81
84
  ### Caption text helpers (delegated to `p7d-markdown-it-p-captions`)
@@ -92,16 +95,18 @@ Every option below is forwarded verbatim to `p7d-markdown-it-p-captions`, which
92
95
  - `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.
93
96
  - `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.
94
97
  - `labelPrefixMarker`: allow a leading marker before labels (string or array, e.g., `*Figure. ...`). Arrays are limited to two markers; extras are ignored.
98
+ - `languages`: optional available caption-recognition catalogs delegated to `p7d-markdown-it-p-captions` (default: `['en', 'ja']`). Most users can leave this unset. Set it only when you want to restrict or extend which labels can be recognized (for example English `Figure.` and Japanese `図 `) and which catalogs are available for generated fallback labels. It is separate from the active locale used to choose among those available catalogs.
95
99
  - 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.
100
+ - Generated fallback label tie-break is resolved once per render. Prefer passing the active locale through `env.locale` or `env.preferredLocales`. Compatibility fallbacks are `preferredLanguages`, `env.preferredLanguages`, `env.lang`, and `env.language`. If none of those selects an available catalog, this plugin finally uses a cheap document-script heuristic that skips a leading hyphen-fenced frontmatter block (`---` or longer, spaces allowed before newline), then falls back to the raw `languages` order. This tie-break only affects generated fallback labels; it does not change the caption-recognition dictionaries selected by `languages`. Compatibility note: for generated fallback labels, `env.locale` / `env.preferredLocales` intentionally take precedence over the legacy `preferredLanguages` option so a shared `md` instance can render different documents with different active locales.
97
101
 
98
102
  ### Automatic numbering
99
103
 
100
104
  - `autoLabelNumberSets`: enable numbering per media type. Pass an array such as `['img']`, `['table']`, or `['img', 'table']`.
101
105
  - `autoLabelNumber`: shorthand for turning numbering on for both images and tables without passing the array yourself. Provide `autoLabelNumberSets` explicitly (e.g., `['img']`) when you need finer control—the explicit array always wins.
106
+ - Do not pass p-captions' lower-level `setFigureNumber` option to this plugin. Figure numbering is owned by `autoLabelNumber` / `autoLabelNumberSets`; `setFigureNumber` is rejected during setup to prevent two numbering systems from mutating the same caption.
102
107
  - Counters start at `1` near the top of the document and increment sequentially per media type. Figures and tables keep independent counters even when mixed together.
103
- - The counter only advances when a real caption exists (paragraph, auto-detected alt/title, or fallback text). Figures emitted solely because of `oneImageWithoutCaption` stay unnumbered.
104
- - Manual numbers inside the caption text (e.g., `Figure 5.`) always win. The plugin updates its internal counter so the next automatic number becomes `6`. This applies to captions sourced from paragraphs, auto detection, and fallback captions.
108
+ - The counter only advances when a real caption exists (paragraph, auto-detected alt/title, or fallback text). Figures emitted solely because of `imageOnlyParagraphWithoutCaption` / `oneImageWithoutCaption` stay unnumbered.
109
+ - Manual numbers inside the caption text (e.g., `Figure 5.`) always win. The plugin reads the exact `captionDecision.number` supplied by `p7d-markdown-it-p-captions` and updates its decimal counter so the next automatic number becomes `6`. Explicit compound/alphanumeric numbers (e.g., `Figure A.` or `Figure A.5.`) are preserved without appending an automatic number and do not seed the decimal counter. This applies to captions sourced from paragraphs, auto detection, and fallback captions.
105
110
 
106
111
  ## Basic Usage
107
112
 
@@ -128,12 +133,12 @@ Auto label numbering for images and tables.
128
133
  ```js
129
134
  const figureOption = {
130
135
  // Opinionated defaults
131
- oneImageWithoutCaption: true,
136
+ imageOnlyParagraphWithoutCaption: true,
132
137
  videoWithoutCaption: true,
133
138
  audioWithoutCaption: true,
134
139
  iframeWithoutCaption: true,
135
140
  iframeTypeBlockquoteWithoutCaption: true,
136
- removeUnnumberedLabelExceptMarks: ['blockquote'], // keep Quote. Elabels even when unnumbered
141
+ removeUnnumberedLabelExceptMarks: ['blockquote'], // keep `Quote.` labels even when unnumbered
137
142
  allIframeTypeFigureClassName: 'f-embed', // apply a uniform class to every iframe-style embed
138
143
  autoLabelNumber: true,
139
144
 
@@ -147,7 +152,7 @@ If there is no label number, the label will also be deleted.
147
152
 
148
153
  ```js
149
154
  const figureOption = {
150
- oneImageWithoutCaption: true,
155
+ imageOnlyParagraphWithoutCaption: true,
151
156
  videoWithoutCaption: true,
152
157
  audioWithoutCaption: true,
153
158
  iframeWithoutCaption: true,
@@ -175,7 +180,7 @@ const md = mdit({ html: true }).use(mditFigureWithPCaption, figureOption)
175
180
  [HTML]
176
181
  <p><img src="figure.jpg" alt="A single cat"></p>
177
182
 
178
- <!-- Above: If oneImageWithoutCaption is true, this img element has wrapped into figure element without caption. -->
183
+ <!-- Above: If imageOnlyParagraphWithoutCaption (or its legacy alias oneImageWithoutCaption) is true, this img element has wrapped into figure element without caption. -->
179
184
 
180
185
 
181
186
  [Markdown]
@@ -359,6 +364,8 @@ Slide. A Speaker Deck.
359
364
 
360
365
  ### Auto alt/title detection
361
366
 
367
+ The consumed `alt` or `title` value is cleared only after the image paragraph is confirmed to be wrappable. Tight-list images and image paragraphs with trailing non-image text keep their original accessibility attributes and baseline rendering.
368
+
362
369
  ```
363
370
  [Markdown]
364
371
  ![Figure. A cat.](cat.jpg)
@@ -440,7 +447,7 @@ A paragraph.
440
447
 
441
448
  ### Styles
442
449
 
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`.
450
+ 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; it supports quoted values with spaces, but for broader attrs syntax support, keep using `markdown-it-attrs`.
444
451
 
445
452
  ```
446
453
  [Markdown]
@@ -456,7 +463,7 @@ Figure. Highlighted cat.
456
463
 
457
464
  ### Automatic detection fallbacks
458
465
 
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.
466
+ `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. When these fallbacks are strings, the string must be a label stem recognized as an image caption label by `p7d-markdown-it-p-captions`; invalid strings fail during plugin setup instead of producing a stray paragraph.
460
467
 
461
468
  ```
462
469
  [Markdown]
@@ -498,7 +505,7 @@ $ pwd
498
505
 
499
506
  ### Captionless conversion toggles
500
507
 
501
- If `oneImageWithoutCaption` is enabled, a single image paragraph will be wrapped with `<figure class="f-img">` even without a caption.
508
+ If `imageOnlyParagraphWithoutCaption` (or the legacy alias `oneImageWithoutCaption`) is enabled, a single image paragraph will be wrapped with `<figure class="f-img">` even without a caption. Multi-image image-only paragraphs can also be wrapped, in which case the normal layout classes such as `f-img-horizontal`, `f-img-vertical`, or `f-img-multiple` are used.
502
509
 
503
510
  ```
504
511
  [Markdown]
@@ -510,7 +517,7 @@ If `oneImageWithoutCaption` is enabled, a single image paragraph will be wrapped
510
517
  </figure>
511
518
  ```
512
519
 
513
- If `videoWithoutCaption` is enabled, an `iframe` pointing to a known video host (such as YouTube or video elements) will be wrapped with `<figure class="f-video">`.
520
+ If `videoWithoutCaption` is enabled, `<video>` elements and iframes pointing to known video hosts (such as `www.youtube.com`, `youtube.com`, `www.youtube-nocookie.com`, or Vimeo) will be wrapped with `<figure class="f-video">`.
514
521
 
515
522
  ```
516
523
  [Markdown]
package/embeds/detect.js CHANGED
@@ -6,26 +6,38 @@ import {
6
6
 
7
7
  const htmlRegCache = new Map()
8
8
  const openingClassAttrReg = /^<[^>]*?\bclass=(?:"([^"]*)"|'([^']*)')/i
9
- const openingSrcAttrReg = /^<[^>]*?\bsrc=(?:"([^"]*)"|'([^']*)')/i
10
- const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/
9
+ const iframeSrcAttrReg = /<iframe\b[^>]*?\bsrc=(?:"([^"]*)"|'([^']*)')/i
10
+ const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/i
11
+ const targetHtmlHintReg = /<(?:video|audio|iframe|blockquote|div)\b/i
12
+ const blueskyEmbedHintReg = /bluesky-embed/i
13
+ const videoTagHintReg = /<video\b/i
14
+ const audioTagHintReg = /<audio\b/i
15
+ const iframeTagHintReg = /<iframe\b/i
16
+ const blockquoteTagHintReg = /<blockquote\b/i
17
+ const divTagHintReg = /<div\b/i
11
18
  const iframeTagReg = /<iframe(?=[\s>])/i
12
19
 
13
20
  const getHtmlReg = (tag) => {
14
21
  const cached = htmlRegCache.get(tag)
15
22
  if (cached) return cached
16
23
  const regexStr = `^<${tag} ?[^>]*?>[\\s\\S]*?<\\/${tag}>(\\n| *?)(<script [^>]*?>(?:<\\/script>)?)? *(\\n|$)`
17
- const reg = new RegExp(regexStr)
24
+ const reg = new RegExp(regexStr, 'i')
18
25
  htmlRegCache.set(tag, reg)
19
26
  return reg
20
27
  }
21
28
 
22
29
  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
30
+ const source = typeof content === 'string' ? content : ''
31
+ const hasTargetHtmlHint = targetHtmlHintReg.test(source)
32
+ const hasBlueskyHint = blueskyEmbedHintReg.test(source)
33
+ if (!hasTargetHtmlHint && !hasBlueskyHint) {
34
+ return null
35
+ }
36
+ const hasVideoHint = videoTagHintReg.test(source)
37
+ const hasAudioHint = audioTagHintReg.test(source)
38
+ const hasIframeHint = iframeTagHintReg.test(source)
39
+ const hasBlockquoteHint = blockquoteTagHintReg.test(source)
40
+ const hasDivHint = divTagHintReg.test(source)
29
41
  return {
30
42
  hasBlueskyHint,
31
43
  hasVideoHint,
@@ -33,21 +45,10 @@ const getHtmlDetectionHints = (content) => {
33
45
  hasIframeHint,
34
46
  hasBlockquoteHint,
35
47
  hasDivHint,
36
- hasIframeTag: hasIframeHint || (hasDivHint && iframeTagReg.test(content)),
48
+ hasIframeTag: hasIframeHint || (hasDivHint && iframeTagReg.test(source)),
37
49
  }
38
50
  }
39
51
 
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
52
  const appendHtmlBlockNewlineIfNeeded = (token, hasTag) => {
52
53
  if ((hasTag[2] && hasTag[3] !== '\n') || (hasTag[1] !== '\n' && hasTag[2] === undefined)) {
53
54
  token.content += '\n'
@@ -89,6 +90,13 @@ const getOpeningAttrValue = (content, reg) => {
89
90
  return match[1] || match[2] || ''
90
91
  }
91
92
 
93
+ const getAttrValue = (content, reg) => {
94
+ if (typeof content !== 'string') return ''
95
+ const match = content.match(reg)
96
+ if (!match) return ''
97
+ return match[1] || match[2] || ''
98
+ }
99
+
92
100
  const hasKnownBlockquoteEmbedClass = (content) => {
93
101
  const classAttr = getOpeningAttrValue(content, openingClassAttrReg)
94
102
  if (!classAttr) return false
@@ -105,14 +113,14 @@ const hasKnownBlockquoteEmbedClass = (content) => {
105
113
  }
106
114
 
107
115
  const isKnownVideoIframe = (content) => {
108
- const src = getOpeningAttrValue(content, openingSrcAttrReg)
116
+ const src = getAttrValue(content, iframeSrcAttrReg)
109
117
  if (!src || src.slice(0, 8).toLowerCase() !== 'https://') return false
110
118
  const slashIndex = src.indexOf('/', 8)
111
119
  const host = (slashIndex === -1 ? src.slice(8) : src.slice(8, slashIndex)).toLowerCase()
112
120
  return VIDEO_IFRAME_HOSTS.has(host)
113
121
  }
114
122
 
115
- const detectHtmlTagCandidate = (tokens, token, startIndex, detector, hints, result) => {
123
+ const detectHtmlTagCandidate = (tokens, token, startIndex, detector, hints) => {
116
124
  if (detector.requiresIframeTag && !hints.hasIframeTag) return ''
117
125
  const hasTagHint = !!(detector.hintKey && hints[detector.hintKey])
118
126
  const allowBlueskyFallback = detector.candidate === 'blockquote' && hints.hasBlueskyHint
@@ -122,9 +130,6 @@ const detectHtmlTagCandidate = (tokens, token, startIndex, detector, hints, resu
122
130
  if (!hasTag && !isBlueskyFallback) return ''
123
131
  if (hasTag) {
124
132
  appendHtmlBlockNewlineIfNeeded(token, hasTag)
125
- if (detector.treatAsVideoIframe) {
126
- result.isVideoIframe = true
127
- }
128
133
  return detector.matchedTag || detector.candidate
129
134
  }
130
135
  consumeBlockquoteEmbedScript(tokens, token, startIndex)
@@ -136,13 +141,16 @@ const resolveHtmlWrapWithoutCaption = (matchedTag, result, htmlWrapWithoutCaptio
136
141
  if (matchedTag === 'blockquote') {
137
142
  return !!(result.isIframeTypeBlockquote && htmlWrapWithoutCaption.iframeTypeBlockquote)
138
143
  }
144
+ if (matchedTag === 'iframe' && result.isVideoIframe) {
145
+ return !!(htmlWrapWithoutCaption.video || htmlWrapWithoutCaption.iframe)
146
+ }
139
147
  return !!htmlWrapWithoutCaption[matchedTag]
140
148
  }
141
149
 
142
150
  export const detectHtmlFigureCandidate = (tokens, token, startIndex, htmlWrapWithoutCaption) => {
143
151
  if (!token || token.type !== 'html_block') return null
144
152
  const hints = getHtmlDetectionHints(token.content)
145
- if (!hasAnyHtmlDetectionHint(hints)) return null
153
+ if (!hints) return null
146
154
 
147
155
  const result = {
148
156
  isVideoIframe: false,
@@ -151,7 +159,7 @@ export const detectHtmlFigureCandidate = (tokens, token, startIndex, htmlWrapWit
151
159
 
152
160
  let matchedTag = ''
153
161
  for (let i = 0; i < HTML_EMBED_CANDIDATES.length; i++) {
154
- matchedTag = detectHtmlTagCandidate(tokens, token, startIndex, HTML_EMBED_CANDIDATES[i], hints, result)
162
+ matchedTag = detectHtmlTagCandidate(tokens, token, startIndex, HTML_EMBED_CANDIDATES[i], hints)
155
163
  if (matchedTag) break
156
164
  }
157
165
  if (!matchedTag) return null
@@ -9,7 +9,6 @@ export const HTML_EMBED_CANDIDATES = Object.freeze([
9
9
  hintKey: 'hasDivHint',
10
10
  requiresIframeTag: true,
11
11
  matchedTag: 'iframe',
12
- treatAsVideoIframe: true,
13
12
  },
14
13
  ])
15
14
 
@@ -22,6 +21,9 @@ export const BLOCKQUOTE_EMBED_CLASS_NAMES = new Set([
22
21
  ])
23
22
 
24
23
  export const VIDEO_IFRAME_HOSTS = new Set([
24
+ 'www.youtube.com',
25
+ 'youtube.com',
25
26
  'www.youtube-nocookie.com',
27
+ 'youtube-nocookie.com',
26
28
  'player.vimeo.com',
27
29
  ])
package/index.js CHANGED
@@ -13,14 +13,18 @@ import { detectHtmlFigureCandidate } from './embeds/detect.js'
13
13
  const imageAttrsReg = /^ *\{(.*?)\} *$/
14
14
  const classAttrReg = /^\./
15
15
  const idAttrReg = /^#/
16
- const attrParseReg = /^(.*?)="?(.*)"?$/
17
16
  const sampLangReg = /^ *(?:samp|shell|console)(?:(?= )|$)/
18
17
  const asciiLabelReg = /^[A-Za-z]/
18
+ const attrNameReg = /^[^\s=]+$/
19
+ const labelTrailingJointReg = /[.\u3002\uff0e::]\s*$/
19
20
  const CHECK_TYPE_TOKEN_MAP = {
20
21
  table_open: 'table',
21
22
  pre_open: 'pre',
22
23
  blockquote_open: 'blockquote',
23
24
  }
25
+ const hasOwnOption = (option, name) => !!(
26
+ option && Object.prototype.hasOwnProperty.call(option, name)
27
+ )
24
28
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25
29
  const normalizeLanguageCode = (value) => {
26
30
  if (value === null || value === undefined) return ''
@@ -29,28 +33,47 @@ const normalizeLanguageCode = (value) => {
29
33
  const separatorIndex = normalized.search(/[-_]/)
30
34
  return separatorIndex === -1 ? normalized : normalized.slice(0, separatorIndex)
31
35
  }
36
+ const appendAvailableLanguage = (target, lang, availableLanguages) => {
37
+ if (!lang) return false
38
+ if (availableLanguages.indexOf(lang) === -1) return false
39
+ if (target.indexOf(lang) !== -1) return false
40
+ target.push(lang)
41
+ return true
42
+ }
32
43
  const normalizePreferredLanguages = (value, availableLanguages) => {
33
44
  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
45
  const languages = []
38
- const seen = new Set()
46
+ if (typeof value === 'string') {
47
+ appendAvailableLanguage(languages, normalizeLanguageCode(value), availableLanguages)
48
+ return languages
49
+ }
50
+ const source = Array.isArray(value) ? value : []
51
+ if (source.length === 0) return languages
39
52
  for (let i = 0; i < source.length; i++) {
40
53
  const lang = normalizeLanguageCode(source[i])
41
- if (!lang || seen.has(lang) || !allowed.has(lang)) continue
42
- seen.add(lang)
43
- languages.push(lang)
54
+ appendAvailableLanguage(languages, lang, availableLanguages)
44
55
  }
45
56
  return languages
46
57
  }
47
- const prioritizeLanguage = (languages, preferredLanguage) => {
48
- if (!preferredLanguage || languages.length === 0) return languages.slice()
58
+ const prioritizeLanguages = (languages, preferredLanguages) => {
59
+ if (!Array.isArray(languages) || languages.length === 0) return []
60
+ if (typeof preferredLanguages === 'string') {
61
+ if (!preferredLanguages || languages.indexOf(preferredLanguages) === -1) return languages
62
+ if (languages[0] === preferredLanguages) return languages
63
+ const prioritized = [preferredLanguages]
64
+ for (let i = 0; i < languages.length; i++) {
65
+ appendAvailableLanguage(prioritized, languages[i], languages)
66
+ }
67
+ return prioritized
68
+ }
69
+ if (!Array.isArray(preferredLanguages) || preferredLanguages.length === 0) return languages
49
70
  const prioritized = []
50
- prioritized.push(preferredLanguage)
71
+ for (let i = 0; i < preferredLanguages.length; i++) {
72
+ appendAvailableLanguage(prioritized, preferredLanguages[i], languages)
73
+ }
74
+ if (prioritized.length === 0) return languages
51
75
  for (let i = 0; i < languages.length; i++) {
52
- if (languages[i] === preferredLanguage) continue
53
- prioritized.push(languages[i])
76
+ appendAvailableLanguage(prioritized, languages[i], languages)
54
77
  }
55
78
  return prioritized
56
79
  }
@@ -129,34 +152,49 @@ const resolvePreferredLanguagesForState = (state, opt) => {
129
152
  ) ? opt.markRegState.languages : []
130
153
  if (availableLanguages.length === 0) return []
131
154
 
132
- const explicitPreferred = opt && Array.isArray(opt.preferredLanguages)
133
- ? opt.preferredLanguages
134
- : []
135
- if (explicitPreferred.length > 0) return explicitPreferred
136
-
137
155
  const optionLanguages = opt && Array.isArray(opt.normalizedOptionLanguages)
138
156
  ? opt.normalizedOptionLanguages
139
157
  : []
140
158
  const baseLanguages = optionLanguages.length > 0 ? optionLanguages : availableLanguages
141
159
  const env = state && state.env ? state.env : null
142
- const envPreferred = normalizePreferredLanguages(env && env.preferredLanguages, availableLanguages)
143
- if (envPreferred.length > 0) return envPreferred
144
160
 
145
- const envLanguage = normalizeLanguageCode(env && (env.preferredLanguage || env.lang || env.language || env.locale))
161
+ const envLocale = normalizeLanguageCode(env && env.locale)
162
+ if (envLocale && baseLanguages.indexOf(envLocale) !== -1) {
163
+ return prioritizeLanguages(baseLanguages, envLocale)
164
+ }
165
+
166
+ const envPreferredLocales = normalizePreferredLanguages(env && env.preferredLocales, baseLanguages)
167
+ if (envPreferredLocales.length > 0) {
168
+ return prioritizeLanguages(baseLanguages, envPreferredLocales)
169
+ }
170
+
171
+ const explicitPreferred = opt && Array.isArray(opt.preferredLanguages)
172
+ ? opt.preferredLanguages
173
+ : []
174
+ if (explicitPreferred.length > 0) {
175
+ return prioritizeLanguages(baseLanguages, explicitPreferred)
176
+ }
177
+
178
+ const envPreferred = normalizePreferredLanguages(env && env.preferredLanguages, baseLanguages)
179
+ if (envPreferred.length > 0) {
180
+ return prioritizeLanguages(baseLanguages, envPreferred)
181
+ }
182
+
183
+ const envLanguage = normalizeLanguageCode(env && (env.preferredLanguage || env.lang || env.language))
146
184
  if (envLanguage && baseLanguages.indexOf(envLanguage) !== -1) {
147
- return prioritizeLanguage(baseLanguages, envLanguage)
185
+ return prioritizeLanguages(baseLanguages, envLanguage)
148
186
  }
149
187
 
150
188
  const detectedLanguage = detectDocumentPrimaryLanguage(state && state.src ? state.src : '', baseLanguages)
151
189
  if (detectedLanguage) {
152
- return prioritizeLanguage(baseLanguages, detectedLanguage)
190
+ return prioritizeLanguages(baseLanguages, detectedLanguage)
153
191
  }
154
192
  return baseLanguages
155
193
  }
156
194
  const needsPreferredLanguagesResolution = (opt) => {
157
195
  if (!opt || !opt.markRegState || !Array.isArray(opt.markRegState.languages)) return false
196
+ if (!opt.autoCaptionDetection) return false
158
197
  if (opt.markRegState.languages.length <= 1) return false
159
- if (Array.isArray(opt.preferredLanguages) && opt.preferredLanguages.length > 0) return false
160
198
  return opt.autoAltCaption === true || opt.autoTitleCaption === true
161
199
  }
162
200
  const normalizeOptionalClassName = (value) => {
@@ -190,10 +228,62 @@ const getLabelPrefixMarkerMatch = (inlineToken, markerReg) => {
190
228
  return match[0]
191
229
  }
192
230
 
193
- const parseImageAttrs = (raw) => {
231
+ const splitImageAttrParts = (raw) => {
194
232
  if (raw === null || raw === undefined) return null
233
+ const parts = []
234
+ let current = ''
235
+ let quote = ''
236
+ let escaped = false
237
+ for (let i = 0; i < raw.length; i++) {
238
+ const ch = raw[i]
239
+ if (quote) {
240
+ current += ch
241
+ if (escaped) {
242
+ escaped = false
243
+ continue
244
+ }
245
+ if (ch === '\\') {
246
+ escaped = true
247
+ continue
248
+ }
249
+ if (ch === quote) {
250
+ quote = ''
251
+ }
252
+ continue
253
+ }
254
+ if (ch === '"' || ch === "'") {
255
+ quote = ch
256
+ current += ch
257
+ continue
258
+ }
259
+ if (ch === ' ') {
260
+ if (current) {
261
+ parts.push(current)
262
+ current = ''
263
+ }
264
+ continue
265
+ }
266
+ current += ch
267
+ }
268
+ if (quote) return null
269
+ if (current) parts.push(current)
270
+ return parts
271
+ }
272
+
273
+ const unquoteAttrValue = (value) => {
274
+ if (typeof value !== 'string' || value.length < 2) return value || ''
275
+ const first = value[0]
276
+ const last = value[value.length - 1]
277
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
278
+ return value.slice(1, -1).replace(/\\(["'\\])/g, '$1')
279
+ }
280
+ return value
281
+ }
282
+
283
+ const parseImageAttrs = (raw) => {
284
+ const parts = splitImageAttrParts(raw)
285
+ if (!parts || parts.length === 0) return null
195
286
  const attrs = []
196
- const parts = raw.split(/ +/)
197
287
  for (let i = 0; i < parts.length; i++) {
198
288
  let entry = parts[i]
199
289
  if (!entry) continue
@@ -202,9 +292,15 @@ const parseImageAttrs = (raw) => {
202
292
  } else if (idAttrReg.test(entry)) {
203
293
  entry = entry.replace(idAttrReg, 'id=')
204
294
  }
205
- const imageAttr = entry.match(attrParseReg)
206
- if (!imageAttr || !imageAttr[1]) continue
207
- attrs.push([imageAttr[1], imageAttr[2]])
295
+ const equalIndex = entry.indexOf('=')
296
+ if (equalIndex === -1) {
297
+ if (!attrNameReg.test(entry)) return null
298
+ attrs.push([entry, ''])
299
+ continue
300
+ }
301
+ const name = entry.slice(0, equalIndex)
302
+ if (!name || !attrNameReg.test(name)) return null
303
+ attrs.push([name, unquoteAttrValue(entry.slice(equalIndex + 1))])
208
304
  }
209
305
  return attrs
210
306
  }
@@ -214,7 +310,7 @@ const normalizeAutoLabelNumberSets = (value) => {
214
310
  if (!value) return normalized
215
311
  if (Array.isArray(value)) {
216
312
  for (const entry of value) {
217
- if (normalized.hasOwnProperty(entry)) normalized[entry] = true
313
+ if (entry === 'img' || entry === 'table') normalized[entry] = true
218
314
  }
219
315
  return normalized
220
316
  }
@@ -299,6 +395,14 @@ const getImageAltText = (token) => {
299
395
 
300
396
  const getImageTitleText = (token) => getTokenAttr(token, 'title')
301
397
 
398
+ const getFallbackStringLabelJoint = (label) => {
399
+ if (!label) return ''
400
+ if (labelTrailingJointReg.test(label)) {
401
+ return asciiLabelReg.test(label) ? ' ' : ''
402
+ }
403
+ return asciiLabelReg.test(label) ? '. ' : ' '
404
+ }
405
+
302
406
  const buildCaptionWithFallback = (text, fallbackOption, mark, markRegState, preferredLanguages) => {
303
407
  const trimmedText = (text || '').trim()
304
408
  if (!fallbackOption) return ''
@@ -315,7 +419,19 @@ const buildCaptionWithFallback = (text, fallbackOption, mark, markRegState, pref
315
419
  if (generatedDefaults) {
316
420
  return label + (generatedDefaults.joint || '') + (generatedDefaults.space || '') + trimmedText
317
421
  }
318
- return label + (asciiLabelReg.test(label) ? '. ' : ' ') + trimmedText
422
+ return label + getFallbackStringLabelJoint(label) + trimmedText
423
+ }
424
+
425
+ const validateFallbackCaptionLabelOption = (optionName, fallbackOption, markRegState) => {
426
+ if (typeof fallbackOption !== 'string') return
427
+ const sampleCaption = buildCaptionWithFallback('caption', fallbackOption, 'img', markRegState, null)
428
+ const analysis = analyzeCaptionStart(sampleCaption, {
429
+ markRegState,
430
+ preferredMark: 'img',
431
+ })
432
+ if (!analysis || analysis.mark !== 'img' || analysis.kind !== 'caption') {
433
+ throw new Error(`${optionName} must be a string label recognized as an image caption by p7d-markdown-it-p-captions: ${fallbackOption}`)
434
+ }
319
435
  }
320
436
 
321
437
  const createAutoCaptionParagraph = (captionText, TokenConstructor) => {
@@ -343,12 +459,18 @@ const getCaptionInlineToken = (tokens, range, caption) => {
343
459
  }
344
460
 
345
461
  const hasClassName = (classAttr, className) => {
346
- const index = classAttr.indexOf(className)
347
- if (index === -1) return false
348
- const end = index + className.length
349
- if (index > 0 && classAttr.charCodeAt(index - 1) > 0x20) return false
350
- if (end < classAttr.length && classAttr.charCodeAt(end) > 0x20) return false
351
- return true
462
+ if (!classAttr || !className) return false
463
+ let index = 0
464
+ while (index < classAttr.length) {
465
+ index = classAttr.indexOf(className, index)
466
+ if (index === -1) return false
467
+ const end = index + className.length
468
+ const beforeBoundary = index === 0 || classAttr.charCodeAt(index - 1) <= 0x20
469
+ const afterBoundary = end >= classAttr.length || classAttr.charCodeAt(end) <= 0x20
470
+ if (beforeBoundary && afterBoundary) return true
471
+ index = end
472
+ }
473
+ return false
352
474
  }
353
475
 
354
476
  const hasAnyClassName = (classAttr, classNames) => {
@@ -387,29 +509,18 @@ const updateInlineTokenContent = (inlineToken, originalText, newText) => {
387
509
  inlineToken.content.slice(index + originalText.length)
388
510
  }
389
511
 
390
- const parseTrailingPositiveInteger = (text) => {
512
+ const parsePositiveInteger = (text) => {
391
513
  if (typeof text !== 'string' || text.length === 0) return null
392
- let end = text.length - 1
393
- while (end >= 0 && text.charCodeAt(end) === 0x20) end--
394
- if (end < 0) return null
395
- const lastCode = text.charCodeAt(end)
396
- if (lastCode < 0x30 || lastCode > 0x39) return null
397
- let start = end
398
- while (start >= 0) {
399
- const code = text.charCodeAt(start)
400
- if (code < 0x30 || code > 0x39) break
401
- start--
402
- }
403
514
  let value = 0
404
- let digitBase = 1
405
- for (let i = end; i > start; i--) {
406
- value += (text.charCodeAt(i) - 0x30) * digitBase
407
- digitBase *= 10
515
+ for (let i = 0; i < text.length; i++) {
516
+ const code = text.charCodeAt(i)
517
+ if (code < 0x30 || code > 0x39) return null
518
+ value = value * 10 + code - 0x30
408
519
  }
409
520
  return value
410
521
  }
411
522
 
412
- const ensureAutoFigureNumbering = (tokens, range, caption, figureNumberState, opt) => {
523
+ const ensureAutoFigureNumbering = (tokens, range, caption, figureNumberState, sp, opt) => {
413
524
  const captionType = caption.name === 'img' ? 'img' : (caption.name === 'table' ? 'table' : '')
414
525
  if (!captionType) return
415
526
  if (!shouldApplyLabelNumbering(captionType, opt)) return
@@ -418,19 +529,12 @@ const ensureAutoFigureNumbering = (tokens, range, caption, figureNumberState, op
418
529
  const labelTextToken = getInlineLabelTextToken(inlineToken, captionType, opt)
419
530
  if (!labelTextToken || typeof labelTextToken.content !== 'string') return
420
531
  const originalText = labelTextToken.content
421
- let end = originalText.length - 1
422
- while (end >= 0 && originalText.charCodeAt(end) === 0x20) end--
423
- if (end >= 0) {
424
- const code = originalText.charCodeAt(end)
425
- if (code >= 0x30 && code <= 0x39) {
426
- const explicitValue = parseTrailingPositiveInteger(originalText)
427
- if (explicitValue !== null) {
428
- if (explicitValue > (figureNumberState[captionType] || 0)) {
429
- figureNumberState[captionType] = explicitValue
430
- }
431
- return
432
- }
532
+ if (sp && sp.captionDecision && sp.captionDecision.hasExplicitNumber) {
533
+ const explicitValue = parsePositiveInteger(sp.captionDecision.number)
534
+ if (explicitValue !== null && explicitValue > (figureNumberState[captionType] || 0)) {
535
+ figureNumberState[captionType] = explicitValue
433
536
  }
537
+ return
434
538
  }
435
539
  figureNumberState[captionType] = (figureNumberState[captionType] || 0) + 1
436
540
  const baseLabel = originalText.trim()
@@ -453,89 +557,87 @@ const matchAutoCaptionText = (text, opt, preferredMark = 'img') => {
453
557
  return ''
454
558
  }
455
559
 
456
- const getAutoCaptionFromImage = (imageToken, opt) => {
457
- if (!opt.autoCaptionDetection) return ''
458
- if (!opt.autoAltCaption && !opt.autoTitleCaption && !(opt.markRegState && opt.markRegState.markReg && opt.markRegState.markReg.img)) return ''
560
+ const getAutoCaptionFromImage = (imageToken, opt, preferredLanguages) => {
561
+ if (!opt.autoAltCaption && !opt.autoTitleCaption && !(opt.markRegState && opt.markRegState.markReg && opt.markRegState.markReg.img)) return null
459
562
 
460
563
  const altText = getImageAltText(imageToken)
461
564
  let caption = matchAutoCaptionText(altText, opt)
462
565
  if (caption) {
463
- clearImageAltAttr(imageToken)
464
- return caption
566
+ return { text: caption, source: 'alt' }
465
567
  }
466
- if (!caption && opt.autoAltCaption) {
568
+ if (opt.autoAltCaption) {
467
569
  const altForFallback = altText || ''
468
- const fallbackCaption = buildCaptionWithFallback(altForFallback, opt.autoAltCaption, 'img', opt.markRegState, opt.preferredLanguages)
469
- if (fallbackCaption && imageToken) {
470
- clearImageAltAttr(imageToken)
471
- }
570
+ const fallbackCaption = buildCaptionWithFallback(altForFallback, opt.autoAltCaption, 'img', opt.markRegState, preferredLanguages)
472
571
  caption = fallbackCaption
473
572
  }
474
- if (caption) return caption
573
+ if (caption) return { text: caption, source: 'alt' }
475
574
 
476
575
  const titleText = getImageTitleText(imageToken)
477
576
  caption = matchAutoCaptionText(titleText, opt)
478
577
  if (caption) {
479
- clearImageTitleAttr(imageToken)
480
- return caption
578
+ return { text: caption, source: 'title' }
481
579
  }
482
- if (!caption && opt.autoTitleCaption) {
580
+ if (opt.autoTitleCaption) {
483
581
  const titleForFallback = titleText || ''
484
- const fallbackCaption = buildCaptionWithFallback(titleForFallback, opt.autoTitleCaption, 'img', opt.markRegState, opt.preferredLanguages)
485
- if (fallbackCaption && imageToken) {
486
- clearImageTitleAttr(imageToken)
487
- }
582
+ const fallbackCaption = buildCaptionWithFallback(titleForFallback, opt.autoTitleCaption, 'img', opt.markRegState, preferredLanguages)
488
583
  caption = fallbackCaption
489
584
  }
490
- return caption
585
+ return caption ? { text: caption, source: 'title' } : null
586
+ }
587
+
588
+ const consumeAutoCaptionSource = (imageToken, autoCaption) => {
589
+ if (!imageToken || !autoCaption) return
590
+ if (autoCaption.source === 'alt') {
591
+ clearImageAltAttr(imageToken)
592
+ } else if (autoCaption.source === 'title') {
593
+ clearImageTitleAttr(imageToken)
594
+ }
491
595
  }
492
596
 
493
597
  const checkPrevCaption = (tokens, n, caption, sp, opt, captionState) => {
494
- if(n < 3) return caption
495
- const captionStartToken = tokens[n-3]
496
- const captionInlineToken = tokens[n-2]
497
- const captionEndToken = tokens[n-1]
598
+ if (n < 3) return
599
+ const captionStartToken = tokens[n - 3]
600
+ const captionInlineToken = tokens[n - 2]
601
+ const captionEndToken = tokens[n - 1]
498
602
  if (captionStartToken === undefined || captionEndToken === undefined) return
499
603
  if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
500
- setCaptionParagraph(n-3, captionState, caption, null, sp, opt)
604
+ setCaptionParagraph(n - 3, captionState, caption, null, sp, opt)
501
605
  const captionName = sp && sp.captionDecision ? sp.captionDecision.mark : ''
502
- if(!captionName) {
606
+ if (!captionName) {
503
607
  if (opt.labelPrefixMarkerWithoutLabelPrevReg) {
504
- const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelPrevReg)
505
- if (markerMatch) {
506
- stripLabelPrefixMarker(captionInlineToken, markerMatch)
507
- caption.isPrev = true
508
- }
608
+ const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelPrevReg)
609
+ if (markerMatch) {
610
+ stripLabelPrefixMarker(captionInlineToken, markerMatch)
611
+ caption.isPrev = true
612
+ }
509
613
  }
510
614
  return
511
615
  }
512
616
  caption.name = captionName
513
617
  caption.isPrev = true
514
- return
515
618
  }
516
619
 
517
620
  const checkNextCaption = (tokens, en, caption, sp, opt, captionState) => {
518
- if (en + 2 > tokens.length) return
519
- const captionStartToken = tokens[en+1]
520
- const captionInlineToken = tokens[en+2]
521
- const captionEndToken = tokens[en+3]
621
+ if (en + 3 >= tokens.length) return
622
+ const captionStartToken = tokens[en + 1]
623
+ const captionInlineToken = tokens[en + 2]
624
+ const captionEndToken = tokens[en + 3]
522
625
  if (captionStartToken === undefined || captionEndToken === undefined) return
523
626
  if (captionStartToken.type !== 'paragraph_open' || captionEndToken.type !== 'paragraph_close') return
524
- setCaptionParagraph(en+1, captionState, caption, null, sp, opt)
627
+ setCaptionParagraph(en + 1, captionState, caption, null, sp, opt)
525
628
  const captionName = sp && sp.captionDecision ? sp.captionDecision.mark : ''
526
- if(!captionName) {
629
+ if (!captionName) {
527
630
  if (opt.labelPrefixMarkerWithoutLabelNextReg) {
528
- const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelNextReg)
529
- if (markerMatch) {
530
- stripLabelPrefixMarker(captionInlineToken, markerMatch)
531
- caption.isNext = true
532
- }
631
+ const markerMatch = getLabelPrefixMarkerMatch(captionInlineToken, opt.labelPrefixMarkerWithoutLabelNextReg)
632
+ if (markerMatch) {
633
+ stripLabelPrefixMarker(captionInlineToken, markerMatch)
634
+ caption.isNext = true
635
+ }
533
636
  }
534
637
  return
535
638
  }
536
639
  caption.name = captionName
537
640
  caption.isNext = true
538
- return
539
641
  }
540
642
 
541
643
  const cleanCaptionTokenAttrs = (token, captionName, opt) => {
@@ -598,59 +700,115 @@ const changePrevCaptionPosition = (tokens, n, caption, opt) => {
598
700
  const captionStartToken = tokens[n-3]
599
701
  const captionInlineToken = tokens[n-2]
600
702
  const captionEndToken = tokens[n-1]
703
+ const figureBaseLevel = getTokenLevel(tokens[n])
601
704
 
602
705
  cleanCaptionTokenAttrs(captionStartToken, caption.name, opt)
603
706
  captionStartToken.type = 'figcaption_open'
604
707
  captionStartToken.tag = 'figcaption'
708
+ captionStartToken.block = true
709
+ captionStartToken.level = figureBaseLevel + 1
710
+ captionInlineToken.level = figureBaseLevel + 2
605
711
  captionEndToken.type = 'figcaption_close'
606
712
  captionEndToken.tag = 'figcaption'
713
+ captionEndToken.block = true
714
+ captionEndToken.level = figureBaseLevel + 1
607
715
  tokens.splice(n + 2, 0, captionStartToken, captionInlineToken, captionEndToken)
608
716
  tokens.splice(n-3, 3)
609
- return true
610
717
  }
611
718
 
612
719
  const changeNextCaptionPosition = (tokens, en, caption, opt) => {
613
- const captionStartToken = tokens[en+2] // +1: text node for figure.
614
- const captionInlineToken = tokens[en+3]
615
- const captionEndToken = tokens[en+4]
720
+ const captionStartToken = tokens[en+1]
721
+ const captionInlineToken = tokens[en+2]
722
+ const captionEndToken = tokens[en+3]
723
+ const figureBaseLevel = getTokenLevel(tokens[en])
616
724
  cleanCaptionTokenAttrs(captionStartToken, caption.name, opt)
617
725
  captionStartToken.type = 'figcaption_open'
618
726
  captionStartToken.tag = 'figcaption'
727
+ captionStartToken.block = true
728
+ captionStartToken.level = figureBaseLevel + 1
729
+ captionInlineToken.level = figureBaseLevel + 2
619
730
  captionEndToken.type = 'figcaption_close'
620
731
  captionEndToken.tag = 'figcaption'
732
+ captionEndToken.block = true
733
+ captionEndToken.level = figureBaseLevel + 1
621
734
  tokens.splice(en, 0, captionStartToken, captionInlineToken, captionEndToken)
622
- tokens.splice(en+5, 3)
623
- return true
735
+ tokens.splice(en+4, 3)
736
+ }
737
+
738
+ const getTokenMap = (token) => {
739
+ return token && Array.isArray(token.map) && token.map.length === 2 ? token.map : null
740
+ }
741
+
742
+ const findNearestMapInRange = (tokens, start, end, step) => {
743
+ let i = start
744
+ while (step > 0 ? i <= end : i >= end) {
745
+ const map = getTokenMap(tokens[i])
746
+ if (map) return map
747
+ i += step
748
+ }
749
+ return null
750
+ }
751
+
752
+ const getRangeMap = (tokens, start, end) => {
753
+ const startMap = getTokenMap(tokens[start]) || findNearestMapInRange(tokens, start, end, 1)
754
+ if (!startMap) return null
755
+ const endMap = getTokenMap(tokens[end]) || findNearestMapInRange(tokens, end, start, -1) || startMap
756
+ const startLine = startMap[0]
757
+ const endLine = Math.max(startMap[1], endMap[1])
758
+ if (typeof startLine !== 'number' || typeof endLine !== 'number' || endLine < startLine) {
759
+ return [startMap[0], startMap[1]]
760
+ }
761
+ return [startLine, endLine]
762
+ }
763
+
764
+ const getTokenLevel = (token, fallback = 0) => {
765
+ return token && typeof token.level === 'number' ? token.level : fallback
766
+ }
767
+
768
+ const adjustTokenLevels = (tokens, start, end, delta) => {
769
+ if (!delta) return
770
+ for (let i = start; i <= end; i++) {
771
+ const token = tokens[i]
772
+ if (token && typeof token.level === 'number') {
773
+ token.level += delta
774
+ }
775
+ }
624
776
  }
625
777
 
626
778
  const wrapWithFigure = (tokens, range, checkTokenTagName, caption, replaceInsteadOfWrap, sp, opt, TokenConstructor) => {
627
779
  let n = range.start
628
780
  let en = range.end
781
+ const baseLevel = getTokenLevel(tokens[n])
782
+ const childLevel = baseLevel + 1
629
783
  const figureStartToken = new TokenConstructor('figure_open', 'figure', 1)
630
784
  const figureClassName = sp.figureClassName || resolveFigureClassName(checkTokenTagName, sp, opt)
631
785
  figureStartToken.attrSet('class', figureClassName)
786
+ figureStartToken.block = true
787
+ figureStartToken.level = baseLevel
632
788
 
633
789
  if (opt.roleDocExample && (checkTokenTagName === 'pre-code' || checkTokenTagName === 'pre-samp')) {
634
790
  figureStartToken.attrSet('role', 'doc-example')
635
791
  }
636
792
  const figureEndToken = new TokenConstructor('figure_close', 'figure', -1)
637
- const rangeStartMap = tokens[n] && Array.isArray(tokens[n].map) && tokens[n].map.length === 2
638
- ? tokens[n].map
639
- : null
640
- const rangeEndMap = tokens[en] && Array.isArray(tokens[en].map) && tokens[en].map.length === 2
641
- ? tokens[en].map
642
- : rangeStartMap
643
- if (rangeStartMap) {
644
- figureStartToken.map = [rangeStartMap[0], rangeStartMap[1]]
645
- }
646
- if (rangeEndMap) {
647
- figureEndToken.map = [rangeEndMap[0], rangeEndMap[1]]
793
+ figureEndToken.block = true
794
+ figureEndToken.level = baseLevel
795
+ const rangeMap = getRangeMap(tokens, n, en)
796
+ if (rangeMap) {
797
+ figureStartToken.map = [rangeMap[0], rangeMap[1]]
798
+ figureEndToken.map = [rangeMap[0], rangeMap[1]]
648
799
  }
649
800
  const createBreakToken = () => {
650
801
  const breakToken = new TokenConstructor('text', '', 0)
651
802
  breakToken.content = '\n'
803
+ breakToken.level = childLevel
652
804
  return breakToken
653
805
  }
806
+ const createEmptyTextToken = () => {
807
+ const emptyToken = new TokenConstructor('text', '', 0)
808
+ emptyToken.content = ''
809
+ emptyToken.level = childLevel
810
+ return emptyToken
811
+ }
654
812
  if (caption.name === 'img') {
655
813
  const joinAttrs = (attrs) => {
656
814
  if (!attrs || attrs.length === 0) return
@@ -665,24 +823,23 @@ const wrapWithFigure = (tokens, range, checkTokenTagName, caption, replaceInstea
665
823
  joinAttrs(tokens[n].attrs)
666
824
  }
667
825
  if (replaceInsteadOfWrap) {
668
- tokens.splice(en, 1, createBreakToken(), figureEndToken, createBreakToken())
669
- tokens.splice(n, 1, figureStartToken, createBreakToken())
826
+ tokens.splice(en, 1, createBreakToken(), figureEndToken)
827
+ tokens.splice(n, 1, figureStartToken, createEmptyTextToken())
670
828
  en = en + 2
671
829
  } else {
672
- tokens.splice(en+1, 0, figureEndToken, createBreakToken())
673
- tokens.splice(n, 0, figureStartToken, createBreakToken())
830
+ adjustTokenLevels(tokens, n, en, 1)
831
+ tokens.splice(en+1, 0, figureEndToken)
832
+ tokens.splice(n, 0, figureStartToken, createEmptyTextToken())
674
833
  en = en + 3
675
834
  }
676
835
  range.start = n
677
836
  range.end = en
678
- return
679
837
  }
680
838
 
681
839
  const checkCaption = (tokens, n, en, caption, sp, opt, captionState) => {
682
840
  checkPrevCaption(tokens, n, caption, sp, opt, captionState)
683
841
  if (caption.isPrev) return
684
842
  checkNextCaption(tokens, en, caption, sp, opt, captionState)
685
- return
686
843
  }
687
844
 
688
845
  const getNestedContainerType = (token) => {
@@ -786,7 +943,7 @@ const hasLeadingImageChild = (token) => {
786
943
  const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
787
944
  const multipleImagesEnabled = !!opt.multipleImages
788
945
  const styleProcessEnabled = !!opt.styleProcess
789
- const allowSingleImageWithoutCaption = !!opt.oneImageWithoutCaption
946
+ const allowImageParagraphWithoutCaption = !!opt.imageOnlyParagraphWithoutCaption
790
947
  const children = nextToken.children
791
948
  const imageToken = children[0]
792
949
  const childrenLength = children.length
@@ -794,6 +951,7 @@ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
794
951
  let isMultipleImagesHorizontal = true
795
952
  let isMultipleImagesVertical = true
796
953
  let isValid = true
954
+ let trailingAttrToken = null
797
955
  caption.name = 'img'
798
956
  if (childrenLength === 1) {
799
957
  return {
@@ -801,7 +959,7 @@ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
801
959
  tagName: 'img',
802
960
  en: n + 2,
803
961
  replaceInsteadOfWrap: true,
804
- wrapWithoutCaption: allowSingleImageWithoutCaption,
962
+ wrapWithoutCaption: allowImageParagraphWithoutCaption,
805
963
  canWrap: true,
806
964
  imageToken,
807
965
  }
@@ -825,13 +983,13 @@ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
825
983
  const imageAttrs = rawContent.match(imageAttrsReg)
826
984
  if (imageAttrs) {
827
985
  const parsedAttrs = parseImageAttrs(imageAttrs[1])
828
- if (parsedAttrs && parsedAttrs.length) {
986
+ if (parsedAttrs) {
829
987
  for (let i = 0; i < parsedAttrs.length; i++) {
830
988
  sp.attrs.push(parsedAttrs[i])
831
989
  }
832
- child.content = ''
990
+ trailingAttrToken = child
991
+ break
833
992
  }
834
- break
835
993
  }
836
994
  }
837
995
  if (typeof rawContent === 'string' && rawContent.trim()) {
@@ -867,12 +1025,6 @@ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
867
1025
  } else {
868
1026
  caption.nameSuffix = '-multiple'
869
1027
  }
870
- for (let i = 0; i < childrenLength; i++) {
871
- const child = children[i]
872
- if (isOnlySpacesText(child)) {
873
- child.content = ''
874
- }
875
- }
876
1028
  }
877
1029
  const en = n + 2
878
1030
  let tagName = 'img'
@@ -882,9 +1034,25 @@ const detectImageParagraph = (nextToken, n, caption, sp, opt) => {
882
1034
  tagName,
883
1035
  en,
884
1036
  replaceInsteadOfWrap: true,
885
- wrapWithoutCaption: isValid && allowSingleImageWithoutCaption,
1037
+ wrapWithoutCaption: isValid && allowImageParagraphWithoutCaption,
886
1038
  canWrap: isValid,
887
1039
  imageToken,
1040
+ inlineToken: nextToken,
1041
+ trailingAttrToken,
1042
+ }
1043
+ }
1044
+
1045
+ const applyImageParagraphTransform = (detection) => {
1046
+ if (!detection || detection.type !== 'image') return
1047
+ if (detection.trailingAttrToken) {
1048
+ detection.trailingAttrToken.content = ''
1049
+ }
1050
+ if (!detection.inlineToken || detection.tagName === 'img') return
1051
+ const children = detection.inlineToken.children
1052
+ for (let i = 0; i < children.length; i++) {
1053
+ if (isOnlySpacesText(children[i])) {
1054
+ children[i].content = ''
1055
+ }
888
1056
  }
889
1057
  }
890
1058
 
@@ -894,16 +1062,19 @@ const figureWithCaption = (state, opt) => {
894
1062
  table: 0,
895
1063
  }
896
1064
 
897
- const captionState = { tokens: state.tokens, Token: state.Token }
1065
+ const captionState = {
1066
+ tokens: state.tokens,
1067
+ Token: state.Token,
1068
+ preferredLanguages: opt.preferredLanguages,
1069
+ }
898
1070
  const shouldResolvePreferredLanguages = !!(
899
1071
  opt.shouldResolvePreferredLanguages &&
900
1072
  sourceMayNeedPreferredLanguages(state)
901
1073
  )
902
- const renderOpt = shouldResolvePreferredLanguages ? Object.create(opt) : opt
903
1074
  if (shouldResolvePreferredLanguages) {
904
- renderOpt.preferredLanguages = resolvePreferredLanguagesForState(state, opt)
1075
+ captionState.preferredLanguages = resolvePreferredLanguagesForState(state, opt)
905
1076
  }
906
- figureWithCaptionCore(state.tokens, renderOpt, figureNumberState, state.Token, captionState, null, 0)
1077
+ figureWithCaptionCore(state.tokens, opt, figureNumberState, state.Token, captionState, null, 0)
907
1078
  }
908
1079
 
909
1080
  const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor, captionState, parentType = null, startIndex = 0) => {
@@ -984,9 +1155,9 @@ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor,
984
1155
  applyCaptionDrivenFigureClass(rCaption, rSp, opt)
985
1156
 
986
1157
  let hasCaption = rCaption.isPrev || rCaption.isNext
987
- let pendingAutoCaption = ''
1158
+ let pendingAutoCaption = null
988
1159
  if (!hasCaption && detection.type === 'image' && opt.autoCaptionDetection) {
989
- pendingAutoCaption = getAutoCaptionFromImage(detection.imageToken, opt)
1160
+ pendingAutoCaption = getAutoCaptionFromImage(detection.imageToken, opt, captionState.preferredLanguages)
990
1161
  if (pendingAutoCaption) {
991
1162
  hasCaption = true
992
1163
  }
@@ -1002,21 +1173,18 @@ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor,
1002
1173
  continue
1003
1174
  }
1004
1175
 
1005
- let shouldWrap = false
1006
- if (detection.type === 'html') {
1007
- shouldWrap = detection.canWrap !== false && (hasCaption || detection.wrapWithoutCaption)
1008
- } else if (detection.type === 'image') {
1009
- shouldWrap = detection.canWrap !== false && (hasCaption || detection.wrapWithoutCaption)
1010
- if (token.hidden === true) {
1011
- shouldWrap = false
1012
- }
1013
- } else {
1014
- shouldWrap = detection.canWrap !== false && hasCaption
1176
+ let shouldWrap = hasCaption
1177
+ if (detection.type === 'html' || detection.type === 'image') {
1178
+ shouldWrap = shouldWrap || detection.wrapWithoutCaption
1179
+ }
1180
+ if (detection.type === 'image' && token.hidden === true) {
1181
+ shouldWrap = false
1015
1182
  }
1016
1183
 
1017
1184
  if (shouldWrap) {
1018
1185
  if (pendingAutoCaption) {
1019
- const captionTokens = createAutoCaptionParagraph(pendingAutoCaption, TokenConstructor)
1186
+ consumeAutoCaptionSource(detection.imageToken, pendingAutoCaption)
1187
+ const captionTokens = createAutoCaptionParagraph(pendingAutoCaption.text, TokenConstructor)
1020
1188
  tokens.splice(rRange.start, 0, ...captionTokens)
1021
1189
  const insertedLength = captionTokens.length
1022
1190
  rRange.start += insertedLength
@@ -1025,7 +1193,8 @@ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor,
1025
1193
  checkCaption(tokens, rRange.start, rRange.end, rCaption, rSp, opt, captionState)
1026
1194
  applyCaptionDrivenFigureClass(rCaption, rSp, opt)
1027
1195
  }
1028
- ensureAutoFigureNumbering(tokens, rRange, rCaption, figureNumberState, opt)
1196
+ applyImageParagraphTransform(detection)
1197
+ ensureAutoFigureNumbering(tokens, rRange, rCaption, figureNumberState, rSp, opt)
1029
1198
  wrapWithFigure(tokens, rRange, detection.tagName, rCaption, detection.replaceInsteadOfWrap, rSp, opt, TokenConstructor)
1030
1199
  }
1031
1200
 
@@ -1044,8 +1213,6 @@ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor,
1044
1213
  } else if (rCaption.isNext) {
1045
1214
  changeNextCaptionPosition(tokens, en, rCaption, opt)
1046
1215
  nextIndex = en + 4
1047
- } else {
1048
- nextIndex = en + 1
1049
1216
  }
1050
1217
  }
1051
1218
 
@@ -1061,17 +1228,18 @@ const figureWithCaptionCore = (tokens, opt, figureNumberState, TokenConstructor,
1061
1228
  }
1062
1229
 
1063
1230
  const mditFigureWithPCaption = (md, option) => {
1064
- let opt = {
1231
+ const opt = {
1065
1232
  // Caption languages delegated to p-captions.
1066
1233
  languages: ['en', 'ja'],
1067
- preferredLanguages: null, // optional tie-break order for generated fallback labels; defaults to inferred document order / languages
1234
+ preferredLanguages: null, // compatibility tie-break for generated fallback labels; prefer env.locale / env.preferredLocales per render
1068
1235
 
1069
1236
  // --- figure-wrapper behavior ---
1070
1237
  classPrefix: 'f',
1071
1238
  figureClassThatWrapsIframeTypeBlockquote: null,
1072
1239
  figureClassThatWrapsSlides: null,
1073
1240
  styleProcess: true,
1074
- oneImageWithoutCaption: false,
1241
+ imageOnlyParagraphWithoutCaption: false,
1242
+ oneImageWithoutCaption: false, // legacy alias for imageOnlyParagraphWithoutCaption
1075
1243
  iframeWithoutCaption: false,
1076
1244
  videoWithoutCaption: false,
1077
1245
  audioWithoutCaption: false,
@@ -1109,11 +1277,18 @@ const mditFigureWithPCaption = (md, option) => {
1109
1277
  labelClassFollowsFigure: false,
1110
1278
  figureToLabelClassMap: null,
1111
1279
  }
1112
- const hasExplicitAutoLabelNumberSets = option && Object.prototype.hasOwnProperty.call(option, 'autoLabelNumberSets')
1113
- const hasExplicitFigureClassThatWrapsIframeTypeBlockquote = option && Object.prototype.hasOwnProperty.call(option, 'figureClassThatWrapsIframeTypeBlockquote')
1114
- const hasExplicitFigureClassThatWrapsSlides = option && Object.prototype.hasOwnProperty.call(option, 'figureClassThatWrapsSlides')
1115
- const hasExplicitLabelClassFollowsFigure = option && Object.prototype.hasOwnProperty.call(option, 'labelClassFollowsFigure')
1280
+ const hasExplicitAutoLabelNumberSets = hasOwnOption(option, 'autoLabelNumberSets')
1281
+ const hasExplicitImageOnlyParagraphWithoutCaption = hasOwnOption(option, 'imageOnlyParagraphWithoutCaption')
1282
+ const hasExplicitFigureClassThatWrapsIframeTypeBlockquote = hasOwnOption(option, 'figureClassThatWrapsIframeTypeBlockquote')
1283
+ const hasExplicitFigureClassThatWrapsSlides = hasOwnOption(option, 'figureClassThatWrapsSlides')
1284
+ const hasExplicitLabelClassFollowsFigure = hasOwnOption(option, 'labelClassFollowsFigure')
1285
+ if (hasOwnOption(option, 'setFigureNumber')) {
1286
+ throw new Error('setFigureNumber is not supported by figure-with-p-caption; use autoLabelNumber or autoLabelNumberSets')
1287
+ }
1116
1288
  if (option) Object.assign(opt, option)
1289
+ opt.imageOnlyParagraphWithoutCaption = hasExplicitImageOnlyParagraphWithoutCaption
1290
+ ? !!opt.imageOnlyParagraphWithoutCaption
1291
+ : !!opt.oneImageWithoutCaption
1117
1292
  if (!hasExplicitLabelClassFollowsFigure && opt.figureToLabelClassMap) {
1118
1293
  opt.labelClassFollowsFigure = true
1119
1294
  }
@@ -1124,6 +1299,8 @@ const mditFigureWithPCaption = (md, option) => {
1124
1299
  if (opt.preferredLanguages.length === 0) opt.preferredLanguages = null
1125
1300
  opt.normalizedOptionLanguages = normalizePreferredLanguages(opt.languages, opt.markRegState.languages)
1126
1301
  opt.shouldResolvePreferredLanguages = needsPreferredLanguagesResolution(opt)
1302
+ validateFallbackCaptionLabelOption('autoAltCaption', opt.autoAltCaption, opt.markRegState)
1303
+ validateFallbackCaptionLabelOption('autoTitleCaption', opt.autoTitleCaption, opt.markRegState)
1127
1304
  opt.htmlWrapWithoutCaption = {
1128
1305
  iframe: !!opt.iframeWithoutCaption,
1129
1306
  video: !!opt.videoWithoutCaption,
@@ -1171,7 +1348,7 @@ const mditFigureWithPCaption = (md, option) => {
1171
1348
  opt.labelPrefixMarkerWithoutLabelNextReg = null
1172
1349
  }
1173
1350
 
1174
- //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.
1351
+ // Run after markdown-it-attrs has attached paragraph attrs, but before text replacements.
1175
1352
  md.core.ruler.before('replacements', 'figure_with_caption', (state) => {
1176
1353
  figureWithCaption(state, opt)
1177
1354
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaceroad/markdown-it-figure-with-p-caption",
3
- "version": "0.17.0",
3
+ "version": "0.19.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,16 +20,16 @@
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.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",
23
+ "@peaceroad/markdown-it-cjk-breaks-mod": "^0.1.14",
24
+ "@peaceroad/markdown-it-renderer-fence": "^0.8.0",
25
+ "@peaceroad/markdown-it-renderer-image": "^0.16.0",
26
+ "@peaceroad/markdown-it-strong-ja": "^0.9.4",
27
27
  "highlight.js": "^11.11.1",
28
- "markdown-it": "^14.1.0",
29
- "markdown-it-attrs": "^4.3.1"
28
+ "markdown-it": "^14.3.0",
29
+ "markdown-it-attrs": "^5.0.0"
30
30
  },
31
31
  "dependencies": {
32
- "p7d-markdown-it-p-captions": "0.22.0"
32
+ "p7d-markdown-it-p-captions": "0.24.0"
33
33
  },
34
34
  "files": [
35
35
  "index.js",