import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it'; /** * 脚注元数据接口 */ interface FootnoteMeta { id: number; subId: number; label: string; } /** * 脚注列表项接口 */ interface FootnoteItem { label?: string; content?: string; tokens?: Token[]; count: number; } /** * 环境接口 */ interface FootnoteEnv { footnotes?: { refs?: { [key: string]: number }; list?: FootnoteItem[]; }; docId?: string; } /// ///////////////////////////////////////////////////////////////////////////// // Renderer partials function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string { const n = Number(tokens[idx].meta.id + 1).toString(); let prefix = ''; if (typeof env.docId === 'string') prefix = `-${env.docId}-`; return prefix + n; } function render_footnote_caption(tokens: Token[], idx: number): string { let n = Number(tokens[idx].meta.id + 1).toString(); if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`; return `[${n}]`; } function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string { const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf); const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf); let refid = id; if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`; return `${caption}`; } function render_footnote_block_open(tokens: Token[], idx: number, options: any): string { return (options.xhtmlOut ? '
\n' : '
\n') + '
\n' + '
    \n'; } function render_footnote_block_close(): string { return '
\n
\n'; } function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string { let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf); if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`; return `
  • `; } function render_footnote_close(): string { return '
  • \n'; } function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string { let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf); if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`; /* ↩ with escape code to prevent display as Apple Emoji on iOS */ return ` \u21a9\uFE0E`; } /** * markdown-it-footnote 插件 * 用于支持脚注语法 */ export default function footnote_plugin(md: MarkdownIt): void { const parseLinkLabel = md.helpers.parseLinkLabel; const isSpace = md.utils.isSpace; md.renderer.rules.footnote_ref = render_footnote_ref; md.renderer.rules.footnote_block_open = render_footnote_block_open; md.renderer.rules.footnote_block_close = render_footnote_block_close; md.renderer.rules.footnote_open = render_footnote_open; md.renderer.rules.footnote_close = render_footnote_close; md.renderer.rules.footnote_anchor = render_footnote_anchor; // helpers (only used in other rules, no tokens are attached to those) md.renderer.rules.footnote_caption = render_footnote_caption; md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name; // Process footnote block definition function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean { const start = state.bMarks[startLine] + state.tShift[startLine]; const max = state.eMarks[startLine]; // line should be at least 5 chars - "[^x]:" if (start + 4 > max) return false; if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false; if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false; let pos: number; for (pos = start + 2; pos < max; pos++) { if (state.src.charCodeAt(pos) === 0x20) return false; if (state.src.charCodeAt(pos) === 0x5D /* ] */) { break; } } if (pos === start + 2) return false; // no empty footnote labels if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false; if (silent) return true; pos++; const env = state.env as FootnoteEnv; if (!env.footnotes) env.footnotes = {}; if (!env.footnotes.refs) env.footnotes.refs = {}; const label = state.src.slice(start + 2, pos - 2); env.footnotes.refs[`:${label}`] = -1; const token_fref_o = new state.Token('footnote_reference_open', '', 1); token_fref_o.meta = { label }; token_fref_o.level = state.level++; state.tokens.push(token_fref_o); const oldBMark = state.bMarks[startLine]; const oldTShift = state.tShift[startLine]; const oldSCount = state.sCount[startLine]; const oldParentType = state.parentType; const posAfterColon = pos; const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]); let offset = initial; while (pos < max) { const ch = state.src.charCodeAt(pos); if (isSpace(ch)) { if (ch === 0x09) { offset += 4 - offset % 4; } else { offset++; } } else { break; } pos++; } state.tShift[startLine] = pos - posAfterColon; state.sCount[startLine] = offset - initial; state.bMarks[startLine] = posAfterColon; state.blkIndent += 4; state.parentType = 'footnote' as any; if (state.sCount[startLine] < state.blkIndent) { state.sCount[startLine] += state.blkIndent; } state.md.block.tokenize(state, startLine, endLine); state.parentType = oldParentType; state.blkIndent -= 4; state.tShift[startLine] = oldTShift; state.sCount[startLine] = oldSCount; state.bMarks[startLine] = oldBMark; const token_fref_c = new state.Token('footnote_reference_close', '', -1); token_fref_c.level = --state.level; state.tokens.push(token_fref_c); return true; } // Process inline footnotes (^[...]) function footnote_inline(state: StateInline, silent: boolean): boolean { const max = state.posMax; const start = state.pos; if (start + 2 >= max) return false; if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false; if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false; const labelStart = start + 2; const labelEnd = parseLinkLabel(state, start + 1); // parser failed to find ']', so it's not a valid note if (labelEnd < 0) return false; // We found the end of the link, and know for a fact it's a valid link; // so all that's left to do is to call tokenizer. // if (!silent) { const env = state.env as FootnoteEnv; if (!env.footnotes) env.footnotes = {}; if (!env.footnotes.list) env.footnotes.list = []; const footnoteId = env.footnotes.list.length; const tokens: Token[] = []; state.md.inline.parse( state.src.slice(labelStart, labelEnd), state.md, state.env, tokens ); const token = state.push('footnote_ref', '', 0); token.meta = { id: footnoteId }; env.footnotes.list[footnoteId] = { content: state.src.slice(labelStart, labelEnd), tokens, count: 0 }; } state.pos = labelEnd + 1; state.posMax = max; return true; } // Process footnote references ([^...]) function footnote_ref(state: StateInline, silent: boolean): boolean { const max = state.posMax; const start = state.pos; // should be at least 4 chars - "[^x]" if (start + 3 > max) return false; const env = state.env as FootnoteEnv; if (!env.footnotes || !env.footnotes.refs) return false; if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false; if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false; let pos: number; for (pos = start + 2; pos < max; pos++) { if (state.src.charCodeAt(pos) === 0x20) return false; if (state.src.charCodeAt(pos) === 0x0A) return false; if (state.src.charCodeAt(pos) === 0x5D /* ] */) { break; } } if (pos === start + 2) return false; // no empty footnote labels if (pos >= max) return false; pos++; const label = state.src.slice(start + 2, pos - 1); if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false; if (!silent) { if (!env.footnotes.list) env.footnotes.list = []; let footnoteId: number; if (env.footnotes.refs[`:${label}`] < 0) { footnoteId = env.footnotes.list.length; env.footnotes.list[footnoteId] = { label, count: 0 }; env.footnotes.refs[`:${label}`] = footnoteId; } else { footnoteId = env.footnotes.refs[`:${label}`]; } const footnoteSubId = env.footnotes.list[footnoteId].count; env.footnotes.list[footnoteId].count++; const token = state.push('footnote_ref', '', 0); token.meta = { id: footnoteId, subId: footnoteSubId, label }; } state.pos = pos; state.posMax = max; return true; } // Glue footnote tokens to end of token stream function footnote_tail(state: StateCore): void { let tokens: Token[] | null = null; let current: Token[]; let currentLabel: string; let insideRef = false; const refTokens: { [key: string]: Token[] } = {}; const env = state.env as FootnoteEnv; if (!env.footnotes) { return; } state.tokens = state.tokens.filter(function (tok) { if (tok.type === 'footnote_reference_open') { insideRef = true; current = []; currentLabel = tok.meta.label; return false; } if (tok.type === 'footnote_reference_close') { insideRef = false; // prepend ':' to avoid conflict with Object.prototype members refTokens[':' + currentLabel] = current; return false; } if (insideRef) { current.push(tok); } return !insideRef; }); if (!env.footnotes.list) { return; } const list = env.footnotes.list; state.tokens.push(new state.Token('footnote_block_open', '', 1)); for (let i = 0, l = list.length; i < l; i++) { const token_fo = new state.Token('footnote_open', '', 1); token_fo.meta = { id: i, label: list[i].label }; state.tokens.push(token_fo); if (list[i].tokens) { tokens = []; const token_po = new state.Token('paragraph_open', 'p', 1); token_po.block = true; tokens.push(token_po); const token_i = new state.Token('inline', '', 0); token_i.children = list[i].tokens || null; token_i.content = list[i].content || ''; tokens.push(token_i); const token_pc = new state.Token('paragraph_close', 'p', -1); token_pc.block = true; tokens.push(token_pc); } else if (list[i].label) { tokens = refTokens[`:${list[i].label}`] || null; } if (tokens) state.tokens = state.tokens.concat(tokens); let lastParagraph: Token | null; if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') { lastParagraph = state.tokens.pop()!; } else { lastParagraph = null; } const t = list[i].count > 0 ? list[i].count : 1; for (let j = 0; j < t; j++) { const token_a = new state.Token('footnote_anchor', '', 0); token_a.meta = { id: i, subId: j, label: list[i].label }; state.tokens.push(token_a); } if (lastParagraph) { state.tokens.push(lastParagraph); } state.tokens.push(new state.Token('footnote_close', '', -1)); } state.tokens.push(new state.Token('footnote_block_close', '', -1)); } md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] }); md.inline.ruler.after('image', 'footnote_inline', footnote_inline); md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref); md.core.ruler.after('inline', 'footnote_tail', footnote_tail); }