/** * @typedef {import('mdast').Nodes} Nodes * @typedef {import('mdast').Parents} Parents * @typedef {import('mdast').PhrasingContent} PhrasingContent * @typedef {import('mdast').Root} Root * @typedef {import('mdast').Text} Text * @typedef {import('unist-util-visit-parents').Test} Test * @typedef {import('unist-util-visit-parents').VisitorResult} VisitorResult */ /** * @typedef RegExpMatchObject * Info on the match. * @property {number} index * The index of the search at which the result was found. * @property {string} input * A copy of the search string in the text node. * @property {[...Array, Text]} stack * All ancestors of the text node, where the last node is the text itself. * * @typedef {RegExp | string} Find * Pattern to find. * * Strings are escaped and then turned into global expressions. * * @typedef {Array} FindAndReplaceList * Several find and replaces, in array form. * * @typedef {[Find, Replace?]} FindAndReplaceTuple * Find and replace in tuple form. * * @typedef {ReplaceFunction | string | null | undefined} Replace * Thing to replace with. * * @callback ReplaceFunction * Callback called when a search matches. * @param {...any} parameters * The parameters are the result of corresponding search expression: * * * `value` (`string`) — whole match * * `...capture` (`Array`) — matches from regex capture groups * * `match` (`RegExpMatchObject`) — info on the match * @returns {Array | PhrasingContent | string | false | null | undefined} * Thing to replace with. * * * when `null`, `undefined`, `''`, remove the match * * …or when `false`, do not replace at all * * …or when `string`, replace with a text node of that value * * …or when `Node` or `Array`, replace with those nodes * * @typedef {[RegExp, ReplaceFunction]} Pair * Normalized find and replace. * * @typedef {Array} Pairs * All find and replaced. * * @typedef Options * Configuration. * @property {Test | null | undefined} [ignore] * Test for which nodes to ignore (optional). */ import escape from 'escape-string-regexp' import {visitParents} from 'unist-util-visit-parents' import {convert} from 'unist-util-is' /** * Find patterns in a tree and replace them. * * The algorithm searches the tree in *preorder* for complete values in `Text` * nodes. * Partial matches are not supported. * * @param {Nodes} tree * Tree to change. * @param {FindAndReplaceList | FindAndReplaceTuple} list * Patterns to find. * @param {Options | null | undefined} [options] * Configuration (when `find` is not `Find`). * @returns {undefined} * Nothing. */ export function findAndReplace(tree, list, options) { const settings = options || {} const ignored = convert(settings.ignore || []) const pairs = toPairs(list) let pairIndex = -1 while (++pairIndex < pairs.length) { visitParents(tree, 'text', visitor) } /** @type {import('unist-util-visit-parents').BuildVisitor} */ function visitor(node, parents) { let index = -1 /** @type {Parents | undefined} */ let grandparent while (++index < parents.length) { const parent = parents[index] /** @type {Array | undefined} */ const siblings = grandparent ? grandparent.children : undefined if ( ignored( parent, siblings ? siblings.indexOf(parent) : undefined, grandparent ) ) { return } grandparent = parent } if (grandparent) { return handler(node, parents) } } /** * Handle a text node which is not in an ignored parent. * * @param {Text} node * Text node. * @param {Array} parents * Parents. * @returns {VisitorResult} * Result. */ function handler(node, parents) { const parent = parents[parents.length - 1] const find = pairs[pairIndex][0] const replace = pairs[pairIndex][1] let start = 0 /** @type {Array} */ const siblings = parent.children const index = siblings.indexOf(node) let change = false /** @type {Array} */ let nodes = [] find.lastIndex = 0 let match = find.exec(node.value) while (match) { const position = match.index /** @type {RegExpMatchObject} */ const matchObject = { index: match.index, input: match.input, stack: [...parents, node] } let value = replace(...match, matchObject) if (typeof value === 'string') { value = value.length > 0 ? {type: 'text', value} : undefined } // It wasn’t a match after all. if (value === false) { // False acts as if there was no match. // So we need to reset `lastIndex`, which currently being at the end of // the current match, to the beginning. find.lastIndex = position + 1 } else { if (start !== position) { nodes.push({ type: 'text', value: node.value.slice(start, position) }) } if (Array.isArray(value)) { nodes.push(...value) } else if (value) { nodes.push(value) } start = position + match[0].length change = true } if (!find.global) { break } match = find.exec(node.value) } if (change) { if (start < node.value.length) { nodes.push({type: 'text', value: node.value.slice(start)}) } parent.children.splice(index, 1, ...nodes) } else { nodes = [node] } return index + nodes.length } } /** * Turn a tuple or a list of tuples into pairs. * * @param {FindAndReplaceList | FindAndReplaceTuple} tupleOrList * Schema. * @returns {Pairs} * Clean pairs. */ function toPairs(tupleOrList) { /** @type {Pairs} */ const result = [] if (!Array.isArray(tupleOrList)) { throw new TypeError('Expected find and replace tuple or list of tuples') } /** @type {FindAndReplaceList} */ // @ts-expect-error: correct. const list = !tupleOrList[0] || Array.isArray(tupleOrList[0]) ? tupleOrList : [tupleOrList] let index = -1 while (++index < list.length) { const tuple = list[index] result.push([toExpression(tuple[0]), toFunction(tuple[1])]) } return result } /** * Turn a find into an expression. * * @param {Find} find * Find. * @returns {RegExp} * Expression. */ function toExpression(find) { return typeof find === 'string' ? new RegExp(escape(find), 'g') : find } /** * Turn a replace into a function. * * @param {Replace} replace * Replace. * @returns {ReplaceFunction} * Function. */ function toFunction(replace) { return typeof replace === 'function' ? replace : function () { return replace } }