@peaceroad/markdown-it-figure-with-p-caption 0.19.0 → 0.20.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 +259 -475
- package/caption-numbering/counter-series.js +62 -0
- package/caption-numbering/number-codec.js +101 -0
- package/caption-numbering/options.js +101 -0
- package/caption-numbering/scope.js +466 -0
- package/caption-numbering.js +12 -0
- package/docs/examples.md +571 -0
- package/docs/numbering.md +215 -0
- package/docs/reference.md +148 -0
- package/embeds/detect.js +85 -63
- package/index.js +387 -305
- package/package.json +15 -7
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Automatic numbering and integration API
|
|
2
|
+
|
|
3
|
+
This document describes automatic numbering, chapter and appendix scopes,
|
|
4
|
+
frontmatter and render overrides, and the public numbering integration API.
|
|
5
|
+
For general behavior and options, see the [behavior reference](reference.md).
|
|
6
|
+
For installation and the minimum setup, start with the
|
|
7
|
+
[project README](../README.md).
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [Automatic numbering](#automatic-numbering)
|
|
12
|
+
- [Chapter and appendix scopes](#chapter-and-appendix-scopes)
|
|
13
|
+
- [Caption-numbering integration API](#caption-numbering-integration-api)
|
|
14
|
+
|
|
15
|
+
## Automatic numbering
|
|
16
|
+
|
|
17
|
+
Automatic numbering is disabled by default.
|
|
18
|
+
|
|
19
|
+
- `autoLabelNumberSets`: strict caption-mark allowlist. Supported entries are `img`, `table`, `code` / `pre-code`, `samp` / `pre-samp`, and `video`; aliases are deduplicated to their canonical marks.
|
|
20
|
+
- `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.
|
|
21
|
+
- Recognized chapter/appendix scopes are applied automatically whenever at least one mark is numbered. By default, parsed `env.frontmatter.title` values and top-level H1 headings are sources, repeated semantic scopes continue their prior counters, and `.` joins the scope prefix to the local number.
|
|
22
|
+
- `autoLabelNumberPolicy`: customize the automatic scope, separator, and repeated-scope behavior without enabling any media type by itself. Use `scope: 'document'` for document-wide decimal counters regardless of recognized headings/frontmatter.
|
|
23
|
+
- Do not pass p-captions' lower-level `setFigureNumber` option to this plugin. Figure numbering is owned by `autoLabelNumber` / `autoLabelNumberSets`; `setFigureNumber` is rejected during the initial setup to prevent two numbering systems from mutating the same caption.
|
|
24
|
+
- An explicit `[]` disables numbering even when `autoLabelNumber` is true. Explicit `undefined`, `null`, non-arrays, unsupported marks, and invalid entries throw during initial setup instead of being silently ignored.
|
|
25
|
+
- Numbering enablement follows `captionDecision.mark`, not the wrapper class. A known video iframe with a `Figure.` caption requires `img`; the same iframe with `Video.` requires `video`. An unknown iframe with an explicit `Video.` caption also uses the video counter, while its wrapper remains `f-iframe`.
|
|
26
|
+
- Counters start at `1` and use semantic series independent of wrapper/label classes: `img` uses `figure`, `pre-code` uses `listing`, `video` uses `video`, and `table` uses `table`. A `pre-samp` caption uses the figure series when its source label is also an image label (`図`), the listing series when it is also a code label (`リスト`), and otherwise its own samp series (`端末`, `Terminal`, etc.). The overlap check reuses p-captions' active language catalog rather than hardcoded label text.
|
|
27
|
+
- A disabled mark never advances a shared series. Therefore image/`図` samp captions or code/`リスト` samp captions share source-order numbering only when both canonical marks are enabled.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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 the selected semantic series so the next automatic number becomes `6`, even when the next caption has a different mark sharing that series. Explicit compound/alphanumeric numbers (e.g., `Figure A.` or `Figure A.5.`) are preserved without appending an automatic number and do not seed an unscoped decimal counter. This applies to captions sourced from paragraphs, auto detection, and fallback captions.
|
|
30
|
+
- With `removeUnnumberedLabel: true`, the enabled-set filter uses the canonical decision mark, not its semantic counter key. For example a samp block labeled `図` still needs `samp` / `pre-samp` in `removeUnnumberedLabelExceptMarks`, not `img`.
|
|
31
|
+
- Number generation and label-token construction now share p-captions' policy/runtime engine. The source-only `captionDecision` remains unchanged when a number is generated, and callback/configuration failures occur before this plugin commits caption-token or counter changes.
|
|
32
|
+
- Generated numbers must remain inside p-captions' caption-number grammar. In particular, advancing `999999` to a seven-digit segment throws a `RangeError` before the affected caption is mutated.
|
|
33
|
+
|
|
34
|
+
### Chapter and appendix scopes
|
|
35
|
+
|
|
36
|
+
The default policy derives a display prefix and an independent counter sequence
|
|
37
|
+
from top-level H1 headings and parsed per-render frontmatter metadata:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
const md = mdit({ html: true }).use(mditFigureWithPCaption, {
|
|
41
|
+
autoLabelNumber: true,
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```md {test id="numbering-default-scope" setup="numbered"}
|
|
46
|
+
# Chapter 1: Introduction
|
|
47
|
+
|
|
48
|
+
Figure. Architecture
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```html {test id="numbering-default-scope"}
|
|
54
|
+
<h1>Chapter 1: Introduction</h1>
|
|
55
|
+
<figure class="f-img">
|
|
56
|
+
<figcaption><span class="f-img-label">Figure 1.1<span class="f-img-label-joint">.</span></span> Architecture</figcaption>
|
|
57
|
+
<img src="architecture.png" alt="Architecture">
|
|
58
|
+
</figure>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Customize the policy when the document uses another heading level, needs one
|
|
62
|
+
source only, resets repeated scope occurrences, or uses `-`:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const md = mdit({ html: true }).use(mditFigureWithPCaption, {
|
|
66
|
+
autoLabelNumber: true,
|
|
67
|
+
autoLabelNumberPolicy: {
|
|
68
|
+
separator: '-',
|
|
69
|
+
scope: {
|
|
70
|
+
sources: ['heading'],
|
|
71
|
+
headingLevels: [2],
|
|
72
|
+
repeatScope: 'reset',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Within a scope object, `sources` defaults to `['frontmatter', 'heading']`,
|
|
79
|
+
`headingLevels` to `[1]`, and `repeatScope` to `'continue'`. `scope: 'auto'`
|
|
80
|
+
selects those defaults. An explicit `sources: []` disables inferred sources
|
|
81
|
+
while still allowing a fixed render-level scope.
|
|
82
|
+
|
|
83
|
+
Recognized leading scope forms are `Chapter N`, `第N章`, `N章`, `Appendix N`, `Appendix A`, and `付録` / `付属` / `附属` followed by a digit or one uppercase ASCII letter. English keywords are case-insensitive. The marker must end at the same spaced/compact caption boundary used by p-captions, so prose such as `Chapter 1st`, `Appendix API`, or `第1章立て` is not mistaken for a scope. Formatting after a valid boundary is allowed (`# Chapter 1: *Introduction*`), but formatting the marker itself (`# **Chapter 1**`) and ambiguous inline-token continuations fail closed.
|
|
84
|
+
|
|
85
|
+
- Only configured top-level heading levels update scope. Headings inside blockquotes/lists do not become scope sources in this release, although figures inside those containers inherit the current top-level scope.
|
|
86
|
+
- Captions before the first recognized scope use the ordinary unscoped decimal sequence.
|
|
87
|
+
- With `repeatScope: 'continue'`, a repeated semantic scope resumes its prior per-mark counter. With `repeatScope: 'reset'`, every scope occurrence gets a new render-local counter partition even when its displayed prefix is identical.
|
|
88
|
+
- In scoped mode, an explicit number seeds the counter only when it uses the active prefix and separator (for example, `Figure 2.5.` under `Chapter 2` with the default separator). Other explicit numbers are preserved as source text but do not seed that sequence.
|
|
89
|
+
- To retain document-wide numbering for every render, set `autoLabelNumberPolicy: { scope: 'document' }`. Explicit `autoLabelNumberPolicy: null` remains an equivalent compatibility opt-out. Per-render frontmatter/env overrides described below can still select `document` when automatic scope is configured.
|
|
90
|
+
|
|
91
|
+
The canonical frontmatter input is parsed metadata supplied per render as `env.frontmatter.title`:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
md.render(source, { frontmatter: { title: 'Appendix A: Data' } })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This plugin does not register a frontmatter block rule and does not parse YAML. If the host already produces a `front_matter` token, provide `resolveFrontmatterTitle(raw, state)` to adapt its raw string. Resolver errors and non-string results fail closed to the unscoped sequence. `markdown-it-front-matter` is used only as a development/integration-test adapter here; adding that detector alone does not parse a YAML `title`.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
autoLabelNumberPolicy: {
|
|
101
|
+
scope: {
|
|
102
|
+
resolveFrontmatterTitle(raw, state) {
|
|
103
|
+
const match = raw.match(/^title:\s*(.+)$/m)
|
|
104
|
+
return match ? match[1] : null
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Parsed frontmatter may override the configured scope mode and separator for one document through the nested `figure-caption-numbering` object:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
---
|
|
114
|
+
title: Chapter 1. A title
|
|
115
|
+
figure-caption-numbering:
|
|
116
|
+
scope: auto
|
|
117
|
+
separator: "."
|
|
118
|
+
---
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The equivalent render input is:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
md.render(source, {
|
|
125
|
+
frontmatter: {
|
|
126
|
+
title: 'Chapter 1. A title',
|
|
127
|
+
'figure-caption-numbering': {
|
|
128
|
+
scope: 'auto',
|
|
129
|
+
separator: '.',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The equivalent flattened form is also accepted when a frontmatter pipeline exposes dotted keys:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
---
|
|
139
|
+
title: Chapter 1. A title
|
|
140
|
+
figure-caption-numbering.scope: auto
|
|
141
|
+
figure-caption-numbering.separator: "."
|
|
142
|
+
---
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Nested and dotted properties may be combined when they configure different fields, but defining the same logical field twice throws instead of choosing an ambiguous winner. Abbreviated aliases are intentionally not recognized.
|
|
146
|
+
|
|
147
|
+
`scope: 'auto'` uses the heading/frontmatter sources enabled by
|
|
148
|
+
`autoLabelNumberPolicy.scope`; its default sources are parsed frontmatter
|
|
149
|
+
titles and top-level H1 headings. It does not re-enable a source removed by an
|
|
150
|
+
explicit `sources` array. `scope: 'document'` fixes the render to the unscoped
|
|
151
|
+
per-series counters, so a recognized `Chapter 1` still produces `Figure 1`,
|
|
152
|
+
`Figure 2`, and so on. `separator` accepts only `.` or `-` and overrides the
|
|
153
|
+
setup-time separator for that render. The default is `.`, so `Chapter 1`
|
|
154
|
+
produces `Figure 1.1`; select `-` for `Figure 1-1`.
|
|
155
|
+
|
|
156
|
+
Unknown nested properties, invalid values, and duplicate nested/dotted definitions throw before caption mutation.
|
|
157
|
+
|
|
158
|
+
A host may override parsed frontmatter through `env.figureCaptionNumbering`. Its `scope` may be `'auto'`, `'document'`, or a validated fixed scope object. A render-level `separator` takes precedence over parsed frontmatter, which in turn takes precedence over `autoLabelNumberPolicy.separator`:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
md.render(source, {
|
|
162
|
+
figureCaptionNumbering: {
|
|
163
|
+
separator: '.',
|
|
164
|
+
scope: {
|
|
165
|
+
scopeKey: 'chapter:7',
|
|
166
|
+
sequenceKey: 'edition-2:chapter:7',
|
|
167
|
+
displayPrefix: '7',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
When `sequenceKey` is omitted from a fixed scope it defaults to `scopeKey`; specify it when semantic identity and counter partition must differ. Invalid explicit overrides throw before caption mutation rather than silently falling back to the unscoped sequence.
|
|
174
|
+
|
|
175
|
+
### Caption-numbering integration API
|
|
176
|
+
|
|
177
|
+
Source editors and other markdown-it integrations can reuse the figure-specific numbering semantics without importing the renderer walker:
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import {
|
|
181
|
+
createFigureCaptionCounterKeyResolver,
|
|
182
|
+
createFigureCaptionNumberCodec,
|
|
183
|
+
createFigureCaptionScopeTimeline,
|
|
184
|
+
normalizeFigureCaptionNumberingPolicy,
|
|
185
|
+
} from '@peaceroad/markdown-it-figure-with-p-caption/caption-numbering.js'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- `normalizeFigureCaptionNumberingPolicy(value)` applies the same validation and defaults as `autoLabelNumberPolicy`. `undefined` or an object with no scope fields uses the automatic defaults; `scope: 'document'` creates an unscoped policy. Only explicit `null` returns `null`; otherwise the helper returns an opaque frozen policy that must be passed to the timeline API.
|
|
189
|
+
- `createFigureCaptionScopeTimeline(state, policy)` reads a markdown-it `StateCore` after inline parsing and returns the initial numbering context plus frozen, source-ordered top-level heading boundaries. It applies the same parsed-frontmatter, render override, heading level, marker boundary, separator, and repeat-scope rules as the renderer. It never mutates `state`, its tokens, or inline children. A recognized heading without a usable `token.map` sets `hasUnmappableBoundaries` so a source editor can fail closed rather than guess an edit range.
|
|
190
|
+
- `createFigureCaptionCounterKeyResolver({ languages })` returns a frozen resolver from a p-captions `captionDecision` to the same semantic `figure` / `listing` / `samp` / `video` / `table` series used by this plugin. The language catalog is normalized once when the resolver is created.
|
|
191
|
+
- `createFigureCaptionNumberCodec()` returns a frozen stateless codec. `parseExplicit(number, context)` returns the compatible positive decimal counter value or `null`; `format(sequence, context)` generates the scoped or unscoped number and enforces p-captions' number grammar. Contexts are branded frozen values returned by the timeline API, so arbitrary look-alike objects are rejected.
|
|
192
|
+
|
|
193
|
+
Create the timeline inside a core rule so the real `StateCore`, including `env` and inline children, stays available:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
const policy = normalizeFigureCaptionNumberingPolicy({
|
|
197
|
+
separator: '.',
|
|
198
|
+
scope: {
|
|
199
|
+
sources: ['frontmatter', 'heading'],
|
|
200
|
+
headingLevels: [1],
|
|
201
|
+
repeatScope: 'continue',
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
md.core.ruler.after('inline', 'collect_figure_caption_scopes', (state) => {
|
|
206
|
+
const timeline = createFigureCaptionScopeTimeline(state, policy)
|
|
207
|
+
// Collect source edits here or store the immutable timeline in render-local state.
|
|
208
|
+
state.env.figureCaptionScopeTimeline = timeline
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This subpath intentionally does not expose figure-candidate detection, wrapping, or token mutation. `p7d-markdown-it-p-captions` still owns caption grammar and `captionDecision`; consumers own source selection and editing.
|
|
213
|
+
|
|
214
|
+
For complete rendered samples and configuration variations, see
|
|
215
|
+
[Complete conversion and option examples](examples.md).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Behavior and options reference
|
|
2
|
+
|
|
3
|
+
This document contains the detailed behavior and option reference for
|
|
4
|
+
`@peaceroad/markdown-it-figure-with-p-caption`. For installation, quick-start
|
|
5
|
+
guidance, recommended options, and representative conversions, start with the
|
|
6
|
+
[project README](../README.md).
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- [Behavior](#behavior)
|
|
11
|
+
- [Behavior customization](#behavior-customization)
|
|
12
|
+
- [Basic usage](#basic-usage)
|
|
13
|
+
- [Recommended options](#recommended-options)
|
|
14
|
+
- [Automatic numbering and integration API](numbering.md)
|
|
15
|
+
- [Complete conversion and option examples](examples.md)
|
|
16
|
+
|
|
17
|
+
## Behavior
|
|
18
|
+
|
|
19
|
+
Calling `.use(mditFigureWithPCaption)` more than once on the same markdown-it instance is intentionally a no-op after the first successful call, before later options are validated. Initial setup validates and registers the rule before setting its installed sentinel, so a failed first setup can be corrected on the same markdown-it instance. Create a separate instance when you need a different successful option set.
|
|
20
|
+
|
|
21
|
+
### Image
|
|
22
|
+
|
|
23
|
+
- Pure image paragraphs (``) become `<figure class="f-img">` blocks as soon as a caption paragraph (previous or next) or an auto-detected caption exists.
|
|
24
|
+
- Auto detection runs per image paragraph when `autoCaptionDetection` is `true` (default). The priority is:
|
|
25
|
+
1. Caption paragraphs immediately before or after the image (standard syntax).
|
|
26
|
+
2. Image `alt` text that `p7d-markdown-it-p-captions` recognizes as an image caption start (`Figure. `, `Figure 1. `, `図 `, `図1 `, etc.).
|
|
27
|
+
3. Image `title` attribute that matches the same labels.
|
|
28
|
+
4. Optional fallbacks (`autoAltCaption`, `autoTitleCaption`) that inject the label when the alt/title lacks one.
|
|
29
|
+
- `autoAltCaption`: `false`/`null` (off), `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. Other value types are rejected. This plugin appends the default joint/space unless the string already ends with a recognized joint such as `.` / `。` / `:` / ` `. Empty alt text does not generate a fallback caption.
|
|
30
|
+
- `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.
|
|
31
|
+
- Set `autoCaptionDetection: false` to disable the auto-caption workflow entirely.
|
|
32
|
+
- Multi-image paragraphs are still wrapped as one figure when `multipleImages: true` (default). Layout-specific classes help with styling:
|
|
33
|
+
- `f-img-horizontal` when images sit on the same line (space-delimited).
|
|
34
|
+
- `f-img-vertical` when separated only by soft breaks.
|
|
35
|
+
- `f-img-multiple` for mixed layouts.
|
|
36
|
+
- Automatic detection inspects only the first image in the paragraph. If it yields a caption, the entire figure reuses that caption while later images keep their own `alt`/`title`.
|
|
37
|
+
- Paragraphs that contain only images also convert when they appear inside loose lists (leave blank lines between items), blockquotes, or description lists.
|
|
38
|
+
- Caption detection intentionally skips paragraphs that are the first block inside a list item (`list_item_open` immediately before the paragraph). In practice, `- Figure. ...` followed by an image in the same item is treated as plain text unless you insert another block first.
|
|
39
|
+
- A media candidate that ultimately cannot be wrapped (for example, an image paragraph with trailing prose) no longer causes an adjacent caption paragraph to be decorated as a side effect. This is an intentional correctness fix from 0.19.0; valid figures retain the existing output.
|
|
40
|
+
|
|
41
|
+
### Table
|
|
42
|
+
|
|
43
|
+
- Markdown tables (including those produced by `markdown-it-multimd-table` or similar) convert into `<figure class="f-table">` blocks.
|
|
44
|
+
- Caption paragraphs immediately before/after the table become `<figcaption>` element ahead of the `<table>`.
|
|
45
|
+
|
|
46
|
+
### Code block
|
|
47
|
+
|
|
48
|
+
- Captions labeled `Code. `, `Terminal. `, etc. wrap the fence in `<figure class="f-pre-code">` / `<figure class="f-pre-samp">`.
|
|
49
|
+
- If `roleDocExample: true`, these figures add `role="doc-example"`.
|
|
50
|
+
|
|
51
|
+
### Blockquote
|
|
52
|
+
|
|
53
|
+
- Captioned blockquotes (e.g., `Source. A paragraph.` written immediately before or after `> ...`) become `<figure class="f-blockquote">` while keeping the original blockquote intact.
|
|
54
|
+
|
|
55
|
+
### Video & Audio
|
|
56
|
+
|
|
57
|
+
- Block HTML `<video>` and `<audio>` tags are detected as media figures (`<figure class="f-video">` and `<figure class="f-audio">`). A single-line tag parsed by markdown-it as inline HTML is not promoted to a block figure.
|
|
58
|
+
- A caption paragraph labeled `Video. ` / `Audio. ` (or any registered label) is promoted to `<figcaption>` before/after the media so controls remain unobstructed.
|
|
59
|
+
|
|
60
|
+
### Embedded content by iframe
|
|
61
|
+
|
|
62
|
+
- HTML blocks containing `<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`).
|
|
63
|
+
- `<div>` wrappers are treated as iframe-type embeds only when the same HTML block contains an `<iframe ...>` tag (for example common video wrapper markup).
|
|
64
|
+
- `videoWithoutCaption` enables captionless wrapping for `<video>` blocks and for iframes (including those nested in `<div>` wrappers) whose `src` host is a known YouTube/Vimeo provider. `iframeWithoutCaption` also enables captionless wrapping for iframe candidates generally. A known video iframe keeps the `f-video` classification through either option; a generic iframe requires `iframeWithoutCaption` and uses `f-iframe`.
|
|
65
|
+
- Blockquote-based social embeds (Twitter/X `twitter-tweet`, Mastodon `mastodon-embed`, Bluesky `bluesky-embed`, Instagram `instagram-media`, Threads `text-post-media`) are treated like iframe-type embeds when their class list contains one of those provider classes. Extra classes, `data-*` attributes, inline styles, SVG content, and the embed-script host do not block detection. By default they become `<figure class="f-img">` so the caption label behaves like an image label; quote labels are also accepted. You can override that figure class with `figureClassThatWrapsIframeTypeBlockquote` or the global `allIframeTypeFigureClassName`.
|
|
66
|
+
- `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.
|
|
67
|
+
- Other eligible iframe blocks use `<figure class="f-iframe">` unless you override the class via `allIframeTypeFigureClassName`. Eligibility still requires a caption or the corresponding captionless-wrapping option.
|
|
68
|
+
|
|
69
|
+
### Label span class name
|
|
70
|
+
|
|
71
|
+
- 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`.
|
|
72
|
+
- With `markdown-it-attrs`, attributes attached to image-only paragraphs (for example ` {.foo #bar}`) are forwarded to the generated `<figure>`.
|
|
73
|
+
- `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.
|
|
74
|
+
- 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>`.
|
|
75
|
+
- Attributes attached to caption paragraphs stay on the converted `<figcaption>` token after paragraph-to-figcaption conversion.
|
|
76
|
+
|
|
77
|
+
## Behavior Customization
|
|
78
|
+
|
|
79
|
+
### Styles
|
|
80
|
+
|
|
81
|
+
- Set `allIframeTypeFigureClassName: 'f-embed'` (recommended) to force a single CSS class across `<iframe>` and social-embed figures so they can share styles, ensuring every embed wrapper shares the same predictable class name.
|
|
82
|
+
- `figureClassThatWrapsIframeTypeBlockquote`: override the class used when recognized blockquote-based embeds (Twitter/X, Mastodon, Bluesky, Instagram, Threads) are wrapped.
|
|
83
|
+
- `figureClassThatWrapsSlides`: override the class assigned when a caption paragraph uses the `Slide.` label.
|
|
84
|
+
- `classPrefix` (default `f`) controls the CSS namespace for generated default classes (`f-img`, `f-table`, etc.) so you can align with existing styles. Explicit wrapper-class overrides remain independent.
|
|
85
|
+
- Wrapper/class-prefix options are trimmed during setup; whitespace-only values fall back to the default class for that option.
|
|
86
|
+
|
|
87
|
+
### Wrapping without captions
|
|
88
|
+
|
|
89
|
+
- `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.
|
|
90
|
+
- `oneImageWithoutCaption`: legacy alias for `imageOnlyParagraphWithoutCaption`. When both are provided, `imageOnlyParagraphWithoutCaption` wins.
|
|
91
|
+
- `videoWithoutCaption`, `audioWithoutCaption`, `iframeWithoutCaption`, `iframeTypeBlockquoteWithoutCaption`: wrap the respective media blocks without caption.
|
|
92
|
+
|
|
93
|
+
### Caption text helpers (integrated with `p7d-markdown-it-p-captions`)
|
|
94
|
+
|
|
95
|
+
`p7d-markdown-it-p-captions` owns caption parsing and figcaption rendering for
|
|
96
|
+
the options below. This plugin normalizes the values that also affect figure
|
|
97
|
+
selection, numbering, or final wrapper-class integration before passing the
|
|
98
|
+
shared option state to p-captions:
|
|
99
|
+
|
|
100
|
+
- `strongFilename` / `dquoteFilename`: pull out filenames from captions using `**filename**` or `"filename"` syntax and wrap them in `<strong class="f-*-filename">`.
|
|
101
|
+
- `jointSpaceUseHalfWidth`: replace full-width space between Japanese labels and caption body with half-width space.
|
|
102
|
+
- `bLabel` / `strongLabel`: emphasize the label span itself.
|
|
103
|
+
- `removeUnnumberedLabel`: drop the leading label entirely when no label number is present. Use `removeUnnumberedLabelExceptMarks` to keep specific labels (e.g., `['blockquote']` keeps `Quote. `).
|
|
104
|
+
- `removeMarkNameInCaptionClass`: replace `.f-img-label` / `.f-table-label` with the generic `.f-label`.
|
|
105
|
+
- `wrapCaptionBody`: wrap the non-label caption text in a span element.
|
|
106
|
+
- `hasNumClass`: add a class attribute to a label span when the source caption has a label number. For compatibility with the pre-shared-engine pipeline, numbers generated by this figure plugin do not retroactively add `label-has-num`.
|
|
107
|
+
- `labelClassFollowsFigure`: mirror the final 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. Caption-driven slide classes are resolved before span construction, including custom `figureClassThatWrapsSlides` values.
|
|
108
|
+
- `figureToLabelClassMap`: extend `labelClassFollowsFigure` by mapping specific final figure classes (e.g., `f-embed` or `f-slide`) 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.
|
|
109
|
+
- `labelPrefixMarker`: allow a leading marker before labels (string or array, e.g., `*Figure. ...`). Arrays are limited to two markers; extras are ignored.
|
|
110
|
+
- `allowLabelPrefixMarkerWithoutLabel`: accept marker-only caption paragraphs when set to `true`. With a marker array, the first marker applies before a figure and the second after it.
|
|
111
|
+
- `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.
|
|
112
|
+
- 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.
|
|
113
|
+
- Generated fallback label tie-break is resolved lazily, at most once per render, when an image actually needs an unlabeled fallback. 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. Laziness is based on the final image token stream rather than raw `'))
|
|
127
|
+
// <figure class="f-img">
|
|
128
|
+
// <figcaption><span class="f-img-label">Figure<span class="f-img-label-joint">.</span></span> A Cat.</figcaption>
|
|
129
|
+
// <img src="cat.jpg" alt="A cat">
|
|
130
|
+
// </figure>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Recommended options
|
|
134
|
+
|
|
135
|
+
The canonical practical baseline is kept in the project README under
|
|
136
|
+
[Recommended options](../README.md#recommended-options), where it remains
|
|
137
|
+
visible to new users and is less likely to drift from the quick-start example.
|
|
138
|
+
|
|
139
|
+
That configuration enables captionless media wrapping, gives iframe-like
|
|
140
|
+
embeds one predictable class, and enables document-wide image/table numbering.
|
|
141
|
+
`removeUnnumberedLabel` is shown separately because it removes labels for every
|
|
142
|
+
caption that still lacks a number. Use `removeUnnumberedLabelExceptMarks` when
|
|
143
|
+
specific unnumbered labels, such as `Quote.` / `Source.`, must remain visible.
|
|
144
|
+
|
|
145
|
+
For automatic numbering, chapter/appendix scopes, and the public integration
|
|
146
|
+
API, see [Automatic numbering and integration API](numbering.md). For the
|
|
147
|
+
complete input/output samples, see
|
|
148
|
+
[Complete conversion and option examples](examples.md).
|
package/embeds/detect.js
CHANGED
|
@@ -8,14 +8,13 @@ const htmlRegCache = new Map()
|
|
|
8
8
|
const openingClassAttrReg = /^<[^>]*?\bclass=(?:"([^"]*)"|'([^']*)')/i
|
|
9
9
|
const iframeSrcAttrReg = /<iframe\b[^>]*?\bsrc=(?:"([^"]*)"|'([^']*)')/i
|
|
10
10
|
const endBlockquoteScriptReg = /<\/blockquote> *<script[^>]*?><\/script>$/i
|
|
11
|
-
const targetHtmlHintReg = /<(?:video|audio|iframe|blockquote|div)\
|
|
11
|
+
const targetHtmlHintReg = /<(?:video|audio|iframe|blockquote|div)(?=[\s>])/i
|
|
12
12
|
const blueskyEmbedHintReg = /bluesky-embed/i
|
|
13
|
-
const videoTagHintReg = /<video\
|
|
14
|
-
const audioTagHintReg = /<audio\
|
|
15
|
-
const iframeTagHintReg = /<iframe\
|
|
16
|
-
const blockquoteTagHintReg = /<blockquote\
|
|
17
|
-
const divTagHintReg = /<div\
|
|
18
|
-
const iframeTagReg = /<iframe(?=[\s>])/i
|
|
13
|
+
const videoTagHintReg = /<video(?=[\s>])/i
|
|
14
|
+
const audioTagHintReg = /<audio(?=[\s>])/i
|
|
15
|
+
const iframeTagHintReg = /<iframe(?=[\s>])/i
|
|
16
|
+
const blockquoteTagHintReg = /<blockquote(?=[\s>])/i
|
|
17
|
+
const divTagHintReg = /<div(?=[\s>])/i
|
|
19
18
|
|
|
20
19
|
const getHtmlReg = (tag) => {
|
|
21
20
|
const cached = htmlRegCache.get(tag)
|
|
@@ -45,42 +44,35 @@ const getHtmlDetectionHints = (content) => {
|
|
|
45
44
|
hasIframeHint,
|
|
46
45
|
hasBlockquoteHint,
|
|
47
46
|
hasDivHint,
|
|
48
|
-
hasIframeTag: hasIframeHint || (hasDivHint && iframeTagReg.test(source)),
|
|
49
47
|
}
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
50
|
+
const needsHtmlBlockNewline = (hasTag) => !!(
|
|
51
|
+
(hasTag[2] && hasTag[3] !== '\n') ||
|
|
52
|
+
(hasTag[1] !== '\n' && hasTag[2] === undefined)
|
|
53
|
+
)
|
|
57
54
|
|
|
58
|
-
const
|
|
55
|
+
const findBlockquoteEmbedScriptTransform = (tokens, token, startIndex) => {
|
|
59
56
|
let addedContent = ''
|
|
60
57
|
let i = startIndex + 1
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (nextToken.type === 'inline' && endBlockquoteScriptReg.test(nextToken.content)) {
|
|
64
|
-
addedContent += nextToken.content + '\n'
|
|
65
|
-
if (tokens[i + 1] && tokens[i + 1].type === 'paragraph_close') {
|
|
66
|
-
tokens.splice(i + 1, 1)
|
|
67
|
-
}
|
|
68
|
-
nextToken.content = ''
|
|
69
|
-
if (nextToken.children) {
|
|
70
|
-
for (let j = 0; j < nextToken.children.length; j++) {
|
|
71
|
-
nextToken.children[j].content = ''
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
break
|
|
75
|
-
}
|
|
76
|
-
if (nextToken.type === 'paragraph_open') {
|
|
77
|
-
addedContent += '\n'
|
|
78
|
-
tokens.splice(i, 1)
|
|
79
|
-
continue
|
|
80
|
-
}
|
|
58
|
+
if (tokens[i] && tokens[i].type === 'paragraph_open') {
|
|
59
|
+
addedContent = '\n'
|
|
81
60
|
i++
|
|
82
61
|
}
|
|
83
|
-
|
|
62
|
+
const inlineToken = tokens[i]
|
|
63
|
+
if (!inlineToken || inlineToken.type !== 'inline' || !endBlockquoteScriptReg.test(inlineToken.content)) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
addedContent += inlineToken.content + '\n'
|
|
67
|
+
let endIndex = i
|
|
68
|
+
if (tokens[i + 1] && tokens[i + 1].type === 'paragraph_close') endIndex++
|
|
69
|
+
return {
|
|
70
|
+
type: 'merge-blockquote-script',
|
|
71
|
+
token,
|
|
72
|
+
startIndex,
|
|
73
|
+
endIndex,
|
|
74
|
+
addedContent,
|
|
75
|
+
}
|
|
84
76
|
}
|
|
85
77
|
|
|
86
78
|
const getOpeningAttrValue = (content, reg) => {
|
|
@@ -121,27 +113,39 @@ const isKnownVideoIframe = (content) => {
|
|
|
121
113
|
}
|
|
122
114
|
|
|
123
115
|
const detectHtmlTagCandidate = (tokens, token, startIndex, detector, hints) => {
|
|
124
|
-
if (detector.requiresIframeTag && !hints.
|
|
116
|
+
if (detector.requiresIframeTag && !hints.hasIframeHint) return null
|
|
125
117
|
const hasTagHint = !!(detector.hintKey && hints[detector.hintKey])
|
|
126
118
|
const allowBlueskyFallback = detector.candidate === 'blockquote' && hints.hasBlueskyHint
|
|
127
|
-
if (!hasTagHint && !allowBlueskyFallback) return
|
|
119
|
+
if (!hasTagHint && !allowBlueskyFallback) return null
|
|
128
120
|
const hasTag = hasTagHint ? token.content.match(getHtmlReg(detector.lookupTag)) : null
|
|
129
121
|
const isBlueskyFallback = detector.candidate === 'blockquote' && !hasTag && hints.hasBlueskyHint
|
|
130
|
-
if (!hasTag && !isBlueskyFallback) return
|
|
122
|
+
if (!hasTag && !isBlueskyFallback) return null
|
|
131
123
|
if (hasTag) {
|
|
132
|
-
|
|
133
|
-
|
|
124
|
+
return {
|
|
125
|
+
matchedTag: detector.matchedTag || detector.candidate,
|
|
126
|
+
endIndex: startIndex,
|
|
127
|
+
transform: needsHtmlBlockNewline(hasTag)
|
|
128
|
+
? { type: 'append-newline', token }
|
|
129
|
+
: null,
|
|
130
|
+
}
|
|
134
131
|
}
|
|
135
|
-
|
|
136
|
-
return
|
|
132
|
+
const transform = findBlockquoteEmbedScriptTransform(tokens, token, startIndex)
|
|
133
|
+
return transform
|
|
134
|
+
? { matchedTag: 'blockquote', endIndex: transform.endIndex, transform }
|
|
135
|
+
: null
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
const resolveHtmlWrapWithoutCaption = (
|
|
138
|
+
const resolveHtmlWrapWithoutCaption = (
|
|
139
|
+
matchedTag,
|
|
140
|
+
isVideoIframe,
|
|
141
|
+
isIframeTypeBlockquote,
|
|
142
|
+
htmlWrapWithoutCaption,
|
|
143
|
+
) => {
|
|
140
144
|
if (!htmlWrapWithoutCaption) return false
|
|
141
145
|
if (matchedTag === 'blockquote') {
|
|
142
|
-
return !!(
|
|
146
|
+
return !!(isIframeTypeBlockquote && htmlWrapWithoutCaption.iframeTypeBlockquote)
|
|
143
147
|
}
|
|
144
|
-
if (matchedTag === 'iframe' &&
|
|
148
|
+
if (matchedTag === 'iframe' && isVideoIframe) {
|
|
145
149
|
return !!(htmlWrapWithoutCaption.video || htmlWrapWithoutCaption.iframe)
|
|
146
150
|
}
|
|
147
151
|
return !!htmlWrapWithoutCaption[matchedTag]
|
|
@@ -152,35 +156,53 @@ export const detectHtmlFigureCandidate = (tokens, token, startIndex, htmlWrapWit
|
|
|
152
156
|
const hints = getHtmlDetectionHints(token.content)
|
|
153
157
|
if (!hints) return null
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
isVideoIframe: false,
|
|
157
|
-
isIframeTypeBlockquote: false,
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
let matchedTag = ''
|
|
159
|
+
let candidate = null
|
|
161
160
|
for (let i = 0; i < HTML_EMBED_CANDIDATES.length; i++) {
|
|
162
|
-
|
|
163
|
-
if (
|
|
161
|
+
candidate = detectHtmlTagCandidate(tokens, token, startIndex, HTML_EMBED_CANDIDATES[i], hints)
|
|
162
|
+
if (candidate) break
|
|
164
163
|
}
|
|
165
|
-
if (!
|
|
164
|
+
if (!candidate) return null
|
|
165
|
+
const matchedTag = candidate.matchedTag
|
|
166
|
+
let isIframeTypeBlockquote = false
|
|
166
167
|
|
|
167
168
|
if (matchedTag === 'blockquote') {
|
|
168
169
|
if (!hasKnownBlockquoteEmbedClass(token.content)) return null
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (matchedTag === 'iframe' && isKnownVideoIframe(token.content)) {
|
|
173
|
-
result.isVideoIframe = true
|
|
170
|
+
isIframeTypeBlockquote = true
|
|
174
171
|
}
|
|
172
|
+
const isVideoIframe = matchedTag === 'iframe' && isKnownVideoIframe(token.content)
|
|
175
173
|
|
|
176
174
|
return {
|
|
177
175
|
type: 'html',
|
|
178
176
|
tagName: matchedTag,
|
|
179
|
-
en:
|
|
177
|
+
en: candidate.endIndex,
|
|
180
178
|
replaceInsteadOfWrap: false,
|
|
181
|
-
wrapWithoutCaption: resolveHtmlWrapWithoutCaption(
|
|
179
|
+
wrapWithoutCaption: resolveHtmlWrapWithoutCaption(
|
|
180
|
+
matchedTag,
|
|
181
|
+
isVideoIframe,
|
|
182
|
+
isIframeTypeBlockquote,
|
|
183
|
+
htmlWrapWithoutCaption,
|
|
184
|
+
),
|
|
182
185
|
canWrap: true,
|
|
183
|
-
isVideoIframe
|
|
184
|
-
isIframeTypeBlockquote
|
|
186
|
+
isVideoIframe,
|
|
187
|
+
isIframeTypeBlockquote,
|
|
188
|
+
transform: candidate.transform,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const applyHtmlFigureTransform = (tokens, detection) => {
|
|
193
|
+
const transform = detection && detection.transform
|
|
194
|
+
if (!transform) return detection ? detection.en : 0
|
|
195
|
+
detection.transform = null
|
|
196
|
+
if (transform.type === 'append-newline') {
|
|
197
|
+
transform.token.content += '\n'
|
|
198
|
+
return detection.en
|
|
199
|
+
}
|
|
200
|
+
if (transform.type === 'merge-blockquote-script') {
|
|
201
|
+
transform.token.content += transform.addedContent
|
|
202
|
+
const removeCount = transform.endIndex - transform.startIndex
|
|
203
|
+
if (removeCount > 0) tokens.splice(transform.startIndex + 1, removeCount)
|
|
204
|
+
detection.en = transform.startIndex
|
|
205
|
+
return detection.en
|
|
185
206
|
}
|
|
207
|
+
return detection.en
|
|
186
208
|
}
|