├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── index.js ├── lib └── index.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [closed, edited, labeled, opened, reopened, unlabeled] 12 | pull_request_target: 13 | types: [closed, edited, labeled, opened, reopened, unlabeled] 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: ${{matrix.node}} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v5 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | name: main 19 | on: 20 | - pull_request 21 | - push 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.log 3 | *.map 4 | *.tsbuildinfo 5 | .DS_Store 6 | coverage/ 7 | node_modules/ 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/index.js').FindAndReplaceList} FindAndReplaceList 3 | * @typedef {import('./lib/index.js').FindAndReplaceTuple} FindAndReplaceTuple 4 | * @typedef {import('./lib/index.js').Find} Find 5 | * @typedef {import('./lib/index.js').Options} Options 6 | * @typedef {import('./lib/index.js').RegExpMatchObject} RegExpMatchObject 7 | * @typedef {import('./lib/index.js').ReplaceFunction} ReplaceFunction 8 | * @typedef {import('./lib/index.js').Replace} Replace 9 | */ 10 | 11 | export {findAndReplace} from './lib/index.js' 12 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Nodes, Parents, PhrasingContent, Root, Text} from 'mdast' 3 | * @import {BuildVisitor, Test, VisitorResult} from 'unist-util-visit-parents' 4 | */ 5 | 6 | /** 7 | * @typedef RegExpMatchObject 8 | * Info on the match. 9 | * @property {number} index 10 | * The index of the search at which the result was found. 11 | * @property {string} input 12 | * A copy of the search string in the text node. 13 | * @property {[...Array, Text]} stack 14 | * All ancestors of the text node, where the last node is the text itself. 15 | * 16 | * @typedef {RegExp | string} Find 17 | * Pattern to find. 18 | * 19 | * Strings are escaped and then turned into global expressions. 20 | * 21 | * @typedef {Array} FindAndReplaceList 22 | * Several find and replaces, in array form. 23 | * 24 | * @typedef {[Find, Replace?]} FindAndReplaceTuple 25 | * Find and replace in tuple form. 26 | * 27 | * @typedef {ReplaceFunction | string | null | undefined} Replace 28 | * Thing to replace with. 29 | * 30 | * @callback ReplaceFunction 31 | * Callback called when a search matches. 32 | * @param {...any} parameters 33 | * The parameters are the result of corresponding search expression: 34 | * 35 | * * `value` (`string`) — whole match 36 | * * `...capture` (`Array`) — matches from regex capture groups 37 | * * `match` (`RegExpMatchObject`) — info on the match 38 | * @returns {Array | PhrasingContent | string | false | null | undefined} 39 | * Thing to replace with. 40 | * 41 | * * when `null`, `undefined`, `''`, remove the match 42 | * * …or when `false`, do not replace at all 43 | * * …or when `string`, replace with a text node of that value 44 | * * …or when `Node` or `Array`, replace with those nodes 45 | * 46 | * @typedef {[RegExp, ReplaceFunction]} Pair 47 | * Normalized find and replace. 48 | * 49 | * @typedef {Array} Pairs 50 | * All find and replaced. 51 | * 52 | * @typedef Options 53 | * Configuration. 54 | * @property {Test | null | undefined} [ignore] 55 | * Test for which nodes to ignore (optional). 56 | */ 57 | 58 | import escape from 'escape-string-regexp' 59 | import {visitParents} from 'unist-util-visit-parents' 60 | import {convert} from 'unist-util-is' 61 | 62 | /** 63 | * Find patterns in a tree and replace them. 64 | * 65 | * The algorithm searches the tree in *preorder* for complete values in `Text` 66 | * nodes. 67 | * Partial matches are not supported. 68 | * 69 | * @param {Nodes} tree 70 | * Tree to change. 71 | * @param {FindAndReplaceList | FindAndReplaceTuple} list 72 | * Patterns to find. 73 | * @param {Options | null | undefined} [options] 74 | * Configuration (when `find` is not `Find`). 75 | * @returns {undefined} 76 | * Nothing. 77 | */ 78 | export function findAndReplace(tree, list, options) { 79 | const settings = options || {} 80 | const ignored = convert(settings.ignore || []) 81 | const pairs = toPairs(list) 82 | let pairIndex = -1 83 | 84 | while (++pairIndex < pairs.length) { 85 | visitParents(tree, 'text', visitor) 86 | } 87 | 88 | /** @type {BuildVisitor} */ 89 | function visitor(node, parents) { 90 | let index = -1 91 | /** @type {Parents | undefined} */ 92 | let grandparent 93 | 94 | while (++index < parents.length) { 95 | const parent = parents[index] 96 | /** @type {Array | undefined} */ 97 | const siblings = grandparent ? grandparent.children : undefined 98 | 99 | if ( 100 | ignored( 101 | parent, 102 | siblings ? siblings.indexOf(parent) : undefined, 103 | grandparent 104 | ) 105 | ) { 106 | return 107 | } 108 | 109 | grandparent = parent 110 | } 111 | 112 | if (grandparent) { 113 | return handler(node, parents) 114 | } 115 | } 116 | 117 | /** 118 | * Handle a text node which is not in an ignored parent. 119 | * 120 | * @param {Text} node 121 | * Text node. 122 | * @param {Array} parents 123 | * Parents. 124 | * @returns {VisitorResult} 125 | * Result. 126 | */ 127 | function handler(node, parents) { 128 | const parent = parents[parents.length - 1] 129 | const find = pairs[pairIndex][0] 130 | const replace = pairs[pairIndex][1] 131 | let start = 0 132 | /** @type {Array} */ 133 | const siblings = parent.children 134 | const index = siblings.indexOf(node) 135 | let change = false 136 | /** @type {Array} */ 137 | let nodes = [] 138 | 139 | find.lastIndex = 0 140 | 141 | let match = find.exec(node.value) 142 | 143 | while (match) { 144 | const position = match.index 145 | /** @type {RegExpMatchObject} */ 146 | const matchObject = { 147 | index: match.index, 148 | input: match.input, 149 | stack: [...parents, node] 150 | } 151 | let value = replace(...match, matchObject) 152 | 153 | if (typeof value === 'string') { 154 | value = value.length > 0 ? {type: 'text', value} : undefined 155 | } 156 | 157 | // It wasn’t a match after all. 158 | if (value === false) { 159 | // False acts as if there was no match. 160 | // So we need to reset `lastIndex`, which currently being at the end of 161 | // the current match, to the beginning. 162 | find.lastIndex = position + 1 163 | } else { 164 | if (start !== position) { 165 | nodes.push({ 166 | type: 'text', 167 | value: node.value.slice(start, position) 168 | }) 169 | } 170 | 171 | if (Array.isArray(value)) { 172 | nodes.push(...value) 173 | } else if (value) { 174 | nodes.push(value) 175 | } 176 | 177 | start = position + match[0].length 178 | change = true 179 | } 180 | 181 | if (!find.global) { 182 | break 183 | } 184 | 185 | match = find.exec(node.value) 186 | } 187 | 188 | if (change) { 189 | if (start < node.value.length) { 190 | nodes.push({type: 'text', value: node.value.slice(start)}) 191 | } 192 | 193 | parent.children.splice(index, 1, ...nodes) 194 | } else { 195 | nodes = [node] 196 | } 197 | 198 | return index + nodes.length 199 | } 200 | } 201 | 202 | /** 203 | * Turn a tuple or a list of tuples into pairs. 204 | * 205 | * @param {FindAndReplaceList | FindAndReplaceTuple} tupleOrList 206 | * Schema. 207 | * @returns {Pairs} 208 | * Clean pairs. 209 | */ 210 | function toPairs(tupleOrList) { 211 | /** @type {Pairs} */ 212 | const result = [] 213 | 214 | if (!Array.isArray(tupleOrList)) { 215 | throw new TypeError('Expected find and replace tuple or list of tuples') 216 | } 217 | 218 | /** @type {FindAndReplaceList} */ 219 | // @ts-expect-error: correct. 220 | const list = 221 | !tupleOrList[0] || Array.isArray(tupleOrList[0]) 222 | ? tupleOrList 223 | : [tupleOrList] 224 | 225 | let index = -1 226 | 227 | while (++index < list.length) { 228 | const tuple = list[index] 229 | result.push([toExpression(tuple[0]), toFunction(tuple[1])]) 230 | } 231 | 232 | return result 233 | } 234 | 235 | /** 236 | * Turn a find into an expression. 237 | * 238 | * @param {Find} find 239 | * Find. 240 | * @returns {RegExp} 241 | * Expression. 242 | */ 243 | function toExpression(find) { 244 | return typeof find === 'string' ? new RegExp(escape(find), 'g') : find 245 | } 246 | 247 | /** 248 | * Turn a replace into a function. 249 | * 250 | * @param {Replace} replace 251 | * Replace. 252 | * @returns {ReplaceFunction} 253 | * Function. 254 | */ 255 | function toFunction(replace) { 256 | return typeof replace === 'function' 257 | ? replace 258 | : function () { 259 | return replace 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Titus Wormer (https://wooorm.com)", 3 | "bugs": "https://github.com/syntax-tree/mdast-util-find-and-replace/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)" 6 | ], 7 | "dependencies": { 8 | "@types/mdast": "^4.0.0", 9 | "escape-string-regexp": "^5.0.0", 10 | "unist-util-is": "^6.0.0", 11 | "unist-util-visit-parents": "^6.0.0" 12 | }, 13 | "description": "mdast utility to find and replace text in a tree", 14 | "devDependencies": { 15 | "@types/node": "^22.0.0", 16 | "c8": "^10.0.0", 17 | "prettier": "^3.0.0", 18 | "remark-cli": "^12.0.0", 19 | "remark-preset-wooorm": "^10.0.0", 20 | "type-coverage": "^2.0.0", 21 | "typescript": "^5.0.0", 22 | "unist-builder": "^4.0.0", 23 | "xo": "^0.60.0" 24 | }, 25 | "exports": "./index.js", 26 | "files": [ 27 | "index.d.ts.map", 28 | "index.d.ts", 29 | "index.js", 30 | "lib/" 31 | ], 32 | "funding": { 33 | "type": "opencollective", 34 | "url": "https://opencollective.com/unified" 35 | }, 36 | "keywords": [ 37 | "find", 38 | "markdown", 39 | "mdast-util", 40 | "mdast", 41 | "unist", 42 | "utility", 43 | "util", 44 | "replace" 45 | ], 46 | "license": "MIT", 47 | "name": "mdast-util-find-and-replace", 48 | "prettier": { 49 | "bracketSpacing": false, 50 | "semi": false, 51 | "singleQuote": true, 52 | "tabWidth": 2, 53 | "trailingComma": "none", 54 | "useTabs": false 55 | }, 56 | "remarkConfig": { 57 | "plugins": [ 58 | "remark-preset-wooorm" 59 | ] 60 | }, 61 | "repository": "syntax-tree/mdast-util-find-and-replace", 62 | "scripts": { 63 | "build": "tsc --build --clean && tsc --build && type-coverage", 64 | "format": "remark --frail --output --quiet -- . && prettier --log-level warn --write -- . && xo --fix", 65 | "test-api": "node --conditions development test.js", 66 | "test-coverage": "c8 --100 --reporter lcov -- npm run test-api", 67 | "test": "npm run build && npm run format && npm run test-coverage" 68 | }, 69 | "sideEffects": false, 70 | "typeCoverage": { 71 | "atLeast": 100, 72 | "ignoreFiles": [ 73 | "lib/index.d.ts" 74 | ], 75 | "strict": true 76 | }, 77 | "type": "module", 78 | "version": "3.0.2", 79 | "xo": { 80 | "prettier": true, 81 | "rules": { 82 | "unicorn/prefer-at": "off" 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # mdast-util-find-and-replace 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | [mdast][] utility to find and replace things. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`findAndReplace(tree, list[, options])`](#findandreplacetree-list-options) 21 | * [`Find`](#find) 22 | * [`FindAndReplaceList`](#findandreplacelist) 23 | * [`FindAndReplaceTuple`](#findandreplacetuple) 24 | * [`Options`](#options) 25 | * [`RegExpMatchObject`](#regexpmatchobject) 26 | * [`Replace`](#replace) 27 | * [`ReplaceFunction`](#replacefunction) 28 | * [Types](#types) 29 | * [Compatibility](#compatibility) 30 | * [Security](#security) 31 | * [Related](#related) 32 | * [Contribute](#contribute) 33 | * [License](#license) 34 | 35 | ## What is this? 36 | 37 | This package is a utility that lets you find patterns (`string`, `RegExp`) in 38 | text and replace them with nodes. 39 | 40 | ## When should I use this? 41 | 42 | This utility is typically useful when you have regexes and want to modify mdast. 43 | One example is when you have some form of “mentions” (such as 44 | `/@([a-z][_a-z0-9])\b/gi`) and want to create links to persons from them. 45 | 46 | A similar package, [`hast-util-find-and-replace`][hast-util-find-and-replace] 47 | does the same but on [hast][]. 48 | 49 | ## Install 50 | 51 | This package is [ESM only][esm]. 52 | In Node.js (version 16+), install with [npm][]: 53 | 54 | ```sh 55 | npm install mdast-util-find-and-replace 56 | ``` 57 | 58 | In Deno with [`esm.sh`][esmsh]: 59 | 60 | ```js 61 | import {findAndReplace} from 'https://esm.sh/mdast-util-find-and-replace@3' 62 | ``` 63 | 64 | In browsers with [`esm.sh`][esmsh]: 65 | 66 | ```html 67 | 70 | ``` 71 | 72 | ## Use 73 | 74 | ```js 75 | import {findAndReplace} from 'mdast-util-find-and-replace' 76 | import {u} from 'unist-builder' 77 | import {inspect} from 'unist-util-inspect' 78 | 79 | const tree = u('paragraph', [ 80 | u('text', 'Some '), 81 | u('emphasis', [u('text', 'emphasis')]), 82 | u('text', ' and '), 83 | u('strong', [u('text', 'importance')]), 84 | u('text', '.') 85 | ]) 86 | 87 | findAndReplace(tree, [ 88 | [/and/gi, 'or'], 89 | [/emphasis/gi, 'em'], 90 | [/importance/gi, 'strong'], 91 | [ 92 | /Some/g, 93 | function ($0) { 94 | return u('link', {url: '//example.com#' + $0}, [u('text', $0)]) 95 | } 96 | ] 97 | ]) 98 | 99 | console.log(inspect(tree)) 100 | ``` 101 | 102 | Yields: 103 | 104 | ```txt 105 | paragraph[8] 106 | ├─0 link[1] 107 | │ │ url: "//example.com#Some" 108 | │ └─0 text "Some" 109 | ├─1 text " " 110 | ├─2 emphasis[1] 111 | │ └─0 text "em" 112 | ├─3 text " " 113 | ├─4 text "or" 114 | ├─5 text " " 115 | ├─6 strong[1] 116 | │ └─0 text "strong" 117 | └─7 text "." 118 | ``` 119 | 120 | ## API 121 | 122 | This package exports the identifier [`findAndReplace`][api-find-and-replace]. 123 | There is no default export. 124 | 125 | ### `findAndReplace(tree, list[, options])` 126 | 127 | Find patterns in a tree and replace them. 128 | 129 | The algorithm searches the tree in *[preorder][]* for complete values in 130 | [`Text`][text] nodes. 131 | Partial matches are not supported. 132 | 133 | ###### Parameters 134 | 135 | * `tree` ([`Node`][node]) 136 | — tree to change 137 | * `list` ([`FindAndReplaceList`][api-find-and-replace-list] or 138 | [`FindAndReplaceTuple`][api-find-and-replace-tuple]) 139 | — one or more find-and-replace pairs 140 | * `options` ([`Options`][api-options]) 141 | — configuration 142 | 143 | ###### Returns 144 | 145 | Nothing (`undefined`). 146 | 147 | ### `Find` 148 | 149 | Pattern to find (TypeScript type). 150 | 151 | Strings are escaped and then turned into global expressions. 152 | 153 | ###### Type 154 | 155 | ```ts 156 | type Find = RegExp | string 157 | ``` 158 | 159 | ### `FindAndReplaceList` 160 | 161 | Several find and replaces, in array form (TypeScript type). 162 | 163 | ###### Type 164 | 165 | ```ts 166 | type FindAndReplaceList = Array 167 | ``` 168 | 169 | See [`FindAndReplaceTuple`][api-find-and-replace-tuple]. 170 | 171 | ### `FindAndReplaceTuple` 172 | 173 | Find and replace in tuple form (TypeScript type). 174 | 175 | ###### Type 176 | 177 | ```ts 178 | type FindAndReplaceTuple = [Find, Replace?] 179 | ``` 180 | 181 | See [`Find`][api-find] and [`Replace`][api-replace]. 182 | 183 | ### `Options` 184 | 185 | Configuration (TypeScript type). 186 | 187 | ###### Fields 188 | 189 | * `ignore` ([`Test`][test], optional) 190 | — test for which elements to ignore 191 | 192 | ### `RegExpMatchObject` 193 | 194 | Info on the match (TypeScript type). 195 | 196 | ###### Fields 197 | 198 | * `index` (`number`) 199 | — the index of the search at which the result was found 200 | * `input` (`string`) 201 | — a copy of the search string in the text node 202 | * `stack` ([`Array`][node]) 203 | — all ancestors of the text node, where the last node is the text itself 204 | 205 | ### `Replace` 206 | 207 | Thing to replace with (TypeScript type). 208 | 209 | ###### Type 210 | 211 | ```ts 212 | type Replace = ReplaceFunction | string 213 | ``` 214 | 215 | See [`ReplaceFunction`][api-replace-function]. 216 | 217 | ### `ReplaceFunction` 218 | 219 | Callback called when a search matches (TypeScript type). 220 | 221 | ###### Parameters 222 | 223 | The parameters are the result of corresponding search expression: 224 | 225 | * `value` (`string`) 226 | — whole match 227 | * `...capture` (`Array`) 228 | — matches from regex capture groups 229 | * `match` ([`RegExpMatchObject`][api-regexp-match-object]) 230 | — info on the match 231 | 232 | ###### Returns 233 | 234 | Thing to replace with: 235 | 236 | * when `null`, `undefined`, `''`, remove the match 237 | * …or when `false`, do not replace at all 238 | * …or when `string`, replace with a text node of that value 239 | * …or when `Node` or `Array`, replace with those nodes 240 | 241 | ## Types 242 | 243 | This package is fully typed with [TypeScript][]. 244 | It exports the additional types [`Find`][api-find], 245 | [`FindAndReplaceList`][api-find-and-replace-list], 246 | [`FindAndReplaceTuple`][api-find-and-replace-tuple], 247 | [`Options`][api-options], 248 | [`RegExpMatchObject`][api-regexp-match-object], 249 | [`Replace`][api-replace], and 250 | [`ReplaceFunction`][api-replace-function]. 251 | 252 | ## Compatibility 253 | 254 | Projects maintained by the unified collective are compatible with maintained 255 | versions of Node.js. 256 | 257 | When we cut a new major release, we drop support for unmaintained versions of 258 | Node. 259 | This means we try to keep the current release line, 260 | `mdast-util-find-and-replace@^3`, compatible with Node.js 16. 261 | 262 | ## Security 263 | 264 | Use of `mdast-util-find-and-replace` does not involve [hast][] or user content 265 | so there are no openings for [cross-site scripting (XSS)][xss] attacks. 266 | 267 | ## Related 268 | 269 | * [`hast-util-find-and-replace`](https://github.com/syntax-tree/hast-util-find-and-replace) 270 | — find and replace in hast 271 | * [`hast-util-select`](https://github.com/syntax-tree/hast-util-select) 272 | — `querySelector`, `querySelectorAll`, and `matches` 273 | * [`unist-util-select`](https://github.com/syntax-tree/unist-util-select) 274 | — select unist nodes with CSS-like selectors 275 | 276 | ## Contribute 277 | 278 | See [`contributing.md`][contributing] in [`syntax-tree/.github`][health] for 279 | ways to get started. 280 | See [`support.md`][support] for ways to get help. 281 | 282 | This project has a [code of conduct][coc]. 283 | By interacting with this repository, organisation, or community you agree to 284 | abide by its terms. 285 | 286 | ## License 287 | 288 | [MIT][license] © [Titus Wormer][author] 289 | 290 | 291 | 292 | [build-badge]: https://github.com/syntax-tree/mdast-util-find-and-replace/workflows/main/badge.svg 293 | 294 | [build]: https://github.com/syntax-tree/mdast-util-find-and-replace/actions 295 | 296 | [coverage-badge]: https://img.shields.io/codecov/c/github/syntax-tree/mdast-util-find-and-replace.svg 297 | 298 | [coverage]: https://codecov.io/github/syntax-tree/mdast-util-find-and-replace 299 | 300 | [downloads-badge]: https://img.shields.io/npm/dm/mdast-util-find-and-replace.svg 301 | 302 | [downloads]: https://www.npmjs.com/package/mdast-util-find-and-replace 303 | 304 | [size-badge]: https://img.shields.io/badge/dynamic/json?label=minzipped%20size&query=$.size.compressedSize&url=https://deno.bundlejs.com/?q=mdast-util-find-and-replace 305 | 306 | [size]: https://bundlejs.com/?q=mdast-util-find-and-replace 307 | 308 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 309 | 310 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 311 | 312 | [collective]: https://opencollective.com/unified 313 | 314 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 315 | 316 | [chat]: https://github.com/syntax-tree/unist/discussions 317 | 318 | [npm]: https://docs.npmjs.com/cli/install 319 | 320 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 321 | 322 | [esmsh]: https://esm.sh 323 | 324 | [typescript]: https://www.typescriptlang.org 325 | 326 | [license]: license 327 | 328 | [author]: https://wooorm.com 329 | 330 | [health]: https://github.com/syntax-tree/.github 331 | 332 | [contributing]: https://github.com/syntax-tree/.github/blob/main/contributing.md 333 | 334 | [support]: https://github.com/syntax-tree/.github/blob/main/support.md 335 | 336 | [coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md 337 | 338 | [hast]: https://github.com/syntax-tree/hast 339 | 340 | [mdast]: https://github.com/syntax-tree/mdast 341 | 342 | [node]: https://github.com/syntax-tree/mdast#nodes 343 | 344 | [preorder]: https://github.com/syntax-tree/unist#preorder 345 | 346 | [text]: https://github.com/syntax-tree/mdast#text 347 | 348 | [xss]: https://en.wikipedia.org/wiki/Cross-site_scripting 349 | 350 | [test]: https://github.com/syntax-tree/unist-util-is#api 351 | 352 | [hast-util-find-and-replace]: https://github.com/syntax-tree/hast-util-find-and-replace 353 | 354 | [api-find-and-replace]: #findandreplacetree-list-options 355 | 356 | [api-options]: #options 357 | 358 | [api-find]: #find 359 | 360 | [api-replace]: #replace 361 | 362 | [api-replace-function]: #replacefunction 363 | 364 | [api-find-and-replace-list]: #findandreplacelist 365 | 366 | [api-find-and-replace-tuple]: #findandreplacetuple 367 | 368 | [api-regexp-match-object]: #regexpmatchobject 369 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Root} from 'mdast' 3 | */ 4 | 5 | import assert from 'node:assert/strict' 6 | import test from 'node:test' 7 | import {findAndReplace} from 'mdast-util-find-and-replace' 8 | import {u} from 'unist-builder' 9 | 10 | test('findAndReplace', async function (t) { 11 | await t.test('should expose the public api', async function () { 12 | assert.deepEqual( 13 | Object.keys(await import('mdast-util-find-and-replace')).sort(), 14 | ['findAndReplace'] 15 | ) 16 | }) 17 | 18 | await t.test( 19 | 'should throw on invalid search and replaces', 20 | async function () { 21 | assert.throws(function () { 22 | // @ts-expect-error: check that the runtime throws an error. 23 | findAndReplace(create(), true) 24 | }, /Expected find and replace tuple or list of tuples/) 25 | } 26 | ) 27 | 28 | await t.test('should remove without `replace`', async function () { 29 | const tree = create() 30 | 31 | findAndReplace(tree, ['emphasis']) 32 | 33 | assert.deepEqual( 34 | tree, 35 | u('paragraph', [ 36 | u('text', 'Some '), 37 | u('emphasis', []), 38 | u('text', ', '), 39 | u('strong', [u('text', 'importance')]), 40 | u('text', ', and '), 41 | u('inlineCode', 'code'), 42 | u('text', '.') 43 | ]) 44 | ) 45 | }) 46 | 47 | await t.test( 48 | 'should work when given a find-and-replace tuple', 49 | async function () { 50 | const tree = create() 51 | findAndReplace(tree, ['emphasis', '!!!']) 52 | assert.deepEqual( 53 | tree, 54 | u('paragraph', [ 55 | u('text', 'Some '), 56 | u('emphasis', [u('text', '!!!')]), 57 | u('text', ', '), 58 | u('strong', [u('text', 'importance')]), 59 | u('text', ', and '), 60 | u('inlineCode', 'code'), 61 | u('text', '.') 62 | ]) 63 | ) 64 | } 65 | ) 66 | 67 | await t.test( 68 | 'should work when given `find` as a `RegExp` and `replace` as a `Function`', 69 | async function () { 70 | const tree = create() 71 | 72 | findAndReplace(tree, [ 73 | /em(\w+)is/, 74 | function (/** @type {string} */ _, /** @type {string} */ $1) { 75 | return '[' + $1 + ']' 76 | } 77 | ]) 78 | 79 | assert.deepEqual( 80 | tree, 81 | u('paragraph', [ 82 | u('text', 'Some '), 83 | u('emphasis', [u('text', '[phas]')]), 84 | u('text', ', '), 85 | u('strong', [u('text', 'importance')]), 86 | u('text', ', and '), 87 | u('inlineCode', 'code'), 88 | u('text', '.') 89 | ]) 90 | ) 91 | } 92 | ) 93 | 94 | await t.test( 95 | 'should work when given `replace` returns an empty string', 96 | async function () { 97 | const tree = create() 98 | 99 | findAndReplace(tree, [ 100 | 'emphasis', 101 | function () { 102 | return '' 103 | } 104 | ]) 105 | 106 | assert.deepEqual( 107 | tree, 108 | u('paragraph', [ 109 | u('text', 'Some '), 110 | u('emphasis', []), 111 | u('text', ', '), 112 | u('strong', [u('text', 'importance')]), 113 | u('text', ', and '), 114 | u('inlineCode', 'code'), 115 | u('text', '.') 116 | ]) 117 | ) 118 | } 119 | ) 120 | 121 | await t.test( 122 | 'should work when given `replace` returns a node', 123 | async function () { 124 | const tree = create() 125 | 126 | findAndReplace(tree, [ 127 | 'emphasis', 128 | function () { 129 | return u('delete', [u('break')]) 130 | } 131 | ]) 132 | 133 | assert.deepEqual( 134 | tree, 135 | u('paragraph', [ 136 | u('text', 'Some '), 137 | u('emphasis', [u('delete', [u('break')])]), 138 | u('text', ', '), 139 | u('strong', [u('text', 'importance')]), 140 | u('text', ', and '), 141 | u('inlineCode', 'code'), 142 | u('text', '.') 143 | ]) 144 | ) 145 | } 146 | ) 147 | 148 | await t.test( 149 | 'should work when given `replace` returns a list of nodes', 150 | async function () { 151 | const tree = create() 152 | 153 | findAndReplace(tree, [ 154 | 'emphasis', 155 | function () { 156 | return [u('delete', []), u('break')] 157 | } 158 | ]) 159 | 160 | assert.deepEqual( 161 | tree, 162 | u('paragraph', [ 163 | u('text', 'Some '), 164 | u('emphasis', [u('delete', []), u('break')]), 165 | u('text', ', '), 166 | u('strong', [u('text', 'importance')]), 167 | u('text', ', and '), 168 | u('inlineCode', 'code'), 169 | u('text', '.') 170 | ]) 171 | ) 172 | } 173 | ) 174 | 175 | await t.test('should work when given a list of tuples', async function () { 176 | const tree = create() 177 | 178 | findAndReplace(tree, [ 179 | ['emphasis', '!!!'], 180 | ['importance', '???'] 181 | ]) 182 | 183 | assert.deepEqual( 184 | tree, 185 | u('paragraph', [ 186 | u('text', 'Some '), 187 | u('emphasis', [u('text', '!!!')]), 188 | u('text', ', '), 189 | u('strong', [u('text', '???')]), 190 | u('text', ', and '), 191 | u('inlineCode', 'code'), 192 | u('text', '.') 193 | ]) 194 | ) 195 | }) 196 | 197 | await t.test( 198 | 'should work when given an empty list of tuples', 199 | async function () { 200 | const tree = create() 201 | 202 | findAndReplace(tree, []) 203 | 204 | assert.deepEqual(tree, create()) 205 | } 206 | ) 207 | 208 | await t.test('should work on partial matches', async function () { 209 | const tree = create() 210 | 211 | findAndReplace(tree, [/\Bmp\B/, '[MP]']) 212 | 213 | assert.deepEqual( 214 | tree, 215 | u('paragraph', [ 216 | u('text', 'Some '), 217 | u('emphasis', [u('text', 'e'), u('text', '[MP]'), u('text', 'hasis')]), 218 | u('text', ', '), 219 | u('strong', [u('text', 'i'), u('text', '[MP]'), u('text', 'ortance')]), 220 | u('text', ', and '), 221 | u('inlineCode', 'code'), 222 | u('text', '.') 223 | ]) 224 | ) 225 | }) 226 | 227 | await t.test('should find-and-replace recursively', async function () { 228 | const tree = create() 229 | 230 | findAndReplace(tree, [ 231 | [ 232 | 'emphasis', 233 | function () { 234 | return u('link', {url: 'x'}, [u('text', 'importance')]) 235 | } 236 | ], 237 | ['importance', 'something else'] 238 | ]) 239 | 240 | assert.deepEqual( 241 | tree, 242 | 243 | u('paragraph', [ 244 | u('text', 'Some '), 245 | u('emphasis', [u('link', {url: 'x'}, [u('text', 'something else')])]), 246 | u('text', ', '), 247 | u('strong', [u('text', 'something else')]), 248 | u('text', ', and '), 249 | u('inlineCode', 'code'), 250 | u('text', '.') 251 | ]) 252 | ) 253 | }) 254 | 255 | await t.test('should ignore from options', async function () { 256 | const tree = u('paragraph', [ 257 | u('text', 'Some '), 258 | u('emphasis', [u('text', 'importance')]), 259 | u('text', ' and '), 260 | u('strong', [u('text', 'importance')]), 261 | u('text', '.') 262 | ]) 263 | 264 | findAndReplace(tree, ['importance', '!!!'], {ignore: 'strong'}) 265 | 266 | assert.deepEqual( 267 | tree, 268 | u('paragraph', [ 269 | u('text', 'Some '), 270 | u('emphasis', [u('text', '!!!')]), 271 | u('text', ' and '), 272 | u('strong', [u('text', 'importance')]), 273 | u('text', '.') 274 | ]) 275 | ) 276 | }) 277 | 278 | await t.test('should not be order-sensitive with strings', async function () { 279 | const tree = u('paragraph', [ 280 | u('text', 'Some emphasis, importance, and code.') 281 | ]) 282 | 283 | findAndReplace(tree, [ 284 | [ 285 | 'importance', 286 | function (/** @type {string} */ value) { 287 | return u('strong', [u('text', value)]) 288 | } 289 | ], 290 | [ 291 | 'code', 292 | function (/** @type {string} */ value) { 293 | return u('inlineCode', value) 294 | } 295 | ], 296 | [ 297 | 'emphasis', 298 | function (/** @type {string} */ value) { 299 | return u('emphasis', [u('text', value)]) 300 | } 301 | ] 302 | ]) 303 | 304 | assert.deepEqual(tree, create()) 305 | }) 306 | 307 | await t.test('should not be order-sensitive with regexes', async function () { 308 | const tree = u('paragraph', [ 309 | u('text', 'Some emphasis, importance, and code.') 310 | ]) 311 | 312 | findAndReplace(tree, [ 313 | [ 314 | /importance/g, 315 | function (/** @type {string} */ value) { 316 | return u('strong', [u('text', value)]) 317 | } 318 | ], 319 | [ 320 | /code/g, 321 | function (/** @type {string} */ value) { 322 | return u('inlineCode', value) 323 | } 324 | ], 325 | [ 326 | /emphasis/g, 327 | function (/** @type {string} */ value) { 328 | return u('emphasis', [u('text', value)]) 329 | } 330 | ] 331 | ]) 332 | 333 | assert.deepEqual(tree, create()) 334 | }) 335 | 336 | await t.test('should support a match, and then a `false`', async function () { 337 | const tree = u('paragraph', [u('text', 'aaa bbb')]) 338 | 339 | findAndReplace(tree, [ 340 | [ 341 | /\b\w+\b/g, 342 | function (/** @type {string} */ value) { 343 | return value === 'aaa' ? u('strong', [u('text', value)]) : false 344 | } 345 | ] 346 | ]) 347 | 348 | assert.deepEqual(tree, { 349 | type: 'paragraph', 350 | children: [ 351 | {type: 'strong', children: [{type: 'text', value: 'aaa'}]}, 352 | {type: 'text', value: ' bbb'} 353 | ] 354 | }) 355 | }) 356 | 357 | await t.test('should not replace when returning false', async function () { 358 | const tree = create() 359 | 360 | findAndReplace(tree, [ 361 | 'emphasis', 362 | function () { 363 | return false 364 | } 365 | ]) 366 | 367 | assert.deepEqual( 368 | tree, 369 | u('paragraph', [ 370 | u('text', 'Some '), 371 | u('emphasis', [u('text', 'emphasis')]), 372 | u('text', ', '), 373 | u('strong', [u('text', 'importance')]), 374 | u('text', ', and '), 375 | u('inlineCode', 'code'), 376 | u('text', '.') 377 | ]) 378 | ) 379 | }) 380 | 381 | await t.test('should not treat `false` as a match', async function () { 382 | /** @type {Root} */ 383 | const tree = {type: 'root', children: [{type: 'text', value: ':1:2:'}]} 384 | 385 | findAndReplace(tree, [ 386 | /:(\d+):/g, 387 | /** 388 | * @param {string} _ 389 | * @param {string} $1 390 | */ 391 | function (_, $1) { 392 | return $1 === '2' ? u('strong', [u('text', $1)]) : false 393 | } 394 | ]) 395 | 396 | assert.deepEqual(tree, { 397 | type: 'root', 398 | children: [ 399 | {type: 'text', value: ':1'}, 400 | {type: 'strong', children: [{type: 'text', value: '2'}]} 401 | ] 402 | }) 403 | }) 404 | 405 | await t.test('should not recurse into a replaced value', async function () { 406 | const tree = u('paragraph', [u('text', 'asd.')]) 407 | 408 | findAndReplace(tree, [ 409 | 'asd', 410 | function (/** @type {string} */ d) { 411 | return d 412 | } 413 | ]) 414 | 415 | assert.deepEqual(tree, u('paragraph', [u('text', 'asd'), u('text', '.')])) 416 | }) 417 | 418 | await t.test( 419 | 'should not recurse into a replaced node (head)', 420 | async function () { 421 | const tree = u('paragraph', [u('text', 'asd.')]) 422 | 423 | findAndReplace(tree, [ 424 | 'asd', 425 | function (/** @type {string} */ d) { 426 | return u('emphasis', [u('text', d)]) 427 | } 428 | ]) 429 | 430 | assert.deepEqual( 431 | tree, 432 | u('paragraph', [u('emphasis', [u('text', 'asd')]), u('text', '.')]) 433 | ) 434 | } 435 | ) 436 | 437 | await t.test( 438 | 'should not recurse into a replaced node (tail)', 439 | async function () { 440 | const tree = u('paragraph', [u('text', '.asd')]) 441 | 442 | findAndReplace(tree, [ 443 | 'asd', 444 | function (/** @type {string} */ d) { 445 | return u('emphasis', [u('text', d)]) 446 | } 447 | ]) 448 | 449 | assert.deepEqual( 450 | tree, 451 | u('paragraph', [u('text', '.'), u('emphasis', [u('text', 'asd')])]) 452 | ) 453 | } 454 | ) 455 | 456 | await t.test( 457 | 'should not recurse into a replaced node (head and tail)', 458 | async function () { 459 | const tree = u('paragraph', [u('text', 'asd')]) 460 | 461 | findAndReplace(tree, [ 462 | 'asd', 463 | function (/** @type {string} */ d) { 464 | return u('emphasis', [u('text', d)]) 465 | } 466 | ]) 467 | 468 | assert.deepEqual( 469 | tree, 470 | u('paragraph', [u('emphasis', [u('text', 'asd')])]) 471 | ) 472 | } 473 | ) 474 | 475 | await t.test('security: replacer as string (safe)', async function () { 476 | const tree = create() 477 | 478 | findAndReplace(tree, ['and', 'alert(1)']) 479 | 480 | assert.deepEqual( 481 | tree, 482 | u('paragraph', [ 483 | u('text', 'Some '), 484 | u('emphasis', [u('text', 'emphasis')]), 485 | u('text', ', '), 486 | u('strong', [u('text', 'importance')]), 487 | u('text', ', '), 488 | u('text', 'alert(1)'), 489 | u('text', ' '), 490 | u('inlineCode', 'code'), 491 | u('text', '.') 492 | ]) 493 | ) 494 | }) 495 | 496 | await t.test( 497 | 'should replace multiple matches in the same node', 498 | async function () { 499 | const tree = create() 500 | 501 | findAndReplace(tree, [/(emph|sis)/g, 'foo']) 502 | 503 | assert.deepEqual( 504 | tree, 505 | u('paragraph', [ 506 | u('text', 'Some '), 507 | u('emphasis', [u('text', 'foo'), u('text', 'a'), u('text', 'foo')]), 508 | u('text', ', '), 509 | u('strong', [u('text', 'importance')]), 510 | u('text', ', and '), 511 | u('inlineCode', 'code'), 512 | u('text', '.') 513 | ]) 514 | ) 515 | } 516 | ) 517 | }) 518 | 519 | function create() { 520 | return u('paragraph', [ 521 | u('text', 'Some '), 522 | u('emphasis', [u('text', 'emphasis')]), 523 | u('text', ', '), 524 | u('strong', [u('text', 'importance')]), 525 | u('text', ', and '), 526 | u('inlineCode', 'code'), 527 | u('text', '.') 528 | ]) 529 | } 530 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js"] 16 | } 17 | --------------------------------------------------------------------------------