@peaceroad/markdown-it-figure-with-p-caption 0.18.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.
@@ -0,0 +1,466 @@
1
+ import { isCaptionLabelBoundary } from 'p7d-markdown-it-p-captions'
2
+ import {
3
+ createScopedNumberingContext,
4
+ createUnscopedNumberingContext,
5
+ } from './number-codec.js'
6
+ import {
7
+ getFigureCaptionNumberingPolicyState,
8
+ normalizeNumberingScopeMode,
9
+ normalizeNumberingSeparator,
10
+ } from './options.js'
11
+
12
+ const captionNumberSegmentReg = /^[A-Z0-9]{1,6}$/
13
+ const maxScopeKeyLength = 256
14
+ const maxScopeVisiblePrefixLength = 128
15
+ const frontmatterNumberingKey = 'figure-caption-numbering'
16
+ const frontmatterNumberingScopeKey = frontmatterNumberingKey + '.scope'
17
+ const frontmatterNumberingSeparatorKey = frontmatterNumberingKey + '.separator'
18
+ const scopeTransparentInlineTokenTypes = new Set([
19
+ 'em_open', 'em_close',
20
+ 'strong_open', 'strong_close',
21
+ 's_open', 's_close',
22
+ 'link_open', 'link_close',
23
+ ])
24
+ const emptyScopeBoundaries = Object.freeze([])
25
+
26
+ const hasOwnOption = (option, name) => !!(
27
+ option && Object.prototype.hasOwnProperty.call(option, name)
28
+ )
29
+
30
+ export const getFigureCaptionScopeNestedContainerType = (token) => {
31
+ if (!token) return null
32
+ switch (token.type) {
33
+ case 'blockquote_open':
34
+ return 'blockquote'
35
+ case 'list_item_open':
36
+ return 'list_item'
37
+ case 'dd_open':
38
+ return 'dd'
39
+ default:
40
+ return null
41
+ }
42
+ }
43
+
44
+ const startsWithAsciiCaseInsensitive = (text, expectedLowerCase) => {
45
+ if (text.length < expectedLowerCase.length) return false
46
+ for (let index = 0; index < expectedLowerCase.length; index++) {
47
+ let code = text.charCodeAt(index)
48
+ if (code >= 0x41 && code <= 0x5a) code += 0x20
49
+ if (code !== expectedLowerCase.charCodeAt(index)) return false
50
+ }
51
+ return true
52
+ }
53
+
54
+ const readAsciiDigits = (text, start) => {
55
+ let end = start
56
+ while (end < text.length && end - start < 6) {
57
+ const code = text.charCodeAt(end)
58
+ if (code < 0x30 || code > 0x39) break
59
+ end++
60
+ }
61
+ return end === start ? null : { id: text.slice(start, end), end }
62
+ }
63
+
64
+ const readAppendixIdentifier = (text, start) => {
65
+ const digits = readAsciiDigits(text, start)
66
+ if (digits) return digits
67
+ const code = text.charCodeAt(start)
68
+ if (code >= 0x41 && code <= 0x5a) {
69
+ return { id: text.charAt(start), end: start + 1 }
70
+ }
71
+ return null
72
+ }
73
+
74
+ const matchScopeMarker = (text) => {
75
+ if (typeof text !== 'string' || !text) return null
76
+ const first = text.charAt(0)
77
+ if (first === 'C' || first === 'c') {
78
+ if (!startsWithAsciiCaseInsensitive(text, 'chapter ')) return null
79
+ const identifier = readAsciiDigits(text, 8)
80
+ return identifier && {
81
+ kind: 'chapter',
82
+ id: identifier.id,
83
+ markerEnd: identifier.end,
84
+ layout: 'spaced',
85
+ }
86
+ }
87
+ if (first === 'A' || first === 'a') {
88
+ if (!startsWithAsciiCaseInsensitive(text, 'appendix ')) return null
89
+ const identifier = readAppendixIdentifier(text, 9)
90
+ return identifier && {
91
+ kind: 'appendix',
92
+ id: identifier.id,
93
+ markerEnd: identifier.end,
94
+ layout: 'spaced',
95
+ }
96
+ }
97
+ if (first === '第') {
98
+ const identifier = readAsciiDigits(text, 1)
99
+ if (!identifier || text.charAt(identifier.end) !== '章') return null
100
+ return {
101
+ kind: 'chapter',
102
+ id: identifier.id,
103
+ markerEnd: identifier.end + 1,
104
+ layout: 'compact',
105
+ }
106
+ }
107
+ const firstCode = text.charCodeAt(0)
108
+ if (firstCode >= 0x30 && firstCode <= 0x39) {
109
+ const identifier = readAsciiDigits(text, 0)
110
+ if (!identifier || text.charAt(identifier.end) !== '章') return null
111
+ return {
112
+ kind: 'chapter',
113
+ id: identifier.id,
114
+ markerEnd: identifier.end + 1,
115
+ layout: 'compact',
116
+ }
117
+ }
118
+ let prefix = ''
119
+ if (text.startsWith('付録')) prefix = '付録'
120
+ else if (text.startsWith('付属')) prefix = '付属'
121
+ else if (text.startsWith('附属')) prefix = '附属'
122
+ if (!prefix) return null
123
+ const identifier = readAppendixIdentifier(text, prefix.length)
124
+ return identifier && {
125
+ kind: 'appendix',
126
+ id: identifier.id,
127
+ markerEnd: identifier.end,
128
+ layout: 'compact',
129
+ }
130
+ }
131
+
132
+ const buildScopeSemanticResult = (candidate) => ({
133
+ scopeKey: candidate.kind + ':' + candidate.id,
134
+ displayPrefix: candidate.id,
135
+ })
136
+
137
+ const recognizeScopeText = (text, requireVisibleTail) => {
138
+ const candidate = matchScopeMarker(text)
139
+ if (!candidate) return null
140
+ if (requireVisibleTail && candidate.markerEnd === text.length) return null
141
+ if (!isCaptionLabelBoundary(text, candidate.markerEnd, {
142
+ layout: candidate.layout,
143
+ hasNumber: true,
144
+ })) return null
145
+ return buildScopeSemanticResult(candidate)
146
+ }
147
+
148
+ const recognizeScopeFromInline = (inlineToken) => {
149
+ if (!inlineToken || inlineToken.type !== 'inline' || !Array.isArray(inlineToken.children)) return null
150
+ const children = inlineToken.children
151
+ if (children.length === 0 || !children[0] || children[0].type !== 'text') return null
152
+ if (typeof children[0].content !== 'string' || children[0].content.length === 0) return null
153
+ let visiblePrefix = ''
154
+ for (let index = 0; index < children.length; index++) {
155
+ const child = children[index]
156
+ if (!child) return null
157
+ if (child.type === 'text') {
158
+ const content = typeof child.content === 'string' ? child.content : ''
159
+ const remaining = maxScopeVisiblePrefixLength - visiblePrefix.length
160
+ if (remaining <= 0) return null
161
+ visiblePrefix += content.slice(0, remaining)
162
+ const result = recognizeScopeText(visiblePrefix, true)
163
+ // A half-width joint at this token boundary is not conclusive because
164
+ // later visible text can turn `Chapter 1:` into `Chapter 1:st`.
165
+ const lastCode = visiblePrefix.charCodeAt(visiblePrefix.length - 1)
166
+ if (result && lastCode !== 0x2e && lastCode !== 0x3a) return result
167
+ if (content.length > remaining) return null
168
+ continue
169
+ }
170
+ if (scopeTransparentInlineTokenTypes.has(child.type)) continue
171
+ return null
172
+ }
173
+ return recognizeScopeText(visiblePrefix, false)
174
+ }
175
+
176
+ const normalizeFigureSequenceKey = (value, optionName) => {
177
+ if (typeof value === 'string') {
178
+ if (!value || value.length > maxScopeKeyLength) {
179
+ throw new RangeError(`${optionName} must be a non-empty string of at most ${maxScopeKeyLength} UTF-16 code units.`)
180
+ }
181
+ return value
182
+ }
183
+ if (typeof value === 'number' && Number.isFinite(value)) return value
184
+ throw new TypeError(`${optionName} must be a non-empty string or a finite number.`)
185
+ }
186
+
187
+ const getFrontmatterNumberingOverride = (env) => {
188
+ const frontmatter = env && env.frontmatter
189
+ if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) return null
190
+ const hasNested = hasOwnOption(frontmatter, frontmatterNumberingKey)
191
+ const hasDottedScope = hasOwnOption(frontmatter, frontmatterNumberingScopeKey)
192
+ const hasDottedSeparator = hasOwnOption(frontmatter, frontmatterNumberingSeparatorKey)
193
+ if (!hasNested && !hasDottedScope && !hasDottedSeparator) return null
194
+ const value = hasNested ? frontmatter[frontmatterNumberingKey] : null
195
+ const optionName = `env.frontmatter["${frontmatterNumberingKey}"]`
196
+ if (hasNested && (!value || typeof value !== 'object' || Array.isArray(value))) {
197
+ throw new TypeError(`${optionName} must be an object.`)
198
+ }
199
+ if (hasNested) {
200
+ const keys = Object.keys(value)
201
+ for (let index = 0; index < keys.length; index++) {
202
+ const key = keys[index]
203
+ if (key !== 'scope' && key !== 'separator') {
204
+ throw new TypeError(`${optionName}.${key} is not supported.`)
205
+ }
206
+ }
207
+ }
208
+ const normalized = {}
209
+ if (hasNested && hasOwnOption(value, 'scope')) {
210
+ if (hasDottedScope) {
211
+ throw new TypeError(`${optionName}.scope is defined more than once.`)
212
+ }
213
+ normalized.scope = normalizeNumberingScopeMode(value.scope, `${optionName}.scope`)
214
+ } else if (hasDottedScope) {
215
+ normalized.scope = normalizeNumberingScopeMode(
216
+ frontmatter[frontmatterNumberingScopeKey],
217
+ `env.frontmatter["${frontmatterNumberingScopeKey}"]`,
218
+ )
219
+ }
220
+ if (hasNested && hasOwnOption(value, 'separator')) {
221
+ if (hasDottedSeparator) {
222
+ throw new TypeError(`${optionName}.separator is defined more than once.`)
223
+ }
224
+ normalized.separator = normalizeNumberingSeparator(
225
+ value.separator,
226
+ `${optionName}.separator`,
227
+ )
228
+ } else if (hasDottedSeparator) {
229
+ normalized.separator = normalizeNumberingSeparator(
230
+ frontmatter[frontmatterNumberingSeparatorKey],
231
+ `env.frontmatter["${frontmatterNumberingSeparatorKey}"]`,
232
+ )
233
+ }
234
+ return normalized
235
+ }
236
+
237
+ const normalizeExplicitScopeOverride = (value, separator) => {
238
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
239
+ throw new TypeError('env.figureCaptionNumbering.scope must be an object.')
240
+ }
241
+ const scopeKey = normalizeFigureSequenceKey(value.scopeKey, 'env.figureCaptionNumbering.scope.scopeKey')
242
+ if (typeof scopeKey !== 'string') {
243
+ throw new TypeError('env.figureCaptionNumbering.scope.scopeKey must be a non-empty string.')
244
+ }
245
+ if (typeof value.displayPrefix !== 'string' || !captionNumberSegmentReg.test(value.displayPrefix)) {
246
+ throw new TypeError('env.figureCaptionNumbering.scope.displayPrefix must match the caption number segment grammar.')
247
+ }
248
+ const sequenceKey = value.sequenceKey === undefined
249
+ ? scopeKey
250
+ : normalizeFigureSequenceKey(value.sequenceKey, 'env.figureCaptionNumbering.scope.sequenceKey')
251
+ return createScopedNumberingContext(
252
+ scopeKey,
253
+ sequenceKey,
254
+ value.displayPrefix,
255
+ separator,
256
+ )
257
+ }
258
+
259
+ const applySemanticScope = (scopeState, semanticScope) => {
260
+ const sequenceKey = scopeState.repeatScope === 'reset'
261
+ ? scopeState.nextResetSequenceKey++
262
+ : semanticScope.scopeKey
263
+ scopeState.currentContext = createScopedNumberingContext(
264
+ semanticScope.scopeKey,
265
+ sequenceKey,
266
+ semanticScope.displayPrefix,
267
+ scopeState.separator,
268
+ )
269
+ }
270
+
271
+ const findRawFrontmatterToken = (tokens) => {
272
+ const firstToken = tokens && tokens[0]
273
+ return firstToken && firstToken.type === 'front_matter' ? firstToken : null
274
+ }
275
+
276
+ const resolveInitialFrontmatterScope = (state, scopeConfig) => {
277
+ const env = state.env && typeof state.env === 'object' ? state.env : null
278
+ const parsedTitle = env && env.frontmatter && typeof env.frontmatter === 'object'
279
+ ? env.frontmatter.title
280
+ : null
281
+ if (typeof parsedTitle === 'string') {
282
+ const prefix = parsedTitle.slice(0, maxScopeVisiblePrefixLength)
283
+ const recognized = recognizeScopeText(prefix, parsedTitle.length > prefix.length)
284
+ if (recognized) return recognized
285
+ }
286
+ if (!scopeConfig.resolveFrontmatterTitle) return null
287
+ const token = findRawFrontmatterToken(state.tokens)
288
+ if (!token) return null
289
+ let title
290
+ try {
291
+ const raw = typeof token.meta === 'string'
292
+ ? token.meta
293
+ : (typeof token.content === 'string' ? token.content : '')
294
+ title = scopeConfig.resolveFrontmatterTitle(raw, state)
295
+ } catch (_err) {
296
+ return null
297
+ }
298
+ if (typeof title !== 'string') return null
299
+ const prefix = title.slice(0, maxScopeVisiblePrefixLength)
300
+ return recognizeScopeText(prefix, title.length > prefix.length)
301
+ }
302
+
303
+ const createFigureCaptionScopeRuntimeFromPolicyState = (
304
+ state,
305
+ advancedPolicy,
306
+ unscopedContext,
307
+ ) => {
308
+ const scopeConfig = advancedPolicy.scope
309
+ if (!scopeConfig) return null
310
+ const env = state.env && typeof state.env === 'object' ? state.env : null
311
+ const frontmatterOverride = getFrontmatterNumberingOverride(env)
312
+ let separator = hasOwnOption(frontmatterOverride, 'separator')
313
+ ? frontmatterOverride.separator
314
+ : advancedPolicy.separator
315
+ let scopeMode = hasOwnOption(frontmatterOverride, 'scope')
316
+ ? frontmatterOverride.scope
317
+ : 'auto'
318
+ let explicitScope = null
319
+ let hasExplicitScope = false
320
+ if (env && hasOwnOption(env, 'figureCaptionNumbering')) {
321
+ const namespace = env.figureCaptionNumbering
322
+ if (!namespace || typeof namespace !== 'object' || Array.isArray(namespace)) {
323
+ throw new TypeError('env.figureCaptionNumbering must be an object.')
324
+ }
325
+ if (hasOwnOption(namespace, 'separator')) {
326
+ separator = normalizeNumberingSeparator(
327
+ namespace.separator,
328
+ 'env.figureCaptionNumbering.separator',
329
+ )
330
+ }
331
+ if (hasOwnOption(namespace, 'scope')) {
332
+ if (typeof namespace.scope === 'string') {
333
+ scopeMode = normalizeNumberingScopeMode(
334
+ namespace.scope,
335
+ 'env.figureCaptionNumbering.scope',
336
+ )
337
+ } else {
338
+ explicitScope = namespace.scope
339
+ hasExplicitScope = true
340
+ }
341
+ }
342
+ }
343
+ const currentUnscopedContext = separator === advancedPolicy.separator && unscopedContext
344
+ ? unscopedContext
345
+ : createUnscopedNumberingContext(separator)
346
+ const scopeState = {
347
+ separator,
348
+ repeatScope: scopeConfig.repeatScope,
349
+ nextResetSequenceKey: 1,
350
+ currentContext: currentUnscopedContext,
351
+ fixed: false,
352
+ scopeConfig,
353
+ }
354
+ if (hasExplicitScope) {
355
+ scopeState.currentContext = normalizeExplicitScopeOverride(explicitScope, separator)
356
+ scopeState.fixed = true
357
+ return scopeState
358
+ }
359
+ if (scopeMode === 'document') {
360
+ scopeState.fixed = true
361
+ return scopeState
362
+ }
363
+ if (scopeConfig.usesFrontmatter) {
364
+ const initialScope = resolveInitialFrontmatterScope(state, scopeConfig)
365
+ if (initialScope) applySemanticScope(scopeState, initialScope)
366
+ }
367
+ return scopeState
368
+ }
369
+
370
+ export const createFigureCaptionScopeRuntime = (
371
+ state,
372
+ numberingPolicy,
373
+ unscopedContext,
374
+ ) => {
375
+ if (!numberingPolicy) return null
376
+ return createFigureCaptionScopeRuntimeFromPolicyState(
377
+ state,
378
+ getFigureCaptionNumberingPolicyState(numberingPolicy),
379
+ unscopedContext,
380
+ )
381
+ }
382
+
383
+ export const updateFigureCaptionScopeFromHeading = (tokens, index, scopeState) => {
384
+ const token = tokens[index]
385
+ if (!token || typeof token.tag !== 'string') return false
386
+ const level = token.tag.length === 2 && token.tag.charCodeAt(0) === 0x68
387
+ ? token.tag.charCodeAt(1) - 0x30
388
+ : 0
389
+ if (!scopeState.scopeConfig.headingLevelLookup[level]) return false
390
+ const semanticScope = recognizeScopeFromInline(tokens[index + 1])
391
+ if (!semanticScope) return false
392
+ applySemanticScope(scopeState, semanticScope)
393
+ return true
394
+ }
395
+
396
+ const isValidSourceMap = (map) => (
397
+ Array.isArray(map) &&
398
+ Number.isSafeInteger(map[0]) &&
399
+ Number.isSafeInteger(map[1]) &&
400
+ map[0] >= 0 &&
401
+ map[1] > map[0]
402
+ )
403
+
404
+ export const createFigureCaptionScopeTimeline = (state, numberingPolicy) => {
405
+ if (!state || typeof state !== 'object' || !Array.isArray(state.tokens)) {
406
+ throw new TypeError('state must be a markdown-it StateCore with a tokens array.')
407
+ }
408
+ if (!numberingPolicy) return null
409
+ const advancedPolicy = getFigureCaptionNumberingPolicyState(numberingPolicy)
410
+ if (!advancedPolicy.scope) {
411
+ return Object.freeze({
412
+ initialContext: createUnscopedNumberingContext(advancedPolicy.separator),
413
+ boundaries: emptyScopeBoundaries,
414
+ hasUnmappableBoundaries: false,
415
+ })
416
+ }
417
+ const scopeState = createFigureCaptionScopeRuntimeFromPolicyState(
418
+ state,
419
+ advancedPolicy,
420
+ null,
421
+ )
422
+ const initialContext = scopeState.currentContext
423
+ let boundaries = null
424
+ let hasUnmappableBoundaries = false
425
+ if (!scopeState.fixed && scopeState.scopeConfig.usesHeading) {
426
+ const nestedContainerStack = []
427
+ for (let index = 0; index < state.tokens.length; index++) {
428
+ const token = state.tokens[index]
429
+ const containerType = getFigureCaptionScopeNestedContainerType(token)
430
+ if (containerType) {
431
+ nestedContainerStack.push(containerType + '_close')
432
+ continue
433
+ }
434
+ if (nestedContainerStack.length > 0) {
435
+ const parentCloseType = nestedContainerStack[nestedContainerStack.length - 1]
436
+ if (token && token.type === parentCloseType) {
437
+ nestedContainerStack.pop()
438
+ }
439
+ continue
440
+ }
441
+ if (
442
+ !token ||
443
+ token.type !== 'heading_open' ||
444
+ !updateFigureCaptionScopeFromHeading(state.tokens, index, scopeState)
445
+ ) {
446
+ continue
447
+ }
448
+ if (!isValidSourceMap(token.map)) {
449
+ hasUnmappableBoundaries = true
450
+ continue
451
+ }
452
+ if (!boundaries) boundaries = []
453
+ boundaries.push(Object.freeze({
454
+ tokenIndex: index,
455
+ sourceStartLine: token.map[0],
456
+ sourceEndLine: token.map[1],
457
+ context: scopeState.currentContext,
458
+ }))
459
+ }
460
+ }
461
+ return Object.freeze({
462
+ initialContext,
463
+ boundaries: boundaries ? Object.freeze(boundaries) : emptyScopeBoundaries,
464
+ hasUnmappableBoundaries,
465
+ })
466
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ normalizeFigureCaptionNumberingPolicy,
3
+ } from './caption-numbering/options.js'
4
+ export {
5
+ createFigureCaptionNumberCodec,
6
+ } from './caption-numbering/number-codec.js'
7
+ export {
8
+ createFigureCaptionCounterKeyResolver,
9
+ } from './caption-numbering/counter-series.js'
10
+ export {
11
+ createFigureCaptionScopeTimeline,
12
+ } from './caption-numbering/scope.js'