diff --git a/.circleci/config.yml b/.circleci/config.yml index 9cd1425..a2b4b8a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: - browser-tools: circleci/browser-tools@1.1.1 + browser-tools: circleci/browser-tools@1.4.1 commands: run-tox: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f792ab5..5bcaae6 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,11 +1,9 @@ version: 2 build: - os: "ubuntu-20.04" + os: "ubuntu-22.04" tools: - # sphinx-js isn't compatible with python 3.10. - # https://github.com/mozilla/sphinx-js/issues/186 - python: "3.9" + python: "3" nodejs: "16" python: @@ -16,5 +14,3 @@ python: sphinx: configuration: docs/conf.py - -formats: [] diff --git a/docs/conf.py b/docs/conf.py index a4d0349..a486284 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,9 +11,10 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os +from pathlib import Path import sys -sys.path.insert(0, os.path.abspath('..')) -sys.path.append(os.path.abspath("./_ext")) +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent / "_ext")) ON_RTD = os.environ.get('READTHEDOCS', False) @@ -100,6 +101,8 @@ if not ON_RTD: html_js_files = ['rtd_dummy_data.js'] + os.environ['READTHEDOCS_PROJECT'] = 'readthedocs-sphinx-search' + os.environ['READTHEDOCS_VERSION'] = 'latest' # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/docs/configuration.rst b/docs/configuration.rst index fbe9981..93ef629 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -16,3 +16,47 @@ You can customize these configuration options in your ``conf.py`` file: Default: ``'minified'`` Type: ``string`` + +.. confval:: rtd_sphinx_search_default_filter + + Description: Default filter to be used when the user hasn't selected any other filters. + The filter will simply be appended to the current search query. + + Default: ``project:/`` + + Type: ``string`` + + Example: + + .. code-block:: python + + # https://docs.readthedocs.io/page/reference/environment-variables.html + project = os.environ["READTHEDOCS_PROJECT"] + version = os.environ["READTHEDOCS_VERSION"] + + # Include results from subprojects by default. + rtd_sphinx_search_default_filter = f"subprojects:{project}/{version}" + +.. confval:: rtd_sphinx_search_filters + + Description: Map of filters to show in the search bar. + The key is the name of the filter to show to the user, + and the value is the filter itself. + The filter will simply be appended to the current search query. + + Default: ``{}`` + + Type: ``dict`` + + Example: + + .. code-block:: python + + # https://docs.readthedocs.io/page/reference/environment-variables.html + project = os.environ["READTHEDOCS_PROJECT"] + version = os.environ["READTHEDOCS_VERSION"] + + rtd_sphinx_search_filters = { + "Search this project": f"project:{project}/{version}", + "Search subprojects": f"subprojects:{project}/{version}", + } diff --git a/docs/development.rst b/docs/development.rst index d304df4..af0ce1e 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -41,7 +41,7 @@ Go to http://127.0.0.1:8000 and start searching! The extension works when is hosted on Read the Docs, but to make it work locally a custom ``READTHEDOCS_DATA`` js variable is injected automatically - to send the search requests to https://readthedocs.org/api/v2/search/. + to send the search requests to https://readthedocs.org/api/v3/search/. Releasing --------- diff --git a/package-lock.json b/package-lock.json index 0a4f7da..8e80c77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "readthedocs-sphinx-search", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/sphinx_search/extension.py b/sphinx_search/extension.py index 28c24ea..41e96a1 100644 --- a/sphinx_search/extension.py +++ b/sphinx_search/extension.py @@ -1,17 +1,19 @@ import os - from sphinx_search import __version__ from sphinx.errors import ExtensionError +from pathlib import Path from sphinx.util.fileutil import copy_asset ASSETS_FILES = { 'minified': [ - os.path.join('js', 'rtd_sphinx_search.min.js'), - os.path.join('css', 'rtd_sphinx_search.min.css'), + Path("js/rtd_search_config.js_t"), + Path("js/rtd_sphinx_search.min.js"), + Path("css/rtd_sphinx_search.min.css"), ], 'un-minified': [ - os.path.join('js', 'rtd_sphinx_search.js'), - os.path.join('css', 'rtd_sphinx_search.css'), + Path("js/rtd_search_config.js_t"), + Path("js/rtd_sphinx_search.js"), + Path("css/rtd_sphinx_search.css"), ] } @@ -24,16 +26,52 @@ def _get_static_files(config): return ASSETS_FILES[file_type] +def get_context(config): + """ + Get context for templates. + + This mainly returns the settings from the extension + that are needed in our JS code. + """ + default_filter = config.rtd_sphinx_search_default_filter + filters = config.rtd_sphinx_search_filters + # When converting to JSON, the order of the keys is not guaranteed. + # So we pass a list of tuples to preserve the order. + filters = [(name, filter) for name, filter in filters.items()] + return { + "rtd_search_config": { + "filters": filters, + "default_filter": default_filter, + } + } + + def copy_asset_files(app, exception): + """ + Copy assets files to the output directory. + + If the name of the file ends with ``_t``, it will be interpreted as a template. + """ if exception is None: # build succeeded + root = Path(__file__).parent for file in _get_static_files(app.config): - path = os.path.join(os.path.dirname(__file__), 'static', file) - copy_asset(path, os.path.join(app.outdir, '_static', file.split('.')[-1])) + source = root / 'static' / file + destination = Path(app.outdir) / '_static' / file.parent + context = None + # If the file ends with _t, it is a template file, + # so we provide a context to treat it as a template. + if file.name.endswith('_t'): + context = get_context(app.config) + copy_asset(str(source), str(destination), context=context) def inject_static_files(app): """Inject correct CSS and JS files based on the value of ``rtd_sphinx_search_file_type``.""" for file in _get_static_files(app.config): + file = str(file) + # Templates end with `_t`, Sphinx removes the _t when copying the file. + if file.endswith('_t'): + file = file[:-2] if file.endswith('.js'): app.add_js_file(file) elif file.endswith('.css'): @@ -41,8 +79,12 @@ def inject_static_files(app): def setup(app): + project = os.environ.get('READTHEDOCS_PROJECT', '') + version = os.environ.get('READTHEDOCS_VERSION', '') app.add_config_value('rtd_sphinx_search_file_type', 'minified', 'html') + app.add_config_value('rtd_sphinx_search_default_filter', f'project:{project}/{version}', 'html') + app.add_config_value('rtd_sphinx_search_filters', {}, 'html') app.connect('builder-inited', inject_static_files) app.connect('build-finished', copy_asset_files) diff --git a/sphinx_search/static/css/rtd_sphinx_search.css b/sphinx_search/static/css/rtd_sphinx_search.css index 33f0260..705112b 100644 --- a/sphinx_search/static/css/rtd_sphinx_search.css +++ b/sphinx_search/static/css/rtd_sphinx_search.css @@ -149,9 +149,12 @@ /* Search result */ +.search__result__box { + padding: 0px 10px; +} + .search__result__single { margin-top: 10px; - padding: 0px 10px; border-bottom: 1px solid #e6e6e6; } @@ -282,6 +285,34 @@ letter-spacing: 1px; } +.search__filters { + padding: 0px 10px; +} + +.search__filters ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; +} + +.search__filters li { + display: flex; + align-items: center; + margin-right: 15px; +} + +.search__filters label { + color: black; + font-size: 15px; + margin: auto; +} + +.search__filters .search__filters__title { + color: black; + font-size: 15px; +} + @media (max-width: 670px) { .rtd__search__credits { height: 50px; diff --git a/sphinx_search/static/css/rtd_sphinx_search.min.css b/sphinx_search/static/css/rtd_sphinx_search.min.css index 8b8252e..b52e190 100644 --- a/sphinx_search/static/css/rtd_sphinx_search.min.css +++ b/sphinx_search/static/css/rtd_sphinx_search.min.css @@ -1 +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__single{margin-top:10px;padding:0 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}@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%}} \ No newline at end of file +@-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%}} \ No newline at end of file diff --git a/sphinx_search/static/js/rtd_search_config.js_t b/sphinx_search/static/js/rtd_search_config.js_t new file mode 100644 index 0000000..7f00dd6 --- /dev/null +++ b/sphinx_search/static/js/rtd_search_config.js_t @@ -0,0 +1,2 @@ +{# Set the extension options as a JSON object, so it can be used from our JS code. #} +var RTD_SEARCH_CONFIG = {{ rtd_search_config | tojson }}; diff --git a/sphinx_search/static/js/rtd_sphinx_search.js b/sphinx_search/static/js/rtd_sphinx_search.js index c973610..65d68ff 100644 --- a/sphinx_search/static/js/rtd_sphinx_search.js +++ b/sphinx_search/static/js/rtd_sphinx_search.js @@ -271,12 +271,16 @@ const generateSingleResult = (resultData, projectName, id) => { let h2_element = createDomNode("h2", {class: "search__result__title"}); h2_element.innerHTML = page_title; - // If the result is not from the same project, - // then it's from a subproject. - if (projectName !== resultData.project) { + // Results can belong to different projects. + // If the result isn't from the current project, add a note about it. + const project_slug = resultData.project.slug + if (projectName !== project_slug) { let subtitle = createDomNode("small", {class: "rtd_ui_search_subtitle"}); - subtitle.innerText = `(from project ${resultData.project})`; + subtitle.innerText = ` (from project ${project_slug})`; h2_element.appendChild(subtitle); + // If the result isn't from the current project, + // then we create an absolute link to the page. + page_link = `${resultData.domain}${page_link}`; } h2_element.appendChild(createDomNode("br")) @@ -550,28 +554,78 @@ const fetchAndGenerateResults = (api_endpoint, parameters, projectName) => { * This html structure will serve as the boilerplate * to show our search results. * + * @param {Array} filters: filters to be applied to the search. + * {["Filter name", "Filter value"]} * @return {String} initial html structure */ -const generateAndReturnInitialHtml = () => { - let innerHTML = - '
\ -
\ - \ - \ - \ - \ -
\ - \ - \ -
\ - '; +const generateAndReturnInitialHtml = (filters) => { + let innerHTML = ` +
+
+ + + + +
+ +
+
+
    +
