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}