@peaceroad/markdown-it-footnote-here 0.3.2 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +17 -3
  2. package/index.js +248 -80
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,9 @@ HTML:
26
26
  <p>A paragraph.</p>
27
27
  ```
28
28
 
29
- Notice. When multiple instances of the same footnote number appear in the main content, the default behavior is that the backlink from the footnote will refer to the first instance.
29
+ Notice.
30
+ - When multiple instances of the same footnote number appear in the main content, the default behavior is that the backlink from the footnote will refer to the first instance.
31
+ - When the same footnote/endnote label is defined multiple times, behavior is controlled by `duplicateDefinitionPolicy` (default: `warn`).
30
32
 
31
33
  ## Endnotes
32
34
 
@@ -74,12 +76,14 @@ npm install @peaceroad/markdown-it-footnote-here
74
76
 
75
77
  ## Options
76
78
 
77
- - beforeSameBacklink (boolean): false by default. When true, duplicate footnote references will use letter suffixes (a, b, c, ...) and generate matching backlinks in footnote definitions.
79
+ - beforeSameBacklink (boolean): false by default. When true, duplicate footnote references will use suffixes (a, b, ... z, aa, ab, ...) and generate matching backlinks in footnote definitions.
78
80
  - afterBacklink (boolean): false by default. If true, backlinks (↩) are placed at the end of the footnote content instead of before it.
81
+ - Note: If `beforeSameBacklink` is also true, both backlink styles are rendered (before-label links and trailing ↩ links). Use one style if you need a cleaner output.
79
82
  - afterBacklinkContent (string): The content for the backlink (default: '↩').
80
83
  - afterBacklinkWithNumber (boolean): If true, backlink will show a number or letter suffix.
81
84
  - afterBacklinkSuffixArabicNumerals (boolean): If true, backlink suffix uses numbers (1, 2, ...) instead of letters (a, b, ...).
82
- - afterBacklinkdAriaLabelPrefix (string): Prefix for aria-label of backlink (default: 'Back to reference ').
85
+ - afterBacklinkAriaLabelPrefix (string): Prefix for aria-label of backlink (default: 'Back to reference ').
86
+ - Breaking change: `afterBacklinkdAriaLabelPrefix` (old typo key) has been removed.
83
87
  - labelBra (string): Bracket to use before footnote number (default: '[').
84
88
  - labelKet (string): Bracket to use after footnote number (default: ']').
85
89
  - labelSupTag (boolean): If true, wraps footnote reference in `<sup>` tag.
@@ -91,3 +95,13 @@ npm install @peaceroad/markdown-it-footnote-here
91
95
  - endnotesSectionClass (string): `class` attribute for the endnotes section wrapper; omitted when empty (default: `''`).
92
96
  - endnotesSectionAriaLabel (string): Used as `aria-label` when `endnotesUseHeading` is false. When `endnotesUseHeading` is true, this value becomes the heading text (default: `'Notes'`).
93
97
  - endnotesUseHeading (boolean): If true, render `<h2>{endnotesSectionAriaLabel}</h2>` and omit `aria-label`. If false (default), omit the heading and set `aria-label` when provided.
98
+ - duplicateDefinitionPolicy (string): Policy for duplicate labels (`'warn' | 'ignore' | 'strict'`, default: `'warn'`).
99
+ - `'warn'`: keep first definition, mark note block with `footnote-error`, mark backlinks with `footnote-error-backlink`, and prepend `<span class="footnote-error-message">...</span>` in note content.
100
+ - `'ignore'`: keep first definition and do not add warning classes/messages.
101
+ - `'strict'`: throw an error on duplicate label.
102
+ - duplicateDefinitionMessage (string): Message text used in warning span when policy is `warn` (default: `'[Duplicate footnote label detected. Using the first definition.]'`).
103
+ - injectErrorStyle (boolean): If true and policy is `warn`, inject a `<style>` block once per document for `.footnote-error-message` and `.footnote-error-backlink` (includes `prefers-color-scheme` and `forced-colors` handling). Default: `false`.
104
+ - Diagnostics: when duplicates are detected, details are collected in `env.footnoteHereDiagnostics.duplicateDefinitions`.
105
+ - Security note: option strings used in HTML output are escaped before rendering (labels, aria/id/class values, heading text, backlink content/message).
106
+ - `env.docId` note: if provided, it is URL-encoded and applied consistently to note/ref ids to keep links valid and safe.
107
+ - Runtime note: when using `env.docId`, prefer a new `env` object per render; changing `env.docId` on a reused object may keep prior cached id parts.
package/index.js CHANGED
@@ -1,7 +1,25 @@
1
+ const getDocIdPart = (env) => {
2
+ if (!env || typeof env !== 'object') return ''
3
+ const cached = docIdPartCache.get(env)
4
+ if (cached !== undefined) return cached
5
+
6
+ let value = ''
7
+ if (typeof env.docId === 'string' && env.docId.length > 0) {
8
+ value = `-${encodeURIComponent(env.docId)}-`
9
+ }
10
+ docIdPartCache.set(env, value)
11
+ return value
12
+ }
13
+
14
+ const getRefIdBase = (noteDomPrefix, env) => {
15
+ const docIdPart = getDocIdPart(env)
16
+ if (!docIdPart) return `${noteDomPrefix}-ref`
17
+ return `${noteDomPrefix}${docIdPart}ref`
18
+ }
19
+
1
20
  const render_footnote_anchor_name = (tokens, idx, _opt, env) => {
2
21
  const n = tokens[idx].meta.id + 1
3
- const prefix = typeof env.docId === 'string' ? `-${env.docId}-` : ''
4
- return prefix + n
22
+ return getDocIdPart(env) + n
5
23
  }
6
24
 
7
25
  const isEndnoteLabel = (label, opt) => {
@@ -17,6 +35,75 @@ const ensureNotesEnv = (env, key) => {
17
35
  }
18
36
 
19
37
  const ENDNOTE_DOM_PREFIX = 'en'
38
+ const FOOTNOTE_DOM_PREFIX = 'fn'
39
+ const DEFAULT_DUPLICATE_DEFINITION_MESSAGE = '[Duplicate footnote label detected. Using the first definition.]'
40
+ const ERROR_STYLE_CONTENT = '<style>\n:root {\n --footnote-error-text: #b42318;\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --footnote-error-text: #fca5a5;\n }\n}\n.footnote-error-message {\n color: var(--footnote-error-text);\n font-weight: 600;\n margin-right: 0.35em;\n}\n.footnote-error-backlink {\n color: var(--footnote-error-text);\n position: relative;\n}\n.footnote-error-backlink::before {\n content: "";\n position: absolute;\n left: -0.35em;\n top: 0.08em;\n bottom: 0.08em;\n width: 2px;\n background: var(--footnote-error-text);\n border-radius: 1px;\n}\n@media (forced-colors: active) {\n .footnote-error-message,\n .footnote-error-backlink {\n color: CanvasText;\n }\n .footnote-error-backlink::before {\n background: CanvasText;\n }\n}\n</style>\n'
41
+ const docIdPartCache = new WeakMap()
42
+
43
+ const hasLabelWhitespace = (label) => {
44
+ for (let i = 0; i < label.length; i++) {
45
+ if (label.charCodeAt(i) <= 0x20) return true
46
+ }
47
+ return false
48
+ }
49
+
50
+ const alphaSuffixCache = ['']
51
+ const arabicSuffixCache = ['']
52
+
53
+ const formatRefSuffix = (index, useArabicNumerals) => {
54
+ const cache = useArabicNumerals ? arabicSuffixCache : alphaSuffixCache
55
+ if (cache[index]) return cache[index]
56
+
57
+ if (useArabicNumerals) {
58
+ const value = String(index)
59
+ cache[index] = value
60
+ return value
61
+ }
62
+
63
+ if (index <= 26) {
64
+ const value = String.fromCharCode(96 + index)
65
+ cache[index] = value
66
+ return value
67
+ }
68
+
69
+ let value = ''
70
+ let n = index
71
+ while (n > 0) {
72
+ n--
73
+ value = String.fromCharCode(97 + (n % 26)) + value
74
+ n = Math.floor(n / 26)
75
+ }
76
+ cache[index] = value
77
+ return value
78
+ }
79
+
80
+ const findLabelEnd = (src, start, max) => {
81
+ for (let i = start; i < max - 1; i++) {
82
+ if (src.charCodeAt(i) === 0x5D /* ] */ && src.charCodeAt(i + 1) === 0x3A /* : */) {
83
+ return i
84
+ }
85
+ }
86
+ return -1
87
+ }
88
+
89
+ const normalizeDuplicateDefinitionPolicy = (policy) => {
90
+ if (policy === 'ignore' || policy === 'warn' || policy === 'strict') return policy
91
+ return 'warn'
92
+ }
93
+
94
+ const toOptionString = (value, fallback = '') => {
95
+ if (value === null || value === undefined) return fallback
96
+ return String(value)
97
+ }
98
+
99
+ const hasAnyDuplicateDefinition = (notes) => {
100
+ if (!notes || !notes.duplicateCounts) return false
101
+ const counts = notes.duplicateCounts
102
+ for (let i = 0; i < counts.length; i++) {
103
+ if (counts[i] > 0) return true
104
+ }
105
+ return false
106
+ }
20
107
 
21
108
  const selectNoteEnv = (label, env, preferEndnote) => {
22
109
  const footRefs = env.footnotes && env.footnotes.refs
@@ -47,28 +134,29 @@ const selectNoteEnv = (label, env, preferEndnote) => {
47
134
 
48
135
  const render_footnote_ref = (tokens, idx, opt, env) => {
49
136
  const token = tokens[idx]
137
+ const safe = opt._safe
50
138
  const id = token.meta.id
51
139
  const n = id + 1
52
140
  const isEndnote = token.meta.isEndnote
53
141
  const notes = isEndnote ? env.endnotes : env.footnotes
54
- const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : 'fn'
55
- const displayPrefix = isEndnote ? opt.endnotesLabelPrefix : ''
142
+ const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : FOOTNOTE_DOM_PREFIX
143
+ const displayPrefix = isEndnote ? safe.endnotesLabelPrefix : ''
144
+ const docIdPart = getDocIdPart(env)
145
+ const noteIdBase = `${noteDomPrefix}${docIdPart}`
146
+ const refIdBase = getRefIdBase(noteDomPrefix, env)
56
147
  const totalCounts = notes.totalCounts ? notes.totalCounts[id] || 0 : 0
57
148
  let suffix = ''
58
- let label = `${opt.labelBra}${displayPrefix}${n}${opt.labelKet}`
149
+ let label = `${safe.labelBra}${displayPrefix}${n}${safe.labelKet}`
59
150
  if (totalCounts > 1) {
60
151
  const refCount = notes._refCount || (notes._refCount = [])
61
- let refIdx = (refCount[id] = (refCount[id] || 0) + 1)
62
- if (!opt.afterBacklinkSuffixArabicNumerals) {
63
- refIdx = String.fromCharCode(96 + refIdx)
64
- }
65
- suffix = '-' + refIdx
152
+ const refIdx = (refCount[id] = (refCount[id] || 0) + 1)
153
+ suffix = '-' + formatRefSuffix(refIdx, opt.afterBacklinkSuffixArabicNumerals)
66
154
  if (opt.beforeSameBacklink) {
67
- label = `${opt.labelBra}${displayPrefix}${n}${suffix}${opt.labelKet}`
155
+ label = `${safe.labelBra}${displayPrefix}${n}${suffix}${safe.labelKet}`
68
156
  }
69
157
  }
70
- const href = `${noteDomPrefix}${n}`
71
- let refCont = `<a href="#${href}" id="${noteDomPrefix}-ref${n}${suffix}" class="${noteDomPrefix}-noteref" role="doc-noteref">${label}</a>`
158
+ const href = `${noteIdBase}${n}`
159
+ let refCont = `<a href="#${href}" id="${refIdBase}${n}${suffix}" class="${noteDomPrefix}-noteref" role="doc-noteref">${label}</a>`
72
160
  if (opt.labelSupTag) refCont = `<sup class="${noteDomPrefix}-noteref-wrapper">${refCont}</sup>`
73
161
  return refCont
74
162
  }
@@ -76,8 +164,16 @@ const render_footnote_ref = (tokens, idx, opt, env) => {
76
164
  const render_footnote_open = (tokens, idx, opt, env, slf) => {
77
165
  const id = slf.rules.footnote_anchor_name(tokens, idx, opt, env, slf)
78
166
  const isEndnote = tokens[idx].meta.isEndnote
79
- if (isEndnote) return `<li id="${ENDNOTE_DOM_PREFIX}${id}">\n`
80
- return `<aside id="fn${id}" class="fn" role="doc-footnote">\n`
167
+ const noteId = tokens[idx].meta.id
168
+ const notes = isEndnote ? env.endnotes : env.footnotes
169
+ const hasDuplicate = !!(notes && notes.duplicateCounts && notes.duplicateCounts[noteId] > 0)
170
+ if (isEndnote) {
171
+ if (hasDuplicate) return `<li id="${ENDNOTE_DOM_PREFIX}${id}" class="footnote-error">\n`
172
+ return `<li id="${ENDNOTE_DOM_PREFIX}${id}">\n`
173
+ }
174
+ let className = FOOTNOTE_DOM_PREFIX
175
+ if (hasDuplicate) className += ' footnote-error'
176
+ return `<aside id="${FOOTNOTE_DOM_PREFIX}${id}" class="${className}" role="doc-footnote">\n`
81
177
  }
82
178
 
83
179
  const render_footnote_close = (tokens, idx) => {
@@ -87,54 +183,67 @@ const render_footnote_close = (tokens, idx) => {
87
183
  }
88
184
 
89
185
  const render_footnote_anchor = (tokens, idx, opt, env) => {
186
+ const safe = opt._safe
90
187
  const idNum = tokens[idx].meta.id
91
188
  const n = idNum + 1
92
189
  const isEndnote = tokens[idx].meta.isEndnote
190
+ const hasDuplicate = !!tokens[idx].meta.hasDuplicateDefinition
93
191
  const notes = isEndnote ? env.endnotes : env.footnotes
94
192
  const totalCounts = notes.totalCounts
95
193
  const count = totalCounts ? totalCounts[idNum] || 0 : 0
96
- const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : 'fn'
97
- const displayPrefix = isEndnote ? opt.endnotesLabelPrefix : ''
194
+ const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : FOOTNOTE_DOM_PREFIX
195
+ const displayPrefix = isEndnote ? safe.endnotesLabelPrefix : ''
196
+ const refIdBase = getRefIdBase(noteDomPrefix, env)
197
+ const backlinkClass = hasDuplicate
198
+ ? `${noteDomPrefix}-backlink footnote-error-backlink`
199
+ : `${noteDomPrefix}-backlink`
200
+ const duplicateMessage = hasDuplicate ? `${opt._duplicateDefinitionMessageHtml} ` : ''
98
201
 
99
202
  if (opt.beforeSameBacklink && count > 1) {
100
203
  let links = ''
101
204
  for (let i = 1; i <= count; i++) {
102
- const suffix = '-' + String.fromCharCode(96 + i); // a, b, c ...
103
- links += `<a href="#${noteDomPrefix}-ref${n}${suffix}" class="${noteDomPrefix}-backlink" role="doc-backlink">${opt.backLabelBra}${displayPrefix}${n}${suffix}${opt.backLabelKet}</a>`
205
+ const suffix = '-' + formatRefSuffix(i, opt.afterBacklinkSuffixArabicNumerals)
206
+ links += `<a href="#${refIdBase}${n}${suffix}" class="${backlinkClass}" role="doc-backlink">${safe.backLabelBra}${displayPrefix}${n}${suffix}${safe.backLabelKet}</a>`
104
207
  }
105
- return links + ' '
208
+ return links + ' ' + duplicateMessage
106
209
  }
107
210
 
108
211
  if (opt.afterBacklink) {
109
- return `<span class="${noteDomPrefix}-label">${opt.backLabelBra}${displayPrefix}${n}${opt.backLabelKet}</span> `
212
+ return `<span class="${noteDomPrefix}-label">${safe.backLabelBra}${displayPrefix}${n}${safe.backLabelKet}</span> ${duplicateMessage}`
110
213
  }
111
214
 
112
215
  if (count > 1) {
113
- return `<a href="#${noteDomPrefix}-ref${n}-a" class="${noteDomPrefix}-backlink" role="doc-backlink">${opt.backLabelBra}${displayPrefix}${n}${opt.backLabelKet}</a> `
216
+ const firstSuffix = '-' + formatRefSuffix(1, opt.afterBacklinkSuffixArabicNumerals)
217
+ return `<a href="#${refIdBase}${n}${firstSuffix}" class="${backlinkClass}" role="doc-backlink">${safe.backLabelBra}${displayPrefix}${n}${safe.backLabelKet}</a> ${duplicateMessage}`
114
218
  }
115
219
 
116
- return `<a href="#${noteDomPrefix}-ref${n}" class="${noteDomPrefix}-backlink" role="doc-backlink">${opt.backLabelBra}${displayPrefix}${n}${opt.backLabelKet}</a> `
220
+ return `<a href="#${refIdBase}${n}" class="${backlinkClass}" role="doc-backlink">${safe.backLabelBra}${displayPrefix}${n}${safe.backLabelKet}</a> ${duplicateMessage}`
117
221
  }
118
222
 
119
- function createAfterBackLinkToken(state, counts, n, opt, noteDomPrefix, isEndnote) {
120
- const displayPrefix = isEndnote ? opt.endnotesLabelPrefix : ''
223
+ function createAfterBackLinkToken(state, counts, n, opt, noteDomPrefix, isEndnote, hasDuplicate) {
224
+ const safe = opt._safe
225
+ const displayPrefix = isEndnote ? safe.endnotesLabelPrefix : ''
226
+ const refIdBase = getRefIdBase(noteDomPrefix, state.env)
227
+ const backlinkClass = hasDuplicate
228
+ ? `${noteDomPrefix}-backlink footnote-error-backlink`
229
+ : `${noteDomPrefix}-backlink`
121
230
  let html = ' '
122
231
  if (counts && counts > 1) {
123
232
  for (let i = 1; i <= counts; i++) {
124
- const suffixChar = opt.afterBacklinkSuffixArabicNumerals ? i : String.fromCharCode(96 + i)
233
+ const suffixChar = formatRefSuffix(i, opt.afterBacklinkSuffixArabicNumerals)
125
234
  const suffix = '-' + suffixChar
126
- html += `<a href="#${noteDomPrefix}-ref${n}${suffix}" class="${noteDomPrefix}-backlink" role="doc-backlink"`
127
- if (opt.afterBacklinkdAriaLabelPrefix) html += ` aria-label="${opt.afterBacklinkdAriaLabelPrefix}${displayPrefix}${n}${suffix}"`
128
- html += `>${opt.afterBacklinkContent}`
235
+ html += `<a href="#${refIdBase}${n}${suffix}" class="${backlinkClass}" role="doc-backlink"`
236
+ if (safe.afterBacklinkAriaLabelPrefix) html += ` aria-label="${safe.afterBacklinkAriaLabelPrefix}${displayPrefix}${n}${suffix}"`
237
+ html += `>${safe.afterBacklinkContent}`
129
238
  if (opt.afterBacklinkWithNumber) {
130
239
  html += `<sup>${suffixChar}</sup>`
131
240
  }
132
241
  html += `</a>`
133
242
  }
134
243
  } else {
135
- html += `<a href="#${noteDomPrefix}-ref${n}" class="${noteDomPrefix}-backlink" role="doc-backlink"`
136
- if (opt.afterBacklinkdAriaLabelPrefix) html += ` aria-label="${opt.afterBacklinkdAriaLabelPrefix}${displayPrefix}${n}"`
137
- html += `>${opt.afterBacklinkContent}</a>`
244
+ html += `<a href="#${refIdBase}${n}" class="${backlinkClass}" role="doc-backlink"`
245
+ if (safe.afterBacklinkAriaLabelPrefix) html += ` aria-label="${safe.afterBacklinkAriaLabelPrefix}${displayPrefix}${n}"`
246
+ html += `>${safe.afterBacklinkContent}</a>`
138
247
  }
139
248
  const token = new state.Token('html_inline', '', 0)
140
249
  token.content = html
@@ -153,15 +262,43 @@ const footnote_plugin = (md, option) =>{
153
262
  afterBacklinkContent: '↩',
154
263
  afterBacklinkWithNumber: false,
155
264
  afterBacklinkSuffixArabicNumerals: false,
156
- afterBacklinkdAriaLabelPrefix: 'Back to reference ', /* 戻る:本文参照 */
265
+ afterBacklinkAriaLabelPrefix: 'Back to reference ', /* 戻る:本文参照 */
157
266
  endnotesPrefix: 'en-',
158
267
  endnotesLabelPrefix: 'E',
159
268
  endnotesSectionId: 'endnotes',
160
269
  endnotesSectionClass: '',
161
270
  endnotesSectionAriaLabel: 'Notes',
162
271
  endnotesUseHeading: false,
272
+ duplicateDefinitionPolicy: 'warn',
273
+ duplicateDefinitionMessage: DEFAULT_DUPLICATE_DEFINITION_MESSAGE,
274
+ injectErrorStyle: false,
275
+ }
276
+ if (option) {
277
+ Object.assign(opt, option)
163
278
  }
164
- if (option) Object.assign(opt, option)
279
+ opt.duplicateDefinitionPolicy = normalizeDuplicateDefinitionPolicy(opt.duplicateDefinitionPolicy)
280
+ if (typeof opt.duplicateDefinitionMessage !== 'string') {
281
+ opt.duplicateDefinitionMessage = DEFAULT_DUPLICATE_DEFINITION_MESSAGE
282
+ }
283
+ const escapeHtml = md.utils.escapeHtml
284
+ const escapeOption = (value, fallback = '') => escapeHtml(toOptionString(value, fallback))
285
+ opt._safe = {
286
+ labelBra: escapeOption(opt.labelBra, '['),
287
+ labelKet: escapeOption(opt.labelKet, ']'),
288
+ backLabelBra: escapeOption(opt.backLabelBra, '['),
289
+ backLabelKet: escapeOption(opt.backLabelKet, ']'),
290
+ afterBacklinkContent: escapeOption(opt.afterBacklinkContent, '↩'),
291
+ afterBacklinkAriaLabelPrefix: escapeOption(opt.afterBacklinkAriaLabelPrefix, 'Back to reference '),
292
+ endnotesLabelPrefix: escapeOption(opt.endnotesLabelPrefix, 'E'),
293
+ endnotesSectionId: escapeOption(opt.endnotesSectionId, 'endnotes'),
294
+ endnotesSectionClass: escapeOption(opt.endnotesSectionClass, ''),
295
+ endnotesSectionAriaLabel: escapeOption(opt.endnotesSectionAriaLabel, 'Notes'),
296
+ duplicateDefinitionMessage: escapeOption(opt.duplicateDefinitionMessage, DEFAULT_DUPLICATE_DEFINITION_MESSAGE),
297
+ }
298
+ opt._duplicateDefinitionMessageHtml = `<span class="footnote-error-message">${opt._safe.duplicateDefinitionMessage}</span>`
299
+ const duplicatePolicy = opt.duplicateDefinitionPolicy
300
+ const duplicateWarnEnabled = duplicatePolicy === 'warn'
301
+ const duplicateStrictEnabled = duplicatePolicy === 'strict'
165
302
 
166
303
  const isSpace = md.utils.isSpace
167
304
 
@@ -171,6 +308,32 @@ const footnote_plugin = (md, option) =>{
171
308
  md.renderer.rules.footnote_anchor = (tokens, idx, _options, env, slf) => render_footnote_anchor(tokens, idx, opt, env, slf)
172
309
  md.renderer.rules.footnote_anchor_name = (tokens, idx, _options, env, slf) => render_footnote_anchor_name(tokens, idx, opt, env, slf)
173
310
 
311
+ // Reset plugin-owned env state for each parse to avoid cross-render leaks.
312
+ const footnote_reset = (state) => {
313
+ if (!state.env) {
314
+ state.env = {}
315
+ return
316
+ }
317
+ if (state.env.footnotes) delete state.env.footnotes
318
+ if (state.env.endnotes) delete state.env.endnotes
319
+ if (state.env.footnoteHereDiagnostics) delete state.env.footnoteHereDiagnostics
320
+ }
321
+
322
+ const registerDuplicateDefinition = (state, notes, id, label, isEndnote, line) => {
323
+ notes.duplicateCounts = notes.duplicateCounts || []
324
+ notes.duplicateCounts[id] = (notes.duplicateCounts[id] || 0) + 1
325
+
326
+ if (!state.env.footnoteHereDiagnostics) {
327
+ state.env.footnoteHereDiagnostics = { duplicateDefinitions: [] }
328
+ }
329
+ state.env.footnoteHereDiagnostics.duplicateDefinitions.push({
330
+ label,
331
+ isEndnote,
332
+ line: line + 1,
333
+ noteId: id,
334
+ })
335
+ }
336
+
174
337
  // Process footnote block definition
175
338
  const footnote_def = (state, startLine, endLine, silent) => {
176
339
  const bMarks = state.bMarks, tShift = state.tShift, eMarks = state.eMarks, src = state.src
@@ -182,12 +345,11 @@ const footnote_plugin = (md, option) =>{
182
345
 
183
346
  if (src.charCodeAt(start) !== 0x5B/* [ */ || src.charCodeAt(start + 1) !== 0x5E/* ^ */) { return false; }
184
347
 
185
- // locate end of label efficiently
186
- const idx = src.indexOf(']:', start + 2)
187
- if (idx < start + 3 || idx > max - 2) { return false; }
348
+ const idx = findLabelEnd(src, start + 2, max)
349
+ if (idx < start + 3) { return false; }
188
350
 
189
351
  const label = src.slice(start + 2, idx)
190
- if (label.indexOf(' ') >= 0) { return false; }
352
+ if (hasLabelWhitespace(label)) { return false; }
191
353
  const pos = idx + 2
192
354
 
193
355
  if (silent) { return true; }
@@ -196,18 +358,26 @@ const footnote_plugin = (md, option) =>{
196
358
  const fn = ensureNotesEnv(state.env, isEndnote ? 'endnotes' : 'footnotes')
197
359
  const refKey = ':' + label
198
360
  const existingId = fn.refs[refKey]
199
- const isDuplicate = isEndnote && existingId !== undefined
361
+ const isDuplicate = existingId !== undefined
200
362
  const id = isDuplicate ? existingId : fn.length++
201
363
  if (!isDuplicate) {
202
364
  fn.refs[refKey] = id
365
+ } else {
366
+ if (duplicateStrictEnabled) {
367
+ throw new Error(`[markdown-it-footnote-here] Duplicate footnote label "${label}" at line ${startLine + 1}.`)
368
+ }
369
+ if (duplicateWarnEnabled) {
370
+ registerDuplicateDefinition(state, fn, id, label, isEndnote, startLine)
371
+ }
203
372
  }
204
373
 
205
374
  let tokenStart = 0
375
+ let openToken = null
206
376
  if (!isDuplicate) {
207
- const token = new state.Token('footnote_open', '', 1)
208
- token.meta = { id, isEndnote }
209
- token.level = state.level++
210
- state.tokens.push(token)
377
+ openToken = new state.Token('footnote_open', '', 1)
378
+ openToken.meta = { id, isEndnote }
379
+ openToken.level = state.level++
380
+ state.tokens.push(openToken)
211
381
  fn.positions.push(state.tokens.length - 1)
212
382
  } else {
213
383
  tokenStart = state.tokens.length
@@ -249,6 +419,9 @@ const footnote_plugin = (md, option) =>{
249
419
  }
250
420
 
251
421
  state.md.block.tokenize(state, startLine, endLine, true)
422
+ if (openToken) {
423
+ openToken.map = [startLine, state.line]
424
+ }
252
425
 
253
426
  state.parentType = oldParentType
254
427
  state.blkIndent -= 4
@@ -282,7 +455,7 @@ const footnote_plugin = (md, option) =>{
282
455
  for (; pos < posMax; pos++) {
283
456
  const ch = src.charCodeAt(pos)
284
457
  if (ch === 0x5D /* ] */) break
285
- if (ch === 0x20 || ch === 0x0A) { return false; } // space or linebreak
458
+ if (ch <= 0x20) { return false; } // whitespace/control chars are invalid in label
286
459
  }
287
460
 
288
461
  if (pos >= posMax || pos === start + 2) { return false; }
@@ -311,39 +484,13 @@ const footnote_plugin = (md, option) =>{
311
484
  const footnote_anchor = (state) => {
312
485
  if (!state.env.footnotes && !state.env.endnotes) return
313
486
  const tokens = state.tokens
314
- const createAnchorToken = (id, isEndnote) => {
315
- const aToken = new state.Token('footnote_anchor', '', 0)
316
- aToken.meta = { id, isEndnote }
317
- return aToken
318
- }
319
487
 
320
488
  const injectAnchors = (notes, isEndnote) => {
321
489
  const positions = notes && notes.positions
322
490
  if (!positions || positions.length === 0) { return; }
323
-
324
- if (opt.afterBacklink) {
325
- const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : 'fn'
326
- const totalCounts = notes.totalCounts
327
- for (let j = 0, len = positions.length; j < len; ++j) {
328
- const posOpen = positions[j]
329
- if (posOpen + 2 >= tokens.length) continue
330
-
331
- const t1 = tokens[posOpen + 1]
332
- if (t1.type !== 'paragraph_open') continue
333
-
334
- const t2 = tokens[posOpen + 2]
335
- if (t2.type !== 'inline') continue
336
-
337
- const t0 = tokens[posOpen]
338
- const id = t0.meta.id
339
-
340
- t2.children.unshift(createAnchorToken(id, isEndnote))
341
- const n = id + 1
342
- const counts = totalCounts && totalCounts[id]
343
- t2.children.push(createAfterBackLinkToken(state, counts, n, opt, noteDomPrefix, isEndnote))
344
- }
345
- return
346
- }
491
+ const noteDomPrefix = isEndnote ? ENDNOTE_DOM_PREFIX : FOOTNOTE_DOM_PREFIX
492
+ const totalCounts = notes.totalCounts
493
+ const duplicateCounts = notes.duplicateCounts
347
494
 
348
495
  for (let j = 0, len = positions.length; j < len; ++j) {
349
496
  const posOpen = positions[j]
@@ -357,8 +504,15 @@ const footnote_plugin = (md, option) =>{
357
504
 
358
505
  const t0 = tokens[posOpen]
359
506
  const id = t0.meta.id
360
-
361
- t2.children.unshift(createAnchorToken(id, isEndnote))
507
+ const duplicateDef = !!(duplicateCounts && duplicateCounts[id] > 0)
508
+ const aToken = new state.Token('footnote_anchor', '', 0)
509
+ aToken.meta = { id, isEndnote, hasDuplicateDefinition: duplicateDef }
510
+ t2.children.unshift(aToken)
511
+ if (opt.afterBacklink) {
512
+ const n = id + 1
513
+ const counts = totalCounts && totalCounts[id]
514
+ t2.children.push(createAfterBackLinkToken(state, counts, n, opt, noteDomPrefix, isEndnote, duplicateDef))
515
+ }
362
516
  }
363
517
  }
364
518
 
@@ -366,6 +520,17 @@ const footnote_plugin = (md, option) =>{
366
520
  injectAnchors(state.env.endnotes, true)
367
521
  }
368
522
 
523
+ const inject_error_style = (state) => {
524
+ if (!opt.injectErrorStyle) return
525
+ if (!duplicateWarnEnabled) return
526
+ const hasDuplicate = hasAnyDuplicateDefinition(state.env.footnotes) || hasAnyDuplicateDefinition(state.env.endnotes)
527
+ if (!hasDuplicate) return
528
+
529
+ const token = new state.Token('html_block', '', 0)
530
+ token.content = ERROR_STYLE_CONTENT
531
+ state.tokens.unshift(token)
532
+ }
533
+
369
534
  const move_endnotes_to_section = (state) => {
370
535
  if (!opt.endnotesPrefix) return
371
536
  if (!state.env.endnotes || !state.env.endnotes.positions || state.env.endnotes.positions.length === 0) {
@@ -400,16 +565,17 @@ const footnote_plugin = (md, option) =>{
400
565
  if (endnoteTokens.length === 0) return
401
566
 
402
567
  const sectionOpen = new state.Token('html_block', '', 0)
568
+ const safe = opt._safe
403
569
  const attrs = []
404
570
  if (!opt.endnotesUseHeading && opt.endnotesSectionAriaLabel) {
405
- attrs.push(`aria-label="${opt.endnotesSectionAriaLabel}"`)
571
+ attrs.push(`aria-label="${safe.endnotesSectionAriaLabel}"`)
406
572
  }
407
- if (opt.endnotesSectionId) attrs.push(`id="${opt.endnotesSectionId}"`)
408
- if (opt.endnotesSectionClass) attrs.push(`class="${opt.endnotesSectionClass}"`)
573
+ if (opt.endnotesSectionId) attrs.push(`id="${safe.endnotesSectionId}"`)
574
+ if (opt.endnotesSectionClass) attrs.push(`class="${safe.endnotesSectionClass}"`)
409
575
  attrs.push('role="doc-endnotes"')
410
576
  let sectionContent = `<section ${attrs.join(' ')}>\n`
411
577
  if (opt.endnotesUseHeading && opt.endnotesSectionAriaLabel) {
412
- sectionContent += `<h2>${opt.endnotesSectionAriaLabel}</h2>\n`
578
+ sectionContent += `<h2>${safe.endnotesSectionAriaLabel}</h2>\n`
413
579
  }
414
580
  sectionContent += '<ol>\n'
415
581
  sectionOpen.content = sectionContent
@@ -423,10 +589,12 @@ const footnote_plugin = (md, option) =>{
423
589
  tokens.push(sectionClose)
424
590
  }
425
591
 
592
+ md.core.ruler.before('block', 'footnote_reset', footnote_reset)
426
593
  md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: [ 'paragraph', 'reference' ] })
427
594
  md.inline.ruler.after('image', 'footnote_ref', footnote_ref)
428
595
  md.core.ruler.after('inline', 'footnote_anchor', footnote_anchor)
429
- md.core.ruler.after('footnote_anchor', 'endnotes_move', move_endnotes_to_section)
596
+ md.core.ruler.after('footnote_anchor', 'footnote_error_style', inject_error_style)
597
+ md.core.ruler.after('footnote_error_style', 'endnotes_move', move_endnotes_to_section)
430
598
  }
431
599
 
432
600
  export default footnote_plugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaceroad/markdown-it-footnote-here",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A markdown-it plugin. This generate aside[role|doc-footnote] element just below the footnote reference paragraph.",
5
5
  "main": "index.js",
6
6
  "type":"module",