@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.
- package/README.md +17 -3
- package/index.js +248 -80
- 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.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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 :
|
|
55
|
-
const displayPrefix = isEndnote ?
|
|
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 = `${
|
|
149
|
+
let label = `${safe.labelBra}${displayPrefix}${n}${safe.labelKet}`
|
|
59
150
|
if (totalCounts > 1) {
|
|
60
151
|
const refCount = notes._refCount || (notes._refCount = [])
|
|
61
|
-
|
|
62
|
-
|
|
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 = `${
|
|
155
|
+
label = `${safe.labelBra}${displayPrefix}${n}${suffix}${safe.labelKet}`
|
|
68
156
|
}
|
|
69
157
|
}
|
|
70
|
-
const href = `${
|
|
71
|
-
let refCont = `<a href="#${href}" id="${
|
|
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
|
-
|
|
80
|
-
|
|
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 :
|
|
97
|
-
const displayPrefix = isEndnote ?
|
|
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 = '-' +
|
|
103
|
-
links += `<a href="#${
|
|
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">${
|
|
212
|
+
return `<span class="${noteDomPrefix}-label">${safe.backLabelBra}${displayPrefix}${n}${safe.backLabelKet}</span> ${duplicateMessage}`
|
|
110
213
|
}
|
|
111
214
|
|
|
112
215
|
if (count > 1) {
|
|
113
|
-
|
|
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="#${
|
|
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
|
|
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
|
|
233
|
+
const suffixChar = formatRefSuffix(i, opt.afterBacklinkSuffixArabicNumerals)
|
|
125
234
|
const suffix = '-' + suffixChar
|
|
126
|
-
html += `<a href="#${
|
|
127
|
-
if (
|
|
128
|
-
html += `>${
|
|
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="#${
|
|
136
|
-
if (
|
|
137
|
-
html += `>${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
state.tokens.push(
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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="${
|
|
571
|
+
attrs.push(`aria-label="${safe.endnotesSectionAriaLabel}"`)
|
|
406
572
|
}
|
|
407
|
-
if (opt.endnotesSectionId) attrs.push(`id="${
|
|
408
|
-
if (opt.endnotesSectionClass) attrs.push(`class="${
|
|
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>${
|
|
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', '
|
|
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
|
+
"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",
|