+
+
+ + `; let div = createDomNode("div", { class: "search__outer__wrapper search__backdrop", }); div.innerHTML = innerHTML; + + let filters_list = div.querySelector(".search__filters ul"); + const config = getConfig(); + // Add filters below the search box if present. + if (filters.length > 0) { + let li = createDomNode("li", {"class": "search__filters__title"}); + li.innerText = "Filters:"; + filters_list.appendChild(li); + } + // Each checkbox contains the index of the filter, + // so we can get the proper filter when selected. + for (let i = 0, len = filters.length; i < len; i++) { + const [name, filter] = filters[i]; + let li = createDomNode("li", {"class": "search__filter", "title": filter}); + let id = `rtd-search-filter-${i}`; + let checkbox = createDomNode("input", {"type": "checkbox", "id": id}); + let label = createDomNode("label", {"for": id}); + label.innerText = name; + checkbox.value = i; + li.appendChild(checkbox); + li.appendChild(label); + filters_list.appendChild(li); + + checkbox.addEventListener("click", event => { + // Uncheck all other filters when one is checked. + // We only support one filter at a time. + const checkboxes = document.querySelectorAll('.search__filters input'); + for (const checkbox of checkboxes) { + if (checkbox.checked && checkbox.value != event.target.value) { + checkbox.checked = false; + } + } + + // Trigger a search with the current selected filter. + let search_query = getSearchTerm(); + const filter = getCurrentFilter(config); + search_query = filter + " " + search_query; + const search_params = { + q: search_query, + }; + fetchAndGenerateResults(config.api_endpoint, search_params, config.project)(); + }); + } return div; }; @@ -609,7 +663,7 @@ const showSearchModal = custom_query => { search_outer_input.focus(); } }; - + if (window.jQuery) { $(".search__outer__wrapper").fadeIn(ANIMATION_TIME, show_modal); } else { @@ -650,15 +704,58 @@ const removeSearchModal = () => { } }; + +/** + * Get the config used by the search. + * + * This configiration is extracted from the global variable + * READTHEDOCS_DATA, which is defined by Read the Docs, + * and the global variable RTD_SEARCH_CONFIG, which is defined + * by the sphinx extension. + * + * @return {Object} config + */ +function getConfig() { + const project = READTHEDOCS_DATA.project; + const version = READTHEDOCS_DATA.version; + const api_host = READTHEDOCS_DATA.proxied_api_host || '/_'; + // This variable is defined in the `rtd_search_config.js` file + // that is loaded before this file, + // containing settings from the sphinx extension. + const search_config = RTD_SEARCH_CONFIG || {}; + const api_endpoint = api_host + "/api/v3/search/"; + return { + project: project, + version: version, + api_endpoint: api_endpoint, + filters: search_config.filters, + default_filter: search_config.default_filter, + } +} + + +/** + * Get the current selected filter. + * + * If no filter checkbox is selected, the default filter is returned. + * + * @param {Object} config + */ +function getCurrentFilter(config) { + const checkbox = document.querySelector('.search__filters input:checked'); + if (checkbox == null) { + return config.default_filter; + } + return config.filters[parseInt(checkbox.value)][1]; +} + window.addEventListener("DOMContentLoaded", () => { // only add event listeners if READTHEDOCS_DATA global // variable is found. if (window.hasOwnProperty("READTHEDOCS_DATA")) { - const project = READTHEDOCS_DATA.project; - const version = READTHEDOCS_DATA.version; - const api_host = READTHEDOCS_DATA.proxied_api_host || '/_'; + const config = getConfig(); - let initialHtml = generateAndReturnInitialHtml(); + let initialHtml = generateAndReturnInitialHtml(config.filters); document.body.appendChild(initialHtml); let search_outer_wrapper = document.querySelector( @@ -684,13 +781,12 @@ window.addEventListener("DOMContentLoaded", () => { // cancel previous ajax request. current_request.cancel(); } - const search_endpoint = api_host + "/api/v2/search/"; + const filter = getCurrentFilter(config); + search_query = filter + " " + search_query; const search_params = { q: search_query, - project: project, - version: version, }; - current_request = fetchAndGenerateResults(search_endpoint, search_params, project); + current_request = fetchAndGenerateResults(config.api_endpoint, search_params, config.project); current_request(); } else { // if the last request returns the results, diff --git a/sphinx_search/static/js/rtd_sphinx_search.min.js b/sphinx_search/static/js/rtd_sphinx_search.min.js index 2b38572..ee0b300 100644 --- a/sphinx_search/static/js/rtd_sphinx_search.min.js +++ b/sphinx_search/static/js/rtd_sphinx_search.min.js @@ -1 +1 @@ -"use strict";var MAX_SUGGESTIONS=50,MAX_SECTION_RESULTS=3,MAX_SUBSTRING_LIMIT=100,ANIMATION_TIME=200,FETCH_RESULTS_DELAY=250,CLEAR_RESULTS_DELAY=300,RTD_SEARCH_PARAMETER="rtd_search",debounce=function(r,n){function e(){var e=this,t=arguments;clearTimeout(o),o=setTimeout(function(){return r.apply(e,t)},n)}var o;return e.cancel=function(){clearTimeout(o),o=null},e},buildSection=function(e,t,r,n){var o=createDomNode("span",{class:"search__result__subheading"});o.innerHTML=t;var a=createDomNode("div",{class:"outer_div_page_results",id:e});a.appendChild(o);for(var l=0;lSearching ....",o.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;0=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;0 - - + +
+
+
    +
+