├── .circleci └── config.yml ├── .gitattributes ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── _ext │ └── hidden_code_block.py ├── _static │ └── rtd_dummy_data.js ├── changelog.rst ├── conf.py ├── configuration.rst ├── customization.rst ├── development.rst ├── features.rst ├── get-involved.rst ├── index.rst ├── installation.rst ├── requirements.txt └── testing.rst ├── gulpfile.js ├── package-lock.json ├── package.json ├── pytest.ini ├── scripts ├── setup_chromedriver.sh └── setup_geckodriver.sh ├── setup.py ├── sphinx_search ├── __init__.py ├── extension.py └── static │ ├── css │ ├── rtd_sphinx_search.css │ └── rtd_sphinx_search.min.css │ └── js │ ├── rtd_search_config.js_t │ ├── rtd_sphinx_search.js │ └── rtd_sphinx_search.min.js ├── tests ├── __init__.py ├── conftest.py ├── dummy_results.json ├── example │ ├── conf.py │ └── index.rst ├── test_extension.py ├── test_ui.py └── utils.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | browser-tools: circleci/browser-tools@1.4.1 5 | 6 | commands: 7 | run-tox: 8 | description: "Run tox" 9 | parameters: 10 | version: 11 | type: string 12 | sphinx-version: 13 | type: string 14 | default: "1,2,3,4,5,6,latest" 15 | steps: 16 | - browser-tools/install-browser-tools 17 | - checkout 18 | # Upgrade tox once https://github.com/tox-dev/tox/issues/2850 is solved. 19 | - run: pip install --user 'tox<4' 20 | - run: 21 | name: Test with Chrome driver 22 | command: tox -e "<>-sphinx{<>}" -- --driver Chrome 23 | - run: 24 | name: Test with Firefox driver 25 | command: tox -e "<>-sphinx{<>}" -- --driver Firefox 26 | 27 | jobs: 28 | py37: 29 | docker: 30 | - image: 'cimg/python:3.7-browsers' 31 | steps: 32 | - run-tox: 33 | version: py37 34 | py38: 35 | docker: 36 | - image: 'cimg/python:3.8-browsers' 37 | steps: 38 | - run-tox: 39 | version: py38 40 | py39: 41 | docker: 42 | - image: 'cimg/python:3.9-browsers' 43 | steps: 44 | - run-tox: 45 | version: py39 46 | py310: 47 | docker: 48 | - image: 'cimg/python:3.10-browsers' 49 | steps: 50 | - run-tox: 51 | version: py310 52 | sphinx-version: 4,5,6,latest 53 | py311: 54 | docker: 55 | - image: 'cimg/python:3.11-browsers' 56 | steps: 57 | - run-tox: 58 | version: py311 59 | sphinx-version: 4,5,6,latest 60 | 61 | workflows: 62 | version: 2 63 | tests: 64 | jobs: 65 | - py311 66 | - py310 67 | - py39 68 | - py38 69 | - py37 70 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Document global line endings settings 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | * text eol=lf 4 | 5 | 6 | # Denote all files that are truly binary and should not be modified. 7 | *.gif binary 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # Environments 102 | .env 103 | .venv 104 | env/ 105 | venv/ 106 | ENV/ 107 | env.bak/ 108 | venv.bak/ 109 | 110 | # Spyder project settings 111 | .spyderproject 112 | .spyproject 113 | 114 | # Rope project settings 115 | .ropeproject 116 | 117 | # mkdocs documentation 118 | /site 119 | 120 | # mypy 121 | .mypy_cache/ 122 | .dmypy.json 123 | dmypy.json 124 | 125 | # Pyre type checker 126 | .pyre/ 127 | 128 | 129 | # https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore 130 | 131 | # Logs 132 | logs 133 | *.log 134 | npm-debug.log* 135 | yarn-debug.log* 136 | yarn-error.log* 137 | lerna-debug.log* 138 | 139 | # Diagnostic reports (https://nodejs.org/api/report.html) 140 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 141 | 142 | # Runtime data 143 | pids 144 | *.pid 145 | *.seed 146 | *.pid.lock 147 | 148 | # Directory for instrumented libs generated by jscoverage/JSCover 149 | lib-cov 150 | 151 | # Coverage directory used by tools like istanbul 152 | coverage 153 | *.lcov 154 | 155 | # nyc test coverage 156 | .nyc_output 157 | 158 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 159 | .grunt 160 | 161 | # Bower dependency directory (https://bower.io/) 162 | bower_components 163 | 164 | # node-waf configuration 165 | .lock-wscript 166 | 167 | # Compiled binary addons (https://nodejs.org/api/addons.html) 168 | build/Release 169 | 170 | # Dependency directories 171 | node_modules/ 172 | jspm_packages/ 173 | 174 | # TypeScript v1 declaration files 175 | typings/ 176 | 177 | # TypeScript cache 178 | *.tsbuildinfo 179 | 180 | # Optional npm cache directory 181 | .npm 182 | 183 | # Optional eslint cache 184 | .eslintcache 185 | 186 | # Optional REPL history 187 | .node_repl_history 188 | 189 | # Output of 'npm pack' 190 | *.tgz 191 | 192 | # Yarn Integrity file 193 | .yarn-integrity 194 | 195 | # dotenv environment variables file 196 | .env 197 | .env.test 198 | 199 | # parcel-bundler cache (https://parceljs.org/) 200 | .cache 201 | 202 | # next.js build output 203 | .next 204 | 205 | # nuxt.js build output 206 | .nuxt 207 | 208 | # vuepress build output 209 | .vuepress/dist 210 | 211 | # Serverless directories 212 | .serverless/ 213 | 214 | # FuseBox cache 215 | .fusebox/ 216 | 217 | # DynamoDB Local files 218 | .dynamodb/ 219 | 220 | 221 | # Custom 222 | 223 | .vscode/ 224 | _build/ 225 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3" 7 | nodejs: "16" 8 | 9 | python: 10 | install: 11 | - method: pip 12 | path: . 13 | - requirements: docs/requirements.txt 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Version 0.3.2 2 | ------------- 3 | 4 | :Date: Jan 15, 2024 5 | 6 | * @stsewd: Security fix, more information in `GHSA-xgfm-fjx6-62mj `__. 7 | 8 | Version 0.3.1 9 | ------------- 10 | 11 | :Date: Mar 27, 2023 12 | 13 | * @stsewd: Add missing static file (#135) 14 | 15 | Version 0.3.0 16 | ------------- 17 | 18 | :Date: Mar 27, 2023 19 | 20 | * @stsewd: Use the search API V3 and add support for custom filters (#132) 21 | 22 | Version 0.2.0 23 | ------------- 24 | 25 | :Date: Jan 24, 2023 26 | 27 | This version adds support for sphinx 6.x, 28 | and makes JQuery optional for animations. 29 | 30 | * @stsewd: CI: fix tests (#127) 31 | * @dependabot[bot]: Bump decode-uri-component from 0.2.0 to 0.2.2 (#120) 32 | * @dependabot[bot]: Bump minimatch and gulp (#119) 33 | * @stsewd: Refactor: don't depend on underscore.js (#116) 34 | * @stsewd: Remove usage of jquery (#115) 35 | 36 | Version 0.1.2 37 | ------------- 38 | 39 | :Date: May 11, 2022 40 | 41 | * `@ericholscher `__: Improve SEO of README & docs index (`#111 `__) 42 | * `@dependabot[bot] `__: Bump moment from 2.29.1 to 2.29.2 (`#110 `__) 43 | * `@stsewd `__: Test with sphinx 4 and python 3.10 (`#109 `__) 44 | * `@stsewd `__: Add sphinx metadata (`#108 `__) 45 | * `@dependabot[bot] `__: Bump minimist from 1.2.5 to 1.2.6 (`#107 `__) 46 | * `@stsewd `__: Rename master -> main (`#106 `__) 47 | * `@stsewd `__: Docs: use sphinx 4.x (`#105 `__) 48 | * `@astrojuanlu `__: Fix docs (`#102 `__) 49 | * `@dependabot[bot] `__: Bump postcss from 7.0.35 to 7.0.39 (`#100 `__) 50 | * `@dependabot[bot] `__: Bump path-parse from 1.0.6 to 1.0.7 (`#99 `__) 51 | 52 | Version 0.1.1 53 | ------------- 54 | 55 | :Date: October 26, 2021 56 | 57 | * `@stsewd `__: Docs: typo (`#94 `__) 58 | * `@nienn `__: Fix #92: Search as you type delay (`#93 `__) 59 | * `@flying-sheep `__: Stop providing top level tests package (`#89 `__) 60 | * `@dependabot[bot] `__: Bump browserslist from 4.16.1 to 4.16.5 (`#88 `__) 61 | * `@stsewd `__: Make it easy to test locally (`#83 `__) 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vaibhav Gupta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune common 2 | include LICENSE 3 | include sphinx_search/static/js/rtd_sphinx_search.js 4 | include sphinx_search/static/js/rtd_sphinx_search.min.js 5 | include sphinx_search/static/js/rtd_search_config.js_t 6 | include sphinx_search/static/css/rtd_sphinx_search.css 7 | include sphinx_search/static/css/rtd_sphinx_search.min.css 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release: clean 2 | git checkout main 3 | git pull origin main 4 | python -m build --sdist --wheel 5 | python -m twine upload dist/* 6 | 7 | tag: 8 | git checkout main 9 | git pull origin main 10 | git tag `python -c "print(__import__('sphinx_search').__version__)"` 11 | git push --tags 12 | 13 | clean: 14 | rm -rf dist/ 15 | 16 | .PHONY: release clean tag 17 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | readthedocs-sphinx-search - Search as you type for Read the Docs 2 | ================================================================ 3 | 4 | .. warning:: 5 | 6 | **This extension is deprecated and it shouldn't be used.** 7 | Read more about this deprecation at https://github.com/readthedocs/readthedocs-sphinx-search/issues/144. 8 | 9 | |pypi| |docs| |license| |build-status| 10 | 11 | ``readthedocs-sphinx-search`` is a `Sphinx`_ extension to enable *search as you type* for docs hosted on `Read the Docs`_. 12 | Try it at https://readthedocs-sphinx-search.readthedocs.io/en/latest/?rtd_search=testing. 13 | 14 | .. _Sphinx: https://www.sphinx-doc.org/ 15 | .. _Read the Docs: https://readthedocs.org/ 16 | 17 | Installation 18 | ------------ 19 | 20 | .. code-block:: bash 21 | 22 | pip install readthedocs-sphinx-search 23 | 24 | 25 | Configuration 26 | ------------- 27 | 28 | Add this extension in your ``conf.py`` file as: 29 | 30 | .. code-block:: python 31 | 32 | extensions = [ 33 | # ... other extensions here 34 | 'sphinx_search.extension', 35 | ] 36 | 37 | 38 | .. |docs| image:: https://readthedocs.org/projects/readthedocs-sphinx-search/badge/?version=latest 39 | :alt: Documentation Status 40 | :target: https://readthedocs-sphinx-search.readthedocs.io/en/latest/?badge=latest 41 | 42 | .. |license| image:: https://img.shields.io/github/license/readthedocs/readthedocs-sphinx-search.svg 43 | :target: LICENSE 44 | :alt: Repository license 45 | 46 | .. |build-status| image:: https://circleci.com/gh/readthedocs/readthedocs-sphinx-search.svg?style=svg 47 | :alt: Build status 48 | :target: https://circleci.com/gh/readthedocs/readthedocs-sphinx-search 49 | 50 | 51 | .. |pypi| image:: https://img.shields.io/pypi/v/readthedocs-sphinx-search.svg 52 | :target: https://pypi.python.org/pypi/readthedocs-sphinx-search 53 | :alt: PyPI Version 54 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_ext/hidden_code_block.py: -------------------------------------------------------------------------------- 1 | # Source: http://scopatz.github.io/hiddencode/ 2 | 3 | """Simple, inelegant Sphinx extension which adds a directive for a 4 | highlighted code-block that may be toggled hidden and shown in HTML. 5 | This is possibly useful for teaching courses. 6 | 7 | The directive, like the standard code-block directive, takes 8 | a language argument and an optional linenos parameter. The 9 | hidden-code-block adds starthidden and label as optional 10 | parameters. 11 | 12 | Examples: 13 | 14 | .. hidden-code-block:: python 15 | :starthidden: False 16 | 17 | a = 10 18 | b = a + 5 19 | 20 | .. hidden-code-block:: python 21 | :label: --- SHOW/HIDE --- 22 | 23 | x = 10 24 | y = x + 5 25 | 26 | Thanks to http://www.javascriptkit.com/javatutors/dom3.shtml for 27 | inspiration on the javascript. 28 | 29 | Thanks to Milad 'animal' Fatenejad for suggesting this extension 30 | in the first place. 31 | 32 | Written by Anthony 'el Scopz' Scopatz, January 2012. 33 | 34 | Released under the WTFPL (http://sam.zoy.org/wtfpl/). 35 | """ 36 | 37 | from docutils import nodes 38 | from docutils.parsers.rst import directives 39 | from sphinx.directives.code import CodeBlock 40 | 41 | HCB_COUNTER = 0 42 | 43 | js_showhide = """\ 44 | 55 | """ 56 | 57 | def nice_bool(arg): 58 | tvalues = ('true', 't', 'yes', 'y') 59 | fvalues = ('false', 'f', 'no', 'n') 60 | arg = directives.choice(arg, tvalues + fvalues) 61 | return arg in tvalues 62 | 63 | 64 | class hidden_code_block(nodes.General, nodes.FixedTextElement): 65 | pass 66 | 67 | 68 | class HiddenCodeBlock(CodeBlock): 69 | """Hidden code block is Hidden""" 70 | 71 | option_spec = dict(starthidden=nice_bool, 72 | label=str, 73 | **CodeBlock.option_spec) 74 | 75 | def run(self): 76 | # Body of the method is more or less copied from CodeBlock 77 | code = u'\n'.join(self.content) 78 | hcb = hidden_code_block(code, code) 79 | hcb['language'] = self.arguments[0] 80 | hcb['linenos'] = 'linenos' in self.options 81 | hcb['starthidden'] = self.options.get('starthidden', True) 82 | hcb['label'] = self.options.get('label', '+ show/hide code') 83 | hcb.line = self.lineno 84 | return [hcb] 85 | 86 | 87 | def visit_hcb_html(self, node): 88 | """Visit hidden code block""" 89 | global HCB_COUNTER 90 | HCB_COUNTER += 1 91 | 92 | # We want to use the original highlighter so that we don't 93 | # have to reimplement it. However it raises a SkipNode 94 | # error at the end of the function call. Thus we intercept 95 | # it and raise it again later. 96 | try: 97 | self.visit_literal_block(node) 98 | except nodes.SkipNode: 99 | pass 100 | 101 | # The last element of the body should be the literal code 102 | # block that was just made. 103 | code_block = self.body[-1] 104 | 105 | fill_header = {'divname': 'hiddencodeblock{0}'.format(HCB_COUNTER), 106 | 'startdisplay': 'none' if node['starthidden'] else 'block', 107 | 'label': node.get('label'), 108 | } 109 | 110 | divheader = ("""""" 111 | """{label}
""" 112 | '''
''' 113 | ).format(**fill_header) 114 | 115 | code_block = js_showhide + divheader + code_block + "
" 116 | 117 | # reassign and exit 118 | self.body[-1] = code_block 119 | raise nodes.SkipNode 120 | 121 | 122 | def depart_hcb_html(self, node): 123 | """Depart hidden code block""" 124 | # Stub because of SkipNode in visit 125 | 126 | 127 | def setup(app): 128 | app.add_directive('hidden-code-block', HiddenCodeBlock) 129 | app.add_node(hidden_code_block, html=(visit_hcb_html, depart_hcb_html)) 130 | -------------------------------------------------------------------------------- /docs/_static/rtd_dummy_data.js: -------------------------------------------------------------------------------- 1 | var READTHEDOCS_DATA = { 2 | 'project': 'readthedocs-sphinx-search', 3 | 'version': 'latest', 4 | 'language': 'en', 5 | 'proxied_api_host': 'https://readthedocs.org', 6 | }; 7 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. include:: ../CHANGELOG.rst 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file does only contain a selection of the most common options. For a 4 | # full list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | from pathlib import Path 15 | import sys 16 | sys.path.insert(0, str(Path(__file__).parent.parent)) 17 | sys.path.insert(0, str(Path(__file__).parent / "_ext")) 18 | 19 | ON_RTD = os.environ.get('READTHEDOCS', False) 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'readthedocs-sphinx-search' 25 | copyright = '2019, Vaibhav Gupta' 26 | author = 'Vaibhav Gupta' 27 | 28 | # The short X.Y version 29 | # The full version, including alpha/beta/rc tags 30 | import sphinx_search 31 | version = release = sphinx_search.__version__ 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # 38 | # needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | 'notfound.extension', 45 | 'sphinx_tabs.tabs', 46 | 'sphinx-prompt', 47 | 'sphinx_search.extension', 48 | 'hidden_code_block', 49 | ] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix(es) of source filenames. 55 | # You can specify multiple suffix as a list of string: 56 | # 57 | # source_suffix = ['.rst', '.md'] 58 | source_suffix = '.rst' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = 'en' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This pattern also affects html_static_path and html_extra_path. 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = None 77 | 78 | 79 | 80 | # -- Options for HTML output ------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'sphinx_rtd_theme' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | if not ON_RTD: 99 | html_js_files = ['rtd_dummy_data.js'] 100 | os.environ['READTHEDOCS_PROJECT'] = 'readthedocs-sphinx-search' 101 | os.environ['READTHEDOCS_VERSION'] = 'latest' 102 | 103 | # Custom sidebar templates, must be a dictionary that maps document names 104 | # to template names. 105 | # 106 | # The default sidebars (for documents that don't match any pattern) are 107 | # defined by theme itself. Builtin themes are using these templates by 108 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 109 | # 'searchbox.html']``. 110 | # 111 | # html_sidebars = {} 112 | 113 | 114 | # -- Options for HTMLHelp output --------------------------------------------- 115 | 116 | # Output file base name for HTML help builder. 117 | htmlhelp_basename = 'readthedocs-sphinx-searchdoc' 118 | 119 | 120 | # -- Options for LaTeX output ------------------------------------------------ 121 | 122 | latex_elements = { 123 | # The paper size ('letterpaper' or 'a4paper'). 124 | # 125 | # 'papersize': 'letterpaper', 126 | 127 | # The font size ('10pt', '11pt' or '12pt'). 128 | # 129 | # 'pointsize': '10pt', 130 | 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | 135 | # Latex figure (float) alignment 136 | # 137 | # 'figure_align': 'htbp', 138 | } 139 | 140 | # Grouping the document tree into LaTeX files. List of tuples 141 | # (source start file, target name, title, 142 | # author, documentclass [howto, manual, or own class]). 143 | latex_documents = [ 144 | (master_doc, 'readthedocs-sphinx-search.tex', 'readthedocs-sphinx-search Documentation', 145 | 'Vaibhav Gupta', 'manual'), 146 | ] 147 | 148 | 149 | # -- Options for manual page output ------------------------------------------ 150 | 151 | # One entry per manual page. List of tuples 152 | # (source start file, name, description, authors, manual section). 153 | man_pages = [ 154 | (master_doc, 'readthedocs-sphinx-search', 'readthedocs-sphinx-search Documentation', 155 | [author], 1) 156 | ] 157 | 158 | 159 | # -- Options for Texinfo output ---------------------------------------------- 160 | 161 | # Grouping the document tree into Texinfo files. List of tuples 162 | # (source start file, target name, title, author, 163 | # dir menu entry, description, category) 164 | texinfo_documents = [ 165 | (master_doc, 'readthedocs-sphinx-search', 'readthedocs-sphinx-search Documentation', 166 | author, 'readthedocs-sphinx-search', 'One line description of project.', 167 | 'Miscellaneous'), 168 | ] 169 | 170 | 171 | # -- Options for Epub output ------------------------------------------------- 172 | 173 | # Bibliographic Dublin Core info. 174 | epub_title = project 175 | 176 | # The unique identifier of the text. This can be a ISBN number 177 | # or the project homepage. 178 | # 179 | # epub_identifier = '' 180 | 181 | # A unique identification for the text. 182 | # 183 | # epub_uid = '' 184 | 185 | # A list of files that should not be packed into the epub file. 186 | epub_exclude_files = ['search.html'] 187 | 188 | # -- Setup for 'confval' used in docs/configuration.rst ---------------------- 189 | 190 | def setup(app): 191 | app.add_object_type('confval', 'confval', 192 | 'pair: %s; configuration value') 193 | 194 | # Install necessary NPM dependencies 195 | import subprocess 196 | subprocess.check_output(["npm", "install", "-g", "jsdoc"]) 197 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | The following settings are available. 5 | You can customize these configuration options in your ``conf.py`` file: 6 | 7 | .. confval:: rtd_sphinx_search_file_type 8 | 9 | Description: Type of files to be included in the html. 10 | 11 | Possible values: 12 | 13 | - ``minified``: Include the minified and uglified CSS and JS files. 14 | - ``un-minified``: Include the original CSS and JS files. 15 | 16 | Default: ``'minified'`` 17 | 18 | Type: ``string`` 19 | 20 | .. confval:: rtd_sphinx_search_default_filter 21 | 22 | Description: Default filter to be used when the user hasn't selected any other filters. 23 | The filter will simply be appended to the current search query. 24 | 25 | Default: ``project:/`` 26 | 27 | Type: ``string`` 28 | 29 | Example: 30 | 31 | .. code-block:: python 32 | 33 | # https://docs.readthedocs.io/page/reference/environment-variables.html 34 | project = os.environ["READTHEDOCS_PROJECT"] 35 | version = os.environ["READTHEDOCS_VERSION"] 36 | 37 | # Include results from subprojects by default. 38 | rtd_sphinx_search_default_filter = f"subprojects:{project}/{version}" 39 | 40 | .. confval:: rtd_sphinx_search_filters 41 | 42 | Description: Map of filters to show in the search bar. 43 | The key is the name of the filter to show to the user, 44 | and the value is the filter itself. 45 | The filter will simply be appended to the current search query. 46 | 47 | Default: ``{}`` 48 | 49 | Type: ``dict`` 50 | 51 | Example: 52 | 53 | .. code-block:: python 54 | 55 | # https://docs.readthedocs.io/page/reference/environment-variables.html 56 | project = os.environ["READTHEDOCS_PROJECT"] 57 | version = os.environ["READTHEDOCS_VERSION"] 58 | 59 | rtd_sphinx_search_filters = { 60 | "Search this project": f"project:{project}/{version}", 61 | "Search subprojects": f"subprojects:{project}/{version}", 62 | } 63 | -------------------------------------------------------------------------------- /docs/customization.rst: -------------------------------------------------------------------------------- 1 | Customization 2 | ============= 3 | 4 | Custom search input 5 | ------------------- 6 | 7 | This extension will attach events to an ``input`` element with a ``search`` role. 8 | If you have a custom search bar, make sure it has the ``search`` role: 9 | 10 | .. code-block:: html 11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 | Custom styles 19 | ------------- 20 | 21 | If you want to change the styles of the search UI, 22 | you can do so by `adding your custom stylesheet`_ to your documentation. 23 | 24 | Basic structure of the HTML which is generated for the search UI 25 | is given below for reference: 26 | 27 | .. hidden-code-block:: html 28 | :starthidden: True 29 | :label: Show/Hide HTML 30 | 31 |
32 |
33 |
34 | 35 | 44 | 46 | 47 | 48 |
49 | 50 | 51 | 138 | 139 | .. _adding your custom stylesheet: https://docs.readthedocs.io/page/guides/adding-custom-css.html 140 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Clone the repository: 5 | 6 | .. prompt:: bash 7 | 8 | git clone https://github.com/readthedocs/readthedocs-sphinx-search 9 | cd readthedocs-sphinx-search/ 10 | 11 | Install dependencies via ``npm``: 12 | 13 | .. prompt:: bash 14 | 15 | npm install 16 | 17 | Generate minified JS and CSS files via ``Gulp``: 18 | 19 | .. prompt:: bash 20 | 21 | gulp 22 | 23 | Run the test suite with ``tox``. More information about testing is 24 | available at :doc:`Testing page `. 25 | 26 | Local testing 27 | ------------- 28 | 29 | You can test this extension from the docs folder: 30 | 31 | .. prompt:: bash 32 | 33 | cd docs 34 | pip install sphinx-autobuild 35 | pip install -r requirements.txt 36 | sphinx-autobuild . _build/html 37 | 38 | Go to http://127.0.0.1:8000 and start searching! 39 | 40 | .. note:: 41 | 42 | The extension works when is hosted on Read the Docs, 43 | but to make it work locally a custom ``READTHEDOCS_DATA`` js variable is injected automatically 44 | to send the search requests to https://readthedocs.org/api/v3/search/. 45 | 46 | Releasing 47 | --------- 48 | 49 | Make sure you have the latest version of these packages: 50 | 51 | .. code-block:: bash 52 | 53 | python -m pip install --upgrade setuptools wheel twine build 54 | 55 | Update the version in ``sphinx_search/__init__.py`` and ``package.json``, 56 | and run: 57 | 58 | .. prompt:: bash 59 | 60 | npm run build 61 | npm run changelog 62 | 63 | Open a pull request with the changes. 64 | After the pull request is merged, run: 65 | 66 | .. prompt:: bash 67 | 68 | TWINE_USERNAME=__token__ 69 | TWINE_PASSWORD= 70 | make release 71 | make tag 72 | 73 | .. note:: Make sure you don't have any uncommitted changes before releasing. 74 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ======== 3 | 4 | Search As You Type 5 | ------------------ 6 | 7 | The extension offers a "search as you type" feature. 8 | This means that results will be shown to the user instantly while typing. 9 | 10 | Minimal UI 11 | ---------- 12 | 13 | The design of the full page search UI is clean and minimal. 14 | 15 | Interaction With Keyboard 16 | ------------------------- 17 | 18 | You can search with only using your Keyboard. 19 | 20 | Opening The Search UI 21 | ~~~~~~~~~~~~~~~~~~~~~ 22 | 23 | - You can open the search UI by pressing :guilabel:`/` (forward slash) button. 24 | After opening of the UI, you can directly start typing your query. 25 | 26 | Selecting Results 27 | ~~~~~~~~~~~~~~~~~ 28 | 29 | - You can iterate on the search results with :guilabel:`↑` (Arrow Up) and 30 | :guilabel:`↓` (Arrow Down) keys. 31 | 32 | - Pressing :guilabel:`↵` (Enter) on a search result will take you to its location. 33 | 34 | - Pressing :guilabel:`↵` (Enter) on input field will take you to the search result page. 35 | 36 | Closing The Search UI 37 | ~~~~~~~~~~~~~~~~~~~~~ 38 | 39 | - Pressing :guilabel:`Esc` (Escape) will close the search UI. 40 | 41 | Link To The Search UI 42 | --------------------- 43 | 44 | If you want to share your search results, 45 | you can do so by passing an URL param: ``rtd_search``, 46 | e.g. ``rtd_search=testing``. 47 | The search UI will open when the page finishes loading, 48 | and the query specified will be searched. Example:: 49 | 50 | https://readthedocs-sphinx-search.readthedocs.io/en/latest?rtd_search=testing 51 | 52 | Browser Support 53 | --------------- 54 | 55 | The JavaScript for this extension is written with new features and syntax, 56 | however, we also want to support older browsers up to IE11. 57 | Therefore, we are using babel to transpile the new and shiny JavaScript code 58 | to support all browsers. 59 | 60 | The CSS is also autoprefixed to extend the support to most of the browsers. 61 | -------------------------------------------------------------------------------- /docs/get-involved.rst: -------------------------------------------------------------------------------- 1 | Get Involved 2 | ============ 3 | 4 | We appreciate your interest in contributing to this project. 5 | Your help will benefit a lot of people around the world. 6 | 7 | Please, if you want to collaborate with us, 8 | you can check out the `list of issues we have on GitHub`_ 9 | and comment there if you need further guidance or just send a Pull Request. 10 | 11 | 12 | .. _list of issues we have on GitHub: https://github.com/readthedocs/readthedocs-sphinx-search/issues 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | :caption: Table of Contents 6 | 7 | installation 8 | features 9 | configuration 10 | customization 11 | development 12 | testing 13 | js-api-reference 14 | get-involved 15 | changelog 16 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | .. note:: 5 | 6 | This extension is developed to be used only on `Read the Docs`_. 7 | If you are building your documentation locally, 8 | this extension will degrade gracefully. 9 | However, if you have a local instance of Read the Docs running, 10 | you can make this extension work by building your documentation with it by 11 | following the instructions on the :doc:`development page `. 12 | 13 | Install the package 14 | 15 | .. tabs:: 16 | 17 | .. tab:: from PyPI 18 | 19 | .. prompt:: bash 20 | 21 | pip install readthedocs-sphinx-search 22 | 23 | .. tab:: from GitHub 24 | 25 | .. prompt:: bash 26 | 27 | pip install git+https://github.com/readthedocs/readthedocs-sphinx-search@master 28 | 29 | 30 | Then, enable this extension by adding it to your ``conf.py``. 31 | 32 | .. code-block:: python 33 | 34 | # conf.py 35 | extensions = [ 36 | # ... other extensions 37 | 'sphinx_search.extension', 38 | ] 39 | 40 | After installing the package and adding it to your ``conf.py`` file, 41 | build your documentation again on `Read the Docs`_ and you'll see the search 42 | UI in your documentation. 43 | 44 | .. _Read the Docs: https://readthedocs.org/ 45 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-prompt 3 | sphinx-tabs 4 | sphinx-rtd-theme 5 | sphinx-notfound-page 6 | sphinx-js 7 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Testing is done using `Selenium WebDriver`_ for automating browser tests. 5 | `Tox`_ is used to execute testing procedures. 6 | 7 | Before running all tests locally, `ChromeDriver`_ (for testing on Chrome) 8 | and `GeckoDriver`_ (for testing on Firefox) are required. 9 | 10 | Install Tox via pip: 11 | 12 | .. prompt:: bash 13 | 14 | pip install tox 15 | 16 | Download and setup the ChromeDriver: 17 | 18 | .. literalinclude:: ../scripts/setup_chromedriver.sh 19 | :language: bash 20 | 21 | Download and setup the GeckoDriver: 22 | 23 | .. literalinclude:: ../scripts/setup_geckodriver.sh 24 | :language: bash 25 | 26 | To run the full test suite against your changes, simply run Tox. 27 | Tox should return without any errors. 28 | You can run Tox against all of our environments and both browsers by running: 29 | 30 | .. prompt:: bash 31 | 32 | tox 33 | 34 | To run tests with a specific environment and for both browsers: 35 | 36 | .. prompt:: bash 37 | 38 | tox -e py36-sphinx20 39 | 40 | To run tests with a specific environment and for a specified browser: 41 | 42 | .. prompt:: bash 43 | 44 | tox -e py36-sphinx20 -- --driver Chrome # run tests with Python 3.6 and Sphinx < 2.1 with Chrome browser 45 | tox -e py36-sphinx20 -- --driver Firefox # run tests with Python 3.6 and Sphinx < 2.1 with Firefox browser 46 | 47 | To run tests against all environments but with a specified browser: 48 | 49 | .. prompt:: bash 50 | 51 | tox -- --driver Chrome # run tests with all environments with Chrome browser 52 | tox -- --driver Firefox # run tests with all environments with Firefox browser 53 | 54 | Internet Explorer and Edge 55 | -------------------------- 56 | 57 | Internet Explorer and Edge don't support headless mode, 58 | but we can check that everything works with 59 | https://saucelabs.com/. 60 | 61 | 62 | .. _Selenium WebDriver: https://seleniumhq.github.io/selenium/docs/api/py/index.html 63 | .. _Tox: https://tox.readthedocs.io/en/latest/ 64 | .. _ChromeDriver: http://chromedriver.chromium.org/ 65 | .. _GeckoDriver: https://github.com/mozilla/geckodriver 66 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var autoprefixer = require("gulp-autoprefixer"), 4 | csso = require("gulp-csso"), 5 | del = require("del"), 6 | gulp = require("gulp"), 7 | rename = require("gulp-rename"), 8 | runSequence = require("run-sequence"), 9 | uglify = require("gulp-uglify"), 10 | babel = require("gulp-babel"); 11 | 12 | gulp.task("styles", function() { 13 | return gulp 14 | .src("sphinx_search/static/css/rtd_sphinx_search.css") 15 | .pipe(autoprefixer()) 16 | .pipe(csso()) 17 | .pipe(rename({ extname: ".min.css" })) 18 | .pipe(gulp.dest("sphinx_search/static/css")); 19 | }); 20 | 21 | gulp.task("scripts", function() { 22 | return gulp 23 | .src("sphinx_search/static/js/rtd_sphinx_search.js") 24 | .pipe(babel({ presets: ["@babel/env"] })) 25 | .pipe(uglify()) 26 | .pipe(rename({ extname: ".min.js" })) 27 | .pipe(gulp.dest("sphinx_search/static/js")); 28 | }); 29 | 30 | gulp.task("clean", function() { 31 | return del(["sphinx_search/**/rtd_sphinx_search.min.*"]); 32 | }); 33 | 34 | gulp.task("default", ["clean"], function() { 35 | runSequence("styles", "scripts"); 36 | }); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readthedocs-sphinx-search", 3 | "version": "0.3.2", 4 | "description": "Enable search-as-you-type feature.", 5 | "scripts": { 6 | "build": "gulp", 7 | "changelog": "gh-changelog -o readthedocs -r readthedocs-sphinx-search -e '' -f CHANGELOG.rst" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/readthedocs/readthedocs-sphinx-search.git" 12 | }, 13 | "author": "Vaibhav Gupta", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/readthedocs/readthedocs-sphinx-search/issues" 17 | }, 18 | "homepage": "https://github.com/readthedocs/readthedocs-sphinx-search", 19 | "devDependencies": { 20 | "@babel/core": "^7.4.5", 21 | "@babel/preset-env": "^7.4.5", 22 | "browserslist": "^4.16.5", 23 | "del": "^4.1.1", 24 | "github-changelog": "git+https://github.com/agjohnson/github-changelog.git", 25 | "gulp": "^3.9.1", 26 | "gulp-autoprefixer": "^6.1.0", 27 | "gulp-babel": "^8.0.0", 28 | "gulp-csso": "^3.0.1", 29 | "gulp-rename": "^1.4.0", 30 | "gulp-uglify": "^3.0.2", 31 | "run-sequence": "^2.2.1" 32 | }, 33 | "browserslist": [ 34 | "last 10 versions", 35 | "ie 11" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | sphinx 4 | 5 | filterwarnings = 6 | ignore:'U' mode is deprecated:DeprecationWarning: 7 | ignore:sphinx.builders.html.DirectoryHTMLBuilder is now deprecated.*:DeprecationWarning: 8 | ignore:sphinx.builders.html.DirectoryHTMLBuilder is now deprecated.*:PendingDeprecationWarning: 9 | -------------------------------------------------------------------------------- /scripts/setup_chromedriver.sh: -------------------------------------------------------------------------------- 1 | # Get the latest version from https://sites.google.com/a/chromium.org/chromedriver/ 2 | wget -N https://chromedriver.storage.googleapis.com/87.0.4280.88/chromedriver_linux64.zip -P ~/ 3 | unzip ~/chromedriver_linux64.zip -d ~/ 4 | rm ~/chromedriver_linux64.zip 5 | sudo mv -f ~/chromedriver /usr/local/bin/ 6 | sudo chmod +x /usr/local/bin/chromedriver 7 | -------------------------------------------------------------------------------- /scripts/setup_geckodriver.sh: -------------------------------------------------------------------------------- 1 | VERSION=v0.32.0 2 | wget -N https://github.com/mozilla/geckodriver/releases/download/${VERSION}/geckodriver-${VERSION}-linux64.tar.gz -P ~/ 3 | tar xvzf ~/geckodriver-${VERSION}-linux64.tar.gz -C ~/ 4 | rm ~/geckodriver-${VERSION}-linux64.tar.gz 5 | sudo mv -f ~/geckodriver /usr/local/bin/ 6 | sudo chmod +x /usr/local/bin/geckodriver 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | import sphinx_search 4 | 5 | 6 | with open('README.rst', 'r') as fh: 7 | long_description = fh.read() 8 | 9 | 10 | setuptools.setup( 11 | name='readthedocs-sphinx-search', 12 | version=sphinx_search.__version__, 13 | author='Vaibhav Gupta', 14 | author_email='vaibhgupt199@gmail.com', 15 | description='Sphinx extension to enable search as you type for docs hosted on Read the Docs.', 16 | url='https://github.com/readthedocs/readthedocs-sphinx-search', 17 | license='MIT', 18 | packages=setuptools.find_packages(exclude=['tests']), 19 | long_description=long_description, 20 | long_description_content_type='text/x-rst', 21 | include_package_data=True, 22 | zip_safe=False, 23 | keywords='sphinx search readthedocs', 24 | python_requires='>=3.6', 25 | project_urls={ 26 | 'Documentation': 'https://readthedocs-sphinx-search.readthedocs.io/', 27 | 'Bug Reports': 'https://github.com/readthedocs/readthedocs-sphinx-search/issues', 28 | 'Source': 'https://github.com/readthedocs/readthedocs-sphinx-search', 29 | }, 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Programming Language :: Python :: 3', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Framework :: Sphinx :: Extension', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /sphinx_search/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.2' 2 | -------------------------------------------------------------------------------- /sphinx_search/extension.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sphinx_search import __version__ 3 | from sphinx.errors import ExtensionError 4 | from pathlib import Path 5 | from sphinx.util.fileutil import copy_asset 6 | 7 | ASSETS_FILES = { 8 | 'minified': [ 9 | Path("js/rtd_search_config.js_t"), 10 | Path("js/rtd_sphinx_search.min.js"), 11 | Path("css/rtd_sphinx_search.min.css"), 12 | ], 13 | 'un-minified': [ 14 | Path("js/rtd_search_config.js_t"), 15 | Path("js/rtd_sphinx_search.js"), 16 | Path("css/rtd_sphinx_search.css"), 17 | ] 18 | } 19 | 20 | 21 | def _get_static_files(config): 22 | file_type = config.rtd_sphinx_search_file_type 23 | if file_type not in ASSETS_FILES: 24 | raise ExtensionError(f'"{file_type}" file type is not supported') 25 | 26 | return ASSETS_FILES[file_type] 27 | 28 | 29 | def get_context(config): 30 | """ 31 | Get context for templates. 32 | 33 | This mainly returns the settings from the extension 34 | that are needed in our JS code. 35 | """ 36 | default_filter = config.rtd_sphinx_search_default_filter 37 | filters = config.rtd_sphinx_search_filters 38 | # When converting to JSON, the order of the keys is not guaranteed. 39 | # So we pass a list of tuples to preserve the order. 40 | filters = [(name, filter) for name, filter in filters.items()] 41 | return { 42 | "rtd_search_config": { 43 | "filters": filters, 44 | "default_filter": default_filter, 45 | } 46 | } 47 | 48 | 49 | def copy_asset_files(app, exception): 50 | """ 51 | Copy assets files to the output directory. 52 | 53 | If the name of the file ends with ``_t``, it will be interpreted as a template. 54 | """ 55 | if exception is None: # build succeeded 56 | root = Path(__file__).parent 57 | for file in _get_static_files(app.config): 58 | source = root / 'static' / file 59 | destination = Path(app.outdir) / '_static' / file.parent 60 | context = None 61 | # If the file ends with _t, it is a template file, 62 | # so we provide a context to treat it as a template. 63 | if file.name.endswith('_t'): 64 | context = get_context(app.config) 65 | copy_asset(str(source), str(destination), context=context) 66 | 67 | 68 | def inject_static_files(app): 69 | """Inject correct CSS and JS files based on the value of ``rtd_sphinx_search_file_type``.""" 70 | for file in _get_static_files(app.config): 71 | file = str(file) 72 | # Templates end with `_t`, Sphinx removes the _t when copying the file. 73 | if file.endswith('_t'): 74 | file = file[:-2] 75 | if file.endswith('.js'): 76 | app.add_js_file(file) 77 | elif file.endswith('.css'): 78 | app.add_css_file(file) 79 | 80 | 81 | def setup(app): 82 | project = os.environ.get('READTHEDOCS_PROJECT', '') 83 | version = os.environ.get('READTHEDOCS_VERSION', '') 84 | 85 | app.add_config_value('rtd_sphinx_search_file_type', 'minified', 'html') 86 | app.add_config_value('rtd_sphinx_search_default_filter', f'project:{project}/{version}', 'html') 87 | app.add_config_value('rtd_sphinx_search_filters', {}, 'html') 88 | 89 | app.connect('builder-inited', inject_static_files) 90 | app.connect('build-finished', copy_asset_files) 91 | 92 | return { 93 | 'version': __version__, 94 | 'parallel_read_safe': True, 95 | 'parallel_write_safe': True, 96 | } 97 | -------------------------------------------------------------------------------- /sphinx_search/static/css/rtd_sphinx_search.css: -------------------------------------------------------------------------------- 1 | .search__outer__wrapper { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | z-index: 700; 8 | } 9 | 10 | /* Backdrop */ 11 | 12 | .search__backdrop { 13 | /* Positioning */ 14 | position: fixed; 15 | top: 0; 16 | left: 0; 17 | z-index: 500; 18 | 19 | /* Display and box model */ 20 | width: 100%; 21 | height: 100%; 22 | display: none; 23 | 24 | /* Other */ 25 | background-color: rgba(0, 0, 0, 0.502); 26 | } 27 | 28 | .search__outer { 29 | /* Positioning */ 30 | margin: auto; 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | right: 0; 35 | bottom: 0; 36 | z-index: 100000; 37 | 38 | /* Display and box model */ 39 | height: 80%; 40 | width: 80%; 41 | max-height: 1000px; 42 | max-width: 1500px; 43 | padding: 10px; 44 | overflow-y: scroll; 45 | 46 | /* Other */ 47 | border: 1px solid #e0e0e0; 48 | line-height: 1.875; 49 | background-color: #fcfcfc; 50 | box-shadow: 1px 3px 4px rgba(0, 0, 0, 0.09); 51 | text-align: left; 52 | } 53 | 54 | /* Custom scrollbar */ 55 | 56 | .search__outer::-webkit-scrollbar-track { 57 | border-radius: 10px; 58 | background-color: #fcfcfc; 59 | } 60 | 61 | .search__outer::-webkit-scrollbar { 62 | width: 7px; 63 | height: 7px; 64 | background-color: #fcfcfc; 65 | } 66 | 67 | .search__outer::-webkit-scrollbar-thumb { 68 | border-radius: 10px; 69 | background-color: #8f8f8f; 70 | } 71 | 72 | /* Cross icon on top-right corner */ 73 | 74 | .search__cross__img { 75 | width: 15px; 76 | height: 15px; 77 | margin: 12px; 78 | } 79 | 80 | .search__cross { 81 | position: absolute; 82 | top: 0; 83 | right: 0; 84 | } 85 | 86 | .search__cross:hover { 87 | cursor: pointer; 88 | } 89 | 90 | /* Input field on search modal */ 91 | 92 | .search__outer__input { 93 | /* Display and box model */ 94 | width: 90%; 95 | height: 30px; 96 | font-size: 19px; 97 | outline: none; 98 | box-sizing: border-box; 99 | 100 | /* Other */ 101 | background-color: #fcfcfc; 102 | border: none; 103 | border-bottom: 1px solid #757575; 104 | 105 | /* search icon */ 106 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjEuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB2aWV3Qm94PSIwIDAgNDUxIDQ1MSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDUxIDQ1MTsiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGc+DQoJPHBhdGggZD0iTTQ0Ny4wNSw0MjhsLTEwOS42LTEwOS42YzI5LjQtMzMuOCw0Ny4yLTc3LjksNDcuMi0xMjYuMUMzODQuNjUsODYuMiwyOTguMzUsMCwxOTIuMzUsMEM4Ni4yNSwwLDAuMDUsODYuMywwLjA1LDE5Mi4zDQoJCXM4Ni4zLDE5Mi4zLDE5Mi4zLDE5Mi4zYzQ4LjIsMCw5Mi4zLTE3LjgsMTI2LjEtNDcuMkw0MjguMDUsNDQ3YzIuNiwyLjYsNi4xLDQsOS41LDRzNi45LTEuMyw5LjUtNA0KCQlDNDUyLjI1LDQ0MS44LDQ1Mi4yNSw0MzMuMiw0NDcuMDUsNDI4eiBNMjYuOTUsMTkyLjNjMC05MS4yLDc0LjItMTY1LjMsMTY1LjMtMTY1LjNjOTEuMiwwLDE2NS4zLDc0LjIsMTY1LjMsMTY1LjMNCgkJcy03NC4xLDE2NS40LTE2NS4zLDE2NS40QzEwMS4xNSwzNTcuNywyNi45NSwyODMuNSwyNi45NSwxOTIuM3oiLz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjwvc3ZnPg0K"); 107 | background-repeat: no-repeat; 108 | background-position: left; 109 | background-size: 15px 15px; 110 | padding-left: 25px; 111 | } 112 | 113 | .search__outer__input:focus { 114 | outline: none; 115 | } 116 | 117 | /* For material UI style underline on input field */ 118 | 119 | .search__outer .bar { 120 | position: relative; 121 | display: block; 122 | width: 90%; 123 | margin-bottom: 15px; 124 | } 125 | 126 | .search__outer .bar:before, 127 | .search__outer .bar:after { 128 | content: ""; 129 | height: 2px; 130 | width: 0; 131 | bottom: 1px; 132 | position: absolute; 133 | background: #5264ae; 134 | transition: 0.2s ease all; 135 | } 136 | 137 | .search__outer .bar:before { 138 | left: 50%; 139 | } 140 | 141 | .search__outer .bar:after { 142 | right: 50%; 143 | } 144 | 145 | .search__outer__input:focus ~ .bar:before, 146 | .search__outer__input:focus ~ .bar:after { 147 | width: 50%; 148 | } 149 | 150 | /* Search result */ 151 | 152 | .search__result__box { 153 | padding: 0px 10px; 154 | } 155 | 156 | .search__result__single { 157 | margin-top: 10px; 158 | border-bottom: 1px solid #e6e6e6; 159 | } 160 | 161 | .search__result__box .active { 162 | background-color: rgb(245, 245, 245); 163 | } 164 | 165 | .search__error__box { 166 | color: black; 167 | min-width: 300px; 168 | font-weight: 700; 169 | } 170 | 171 | .outer_div_page_results { 172 | margin: 5px 0px; 173 | overflow: auto; 174 | padding: 3px 5px; 175 | } 176 | 177 | .search__result__single a { 178 | text-decoration: none; 179 | cursor: pointer; 180 | } 181 | 182 | /* Title of each search result */ 183 | 184 | .search__result__title { 185 | /* Display and box model */ 186 | display: inline-block; 187 | font-weight: 500; 188 | margin-bottom: 15px; 189 | margin-top: 0; 190 | font-size: 15px; 191 | 192 | /* Other */ 193 | color: #6ea0ec; 194 | border-bottom: 1px solid #6ea0ec; 195 | } 196 | 197 | .search__result__subheading { 198 | color: black; 199 | font-weight: 700; 200 | float: left; 201 | width: 20%; 202 | font-size: 15px; 203 | margin-right: 10px; 204 | word-break: break-all; 205 | overflow-x: hidden; 206 | } 207 | 208 | .search__result__content { 209 | margin: 0; 210 | text-decoration: none; 211 | color: black; 212 | font-size: 15px; 213 | display: block; 214 | margin-bottom: 5px; 215 | margin-bottom: 0; 216 | line-height: inherit; 217 | float: right; 218 | width: calc(80% - 15px); 219 | text-align: left; 220 | } 221 | 222 | /* Highlighting of matched results */ 223 | 224 | .search__outer span { 225 | font-style: normal; 226 | } 227 | 228 | .search__outer .search__result__title span { 229 | background-color: #e5f6ff; 230 | padding-bottom: 3px; 231 | border-bottom-color: black; 232 | } 233 | 234 | .search__outer .search__result__content span { 235 | background-color: #e5f6ff; 236 | border-bottom: 1px solid black; 237 | } 238 | 239 | .search__result__subheading span { 240 | border-bottom: 1px solid black; 241 | } 242 | 243 | .outer_div_page_results:hover { 244 | background-color: rgb(245, 245, 245); 245 | } 246 | 247 | .br-for-hits { 248 | display: block; 249 | content: ""; 250 | margin-top: 10px; 251 | } 252 | 253 | .rtd_ui_search_subtitle { 254 | all: unset; 255 | color: inherit; 256 | font-size: 85%; 257 | } 258 | 259 | .rtd__search__credits { 260 | margin: auto; 261 | position: absolute; 262 | top: 0; 263 | left: 0; 264 | right: 0; 265 | bottom: calc(-80% - 20px); 266 | width: 80%; 267 | max-width: 1500px; 268 | height: 30px; 269 | overflow: hidden; 270 | background: #eee; 271 | z-index: 100000; 272 | border: 1px solid #eee; 273 | padding: 5px 10px; 274 | text-align: center; 275 | color: black; 276 | } 277 | 278 | .rtd__search__credits a { 279 | color: black; 280 | text-decoration: underline; 281 | } 282 | 283 | .search__domain_role_name { 284 | font-size: 80%; 285 | letter-spacing: 1px; 286 | } 287 | 288 | .search__filters { 289 | padding: 0px 10px; 290 | } 291 | 292 | .search__filters ul { 293 | list-style: none; 294 | padding: 0; 295 | margin: 0; 296 | display: flex; 297 | } 298 | 299 | .search__filters li { 300 | display: flex; 301 | align-items: center; 302 | margin-right: 15px; 303 | } 304 | 305 | .search__filters label { 306 | color: black; 307 | font-size: 15px; 308 | margin: auto; 309 | } 310 | 311 | .search__filters .search__filters__title { 312 | color: black; 313 | font-size: 15px; 314 | } 315 | 316 | @media (max-width: 670px) { 317 | .rtd__search__credits { 318 | height: 50px; 319 | bottom: calc(-80% - 40px); 320 | overflow: hidden; 321 | } 322 | } 323 | 324 | @media (min-height: 1250px) { 325 | .rtd__search__credits { 326 | bottom: calc(-1000px - 30px); 327 | } 328 | } 329 | 330 | @media (max-width: 630px) { 331 | .search__result__subheading { 332 | float: none; 333 | width: 90%; 334 | } 335 | 336 | .search__result__content { 337 | float: none; 338 | width: 90%; 339 | } 340 | } 341 | 342 | @keyframes fade-in { 343 | from { 344 | opacity: 0; 345 | } 346 | to { 347 | opacity: 1; 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /sphinx_search/static/css/rtd_sphinx_search.min.css: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.search__backdrop,.search__outer__wrapper{position:fixed;top:0;left:0;width:100%;height:100%;z-index:700}.search__backdrop{z-index:500;display:none;background-color:rgba(0,0,0,.502)}.search__outer{margin:auto;position:absolute;top:0;left:0;right:0;bottom:0;z-index:100000;height:80%;width:80%;max-height:1000px;max-width:1500px;padding:10px;overflow-y:scroll;border:1px solid #e0e0e0;line-height:1.875;background-color:#fcfcfc;-webkit-box-shadow:1px 3px 4px rgba(0,0,0,.09);box-shadow:1px 3px 4px rgba(0,0,0,.09);text-align:left}.search__outer::-webkit-scrollbar-track{border-radius:10px;background-color:#fcfcfc}.search__outer::-webkit-scrollbar{width:7px;height:7px;background-color:#fcfcfc}.search__outer::-webkit-scrollbar-thumb{border-radius:10px;background-color:#8f8f8f}.search__cross__img{width:15px;height:15px;margin:12px}.search__cross{position:absolute;top:0;right:0}.search__cross:hover{cursor:pointer}.search__outer__input{width:90%;height:30px;font-size:19px;outline:0;-webkit-box-sizing:border-box;box-sizing:border-box;background-color:#fcfcfc;border:0;border-bottom:1px solid #757575;background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjEuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB2aWV3Qm94PSIwIDAgNDUxIDQ1MSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDUxIDQ1MTsiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGc+DQoJPHBhdGggZD0iTTQ0Ny4wNSw0MjhsLTEwOS42LTEwOS42YzI5LjQtMzMuOCw0Ny4yLTc3LjksNDcuMi0xMjYuMUMzODQuNjUsODYuMiwyOTguMzUsMCwxOTIuMzUsMEM4Ni4yNSwwLDAuMDUsODYuMywwLjA1LDE5Mi4zDQoJCXM4Ni4zLDE5Mi4zLDE5Mi4zLDE5Mi4zYzQ4LjIsMCw5Mi4zLTE3LjgsMTI2LjEtNDcuMkw0MjguMDUsNDQ3YzIuNiwyLjYsNi4xLDQsOS41LDRzNi45LTEuMyw5LjUtNA0KCQlDNDUyLjI1LDQ0MS44LDQ1Mi4yNSw0MzMuMiw0NDcuMDUsNDI4eiBNMjYuOTUsMTkyLjNjMC05MS4yLDc0LjItMTY1LjMsMTY1LjMtMTY1LjNjOTEuMiwwLDE2NS4zLDc0LjIsMTY1LjMsMTY1LjMNCgkJcy03NC4xLDE2NS40LTE2NS4zLDE2NS40QzEwMS4xNSwzNTcuNywyNi45NSwyODMuNSwyNi45NSwxOTIuM3oiLz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjwvc3ZnPg0K);background-repeat:no-repeat;background-position:left;background-size:15px 15px;padding-left:25px}.search__outer__input:focus{outline:0}.search__outer .bar{position:relative;display:block;width:90%;margin-bottom:15px}.search__outer .bar:after,.search__outer .bar:before{content:"";height:2px;width:0;bottom:1px;position:absolute;background:#5264ae;-webkit-transition:.2s ease all;-o-transition:.2s ease all;transition:.2s ease all}.search__outer .bar:before{left:50%}.search__outer .bar:after{right:50%}.search__outer__input:focus~.bar:after,.search__outer__input:focus~.bar:before{width:50%}.search__result__box{padding:0 10px}.search__result__single{margin-top:10px;border-bottom:1px solid #e6e6e6}.outer_div_page_results:hover,.search__result__box .active{background-color:#f5f5f5}.search__error__box{color:#000;min-width:300px;font-weight:700}.outer_div_page_results{margin:5px 0;overflow:auto;padding:3px 5px}.search__result__single a{text-decoration:none;cursor:pointer}.search__result__title{display:inline-block;font-weight:500;margin-bottom:15px;margin-top:0;font-size:15px;color:#6ea0ec;border-bottom:1px solid #6ea0ec}.search__result__subheading{color:#000;font-weight:700;float:left;width:20%;font-size:15px;margin-right:10px;word-break:break-all;overflow-x:hidden}.search__result__content{text-decoration:none;color:#000;font-size:15px;display:block;margin:0;line-height:inherit;float:right;width:calc(80% - 15px);text-align:left}.search__outer span{font-style:normal}.search__outer .search__result__title span{background-color:#e5f6ff;padding-bottom:3px;border-bottom-color:#000}.search__outer .search__result__content span{background-color:#e5f6ff;border-bottom:1px solid #000}.search__result__subheading span{border-bottom:1px solid #000}.br-for-hits{display:block;content:"";margin-top:10px}.rtd_ui_search_subtitle{all:unset;color:inherit;font-size:85%}.rtd__search__credits{margin:auto;position:absolute;top:0;left:0;right:0;bottom:calc(-80% - 20px);width:80%;max-width:1500px;height:30px;overflow:hidden;background:#eee;z-index:100000;border:1px solid #eee;padding:5px 10px;text-align:center;color:#000}.rtd__search__credits a{color:#000;text-decoration:underline}.search__domain_role_name{font-size:80%;letter-spacing:1px}.search__filters{padding:0 10px}.search__filters li,.search__filters ul{display:-webkit-box;display:-ms-flexbox;display:flex}.search__filters ul{list-style:none;padding:0;margin:0}.search__filters li{-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-right:15px}.search__filters label{margin:auto}.search__filters .search__filters__title,.search__filters label{color:#000;font-size:15px}@media (max-width:670px){.rtd__search__credits{height:50px;bottom:calc(-80% - 40px);overflow:hidden}}@media (min-height:1250px){.rtd__search__credits{bottom:calc(-1000px - 30px)}}@media (max-width:630px){.search__result__content,.search__result__subheading{float:none;width:90%}} -------------------------------------------------------------------------------- /sphinx_search/static/js/rtd_search_config.js_t: -------------------------------------------------------------------------------- 1 | {# Set the extension options as a JSON object, so it can be used from our JS code. #} 2 | var RTD_SEARCH_CONFIG = {{ rtd_search_config | tojson }}; 3 | -------------------------------------------------------------------------------- /sphinx_search/static/js/rtd_sphinx_search.js: -------------------------------------------------------------------------------- 1 | const MAX_SUGGESTIONS = 50; 2 | const MAX_SECTION_RESULTS = 3; 3 | const MAX_SUBSTRING_LIMIT = 100; 4 | const ANIMATION_TIME = 200; 5 | const FETCH_RESULTS_DELAY = 250; 6 | const CLEAR_RESULTS_DELAY = 300; 7 | const RTD_SEARCH_PARAMETER = "rtd_search"; 8 | 9 | 10 | /** 11 | * Mark a string as safe to be used as HTML in setNodeContent. 12 | */ 13 | function SafeHtmlString(value) { 14 | this.value = value; 15 | this.isSafe = true; 16 | } 17 | 18 | /** 19 | * Create a SafeHtmlString instance from a string. 20 | * 21 | * @param {String} value 22 | */ 23 | function markAsSafe(value) { 24 | return new SafeHtmlString(value); 25 | } 26 | 27 | /** 28 | * Set the content of an element as text or HTML. 29 | * 30 | * @param {Element} element 31 | * @param {String|SafeHtmlString} content 32 | */ 33 | function setElementContent(element, content) { 34 | if (content.isSafe) { 35 | element.innerHTML = content.value; 36 | } else { 37 | element.innerText = content; 38 | } 39 | } 40 | 41 | /** 42 | * Debounce the function. 43 | * Usage:: 44 | * 45 | * let func = debounce(() => console.log("Hello World"), 3000); 46 | * 47 | * // calling the func 48 | * func(); 49 | * 50 | * //cancelling the execution of the func (if not executed) 51 | * func.cancel(); 52 | * 53 | * @param {Function} func function to be debounced 54 | * @param {Number} wait time to wait before running func (in miliseconds) 55 | * @return {Function} debounced function 56 | */ 57 | const debounce = (func, wait) => { 58 | let timeout; 59 | 60 | let debounced = function() { 61 | let context = this; 62 | let args = arguments; 63 | clearTimeout(timeout); 64 | timeout = setTimeout(() => func.apply(context, args), wait); 65 | }; 66 | 67 | debounced.cancel = () => { 68 | clearTimeout(timeout); 69 | timeout = null; 70 | }; 71 | 72 | return debounced; 73 | }; 74 | 75 | 76 | /** 77 | * Build a section with its matching results. 78 | * 79 | * A section has the form: 80 | * 81 | * 82 | *
83 | * 84 | * {title} 85 | * 86 | *

87 | * {contents[0]} 88 | *

89 | *

90 | * {contents[1]} 91 | *

92 | * ... 93 | *
94 | *
95 | * 96 | * @param {String} id. 97 | * @param {String} title. 98 | * @param {String} link. 99 | * @param {Array} contents. 100 | */ 101 | const buildSection = function (id, title, link, contents) { 102 | let span_element = createDomNode("span", {class: "search__result__subheading"}); 103 | setElementContent(span_element, title) 104 | 105 | let div_element = createDomNode("div", {class: "outer_div_page_results", id: id}); 106 | div_element.appendChild(span_element); 107 | 108 | for (var i = 0; i < contents.length; i += 1) { 109 | let p_element = createDomNode("p", {class: "search__result__content"}); 110 | setElementContent(p_element, contents[i]); 111 | div_element.appendChild(p_element); 112 | } 113 | 114 | let section = createDomNode("a", {href: link}); 115 | section.appendChild(div_element); 116 | return section; 117 | }; 118 | 119 | 120 | /** 121 | * Adds/removes "rtd_search" url parameter to the url. 122 | */ 123 | const updateUrl = () => { 124 | let parsed_url = new URL(window.location.href); 125 | let search_query = getSearchTerm(); 126 | // search_query should not be an empty string. 127 | if (search_query.length > 0) { 128 | parsed_url.searchParams.set(RTD_SEARCH_PARAMETER, search_query); 129 | } else { 130 | parsed_url.searchParams.delete(RTD_SEARCH_PARAMETER); 131 | } 132 | // Update url. 133 | window.history.pushState({}, null, parsed_url.toString()); 134 | }; 135 | 136 | 137 | /* 138 | * Keeps in sync the original search bar with the input from the modal. 139 | */ 140 | const updateSearchBar = () => { 141 | let search_bar = getInputField(); 142 | search_bar.value = getSearchTerm(); 143 | }; 144 | 145 | 146 | /* 147 | * Returns true if the modal window is visible. 148 | */ 149 | const isModalVisible = () => { 150 | let modal = document.querySelector(".search__outer__wrapper"); 151 | if (modal !== null && modal.style !== null && modal.style.display !== null) { 152 | return modal.style.display === 'block'; 153 | } 154 | return false; 155 | }; 156 | 157 | 158 | /** 159 | * Create and return DOM nodes 160 | * with passed attributes. 161 | * 162 | * @param {String} nodeName name of the node 163 | * @param {Object} attributes obj of attributes to be assigned to the node 164 | * @return {Object} dom node with attributes 165 | */ 166 | const createDomNode = (nodeName, attributes) => { 167 | let node = document.createElement(nodeName); 168 | if (attributes !== null) { 169 | for (let attr in attributes) { 170 | node.setAttribute(attr, attributes[attr]); 171 | } 172 | } 173 | return node; 174 | }; 175 | 176 | /** 177 | * Checks if data type is "string" or not 178 | * 179 | * @param {*} data data whose data-type is to be checked 180 | * @return {Boolean} 'true' if type is "string" and length is > 0 181 | */ 182 | const _is_string = str => { 183 | if (typeof str === "string" && str.length > 0) { 184 | return true; 185 | } else { 186 | return false; 187 | } 188 | }; 189 | 190 | 191 | /** 192 | * Generate and return html structure 193 | * for a page section result. 194 | * 195 | * @param {Object} sectionData object containing the result data 196 | * @param {String} page_link link of the main page. It is used to construct the section link 197 | * @param {Number} id to be used in for this section 198 | */ 199 | const get_section_html = (sectionData, page_link, id) => { 200 | let section_subheading = sectionData.title; 201 | let highlights = sectionData.highlights; 202 | if (highlights.title.length) { 203 | section_subheading = markAsSafe(highlights.title[0]); 204 | } 205 | 206 | let section_content = [ 207 | sectionData.content.substring(0, MAX_SUBSTRING_LIMIT) + " ..." 208 | ]; 209 | 210 | if (highlights.content.length) { 211 | let highlight_content = highlights.content; 212 | section_content = []; 213 | for ( 214 | let j = 0; 215 | j < highlight_content.length && j < MAX_SECTION_RESULTS; 216 | ++j 217 | ) { 218 | section_content.push(markAsSafe("... " + highlight_content[j] + " ...")); 219 | } 220 | } 221 | 222 | let section_link = `${page_link}#${sectionData.id}`; 223 | let section_id = "hit__" + id; 224 | return buildSection(section_id, section_subheading, section_link, section_content); 225 | }; 226 | 227 | 228 | /** 229 | * Generate search results for a single page. 230 | * 231 | * This has the form: 232 | * 251 | * 252 | * @param {Object} resultData search results of a page 253 | * @param {String} projectName 254 | * @param {Number} id from the last section 255 | * @return {Object} a
node with the results of a single page 256 | */ 257 | const generateSingleResult = (resultData, projectName, id) => { 258 | let page_link = resultData.path; 259 | let page_title = resultData.title; 260 | let highlights = resultData.highlights; 261 | 262 | if (highlights.title.length) { 263 | page_title = markAsSafe(highlights.title[0]); 264 | } 265 | 266 | let h2_element = createDomNode("h2", {class: "search__result__title"}); 267 | setElementContent(h2_element, page_title); 268 | 269 | // Results can belong to different projects. 270 | // If the result isn't from the current project, add a note about it. 271 | const project_slug = resultData.project.slug 272 | if (projectName !== project_slug) { 273 | let subtitle = createDomNode("small", {class: "rtd_ui_search_subtitle"}); 274 | subtitle.innerText = ` (from project ${project_slug})`; 275 | h2_element.appendChild(subtitle); 276 | // If the result isn't from the current project, 277 | // then we create an absolute link to the page. 278 | page_link = `${resultData.domain}${page_link}`; 279 | } 280 | h2_element.appendChild(createDomNode("br")) 281 | 282 | let a_element = createDomNode("a", {href: page_link}); 283 | a_element.appendChild(h2_element); 284 | 285 | let content = createDomNode("div"); 286 | content.appendChild(a_element); 287 | 288 | let separator = createDomNode("br", {class: "br-for-hits"}); 289 | for (let i = 0; i < resultData.blocks.length; ++i) { 290 | let block = resultData.blocks[i]; 291 | let section = null; 292 | id += 1; 293 | if (block.type === "section") { 294 | section = get_section_html( 295 | block, 296 | page_link, 297 | id, 298 | ); 299 | } 300 | 301 | if (section !== null) { 302 | content.appendChild(section); 303 | content.appendChild(separator); 304 | } 305 | } 306 | return content; 307 | }; 308 | 309 | /** 310 | * Generate search suggestions list. 311 | * 312 | * @param {Object} data response data from the search backend 313 | * @param {String} projectName name (slug) of the project 314 | * @return {Object} a
node with class "search__result__box" with results 315 | */ 316 | const generateSuggestionsList = (data, projectName) => { 317 | let search_result_box = createDomNode("div", { 318 | class: "search__result__box" 319 | }); 320 | 321 | let max_results = Math.min(MAX_SUGGESTIONS, data.results.length); 322 | let id = 0; 323 | for (let i = 0; i < max_results; ++i) { 324 | let search_result_single = createDomNode("div", { 325 | class: "search__result__single" 326 | }); 327 | 328 | let content = generateSingleResult(data.results[i], projectName, id); 329 | 330 | search_result_single.appendChild(content); 331 | search_result_box.appendChild(search_result_single); 332 | 333 | id += data.results[i].blocks.length; 334 | } 335 | return search_result_box; 336 | }; 337 | 338 | /** 339 | * Removes .active class from all the suggestions. 340 | */ 341 | const removeAllActive = () => { 342 | const results = document.querySelectorAll(".outer_div_page_results.active"); 343 | const results_arr = Object.keys(results).map(i => results[i]); 344 | for (let i = 1; i <= results_arr.length; ++i) { 345 | results_arr[i - 1].classList.remove("active"); 346 | } 347 | }; 348 | 349 | /** 350 | * Add .active class to the search suggestion 351 | * corresponding to `id`, and scroll to that suggestion smoothly. 352 | * 353 | * @param {Number} id of the suggestion to activate 354 | */ 355 | const addActive = (id) => { 356 | const current_item = document.querySelector("#hit__" + id); 357 | // in case of no results or any error, 358 | // current_item will not be found in the DOM. 359 | if (current_item !== null) { 360 | current_item.classList.add("active"); 361 | current_item.scrollIntoView({ 362 | behavior: "smooth", 363 | block: "nearest", 364 | inline: "start" 365 | }); 366 | } 367 | }; 368 | 369 | 370 | /* 371 | * Select next/previous result. 372 | * Go to the first result if already in the last result, 373 | * or to the last result if already in the first result. 374 | * 375 | * @param {Boolean} forward. 376 | */ 377 | const selectNextResult = (forward) => { 378 | const all = document.querySelectorAll(".outer_div_page_results"); 379 | const current = document.querySelector(".outer_div_page_results.active"); 380 | 381 | let next_id = 1; 382 | let last_id = 1; 383 | 384 | if (all.length > 0) { 385 | let last = all[all.length - 1]; 386 | if (last.id !== null) { 387 | let match = last.id.match(/\d+/); 388 | if (match !== null) { 389 | last_id = Number(match[0]); 390 | } 391 | } 392 | } 393 | 394 | if (current !== null && current.id !== null) { 395 | let match = current.id.match(/\d+/); 396 | if (match !== null) { 397 | next_id = Number(match[0]); 398 | next_id += forward? 1 : -1; 399 | } 400 | } 401 | 402 | // Cycle to the first or last result. 403 | if (next_id <= 0) { 404 | next_id = last_id; 405 | } else if (next_id > last_id) { 406 | next_id = 1; 407 | } 408 | 409 | removeAllActive(); 410 | addActive(next_id); 411 | }; 412 | 413 | 414 | /** 415 | * Returns initial search input field, 416 | * which is already present in the docs. 417 | * 418 | * @return {Object} Input field node 419 | */ 420 | const getInputField = () => { 421 | let inputField; 422 | 423 | // on search some pages (like search.html), 424 | // no div is present with role="search", 425 | // in that case, use the other query to select 426 | // the input field 427 | try { 428 | inputField = document.querySelector("[role='search'] input"); 429 | if (inputField === undefined || inputField === null) { 430 | throw "'[role='search'] input' not found"; 431 | } 432 | } catch (err) { 433 | inputField = document.querySelector("input[name='q']"); 434 | } 435 | 436 | return inputField; 437 | }; 438 | 439 | /* 440 | * Returns the current search term from the modal. 441 | */ 442 | const getSearchTerm = () => { 443 | let search_outer_input = document.querySelector(".search__outer__input"); 444 | if (search_outer_input !== null) { 445 | return search_outer_input.value || ""; 446 | } 447 | return ""; 448 | } 449 | 450 | /** 451 | * Removes all results from the search modal. 452 | * It doesn't close the search box. 453 | */ 454 | const removeResults = () => { 455 | let all_results = document.querySelectorAll(".search__result__box"); 456 | for (let i = 0; i < all_results.length; ++i) { 457 | all_results[i].parentElement.removeChild(all_results[i]); 458 | } 459 | }; 460 | 461 | /** 462 | * Creates and returns a div with error message 463 | * and some styles. 464 | * 465 | * @param {String} err_msg error message to be displayed 466 | */ 467 | const getErrorDiv = err_msg => { 468 | let err_div = createDomNode("div", { 469 | class: "search__result__box search__error__box" 470 | }); 471 | err_div.innerText = err_msg; 472 | return err_div; 473 | }; 474 | 475 | /** 476 | * Fetch the suggestions from search backend, 477 | * and appends the results to
node, 478 | * which is already created when the page was loaded. 479 | * 480 | * @param {String} api_endpoint: API endpoint 481 | * @param {Object} parameters: search parameters 482 | * @param {String} projectName: name (slug) of the project 483 | * @return {Function} debounced function with debounce time of 500ms 484 | */ 485 | const fetchAndGenerateResults = (api_endpoint, parameters, projectName) => { 486 | let search_outer = document.querySelector(".search__outer"); 487 | 488 | // Removes all results (if there is any), 489 | // and show the "Searching ...." text to 490 | // the user. 491 | removeResults(); 492 | let search_loding = createDomNode("div", { class: "search__result__box" }); 493 | search_loding.innerHTML = "Searching ...."; 494 | search_outer.appendChild(search_loding); 495 | 496 | let fetchFunc = () => { 497 | // Update URL just before fetching the results 498 | updateUrl(); 499 | updateSearchBar(); 500 | 501 | const url = api_endpoint + "?" + new URLSearchParams(parameters).toString(); 502 | 503 | fetch(url, {method: "GET"}) 504 | .then(response => { 505 | if (!response.ok) { 506 | throw new Error(); 507 | } 508 | return response.json(); 509 | }) 510 | .then(data => { 511 | if (data.results.length > 0) { 512 | let search_result_box = generateSuggestionsList( 513 | data, 514 | projectName 515 | ); 516 | removeResults(); 517 | search_outer.appendChild(search_result_box); 518 | 519 | // remove active classes from all suggestions 520 | // if the mouse hovers, otherwise styles from 521 | // :hover and .active will clash. 522 | search_outer.addEventListener("mouseenter", e => { 523 | removeAllActive(); 524 | }); 525 | } else { 526 | removeResults(); 527 | let err_div = getErrorDiv("No results found"); 528 | search_outer.appendChild(err_div); 529 | } 530 | }) 531 | .catch(error => { 532 | removeResults(); 533 | let err_div = getErrorDiv("There was an error. Please try again."); 534 | search_outer.appendChild(err_div); 535 | }); 536 | }; 537 | return debounce(fetchFunc, FETCH_RESULTS_DELAY); 538 | }; 539 | 540 | /** 541 | * Creates the initial html structure which will be 542 | * appended to the as soon as the page loads. 543 | * This html structure will serve as the boilerplate 544 | * to show our search results. 545 | * 546 | * @param {Array} filters: filters to be applied to the search. 547 | * {["Filter name", "Filter value"]} 548 | * @return {String} initial html structure 549 | */ 550 | const generateAndReturnInitialHtml = (filters) => { 551 | let innerHTML = ` 552 |
553 |
554 | 555 | 556 | 557 | 558 |
559 | 560 |
561 |
562 |
    563 |
564 |
565 |
566 |
567 | Search by Read the Docs & readthedocs-sphinx-search 568 |
569 | `; 570 | 571 | let div = createDomNode("div", { 572 | class: "search__outer__wrapper search__backdrop", 573 | }); 574 | div.innerHTML = innerHTML; 575 | 576 | let filters_list = div.querySelector(".search__filters ul"); 577 | const config = getConfig(); 578 | // Add filters below the search box if present. 579 | if (filters.length > 0) { 580 | let li = createDomNode("li", {"class": "search__filters__title"}); 581 | li.innerText = "Filters:"; 582 | filters_list.appendChild(li); 583 | } 584 | // Each checkbox contains the index of the filter, 585 | // so we can get the proper filter when selected. 586 | for (let i = 0, len = filters.length; i < len; i++) { 587 | const [name, filter] = filters[i]; 588 | let li = createDomNode("li", {"class": "search__filter", "title": filter}); 589 | let id = `rtd-search-filter-${i}`; 590 | let checkbox = createDomNode("input", {"type": "checkbox", "id": id}); 591 | let label = createDomNode("label", {"for": id}); 592 | label.innerText = name; 593 | checkbox.value = i; 594 | li.appendChild(checkbox); 595 | li.appendChild(label); 596 | filters_list.appendChild(li); 597 | 598 | checkbox.addEventListener("click", event => { 599 | // Uncheck all other filters when one is checked. 600 | // We only support one filter at a time. 601 | const checkboxes = document.querySelectorAll('.search__filters input'); 602 | for (const checkbox of checkboxes) { 603 | if (checkbox.checked && checkbox.value != event.target.value) { 604 | checkbox.checked = false; 605 | } 606 | } 607 | 608 | // Trigger a search with the current selected filter. 609 | let search_query = getSearchTerm(); 610 | const filter = getCurrentFilter(config); 611 | search_query = filter + " " + search_query; 612 | const search_params = { 613 | q: search_query, 614 | }; 615 | fetchAndGenerateResults(config.api_endpoint, search_params, config.project)(); 616 | }); 617 | } 618 | return div; 619 | }; 620 | 621 | /** 622 | * Opens the search modal. 623 | * 624 | * @param {String} custom_query if a custom query is provided, 625 | * initialize the value of input field with it, or fallback to the 626 | * value from the original search bar. 627 | */ 628 | const showSearchModal = custom_query => { 629 | // removes previous results (if there are any). 630 | removeResults(); 631 | 632 | let show_modal = function () { 633 | // removes the focus from the initial input field 634 | // which as already present in the docs. 635 | let search_bar = getInputField(); 636 | search_bar.blur(); 637 | 638 | // sets the value of the input field to empty string and focus it. 639 | let search_outer_input = document.querySelector( 640 | ".search__outer__input" 641 | ); 642 | if (search_outer_input !== null) { 643 | if ( 644 | typeof custom_query !== "undefined" && 645 | _is_string(custom_query) 646 | ) { 647 | search_outer_input.value = custom_query; 648 | search_bar.value = custom_query; 649 | } else { 650 | search_outer_input.value = search_bar.value; 651 | } 652 | search_outer_input.focus(); 653 | } 654 | }; 655 | 656 | if (window.jQuery) { 657 | $(".search__outer__wrapper").fadeIn(ANIMATION_TIME, show_modal); 658 | } else { 659 | let element = document.querySelector(".search__outer__wrapper"); 660 | if (element && element.style) { 661 | element.style.display = "block"; 662 | } 663 | show_modal(); 664 | } 665 | }; 666 | 667 | /** 668 | * Closes the search modal. 669 | */ 670 | const removeSearchModal = () => { 671 | // removes previous results before closing 672 | removeResults(); 673 | 674 | updateSearchBar(); 675 | 676 | // sets the value of input field to empty string and remove the focus. 677 | let search_outer_input = document.querySelector(".search__outer__input"); 678 | if (search_outer_input !== null) { 679 | search_outer_input.value = ""; 680 | search_outer_input.blur(); 681 | } 682 | 683 | // update url (remove 'rtd_search' param) 684 | updateUrl(); 685 | 686 | if (window.jQuery) { 687 | $(".search__outer__wrapper").fadeOut(ANIMATION_TIME); 688 | } else { 689 | let element = document.querySelector(".search__outer__wrapper"); 690 | if (element && element.style) { 691 | element.style.display = "none"; 692 | } 693 | } 694 | }; 695 | 696 | 697 | /** 698 | * Get the config used by the search. 699 | * 700 | * This configiration is extracted from the global variable 701 | * READTHEDOCS_DATA, which is defined by Read the Docs, 702 | * and the global variable RTD_SEARCH_CONFIG, which is defined 703 | * by the sphinx extension. 704 | * 705 | * @return {Object} config 706 | */ 707 | function getConfig() { 708 | const project = READTHEDOCS_DATA.project; 709 | const version = READTHEDOCS_DATA.version; 710 | const api_host = READTHEDOCS_DATA.proxied_api_host || '/_'; 711 | // This variable is defined in the `rtd_search_config.js` file 712 | // that is loaded before this file, 713 | // containing settings from the sphinx extension. 714 | const search_config = RTD_SEARCH_CONFIG || {}; 715 | const api_endpoint = api_host + "/api/v3/search/"; 716 | return { 717 | project: project, 718 | version: version, 719 | api_endpoint: api_endpoint, 720 | filters: search_config.filters, 721 | default_filter: search_config.default_filter, 722 | } 723 | } 724 | 725 | 726 | /** 727 | * Get the current selected filter. 728 | * 729 | * If no filter checkbox is selected, the default filter is returned. 730 | * 731 | * @param {Object} config 732 | */ 733 | function getCurrentFilter(config) { 734 | const checkbox = document.querySelector('.search__filters input:checked'); 735 | if (checkbox == null) { 736 | return config.default_filter; 737 | } 738 | return config.filters[parseInt(checkbox.value)][1]; 739 | } 740 | 741 | window.addEventListener("DOMContentLoaded", () => { 742 | // only add event listeners if READTHEDOCS_DATA global 743 | // variable is found. 744 | if (window.hasOwnProperty("READTHEDOCS_DATA")) { 745 | const config = getConfig(); 746 | 747 | let initialHtml = generateAndReturnInitialHtml(config.filters); 748 | document.body.appendChild(initialHtml); 749 | 750 | let search_outer_wrapper = document.querySelector( 751 | ".search__outer__wrapper" 752 | ); 753 | let search_outer_input = document.querySelector( 754 | ".search__outer__input" 755 | ); 756 | let cross_icon = document.querySelector(".search__cross"); 757 | 758 | // this stores the current request. 759 | let current_request = null; 760 | 761 | let search_bar = getInputField(); 762 | search_bar.addEventListener("focus", e => { 763 | showSearchModal(); 764 | }); 765 | 766 | search_outer_input.addEventListener("input", e => { 767 | let search_query = getSearchTerm(); 768 | if (search_query.length > 0) { 769 | if (current_request !== null) { 770 | // cancel previous ajax request. 771 | current_request.cancel(); 772 | } 773 | const filter = getCurrentFilter(config); 774 | search_query = filter + " " + search_query; 775 | const search_params = { 776 | q: search_query, 777 | }; 778 | current_request = fetchAndGenerateResults(config.api_endpoint, search_params, config.project); 779 | current_request(); 780 | } else { 781 | // if the last request returns the results, 782 | // the suggestions list is generated even if there 783 | // is no query. To prevent that, this function 784 | // is debounced here. 785 | let func = () => { 786 | removeResults(); 787 | updateUrl(); 788 | }; 789 | debounce(func, CLEAR_RESULTS_DELAY)(); 790 | updateUrl(); 791 | } 792 | }); 793 | 794 | search_outer_input.addEventListener("keydown", e => { 795 | // if "ArrowDown is pressed" 796 | if (e.keyCode === 40) { 797 | e.preventDefault(); 798 | selectNextResult(true); 799 | } 800 | 801 | // if "ArrowUp" is pressed. 802 | if (e.keyCode === 38) { 803 | e.preventDefault(); 804 | selectNextResult(false); 805 | } 806 | 807 | // if "Enter" key is pressed. 808 | if (e.keyCode === 13) { 809 | e.preventDefault(); 810 | const current_item = document.querySelector( 811 | ".outer_div_page_results.active" 812 | ); 813 | // if an item is selected, 814 | // then redirect to its link 815 | if (current_item !== null) { 816 | const link = current_item.parentElement["href"]; 817 | window.location.href = link; 818 | } else { 819 | // submit search form if there 820 | // is no active item. 821 | const input_field = getInputField(); 822 | const form = input_field.parentElement; 823 | 824 | search_bar.value = getSearchTerm(); 825 | form.submit(); 826 | } 827 | } 828 | }); 829 | 830 | search_outer_wrapper.addEventListener("click", e => { 831 | // HACK: only close the search modal if the 832 | // element clicked has as the parent Node. 833 | // This is done so that search modal only gets closed 834 | // if the user clicks on the backdrop area. 835 | if (e.target.parentNode === document.body) { 836 | removeSearchModal(); 837 | } 838 | }); 839 | 840 | // close the search modal if clicked on cross icon. 841 | cross_icon.addEventListener("click", e => { 842 | removeSearchModal(); 843 | }); 844 | 845 | // close the search modal if the user pressed 846 | // Escape button 847 | document.addEventListener("keydown", e => { 848 | if (e.keyCode === 27) { 849 | removeSearchModal(); 850 | } 851 | }); 852 | 853 | // open search modal if "forward slash" button is pressed 854 | document.addEventListener("keydown", e => { 855 | if (e.keyCode === 191 && !isModalVisible()) { 856 | // prevent opening "Quick Find" in Firefox 857 | e.preventDefault(); 858 | showSearchModal(); 859 | } 860 | }); 861 | 862 | // if "rtd_search" is present in URL parameters, 863 | // then open the search modal and show the results 864 | // for the value of "rtd_search" 865 | const url_params = new URLSearchParams(document.location.search); 866 | const query = url_params.get(RTD_SEARCH_PARAMETER); 867 | if (query !== null) { 868 | showSearchModal(query); 869 | search_outer_input.value = query; 870 | 871 | let event = document.createEvent("Event"); 872 | event.initEvent("input", true, true); 873 | search_outer_input.dispatchEvent(event); 874 | } 875 | } else { 876 | console.log( 877 | "[INFO] Docs are not being served on Read the Docs, readthedocs-sphinx-search will not work." 878 | ); 879 | } 880 | }); 881 | -------------------------------------------------------------------------------- /sphinx_search/static/js/rtd_sphinx_search.min.js: -------------------------------------------------------------------------------- 1 | "use strict";function _createForOfIteratorHelper(e,t){var r;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(r=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var n=0,t=function(){};return{s:t,n:function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}},e:function(e){throw e},f:t}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,o=!0,l=!1;return{s:function(){r=e[Symbol.iterator]()},n:function(){var e=r.next();return o=e.done,e},e:function(e){l=!0,a=e},f:function(){try{o||null==r.return||r.return()}finally{if(l)throw a}}}}function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);return"Map"===(r="Object"===r&&e.constructor?e.constructor.name:r)||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=new Array(t);rSearching ....",a.appendChild(e);return debounce(function(){updateUrl(),updateSearchBar();var e=t+"?"+new URLSearchParams(r).toString();fetch(e,{method:"GET"}).then(function(e){if(!e.ok)throw new Error;return e.json()}).then(function(e){var t;0Hello world” extension" 14 | ] 15 | }, 16 | "blocks": [ 17 | { 18 | "type": "section", 19 | "id": "overview", 20 | "title": "Overview", 21 | "content": "We want the extension to add the following to Sphinx:\nA helloworld directive, that will simply output the text “hello world”.", 22 | "highlights": { 23 | "title": [], 24 | "content": ["world”."] 25 | } 26 | }, 27 | { 28 | "type": "section", 29 | "id": "developing-a-hello-world-extension", 30 | "title": "Developing a “Hello world” extension", 31 | "content": "Only basic information is provided in this tutorial. For more information, refer to the other tutorials that go into more details.\n\nWarning\nFor this extension, you will need some basic understanding of docutils and Python.\n", 32 | "highlights": { 33 | "title": [ 34 | "Developing a “Hello world” extension" 35 | ], 36 | "content": [] 37 | } 38 | }, 39 | { 40 | "type": "section", 41 | "id": "using-the-extension", 42 | "title": "Using the extension", 43 | "content": "The extension has to be declared in your conf.py file to make Sphinx aware of it. There are two steps necessary here:\nAdd the _ext directory to the Python path using sys.path.append. This should be placed at the top of the file.\nUpdate or create the extensions list and add the extension file name to the list\nFor example:\nimport os import sys sys.path.append(os.path.abspath(\"./_ext\")) extensions = ['helloworld']\nTip\nWe’re not distributing this extension as a Python package, we need to modify the Python path so Sphinx can find our extension. This is why we need the call to sys.path.append.\nYou can now use the extension in a file. For example:\nSome intro text here... .. helloworld:: Some more text here...\nThe sample above would generate:\nSome intro text here... Hello World! Some more text here...", 44 | "highlights": { 45 | "title": [], 46 | "content": [ 47 | "Hello World! Some more text here..." 48 | ] 49 | } 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /tests/example/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | master_doc = 'index' 4 | extensions = [ 5 | 'sphinx_search.extension', 6 | ] 7 | -------------------------------------------------------------------------------- /tests/example/index.rst: -------------------------------------------------------------------------------- 1 | readthedocs-sphinx-search 2 | ========================= 3 | 4 | Features 5 | -------- 6 | 7 | * When the page finished loading, there should be additional
nodes 8 | in the DOM. 9 | * Search modal should open (with the backdrop) when user clicks on the search input field 10 | which was already present. 11 | * Search modal should close if the user clicks on "cross icon" at the top-right corner 12 | of the modal, user presses "Escape" button or user clicks on the backdrop. 13 | * User should be able to iterate through the results by ArrowUp/ArrowDown buttons. 14 | There should be smooth scrolling. 15 | * When the search modal appears, there should be no previous results or previous query. 16 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | """Test working of extension.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from sphinx_search.extension import ASSETS_FILES 8 | from tests import TEST_DOCS_SRC 9 | 10 | 11 | @pytest.mark.sphinx( 12 | srcdir=TEST_DOCS_SRC, 13 | confoverrides={ 14 | 'rtd_sphinx_search_file_type': 'minified' 15 | } 16 | ) 17 | def test_minified_static_files_injected_in_html(selenium, app, status, warning): 18 | """Test if the static files are correctly injected in the html.""" 19 | app.build() 20 | path = app.outdir / 'index.html' 21 | 22 | selenium.get(f'file://{path}') 23 | page_source = selenium.page_source 24 | 25 | file_type = app.config.rtd_sphinx_search_file_type 26 | assert file_type == 'minified' 27 | 28 | for file in ASSETS_FILES[file_type]: 29 | file_path = Path(app.outdir) / '_static' / file 30 | if file_path.name.endswith('_t'): 31 | file_path = Path(str(file_path)[:-2]) 32 | assert ( 33 | file_path.exists() 34 | ), f'{file_path} should be present in the _build folder' 35 | 36 | assert ( 37 | page_source.count(file_path.name) == 1 38 | ), f'{file_path} should be present in the page source' 39 | 40 | 41 | @pytest.mark.sphinx( 42 | srcdir=TEST_DOCS_SRC, 43 | confoverrides={ 44 | 'rtd_sphinx_search_file_type': 'un-minified' 45 | } 46 | ) 47 | def test_un_minified_static_files_injected_in_html(selenium, app, status, warning): 48 | """Test if the static files are correctly injected in the html.""" 49 | app.build() 50 | path = app.outdir / 'index.html' 51 | 52 | selenium.get(f'file://{path}') 53 | page_source = selenium.page_source 54 | 55 | file_type = app.config.rtd_sphinx_search_file_type 56 | assert file_type == 'un-minified' 57 | 58 | for file in ASSETS_FILES[file_type]: 59 | file_path = Path(app.outdir) / '_static' / file 60 | if file_path.name.endswith('_t'): 61 | file_path = Path(str(file_path)[:-2]) 62 | assert file_path.exists(), f'{file_path} should be present in the _build folder' 63 | 64 | assert ( 65 | page_source.count(file_path.name) == 1 66 | ), f'{file_path} should be present in the page source' 67 | -------------------------------------------------------------------------------- /tests/test_ui.py: -------------------------------------------------------------------------------- 1 | """UI tests.""" 2 | 3 | import json 4 | import textwrap 5 | import time 6 | from urllib import parse 7 | 8 | import pytest 9 | from selenium.webdriver import ActionChains 10 | from selenium.webdriver.common.by import By 11 | from selenium.webdriver.common.keys import Keys 12 | from selenium.webdriver.support import expected_conditions as EC 13 | from selenium.webdriver.support.ui import WebDriverWait 14 | 15 | from tests import TEST_DOCS_SRC 16 | from tests.utils import ( 17 | InjectJsManager, 18 | get_ajax_overwrite_func, 19 | set_viewport_size 20 | ) 21 | 22 | READTHEDOCS_DATA = { 23 | 'project': 'docs', 24 | 'version': 'latest', 25 | 'language': 'en', 26 | } 27 | 28 | # This will be inserted in the html page 29 | # to support the working of extension 30 | # in test cases. 31 | SCRIPT_TAG = ''.format( 32 | json.dumps(READTHEDOCS_DATA) 33 | ) 34 | 35 | 36 | def open_search_modal(driver): 37 | """Open search modal and checks if its open correctly.""" 38 | sphinx_search_input = driver.find_element( 39 | By.CSS_SELECTOR, 40 | 'div[role="search"] input' 41 | ) 42 | search_outer_wrapper = driver.find_element( 43 | By.CLASS_NAME, 44 | 'search__outer__wrapper' 45 | ) 46 | 47 | assert ( 48 | search_outer_wrapper.is_displayed() is False 49 | ), 'search modal should not be displayed when the page loads' 50 | 51 | sphinx_search_input.click() 52 | 53 | # wait for the fadeIn animation to get completed 54 | time.sleep(0.5) 55 | 56 | assert ( 57 | search_outer_wrapper.is_displayed() is True 58 | ), 'search modal should open after clicking on input field' 59 | 60 | 61 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 62 | def test_index_page_opening(selenium, app, status, warning): 63 | """Test if `index.html` is generated/opening correctly.""" 64 | app.build() 65 | path = app.outdir / 'index.html' 66 | selenium.get(f'file://{path}') 67 | assert ( 68 | 'readthedocs-sphinx-search' in selenium.title 69 | ), 'title of the documentation must contains "readthedocs-sphinx-search"' 70 | 71 | 72 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 73 | def test_appending_of_initial_html(selenium, app, status, warning): 74 | """Test if initial html is correctly appended to the body after the DOM is loaded.""" 75 | app.build() 76 | path = app.outdir / 'index.html' 77 | 78 | with InjectJsManager(path, SCRIPT_TAG) as _: 79 | selenium.get(f'file://{path}') 80 | 81 | search_outer_wrapper = selenium.find_elements( 82 | By.CLASS_NAME, 83 | 'search__outer__wrapper' 84 | ) 85 | 86 | # there should be only one of these element 87 | assert ( 88 | len(search_outer_wrapper) == 1 89 | ), 'search outer wrapper is not injected correctly in the dom' 90 | 91 | search_outer_wrapper = search_outer_wrapper[0] 92 | 93 | assert ( 94 | search_outer_wrapper.is_displayed() is False 95 | ), 'search outer wrapper shoud not be displayed when the page loads' 96 | 97 | initial_html = textwrap.dedent( 98 | """ 99 |
100 |
101 | 102 | 103 | 104 | 105 |
106 | 107 |
108 |
109 |
    110 |
