@peaceroad/markdown-imgattr-to-pcaption 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,9 @@ setMarkdownImgAttrToPCaption(markdownCont)
64
64
 
65
65
  ## Option
66
66
 
67
- ### imgTitleCaption: true
67
+ ### imgTitleCaption
68
+
69
+ Default: false.
68
70
 
69
71
  ```
70
72
  [Input]
@@ -85,7 +87,9 @@ setMarkdownImgAttrToPCaption(markdownCont)
85
87
  段落。段落。段落。
86
88
  ```
87
89
 
88
- ### labelLang: 'en'
90
+ ### labelLang
91
+
92
+ Default: 'en'.
89
93
 
90
94
  ```
91
95
  [Input]
@@ -99,9 +103,94 @@ setMarkdownImgAttrToPCaption(markdownCont)
99
103
  [Output]
100
104
  段落。段落。段落。
101
105
 
102
- Figure. キャプション
106
+ 図 キャプション
103
107
 
104
108
  ![](image.jpg)
105
109
 
106
110
  段落。段落。段落。
107
- ```
111
+ ```
112
+
113
+ ### autoLangDetection
114
+
115
+ Default: true. To force a specific `labelLang`, set `autoLangDetection: false`.
116
+ When `autoLangDetection` is true, it can override an explicitly set `labelLang` (it is treated as the fallback when detection cannot decide).
117
+
118
+ Detect `labelLang` from the first image caption line. If the caption text contains Japanese characters, it sets `labelLang: 'ja'`. Otherwise, if the caption contains ASCII letters, it sets `labelLang: 'en'` (symbols/emoji are ignored). If the caption contains non-ASCII letters such as accents, the existing `labelLang` is left unchanged.
119
+
120
+ Example (non-ASCII letters keep the current `labelLang`):
121
+
122
+ ```
123
+ [Input]
124
+ 段落。
125
+
126
+ ![Café](image.jpg)
127
+
128
+ 段落。
129
+
130
+ [Output]
131
+ 段落。
132
+
133
+ Figure. Café
134
+
135
+ ![](image.jpg)
136
+
137
+ 段落。
138
+ ```
139
+ Only `ja` and `en` are auto-detected. For other languages, set `labelLang` explicitly (and use `labelSet` as needed) or leave auto-detection off.
140
+ Detection runs only once on the first eligible image line; subsequent images do not affect the language choice.
141
+
142
+ ### labelSet
143
+
144
+ Override the auto-inserted label, joint, and space for captions without labels (useful for other languages).
145
+ `labelSet` accepts either a single config for the current `labelLang` or a per-language map.
146
+ If a matching language entry is not found, the default (English) label config is used.
147
+
148
+ ```
149
+ setMarkdownImgAttrToPCaption(markdownCont, {
150
+ labelSet: { label: '図', joint: ':', space: ' ' }
151
+ })
152
+ ```
153
+
154
+ ```
155
+ setMarkdownImgAttrToPCaption(markdownCont, {
156
+ labelSet: {
157
+ en: { label: 'Figure', joint: '.', space: ' ' },
158
+ ja: { label: '図', joint: ' ', space: '' },
159
+ fr: { label: 'Fig', joint: '.', space: ' ' },
160
+ }
161
+ })
162
+ ```
163
+
164
+ ## Notes
165
+
166
+ - Only converts images that are on a single line and surrounded by blank lines. Inline images or list items are not changed.
167
+ - Skips fenced code blocks (``` or ~~~).
168
+ - Label detection uses `p7d-markdown-it-p-captions` label patterns (en/ja by default). `labelSet` only affects auto-inserted labels when no label is detected.
169
+ - `autoLangDetection` inspects the first eligible image line and uses the caption text (title when `imgTitleCaption: true`, otherwise alt). If the caption text is empty, it falls back to alt.
170
+
171
+ ## Browser DOM helper (live preview)
172
+
173
+ This package also provides a DOM helper to turn image alt/title into `<figure><figcaption>` on the fly.
174
+ It is useful for live preview environments that do not re-run markdown-it on each edit.
175
+ This helper does not insert label prefixes; it uses the raw alt/title text as the caption.
176
+
177
+ ```html
178
+ <script type="module">
179
+ import setImgFigureCaption from '@peaceroad/markdown-imgattr-to-pcaption/script/set-img-figure-caption.js'
180
+
181
+ await setImgFigureCaption({
182
+ imgAltCaption: true,
183
+ imgTitleCaption: false,
184
+ observe: true
185
+ })
186
+ </script>
187
+ ```
188
+
189
+ ### DOM helper options
190
+
191
+ - `imgAltCaption` (boolean|string): use `alt` text as caption (strings are treated as true)
192
+ - `imgTitleCaption` (boolean|string): use `title` text as caption (strings are treated as true)
193
+ - `preferAlt` (boolean): when both are enabled, prefer alt (default true)
194
+ - `figureClass` (string): class name for created figures (default `f-img`)
195
+ - `readMeta` (boolean): read `<meta name="markdown-frontmatter">` and apply `imgAltCaption` / `imgTitleCaption`
196
+ - `observe` (boolean): re-run on DOM changes (MutationObserver)
package/index.js CHANGED
@@ -1,139 +1,305 @@
1
+ import { markAfterNum, markReg, joint } from 'p7d-markdown-it-p-captions'
2
+ import langSets from 'p7d-markdown-it-p-captions/lang.js'
3
+
4
+ const imageLineReg = /^([ \t]*?)!\[ *?(.*?) *?\]\(([^ ]*?)( +"(.*?)")?\)( *(?:{.*?})?)$/
5
+ const backquoteFenceReg = /^[ \t]*```/
6
+ const tildeFenceReg = /^[ \t]*~~~/
7
+ const blankLineReg = /^[ \t]*$/
8
+ const asciiOnlyReg = /^[\x00-\x7F]*$/
9
+ const whitespaceOnlyReg = /^[\s\u3000]+$/
10
+ const unicodeLetterReg = (() => {
11
+ try {
12
+ return new RegExp('\\p{L}', 'u')
13
+ } catch {
14
+ return null
15
+ }
16
+ })()
17
+
18
+ const DEFAULT_LABEL_CONFIG_MAP = {
19
+ en: { label: 'Figure', joint: '.', space: ' ' },
20
+ ja: { label: '図', joint: ' ', space: '' },
21
+ }
22
+
23
+ const buildLabelOnlyReg = () => {
24
+ const langs = Object.keys(langSets)
25
+ if (langs.length === 0) return null
26
+
27
+ const patterns = []
28
+ for (const lang of langs) {
29
+ const data = langSets[lang]
30
+ if (!data || !data.markReg || !data.markReg.img) continue
31
+ let pattern = data.markReg.img
32
+ if (data.type && data.type['inter-word-space']) {
33
+ pattern = pattern.replace(/([a-z])/g, (match) => '[' + match + match.toUpperCase() + ']')
34
+ }
35
+ patterns.push(pattern)
36
+ }
37
+
38
+ if (patterns.length === 0) return null
39
+ return new RegExp('^(' + patterns.join('|') + ')([ .]?' + markAfterNum + ')?$')
40
+ }
41
+
42
+ const labelOnlyReg = buildLabelOnlyReg()
43
+ const jointSuffixReg = new RegExp(joint + '$')
44
+
45
+ const getCaptionText = (imgLine, opt) => {
46
+ if (opt.imgTitleCaption) {
47
+ return imgLine[5] || ''
48
+ }
49
+ return imgLine[2] || ''
50
+ }
51
+
52
+ const getCaptionTextForDetection = (imgLine, opt) => {
53
+ const captionText = getCaptionText(imgLine, opt)
54
+ if (captionText) {
55
+ return captionText
56
+ }
57
+ return imgLine[2] || ''
58
+ }
59
+
60
+ const isAsciiOnly = (value) => asciiOnlyReg.test(value)
61
+ const isAsciiLetterCode = (code) => (
62
+ (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)
63
+ )
64
+
65
+ const isJapaneseCodePoint = (code) => {
66
+ return (
67
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
68
+ (code >= 0x31f0 && code <= 0x31ff) || // Katakana extensions
69
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
70
+ (code >= 0xff66 && code <= 0xff9f) // Half-width Katakana
71
+ )
72
+ }
73
+
74
+ const containsJapanese = (value) => {
75
+ for (const ch of value) {
76
+ const code = ch.codePointAt(0)
77
+ if (code !== undefined && isJapaneseCodePoint(code)) {
78
+ return true
79
+ }
80
+ }
81
+ return false
82
+ }
83
+
84
+ const detectAutoLang = (value) => {
85
+ let hasAsciiLetter = false
86
+ for (const ch of value) {
87
+ const code = ch.codePointAt(0)
88
+ if (code === undefined) continue
89
+ if (isJapaneseCodePoint(code)) return 'ja'
90
+ if (code <= 0x7f) {
91
+ if (isAsciiLetterCode(code)) {
92
+ hasAsciiLetter = true
93
+ }
94
+ continue
95
+ }
96
+ if (unicodeLetterReg) {
97
+ if (unicodeLetterReg.test(ch)) return null
98
+ } else if (ch.toLowerCase() !== ch.toUpperCase()) {
99
+ return null
100
+ }
101
+ }
102
+ return hasAsciiLetter ? 'en' : null
103
+ }
104
+
105
+ const getInterWordSpace = (labelLang, labelText) => {
106
+ const lang = langSets[labelLang]
107
+ if (lang && lang.type) {
108
+ return Boolean(lang.type['inter-word-space'])
109
+ }
110
+ return isAsciiOnly(labelText)
111
+ }
112
+
113
+ const normalizeLabelConfig = (value) => {
114
+ if (!value || typeof value !== 'object') return null
115
+ const config = {}
116
+ const labelValue = (typeof value.label === 'string')
117
+ ? value.label
118
+ : (typeof value.lable === 'string' ? value.lable : undefined)
119
+ if (labelValue !== undefined) {
120
+ config.label = labelValue
121
+ }
122
+ if (Object.prototype.hasOwnProperty.call(value, 'joint')) {
123
+ config.joint = String(value.joint)
124
+ }
125
+ if (Object.prototype.hasOwnProperty.call(value, 'space')) {
126
+ config.space = String(value.space)
127
+ }
128
+ if (Object.keys(config).length === 0) return null
129
+ return config
130
+ }
131
+
132
+ const mergeLabelConfig = (base, override) => {
133
+ if (!override) return base
134
+ const merged = { ...base }
135
+ if (Object.prototype.hasOwnProperty.call(override, 'label')) {
136
+ merged.label = override.label
137
+ }
138
+ if (Object.prototype.hasOwnProperty.call(override, 'joint')) {
139
+ merged.joint = override.joint
140
+ }
141
+ if (Object.prototype.hasOwnProperty.call(override, 'space')) {
142
+ merged.space = override.space
143
+ }
144
+ return merged
145
+ }
146
+
147
+ const getDefaultLabelConfig = (labelLang) => {
148
+ const base = DEFAULT_LABEL_CONFIG_MAP[labelLang] || DEFAULT_LABEL_CONFIG_MAP.en
149
+ return { label: base.label, joint: base.joint, space: base.space }
150
+ }
151
+
152
+ const resolveLabelConfig = (opt) => {
153
+ let config = getDefaultLabelConfig(opt.labelLang)
154
+ let mapConfig = null
155
+ let singleConfig = null
156
+ if (opt.labelSet && typeof opt.labelSet === 'object') {
157
+ singleConfig = normalizeLabelConfig(opt.labelSet)
158
+ if (!singleConfig) {
159
+ mapConfig = normalizeLabelConfig(opt.labelSet[opt.labelLang])
160
+ }
161
+ }
162
+ config = mergeLabelConfig(config, mapConfig)
163
+ config = mergeLabelConfig(config, singleConfig)
164
+
165
+ if (!config.label) {
166
+ config.label = DEFAULT_LABEL_CONFIG_MAP.en.label
167
+ }
168
+
169
+ if (config.joint === undefined || config.space === undefined) {
170
+ const interWordSpace = getInterWordSpace(opt.labelLang, config.label)
171
+ if (config.joint === undefined) {
172
+ config.joint = interWordSpace ? '.' : ' '
173
+ }
174
+ if (config.space === undefined) {
175
+ config.space = interWordSpace ? ' ' : ' '
176
+ }
177
+ }
178
+ return config
179
+ }
180
+
181
+ const buildLabelPrefix = (labelConfig, hasCaption) => {
182
+ const labelText = labelConfig.label || ''
183
+ const joint = labelConfig.joint || ''
184
+ const space = labelConfig.space || ''
185
+ let prefix = labelText
186
+
187
+ if (joint) {
188
+ const jointIsWhitespace = whitespaceOnlyReg.test(joint)
189
+ if (hasCaption || !jointIsWhitespace) {
190
+ if (!prefix.endsWith(joint)) {
191
+ prefix += joint
192
+ }
193
+ }
194
+ }
195
+
196
+ if (hasCaption && space) {
197
+ if (!prefix.endsWith(space)) {
198
+ if (!(space === joint && prefix.endsWith(joint))) {
199
+ prefix += space
200
+ }
201
+ }
202
+ }
203
+ return prefix
204
+ }
205
+
206
+ const buildLabelMeta = (opt) => {
207
+ return resolveLabelConfig(opt)
208
+ }
209
+
1
210
  const setMarkdownImgAttrToPCaption = (markdown, option) => {
2
211
 
3
212
  const opt = {
4
213
  imgAltCaption : true,
5
214
  imgTitleCaption: false,
6
- labelLang: 'ja',
215
+ labelLang: 'en',
216
+ autoLangDetection: true,
217
+ labelSet: null, // { label: '図', joint: ':', space: ' ' } or { ja: { label: '図', joint: ' ', space: '' }, en: { label: 'Figure', joint: '.', space: ' ' } }
7
218
  }
8
- if (option !== undefined) {
219
+ if (option && typeof option === 'object') {
9
220
  if (option.imgTitleCaption) {
10
221
  opt.imgTitleCaption = option.imgTitleCaption
11
222
  }
12
223
  if (option.labelLang) {
13
224
  opt.labelLang = option.labelLang
14
225
  }
226
+ if (option.labelSet && typeof option.labelSet === 'object') {
227
+ opt.labelSet = option.labelSet
228
+ }
229
+ if (Object.prototype.hasOwnProperty.call(option, 'autoLangDetection')) {
230
+ opt.autoLangDetection = Boolean(option.autoLangDetection)
231
+ }
15
232
  }
16
233
  if (opt.imgTitleCaption) opt.imgAltCaption = false
17
234
 
18
- let n = 0
19
- let lines = markdown.split(/\r\n|\n/)
20
- let lineBreaks = markdown.match(/\r\n|\n/g);
235
+ const lines = markdown.split(/\r\n|\n/)
236
+ const lineBreaks = markdown.match(/\r\n|\n/g) || []
21
237
  let isBackquoteCodeBlock = false
22
238
  let isTildeCodeBlock = false
23
- const br = lineBreaks ? lineBreaks[0] : ''
239
+ const br = lineBreaks[0] || '\n'
240
+
241
+ let labelMeta = null
242
+ let autoLangChecked = !opt.autoLangDetection
24
243
 
25
244
  if(lines.length === 0) return markdown
26
245
 
27
- while (n < lines.length) {
28
- let isPrevBreakLine = false
29
- let isNextBreakLine = false
30
- if (lines[n].match(/^[ \t]*```/)) {
31
- if (isBackquoteCodeBlock) {
32
- isBackquoteCodeBlock = false
33
- } else {
34
- isBackquoteCodeBlock = true
35
- }
246
+ for (let n = 0; n < lines.length; n++) {
247
+ const line = lines[n]
248
+ if (backquoteFenceReg.test(line)) {
249
+ isBackquoteCodeBlock = !isBackquoteCodeBlock
36
250
  }
37
- if (lines[n].match(/^[ \t]*~~~/)) {
38
- if (isTildeCodeBlock) {
39
- isTildeCodeBlock = false
40
- } else {
41
- isTildeCodeBlock = true
42
- }
251
+ if (tildeFenceReg.test(line)) {
252
+ isTildeCodeBlock = !isTildeCodeBlock
43
253
  }
44
254
  if (isBackquoteCodeBlock || isTildeCodeBlock) {
45
- n++
46
255
  continue
47
256
  }
48
257
 
49
- if (n === 0) {
50
- isPrevBreakLine = true
51
- } else {
52
- isPrevBreakLine = /^[ \t]*$/.test(lines[n-1])
53
- }
54
- if (n === lines.length -1) {
55
- isNextBreakLine = true
56
- } else {
57
- isNextBreakLine = /^[ \t]*$/.test(lines[n+1])
58
- }
258
+ const isPrevBreakLine = (n === 0) ? true : blankLineReg.test(lines[n-1])
259
+ const isNextBreakLine = (n === lines.length -1) ? true : blankLineReg.test(lines[n+1])
59
260
  if (isPrevBreakLine && isNextBreakLine) {
60
- lines[n] = modLines(n ,lines, br, opt)
261
+ if (line.indexOf('![') !== -1 && line.indexOf('](') !== -1) {
262
+ if (!autoLangChecked) {
263
+ const imgLine = line.match(imageLineReg)
264
+ if (imgLine) {
265
+ const rawText = getCaptionTextForDetection(imgLine, opt).trim()
266
+ if (rawText) {
267
+ const detected = detectAutoLang(rawText)
268
+ if (detected) {
269
+ opt.labelLang = detected
270
+ }
271
+ }
272
+ autoLangChecked = true
273
+ }
274
+ }
275
+ if (!labelMeta && (!opt.autoLangDetection || autoLangChecked)) {
276
+ labelMeta = buildLabelMeta(opt)
277
+ }
278
+ if (labelMeta) {
279
+ lines[n] = modLines(n ,lines, br, opt, labelMeta)
280
+ }
281
+ }
61
282
  }
62
- n++
63
283
  }
64
284
 
65
- n = 0
66
- markdown = ''
67
- while (n < lines.length) {
68
- if (n === lines.length - 1) {
69
- markdown += lines[n]
70
- break
285
+ const output = []
286
+ for (let n = 0; n < lines.length; n++) {
287
+ output.push(lines[n])
288
+ if (n < lines.length - 1) {
289
+ output.push(lineBreaks[n] || br)
71
290
  }
72
- markdown += lines[n] + lineBreaks[n]
73
- n++
74
291
  }
75
- return markdown
292
+ return output.join('')
76
293
  }
77
294
 
78
- const modLines = (n, lines, br, opt) => {
79
-
80
- const markAfterNum = '[A-Z0-9]{1,6}(?:[.-][A-Z0-9]{1,6}){0,5}';
81
- const joint = '[.:.。: ]';
82
- const jointFullWidth = '[.。: ]';
83
- const jointHalfWidth = '[.:]';
84
-
85
- const markAfterEn = '(?:' +
86
- ' *(?:' +
87
- jointHalfWidth + '(?:(?=[ ]+)|$)|' +
88
- jointFullWidth + '|' +
89
- '(?=[ ]+[^0-9a-zA-Z])' +
90
- ')|' +
91
- ' *' + '(' + markAfterNum + ')(?:' +
92
- jointHalfWidth + '(?:(?=[ ]+)|$)|' +
93
- jointFullWidth + '|' +
94
- '(?=[ ]+[^a-z])|$' +
95
- ')|' +
96
- '[.](' + markAfterNum + ')(?:' +
97
- joint + '|(?=[ ]+[^a-z])|$' +
98
- ')' +
99
- ')';
100
- const markAfterJa = '(?:' +
101
- ' *(?:' +
102
- jointHalfWidth + '(?:(?=[ ]+)|$)|' +
103
- jointFullWidth + '|' +
104
- '(?=[ ]+)' +
105
- ')|' +
106
- ' *' + '(' + markAfterNum + ')(?:' +
107
- jointHalfWidth + '(?:(?=[ ]+)|$)|' +
108
- jointFullWidth + '|' +
109
- '(?=[ ]+)|$' +
110
- ')' +
111
- ')';
112
-
113
- const labelEn = '(?:[fF][iI][gG](?:[uU][rR][eE])?|[iI][lL]{2}[uU][sS][tT]|[pP][hH][oO][tT][oO])';
114
- const labelJa = '(?:図|イラスト|写真)';
115
-
116
- const markReg = {
117
- //fig(ure)?, illust, photo
118
- "img": new RegExp('^(?:' + labelEn + markAfterEn + '|' + labelJa + markAfterJa +
119
- ')'),
120
- }
121
- const markRegWithNoJoint = {
122
- "img": new RegExp('^(' + labelEn + '|' + labelJa + ')([ .]?' + markAfterNum + ')?$'),
123
- }
124
-
125
- let reg = /^([ \t]*?)!\[ *?(.*?) *?\]\(([^ ]*?)( +"(.*?)")?\)( *(?:{.*?})?)$/
126
-
127
- const imgLine = lines[n].match(reg)
295
+ const modLines = (n, lines, br, opt, labelMeta) => {
296
+ const imgLine = lines[n].match(imageLineReg)
128
297
  if (!imgLine) return lines[n]
129
298
  //console.log(imgLine)
130
299
 
131
- let hasLabel
132
- if (opt.imgAltCaption) hasLabel = imgLine[2].match(new RegExp(markReg.img))
133
- if (opt.imgTitleCaption) hasLabel = imgLine[5].match(new RegExp(markReg.img))
134
- let hasLabelWithNoJoint
135
- if (opt.imgAltCaption) hasLabelWithNoJoint = imgLine[2].match(new RegExp(markRegWithNoJoint.img))
136
- if (opt.imgTitleCaption) hasLabelWithNoJoint = imgLine[5].match(new RegExp(markRegWithNoJoint.img))
300
+ const captionText = getCaptionText(imgLine, opt)
301
+ const hasLabel = captionText ? captionText.match(markReg.img) : null
302
+ const hasLabelWithNoJoint = captionText && labelOnlyReg ? captionText.match(labelOnlyReg) : null
137
303
 
138
304
  lines[n] = imgLine[1]
139
305
  if (hasLabel) {
@@ -154,36 +320,22 @@ const setMarkdownImgAttrToPCaption = (markdown, option) => {
154
320
  } else if (opt.imgTitleCaption) {
155
321
  lines[n] += imgLine[5]
156
322
  }
157
- lines[n] += br + br + imgLine[1] + '![' + hasLabelWithNoJoint[0].replace(new RegExp(joint + '$')) + ']'
323
+ lines[n] += br + br + imgLine[1] + '![' + hasLabelWithNoJoint[0].replace(jointSuffixReg, '') + ']'
158
324
  } else {
159
325
  //console.log('No label::')
160
- if (opt.labelLang === 'ja') {
161
- if (opt.imgAltCaption) {
162
- if (imgLine[2]) {
163
- lines[n] += '図 ' + imgLine[2] + br + br + imgLine[1] + '![]'
164
- } else {
165
- lines[n] += '図' + br + br + imgLine[1] + '![]'
166
- }
167
- } else if (opt.imgTitleCaption) {
168
- if (imgLine[5]) {
169
- lines[n] += '図 ' + imgLine[5] + br + br + imgLine[1] + '![' + imgLine[2] + ']'
170
- } else {
171
- lines[n] += '図' + br + br + imgLine[1] + '![' + imgLine[2] + ']'
172
- }
326
+ const hasCaption = captionText !== ''
327
+ const labelPrefix = buildLabelPrefix(labelMeta, hasCaption)
328
+ if (opt.imgAltCaption) {
329
+ if (hasCaption) {
330
+ lines[n] += labelPrefix + captionText + br + br + imgLine[1] + '![]'
331
+ } else {
332
+ lines[n] += labelPrefix + br + br + imgLine[1] + '![]'
173
333
  }
174
- } else if (opt.labelLang === 'en') {
175
- if (opt.imgAltCaption) {
176
- if (imgLine[2]) {
177
- lines[n] += 'Figure. ' + imgLine[2] + br + br + imgLine[1] +'![]'
178
- } else {
179
- lines[n] += 'Figure.' + br + br + imgLine[1] +'![]'
180
- }
181
- } else if (opt.imgTitleCaption) {
182
- if (imgLine[5]) {
183
- lines[n] += 'Figure. ' + imgLine[5] + br + br + imgLine[1] + '![' + imgLine[2] + ']'
184
- } else {
185
- lines[n] += 'Figure. ' + br + br + imgLine[1] + '![' + imgLine[2] + ']'
186
- }
334
+ } else if (opt.imgTitleCaption) {
335
+ if (hasCaption) {
336
+ lines[n] += labelPrefix + captionText + br + br + imgLine[1] + '![' + imgLine[2] + ']'
337
+ } else {
338
+ lines[n] += labelPrefix + br + br + imgLine[1] + '![' + imgLine[2] + ']'
187
339
  }
188
340
  }
189
341
  }
@@ -191,4 +343,4 @@ const setMarkdownImgAttrToPCaption = (markdown, option) => {
191
343
  return lines[n]
192
344
  }
193
345
 
194
- export default setMarkdownImgAttrToPCaption
346
+ export default setMarkdownImgAttrToPCaption
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@peaceroad/markdown-imgattr-to-pcaption",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Change img alt attribute to figure caption paragraph for p7d-markdown-it-p-captions.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
+ "files": [
8
+ "index.js",
9
+ "README.md",
10
+ "LICENSE",
11
+ "script/"
12
+ ],
7
13
  "scripts": {
8
14
  "test": "node test/test.js"
9
15
  },
@@ -16,5 +22,8 @@
16
22
  "bugs": {
17
23
  "url": "https://github.com/peaceroad/markdown-imgattr-to-pcaption/issues"
18
24
  },
19
- "homepage": "https://github.com/peaceroad/markdown-imgattr-to-pcaption#readme"
25
+ "homepage": "https://github.com/peaceroad/markdown-imgattr-to-pcaption#readme",
26
+ "dependencies": {
27
+ "p7d-markdown-it-p-captions": "^0.20.0"
28
+ }
20
29
  }
@@ -0,0 +1,305 @@
1
+ const whitespaceOnlyReg = /^[\s\u3000]+$/
2
+
3
+ const normalizeBoolean = (value) => {
4
+ if (value === true) return true
5
+ if (value === false) return false
6
+ if (typeof value === 'string') {
7
+ const trimmed = value.trim()
8
+ if (!trimmed) return null
9
+ if (trimmed.toLowerCase() === 'true') return true
10
+ if (trimmed.toLowerCase() === 'false') return false
11
+ return true
12
+ }
13
+ return null
14
+ }
15
+
16
+ const isBlank = (value) => {
17
+ if (!value) return true
18
+ return whitespaceOnlyReg.test(value)
19
+ }
20
+
21
+ const getAttr = (element, name) => {
22
+ const value = element.getAttribute(name)
23
+ return value == null ? '' : value
24
+ }
25
+
26
+ const setTextIfChanged = (element, value) => {
27
+ if (!element) return
28
+ const nextValue = value == null ? '' : String(value)
29
+ if (element.textContent === nextValue) return
30
+ element.textContent = nextValue
31
+ }
32
+
33
+ const createCaption = (documentRef, text) => {
34
+ const caption = documentRef.createElement('figcaption')
35
+ caption.textContent = text
36
+ return caption
37
+ }
38
+
39
+ const readMetaFrontmatter = (readMeta) => {
40
+ if (!readMeta) return null
41
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return null
42
+ const metaTag = document.querySelector('meta[name="markdown-frontmatter"]')
43
+ if (!metaTag) return null
44
+ const content = metaTag.getAttribute('content')
45
+ if (!content) return null
46
+ const parseJson = (value) => {
47
+ try {
48
+ return JSON.parse(value)
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+ let parsed = parseJson(content)
54
+ if (!parsed && content.includes('&quot;')) {
55
+ parsed = parseJson(content.replace(/&quot;/g, '"'))
56
+ }
57
+ return parsed && typeof parsed === 'object' ? parsed : null
58
+ }
59
+
60
+ const applyMetaOptions = (targetOpt, meta, optionOverrides) => {
61
+ if (!meta || typeof meta !== 'object') return
62
+ const extensionSettings = meta._extensionSettings && typeof meta._extensionSettings === 'object'
63
+ ? meta._extensionSettings
64
+ : null
65
+
66
+ const setFlag = (key) => {
67
+ if (optionOverrides.has(key)) return
68
+ const directValue = Object.prototype.hasOwnProperty.call(meta, key)
69
+ ? normalizeBoolean(meta[key])
70
+ : null
71
+ if (directValue !== null) {
72
+ targetOpt[key] = directValue
73
+ return
74
+ }
75
+ if (!extensionSettings || !Object.prototype.hasOwnProperty.call(extensionSettings, key)) return
76
+ const extValue = normalizeBoolean(extensionSettings[key])
77
+ if (extValue !== null) {
78
+ targetOpt[key] = extValue
79
+ }
80
+ }
81
+
82
+ setFlag('imgAltCaption')
83
+ setFlag('imgTitleCaption')
84
+ }
85
+
86
+ const getCaptionText = (img, opt) => {
87
+ if (!opt.imgAltCaption && !opt.imgTitleCaption) return ''
88
+ const alt = getAttr(img, 'alt')
89
+ const title = getAttr(img, 'title')
90
+ if (opt.imgAltCaption && opt.imgTitleCaption) {
91
+ if (opt.preferAlt) return alt || title
92
+ return title || alt
93
+ }
94
+ if (opt.imgAltCaption) return alt
95
+ if (opt.imgTitleCaption) return title
96
+ return ''
97
+ }
98
+
99
+ const updateFigure = (img, captionText, opt) => {
100
+ const figure = img.closest('figure')
101
+ if (figure) {
102
+ const figcaption = figure.querySelector('figcaption')
103
+ if (!captionText || isBlank(captionText)) {
104
+ if (figcaption && figcaption.parentNode) {
105
+ figcaption.parentNode.removeChild(figcaption)
106
+ }
107
+ return
108
+ }
109
+ if (figcaption) {
110
+ setTextIfChanged(figcaption, captionText)
111
+ } else {
112
+ figure.appendChild(createCaption(figure.ownerDocument, captionText))
113
+ }
114
+ return
115
+ }
116
+
117
+ if (!captionText || isBlank(captionText)) return
118
+ const parent = img.parentNode
119
+ if (!parent) return
120
+ const figureEl = img.ownerDocument.createElement('figure')
121
+ if (opt.figureClass) {
122
+ figureEl.className = opt.figureClass
123
+ }
124
+ parent.insertBefore(figureEl, img)
125
+ figureEl.appendChild(img)
126
+ figureEl.appendChild(createCaption(figureEl.ownerDocument, captionText))
127
+ }
128
+
129
+ const processImages = (images, opt) => {
130
+ if (!images) return []
131
+ const processed = []
132
+ for (const img of images) {
133
+ if (!img || img.nodeType !== 1 || img.tagName !== 'IMG') continue
134
+ const captionText = getCaptionText(img, opt)
135
+ updateFigure(img, captionText, opt)
136
+ processed.push(img)
137
+ }
138
+ return processed
139
+ }
140
+
141
+ export default async function setImgFigureCaption(option = {}) {
142
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return []
143
+
144
+ const opt = {
145
+ imgAltCaption: false,
146
+ imgTitleCaption: false,
147
+ preferAlt: true,
148
+ figureClass: 'f-img',
149
+ readMeta: false,
150
+ observe: false,
151
+ }
152
+ Object.assign(opt, option)
153
+ const optionOverrides = new Set(Object.keys(option || {}))
154
+
155
+ const buildContext = () => {
156
+ const currentOpt = { ...opt }
157
+ const meta = readMetaFrontmatter(currentOpt.readMeta)
158
+ if (meta) {
159
+ applyMetaOptions(currentOpt, meta, optionOverrides)
160
+ }
161
+ return { opt: currentOpt }
162
+ }
163
+
164
+ const runProcess = (targets = null) => {
165
+ const { opt: currentOpt } = buildContext()
166
+ if (!currentOpt.imgAltCaption && !currentOpt.imgTitleCaption) return []
167
+ const images = targets
168
+ ? Array.from(targets)
169
+ : Array.from(document.querySelectorAll('img'))
170
+ return processImages(images, currentOpt)
171
+ }
172
+
173
+ if (!opt.observe || typeof MutationObserver !== 'function') {
174
+ return runProcess()
175
+ }
176
+
177
+ let scheduled = false
178
+ let running = false
179
+ let pending = false
180
+ let pendingAll = false
181
+ const pendingImages = new Set()
182
+ let observer = null
183
+
184
+ const scheduleProcess = () => {
185
+ if (scheduled) return
186
+ scheduled = true
187
+ const run = () => {
188
+ scheduled = false
189
+ runProcessLoop()
190
+ }
191
+ if (typeof requestAnimationFrame === 'function') {
192
+ requestAnimationFrame(run)
193
+ } else {
194
+ setTimeout(run, 50)
195
+ }
196
+ }
197
+
198
+ const runProcessLoop = async () => {
199
+ if (running) {
200
+ pending = true
201
+ return
202
+ }
203
+ running = true
204
+ do {
205
+ pending = false
206
+ const targets = pendingAll ? null : Array.from(pendingImages)
207
+ pendingAll = false
208
+ pendingImages.clear()
209
+ runProcess(targets)
210
+ } while (pending)
211
+ running = false
212
+ }
213
+
214
+ const isElementNode = (node) => node && node.nodeType === 1
215
+ const isMetaNode = (node) => {
216
+ if (!opt.readMeta || !isElementNode(node)) return false
217
+ return node.tagName === 'META'
218
+ && node.getAttribute('name') === 'markdown-frontmatter'
219
+ }
220
+ const isImageNode = (node) => isElementNode(node) && node.tagName === 'IMG'
221
+
222
+ const collectImagesFromNodes = (nodes) => {
223
+ if (!nodes) return
224
+ for (const node of nodes) {
225
+ if (!isElementNode(node)) continue
226
+ if (node.tagName === 'FIGCAPTION') continue
227
+ if (isImageNode(node)) {
228
+ pendingImages.add(node)
229
+ continue
230
+ }
231
+ if (node.querySelectorAll) {
232
+ const images = node.querySelectorAll('img')
233
+ for (const image of images) pendingImages.add(image)
234
+ }
235
+ }
236
+ }
237
+
238
+ const hasMetaInNodes = (nodes) => {
239
+ if (!opt.readMeta || !nodes) return false
240
+ for (const node of nodes) {
241
+ if (!isElementNode(node)) continue
242
+ if (isMetaNode(node)) return true
243
+ if (node.querySelector && node.querySelector('meta[name="markdown-frontmatter"]')) return true
244
+ }
245
+ return false
246
+ }
247
+
248
+ const attributeFilter = ['alt', 'title']
249
+ if (opt.readMeta) attributeFilter.push('content')
250
+
251
+ if (!observer) {
252
+ const root = document.documentElement || document.body
253
+ if (root) {
254
+ observer = new MutationObserver((mutations) => {
255
+ let shouldSchedule = false
256
+ let metaChanged = false
257
+ for (const mutation of mutations) {
258
+ if (!mutation) continue
259
+ if (mutation.type === 'attributes') {
260
+ const target = mutation.target
261
+ if (isImageNode(target) && ['alt', 'title'].includes(mutation.attributeName)) {
262
+ pendingImages.add(target)
263
+ shouldSchedule = true
264
+ continue
265
+ }
266
+ if (isMetaNode(target) && mutation.attributeName === 'content') {
267
+ metaChanged = true
268
+ shouldSchedule = true
269
+ continue
270
+ }
271
+ continue
272
+ }
273
+ if (mutation.type !== 'childList') continue
274
+ if (mutation.addedNodes && mutation.addedNodes.length > 0) {
275
+ collectImagesFromNodes(mutation.addedNodes)
276
+ if (pendingImages.size > 0) shouldSchedule = true
277
+ if (hasMetaInNodes(mutation.addedNodes)) {
278
+ metaChanged = true
279
+ shouldSchedule = true
280
+ }
281
+ }
282
+ if (mutation.removedNodes && mutation.removedNodes.length > 0) {
283
+ if (hasMetaInNodes(mutation.removedNodes)) {
284
+ metaChanged = true
285
+ shouldSchedule = true
286
+ }
287
+ }
288
+ }
289
+ if (metaChanged) {
290
+ pendingAll = true
291
+ pendingImages.clear()
292
+ }
293
+ if (shouldSchedule) scheduleProcess()
294
+ })
295
+ observer.observe(root, {
296
+ childList: true,
297
+ subtree: true,
298
+ attributes: true,
299
+ attributeFilter,
300
+ })
301
+ }
302
+ }
303
+
304
+ return runProcess()
305
+ }
@@ -1,87 +0,0 @@
1
- [Input]
2
- 段落。段落。段落。
3
-
4
- ![ALT](image.jpg "キャプション")
5
-
6
- 段落。段落。段落。
7
-
8
-
9
- [Output]
10
- 段落。段落。段落。
11
-
12
- 図 キャプション
13
-
14
- ![ALT](image.jpg)
15
-
16
- 段落。段落。段落。
17
-
18
-
19
- [Input]
20
- 段落。段落。段落。
21
-
22
- ![ALT](image.jpg "図 キャプション")
23
-
24
- 段落。段落。段落。
25
-
26
- [Output]
27
- 段落。段落。段落。
28
-
29
- 図 キャプション
30
-
31
- ![ALT](image.jpg)
32
-
33
- 段落。段落。段落。
34
-
35
-
36
-
37
- [Input]
38
- 段落。段落。段落。
39
-
40
- ![ALT](image.jpg "図1 キャプション")
41
-
42
- 段落。段落。段落。
43
-
44
- [Output]
45
- 段落。段落。段落。
46
-
47
- 図1 キャプション
48
-
49
- ![ALT](image.jpg)
50
-
51
- 段落。段落。段落。
52
-
53
-
54
-
55
- [Input]
56
- 段落。段落。段落。
57
-
58
- ![ALT](image.jpg "図1 キャプション")
59
-
60
- 段落。段落。段落。
61
-
62
- [Output]
63
- 段落。段落。段落。
64
-
65
- 図1 キャプション
66
-
67
- ![ALT](image.jpg)
68
-
69
- 段落。段落。段落。
70
-
71
-
72
-
73
- [Input]
74
- 段落。段落。段落。
75
-
76
- ![ALT](image.jpg "図1:キャプション")
77
-
78
- 段落。段落。段落。
79
-
80
- [Output]
81
- 段落。段落。段落。
82
-
83
- 図1:キャプション
84
-
85
- ![ALT](image.jpg)
86
-
87
- 段落。段落。段落。
@@ -1,16 +0,0 @@
1
- [Input]
2
- 段落。段落。段落。
3
-
4
- ![キャプション](image.jpg)
5
-
6
- 段落。段落。段落。
7
-
8
-
9
- [Output]
10
- 段落。段落。段落。
11
-
12
- Figure. キャプション
13
-
14
- ![](image.jpg)
15
-
16
- 段落。段落。段落。
package/test/examples.txt DELETED
@@ -1,88 +0,0 @@
1
- [Input]
2
- 段落。段落。段落。
3
-
4
- ![キャプション](image.jpg)
5
-
6
- 段落。段落。段落。
7
-
8
-
9
- [Output]
10
- 段落。段落。段落。
11
-
12
- 図 キャプション
13
-
14
- ![](image.jpg)
15
-
16
- 段落。段落。段落。
17
-
18
-
19
- [Input]
20
- 段落。段落。段落。
21
-
22
- ![図 キャプション](image.jpg)
23
-
24
- 段落。段落。段落。
25
-
26
- [Output]
27
- 段落。段落。段落。
28
-
29
- 図 キャプション
30
-
31
- ![](image.jpg)
32
-
33
- 段落。段落。段落。
34
-
35
-
36
-
37
- [Input]
38
- 段落。段落。段落。
39
-
40
- ![図1 キャプション](image.jpg)
41
-
42
- 段落。段落。段落。
43
-
44
- [Output]
45
- 段落。段落。段落。
46
-
47
- 図1 キャプション
48
-
49
- ![](image.jpg)
50
-
51
- 段落。段落。段落。
52
-
53
-
54
-
55
- [Input]
56
- 段落。段落。段落。
57
-
58
- ![図1 キャプション](image.jpg)
59
-
60
- 段落。段落。段落。
61
-
62
- [Output]
63
- 段落。段落。段落。
64
-
65
- 図1 キャプション
66
-
67
- ![](image.jpg)
68
-
69
- 段落。段落。段落。
70
-
71
-
72
- [Input]
73
- 段落。段落。段落。
74
-
75
- ![図1:キャプション](image.jpg)
76
-
77
- 段落。段落。段落。
78
-
79
- [Output]
80
- 段落。段落。段落。
81
-
82
- 図1:キャプション
83
-
84
- ![](image.jpg)
85
-
86
- 段落。段落。段落。
87
-
88
-
package/test/test.js DELETED
@@ -1,77 +0,0 @@
1
- import assert from 'assert'
2
- import fs from 'fs'
3
- import path from 'path'
4
- import setMarkdownImgAttrToPCaption from '../index.js'
5
-
6
- let __dirname = path.dirname(new URL(import.meta.url).pathname)
7
- const isWindows = (process.platform === 'win32')
8
- if (isWindows) {
9
- __dirname = __dirname.replace(/^\/+/, '').replace(/\//g, '\\')
10
- }
11
-
12
- const check = (name, ex) => {
13
- const exCont = fs.readFileSync(ex, 'utf-8').trim()
14
- let ms = [];
15
- let ms0 = exCont.split(/\n*\[Input\]\n/)
16
- let n = 1;
17
- while(n < ms0.length) {
18
- let mhs = ms0[n].split(/\n+\[Output[^\]]*?\]\n/)
19
- let i = 1
20
- while (i < 2) {
21
- if (mhs[i] === undefined) {
22
- mhs[i] = ''
23
- } else {
24
- mhs[i] = mhs[i].replace(/$/,'\n')
25
- }
26
- i++
27
- }
28
- ms[n] = {
29
- inputMarkdown: mhs[0].trim(),
30
- outputMarkdown: mhs[1].trim(),
31
- };
32
- n++
33
- }
34
-
35
- n = 1
36
- while(n < ms.length) {
37
- //if (n !== 10) { n++; continue }
38
- console.log('Test: ' + n + ' >>>')
39
- const m = ms[n].inputMarkdown
40
- let h
41
- let option = {}
42
- if (name === 'default') {
43
- h = setMarkdownImgAttrToPCaption(m)
44
- }
45
-
46
-
47
- if (name === 'imgTitleAttr') {
48
- h = setMarkdownImgAttrToPCaption(m, {imgTitleCaption: true})
49
- }
50
-
51
- if (name === 'labelLang') {
52
- h = setMarkdownImgAttrToPCaption(m, {labelLang: 'en'})
53
- }
54
-
55
-
56
- try {
57
- assert.strictEqual(h, ms[n].outputMarkdown)
58
- } catch(e) {
59
- console.log('incorrect: ')
60
- //console.log(m)
61
- //console.log('::convert ->')
62
- console.log('H: ' + h +'\n\nC: ' + ms[n].outputMarkdown)
63
- }
64
- n++
65
- }
66
- }
67
-
68
-
69
- const example = {
70
- default: __dirname + path.sep + 'examples.txt',
71
- imgTitleAttr: __dirname + path.sep + 'examples-img-title-attr.txt',
72
- labelLang: __dirname + path.sep + 'examples-label-lang.txt',
73
- }
74
- for (let ex in example) {
75
- console.log('[Test] ' + ex)
76
- check(ex, example[ex])
77
- }