at main 8.3 kB view raw
1// deno-lint-ignore-file no-explicit-any 2 3export interface Options { 4 /** Key to save the title in the page data */ 5 key: string; 6 7 /** The prefix to assign to all ids */ 8 idPrefix: string; 9 10 /** The prefix to assign to all references ids */ 11 referenceIdPrefix: string; 12 13 /** HTML attributes to the <sup> element used for the reference */ 14 referenceAttrs: Record<string, string>; 15} 16 17export const defaults: Options = { 18 key: "footnotes", 19 idPrefix: "fn-", 20 referenceIdPrefix: "fnref-", 21 referenceAttrs: { 22 class: "fn-ref", 23 }, 24}; 25 26export default function footNotes(md: any, userOptions: Partial<Options> = {}) { 27 const options = Object.assign({}, defaults, userOptions) as Options; 28 const parseLinkLabel = md.helpers.parseLinkLabel; 29 const isSpace = md.utils.isSpace; 30 31 md.renderer.rules.footnote_reference = function (tokens: any[], idx: number) { 32 const { id, label } = tokens[idx].meta; 33 const attrs = Object.entries(options.referenceAttrs) 34 .map(([key, value]) => `${key}="${value}"`); 35 36 attrs.push(`href="#${options.idPrefix}${id}"`); 37 attrs.push(`id="${options.referenceIdPrefix}${id}"`); 38 39 return `<sup><a ${attrs.join(" ")}>${id}</a></sup>`; 40 }; 41 42 // Process footnote block definition 43 function footnote_block( 44 state: any, 45 startLine: number, 46 endLine: number, 47 silent: boolean, 48 ) { 49 const start = state.bMarks[startLine] + state.tShift[startLine]; 50 const max = state.eMarks[startLine]; 51 52 /* Line should be at least 5 characters: [^x]: */ 53 if ( 54 start + 4 > max || 55 state.src.charCodeAt(start) !== 0x5B || /* [ */ 56 state.src.charCodeAt(start + 1) !== 0x5E /* ^ */ 57 ) { 58 return false; 59 } 60 61 let pos; 62 63 for (pos = start + 2; pos < max; pos++) { 64 if (state.src.charCodeAt(pos) === 0x20) { 65 return false; 66 } 67 if (state.src.charCodeAt(pos) === 0x5D /* ] */) { 68 break; 69 } 70 } 71 72 // no empty footnote labels 73 if (pos === start + 2) { 74 return false; 75 } 76 77 if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) { 78 return false; 79 } 80 81 if (silent) { 82 return true; 83 } 84 85 pos++; 86 87 const footnotes = getFootnotes(state); 88 const label = state.src.slice(start + 2, pos - 2); 89 const id = footnotes.size + 1; 90 91 const openToken = new state.Token("footnote_reference_open", "", 1); 92 openToken.meta = { id }; 93 openToken.level = state.level++; 94 state.tokens.push(openToken); 95 96 footnotes.set(id, { id, label }); 97 98 const oldBMark = state.bMarks[startLine]; 99 const oldTShift = state.tShift[startLine]; 100 const oldSCount = state.sCount[startLine]; 101 const oldParentType = state.parentType; 102 103 const posAfterColon = pos; 104 const initial = state.sCount[startLine] + pos - 105 (state.bMarks[startLine] + state.tShift[startLine]); 106 let offset = initial; 107 108 while (pos < max) { 109 const ch = state.src.charCodeAt(pos); 110 111 if (!isSpace(ch)) { 112 break; 113 } 114 if (ch === 0x09) { 115 offset += 4 - offset % 4; 116 } else { 117 offset++; 118 } 119 120 pos++; 121 } 122 123 state.tShift[startLine] = pos - posAfterColon; 124 state.sCount[startLine] = offset - initial; 125 state.bMarks[startLine] = posAfterColon; 126 state.blkIndent += 4; 127 state.parentType = "footnote"; 128 129 if (state.sCount[startLine] < state.blkIndent) { 130 state.sCount[startLine] += state.blkIndent; 131 } 132 133 state.md.block.tokenize(state, startLine, endLine, true); 134 135 state.parentType = oldParentType; 136 state.blkIndent -= 4; 137 state.tShift[startLine] = oldTShift; 138 state.sCount[startLine] = oldSCount; 139 state.bMarks[startLine] = oldBMark; 140 141 const closeToken = new state.Token("footnote_reference_close", "", -1); 142 closeToken.level = --state.level; 143 state.tokens.push(closeToken); 144 145 return true; 146 } 147 148 // Process inline footnotes (^[...]) 149 function footnote_inline(state: any, silent: boolean) { 150 const max = state.posMax; 151 const start = state.pos; 152 153 /* Line should be at least 2 characters: ^[ */ 154 if ( 155 start + 2 >= max || 156 state.src.charCodeAt(start) !== 0x5E || 157 state.src.charCodeAt(start + 1) !== 0x5B 158 ) { 159 return false; 160 } 161 162 const labelStart = start + 2; 163 const labelEnd = parseLinkLabel(state, start + 1); 164 165 // parser failed to find ']', so it's not a valid note 166 if (labelEnd < 0) { 167 return false; 168 } 169 170 // We found the end of the link, and know for a fact it's a valid link; 171 // so all that's left to do is to call tokenizer. 172 // 173 if (!silent) { 174 const footnotes = getFootnotes(state); 175 const id = footnotes.size + 1; 176 const label = id.toString(); 177 178 const token = state.push("footnote_reference", "", 0); 179 token.meta = { id, label }; 180 181 footnotes.set(id, { 182 id, 183 label, 184 content: `${state.src.slice(labelStart, labelEnd)}`, 185 }); 186 } 187 188 state.pos = labelEnd + 1; 189 state.posMax = max; 190 return true; 191 } 192 193 // Process footnote references ([^...]) 194 function footnote_reference(state: any, silent: boolean) { 195 const max = state.posMax; 196 const start = state.pos; 197 198 /* Line should be at least 4 characters: [^x] */ 199 if ( 200 start + 3 > max || 201 state.src.charCodeAt(start) !== 0x5B /* [ */ || 202 state.src.charCodeAt(start + 1) !== 0x5E /* ^ */ 203 ) { 204 return false; 205 } 206 207 const labelStart = start + 2; 208 let labelEnd = 0; 209 210 for (labelEnd = labelStart; labelEnd < max; labelEnd++) { 211 const char = state.src.charCodeAt(labelEnd); 212 213 if (char === 0x20 || char === 0x0A) { 214 return false; 215 } 216 217 if (char === 0x5D /* ] */) { 218 break; 219 } 220 } 221 222 if (labelStart === labelEnd || labelEnd >= max) { 223 return false; 224 } 225 226 if (!silent) { 227 const label = state.src.slice(labelStart, labelEnd); 228 const footnote = searchFootnote(state, label); 229 const token = state.push("footnote_reference", "", 0); 230 token.meta = { ...footnote }; 231 } 232 233 state.pos = ++labelEnd; 234 state.posMax = max; 235 return true; 236 } 237 238 // Glue footnote tokens to end of token stream 239 function footnote_tail(state: any) { 240 const footnotes = getFootnotes(state); 241 242 if (!footnotes.size) { 243 return; 244 } 245 246 let currentFootnote: Footnote | undefined; 247 let currentTokens: any[] | undefined; 248 249 state.tokens = state.tokens.filter(function (tok: any) { 250 if (tok.type === "footnote_reference_open") { 251 currentFootnote = footnotes.get(tok.meta.id)!; 252 currentTokens = []; 253 return false; 254 } 255 256 if (tok.type === "footnote_reference_close") { 257 currentFootnote!.content = md.renderer.render( 258 currentTokens, 259 state.md.options, 260 state.env, 261 ); 262 currentTokens = undefined; 263 return false; 264 } 265 266 if (currentTokens) { 267 currentTokens.push(tok); 268 } 269 270 return !currentTokens; 271 }); 272 } 273 274 md.block.ruler.before("reference", "footnote_block", footnote_block, { 275 alt: ["paragraph", "reference"], 276 }); 277 md.inline.ruler.after("image", "footnote_inline", footnote_inline); 278 md.inline.ruler.after("footnote_inline", "footnote_reference", footnote_reference); 279 md.core.ruler.after("inline", "footnote_tail", footnote_tail); 280 281 md.core.ruler.push("saveFootnotes", function (state: any) { 282 const data = state.env.data?.page?.data; 283 284 if (!data || data[options.key]) { 285 return; 286 } 287 288 const footnotes = getFootnotes(state); 289 data[options.key] = Array.from(footnotes.values()).map((footnote) => ({ 290 id: `${options.idPrefix}${footnote.id}`, 291 refId: `${options.referenceIdPrefix}${footnote.id}`, 292 label: footnote.label, 293 content: footnote.content, 294 rawId: `${footnote.id}`, 295 })); 296 }); 297} 298 299interface Footnote { 300 id: number; 301 label?: string; 302 content?: string; 303 tokens?: any[]; 304 rawId?: string; 305} 306 307function getFootnotes(state: any): Map<number, Footnote> { 308 if (!state.env.fn) { 309 state.env.fn = new Map<number, Footnote>(); 310 } 311 312 return state.env.fn; 313} 314 315function searchFootnote(state: any, label: string) { 316 const map = getFootnotes(state); 317 318 for (const value of map.values()) { 319 if (value.label === label) { 320 return value; 321 } 322 } 323}