/** * @import {Event, Exiter, Extension, Resolver, State, Token, TokenizeContext, Tokenizer} from 'micromark-util-types' */ import { blankLine } from 'micromark-core-commonmark'; import { factorySpace } from 'micromark-factory-space'; import { markdownLineEndingOrSpace } from 'micromark-util-character'; import { normalizeIdentifier } from 'micromark-util-normalize-identifier'; const indent = { tokenize: tokenizeIndent, partial: true }; // To do: micromark should support a `_hiddenGfmFootnoteSupport`, which only // affects label start (image). // That will let us drop `tokenizePotentialGfmFootnote*`. // It currently has a `_hiddenFootnoteSupport`, which affects that and more. // That can be removed when `micromark-extension-footnote` is archived. /** * Create an extension for `micromark` to enable GFM footnote syntax. * * @returns {Extension} * Extension for `micromark` that can be passed in `extensions` to * enable GFM footnote syntax. */ export function gfmFootnote() { /** @type {Extension} */ return { document: { [91]: { name: 'gfmFootnoteDefinition', tokenize: tokenizeDefinitionStart, continuation: { tokenize: tokenizeDefinitionContinuation }, exit: gfmFootnoteDefinitionEnd } }, text: { [91]: { name: 'gfmFootnoteCall', tokenize: tokenizeGfmFootnoteCall }, [93]: { name: 'gfmPotentialFootnoteCall', add: 'after', tokenize: tokenizePotentialGfmFootnoteCall, resolveTo: resolveToPotentialGfmFootnoteCall } } }; } // To do: remove after micromark update. /** * @this {TokenizeContext} * @type {Tokenizer} */ function tokenizePotentialGfmFootnoteCall(effects, ok, nok) { const self = this; let index = self.events.length; const defined = self.parser.gfmFootnotes || (self.parser.gfmFootnotes = []); /** @type {Token} */ let labelStart; // Find an opening. while (index--) { const token = self.events[index][1]; if (token.type === "labelImage") { labelStart = token; break; } // Exit if we’ve walked far enough. if (token.type === 'gfmFootnoteCall' || token.type === "labelLink" || token.type === "label" || token.type === "image" || token.type === "link") { break; } } return start; /** * @type {State} */ function start(code) { if (!labelStart || !labelStart._balanced) { return nok(code); } const id = normalizeIdentifier(self.sliceSerialize({ start: labelStart.end, end: self.now() })); if (id.codePointAt(0) !== 94 || !defined.includes(id.slice(1))) { return nok(code); } effects.enter('gfmFootnoteCallLabelMarker'); effects.consume(code); effects.exit('gfmFootnoteCallLabelMarker'); return ok(code); } } // To do: remove after micromark update. /** @type {Resolver} */ function resolveToPotentialGfmFootnoteCall(events, context) { let index = events.length; /** @type {Token | undefined} */ let labelStart; // Find an opening. while (index--) { if (events[index][1].type === "labelImage" && events[index][0] === 'enter') { labelStart = events[index][1]; break; } } // Change the `labelImageMarker` to a `data`. events[index + 1][1].type = "data"; events[index + 3][1].type = 'gfmFootnoteCallLabelMarker'; // The whole (without `!`): /** @type {Token} */ const call = { type: 'gfmFootnoteCall', start: Object.assign({}, events[index + 3][1].start), end: Object.assign({}, events[events.length - 1][1].end) }; // The `^` marker /** @type {Token} */ const marker = { type: 'gfmFootnoteCallMarker', start: Object.assign({}, events[index + 3][1].end), end: Object.assign({}, events[index + 3][1].end) }; // Increment the end 1 character. marker.end.column++; marker.end.offset++; marker.end._bufferIndex++; /** @type {Token} */ const string = { type: 'gfmFootnoteCallString', start: Object.assign({}, marker.end), end: Object.assign({}, events[events.length - 1][1].start) }; /** @type {Token} */ const chunk = { type: "chunkString", contentType: 'string', start: Object.assign({}, string.start), end: Object.assign({}, string.end) }; /** @type {Array} */ const replacement = [ // Take the `labelImageMarker` (now `data`, the `!`) events[index + 1], events[index + 2], ['enter', call, context], // The `[` events[index + 3], events[index + 4], // The `^`. ['enter', marker, context], ['exit', marker, context], // Everything in between. ['enter', string, context], ['enter', chunk, context], ['exit', chunk, context], ['exit', string, context], // The ending (`]`, properly parsed and labelled). events[events.length - 2], events[events.length - 1], ['exit', call, context]]; events.splice(index, events.length - index + 1, ...replacement); return events; } /** * @this {TokenizeContext} * @type {Tokenizer} */ function tokenizeGfmFootnoteCall(effects, ok, nok) { const self = this; const defined = self.parser.gfmFootnotes || (self.parser.gfmFootnotes = []); let size = 0; /** @type {boolean} */ let data; // Note: the implementation of `markdown-rs` is different, because it houses // core *and* extensions in one project. // Therefore, it can include footnote logic inside `label-end`. // We can’t do that, but luckily, we can parse footnotes in a simpler way than // needed for labels. return start; /** * Start of footnote label. * * ```markdown * > | a [^b] c * ^ * ``` * * @type {State} */ function start(code) { effects.enter('gfmFootnoteCall'); effects.enter('gfmFootnoteCallLabelMarker'); effects.consume(code); effects.exit('gfmFootnoteCallLabelMarker'); return callStart; } /** * After `[`, at `^`. * * ```markdown * > | a [^b] c * ^ * ``` * * @type {State} */ function callStart(code) { if (code !== 94) return nok(code); effects.enter('gfmFootnoteCallMarker'); effects.consume(code); effects.exit('gfmFootnoteCallMarker'); effects.enter('gfmFootnoteCallString'); effects.enter('chunkString').contentType = 'string'; return callData; } /** * In label. * * ```markdown * > | a [^b] c * ^ * ``` * * @type {State} */ function callData(code) { if ( // Too long. size > 999 || // Closing brace with nothing. code === 93 && !data || // Space or tab is not supported by GFM for some reason. // `\n` and `[` not being supported makes sense. code === null || code === 91 || markdownLineEndingOrSpace(code)) { return nok(code); } if (code === 93) { effects.exit('chunkString'); const token = effects.exit('gfmFootnoteCallString'); if (!defined.includes(normalizeIdentifier(self.sliceSerialize(token)))) { return nok(code); } effects.enter('gfmFootnoteCallLabelMarker'); effects.consume(code); effects.exit('gfmFootnoteCallLabelMarker'); effects.exit('gfmFootnoteCall'); return ok; } if (!markdownLineEndingOrSpace(code)) { data = true; } size++; effects.consume(code); return code === 92 ? callEscape : callData; } /** * On character after escape. * * ```markdown * > | a [^b\c] d * ^ * ``` * * @type {State} */ function callEscape(code) { if (code === 91 || code === 92 || code === 93) { effects.consume(code); size++; return callData; } return callData(code); } } /** * @this {TokenizeContext} * @type {Tokenizer} */ function tokenizeDefinitionStart(effects, ok, nok) { const self = this; const defined = self.parser.gfmFootnotes || (self.parser.gfmFootnotes = []); /** @type {string} */ let identifier; let size = 0; /** @type {boolean | undefined} */ let data; return start; /** * Start of GFM footnote definition. * * ```markdown * > | [^a]: b * ^ * ``` * * @type {State} */ function start(code) { effects.enter('gfmFootnoteDefinition')._container = true; effects.enter('gfmFootnoteDefinitionLabel'); effects.enter('gfmFootnoteDefinitionLabelMarker'); effects.consume(code); effects.exit('gfmFootnoteDefinitionLabelMarker'); return labelAtMarker; } /** * In label, at caret. * * ```markdown * > | [^a]: b * ^ * ``` * * @type {State} */ function labelAtMarker(code) { if (code === 94) { effects.enter('gfmFootnoteDefinitionMarker'); effects.consume(code); effects.exit('gfmFootnoteDefinitionMarker'); effects.enter('gfmFootnoteDefinitionLabelString'); effects.enter('chunkString').contentType = 'string'; return labelInside; } return nok(code); } /** * In label. * * > 👉 **Note**: `cmark-gfm` prevents whitespace from occurring in footnote * > definition labels. * * ```markdown * > | [^a]: b * ^ * ``` * * @type {State} */ function labelInside(code) { if ( // Too long. size > 999 || // Closing brace with nothing. code === 93 && !data || // Space or tab is not supported by GFM for some reason. // `\n` and `[` not being supported makes sense. code === null || code === 91 || markdownLineEndingOrSpace(code)) { return nok(code); } if (code === 93) { effects.exit('chunkString'); const token = effects.exit('gfmFootnoteDefinitionLabelString'); identifier = normalizeIdentifier(self.sliceSerialize(token)); effects.enter('gfmFootnoteDefinitionLabelMarker'); effects.consume(code); effects.exit('gfmFootnoteDefinitionLabelMarker'); effects.exit('gfmFootnoteDefinitionLabel'); return labelAfter; } if (!markdownLineEndingOrSpace(code)) { data = true; } size++; effects.consume(code); return code === 92 ? labelEscape : labelInside; } /** * After `\`, at a special character. * * > 👉 **Note**: `cmark-gfm` currently does not support escaped brackets: * > * * ```markdown * > | [^a\*b]: c * ^ * ``` * * @type {State} */ function labelEscape(code) { if (code === 91 || code === 92 || code === 93) { effects.consume(code); size++; return labelInside; } return labelInside(code); } /** * After definition label. * * ```markdown * > | [^a]: b * ^ * ``` * * @type {State} */ function labelAfter(code) { if (code === 58) { effects.enter('definitionMarker'); effects.consume(code); effects.exit('definitionMarker'); if (!defined.includes(identifier)) { defined.push(identifier); } // Any whitespace after the marker is eaten, forming indented code // is not possible. // No space is also fine, just like a block quote marker. return factorySpace(effects, whitespaceAfter, 'gfmFootnoteDefinitionWhitespace'); } return nok(code); } /** * After definition prefix. * * ```markdown * > | [^a]: b * ^ * ``` * * @type {State} */ function whitespaceAfter(code) { // `markdown-rs` has a wrapping token for the prefix that is closed here. return ok(code); } } /** * @this {TokenizeContext} * @type {Tokenizer} */ function tokenizeDefinitionContinuation(effects, ok, nok) { /// Start of footnote definition continuation. /// /// ```markdown /// | [^a]: b /// > | c /// ^ /// ``` // // Either a blank line, which is okay, or an indented thing. return effects.check(blankLine, ok, effects.attempt(indent, ok, nok)); } /** @type {Exiter} */ function gfmFootnoteDefinitionEnd(effects) { effects.exit('gfmFootnoteDefinition'); } /** * @this {TokenizeContext} * @type {Tokenizer} */ function tokenizeIndent(effects, ok, nok) { const self = this; return factorySpace(effects, afterPrefix, 'gfmFootnoteDefinitionIndent', 4 + 1); /** * @type {State} */ function afterPrefix(code) { const tail = self.events[self.events.length - 1]; return tail && tail[1].type === 'gfmFootnoteDefinitionIndent' && tail[2].sliceSerialize(tail[1], true).length === 4 ? ok(code) : nok(code); } }