├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DESIGN_DOCUMENT.md ├── LICENSE.txt ├── MiniSearch.png ├── MiniSearch.svg ├── README.md ├── benchmarks ├── autoSuggestion.js ├── combinedSearch.js ├── divinaCommedia.js ├── exactSearch.js ├── fuzzySearch.js ├── index.js ├── indexing.js ├── loadIndex.js ├── memory.js ├── prefixSearch.js ├── ranking.js └── searchFiltering.js ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── icons.js │ ├── icons.svg │ ├── main.js │ ├── navigation.js │ ├── search.js │ └── style.css ├── classes │ ├── MiniSearch.MiniSearch.html │ └── SearchableMap_SearchableMap.SearchableMap.html ├── demo │ ├── README.md │ ├── app.css │ ├── app.js │ ├── billboard_1965-2015.json │ └── index.html ├── index.html ├── modules │ ├── MiniSearch.html │ └── SearchableMap_SearchableMap.html └── types │ ├── MiniSearch.AutoVacuumOptions.html │ ├── MiniSearch.BM25Params.html │ ├── MiniSearch.CombinationOperator.html │ ├── MiniSearch.LowercaseCombinationOperator.html │ ├── MiniSearch.MatchInfo.html │ ├── MiniSearch.Options.html │ ├── MiniSearch.Query.html │ ├── MiniSearch.QueryCombination.html │ ├── MiniSearch.SearchOptions.html │ ├── MiniSearch.SearchResult.html │ ├── MiniSearch.Suggestion.html │ ├── MiniSearch.VacuumConditions.html │ ├── MiniSearch.VacuumOptions.html │ └── MiniSearch.Wildcard.html ├── examples ├── billboard_1965-2015.json └── plain_js │ ├── README.md │ ├── app.css │ ├── app.js │ ├── billboard_1965-2015.json │ └── index.html ├── package.json ├── rollup.config.js ├── src ├── MiniSearch.test.js ├── MiniSearch.ts ├── SearchableMap │ ├── SearchableMap.test.js │ ├── SearchableMap.ts │ ├── TreeIterator.ts │ ├── fuzzySearch.ts │ └── types.ts ├── index.ts └── testSetup │ └── jest.js ├── tsconfig.json ├── typedoc.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "standard", 13 | "plugin:@typescript-eslint/eslint-recommended" 14 | ], 15 | "rules": { 16 | "quotes": ["error", "single", { "avoidEscape": true }], 17 | "no-prototype-builtins": 0, 18 | "no-use-before-define": 0, 19 | "@typescript-eslint/no-use-before-define": [ 20 | "error", 21 | { "functions": false, "classes": false, "variables": false } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI Build 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | test: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 'latest' 26 | cache: 'yarn' 27 | 28 | - name: Install dependencies 29 | run: yarn install --frozen-lockfile 30 | 31 | - name: Run tests 32 | run: yarn test 33 | 34 | coverage: 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-node@v4 41 | with: 42 | node-version: 'latest' 43 | cache: 'yarn' 44 | 45 | - name: Install dependencies 46 | run: yarn install --frozen-lockfile 47 | 48 | - name: Report coverage 49 | env: 50 | COVERALLS_REPO_TOKEN: "${{ secrets.COVERALLS_REPO_TOKEN }}" 51 | COVERALLS_GIT_BRANCH: "${{ github.ref }}" 52 | COVERALLS_SERVICE_NAME: GitHub Actions 53 | run: yarn coverage && yarn run coveralls --verbose < coverage/lcov.info 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .DS_Store 3 | /closet 4 | /tmp 5 | /dist 6 | /examples/dist 7 | /examples/node_modules 8 | /benchmarks/dist 9 | /coverage 10 | yarn-error.log 11 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.11.0 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project maintainer at `mail[AT]lucaongaro.eu`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to `MiniSearch` are welcome :) Just follow the guidelines below. 4 | 5 | ## Bugs and feature requests 6 | 7 | If you have an idea for a feature, or spotted a bug, please [open an 8 | issue on GitHub](https://github.com/lucaong/minisearch/issues). 9 | 10 | - Check if your issue was already reported. In that case, better 11 | to comment on the existing issue rather than open a duplicate one. 12 | 13 | - When reporting a bug, whenever possible, provide some example code to 14 | reproduce the bug: that will make the process of fixing it much faster. 15 | 16 | - Remember this is an open-source project. Your feature requests will be 17 | discussed and taken into consideration, but please do not take it 18 | personally if the feature does not get implemented. This project uses a 19 | permissive license, so pull requests are welcome and forks are permitted. 20 | 21 | - Always be respectful of others when discussing issues. The project 22 | maintainer has the right and responsibility to remove or close discussions 23 | where the tone or language is derogatory, harassing, or offensive toward 24 | others. Keep the [Code of 25 | Conduct](https://lucaong.github.io/minisearch/manual/CODE_OF_CONDUCT.html) 26 | in mind. 27 | 28 | ## Pull requests 29 | 30 | Thinking about sending a pull request? That is great :) Here is how you can 31 | setup your development environment: 32 | 33 | 1. Clone the [repository](https://github.com/lucaong/minisearch) 34 | 35 | 2. Install the development dependencies with `yarn install` 36 | 37 | 3. Run the tests with `yarn test`. You can also automatically trigger a run of 38 | relevant tests for the code that you change by running `yarn test-watch` 39 | 40 | 4. If you are working on optimizing the performance, you can run the 41 | performance benchmarks with `yarn benchmark` 42 | 43 | 5. If you are improving the documentation, you can build the docs with `yarn 44 | build-docs` 45 | 46 | In order to understand implementation details and design goals, read the [design 47 | document](https://lucaong.github.io/minisearch/manual/DESIGN_DOCUMENT.html). 48 | 49 | Also, please follow these guidelines: 50 | 51 | - Add tests for your code. This ensures that your feature won't be broken by 52 | further code changes. If you are not sure how to test, feel free to send the 53 | pull request and ask for advices in the comment. 54 | 55 | - Don't change the version number. That will be done by the maintainer upon 56 | releasing a new version. 57 | 58 | - Make sure that the full test suite passes before sending the pull request. 59 | 60 | - Try to follow the project's code conventions and style when possible. You 61 | can run `yarn lint` to check if your code follows the project style, and 62 | linting errors can often be fixed automatically by running `yarn lintfix`. 63 | -------------------------------------------------------------------------------- /DESIGN_DOCUMENT.md: -------------------------------------------------------------------------------- 1 | # Design Document 2 | 3 | This design document has the aim to explain the details of `MiniSearch` 4 | design and implementation to library developers that intend to contribute to 5 | this project, or that are simply curious about the internals. 6 | 7 | **Latest update: Feb. 21, 2022** 8 | 9 | ## Goals (and non-goals) 10 | 11 | `MiniSearch` is aimed at providing rich full-text search functionalities in a 12 | local setup (e.g. client side, in the browser). It is therefore optimized for: 13 | 14 | 1. Small memory footprint of the index data structure 15 | 2. Fast indexing of documents 16 | 3. Versatile and performant search features, to the extent possible while 17 | meeting goals 1 and 2 18 | 4. Small and simple API surface, on top of which more specific solutions can 19 | be built by application developers 20 | 5. Possibility to add and remove documents from the index at any time 21 | 22 | `MiniSearch` is therefore NOT directly aimed at offering: 23 | 24 | - A solution for use cases requiring large index data structure size 25 | - Distributed setup where the index resides on multiple nodes and need to be 26 | kept in sync 27 | - Turn-key opinionated solutions (e.g. supporting specific locales with custom 28 | stemmers, stopwords, etc.): `MiniSearch` _enables_ developer to build these 29 | on top of its core API, but does not provide them out of the box. 30 | 31 | For these points listed as non-goals, other solutions exist that should be 32 | preferred to `MiniSearch`. Adapting `MiniSearch` to support those goals would in 33 | fact necessarily go against the primary project goals. 34 | 35 | 36 | ## Technical design 37 | 38 | `MiniSearch` is composed of two layers: 39 | 40 | 1. A compact and versatile data structure for indexing terms, providing 41 | lookup by exact match, prefix match, and fuzzy match. 42 | 2. An API layer on top of this data structure, providing the search 43 | features. 44 | 45 | Here follows a description of these two layers. 46 | 47 | ### Index data structure 48 | 49 | The data structure chosen for the index is a [radix 50 | tree](https://en.wikipedia.org/wiki/Radix_tree), which is a prefix tree where 51 | nodes with no siblings are merged with the parent node. The reason for choosing 52 | this data structure follows from the project goals: 53 | 54 | - The radix tree minimizes the memory footprint of the index, because common 55 | prefixes are stored only once, and nodes are compressed into a single 56 | multi-character node whenever possible. 57 | - Radix trees offer fast key lookup, with performance proportional to the key 58 | length, and fast lookup of subtrees sharing the same key prefix. These 59 | properties make it possible to offer performant exact match and prefix 60 | search. 61 | - On top of a radix tree it is possible to implement lookup of keys that are 62 | within a certain maximum edit distance from a given key. This search rapidly 63 | becomes complex as the maximum distance grows, but for practical search 64 | use-cases the maximum distance is small enough for this algorithm to be 65 | performant. Other more performant solutions for fuzzy search would require 66 | more space (e.g. n-gram indexes). 67 | 68 | The class implementing the radix tree is called `SearchableMap`, because it 69 | implements the standard JavaScript [`Map` 70 | interface](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), 71 | adding on top of it some key lookup methods: 72 | 73 | - `SearchableMap.prototype.atPrefix(prefix)`, returning another 74 | `SearchableMap` representing a mutable view of the original one, containing 75 | only entries where the keys share the given prefix. 76 | - `SearchableMap.prototype.fuzzyGet(searchKey, maxEditDistance)`, returning 77 | all the entries where the key is within the given edit (Levenshtein) 78 | distance from `searchKey`. 79 | 80 | As a trade-off for offering these additional features, `SearchableMap` is 81 | restricted to use only string keys. 82 | 83 | The `SearchableMap` data type is part of the public API of `MiniSearch`, exposed 84 | as `MiniSearch.SearchableMap`. Its usefulness is in fact not limited to 85 | providing a data structure for the inverted index, and developers can use it as 86 | a building block for other solutions. When modifying this class, one should 87 | think about it in terms of a generic data structure, that could in principle be 88 | released as a separate library. 89 | 90 | ### Fuzzy search algorithm 91 | 92 | Fuzzy search is performed by calculating the [Levenshtein 93 | distance](https://en.wikipedia.org/wiki/Levenshtein_distance) between the search 94 | term and the keys in the radix tree. The algorithm used is a variation on the 95 | [Wagner-Fischer 96 | algorithm](https://en.wikipedia.org/wiki/Wagner–Fischer_algorithm). This 97 | algorithm constructs a matrix to calculate the edit distance between two terms. 98 | Because the search terms are stored in a radix tree, the same matrix can be 99 | reused for comparisons of child nodes if we do a depth-first traversal of the 100 | tree. 101 | 102 | The algorithm to find matching keys within a maximum edit distance from a given 103 | term is the following: 104 | 105 | - Create a matrix with `query length + 1` columns and `query length + edit 106 | distance + 1` rows. The columns `1..n` correspond to the query characters 107 | `0..n-1`. The rows `1..m` correspond to the characters `0..m-1` for every 108 | key in the radix tree that is visited. 109 | - The first row and and first column is filled with consecutive numbers 0, 1, 110 | 2, 3, ..., up to at least the edit distance. All other entries are set to 111 | `max distance + 1`. 112 | - The radix tree is traversed, starting from the root, visiting each node in a 113 | depth-first traversal and updating the matrix. 114 | - The matrix is updated according to the [Wagner-Fischer 115 | algorithm](https://en.wikipedia.org/wiki/Wagner–Fischer_algorithm): the keys 116 | for every child node are compared with the characters in the query, and the 117 | edit distance for the current matrix entry is calculated based on the 118 | positions in the previous column and previous row. 119 | - Only the diagonal band of `2 * edit distance + 1` needs to be calculated. 120 | - When the current row of the matrix only contains entries above the maximum 121 | edit distance, it is guaranteed that any child nodes below the current node 122 | will not yield any matches and the entire subtree can be skipped. 123 | - For every leaf node, if the edit distance in the lower right corner is equal 124 | to or below the maximum edit distance, it is recorded as a match. 125 | 126 | Note that this algorithm can get complex if the maximum edit distance is large, 127 | as many paths would be followed. The reason why this algorithm is employed is a 128 | trade-off: 129 | 130 | - For full-text search purposes, the maximum edit distance is small, so the 131 | algorithm is performant enough. 132 | - A [Levenshtein 133 | automaton](https://en.wikipedia.org/wiki/Levenshtein_automaton) is a fast 134 | alternative for low edit distances (1 or 2), but can get excessively complex 135 | and memory hungry for edit distances above 3. It is also a much more complex 136 | algorithm. 137 | - Trigram indexes require much more space and often yield worse results (a 138 | trigram index cannot match `votka` to `vodka`). 139 | - As `MiniSearch` is optimized for local and possibly memory-constrained 140 | setup, higher computation complexity is traded in exchange for smaller space 141 | requirement for the index. 142 | 143 | ### Search API layer 144 | 145 | The search API layer offers a small and simple API surface for application 146 | developers. It does not assume that a specific locale is used in the indexed 147 | documents, therefore no stemming nor stop-word filtering is performed, but 148 | instead offers easy options for developers to provide their own implementation. 149 | This heuristic will be followed in future development too: rather than providing 150 | an opinionated solution, the project will offer simple building blocks for 151 | application developers to implement their own solutions. 152 | 153 | The inverted index is implemented with `SearchableMap`, and posting lists are 154 | stored as values in the Map. This way, the same data structure provides both the 155 | inverted index and the set of indexed terms. Different document fields are 156 | indexed within the same index, to further save space. The index is therefore 157 | structured as following: 158 | 159 | ``` 160 | term -> field -> document -> term frequency 161 | ``` 162 | 163 | The fields and documents are referenced in the index with a short numeric ID for 164 | performance and to save space. 165 | 166 | ### Search result scoring 167 | 168 | When performing a search, the entries corresponding to the search term are 169 | looked up in the index (optionally searching the index with prefix or fuzzy 170 | search). If the combination of term, field and document is found, then this 171 | indicates that the term was present in this particular document field. But it is 172 | not helpful to return all matching documents in an arbitrary order. We want to 173 | return the results in order of _relevance_. 174 | 175 | For every document field matching a term, a relevance score is calculated. It 176 | indicates the quality of the match, with a higher score indicating a better 177 | match. The variables that are used to calculate the score are: 178 | - The frequency of the term in the document field that is being scored. 179 | - The total number of documents with matching fields for this term. 180 | - The total number of indexed documents. 181 | - The length of this field. 182 | - The average length of this field for all indexed documents. 183 | 184 | The scoring algorithm is based on 185 | [BM25](https://en.wikipedia.org/wiki/Okapi_BM25) (and its derivative BM25+), 186 | which is also used in other popular search engines such as Lucene. BM25 is an 187 | improvement on [TF-IDF](https://en.wikipedia.org/wiki/Tf–idf) and incorporates 188 | the following ideas: 189 | - If a term is less common, the score should be higher (like TD-IDF). 190 | - If a term occurs more frequently, the score should be higher (so far this is 191 | the same as TD-IDF). But the relationship is not linear. If a term occurs 192 | twice as often, the score is _not_ twice as high. 193 | - If a document field is shorter, it requires fewer term occurrences to be 194 | achieve the same relevance as a longer document field. This encodes the idea 195 | that a term occurring once in, say, a title is more relevant than a word 196 | occuring once in a long paragraph. 197 | 198 | The scores are calculated for every document field matching a query term. The 199 | results are added. To reward documents that match the most terms, the final 200 | score is multiplied by the number of matching terms in the query. 201 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Luca Ongaro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MiniSearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaong/minisearch/d46245015f34932058861ebeb1eb7fdf97ebaaae/MiniSearch.png -------------------------------------------------------------------------------- /MiniSearch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiniSearch 2 | 3 | [![CI Build](https://github.com/lucaong/minisearch/workflows/CI%20Build/badge.svg)](https://github.com/lucaong/minisearch/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/lucaong/minisearch/badge.svg?branch=master)](https://coveralls.io/github/lucaong/minisearch?branch=master) 5 | [![Minzipped Size](https://badgen.net/bundlephobia/minzip/minisearch)](https://bundlephobia.com/result?p=minisearch) 6 | [![npm](https://img.shields.io/npm/v/minisearch?color=%23ff00dd)](https://www.npmjs.com/package/minisearch) 7 | [![npm downloads](https://img.shields.io/npm/dw/minisearch)](https://www.npmjs.com/package/minisearch) 8 | [![types](https://img.shields.io/npm/types/minisearch)](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html) 9 | 10 | `MiniSearch` is a tiny but powerful in-memory fulltext search engine written in 11 | JavaScript. It is respectful of resources, and it can comfortably run both in 12 | Node and in the browser. 13 | 14 | Try out the [demo application](https://lucaong.github.io/minisearch/demo/). 15 | 16 | Find the complete [documentation and API reference 17 | here](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html), 18 | and more background about `MiniSearch`, including a comparison with other 19 | similar libraries, in [this blog 20 | post](https://lucaongaro.eu/blog/2019/01/30/minisearch-client-side-fulltext-search-engine.html). 21 | 22 | `MiniSearch` follows [semantic versioning](https://semver.org/spec/v2.0.0.html), 23 | and documents releases and changes in the 24 | [changelog](https://github.com/lucaong/minisearch/blob/master/CHANGELOG.md). 25 | 26 | 27 | ## Use case 28 | 29 | `MiniSearch` addresses use cases where full-text search features are needed 30 | (e.g. prefix search, fuzzy search, ranking, boosting of fields…), but the data 31 | to be indexed can fit locally in the process memory. While you won't index the 32 | whole Internet with it, there are surprisingly many use cases that are served 33 | well by `MiniSearch`. By storing the index in local memory, `MiniSearch` can 34 | work offline, and can process queries quickly, without network latency. 35 | 36 | A prominent use-case is real time search "as you type" in web and mobile 37 | applications, where keeping the index on the client enables fast and reactive 38 | UIs, removing the need to make requests to a search server. 39 | 40 | 41 | ## Features 42 | 43 | * Memory-efficient index, designed to support memory-constrained use cases 44 | like mobile browsers. 45 | 46 | * Exact match, prefix search, fuzzy match, field boosting. 47 | 48 | * Auto-suggestion engine, for auto-completion of search queries. 49 | 50 | * Modern search result ranking algorithm. 51 | 52 | * Documents can be added and removed from the index at any time. 53 | 54 | * Zero external dependencies. 55 | 56 | `MiniSearch` strives to expose a simple API that provides the building blocks to 57 | build custom solutions, while keeping a small and well tested codebase. 58 | 59 | 60 | ## Installation 61 | 62 | With `npm`: 63 | 64 | ``` 65 | npm install minisearch 66 | ``` 67 | 68 | With `yarn`: 69 | 70 | ``` 71 | yarn add minisearch 72 | ``` 73 | 74 | Then `require` or `import` it in your project: 75 | 76 | ```javascript 77 | // If you are using import: 78 | import MiniSearch from 'minisearch' 79 | 80 | // If you are using require: 81 | const MiniSearch = require('minisearch') 82 | ``` 83 | 84 | Alternatively, if you prefer to use a ` 89 | ``` 90 | 91 | In this case, `MiniSearch` will appear as a global variable in your project. 92 | 93 | Finally, if you want to manually build the library, clone the repository and run 94 | `yarn build` (or `yarn build-minified` for a minified version + source maps). 95 | The compiled source will be created in the `dist` folder (UMD, ES6 and ES2015 96 | module versions are provided). 97 | 98 | 99 | ## Usage 100 | 101 | ### Basic usage 102 | 103 | ```javascript 104 | // A collection of documents for our examples 105 | const documents = [ 106 | { 107 | id: 1, 108 | title: 'Moby Dick', 109 | text: 'Call me Ishmael. Some years ago...', 110 | category: 'fiction' 111 | }, 112 | { 113 | id: 2, 114 | title: 'Zen and the Art of Motorcycle Maintenance', 115 | text: 'I can see by my watch...', 116 | category: 'fiction' 117 | }, 118 | { 119 | id: 3, 120 | title: 'Neuromancer', 121 | text: 'The sky above the port was...', 122 | category: 'fiction' 123 | }, 124 | { 125 | id: 4, 126 | title: 'Zen and the Art of Archery', 127 | text: 'At first sight it must seem...', 128 | category: 'non-fiction' 129 | }, 130 | // ...and more 131 | ] 132 | 133 | let miniSearch = new MiniSearch({ 134 | fields: ['title', 'text'], // fields to index for full-text search 135 | storeFields: ['title', 'category'] // fields to return with search results 136 | }) 137 | 138 | // Index all documents 139 | miniSearch.addAll(documents) 140 | 141 | // Search with default options 142 | let results = miniSearch.search('zen art motorcycle') 143 | // => [ 144 | // { id: 2, title: 'Zen and the Art of Motorcycle Maintenance', category: 'fiction', score: 2.77258, match: { ... } }, 145 | // { id: 4, title: 'Zen and the Art of Archery', category: 'non-fiction', score: 1.38629, match: { ... } } 146 | // ] 147 | ``` 148 | 149 | ### Search options 150 | 151 | `MiniSearch` supports several options for more advanced search behavior: 152 | 153 | ```javascript 154 | // Search only specific fields 155 | miniSearch.search('zen', { fields: ['title'] }) 156 | 157 | // Boost some fields (here "title") 158 | miniSearch.search('zen', { boost: { title: 2 } }) 159 | 160 | // Prefix search (so that 'moto' will match 'motorcycle') 161 | miniSearch.search('moto', { prefix: true }) 162 | 163 | // Search within a specific category 164 | miniSearch.search('zen', { 165 | filter: (result) => result.category === 'fiction' 166 | }) 167 | 168 | // Fuzzy search, in this example, with a max edit distance of 0.2 * term length, 169 | // rounded to nearest integer. The mispelled 'ismael' will match 'ishmael'. 170 | miniSearch.search('ismael', { fuzzy: 0.2 }) 171 | 172 | // You can set the default search options upon initialization 173 | miniSearch = new MiniSearch({ 174 | fields: ['title', 'text'], 175 | searchOptions: { 176 | boost: { title: 2 }, 177 | fuzzy: 0.2 178 | } 179 | }) 180 | miniSearch.addAll(documents) 181 | 182 | // It will now by default perform fuzzy search and boost "title": 183 | miniSearch.search('zen and motorcycles') 184 | ``` 185 | 186 | ### Auto suggestions 187 | 188 | `MiniSearch` can suggest search queries given an incomplete query: 189 | 190 | ```javascript 191 | miniSearch.autoSuggest('zen ar') 192 | // => [ { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 }, 193 | // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 } ] 194 | ``` 195 | 196 | The `autoSuggest` method takes the same options as the `search` method, so you 197 | can get suggestions for misspelled words using fuzzy search: 198 | 199 | ```javascript 200 | miniSearch.autoSuggest('neromancer', { fuzzy: 0.2 }) 201 | // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 1.03998 } ] 202 | ``` 203 | 204 | Suggestions are ranked by the relevance of the documents that would be returned 205 | by that search. 206 | 207 | Sometimes, you might need to filter auto suggestions to, say, only a specific 208 | category. You can do so by providing a `filter` option: 209 | 210 | ```javascript 211 | miniSearch.autoSuggest('zen ar', { 212 | filter: (result) => result.category === 'fiction' 213 | }) 214 | // => [ { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 } ] 215 | ``` 216 | 217 | ### Field extraction 218 | 219 | By default, documents are assumed to be plain key-value objects with field names 220 | as keys and field values as simple values. In order to support custom field 221 | extraction logic (for example for nested fields, or non-string field values that 222 | need processing before tokenization), a custom field extractor function can be 223 | passed as the `extractField` option: 224 | 225 | ```javascript 226 | // Assuming that our documents look like: 227 | const documents = [ 228 | { id: 1, title: 'Moby Dick', author: { name: 'Herman Melville' }, pubDate: new Date(1851, 9, 18) }, 229 | { id: 2, title: 'Zen and the Art of Motorcycle Maintenance', author: { name: 'Robert Pirsig' }, pubDate: new Date(1974, 3, 1) }, 230 | { id: 3, title: 'Neuromancer', author: { name: 'William Gibson' }, pubDate: new Date(1984, 6, 1) }, 231 | { id: 4, title: 'Zen in the Art of Archery', author: { name: 'Eugen Herrigel' }, pubDate: new Date(1948, 0, 1) }, 232 | // ...and more 233 | ] 234 | 235 | // We can support nested fields (author.name) and date fields (pubDate) with a 236 | // custom `extractField` function: 237 | 238 | let miniSearch = new MiniSearch({ 239 | fields: ['title', 'author.name', 'pubYear'], 240 | extractField: (document, fieldName) => { 241 | // If field name is 'pubYear', extract just the year from 'pubDate' 242 | if (fieldName === 'pubYear') { 243 | const pubDate = document['pubDate'] 244 | return pubDate && pubDate.getFullYear().toString() 245 | } 246 | 247 | // Access nested fields 248 | return fieldName.split('.').reduce((doc, key) => doc && doc[key], document) 249 | } 250 | }) 251 | ``` 252 | 253 | The default field extractor can be obtained by calling 254 | `MiniSearch.getDefault('extractField')`. 255 | 256 | ### Tokenization 257 | 258 | By default, documents are tokenized by splitting on Unicode space or punctuation 259 | characters. The tokenization logic can be easily changed by passing a custom 260 | tokenizer function as the `tokenize` option: 261 | 262 | ```javascript 263 | // Tokenize splitting by hyphen 264 | let miniSearch = new MiniSearch({ 265 | fields: ['title', 'text'], 266 | tokenize: (string, _fieldName) => string.split('-') 267 | }) 268 | ``` 269 | 270 | Upon search, the same tokenization is used by default, but it is possible to 271 | pass a `tokenize` search option in case a different search-time tokenization is 272 | necessary: 273 | 274 | ```javascript 275 | // Tokenize splitting by hyphen 276 | let miniSearch = new MiniSearch({ 277 | fields: ['title', 'text'], 278 | tokenize: (string) => string.split('-'), // indexing tokenizer 279 | searchOptions: { 280 | tokenize: (string) => string.split(/[\s-]+/) // search query tokenizer 281 | } 282 | }) 283 | ``` 284 | 285 | The default tokenizer can be obtained by calling 286 | `MiniSearch.getDefault('tokenize')`. 287 | 288 | ### Term processing 289 | 290 | Terms are downcased by default. No stemming is performed, and no stop-word list 291 | is applied. To customize how the terms are processed upon indexing, for example 292 | to normalize them, filter them, or to apply stemming, the `processTerm` option 293 | can be used. The `processTerm` function should return the processed term as a 294 | string, or a falsy value if the term should be discarded: 295 | 296 | ```javascript 297 | let stopWords = new Set(['and', 'or', 'to', 'in', 'a', 'the', /* ...and more */ ]) 298 | 299 | // Perform custom term processing (here discarding stop words and downcasing) 300 | let miniSearch = new MiniSearch({ 301 | fields: ['title', 'text'], 302 | processTerm: (term, _fieldName) => 303 | stopWords.has(term) ? null : term.toLowerCase() 304 | }) 305 | ``` 306 | 307 | By default, the same processing is applied to search queries. In order to apply 308 | a different processing to search queries, supply a `processTerm` search option: 309 | 310 | ```javascript 311 | let miniSearch = new MiniSearch({ 312 | fields: ['title', 'text'], 313 | processTerm: (term) => 314 | stopWords.has(term) ? null : term.toLowerCase(), // index term processing 315 | searchOptions: { 316 | processTerm: (term) => term.toLowerCase() // search query processing 317 | } 318 | }) 319 | ``` 320 | 321 | The default term processor can be obtained by calling 322 | `MiniSearch.getDefault('processTerm')`. 323 | 324 | ### API Documentation 325 | 326 | Refer to the [API 327 | documentation](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html) 328 | for details about configuration options and methods. 329 | 330 | 331 | ## Browser and Node compatibility 332 | 333 | `MiniSearch` supports all browsers and NodeJS versions implementing the ES9 334 | (ES2018) JavaScript standard. That includes all modern browsers and NodeJS 335 | versions. 336 | 337 | ES6 (ES2015) compatibility can be achieved by transpiling the tokenizer RegExp 338 | to expand Unicode character class escapes, for example with 339 | https://babeljs.io/docs/babel-plugin-transform-unicode-sets-regex. 340 | 341 | ## Contributing 342 | 343 | Contributions to `MiniSearch` are welcome. Please read the [contributions 344 | guidelines](https://github.com/lucaong/minisearch/blob/master/CONTRIBUTING.md). 345 | Reading the [design 346 | document](https://github.com/lucaong/minisearch/blob/master/DESIGN_DOCUMENT.md) is 347 | also useful to understand the project goals and the technical implementation. 348 | -------------------------------------------------------------------------------- /benchmarks/autoSuggestion.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { miniSearch as ms } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Auto suggestion') 5 | suite.add('MiniSearch#autoSuggest("virtute cano")', () => { 6 | ms.autoSuggest('virtute cano') 7 | }).add('MiniSearch#autoSuggest("virtue conoscienza", { fuzzy: 0.2 })', () => { 8 | ms.autoSuggest('virtue conoscienza') 9 | }) 10 | 11 | export default suite 12 | -------------------------------------------------------------------------------- /benchmarks/combinedSearch.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { miniSearch as ms } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Combined search') 5 | suite.add('MiniSearch#search("virtute conoscienza", { fuzzy: 0.2, prefix: true })', () => { 6 | ms.search('virtute conoscienza', { 7 | fuzzy: 0.2, 8 | prefix: true 9 | }) 10 | }).add('MiniSearch#search("virtu", { fuzzy: 0.2, prefix: true })', () => { 11 | ms.search('virtu', { 12 | fuzzy: 0.2, 13 | prefix: true 14 | }) 15 | }) 16 | 17 | export default suite 18 | -------------------------------------------------------------------------------- /benchmarks/exactSearch.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { index } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Exact search') 5 | suite.add('SearchableMap#get("virtute")', () => { 6 | index.get('virtute') 7 | }) 8 | 9 | export default suite 10 | -------------------------------------------------------------------------------- /benchmarks/fuzzySearch.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { index } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Fuzzy search') 5 | suite.add('SearchableMap#fuzzyGet("virtute", 1)', () => { 6 | index.fuzzyGet('virtute', 1) 7 | }).add('SearchableMap#fuzzyGet("virtu", 2)', () => { 8 | index.fuzzyGet('virtu', 2) 9 | }).add('SearchableMap#fuzzyGet("virtu", 3)', () => { 10 | index.fuzzyGet('virtu', 3) 11 | }).add('SearchableMap#fuzzyGet("virtute", 4)', () => { 12 | index.fuzzyGet('virtute', 4) 13 | }) 14 | 15 | export default suite 16 | -------------------------------------------------------------------------------- /benchmarks/index.js: -------------------------------------------------------------------------------- 1 | import fuzzySearch from './fuzzySearch.js' 2 | import prefixSearch from './prefixSearch.js' 3 | import exactSearch from './exactSearch.js' 4 | import indexing from './indexing.js' 5 | import combinedSearch from './combinedSearch.js' 6 | import ranking from './ranking.js' 7 | import loadIndex from './loadIndex.js' 8 | import autoSuggestion from './autoSuggestion.js' 9 | import searchFiltering from './searchFiltering.js' 10 | import memory from './memory.js' 11 | import { lines } from './divinaCommedia.js' 12 | 13 | const { terms, documents, memSize, serializedSize } = memory(lines) 14 | console.log(`Index size: ${terms} terms, ${documents} documents, ~${memSize}MB in memory, ${serializedSize}MB serialized.\n`) 15 | 16 | ;[fuzzySearch, prefixSearch, exactSearch, indexing, combinedSearch, ranking, searchFiltering, autoSuggestion, loadIndex].forEach(suite => { 17 | suite.on('start', () => { 18 | console.log(`${suite.name}:`) 19 | console.log('='.repeat(suite.name.length + 1)) 20 | }).on('cycle', ({ target: benchmark }) => { 21 | console.log(` * ${benchmark}`) 22 | }).on('complete', () => { 23 | console.log('') 24 | }).run() 25 | }) 26 | -------------------------------------------------------------------------------- /benchmarks/indexing.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import MiniSearch from '../src/MiniSearch.js' 3 | import { lines } from './divinaCommedia.js' 4 | 5 | const suite = new Benchmark.Suite('Indexing') 6 | suite.add('MiniSearch#addAll(documents)', () => { 7 | const ms = new MiniSearch({ fields: ['txt'] }) 8 | ms.addAll(lines) 9 | }) 10 | 11 | export default suite 12 | -------------------------------------------------------------------------------- /benchmarks/loadIndex.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import MiniSearch from '../src/MiniSearch.js' 3 | import { miniSearch as ms } from './divinaCommedia.js' 4 | 5 | const json = JSON.stringify(ms) 6 | 7 | const suite = new Benchmark.Suite('Load index') 8 | suite.add('MiniSearch.loadJSON(json, options)', () => { 9 | MiniSearch.loadJSON(json, { fields: ['txt'] }) 10 | }) 11 | 12 | export default suite 13 | -------------------------------------------------------------------------------- /benchmarks/memory.js: -------------------------------------------------------------------------------- 1 | import MiniSearch from '../src/MiniSearch.js' 2 | 3 | const heapSize = () => { 4 | if (global.gc) { global.gc() } 5 | return process.memoryUsage().heapUsed 6 | } 7 | 8 | const bytesToMb = (bytes) => { 9 | return (bytes / (1024 * 1024)).toFixed(2) 10 | } 11 | 12 | const memory = (docs) => { 13 | const miniSearch = new MiniSearch({ fields: ['txt'], storeFields: ['txt'] }) 14 | 15 | const heapBefore = heapSize() 16 | miniSearch.addAll(docs) 17 | const heapAfter = heapSize() 18 | 19 | const terms = miniSearch.termCount 20 | const documents = miniSearch.documentCount 21 | const memSize = bytesToMb(heapAfter - heapBefore) 22 | const serializedSize = bytesToMb(JSON.stringify(miniSearch).length) 23 | 24 | return { terms, documents, memSize, serializedSize, miniSearch } 25 | } 26 | 27 | export default memory 28 | -------------------------------------------------------------------------------- /benchmarks/prefixSearch.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { index } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Prefix search') 5 | suite.add('Array.from(SearchableMap#atPrefix("vir"))', () => { 6 | Array.from(index.atPrefix('vir')) 7 | }).add('Array.from(SearchableMap#atPrefix("virtut"))', () => { 8 | Array.from(index.atPrefix('virtut')) 9 | }) 10 | 11 | export default suite 12 | -------------------------------------------------------------------------------- /benchmarks/ranking.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { miniSearch as ms } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Ranking search results') 5 | suite.add('MiniSearch#search("vi", { prefix: true })', () => { 6 | ms.search('vi', { 7 | prefix: true 8 | }) 9 | }) 10 | 11 | export default suite 12 | -------------------------------------------------------------------------------- /benchmarks/searchFiltering.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import { miniSearch } from './divinaCommedia.js' 3 | 4 | const suite = new Benchmark.Suite('Search filtering') 5 | suite.add('MiniSearch#search("virtu", { filter: ... })', () => { 6 | miniSearch.search('virtu', { 7 | prefix: true, 8 | filter: ({ id }) => id.startsWith('Inf') 9 | }) 10 | }) 11 | 12 | export default suite 13 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #001080; 3 | --dark-hl-0: #9CDCFE; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #008000; 7 | --dark-hl-2: #6A9955; 8 | --light-hl-3: #AF00DB; 9 | --dark-hl-3: #C586C0; 10 | --light-hl-4: #A31515; 11 | --dark-hl-4: #CE9178; 12 | --light-hl-5: #0000FF; 13 | --dark-hl-5: #569CD6; 14 | --light-hl-6: #0070C1; 15 | --dark-hl-6: #4FC1FF; 16 | --light-hl-7: #795E26; 17 | --dark-hl-7: #DCDCAA; 18 | --light-hl-8: #800000; 19 | --dark-hl-8: #808080; 20 | --light-hl-9: #800000; 21 | --dark-hl-9: #569CD6; 22 | --light-hl-10: #000000FF; 23 | --dark-hl-10: #D4D4D4; 24 | --light-hl-11: #E50000; 25 | --dark-hl-11: #9CDCFE; 26 | --light-hl-12: #0000FF; 27 | --dark-hl-12: #CE9178; 28 | --light-hl-13: #098658; 29 | --dark-hl-13: #B5CEA8; 30 | --light-hl-14: #811F3F; 31 | --dark-hl-14: #D16969; 32 | --light-hl-15: #D16969; 33 | --dark-hl-15: #CE9178; 34 | --light-hl-16: #000000; 35 | --dark-hl-16: #D7BA7D; 36 | --light-code-background: #FFFFFF; 37 | --dark-code-background: #1E1E1E; 38 | } 39 | 40 | @media (prefers-color-scheme: light) { :root { 41 | --hl-0: var(--light-hl-0); 42 | --hl-1: var(--light-hl-1); 43 | --hl-2: var(--light-hl-2); 44 | --hl-3: var(--light-hl-3); 45 | --hl-4: var(--light-hl-4); 46 | --hl-5: var(--light-hl-5); 47 | --hl-6: var(--light-hl-6); 48 | --hl-7: var(--light-hl-7); 49 | --hl-8: var(--light-hl-8); 50 | --hl-9: var(--light-hl-9); 51 | --hl-10: var(--light-hl-10); 52 | --hl-11: var(--light-hl-11); 53 | --hl-12: var(--light-hl-12); 54 | --hl-13: var(--light-hl-13); 55 | --hl-14: var(--light-hl-14); 56 | --hl-15: var(--light-hl-15); 57 | --hl-16: var(--light-hl-16); 58 | --code-background: var(--light-code-background); 59 | } } 60 | 61 | @media (prefers-color-scheme: dark) { :root { 62 | --hl-0: var(--dark-hl-0); 63 | --hl-1: var(--dark-hl-1); 64 | --hl-2: var(--dark-hl-2); 65 | --hl-3: var(--dark-hl-3); 66 | --hl-4: var(--dark-hl-4); 67 | --hl-5: var(--dark-hl-5); 68 | --hl-6: var(--dark-hl-6); 69 | --hl-7: var(--dark-hl-7); 70 | --hl-8: var(--dark-hl-8); 71 | --hl-9: var(--dark-hl-9); 72 | --hl-10: var(--dark-hl-10); 73 | --hl-11: var(--dark-hl-11); 74 | --hl-12: var(--dark-hl-12); 75 | --hl-13: var(--dark-hl-13); 76 | --hl-14: var(--dark-hl-14); 77 | --hl-15: var(--dark-hl-15); 78 | --hl-16: var(--dark-hl-16); 79 | --code-background: var(--dark-code-background); 80 | } } 81 | 82 | :root[data-theme='light'] { 83 | --hl-0: var(--light-hl-0); 84 | --hl-1: var(--light-hl-1); 85 | --hl-2: var(--light-hl-2); 86 | --hl-3: var(--light-hl-3); 87 | --hl-4: var(--light-hl-4); 88 | --hl-5: var(--light-hl-5); 89 | --hl-6: var(--light-hl-6); 90 | --hl-7: var(--light-hl-7); 91 | --hl-8: var(--light-hl-8); 92 | --hl-9: var(--light-hl-9); 93 | --hl-10: var(--light-hl-10); 94 | --hl-11: var(--light-hl-11); 95 | --hl-12: var(--light-hl-12); 96 | --hl-13: var(--light-hl-13); 97 | --hl-14: var(--light-hl-14); 98 | --hl-15: var(--light-hl-15); 99 | --hl-16: var(--light-hl-16); 100 | --code-background: var(--light-code-background); 101 | } 102 | 103 | :root[data-theme='dark'] { 104 | --hl-0: var(--dark-hl-0); 105 | --hl-1: var(--dark-hl-1); 106 | --hl-2: var(--dark-hl-2); 107 | --hl-3: var(--dark-hl-3); 108 | --hl-4: var(--dark-hl-4); 109 | --hl-5: var(--dark-hl-5); 110 | --hl-6: var(--dark-hl-6); 111 | --hl-7: var(--dark-hl-7); 112 | --hl-8: var(--dark-hl-8); 113 | --hl-9: var(--dark-hl-9); 114 | --hl-10: var(--dark-hl-10); 115 | --hl-11: var(--dark-hl-11); 116 | --hl-12: var(--dark-hl-12); 117 | --hl-13: var(--dark-hl-13); 118 | --hl-14: var(--dark-hl-14); 119 | --hl-15: var(--dark-hl-15); 120 | --hl-16: var(--dark-hl-16); 121 | --code-background: var(--dark-code-background); 122 | } 123 | 124 | .hl-0 { color: var(--hl-0); } 125 | .hl-1 { color: var(--hl-1); } 126 | .hl-2 { color: var(--hl-2); } 127 | .hl-3 { color: var(--hl-3); } 128 | .hl-4 { color: var(--hl-4); } 129 | .hl-5 { color: var(--hl-5); } 130 | .hl-6 { color: var(--hl-6); } 131 | .hl-7 { color: var(--hl-7); } 132 | .hl-8 { color: var(--hl-8); } 133 | .hl-9 { color: var(--hl-9); } 134 | .hl-10 { color: var(--hl-10); } 135 | .hl-11 { color: var(--hl-11); } 136 | .hl-12 { color: var(--hl-12); } 137 | .hl-13 { color: var(--hl-13); } 138 | .hl-14 { color: var(--hl-14); } 139 | .hl-15 { color: var(--hl-15); } 140 | .hl-16 { color: var(--hl-16); } 141 | pre, code { background: var(--code-background); } 142 | -------------------------------------------------------------------------------- /docs/assets/icons.js: -------------------------------------------------------------------------------- 1 | (function(svg) { 2 | svg.innerHTML = ``; 3 | svg.style.display = 'none'; 4 | if (location.protocol === 'file:') { 5 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateUseElements); 6 | else updateUseElements() 7 | function updateUseElements() { 8 | document.querySelectorAll('use').forEach(el => { 9 | if (el.getAttribute('href').includes('#icon-')) { 10 | el.setAttribute('href', el.getAttribute('href').replace(/.*#/, '#')); 11 | } 12 | }); 13 | } 14 | } 15 | })(document.body.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))) -------------------------------------------------------------------------------- /docs/assets/icons.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE5XUW0vDMBQH8O9ynotzhaH2Tfc0sMwL6MMocpbGNZgmJRd0yL67zLG1Wbec7jHkn9/JhZzFLzj+4yCDXCjxytGwChJo0FWQQa1LL7kdtXNXlaslJPAlVAlZmgCrhCwNV5AtohSTaG1InVPH6e0mOWD33uk3ZN7X88YJrWxrunUTir3s0Xav727Gk7SDP+Tp5AkN1jG1DVHcVNdLoXBbet5wg06biHsiTRV41N/cMLT8skqxZVTJHB2rZupTR/xDhsLoJxz4cM+em3WE+Z8fhHSuhPI6UYreLaOPG+SGoS/ceulIcxcjSb9acUscvg1R3O7zTbUqBXXy4+gwmr7Ri/7/u5AlQ1NGvH3kNFX0ngeXkufYjIJRv6UG0x/BaFCPPcPv22yMjxTbtt5iU/wB6KgbchYGAAA=" -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = "data:application/octet-stream;base64,H4sIAAAAAAAAE81dXbObOBL9L+TV61gICbhvszObrdnabGY2UzMPrlspYpMbJra5A/gmmVT++5aEgG7cjQ3Gs3lyfK2jPurTan0glC9ekX8svbv1F+9Ddth6d/7COyT71LvzXmaH7HWaFJv33sI7Fjvvztvn2+MuLZ93Py3fV/udt/A2u6Qs09K787yvi7auVRwK1dX47/xjWmySMv0+37/NDkmV5YdXj2mRVHnR2qg+P2ILQyjC/MJ7TIr0UOEW8KRGcbkNhbrAq0dTbckbR8UmmNVKSd0affPGGBhj7VkLOWcUgwEFsfKDlsG7LN1tR7XXMVi2yHFElq4BLJ9dlQ6IP8THIa/lM5NEjpCr/296KrOmYYzD3uZ5WU2h1wC/FXdZPo23xGRv1c0actYvabGfTNCBvymnGU6N44LrHGebxzjvY5o9vK8m5YoO+q04zjFq482f7LembVxCO/755+dZGC5tVVd0DVwjR/ixSN9ln+ZhXNd1c8o2dH/IN8e9qXVqNwIVfCtRing1fp0eqriZs+vfIuedEUztQA1wVjb75NOLqYQAdlZOGzshTn/LqvdTaGH4rMyq/EN6yP6cFPsA+610x4ZSm+FWk7ti2zq2F+abtCynzlUw/FvxH2DVuDCa7EHYRG5c2PtqUtqtcde6rb/EPLu4/KuWlRMXlFcuJa9bRF6WcLLtC1PjOBIdaCYWZZUX6YsJ7sDAmdikn6oi2VQTHNNDTudzdXgiKqMWib2sgZo0edS6dry6oaP6Y1Q4wUmzDE4zDEs3dBMxFF2U8scPQrv84WFoR4ti12L+ny6qSTTekRO845rBOCY5VvmvyeZ4HBlECDdXxr5s95fM2T3oTIxMK18fHx7SsppEi8RP53ayXV5XneWHgQlWW+b2G+XY1Jhd8q4hXGyMb2obGxA6gsq5ZVVa7If24WgyDWo+HuUmL0aLsmxQV/Hox+PLpNq8//HwLufptEVuHo3Y0ohg7Fpx7kHVf9PyuDu711WX+oseUwFjo59Sueaws/rxtpfZRVNWBL+u13FELu93l3P545gWn3+ZSAiB52R1Lh9whC7PCJdz2ZuuNIFLg7uSS7/X/mx8Dp5c88z6JWd4tv1btttukmKgFzUlZjBm+Z9p3gxm6hng2akRKnbzRHhqbUQmxC3itrRMeL4eXJyyJJYQPI7OuQcwpuLfkmxgQDrDyoGvZUVHyff5YZtdFChdyb8oVnoGR4cLaBqXCLPDD1lRfZ8fh56NDbFZ9qoYTe1cqq6rf5FsBs/9XEKxrWMGjv1Y+q5dd57NOidFZ0h4f3/pq5+SIhka8rsyNw/fnqkRgQsawgTEh7Fmlx/GGT6Xzkbbfzur/YGBmrF/0Vx30L4fDR06dHWjRU73z/Gxhg+W9Kcn5609A5jzqyq6ewVdi3d5sv3X61f/GcMAYGZk8F35+bCZQqMBzsDlIa1+SN8lcH15ARGEmsZCiS7dbfJDWRVHNCRcwALDZnBGsh0Vl3Xxeex+t9uNNF0jZrM+OhgxbAYeRbrPn9IxFFrEbNZHygBBM3DYZuXY3NhB5rM/0gkINYsSj7tkMzIQGsgM9p/wc4oLzD9d/ISCtq59EYAdsLKeTmaHhzEkMGwWJtuTtcRF4TBi9XApi9564UIal68QzkfF+6QcY74uPs8A/dqcFNj2zhhcNkr3oDPwKcdOFp+1iDnGqu5J16ixCsHmiUp3tHJ8/+gBZ2Fj9p1HM4GgGbSp8rHT6hYx0XrvOUnydpe+TB6fo28n71KhX9+gb4MvV8FVE22hafGQhXP2QPOHGsVI8K7ITweucXSeuTpGc1pezPDV29/TzWmkTuDZ1jQf20vXJSPZXrJWudq5SfUTPtA9kSyo50ZMN7s0ud6nrpIbcdymu7Q6nYaOJNnWciOW6aEqsvR0XjCSZlfNrTp+XvwjISYOY3t9W82teJpT+/9Mr09PXT03YvpwPcmHW/KjZswj+Q3Noq/m9yH9fDVBV8eNGJbXK1zOrTCed5bw6eRUikMPKa/24fFxm1yfxdtabpV10mqG3JgOHWe4muNTsjteP9C0tdyI5Tqr6nsC7q9l+qap6c0cbO8XXnbYpp+8uy/eU1qU5lzInecv5TL2Fs37FHfruhkL856XfXHv3v32a2qmjqZEXeT5ylusVwsZLUPh398v1g3C/mD/YIsJb7EWi0AuI1xKoFK+t1j7RCkflZLeYi0pkxIVC7zFOliIaClCgYoFqJjyFmtF1aZQMe0t1pqgplGpkLMZomKRt1iHRGURKhVzlcXYtcbTEeXbngSCq09gFcyLyeuYqhDrICRbIVZCGI8LMkoEFkMYpwtBlsR6CON4QQWLwJIIVhOBRRERbxsLI2K+PVgb30ggJBXSWBzfdpCAKtjrIkYDQQasj+XxWXl8LI9v5dFklVge84oaUyVWx7fqUCHuY3V8o4GgotfH6vgRaxqL48dch/axNtJqQ8W5xNpIo4C/ogpibaTNX1Sak70MxkojsTQyYNWWWBrJSiOxNFKzakusjWR7jsTaSKOAT/VFibWRbEqTWJvAKOBT/SbA2gSCGwwCrE1gtaE6WIC1CYwCPunyoDfA8CMM1iZQfJVYnMAo4JPiBFicwEjgh2RJrE5g1aG6WIDVCVh1AqyOsupQPUdhdRQ75CisjjIaSKqLKayOkqyDFFZHGQ0k1RlVbwKgWE8qrI4yEkgqzhUWRxkFJBW+CmujjAIyIG1jcZSRQCqqSiyONhJIcqqCxdGsOBqLo604VDLXWBxtZ2ZUpGmsjbbaUAGksTaazWq6Nz0zAgRUAGksjbZJjQoLjaXRdsChxNZYGW27DSW2xsqEK3Z2iJUJrTJUrgqxMqHxf0AFRYiVCe2AQwVFiJUJA24+H2JlQsVGboilCa00VPiEvcmzlYYKnxBLE1ppqPAJsTShnQvQzcHaREYBRYVFhLWJjAKKCosIaxMZBRQVFhHWJjIKKErtCGsTGQUUpXaEtYnsmoZSO8LSREYARa5EsDSREUBR0kS9lY0RQFHSRFiayPhfUx02wsrExv+aUibGysTG/5pSJsbKxMb/mpw0xFia2AigKWliLE1sBNCUNDGWJjYCaEqaGEsT2wUnJU2MpYmNAJqSJsbSxEYATUkT99adRoCQkiburzyNAiG5rl/1Fp8ro0FIrtdWveXnyogQUj2n/gkWlWwHr3+DZY0SISVm/RMsarQIKTnrn2BRo0ZICVr/BIsaPUJK0vonWNTuENCL+t5adGXVIpfrq55c9U4BJaw42SsQbB8R/e0Cuy0QkVHQ3zGwGwMRGQX9PQO7MxCRUdDfNLBbAxGpbH/XwG4ORKSy/X0Duz0Qkcr2dw7s/kBEKuu2Duy221NaVOn2x3r7bb1uz7t/8d64Pbl2R/uLF3h3X74uvLD+iOsPIdyndJ/uZ99995X7jOpP6f4u3d+lKy9dfYGzErjvytWvm0+HC1fm82u3H2i+mXbas6cdfyW6BkQDmMQc0gMwH8DEICypD3oCrARYn8NWzUVhHTAEzjYZgEEeq7xsjux0YK07cKzOY/Pm9Y2uCh+KzRI/VnlzrA9AQZsD1s0tlDAeAI/riKnhLUKoDhFydO17VfWDlA4pQ2BrEPnRvpEFkBFASg5pb07qQAKY8/UA6NG92QLaCPyqYw5aX2IKAgkE4BBm2172B8gCHQRL1qAre4FJh4xg9DJAdzIBUFUQxcXtpnsTNG+vRgbRB/w7WEP60d7vBloLQt7n+jk6ogL6+QrEX5MOV1xQoKwUgM4asgh3QAI4S0NnceKYA56b+vQaSA8gD8acOgb5Lum3UwP3xpyP2uPWwD+AbcQp63D99AsjmG2ni97TtgJ0zKHbcx3AvSF0b8gB68uT3tX3QYEgBB6WXBi6J5Gg08D4Z/tbc0MY8FAHa0ZYLj00d0IDB4GkwoHyIk0w1RB1cC6x1KfkAArmkxUXPgaVuzNrACshlvVpfZ0k6NSgYwo3nxCsdwzaHvcAdmNol0P2QBG0yibAh7TaNi8IgXwADIacZx/Syl6Ntj2NBg3SWMzlE3tmBGCAc2O/mc1xAmUo1iVoq+J0ybYnfUSAdkrORVn51J3XB3yBzYjTpHkyjYSB3Vpw3v2A9AD+DDmX1EdcgB0B7XBeMa+q/V6auw+AOcAw5HpkAzyZbwagX4Zc1mou0wL5CnRMyXll1/yXB2dGYNh0pip3pwQIIkBAca22qMxeaQOogxYHHPV98uk0M4CY97n422cHcggNQABqlm8NPh1FA+Aizam0zw5Z81ZABwV2V271wy0OiIm1AM7yOcOnixEBJXVDDNt53A1w/RmhAAON7xKx5Dz3R31lBogPOMZxHd6iNvBKEVABUFtxLbcVuNtpABS0XnHObt6yA6MyyBsRl4drWH/GAxob8Qbd21wAB9SNuDa6q2VABwIJJ3ArcMVN0U4DUgOyMUe2bI/57M2rAGB0hZG14nohgj/nKwOdI+aCpEYTfQMEiNuXCLj0VddRuBungCvBkKb4xuCpApwTCS5O+qvWCM6GBOt2M0U4nSH4wE+SbSO4qQ1AgdpB4yYuWExPOp2QgyCNuSA96YM+yB6BdkHKOavK+4OqBsLEXOLq7isFWQu013cTI8m5uzn/CGSCuziC89PpFooCfCOObw3bgKtcQDCDCjTHl91/kXBRwMXxkzujCFoLl6SC07b9ry86IJxic3Ol7n4DwBO4V7lNxZD08v3Ce8we0112SL279f3Xr/8D1x8AQmNqAAA="; -------------------------------------------------------------------------------- /docs/demo/README.md: -------------------------------------------------------------------------------- 1 | # MiniSearch example in Plain JavaScript 2 | 3 | This is an example client-side application, written in plain JavaScript (no 4 | framework), to showcase `MiniSearch`. It demonstrates search, auto-completion, 5 | and some advanced options. 6 | 7 | ## Start the application 8 | 9 | In order to start this example application on your machine: 10 | 11 | 1. Open a terminal and `cd` to this directory 12 | 2. Start an HTTP server, for example by running `python3 -m http.server` if you have Python installed, or `npx http-server -p 8000` if you have NodeJS installed 13 | 3. Visit the server URL on your browser (e.g. http://localhost:8000) 14 | -------------------------------------------------------------------------------- /docs/demo/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | body { 5 | font-family: 'Open Sans', Helvetica, Arial, sans-serif; 6 | text-rendering: geometricPrecision; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | color: #555; 10 | margin: 0; 11 | padding: 0; 12 | background: #fff; 13 | } 14 | a { 15 | color: #0099cc; 16 | } 17 | h1, h2, h3, h4 { 18 | margin: 1em 0 0.5em 0; 19 | color: #333; 20 | } 21 | dl { 22 | margin: 0; 23 | } 24 | dt, dd { 25 | display: inline; 26 | margin: 0; 27 | } 28 | dt { 29 | font-weight: bold; 30 | color: #333; 31 | } 32 | dd:after { 33 | content: ''; 34 | display: block; 35 | } 36 | details, summary { 37 | outline: none; 38 | } 39 | .App .main { 40 | padding: 1em; 41 | display: flex; 42 | flex-flow: column; 43 | max-height: 100vh; 44 | max-width: 900px; 45 | margin: 0 auto; 46 | } 47 | .Header h1 { 48 | font-size: 2em; 49 | margin-top: 0; 50 | } 51 | .SearchBox { 52 | position: relative; 53 | } 54 | .Search { 55 | position: relative; 56 | } 57 | .Search button.clear { 58 | position: absolute; 59 | top: 0; 60 | bottom: 0.2em; 61 | right: 0.5em; 62 | font-size: 1.5em; 63 | line-height: 1; 64 | z-index: 20; 65 | border: none; 66 | background: none; 67 | outline: none; 68 | margin: 0; 69 | padding: 0; 70 | } 71 | .Search input { 72 | width: 100%; 73 | padding: 0.5em; 74 | font-size: 16px; 75 | border: 1px solid #ccc; 76 | border-radius: 3px; 77 | outline: none; 78 | color: #555; 79 | box-shadow: none; 80 | } 81 | .hasResults .Explanation { 82 | display: none; 83 | } 84 | .AdvancedOptions { 85 | font-size: 0.9em; 86 | } 87 | .AdvancedOptions summary { 88 | text-decoration: underline; 89 | } 90 | .AdvancedOptions .options { 91 | margin-top: 1em; 92 | font-size: 0.85em; 93 | } 94 | .AdvancedOptions .options label { 95 | display: inline; 96 | margin-left: 0.7em; 97 | } 98 | .SongList { 99 | margin: 1em 0 0 0; 100 | padding: 0; 101 | list-style: none; 102 | flex-grow: 1; 103 | position: relative; 104 | overflow-y: scroll; 105 | -webkit-overflow-scrolling: touch; 106 | } 107 | .SongList:before { 108 | content: ''; 109 | display: block; 110 | position: sticky; 111 | z-index: 10; 112 | left: 0; 113 | right: 0; 114 | top: -1px; 115 | width: 100%; 116 | height: 0.7em; 117 | margin-bottom: -0.7em; 118 | background: linear-gradient(white, rgba(255, 255, 255, 0)); 119 | } 120 | .SongList:after { 121 | content: ''; 122 | display: block; 123 | position: sticky; 124 | z-index: 10; 125 | left: 0; 126 | right: 0; 127 | bottom: -1px; 128 | width: 100%; 129 | height: 0.7em; 130 | margin-bottom: -0.7em; 131 | background: linear-gradient(rgba(255, 255, 255, 0), white); 132 | } 133 | .Song { 134 | border-bottom: 1px solid #ccc; 135 | padding: 0.7em 0 1em 0; 136 | } 137 | .Song:last-child { 138 | border-bottom: none; 139 | } 140 | .Song h3 { 141 | margin-top: 0; 142 | margin-bottom: 0.15em; 143 | } 144 | .SuggestionList { 145 | display: none; 146 | list-style: none; 147 | padding: 0; 148 | border: 1px solid #ccc; 149 | border-top: 0; 150 | margin: 0 0 0.2em 0; 151 | border-radius: 3px; 152 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); 153 | background: rgba(255, 255, 255, 0.93); 154 | position: absolute; 155 | z-index: 20; 156 | left: 0; 157 | right: 0; 158 | } 159 | .hasSuggestions .SuggestionList { 160 | display: block; 161 | } 162 | .Suggestion { 163 | padding: 0.5em 1em; 164 | border-bottom: 1px solid #eee; 165 | } 166 | .Suggestion:last-child { 167 | border: none; 168 | } 169 | .Suggestion.selected { 170 | background: rgba(240, 240, 240, 0.95); 171 | } 172 | .Suggestion:hover:not(.selected) { 173 | background: rgba(250, 250, 250, 0.95); 174 | } 175 | .Loader { 176 | display: none; 177 | } 178 | .loading .Loader { 179 | display: block; 180 | } 181 | .loading .main { 182 | display: none; 183 | } 184 | .Loader, 185 | .Loader:after { 186 | border-radius: 50%; 187 | width: 10em; 188 | height: 10em; 189 | } 190 | .Loader { 191 | margin: 5px auto; 192 | font-size: 5px; 193 | position: relative; 194 | text-indent: -9999em; 195 | border-top: 1.1em solid rgba(255, 255, 255, 0.2); 196 | border-right: 1.1em solid rgba(255, 255, 255, 0.2); 197 | border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); 198 | border-left: 1.1em solid #0099cc; 199 | -webkit-transform: translateZ(0); 200 | -ms-transform: translateZ(0); 201 | transform: translateZ(0); 202 | -webkit-animation: load8 1.1s infinite linear; 203 | animation: load8 1.1s infinite linear; 204 | } 205 | @-webkit-keyframes load8 { 206 | 0% { 207 | -webkit-transform: rotate(0deg); 208 | transform: rotate(0deg); 209 | } 210 | 100% { 211 | -webkit-transform: rotate(360deg); 212 | transform: rotate(360deg); 213 | } 214 | } 215 | @keyframes load8 { 216 | 0% { 217 | -webkit-transform: rotate(0deg); 218 | transform: rotate(0deg); 219 | } 220 | 100% { 221 | -webkit-transform: rotate(360deg); 222 | transform: rotate(360deg); 223 | } 224 | } 225 | 226 | -------------------------------------------------------------------------------- /docs/demo/app.js: -------------------------------------------------------------------------------- 1 | // Setup MiniSearch 2 | const miniSearch = new MiniSearch({ 3 | fields: ['artist', 'title'], 4 | storeFields: ['year'] 5 | }) 6 | 7 | // Select DOM elements 8 | const $app = document.querySelector('.App') 9 | const $search = document.querySelector('.Search') 10 | const $searchInput = document.querySelector('.Search input') 11 | const $clearButton = document.querySelector('.Search button.clear') 12 | const $songList = document.querySelector('.SongList') 13 | const $explanation = document.querySelector('.Explanation') 14 | const $suggestionList = document.querySelector('.SuggestionList') 15 | const $options = document.querySelector('.AdvancedOptions form') 16 | 17 | // Fetch and index data 18 | $app.classList.add('loading') 19 | let songsById = {} 20 | 21 | fetch('billboard_1965-2015.json') 22 | .then(response => response.json()) 23 | .then((allSongs) => { 24 | songsById = allSongs.reduce((byId, song) => { 25 | byId[song.id] = song 26 | return byId 27 | }, {}) 28 | return miniSearch.addAll(allSongs) 29 | }).then(() => { 30 | $app.classList.remove('loading') 31 | }) 32 | 33 | // Bind event listeners: 34 | 35 | // Typing into search bar updates search results and suggestions 36 | $searchInput.addEventListener('input', (event) => { 37 | const query = $searchInput.value 38 | 39 | const results = (query.length > 1) ? getSearchResults(query) : [] 40 | renderSearchResults(results) 41 | 42 | const suggestions = (query.length > 1) ? getSuggestions(query) : [] 43 | renderSuggestions(suggestions) 44 | }) 45 | 46 | // Clicking on clear button clears search and suggestions 47 | $clearButton.addEventListener('click', () => { 48 | $searchInput.value = '' 49 | $searchInput.focus() 50 | 51 | renderSearchResults([]) 52 | renderSuggestions([]) 53 | }) 54 | 55 | // Clicking on a suggestion selects it 56 | $suggestionList.addEventListener('click', (event) => { 57 | const $suggestion = event.target 58 | 59 | if ($suggestion.classList.contains('Suggestion')) { 60 | const query = $suggestion.innerText.trim() 61 | $searchInput.value = query 62 | $searchInput.focus() 63 | 64 | const results = getSearchResults(query) 65 | renderSearchResults(results) 66 | renderSuggestions([]) 67 | } 68 | }) 69 | 70 | // Pressing up/down/enter key while on search bar navigates through suggestions 71 | $search.addEventListener('keydown', (event) => { 72 | const key = event.key 73 | 74 | if (key === 'ArrowDown') { 75 | selectSuggestion(+1) 76 | } else if (key === 'ArrowUp') { 77 | selectSuggestion(-1) 78 | } else if (key === 'Enter' || key === 'Escape') { 79 | $searchInput.blur() 80 | renderSuggestions([]) 81 | } else { 82 | return 83 | } 84 | const query = $searchInput.value 85 | const results = getSearchResults(query) 86 | renderSearchResults(results) 87 | }) 88 | 89 | // Clicking outside of search bar clears suggestions 90 | $app.addEventListener('click', (event) => { 91 | renderSuggestions([]) 92 | }) 93 | 94 | // Changing any advanced option triggers a new search with the updated options 95 | $options.addEventListener('change', (event) => { 96 | const query = $searchInput.value 97 | const results = getSearchResults(query) 98 | renderSearchResults(results) 99 | }) 100 | 101 | // Define functions and support variables 102 | const searchOptions = { 103 | fuzzy: 0.2, 104 | prefix: true, 105 | fields: ['title', 'artist'], 106 | combineWith: 'OR', 107 | filter: null 108 | } 109 | 110 | const getSearchResults = (query) => { 111 | const searchOptions = getSearchOptions() 112 | return miniSearch.search(query, searchOptions).map(({ id }) => songsById[id]) 113 | } 114 | 115 | const getSuggestions = (query) => { 116 | return miniSearch.autoSuggest(query, { boost: { artist: 5 } }) 117 | .filter(({ suggestion, score }, _, [first]) => score > first.score / 4) 118 | .slice(0, 5) 119 | } 120 | 121 | const renderSearchResults = (results) => { 122 | $songList.innerHTML = results.map(({ artist, title, year, rank }) => { 123 | return `
  • 124 |

    ${capitalize(title)}

    125 |
    126 |
    Artist:
    ${capitalize(artist)}
    127 |
    Year:
    ${year}
    128 |
    Billbord Position:
    ${rank}
    129 |
    130 |
  • ` 131 | }).join('\n') 132 | 133 | if (results.length > 0) { 134 | $app.classList.add('hasResults') 135 | } else { 136 | $app.classList.remove('hasResults') 137 | } 138 | } 139 | 140 | const renderSuggestions = (suggestions) => { 141 | $suggestionList.innerHTML = suggestions.map(({ suggestion }) => { 142 | return `
  • ${suggestion}
  • ` 143 | }).join('\n') 144 | 145 | if (suggestions.length > 0) { 146 | $app.classList.add('hasSuggestions') 147 | } else { 148 | $app.classList.remove('hasSuggestions') 149 | } 150 | } 151 | 152 | const selectSuggestion = (direction) => { 153 | const $suggestions = document.querySelectorAll('.Suggestion') 154 | const $selected = document.querySelector('.Suggestion.selected') 155 | const index = Array.from($suggestions).indexOf($selected) 156 | 157 | if (index > -1) { 158 | $suggestions[index].classList.remove('selected') 159 | } 160 | 161 | const nextIndex = Math.max(Math.min(index + direction, $suggestions.length - 1), 0) 162 | $suggestions[nextIndex].classList.add('selected') 163 | $searchInput.value = $suggestions[nextIndex].innerText 164 | } 165 | 166 | const getSearchOptions = () => { 167 | const formData = new FormData($options) 168 | const searchOptions = {} 169 | 170 | searchOptions.fuzzy = formData.has('fuzzy') ? 0.2 : false 171 | searchOptions.prefix = formData.has('prefix') 172 | searchOptions.fields = formData.getAll('fields') 173 | searchOptions.combineWith = formData.get('combineWith') 174 | 175 | const fromYear = parseInt(formData.get('fromYear'), 10) 176 | const toYear = parseInt(formData.get('toYear'), 10) 177 | 178 | searchOptions.filter = ({ year }) => { 179 | year = parseInt(year, 10) 180 | return year >= fromYear && year <= toYear 181 | } 182 | 183 | return searchOptions 184 | } 185 | 186 | const capitalize = (string) => string.replace(/(\b\w)/gi, (char) => char.toUpperCase()) 187 | -------------------------------------------------------------------------------- /docs/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MiniSearch Example 5 | 6 | 7 | 8 | 9 | 10 |
    11 |
    12 |
    loading...
    13 |
    14 |
    15 |

    Song Search

    16 | 78 |
    79 |

    80 | This is a demo of the MiniSearch JavaScript 82 | library: try searching through more than 5000 top songs and artists 83 | in Billboard Hot 100 from year 1965 to 2015. This example 84 | demonstrates search (with prefix and fuzzy match) and auto-completion. 85 |

    86 |
      87 |
    88 |
    89 |
    90 |
    91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /docs/modules/MiniSearch.html: -------------------------------------------------------------------------------- 1 | MiniSearch | MiniSearch

    Module MiniSearch

    Index

    Classes

    Type Aliases

    -------------------------------------------------------------------------------- /docs/modules/SearchableMap_SearchableMap.html: -------------------------------------------------------------------------------- 1 | SearchableMap/SearchableMap | MiniSearch

    Module SearchableMap/SearchableMap

    Index

    Classes

    -------------------------------------------------------------------------------- /docs/types/MiniSearch.AutoVacuumOptions.html: -------------------------------------------------------------------------------- 1 | AutoVacuumOptions | MiniSearch

    Type alias AutoVacuumOptions

    AutoVacuumOptions: VacuumOptions & VacuumConditions

    Options to control auto vacuum behavior. When discarding a document with 2 | MiniSearch#discard, a vacuuming operation is automatically started if 3 | the dirtCount and dirtFactor are above the minDirtCount and 4 | minDirtFactor thresholds defined by this configuration. See VacuumConditions for details on these.

    5 |

    Also, batchSize and batchWait can be specified, controlling batching 6 | behavior (see VacuumOptions).

    7 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.BM25Params.html: -------------------------------------------------------------------------------- 1 | BM25Params | MiniSearch

    Type alias BM25Params

    BM25Params: {
        b: number;
        d: number;
        k: number;
    }

    Parameters of the BM25+ scoring algorithm. Customizing these is almost never 2 | necessary, and finetuning them requires an understanding of the BM25 scoring 3 | model.

    4 |

    Some information about BM25 (and BM25+) can be found at these links:

    5 | 9 |

    Type declaration

    • b: number

      Length normalization impact.

      10 |

      Recommended values are around 0.75. Higher values increase the weight 11 | that field length has on scoring. Setting this to 0 (not recommended) 12 | means that the field length has no effect on scoring. Negative values are 13 | invalid. Defaults to 0.7.

      14 |
    • d: number

      BM25+ frequency normalization lower bound (usually called δ).

      15 |

      Recommended values are between 0.5 and 1. Increasing this parameter 16 | increases the minimum relevance of one occurrence of a search term 17 | regardless of its (possibly very long) field length. Negative values are 18 | invalid. Defaults to 0.5.

      19 |
    • k: number

      Term frequency saturation point.

      20 |

      Recommended values are between 1.2 and 2. Higher values increase the 21 | difference in score between documents with higher and lower term 22 | frequencies. Setting this to 0 or a negative value is invalid. Defaults 23 | to 1.2

      24 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.CombinationOperator.html: -------------------------------------------------------------------------------- 1 | CombinationOperator | MiniSearch

    Type alias CombinationOperator

    -------------------------------------------------------------------------------- /docs/types/MiniSearch.LowercaseCombinationOperator.html: -------------------------------------------------------------------------------- 1 | LowercaseCombinationOperator | MiniSearch

    Type alias LowercaseCombinationOperator

    LowercaseCombinationOperator: "or" | "and" | "and_not"
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.MatchInfo.html: -------------------------------------------------------------------------------- 1 | MatchInfo | MiniSearch

    Type alias MatchInfo

    MatchInfo: {
        [term: string]: string[];
    }

    Match information for a search result. It is a key-value object where keys 2 | are terms that matched, and values are the list of fields that the term was 3 | found in.

    4 |

    Type declaration

    • [term: string]: string[]
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.Query.html: -------------------------------------------------------------------------------- 1 | Query | MiniSearch
    Query: QueryCombination | string | Wildcard

    Search query expression, either a query string or an expression tree 2 | combining several queries with a combination of AND or OR.

    3 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.QueryCombination.html: -------------------------------------------------------------------------------- 1 | QueryCombination | MiniSearch

    Type alias QueryCombination

    QueryCombination: SearchOptions & {
        queries: Query[];
    }

    Type declaration

    -------------------------------------------------------------------------------- /docs/types/MiniSearch.SearchResult.html: -------------------------------------------------------------------------------- 1 | SearchResult | MiniSearch

    Type alias SearchResult

    SearchResult: {
        id: any;
        match: MatchInfo;
        queryTerms: string[];
        score: number;
        terms: string[];
        [key: string]: any;
    }

    Type of the search results. Each search result indicates the document ID, the 2 | terms that matched, the match information, the score, and all the stored 3 | fields.

    4 |

    Type declaration

    • [key: string]: any

      Stored fields

      5 |
    • id: any

      The document ID

      6 |
    • match: MatchInfo

      Match information, see MatchInfo

      7 |
    • queryTerms: string[]

      List of query terms that matched. For example, if a prefix search for 8 | "moto" matches "motorcycle", queryTerms will contain "moto".

      9 |
    • score: number

      Score of the search results

      10 |
    • terms: string[]

      List of document terms that matched. For example, if a prefix search for 11 | "moto" matches "motorcycle", terms will contain "motorcycle".

      12 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.Suggestion.html: -------------------------------------------------------------------------------- 1 | Suggestion | MiniSearch

    Type alias Suggestion

    Suggestion: {
        score: number;
        suggestion: string;
        terms: string[];
    }

    The type of auto-suggestions

    2 |

    Type declaration

    • score: number

      Score for the suggestion

      3 |
    • suggestion: string

      The suggestion

      4 |
    • terms: string[]

      Suggestion as an array of terms

      5 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.VacuumConditions.html: -------------------------------------------------------------------------------- 1 | VacuumConditions | MiniSearch

    Type alias VacuumConditions

    VacuumConditions: {
        minDirtCount?: number;
        minDirtFactor?: number;
    }

    Sets minimum thresholds for dirtCount and dirtFactor that trigger an 2 | automatic vacuuming.

    3 |

    Type declaration

    • Optional minDirtCount?: number

      Minimum dirtCount (number of discarded documents since the last vacuuming) 4 | under which auto vacuum is not triggered. It defaults to 20.

      5 |
    • Optional minDirtFactor?: number

      Minimum dirtFactor (proportion of discarded documents over the total) 6 | under which auto vacuum is not triggered. It defaults to 0.1.

      7 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.VacuumOptions.html: -------------------------------------------------------------------------------- 1 | VacuumOptions | MiniSearch

    Type alias VacuumOptions

    VacuumOptions: {
        batchSize?: number;
        batchWait?: number;
    }

    Options to control vacuuming behavior.

    2 |

    Vacuuming cleans up document references made obsolete by MiniSearch.discard from the index. On large indexes, vacuuming is 3 | potentially costly, because it has to traverse the whole inverted index. 4 | Therefore, in order to dilute this cost so it does not negatively affects the 5 | application, vacuuming is performed in batches, with a delay between each 6 | batch. These options are used to configure the batch size and the delay 7 | between batches.

    8 |

    Type declaration

    • Optional batchSize?: number

      Size of each vacuuming batch (the number of terms in the index that will be 9 | traversed in each batch). Defaults to 1000.

      10 |
    • Optional batchWait?: number

      Wait time between each vacuuming batch in milliseconds. Defaults to 10.

      11 |
    -------------------------------------------------------------------------------- /docs/types/MiniSearch.Wildcard.html: -------------------------------------------------------------------------------- 1 | Wildcard | MiniSearch

    Type alias Wildcard

    Wildcard: typeof wildcard

    Wildcard query, used to match all terms

    2 |
    -------------------------------------------------------------------------------- /examples/plain_js/README.md: -------------------------------------------------------------------------------- 1 | # MiniSearch example in Plain JavaScript 2 | 3 | This is an example client-side application, written in plain JavaScript (no 4 | framework), to showcase `MiniSearch`. It demonstrates search, auto-completion, 5 | and some advanced options. 6 | 7 | ## Start the application 8 | 9 | In order to start this example application on your machine: 10 | 11 | 1. Open a terminal and `cd` to this directory 12 | 2. Start an HTTP server, for example by running `python3 -m http.server` if you have Python installed, or `npx http-server -p 8000` if you have NodeJS installed 13 | 3. Visit the server URL on your browser (e.g. http://localhost:8000) 14 | -------------------------------------------------------------------------------- /examples/plain_js/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | body { 5 | font-family: 'Open Sans', Helvetica, Arial, sans-serif; 6 | text-rendering: geometricPrecision; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | color: #555; 10 | margin: 0; 11 | padding: 0; 12 | background: #fff; 13 | } 14 | a { 15 | color: #0099cc; 16 | } 17 | h1, h2, h3, h4 { 18 | margin: 1em 0 0.5em 0; 19 | color: #333; 20 | } 21 | dl { 22 | margin: 0; 23 | } 24 | dt, dd { 25 | display: inline; 26 | margin: 0; 27 | } 28 | dt { 29 | font-weight: bold; 30 | color: #333; 31 | } 32 | dd:after { 33 | content: ''; 34 | display: block; 35 | } 36 | details, summary { 37 | outline: none; 38 | } 39 | .App .main { 40 | padding: 1em; 41 | display: flex; 42 | flex-flow: column; 43 | max-height: 100vh; 44 | max-width: 900px; 45 | margin: 0 auto; 46 | } 47 | .Header h1 { 48 | font-size: 2em; 49 | margin-top: 0; 50 | } 51 | .SearchBox { 52 | position: relative; 53 | } 54 | .Search { 55 | position: relative; 56 | } 57 | .Search button.clear { 58 | position: absolute; 59 | top: 0; 60 | bottom: 0.2em; 61 | right: 0.5em; 62 | font-size: 1.5em; 63 | line-height: 1; 64 | z-index: 20; 65 | border: none; 66 | background: none; 67 | outline: none; 68 | margin: 0; 69 | padding: 0; 70 | } 71 | .Search input { 72 | width: 100%; 73 | padding: 0.5em; 74 | font-size: 16px; 75 | border: 1px solid #ccc; 76 | border-radius: 3px; 77 | outline: none; 78 | color: #555; 79 | box-shadow: none; 80 | } 81 | .hasResults .Explanation { 82 | display: none; 83 | } 84 | .AdvancedOptions { 85 | font-size: 0.9em; 86 | } 87 | .AdvancedOptions summary { 88 | text-decoration: underline; 89 | } 90 | .AdvancedOptions .options { 91 | margin-top: 1em; 92 | font-size: 0.85em; 93 | } 94 | .AdvancedOptions .options label { 95 | display: inline; 96 | margin-left: 0.7em; 97 | } 98 | .SongList { 99 | margin: 1em 0 0 0; 100 | padding: 0; 101 | list-style: none; 102 | flex-grow: 1; 103 | position: relative; 104 | overflow-y: scroll; 105 | -webkit-overflow-scrolling: touch; 106 | } 107 | .SongList:before { 108 | content: ''; 109 | display: block; 110 | position: sticky; 111 | z-index: 10; 112 | left: 0; 113 | right: 0; 114 | top: -1px; 115 | width: 100%; 116 | height: 0.7em; 117 | margin-bottom: -0.7em; 118 | background: linear-gradient(white, rgba(255, 255, 255, 0)); 119 | } 120 | .SongList:after { 121 | content: ''; 122 | display: block; 123 | position: sticky; 124 | z-index: 10; 125 | left: 0; 126 | right: 0; 127 | bottom: -1px; 128 | width: 100%; 129 | height: 0.7em; 130 | margin-bottom: -0.7em; 131 | background: linear-gradient(rgba(255, 255, 255, 0), white); 132 | } 133 | .Song { 134 | border-bottom: 1px solid #ccc; 135 | padding: 0.7em 0 1em 0; 136 | } 137 | .Song:last-child { 138 | border-bottom: none; 139 | } 140 | .Song h3 { 141 | margin-top: 0; 142 | margin-bottom: 0.15em; 143 | } 144 | .SuggestionList { 145 | display: none; 146 | list-style: none; 147 | padding: 0; 148 | border: 1px solid #ccc; 149 | border-top: 0; 150 | margin: 0 0 0.2em 0; 151 | border-radius: 3px; 152 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); 153 | background: rgba(255, 255, 255, 0.93); 154 | position: absolute; 155 | z-index: 20; 156 | left: 0; 157 | right: 0; 158 | } 159 | .hasSuggestions .SuggestionList { 160 | display: block; 161 | } 162 | .Suggestion { 163 | padding: 0.5em 1em; 164 | border-bottom: 1px solid #eee; 165 | } 166 | .Suggestion:last-child { 167 | border: none; 168 | } 169 | .Suggestion.selected { 170 | background: rgba(240, 240, 240, 0.95); 171 | } 172 | .Suggestion:hover:not(.selected) { 173 | background: rgba(250, 250, 250, 0.95); 174 | } 175 | .Loader { 176 | display: none; 177 | } 178 | .loading .Loader { 179 | display: block; 180 | } 181 | .loading .main { 182 | display: none; 183 | } 184 | .Loader, 185 | .Loader:after { 186 | border-radius: 50%; 187 | width: 10em; 188 | height: 10em; 189 | } 190 | .Loader { 191 | margin: 5px auto; 192 | font-size: 5px; 193 | position: relative; 194 | text-indent: -9999em; 195 | border-top: 1.1em solid rgba(255, 255, 255, 0.2); 196 | border-right: 1.1em solid rgba(255, 255, 255, 0.2); 197 | border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); 198 | border-left: 1.1em solid #0099cc; 199 | -webkit-transform: translateZ(0); 200 | -ms-transform: translateZ(0); 201 | transform: translateZ(0); 202 | -webkit-animation: load8 1.1s infinite linear; 203 | animation: load8 1.1s infinite linear; 204 | } 205 | @-webkit-keyframes load8 { 206 | 0% { 207 | -webkit-transform: rotate(0deg); 208 | transform: rotate(0deg); 209 | } 210 | 100% { 211 | -webkit-transform: rotate(360deg); 212 | transform: rotate(360deg); 213 | } 214 | } 215 | @keyframes load8 { 216 | 0% { 217 | -webkit-transform: rotate(0deg); 218 | transform: rotate(0deg); 219 | } 220 | 100% { 221 | -webkit-transform: rotate(360deg); 222 | transform: rotate(360deg); 223 | } 224 | } 225 | 226 | -------------------------------------------------------------------------------- /examples/plain_js/app.js: -------------------------------------------------------------------------------- 1 | // Setup MiniSearch 2 | const miniSearch = new MiniSearch({ 3 | fields: ['artist', 'title'], 4 | storeFields: ['year'] 5 | }) 6 | 7 | // Select DOM elements 8 | const $app = document.querySelector('.App') 9 | const $search = document.querySelector('.Search') 10 | const $searchInput = document.querySelector('.Search input') 11 | const $clearButton = document.querySelector('.Search button.clear') 12 | const $songList = document.querySelector('.SongList') 13 | const $explanation = document.querySelector('.Explanation') 14 | const $suggestionList = document.querySelector('.SuggestionList') 15 | const $options = document.querySelector('.AdvancedOptions form') 16 | 17 | // Fetch and index data 18 | $app.classList.add('loading') 19 | let songsById = {} 20 | 21 | fetch('billboard_1965-2015.json') 22 | .then(response => response.json()) 23 | .then((allSongs) => { 24 | songsById = allSongs.reduce((byId, song) => { 25 | byId[song.id] = song 26 | return byId 27 | }, {}) 28 | return miniSearch.addAll(allSongs) 29 | }).then(() => { 30 | $app.classList.remove('loading') 31 | }) 32 | 33 | // Bind event listeners: 34 | 35 | // Typing into search bar updates search results and suggestions 36 | $searchInput.addEventListener('input', (event) => { 37 | const query = $searchInput.value 38 | 39 | const results = (query.length > 1) ? getSearchResults(query) : [] 40 | renderSearchResults(results) 41 | 42 | const suggestions = (query.length > 1) ? getSuggestions(query) : [] 43 | renderSuggestions(suggestions) 44 | }) 45 | 46 | // Clicking on clear button clears search and suggestions 47 | $clearButton.addEventListener('click', () => { 48 | $searchInput.value = '' 49 | $searchInput.focus() 50 | 51 | renderSearchResults([]) 52 | renderSuggestions([]) 53 | }) 54 | 55 | // Clicking on a suggestion selects it 56 | $suggestionList.addEventListener('click', (event) => { 57 | const $suggestion = event.target 58 | 59 | if ($suggestion.classList.contains('Suggestion')) { 60 | const query = $suggestion.innerText.trim() 61 | $searchInput.value = query 62 | $searchInput.focus() 63 | 64 | const results = getSearchResults(query) 65 | renderSearchResults(results) 66 | renderSuggestions([]) 67 | } 68 | }) 69 | 70 | // Pressing up/down/enter key while on search bar navigates through suggestions 71 | $search.addEventListener('keydown', (event) => { 72 | const key = event.key 73 | 74 | if (key === 'ArrowDown') { 75 | selectSuggestion(+1) 76 | } else if (key === 'ArrowUp') { 77 | selectSuggestion(-1) 78 | } else if (key === 'Enter' || key === 'Escape') { 79 | $searchInput.blur() 80 | renderSuggestions([]) 81 | } else { 82 | return 83 | } 84 | const query = $searchInput.value 85 | const results = getSearchResults(query) 86 | renderSearchResults(results) 87 | }) 88 | 89 | // Clicking outside of search bar clears suggestions 90 | $app.addEventListener('click', (event) => { 91 | renderSuggestions([]) 92 | }) 93 | 94 | // Changing any advanced option triggers a new search with the updated options 95 | $options.addEventListener('change', (event) => { 96 | const query = $searchInput.value 97 | const results = getSearchResults(query) 98 | renderSearchResults(results) 99 | }) 100 | 101 | // Define functions and support variables 102 | const searchOptions = { 103 | fuzzy: 0.2, 104 | prefix: true, 105 | fields: ['title', 'artist'], 106 | combineWith: 'OR', 107 | filter: null 108 | } 109 | 110 | const getSearchResults = (query) => { 111 | const searchOptions = getSearchOptions() 112 | return miniSearch.search(query, searchOptions).map(({ id }) => songsById[id]) 113 | } 114 | 115 | const getSuggestions = (query) => { 116 | return miniSearch.autoSuggest(query, { boost: { artist: 5 } }) 117 | .filter(({ suggestion, score }, _, [first]) => score > first.score / 4) 118 | .slice(0, 5) 119 | } 120 | 121 | const renderSearchResults = (results) => { 122 | $songList.innerHTML = results.map(({ artist, title, year, rank }) => { 123 | return `
  • 124 |

    ${capitalize(title)}

    125 |
    126 |
    Artist:
    ${capitalize(artist)}
    127 |
    Year:
    ${year}
    128 |
    Billbord Position:
    ${rank}
    129 |
    130 |
  • ` 131 | }).join('\n') 132 | 133 | if (results.length > 0) { 134 | $app.classList.add('hasResults') 135 | } else { 136 | $app.classList.remove('hasResults') 137 | } 138 | } 139 | 140 | const renderSuggestions = (suggestions) => { 141 | $suggestionList.innerHTML = suggestions.map(({ suggestion }) => { 142 | return `
  • ${suggestion}
  • ` 143 | }).join('\n') 144 | 145 | if (suggestions.length > 0) { 146 | $app.classList.add('hasSuggestions') 147 | } else { 148 | $app.classList.remove('hasSuggestions') 149 | } 150 | } 151 | 152 | const selectSuggestion = (direction) => { 153 | const $suggestions = document.querySelectorAll('.Suggestion') 154 | const $selected = document.querySelector('.Suggestion.selected') 155 | const index = Array.from($suggestions).indexOf($selected) 156 | 157 | if (index > -1) { 158 | $suggestions[index].classList.remove('selected') 159 | } 160 | 161 | const nextIndex = Math.max(Math.min(index + direction, $suggestions.length - 1), 0) 162 | $suggestions[nextIndex].classList.add('selected') 163 | $searchInput.value = $suggestions[nextIndex].innerText 164 | } 165 | 166 | const getSearchOptions = () => { 167 | const formData = new FormData($options) 168 | const searchOptions = {} 169 | 170 | searchOptions.fuzzy = formData.has('fuzzy') ? 0.2 : false 171 | searchOptions.prefix = formData.has('prefix') 172 | searchOptions.fields = formData.getAll('fields') 173 | searchOptions.combineWith = formData.get('combineWith') 174 | 175 | const fromYear = parseInt(formData.get('fromYear'), 10) 176 | const toYear = parseInt(formData.get('toYear'), 10) 177 | 178 | searchOptions.filter = ({ year }) => { 179 | year = parseInt(year, 10) 180 | return year >= fromYear && year <= toYear 181 | } 182 | 183 | return searchOptions 184 | } 185 | 186 | const capitalize = (string) => string.replace(/(\b\w)/gi, (char) => char.toUpperCase()) 187 | -------------------------------------------------------------------------------- /examples/plain_js/billboard_1965-2015.json: -------------------------------------------------------------------------------- 1 | ../billboard_1965-2015.json -------------------------------------------------------------------------------- /examples/plain_js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MiniSearch Example 5 | 6 | 7 | 8 | 9 | 10 |
    11 |
    12 |
    loading...
    13 |
    14 |
    15 |

    Song Search

    16 | 78 |
    79 |

    80 | This is a demo of the MiniSearch JavaScript 82 | library: try searching through more than 5000 top songs and artists 83 | in Billboard Hot 100 from year 1965 to 2015. This example 84 | demonstrates search (with prefix and fuzzy match) and auto-completion. 85 |

    86 |
      87 |
    88 |
    89 |
    90 |
    91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minisearch", 3 | "version": "7.1.2", 4 | "description": "Tiny but powerful full-text search engine for browser and Node", 5 | "main": "dist/umd/index.js", 6 | "module": "dist/es/index.js", 7 | "es2015": "dist/es/index.js", 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "require": "./dist/cjs/index.cjs", 12 | "import": "./dist/es/index.js", 13 | "default": "./dist/es/index.js" 14 | }, 15 | "./SearchableMap": { 16 | "require": "./dist/cjs/SearchableMap.cjs", 17 | "import": "./dist/es/SearchableMap.js", 18 | "default": "./dist/es/SearchableMap.js" 19 | } 20 | }, 21 | "unpkg": "./dist/umd/index.js", 22 | "jsdelivr": "./dist/umd/index.js", 23 | "types": "./dist/es/index.d.ts", 24 | "author": "Luca Ongaro", 25 | "homepage": "https://lucaong.github.io/minisearch/", 26 | "bugs": "https://github.com/lucaong/minisearch/issues", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/lucaong/minisearch.git" 30 | }, 31 | "keywords": [ 32 | "search", 33 | "full text", 34 | "fuzzy", 35 | "prefix", 36 | "auto suggest", 37 | "auto complete", 38 | "index" 39 | ], 40 | "license": "MIT", 41 | "dependencies": {}, 42 | "devDependencies": { 43 | "@rollup/plugin-terser": "^0.4.4", 44 | "@rollup/plugin-typescript": "^11.0.0", 45 | "@types/benchmark": "^2.1.1", 46 | "@typescript-eslint/eslint-plugin": "^6.9.0", 47 | "@typescript-eslint/parser": "^6.9.0", 48 | "benchmark": "^2.1.4", 49 | "core-js": "^3.1.4", 50 | "coveralls-next": "^4.2.0", 51 | "eslint": "^8.16.0", 52 | "eslint-config-standard": "^17.0.0", 53 | "eslint-plugin-import": "^2.20.2", 54 | "eslint-plugin-n": "^16.2.0", 55 | "eslint-plugin-node": "^11.1.0", 56 | "eslint-plugin-promise": "^6.0.0", 57 | "fast-check": "^3.0.0", 58 | "jest": "^29.3.1", 59 | "regenerator-runtime": "^0.14.0", 60 | "rollup": "^4.1.0", 61 | "rollup-plugin-dts": "^6.1.0", 62 | "snazzy": "^9.0.0", 63 | "ts-jest": "^29.0.3", 64 | "tslib": "^2.0.1", 65 | "typedoc": "^0.25.3", 66 | "typedoc-plugin-rename-defaults": "^0.7.0", 67 | "typescript": "^5.2.2" 68 | }, 69 | "files": [ 70 | "/dist/**/*", 71 | "/src/**/*" 72 | ], 73 | "jest": { 74 | "testEnvironmentOptions": { 75 | "url": "http://localhost:3000/" 76 | }, 77 | "transform": { 78 | "\\.(js|ts)$": "ts-jest" 79 | }, 80 | "moduleFileExtensions": [ 81 | "ts", 82 | "js" 83 | ], 84 | "testRegex": "\\.test\\.(ts|js)$", 85 | "setupFilesAfterEnv": [ 86 | "/src/testSetup/jest.js" 87 | ] 88 | }, 89 | "scripts": { 90 | "test": "jest", 91 | "test-watch": "jest --watch", 92 | "coverage": "jest --coverage", 93 | "benchmark": "yarn build-benchmark && NODE_ENV=production node --expose-gc benchmarks/dist/index.cjs", 94 | "build-benchmark": "BENCHMARKS=true yarn build", 95 | "build": "yarn clean-build && NODE_ENV=production rollup -c", 96 | "clean-build": "rm -rf dist", 97 | "build-minified": "MINIFY=true yarn build", 98 | "build-docs": "typedoc --options typedoc.json && yarn build-demo", 99 | "build-demo": "mkdir -p ./docs/demo && cp -r ./examples/plain_js/. ./docs/demo", 100 | "lint": "eslint 'src/**/*.{js,ts}'", 101 | "lintfix": "eslint --fix 'src/**/*.{js,ts}'", 102 | "prepublishOnly": "yarn test && yarn build" 103 | }, 104 | "sideEffects": false 105 | } 106 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript' 2 | import dts from 'rollup-plugin-dts' 3 | import terser from '@rollup/plugin-terser' 4 | 5 | const config = ({ format, input, output, name, dir, extension = 'js', exports = undefined }) => { 6 | const shouldMinify = process.env.MINIFY === 'true' && output !== 'dts' 7 | 8 | return { 9 | input, 10 | output: { 11 | sourcemap: output !== 'dts', 12 | dir: `dist/${dir || format}`, 13 | exports, 14 | format, 15 | name, 16 | entryFileNames: shouldMinify ? `[name].min.${extension}` : `[name].${extension}`, 17 | plugins: shouldMinify 18 | ? [terser({ 19 | mangle: { 20 | properties: { 21 | regex: /^_/ 22 | } 23 | } 24 | })] 25 | : [] 26 | }, 27 | plugins: [output === 'dts' ? dts() : typescript()] 28 | } 29 | } 30 | 31 | const benchmarks = { 32 | input: 'benchmarks/index.js', 33 | output: { 34 | sourcemap: true, 35 | dir: 'benchmarks/dist', 36 | format: 'commonjs', 37 | entryFileNames: '[name].cjs', 38 | plugins: [] 39 | }, 40 | external: ['benchmark'], 41 | plugins: [typescript()] 42 | } 43 | 44 | export default process.env.BENCHMARKS === 'true' ? [benchmarks] : [ 45 | // Main (MiniSearch) 46 | config({ format: 'es', input: 'src/index.ts', output: 'es6' }), 47 | config({ format: 'cjs', input: 'src/index.ts', output: 'cjs', dir: 'cjs', extension: 'cjs', exports: 'default' }), 48 | config({ format: 'umd', input: 'src/index.ts', output: 'umd', name: 'MiniSearch' }), 49 | 50 | // SearchableMap 51 | config({ format: 'es', input: 'src/SearchableMap/SearchableMap.ts', output: 'es6' }), 52 | config({ format: 'cjs', input: 'src/SearchableMap/SearchableMap.ts', output: 'cjs', dir: 'cjs', extension: 'cjs', exports: 'default' }), 53 | config({ format: 'umd', input: 'src/SearchableMap/SearchableMap.ts', output: 'umd', name: 'MiniSearch' }), 54 | 55 | // Type declarations 56 | config({ format: 'es', input: 'src/index.ts', output: 'dts', extension: 'd.ts' }), 57 | config({ format: 'es', input: 'src/SearchableMap/SearchableMap.ts', output: 'dts', extension: 'd.ts' }), 58 | config({ format: 'cjs', input: 'src/index.ts', output: 'dts', extension: 'd.cts' }), 59 | config({ format: 'cjs', input: 'src/SearchableMap/SearchableMap.ts', output: 'dts', extension: 'd.cts' }) 60 | ] 61 | -------------------------------------------------------------------------------- /src/SearchableMap/SearchableMap.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import SearchableMap from './SearchableMap' 4 | import * as fc from 'fast-check' 5 | 6 | describe('SearchableMap', () => { 7 | const strings = ['bin', 'border', 'acqua', 'aqua', 'poisson', 'parachute', 8 | 'parapendio', 'acquamarina', 'summertime', 'summer', 'join', 'mediterraneo', 9 | 'perciò', 'borderline', 'bo'] 10 | const keyValues = strings.map((key, i) => [key, i]) 11 | const object = keyValues.reduce((obj, [key, value]) => ({ ...obj, [key]: value })) 12 | 13 | const editDistance = function (a, b, mem = [[0]]) { 14 | mem[a.length] = mem[a.length] || [a.length] 15 | if (mem[a.length][b.length] !== undefined) { return mem[a.length][b.length] } 16 | const d = (a[a.length - 1] === b[b.length - 1]) ? 0 : 1 17 | const distance = (a.length === 1 && b.length === 1) 18 | ? d 19 | : Math.min( 20 | ((a.length > 0) ? editDistance(a.slice(0, -1), b, mem) + 1 : Infinity), 21 | ((b.length > 0) ? editDistance(a, b.slice(0, -1), mem) + 1 : Infinity), 22 | ((a.length > 0 && b.length > 0) ? editDistance(a.slice(0, -1), b.slice(0, -1), mem) + d : Infinity) 23 | ) 24 | mem[a.length][b.length] = distance 25 | return distance 26 | } 27 | 28 | describe('clear', () => { 29 | it('empties the map', () => { 30 | const map = SearchableMap.from(keyValues) 31 | map.clear() 32 | expect(Array.from(map.entries())).toEqual([]) 33 | }) 34 | }) 35 | 36 | describe('delete', () => { 37 | it('deletes the entry at the given key', () => { 38 | const map = SearchableMap.from(keyValues) 39 | map.delete('border') 40 | expect(map.has('border')).toBe(false) 41 | expect(map.has('summer')).toBe(true) 42 | expect(map.has('borderline')).toBe(true) 43 | expect(map.has('bo')).toBe(true) 44 | }) 45 | 46 | it('changes the size of the map', () => { 47 | const map = SearchableMap.from(keyValues) 48 | const sizeBefore = map.size 49 | map.delete('summertime') 50 | expect(map.size).toEqual(sizeBefore - 1) 51 | }) 52 | 53 | it('does nothing if the entry did not exist', () => { 54 | const map = new SearchableMap() 55 | expect(() => map.delete('something')).not.toThrow() 56 | }) 57 | 58 | it('leaves the radix tree in the same state as before the entry was added', () => { 59 | const map = new SearchableMap() 60 | 61 | map.set('hello', 1) 62 | const before = new SearchableMap(new Map(map._tree)) 63 | 64 | map.set('help', 2) 65 | map.delete('help') 66 | 67 | expect(map).toEqual(before) 68 | }) 69 | }) 70 | 71 | describe('entries', () => { 72 | it('returns an iterator of entries', () => { 73 | const map = SearchableMap.from(keyValues) 74 | const entries = Array.from({ [Symbol.iterator]: () => map.entries() }) 75 | expect(entries.sort()).toEqual(keyValues.sort()) 76 | }) 77 | 78 | it('returns an iterable of entries', () => { 79 | const map = SearchableMap.from(keyValues) 80 | const entries = Array.from(map.entries()) 81 | expect(entries.sort()).toEqual(keyValues.sort()) 82 | }) 83 | 84 | it('returns empty iterator, if the map is empty', () => { 85 | const map = new SearchableMap() 86 | const entries = Array.from(map.entries()) 87 | expect(entries).toEqual([]) 88 | }) 89 | }) 90 | 91 | describe('forEach', () => { 92 | it('iterates through each entry', () => { 93 | const entries = [] 94 | const fn = (key, value) => entries.push([key, value]) 95 | const map = SearchableMap.from(keyValues) 96 | map.forEach(fn) 97 | expect(entries).toEqual(Array.from(map.entries())) 98 | }) 99 | }) 100 | 101 | describe('get', () => { 102 | it('gets the value at key', () => { 103 | const key = 'foo' 104 | const value = 42 105 | const map = SearchableMap.fromObject({ [key]: value }) 106 | expect(map.get(key)).toBe(value) 107 | }) 108 | 109 | it('returns undefined if the key is not present', () => { 110 | const map = new SearchableMap() 111 | expect(map.get('not-existent')).toBe(undefined) 112 | }) 113 | }) 114 | 115 | describe('has', () => { 116 | it('returns true if the given key exists in the map', () => { 117 | const map = new SearchableMap() 118 | map.set('something', 42) 119 | expect(map.has('something')).toBe(true) 120 | 121 | map.set('something else', null) 122 | expect(map.has('something else')).toBe(true) 123 | }) 124 | 125 | it('returns false if the given key does not exist in the map', () => { 126 | const map = SearchableMap.fromObject({ something: 42 }) 127 | expect(map.has('not-existing')).toBe(false) 128 | expect(map.has('some')).toBe(false) 129 | }) 130 | }) 131 | 132 | describe('keys', () => { 133 | it('returns an iterator of keys', () => { 134 | const map = SearchableMap.from(keyValues) 135 | const keys = Array.from({ [Symbol.iterator]: () => map.keys() }) 136 | expect(keys.sort()).toEqual(strings.sort()) 137 | }) 138 | 139 | it('returns an iterable of keys', () => { 140 | const map = SearchableMap.from(keyValues) 141 | const keys = Array.from(map.keys()) 142 | expect(keys.sort()).toEqual(strings.sort()) 143 | }) 144 | 145 | it('returns empty iterator, if the map is empty', () => { 146 | const map = new SearchableMap() 147 | const keys = Array.from(map.keys()) 148 | expect(keys).toEqual([]) 149 | }) 150 | }) 151 | 152 | describe('set', () => { 153 | it('sets a value at key', () => { 154 | const map = new SearchableMap() 155 | const key = 'foo' 156 | const value = 42 157 | map.set(key, value) 158 | expect(map.get(key)).toBe(value) 159 | }) 160 | 161 | it('overrides a value at key if it already exists', () => { 162 | const map = SearchableMap.fromObject({ foo: 123 }) 163 | const key = 'foo' 164 | const value = 42 165 | map.set(key, value) 166 | expect(map.get(key)).toBe(value) 167 | }) 168 | 169 | it('throws error if the given key is not a string', () => { 170 | const map = new SearchableMap() 171 | expect(() => map.set(123, 'foo')).toThrow('key must be a string') 172 | }) 173 | }) 174 | 175 | describe('size', () => { 176 | it('is a property containing the size of the map', () => { 177 | const map = SearchableMap.from(keyValues) 178 | expect(map.size).toEqual(keyValues.length) 179 | map.set('foo', 42) 180 | expect(map.size).toEqual(keyValues.length + 1) 181 | map.delete('border') 182 | expect(map.size).toEqual(keyValues.length) 183 | map.clear() 184 | expect(map.size).toEqual(0) 185 | }) 186 | }) 187 | 188 | describe('update', () => { 189 | it('sets a value at key applying a function to the previous value', () => { 190 | const map = new SearchableMap() 191 | const key = 'foo' 192 | const fn = jest.fn(x => (x || 0) + 1) 193 | map.update(key, fn) 194 | expect(fn).toHaveBeenCalledWith(undefined) 195 | expect(map.get(key)).toBe(1) 196 | map.update(key, fn) 197 | expect(fn).toHaveBeenCalledWith(1) 198 | expect(map.get(key)).toBe(2) 199 | }) 200 | 201 | it('throws error if the given key is not a string', () => { 202 | const map = new SearchableMap() 203 | expect(() => map.update(123, () => {})).toThrow('key must be a string') 204 | }) 205 | }) 206 | 207 | describe('values', () => { 208 | it('returns an iterator of values', () => { 209 | const map = SearchableMap.fromObject(object) 210 | const values = Array.from({ [Symbol.iterator]: () => map.values() }) 211 | expect(values.sort()).toEqual(Object.values(object).sort()) 212 | }) 213 | 214 | it('returns an iterable of values', () => { 215 | const map = SearchableMap.fromObject(object) 216 | const values = Array.from(map.values()) 217 | expect(values.sort()).toEqual(Object.values(object).sort()) 218 | }) 219 | 220 | it('returns empty iterator, if the map is empty', () => { 221 | const map = new SearchableMap() 222 | const values = Array.from(map.values()) 223 | expect(values).toEqual([]) 224 | }) 225 | }) 226 | 227 | describe('atPrefix', () => { 228 | it('returns the submap at the given prefix', () => { 229 | const map = SearchableMap.from(keyValues) 230 | 231 | const sum = map.atPrefix('sum') 232 | expect(Array.from(sum.keys()).sort()).toEqual(strings.filter(string => string.startsWith('sum')).sort()) 233 | 234 | const summer = sum.atPrefix('summer') 235 | expect(Array.from(summer.keys()).sort()).toEqual(strings.filter(string => string.startsWith('summer')).sort()) 236 | 237 | const xyz = map.atPrefix('xyz') 238 | expect(Array.from(xyz.keys())).toEqual([]) 239 | 240 | expect(() => sum.atPrefix('xyz')).toThrow() 241 | }) 242 | 243 | it('correctly computes the size', () => { 244 | const map = SearchableMap.from(keyValues) 245 | const sum = map.atPrefix('sum') 246 | expect(sum.size).toEqual(strings.filter(string => string.startsWith('sum')).length) 247 | }) 248 | }) 249 | 250 | describe('fuzzyGet', () => { 251 | const terms = ['summer', 'acqua', 'aqua', 'acquire', 'poisson', 'qua'] 252 | const keyValues = terms.map((key, i) => [key, i]) 253 | const map = SearchableMap.from(keyValues) 254 | 255 | it('returns all entries having the given maximum edit distance from the given key', () => { 256 | [0, 1, 2, 3].forEach(distance => { 257 | const results = map.fuzzyGet('acqua', distance) 258 | const entries = Array.from(results) 259 | expect(entries.map(([key, [value, dist]]) => [key, dist]).sort()) 260 | .toEqual(terms.map(term => [term, editDistance('acqua', term)]).filter(([, d]) => d <= distance).sort()) 261 | expect(entries.every(([key, [value]]) => map.get(key) === value)).toBe(true) 262 | }) 263 | }) 264 | 265 | it('returns an empty object if no matching entries are found', () => { 266 | expect(map.fuzzyGet('winter', 1)).toEqual(new Map()) 267 | }) 268 | 269 | it('returns entries if edit distance is longer than key', () => { 270 | const map = SearchableMap.from([['x', 1], [' x', 2]]) 271 | expect(Array.from(map.fuzzyGet('x', 2).values())).toEqual([[1, 0], [2, 1]]) 272 | }) 273 | }) 274 | 275 | describe('with generated test data', () => { 276 | it('adds and removes entries', () => { 277 | const arrayOfStrings = fc.array(fc.oneof(fc.unicodeString(), fc.string()), { maxLength: 70 }) 278 | const string = fc.oneof(fc.unicodeString({ minLength: 0, maxLength: 4 }), fc.string({ minLength: 0, maxLength: 4 })) 279 | const int = fc.integer({ min: 1, max: 4 }) 280 | 281 | fc.assert(fc.property(arrayOfStrings, string, int, (terms, prefix, maxDist) => { 282 | const map = new SearchableMap() 283 | const standardMap = new Map() 284 | const uniqueTerms = [...new Set(terms)] 285 | 286 | terms.forEach((term, i) => { 287 | map.set(term, i) 288 | standardMap.set(term, i) 289 | expect(map.has(term)).toBe(true) 290 | expect(standardMap.get(term)).toEqual(i) 291 | }) 292 | 293 | expect(map.size).toEqual(standardMap.size) 294 | expect(Array.from(map.entries()).sort()).toEqual(Array.from(standardMap.entries()).sort()) 295 | 296 | expect(Array.from(map.atPrefix(prefix).keys()).sort()) 297 | .toEqual(Array.from(new Set(terms)).filter(t => t.startsWith(prefix)).sort()) 298 | 299 | const fuzzy = map.fuzzyGet(terms[0], maxDist) 300 | expect(Array.from(fuzzy, ([key, [value, dist]]) => [key, dist]).sort()) 301 | .toEqual(uniqueTerms.map(term => [term, editDistance(terms[0], term)]) 302 | .filter(([, dist]) => dist <= maxDist).sort()) 303 | 304 | terms.forEach(term => { 305 | map.delete(term) 306 | expect(map.has(term)).toBe(false) 307 | expect(map.get(term)).toEqual(undefined) 308 | }) 309 | 310 | expect(map.size).toEqual(0) 311 | })) 312 | }) 313 | }) 314 | }) 315 | -------------------------------------------------------------------------------- /src/SearchableMap/TreeIterator.ts: -------------------------------------------------------------------------------- 1 | import type { RadixTree, Entry, LeafType } from './types' 2 | 3 | /** @ignore */ 4 | const ENTRIES = 'ENTRIES' 5 | 6 | /** @ignore */ 7 | const KEYS = 'KEYS' 8 | 9 | /** @ignore */ 10 | const VALUES = 'VALUES' 11 | 12 | /** @ignore */ 13 | const LEAF = '' as LeafType 14 | 15 | interface Iterators { 16 | ENTRIES: Entry 17 | KEYS: string 18 | VALUES: T 19 | } 20 | 21 | type Kind = keyof Iterators 22 | type Result> = Iterators[K] 23 | 24 | type IteratorPath = { 25 | node: RadixTree, 26 | keys: string[] 27 | }[] 28 | 29 | export type IterableSet = { 30 | _tree: RadixTree, 31 | _prefix: string 32 | } 33 | 34 | /** 35 | * @private 36 | */ 37 | class TreeIterator> implements Iterator> { 38 | set: IterableSet 39 | _type: K 40 | _path: IteratorPath 41 | 42 | constructor (set: IterableSet, type: K) { 43 | const node = set._tree 44 | const keys = Array.from(node.keys()) 45 | this.set = set 46 | this._type = type 47 | this._path = keys.length > 0 ? [{ node, keys }] : [] 48 | } 49 | 50 | next (): IteratorResult> { 51 | const value = this.dive() 52 | this.backtrack() 53 | return value 54 | } 55 | 56 | dive (): IteratorResult> { 57 | if (this._path.length === 0) { return { done: true, value: undefined } } 58 | const { node, keys } = last(this._path)! 59 | if (last(keys) === LEAF) { return { done: false, value: this.result() } } 60 | 61 | const child = node.get(last(keys)!)! 62 | this._path.push({ node: child, keys: Array.from(child.keys()) }) 63 | return this.dive() 64 | } 65 | 66 | backtrack (): void { 67 | if (this._path.length === 0) { return } 68 | const keys = last(this._path)!.keys 69 | keys.pop() 70 | if (keys.length > 0) { return } 71 | this._path.pop() 72 | this.backtrack() 73 | } 74 | 75 | key (): string { 76 | return this.set._prefix + this._path 77 | .map(({ keys }) => last(keys)) 78 | .filter(key => key !== LEAF) 79 | .join('') 80 | } 81 | 82 | value (): T { 83 | return last(this._path)!.node.get(LEAF)! 84 | } 85 | 86 | result (): Result { 87 | switch (this._type) { 88 | case VALUES: return this.value() as Result 89 | case KEYS: return this.key() as Result 90 | default: return [this.key(), this.value()] as Result 91 | } 92 | } 93 | 94 | [Symbol.iterator] () { 95 | return this 96 | } 97 | } 98 | 99 | const last = (array: T[]): T | undefined => { 100 | return array[array.length - 1] 101 | } 102 | 103 | export { TreeIterator, ENTRIES, KEYS, VALUES, LEAF } 104 | -------------------------------------------------------------------------------- /src/SearchableMap/fuzzySearch.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-labels */ 2 | import { LEAF } from './TreeIterator' 3 | import type { RadixTree } from './types' 4 | 5 | export type FuzzyResult = [T, number] 6 | 7 | export type FuzzyResults = Map> 8 | 9 | /** 10 | * @ignore 11 | */ 12 | export const fuzzySearch = (node: RadixTree, query: string, maxDistance: number): FuzzyResults => { 13 | const results: FuzzyResults = new Map() 14 | if (query === undefined) return results 15 | 16 | // Number of columns in the Levenshtein matrix. 17 | const n = query.length + 1 18 | 19 | // Matching terms can never be longer than N + maxDistance. 20 | const m = n + maxDistance 21 | 22 | // Fill first matrix row and column with numbers: 0 1 2 3 ... 23 | const matrix = new Uint8Array(m * n).fill(maxDistance + 1) 24 | for (let j = 0; j < n; ++j) matrix[j] = j 25 | for (let i = 1; i < m; ++i) matrix[i * n] = i 26 | 27 | recurse( 28 | node, 29 | query, 30 | maxDistance, 31 | results, 32 | matrix, 33 | 1, 34 | n, 35 | '' 36 | ) 37 | 38 | return results 39 | } 40 | 41 | // Modified version of http://stevehanov.ca/blog/?id=114 42 | 43 | // This builds a Levenshtein matrix for a given query and continuously updates 44 | // it for nodes in the radix tree that fall within the given maximum edit 45 | // distance. Keeping the same matrix around is beneficial especially for larger 46 | // edit distances. 47 | // 48 | // k a t e <-- query 49 | // 0 1 2 3 4 50 | // c 1 1 2 3 4 51 | // a 2 2 1 2 3 52 | // t 3 3 2 1 [2] <-- edit distance 53 | // ^ 54 | // ^ term in radix tree, rows are added and removed as needed 55 | 56 | const recurse = ( 57 | node: RadixTree, 58 | query: string, 59 | maxDistance: number, 60 | results: FuzzyResults, 61 | matrix: Uint8Array, 62 | m: number, 63 | n: number, 64 | prefix: string 65 | ): void => { 66 | const offset = m * n 67 | 68 | key: for (const key of node.keys()) { 69 | if (key === LEAF) { 70 | // We've reached a leaf node. Check if the edit distance acceptable and 71 | // store the result if it is. 72 | const distance = matrix[offset - 1] 73 | if (distance <= maxDistance) { 74 | results.set(prefix, [node.get(key)!, distance]) 75 | } 76 | } else { 77 | // Iterate over all characters in the key. Update the Levenshtein matrix 78 | // and check if the minimum distance in the last row is still within the 79 | // maximum edit distance. If it is, we can recurse over all child nodes. 80 | let i = m 81 | for (let pos = 0; pos < key.length; ++pos, ++i) { 82 | const char = key[pos] 83 | const thisRowOffset = n * i 84 | const prevRowOffset = thisRowOffset - n 85 | 86 | // Set the first column based on the previous row, and initialize the 87 | // minimum distance in the current row. 88 | let minDistance = matrix[thisRowOffset] 89 | 90 | const jmin = Math.max(0, i - maxDistance - 1) 91 | const jmax = Math.min(n - 1, i + maxDistance) 92 | 93 | // Iterate over remaining columns (characters in the query). 94 | for (let j = jmin; j < jmax; ++j) { 95 | const different = char !== query[j] 96 | 97 | // It might make sense to only read the matrix positions used for 98 | // deletion/insertion if the characters are different. But we want to 99 | // avoid conditional reads for performance reasons. 100 | const rpl = matrix[prevRowOffset + j] + +different 101 | const del = matrix[prevRowOffset + j + 1] + 1 102 | const ins = matrix[thisRowOffset + j] + 1 103 | 104 | const dist = matrix[thisRowOffset + j + 1] = Math.min(rpl, del, ins) 105 | 106 | if (dist < minDistance) minDistance = dist 107 | } 108 | 109 | // Because distance will never decrease, we can stop. There will be no 110 | // matching child nodes. 111 | if (minDistance > maxDistance) { 112 | continue key 113 | } 114 | } 115 | 116 | recurse( 117 | node.get(key)!, 118 | query, 119 | maxDistance, 120 | results, 121 | matrix, 122 | i, 123 | n, 124 | prefix + key 125 | ) 126 | } 127 | } 128 | } 129 | 130 | export default fuzzySearch 131 | -------------------------------------------------------------------------------- /src/SearchableMap/types.ts: -------------------------------------------------------------------------------- 1 | export type LeafType = '' & { readonly __tag: unique symbol } 2 | 3 | export interface RadixTree extends Map> { 4 | // Distinguish between an empty string indicating a leaf node and a non-empty 5 | // string indicating a subtree. Overriding these types avoids a lot of type 6 | // assertions elsewhere in the code. It is not 100% foolproof because you can 7 | // still pass in a blank string '' disguised as `string` and potentially get a 8 | // leaf value. 9 | get(key: LeafType): T | undefined 10 | get(key: string): RadixTree | undefined 11 | 12 | set(key: LeafType, value: T): this 13 | set(key: string, value: RadixTree): this 14 | } 15 | 16 | export type Entry = [string, T] 17 | 18 | export type Path = [RadixTree | undefined, string][] 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import MiniSearch from './MiniSearch' 2 | 3 | export * from './MiniSearch' 4 | export default MiniSearch 5 | -------------------------------------------------------------------------------- /src/testSetup/jest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | "allowJs": true, /* Allow javascript files to be compiled. */ 11 | "checkJs": false, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | "strictNullChecks": true, /* Enable strict null checks. */ 31 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 68 | }, 69 | "include": ["./src/**/*.ts", "./src/**/*.js"], 70 | "exclude": ["node_modules", "./src/**/*.test.ts", "./src/**/*.test.js", "./src/testSetup/**/*"] 71 | } 72 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MiniSearch", 3 | "entryPoints": ["src/MiniSearch.ts", "src/SearchableMap/SearchableMap.ts"], 4 | "out": "docs", 5 | "excludePrivate": true, 6 | "excludeProtected": true, 7 | "excludeInternal": true, 8 | "excludeExternals": true, 9 | "plugin": ["typedoc-plugin-rename-defaults"] 10 | } 11 | --------------------------------------------------------------------------------