├── .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 |
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MiniSearch
2 |
3 | [](https://github.com/lucaong/minisearch/actions)
4 | [](https://coveralls.io/github/lucaong/minisearch?branch=master)
5 | [](https://bundlephobia.com/result?p=minisearch)
6 | [](https://www.npmjs.com/package/minisearch)
7 | [](https://www.npmjs.com/package/minisearch)
8 | [](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,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";
--------------------------------------------------------------------------------
/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 `
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 |
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).
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:
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
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.
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.
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
OptionalbatchSize?: 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 |
OptionalbatchWait?: number
Wait time between each vacuuming batch in milliseconds. Defaults to 10.
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 |
--------------------------------------------------------------------------------
Options to control auto vacuum behavior. When discarding a document with 2 | MiniSearch#discard, a vacuuming operation is automatically started if 3 | the
5 |dirtCount
anddirtFactor
are above theminDirtCount
and 4 |minDirtFactor
thresholds defined by this configuration. See VacuumConditions for details on these.Also,
7 |batchSize
andbatchWait
can be specified, controlling batching 6 | behavior (see VacuumOptions).