111 |
112 |
113 |
114 | Search by Read the Docs & readthedocs-sphinx-search 115 |
116 | """ 117 | ) 118 | search_outer_wrapper_html = search_outer_wrapper.get_attribute('innerHTML') 119 | initial_html = [ele.strip() for ele in initial_html.strip().split('\n') if ele] 120 | for line in initial_html: 121 | if line: 122 | assert ( 123 | line in search_outer_wrapper_html 124 | ), f'{line} -- must be present in page source' 125 | 126 | 127 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 128 | def test_opening_of_search_modal(selenium, app, status, warning): 129 | """Test if the search modal is opening correctly.""" 130 | app.build() 131 | path = app.outdir / 'index.html' 132 | 133 | with InjectJsManager(path, SCRIPT_TAG) as _: 134 | selenium.get(f'file://{path}') 135 | open_search_modal(selenium) 136 | 137 | 138 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 139 | def test_open_search_modal_when_forward_slash_button_is_pressed(selenium, app, status, warning): 140 | """Test if the search modal is opening correctly.""" 141 | app.build() 142 | path = app.outdir / 'index.html' 143 | 144 | with InjectJsManager(path, SCRIPT_TAG) as _: 145 | selenium.get(f'file://{path}') 146 | 147 | search_outer_wrapper = selenium.find_element( 148 | By.CLASS_NAME, 149 | 'search__outer__wrapper' 150 | ) 151 | 152 | assert ( 153 | search_outer_wrapper.is_displayed() == False 154 | ), 'search__outer__wrapper should not be displayed on page load' 155 | 156 | body = selenium.find_element(By.TAG_NAME, 'body') 157 | body.send_keys('/') 158 | 159 | assert ( 160 | search_outer_wrapper.is_displayed() == True 161 | ), 'search__outer__wrapper should be displayed when forward slash button is pressed' 162 | 163 | 164 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 165 | def test_focussing_of_input_field(selenium, app, status, warning): 166 | """Test if the input field in search modal is focussed after opening the modal.""" 167 | app.build() 168 | path = app.outdir / 'index.html' 169 | 170 | with InjectJsManager(path, SCRIPT_TAG) as _: 171 | selenium.get(f'file://{path}') 172 | open_search_modal(selenium) 173 | 174 | sphinx_search_input = selenium.find_element( 175 | By.CSS_SELECTOR, 176 | 'div[role="search"] input' 177 | ) 178 | search_outer_input = selenium.find_element( 179 | By.CLASS_NAME, 180 | 'search__outer__input' 181 | ) 182 | 183 | assert ( 184 | sphinx_search_input != selenium.switch_to.active_element 185 | ), 'active element should be input field of the modal and not the default search field' 186 | 187 | assert ( 188 | search_outer_input == selenium.switch_to.active_element 189 | ), 'active element should be search input field of the modal' 190 | 191 | 192 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 193 | def test_closing_the_modal_by_clicking_on_backdrop(selenium, app, status, warning): 194 | """Test if the search modal is closed when user clicks on backdrop.""" 195 | app.build() 196 | path = app.outdir / 'index.html' 197 | 198 | with InjectJsManager(path, SCRIPT_TAG) as _: 199 | selenium.get(f'file://{path}') 200 | open_search_modal(selenium) 201 | 202 | search_outer_wrapper = selenium.find_element( 203 | By.CLASS_NAME, 204 | 'search__outer__wrapper' 205 | ) 206 | actions = ActionChains(selenium) 207 | search_outer = selenium.find_element(By.CLASS_NAME, 'search__outer') 208 | # Negative offsets to move the mouse away from the search modal 209 | actions.move_to_element_with_offset(search_outer, -10, -10).click().perform() 210 | WebDriverWait(selenium, 10).until( 211 | EC.invisibility_of_element(search_outer_wrapper) 212 | ) 213 | 214 | assert ( 215 | search_outer_wrapper.is_displayed() is False 216 | ), 'search modal should disappear after clicking on backdrop' 217 | 218 | 219 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 220 | def test_closing_the_modal_by_escape_button(selenium, app, status, warning): 221 | """Test if the search modal is closed when escape button is pressed.""" 222 | app.build() 223 | path = app.outdir / 'index.html' 224 | 225 | with InjectJsManager(path, SCRIPT_TAG) as _: 226 | selenium.get(f'file://{path}') 227 | open_search_modal(selenium) 228 | 229 | search_outer_wrapper = selenium.find_element( 230 | By.CLASS_NAME, 231 | 'search__outer__wrapper' 232 | ) 233 | 234 | # active element is the search input on the modal 235 | selenium.switch_to.active_element.send_keys(Keys.ESCAPE) 236 | WebDriverWait(selenium, 10).until( 237 | EC.invisibility_of_element(search_outer_wrapper) 238 | ) 239 | 240 | assert ( 241 | search_outer_wrapper.is_displayed() is False 242 | ), 'search modal should disappear after pressing Escape button' 243 | 244 | 245 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 246 | def test_closing_modal_by_clicking_cross_icon(selenium, app, status, warning): 247 | """Test if the search modal is closed when cross icon is clicked.""" 248 | app.build() 249 | path = app.outdir / 'index.html' 250 | 251 | with InjectJsManager(path, SCRIPT_TAG) as _: 252 | selenium.get(f'file://{path}') 253 | open_search_modal(selenium) 254 | 255 | search_outer_wrapper = selenium.find_element( 256 | By.CLASS_NAME, 257 | 'search__outer__wrapper' 258 | ) 259 | 260 | cross_icon = selenium.find_element( 261 | By.CLASS_NAME, 262 | 'search__cross' 263 | ) 264 | cross_icon.click() 265 | WebDriverWait(selenium, 10).until( 266 | EC.invisibility_of_element(search_outer_wrapper) 267 | ) 268 | 269 | assert ( 270 | search_outer_wrapper.is_displayed() is False 271 | ), 'search modal should disappear after clicking on cross icon' 272 | 273 | 274 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 275 | def test_no_results_msg(selenium, app, status, warning): 276 | """Test if the user is notified that there are no search results.""" 277 | app.build() 278 | path = app.outdir / 'index.html' 279 | 280 | # to test this, we need to override the $.ajax function 281 | ajax_func = get_ajax_overwrite_func('zero_results') 282 | injected_script = SCRIPT_TAG + ajax_func 283 | 284 | with InjectJsManager(path, injected_script) as _: 285 | selenium.get(f'file://{path}') 286 | open_search_modal(selenium) 287 | 288 | search_outer_input = selenium.find_element( 289 | By.CLASS_NAME, 290 | 'search__outer__input' 291 | ) 292 | search_outer_input.send_keys('no results for this') 293 | WebDriverWait(selenium, 10).until( 294 | EC.text_to_be_present_in_element( 295 | (By.CLASS_NAME, 'search__result__box'), 296 | 'No results found' 297 | ) 298 | ) 299 | search_result_box = selenium.find_element( 300 | By.CLASS_NAME, 301 | 'search__result__box' 302 | ) 303 | 304 | assert ( 305 | search_result_box.text == 'No results found' 306 | ), 'user should be notified that there are no results' 307 | 308 | assert ( 309 | len(search_result_box.find_elements(By.CSS_SELECTOR, '*')) == 0 310 | ), 'search result box should not have any child elements because there are no results' 311 | 312 | 313 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 314 | def test_error_msg(selenium, app, status, warning): 315 | """Test if the user is notified that there is an error while performing search""" 316 | app.build() 317 | path = app.outdir / 'index.html' 318 | 319 | # to test this, we need to override the $.ajax function 320 | ajax_func = get_ajax_overwrite_func('error') 321 | injected_script = SCRIPT_TAG + ajax_func 322 | 323 | with InjectJsManager(path, injected_script) as _: 324 | selenium.get(f'file://{path}') 325 | open_search_modal(selenium) 326 | 327 | search_outer_input = selenium.find_element( 328 | By.CLASS_NAME, 329 | 'search__outer__input' 330 | ) 331 | search_outer_input.send_keys('this will result in error') 332 | WebDriverWait(selenium, 10).until( 333 | EC.text_to_be_present_in_element( 334 | (By.CLASS_NAME, 'search__result__box'), 335 | 'There was an error. Please try again.' 336 | ) 337 | ) 338 | search_result_box = selenium.find_element( 339 | By.CLASS_NAME, 340 | 'search__result__box' 341 | ) 342 | 343 | assert ( 344 | search_result_box.text == 'There was an error. Please try again.' 345 | ), 'user should be notified that there is an error' 346 | 347 | assert ( 348 | len(search_result_box.find_elements(By.CSS_SELECTOR, '*')) == 0 349 | ), 'search result box should not have any child elements because there are no results' 350 | 351 | 352 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 353 | def test_searching_msg(selenium, app, status, warning): 354 | """Test if the user is notified that search is in progress.""" 355 | app.build() 356 | path = app.outdir / 'index.html' 357 | 358 | # to test this, we need to override the $.ajax function 359 | ajax_func = get_ajax_overwrite_func('timeout__zero_results') 360 | injected_script = SCRIPT_TAG + ajax_func 361 | 362 | with InjectJsManager(path, injected_script) as _: 363 | selenium.get(f'file://{path}') 364 | open_search_modal(selenium) 365 | 366 | search_outer_input = selenium.find_element( 367 | By.CLASS_NAME, 368 | 'search__outer__input' 369 | ) 370 | search_outer_input.send_keys( 371 | 'searching the results' 372 | ) 373 | search_result_box = selenium.find_element( 374 | By.CLASS_NAME, 375 | 'search__result__box' 376 | ) 377 | 378 | assert ( 379 | search_result_box.text == 'Searching ....' 380 | ), 'user should be notified that search is in progress' 381 | 382 | WebDriverWait(selenium, 10).until( 383 | EC.text_to_be_present_in_element( 384 | (By.CLASS_NAME, 'search__result__box'), 385 | 'No results found' 386 | ) 387 | ) 388 | 389 | # fetching it again from the DOM to update its status 390 | search_result_box = selenium.find_element( 391 | By.CLASS_NAME, 392 | 'search__result__box' 393 | ) 394 | 395 | assert ( 396 | len(search_result_box.find_elements(By.CSS_SELECTOR, '*')) == 0 397 | ), 'search result box should not have any child elements because there are no results' 398 | assert ( 399 | search_result_box.text == 'No results found' 400 | ), 'user should be notified that there are no results' 401 | 402 | 403 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 404 | def test_results_displayed_to_user(selenium, app, status, warning): 405 | """Test if the results are displayed correctly to the user.""" 406 | app.build() 407 | path = app.outdir / 'index.html' 408 | 409 | # to test this, we need to override the $.ajax function 410 | ajax_func = get_ajax_overwrite_func('dummy_results') 411 | injected_script = SCRIPT_TAG + ajax_func 412 | 413 | with InjectJsManager(path, injected_script) as _: 414 | selenium.get(f'file://{path}') 415 | open_search_modal(selenium) 416 | 417 | search_outer_input = selenium.find_element( 418 | By.CLASS_NAME, 419 | 'search__outer__input' 420 | ) 421 | search_outer_input.send_keys('sphinx') 422 | search_result_box = selenium.find_element( 423 | By.CLASS_NAME, 424 | 'search__result__box' 425 | ) 426 | WebDriverWait(selenium, 10).until( 427 | EC.visibility_of_all_elements_located( 428 | (By.CLASS_NAME, 'search__result__single') 429 | ) 430 | ) 431 | 432 | # fetching search_result_box again to update its content 433 | search_result_box = selenium.find_element( 434 | By.CLASS_NAME, 435 | 'search__result__box' 436 | ) 437 | 438 | assert ( 439 | len( 440 | search_result_box.find_elements( 441 | By.CLASS_NAME, 442 | 'search__result__single' 443 | ) 444 | ) 445 | == 1 446 | ), 'search result box should have results from only 1 page (as per the dummy_results.json)' 447 | 448 | assert ( 449 | len( 450 | search_result_box.find_elements( 451 | By.CLASS_NAME, 452 | 'outer_div_page_results' 453 | ) 454 | ) == 3 455 | ), 'total 3 results should be shown to the user (as per the dummy_results.json)' 456 | 457 | 458 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 459 | def test_navigate_results_with_arrow_up_and_down(selenium, app, status, warning): 460 | """Test if user is able to navigate through search results via keyboard.""" 461 | app.build() 462 | path = app.outdir / 'index.html' 463 | 464 | # to test this, we need to override the $.ajax function 465 | ajax_func = get_ajax_overwrite_func('dummy_results') 466 | injected_script = SCRIPT_TAG + ajax_func 467 | 468 | with InjectJsManager(path, injected_script) as _: 469 | selenium.get(f'file://{path}') 470 | open_search_modal(selenium) 471 | 472 | search_outer_input = selenium.find_element( 473 | By.CLASS_NAME, 474 | 'search__outer__input' 475 | ) 476 | search_outer_input.send_keys('sphinx') 477 | search_result_box = selenium.find_element( 478 | By.CLASS_NAME, 479 | 'search__result__box' 480 | ) 481 | WebDriverWait(selenium, 10).until( 482 | EC.visibility_of_all_elements_located( 483 | (By.CLASS_NAME, 'search__result__single') 484 | ) 485 | ) 486 | 487 | # fetching search_result_box again to update its content 488 | search_result_box = selenium.find_element( 489 | By.CLASS_NAME, 490 | 'search__result__box' 491 | ) 492 | results = selenium.find_elements( 493 | By.CLASS_NAME, 494 | 'outer_div_page_results' 495 | ) 496 | search_outer_input.send_keys(Keys.DOWN) 497 | 498 | assert results[0] == selenium.find_element( 499 | By.CSS_SELECTOR, 500 | '.outer_div_page_results.active' 501 | ), 'first result should be active' 502 | 503 | search_outer_input.send_keys(Keys.DOWN) 504 | assert results[1] == selenium.find_element( 505 | By.CSS_SELECTOR, 506 | '.outer_div_page_results.active' 507 | ), 'second result should be active' 508 | 509 | search_outer_input.send_keys(Keys.UP) 510 | search_outer_input.send_keys(Keys.UP) 511 | assert results[-1] == selenium.find_element( 512 | By.CSS_SELECTOR, 513 | '.outer_div_page_results.active' 514 | ), 'last result should be active' 515 | 516 | search_outer_input.send_keys(Keys.DOWN) 517 | assert results[0] == selenium.find_element( 518 | By.CSS_SELECTOR, 519 | '.outer_div_page_results.active' 520 | ), 'first result should be active' 521 | 522 | 523 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 524 | def test_enter_button_on_input_field_when_no_result_active(selenium, app, status, warning): 525 | """ 526 | Test if pressing Enter on search field takes the user to search page. 527 | 528 | User will be redirected to the search page only if no result is active. 529 | A search result becomes active if the user reaches to it via ArrowUp and 530 | ArrowDown buttons. 531 | """ 532 | app.build() 533 | path = app.outdir / 'index.html' 534 | 535 | # to test this, we need to override the $.ajax function 536 | ajax_func = get_ajax_overwrite_func('error') 537 | injected_script = SCRIPT_TAG + ajax_func 538 | 539 | with InjectJsManager(path, injected_script) as _: 540 | selenium.get(f'file://{path}') 541 | open_search_modal(selenium) 542 | 543 | search_outer_input = selenium.find_element( 544 | By.CLASS_NAME, 545 | 'search__outer__input' 546 | ) 547 | search_outer_input.send_keys('i am searching') 548 | search_outer_input.send_keys(Keys.ENTER) 549 | WebDriverWait(selenium, 10).until( 550 | EC.url_contains(app.outdir / 'search.html') 551 | ) 552 | 553 | # enter button should redirect the user to search page 554 | assert ( 555 | 'search.html' in selenium.current_url 556 | ), 'search.html must be in the url of the page' 557 | 558 | assert ( 559 | 'Search' in selenium.title 560 | ), '"Search" must be in the title of the page' 561 | 562 | 563 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 564 | def test_position_search_modal(selenium, app, status, warning): 565 | """Test if the search modal is in the middle of the page.""" 566 | app.build() 567 | path = app.outdir / 'index.html' 568 | 569 | with InjectJsManager(path, SCRIPT_TAG) as _: 570 | selenium.get(f'file://{path}') 571 | open_search_modal(selenium) 572 | 573 | search_outer_wrapper = selenium.find_element( 574 | By.CLASS_NAME, 575 | 'search__outer__wrapper' 576 | ) 577 | search_outer = selenium.find_element( 578 | By.CLASS_NAME, 579 | 'search__outer' 580 | ) 581 | 582 | window_sizes = [ 583 | # mobile-sized viewports 584 | (414, 896), 585 | (375, 812), 586 | (414, 896), 587 | (375, 812), 588 | 589 | # desktop-sized viewports 590 | (800, 600), 591 | (1280, 720), 592 | (1366, 768), 593 | (1920, 1080), 594 | ] 595 | 596 | for width, height in window_sizes: 597 | set_viewport_size(driver=selenium, width=width, height=height) 598 | modal_location = search_outer.location 599 | modal_size = search_outer.size 600 | 601 | inner_width, inner_height = selenium.execute_script( 602 | "return [window.innerWidth, window.innerHeight];" 603 | ) 604 | 605 | # checking for horizontal position 606 | right = inner_width - (modal_size['width'] + modal_location['x']) 607 | left = inner_width - (modal_size['width'] + right) 608 | assert ( 609 | right == pytest.approx(left, 1) 610 | ), f'Vertical margins should be the same size for {width}x{height} ({left} / {right}).' 611 | 612 | # checking for vertical position 613 | bottom = inner_height - (modal_size['height'] + modal_location['y']) 614 | top = inner_height - (modal_size['height'] + bottom) 615 | assert ( 616 | top == pytest.approx(bottom, 1) 617 | ), f'Horizontal margins should be the same size for {width}x{height} ({top} / {bottom}).' 618 | 619 | 620 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 621 | def test_writing_query_adds_rtd_search_as_url_param(selenium, app, status, warning): 622 | """Test if the `rtd_search` query param is added to the url when user is searching.""" 623 | app.build() 624 | path = app.outdir / 'index.html' 625 | 626 | # to test this, we need to override the $.ajax function 627 | ajax_func = get_ajax_overwrite_func('error') 628 | injected_script = SCRIPT_TAG + ajax_func 629 | 630 | with InjectJsManager(path, injected_script) as _: 631 | selenium.get(f'file://{path}') 632 | open_search_modal(selenium) 633 | query = 'searching' 634 | query_len = len(query) 635 | 636 | assert ( 637 | 'rtd_search=' not in parse.unquote(selenium.current_url) 638 | ), 'rtd_search param must not be present in the url when page loads' 639 | 640 | search_outer_input = selenium.find_element( 641 | By.CLASS_NAME, 642 | 'search__outer__input' 643 | ) 644 | search_outer_input.send_keys(query) 645 | query_param = f'rtd_search={query}' 646 | 647 | # Wait till it updates the URL 648 | time.sleep(0.5) 649 | 650 | assert ( 651 | query_param in parse.unquote(selenium.current_url) 652 | ), 'query param must be present in the url' 653 | 654 | # deleting query from input field 655 | for i in range(query_len): 656 | search_outer_input.send_keys(Keys.BACK_SPACE) 657 | time.sleep(0.5) 658 | 659 | if i != query_len -1: 660 | 661 | current_query = query[:query_len - i - 1] 662 | current_url = parse.unquote(selenium.current_url) 663 | query_in_url = current_url[current_url.find('rtd_search'):] 664 | 665 | assert ( 666 | f'rtd_search={current_query}' == query_in_url 667 | ) 668 | 669 | assert ( 670 | 'rtd_search=' not in parse.unquote(selenium.current_url) 671 | ), 'rtd_search param must not be present in the url if query is empty' 672 | 673 | 674 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 675 | def test_modal_open_if_rtd_search_is_present(selenium, app, status, warning): 676 | """Test if search modal opens if `rtd_search` query param is present in the URL.""" 677 | app.build() 678 | path = app.outdir / 'index.html' 679 | 680 | # to test this, we need to override the $.ajax function 681 | ajax_func = get_ajax_overwrite_func('error') 682 | injected_script = SCRIPT_TAG + ajax_func 683 | 684 | with InjectJsManager(path, injected_script) as _: 685 | selenium.get(f'file://{path}?rtd_search=i am searching') 686 | time.sleep(3) # give time to open modal and start searching 687 | 688 | search_outer_wrapper = selenium.find_element( 689 | By.CLASS_NAME, 690 | 'search__outer__wrapper' 691 | ) 692 | search_result_box = selenium.find_element( 693 | By.CLASS_NAME, 694 | 'search__result__box' 695 | ) 696 | 697 | assert ( 698 | search_outer_wrapper.is_displayed() is True 699 | ), 'search modal should displayed when the page loads' 700 | assert ( 701 | search_result_box.text == 'There was an error. Please try again.' 702 | ), 'user should be notified that there is error while searching' 703 | assert ( 704 | len(search_result_box.find_elements(By.CSS_SELECTOR, '*')) == 0 705 | ), 'search result box should not have any child elements because there are no results' 706 | 707 | 708 | @pytest.mark.sphinx(srcdir=TEST_DOCS_SRC) 709 | def test_rtd_search_remove_from_url_when_modal_closed(selenium, app, status, warning): 710 | """Test if `rtd_search` query param is removed when the modal is closed.""" 711 | app.build() 712 | path = app.outdir / 'index.html' 713 | 714 | # to test this, we need to override the $.ajax function 715 | ajax_func = get_ajax_overwrite_func('error') 716 | injected_script = SCRIPT_TAG + ajax_func 717 | 718 | with InjectJsManager(path, injected_script) as _: 719 | selenium.get(f'file://{path}?rtd_search=i am searching') 720 | time.sleep(3) # give time to open modal and start searching 721 | 722 | # closing modal 723 | search_outer = selenium.find_element( 724 | By.CLASS_NAME, 725 | 'search__outer', 726 | ) 727 | search_outer_wrapper = selenium.find_element( 728 | By.CLASS_NAME, 729 | 'search__outer__wrapper' 730 | ) 731 | actions = ActionChains(selenium) 732 | actions.move_to_element_with_offset( 733 | search_outer, -10, -10 # -ve offsets to move the mouse away from the search modal 734 | ) 735 | actions.click() 736 | actions.perform() 737 | WebDriverWait(selenium, 10).until( 738 | EC.invisibility_of_element(search_outer_wrapper) 739 | ) 740 | 741 | assert ( 742 | search_outer_wrapper.is_displayed() is False 743 | ), 'search modal should disappear after clicking on backdrop' 744 | assert ( 745 | 'rtd_search=' not in parse.unquote(selenium.current_url) 746 | ), 'rtd_search url param should not be present in the url when the modal is closed.' 747 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """Utils for testing.""" 2 | 3 | import os 4 | 5 | DUMMY_RESULTS = os.path.join( 6 | os.path.abspath(os.path.dirname(__file__)), 7 | 'dummy_results.json' 8 | ) 9 | 10 | 11 | class InjectJsManager: 12 | """ 13 | Context manager for injected script tag. 14 | 15 | This will insert ``html_tag`` at the bottom of the tag 16 | in the file which is passed when entered. 17 | And it will restore its original content when exiting. 18 | """ 19 | def __init__(self, file, html_tag): 20 | self._file = file 21 | self._script = html_tag 22 | 23 | def __enter__(self): 24 | if os.path.exists(self._file): 25 | with open(self._file, 'r+') as f: 26 | self.old_content = f.read() 27 | new_content = self.old_content.replace( 28 | '', 29 | self._script + '', 30 | 1, 31 | ) 32 | f.seek(0) 33 | f.write(new_content) 34 | 35 | return self._file 36 | 37 | def __exit__(self, exc_type, exc_val, exc_tb): 38 | if os.path.exists(self._file): 39 | with open(self._file, 'w') as f: 40 | f.write(self.old_content) 41 | 42 | 43 | def set_viewport_size(driver, width, height): 44 | """Sets the viewport size to the given width and height.""" 45 | window_size = driver.execute_script( 46 | """ 47 | return [ 48 | window.outerWidth - window.innerWidth + arguments[0], 49 | window.outerHeight - window.innerHeight + arguments[1] 50 | ]; 51 | """, 52 | width, 53 | height 54 | ) 55 | driver.set_window_size(*window_size) 56 | 57 | 58 | def get_ajax_overwrite_func(type_, **kwargs): 59 | possible_types = [ 60 | 61 | # return ajax func which results in zero results 62 | 'zero_results', 63 | 64 | # return ajax func which results in error while searching 65 | 'error', 66 | 67 | # return ajax func with a setTimeout of 2000ms (default) and 68 | # results in zero results. 69 | # A possible `timeout` argument can be passed to change the 70 | # waiting time of setTimeout 71 | 'timeout__zero_results', 72 | 73 | # return ajax func with dummy results 74 | 'dummy_results', 75 | ] 76 | 77 | # check if current type_ is passed 78 | assert ( 79 | type_ in possible_types 80 | ), 'wrong type is specified' 81 | 82 | if type_ == 'zero_results': 83 | ajax_func = ''' 84 | 98 | ''' 99 | 100 | elif type_ == 'error': 101 | ajax_func = ''' 102 | 113 | ''' 114 | 115 | elif type_ == 'timeout__zero_results': 116 | timeout = kwargs.get('timeout') or 2000 117 | 118 | # setTimeout is used here to give a real feel of the API call 119 | ajax_func = f''' 120 | 136 | ''' 137 | 138 | elif type_ == 'dummy_results': 139 | with open(DUMMY_RESULTS, 'r') as f: 140 | dummy_res = f.read() 141 | 142 | ajax_func = f''' 143 | 157 | ''' 158 | 159 | return ajax_func 160 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38,39}-sphinx{1,2,3,4,latest} 4 | py310-sphinx{4,latest} 5 | docs 6 | skipsdist = True 7 | 8 | [testenv] 9 | description = run the whole test suite 10 | deps = 11 | . 12 | pytest-selenium==3.0.0 13 | sphinx1: Sphinx<2.0 14 | sphinx2: Sphinx<3.0 15 | sphinx3: Sphinx<4.0 16 | sphinx4: Sphinx<5.0 17 | sphinx5: Sphinx<6.0 18 | sphinx6: Sphinx<7.0 19 | {sphinx1,sphinx2,sphinx3}: docutils<0.18 20 | {sphinx1,sphinx2,sphinx3}: jinja2<3.1 21 | sphinxlatest: Sphinx 22 | commands = pytest {posargs} 23 | 24 | [testenv:docs] 25 | description = build readthedocs-sphinx-search docs 26 | deps = 27 | -r {toxinidir}/docs/requirements.txt 28 | changedir = {toxinidir}/docs 29 | commands = 30 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 31 | --------------------------------------------------------------------------------