/** * @typedef {import('micromark-util-types').Event} Event * @typedef {import('micromark-util-types').Exiter} Exiter * @typedef {import('micromark-util-types').Extension} Extension * @typedef {import('micromark-util-types').Resolver} Resolver * @typedef {import('micromark-util-types').State} State * @typedef {import('micromark-util-types').Token} Token * @typedef {import('micromark-util-types').TokenizeContext} TokenizeContext * @typedef {import('micromark-util-types').Tokenizer} Tokenizer */ 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]: { tokenize: tokenizeDefinitionStart, continuation: { tokenize: tokenizeDefinitionContinuation }, exit: gfmFootnoteDefinitionEnd } }, text: { [91]: { tokenize: tokenizeGfmFootnoteCall }, [93]: { 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) } }