├── .github ├── dependabot.yml └── workflows │ ├── create-release.yml │ └── test.yml ├── .gitignore ├── .ruff.toml ├── CHANGES.rst ├── LICENCE.rst ├── README.rst ├── babel.cfg ├── checklist.rst ├── pyproject.toml ├── sphinxcontrib └── websupport │ ├── __init__.py │ ├── builder.py │ ├── core.py │ ├── errors.py │ ├── files │ ├── ajax-loader.gif │ ├── comment-bright.png │ ├── comment-close.png │ ├── comment.png │ ├── down-pressed.png │ ├── down.png │ ├── up-pressed.png │ ├── up.png │ └── websupport.js │ ├── py.typed │ ├── search │ ├── __init__.py │ ├── nullsearch.py │ ├── whooshsearch.py │ └── xapiansearch.py │ ├── storage │ ├── __init__.py │ ├── differ.py │ ├── sqlalchemy_db.py │ └── sqlalchemystorage.py │ ├── templates │ └── searchresults.html │ ├── utils.py │ └── writer.py ├── tests ├── conftest.py ├── roots │ ├── test-root │ │ └── root │ │ │ ├── Makefile │ │ │ ├── _static │ │ │ ├── README │ │ │ ├── excluded.css │ │ │ └── subdir │ │ │ │ └── foo.css │ │ │ ├── _templates │ │ │ ├── contentssb.html │ │ │ ├── customsb.html │ │ │ └── layout.html │ │ │ ├── autodoc_fodder.py │ │ │ ├── autodoc_missing_imports.py │ │ │ ├── bom.po │ │ │ ├── bom.txt │ │ │ ├── conf.py │ │ │ ├── contents.txt │ │ │ ├── en.lproj │ │ │ └── localized.txt │ │ │ ├── ext.py │ │ │ ├── extapi.txt │ │ │ ├── extensions.txt │ │ │ ├── footnote.txt │ │ │ ├── images.txt │ │ │ ├── img.foo.png │ │ │ ├── img.gif │ │ │ ├── img.pdf │ │ │ ├── img.png │ │ │ ├── includes.txt │ │ │ ├── lists.txt │ │ │ ├── literal.inc │ │ │ ├── literal_orig.inc │ │ │ ├── markup.txt │ │ │ ├── math.txt │ │ │ ├── metadata.add │ │ │ ├── objects.txt │ │ │ ├── parsermod.py │ │ │ ├── quotes.inc │ │ │ ├── rimg.png │ │ │ ├── robots.txt │ │ │ ├── special │ │ │ ├── api.h │ │ │ └── code.py │ │ │ ├── subdir.po │ │ │ ├── subdir │ │ │ ├── excluded.txt │ │ │ ├── images.txt │ │ │ ├── img.png │ │ │ ├── include.inc │ │ │ ├── includes.txt │ │ │ └── simg.png │ │ │ ├── svgimg.pdf │ │ │ ├── svgimg.svg │ │ │ ├── tabs.inc │ │ │ ├── templated.css_t │ │ │ ├── test.inc │ │ │ ├── testtheme │ │ │ ├── layout.html │ │ │ ├── static │ │ │ │ ├── staticimg.png │ │ │ │ └── statictmpl.html_t │ │ │ └── theme.conf │ │ │ ├── wrongenc.inc │ │ │ └── ziptheme.zip │ └── test-searchadapters │ │ ├── conf.py │ │ └── markup.txt ├── test_searchadapters.py └── test_websupport.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | publish-pypi: 14 | runs-on: ubuntu-latest 15 | name: PyPI Release 16 | environment: release 17 | permissions: 18 | id-token: write # for PyPI trusted publishing 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: 3 25 | cache: pip 26 | cache-dependency-path: pyproject.toml 27 | 28 | - name: Install build dependencies (pypa/build, twine) 29 | run: | 30 | pip install -U pip 31 | pip install build twine 32 | 33 | - name: Build distribution 34 | run: python -m build 35 | 36 | - name: Mint PyPI API token 37 | id: mint-token 38 | uses: actions/github-script@v7 39 | with: 40 | # language=JavaScript 41 | script: | 42 | // retrieve the ambient OIDC token 43 | const oidc_request_token = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; 44 | const oidc_request_url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; 45 | const oidc_resp = await fetch(`${oidc_request_url}&audience=pypi`, { 46 | headers: {Authorization: `bearer ${oidc_request_token}`}, 47 | }); 48 | const oidc_token = (await oidc_resp.json()).value; 49 | 50 | // exchange the OIDC token for an API token 51 | const mint_resp = await fetch('https://pypi.org/_/oidc/github/mint-token', { 52 | method: 'post', 53 | body: `{"token": "${oidc_token}"}` , 54 | headers: {'Content-Type': 'application/json'}, 55 | }); 56 | const api_token = (await mint_resp.json()).token; 57 | 58 | // mask the newly minted API token, so that we don't accidentally leak it 59 | core.setSecret(api_token) 60 | core.setOutput('api-token', api_token) 61 | 62 | - name: Upload to PyPI 63 | env: 64 | TWINE_NON_INTERACTIVE: "true" 65 | TWINE_USERNAME: "__token__" 66 | TWINE_PASSWORD: "${{ steps.mint-token.outputs.api-token }}" 67 | run: | 68 | twine check dist/* 69 | twine upload dist/* 70 | 71 | github-release: 72 | runs-on: ubuntu-latest 73 | name: GitHub release 74 | environment: release 75 | permissions: 76 | contents: write # for softprops/action-gh-release to create GitHub release 77 | steps: 78 | - uses: actions/checkout@v4 79 | - name: Get release version 80 | id: get_version 81 | uses: actions/github-script@v7 82 | with: 83 | script: core.setOutput('version', context.ref.replace("refs/tags/", "")) 84 | 85 | - name: Create GitHub release 86 | uses: softprops/action-gh-release@v2 87 | if: startsWith(github.ref, 'refs/tags/') 88 | with: 89 | name: "sphinxcontrib-websupport ${{ steps.get_version.outputs.version }}" 90 | body: "Changelog: https://github.com/sphinx-doc/sphinxcontrib-websupport/blob/master/CHANGES.rst" 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: read 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | FORCE_COLOR: "1" 16 | PYTHONDEVMODE: "1" # -X dev 17 | PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding 18 | 19 | jobs: 20 | tests: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python: 25 | - "3.9" 26 | - "3.10" 27 | - "3.11" 28 | - "3.12" 29 | - "3.13-dev" 30 | fail-fast: false 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up Python ${{ matrix.python }} 35 | uses: actions/setup-python@v5 36 | if: "!endsWith(matrix.python, '-dev')" 37 | with: 38 | python-version: ${{ matrix.python }} 39 | - name: Set up Python ${{ matrix.python }} (deadsnakes) 40 | uses: deadsnakes/action@v3.1.0 41 | if: "endsWith(matrix.python, '-dev')" 42 | with: 43 | python-version: ${{ matrix.python }} 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | python -m pip install .[test,whoosh] 48 | 49 | - name: Test with pytest 50 | run: python -m pytest -vv --durations 25 51 | 52 | test-latest-sphinx: 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | - name: Set up Python ${{ matrix.python }} 58 | uses: actions/setup-python@v5 59 | with: 60 | python-version: "3" 61 | - name: Install dependencies 62 | run: | 63 | python -m pip install --upgrade pip 64 | python -m pip install .[test] 65 | python -m pip install "Sphinx @ git+https://github.com/sphinx-doc/sphinx" 66 | 67 | - name: Test with pytest 68 | run: python -m pytest -vv --durations 25 69 | 70 | lint: 71 | runs-on: ubuntu-latest 72 | strategy: 73 | matrix: 74 | env: 75 | - ruff 76 | - mypy 77 | 78 | steps: 79 | - uses: actions/checkout@v4 80 | - name: Setup python 81 | uses: actions/setup-python@v5 82 | with: 83 | python-version: "3" 84 | 85 | - name: Install dependencies 86 | run: | 87 | python -m pip install --upgrade pip 88 | python -m pip install --upgrade tox 89 | 90 | - name: Run tox 91 | run: tox -e ${{ matrix.env }} 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .DS_Store 4 | idea/ 5 | .vscode/ 6 | 7 | .mypy_cache/ 8 | .pytest_cache/ 9 | .ruff_cache/ 10 | .tags 11 | .tox/ 12 | 13 | .venv/ 14 | venv/ 15 | 16 | build/ 17 | dist/ 18 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" # Pin Ruff to Python 3.9 2 | output-format = "full" 3 | line-length = 95 4 | 5 | [lint] 6 | preview = true 7 | select = [ 8 | # "ANN", # flake8-annotations 9 | "C4", # flake8-comprehensions 10 | "COM", # flake8-commas 11 | "B", # flake8-bugbear 12 | "DTZ", # flake8-datetimez 13 | "E", # pycodestyle 14 | "EM", # flake8-errmsg 15 | "EXE", # flake8-executable 16 | "F", # pyflakes 17 | "FA", # flake8-future-annotations 18 | "FLY", # flynt 19 | "FURB", # refurb 20 | "G", # flake8-logging-format 21 | "I", # isort 22 | "ICN", # flake8-import-conventions 23 | "INT", # flake8-gettext 24 | "LOG", # flake8-logging 25 | "PERF", # perflint 26 | "PGH", # pygrep-hooks 27 | "PIE", # flake8-pie 28 | "PT", # flake8-pytest-style 29 | "SIM", # flake8-simplify 30 | "SLOT", # flake8-slots 31 | "TCH", # flake8-type-checking 32 | "UP", # pyupgrade 33 | "W", # pycodestyle 34 | "YTT", # flake8-2020 35 | ] 36 | ignore = [ 37 | "E116", 38 | "E241", 39 | "E251", 40 | ] 41 | 42 | [lint.per-file-ignores] 43 | "tests/*" = [ 44 | "ANN", # tests don't need annotations 45 | ] 46 | 47 | [lint.isort] 48 | forced-separate = [ 49 | "tests", 50 | ] 51 | required-imports = [ 52 | "from __future__ import annotations", 53 | ] 54 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Release 2.0.0 (2024-07-28) 2 | ========================== 3 | 4 | * Adopt Ruff 5 | * Tighten MyPy settings 6 | * Update GitHub actions versions 7 | 8 | Release 1.2.7 (2024-01-13) 9 | ========================== 10 | 11 | * Fix tests for sqlalchemy 2. 12 | * Publish a ``whoosh`` extra. 13 | 14 | Release 1.2.6 (2023-08-09) 15 | ========================== 16 | 17 | * Fix tests for Sphinx 7.1 and below 18 | 19 | Release 1.2.5 (2023-08-07) 20 | ========================== 21 | 22 | * Drop support for Python 3.5, 3.6, 3.7, and 3.8 23 | * Raise minimum required Sphinx version to 5.0 24 | 25 | Release 1.2.4 (2020-08-09) 26 | ========================== 27 | 28 | * Import PickleHTMLBuilder from sphinxcontrib-serializinghtml package 29 | 30 | Release 1.2.3 (2020-06-27) 31 | ========================== 32 | 33 | * #43: doctreedir argument has been ignored on initialize app 34 | 35 | Release 1.2.2 (2020-04-29) 36 | ========================== 37 | 38 | * Stop to use sphinx.util.pycompat:htmlescape 39 | 40 | Release 1.2.1 (2020-03-21) 41 | ========================== 42 | 43 | * #41: templates/searchresults.html is missing in the source tarball 44 | 45 | Release 1.2.0 (2020-02-07) 46 | ========================== 47 | 48 | * Drop python2.7 and 3.4 support 49 | 50 | Release 1.1.2 (2019-05-19) 51 | ========================== 52 | 53 | * #6380: sphinxcontrib-websupport doesn't work with Sphinx 2.0 54 | 55 | 56 | Release 1.1.1 (2019-03-21) 57 | ========================== 58 | 59 | * #6190: sphinxcontrib-websupport doesn't work with Sphinx 2.0.0b1 60 | 61 | Release 1.1.0 (2018-06-05) 62 | ========================== 63 | 64 | * #6 Add compatibility with Sphinx 1.6. Thanks to Dmitry Shachnev. 65 | * #13 Support testing with Sphinx-1.7. 66 | * #9 Include license file in the generated wheel package. Thanks to 67 | Jon Dufresne. 68 | 69 | Release 1.0.1 (2017-05-07) 70 | ========================== 71 | 72 | * Make sqlalchemy and whoosh as optional dependency 73 | 74 | Release 1.0.0 (2017-04-23) 75 | ========================== 76 | 77 | * Initial release (copied from sphinx package) 78 | -------------------------------------------------------------------------------- /LICENCE.rst: -------------------------------------------------------------------------------- 1 | License for sphinxcontrib-websupport 2 | ==================================== 3 | 4 | Copyright (c) 2007-2018 by the Sphinx team 5 | (see https://github.com/sphinx-doc/sphinx/blob/master/AUTHORS). 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | README for sphinxcontrib-websupport 3 | =================================== 4 | 5 | This is the Sphinx documentation generator, see http://www.sphinx-doc.org/. 6 | 7 | 8 | Installing 9 | ========== 10 | 11 | Install from PyPI:: 12 | 13 | pip install -U sphinxcontrib-websupport 14 | 15 | Contributing 16 | ============ 17 | 18 | See `CONTRIBUTING.rst`__ 19 | 20 | .. __: https://github.com/sphinx-doc/sphinx/blob/master/CONTRIBUTING.rst 21 | 22 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [extract_messages] 2 | mapping_file = babel.cfg 3 | output_file = sphinx/locale/sphinx.pot 4 | keywords = _ l_ lazy_gettext 5 | -------------------------------------------------------------------------------- /checklist.rst: -------------------------------------------------------------------------------- 1 | ToDo for releasing 2 | ===================== 3 | 4 | 1. check travis-ci testing result 5 | 2. check release version in ``sphinxcontrib/websupport/version.py`` and ``CHANGES`` 6 | 3. build distribtion files: ``python setup.py release sdist bdist_wheel`` 7 | 4. make a release: ``twine upload --sign --identity= dist/`` 8 | 5. check PyPI page: https://pypi.org/p/sphinxcontrib-websupport 9 | 6. tagging with version name. e.g.: git tag 1.1.0 10 | 7. bump version in ``sphinxcontrib/websupport/version.py`` and ``CHANGES`` 11 | 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.7"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | # project metadata 6 | [project] 7 | name = "sphinxcontrib-websupport" 8 | description = """sphinxcontrib-websupport provides a Python API to easily \ 9 | integrate Sphinx documentation into your Web application""" 10 | readme = "README.rst" 11 | urls.Changelog = "https://github.com/sphinx-doc/sphinxcontrib-websupport/blob/master/CHANGES.rst" 12 | urls.Code = "https://github.com/sphinx-doc/sphinxcontrib-websupport/" 13 | urls.Download = "https://pypi.org/project/sphinxcontrib-websupport/" 14 | urls.Homepage = "https://www.sphinx-doc.org/" 15 | urls."Issue tracker" = "https://github.com/sphinx-doc/sphinx/issues/" 16 | license.text = "BSD-2-Clause" 17 | requires-python = ">=3.9" 18 | 19 | # Classifiers list: https://pypi.org/classifiers/ 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Environment :: Console", 23 | "Environment :: Web Environment", 24 | "Intended Audience :: Developers", 25 | "Intended Audience :: Education", 26 | "License :: OSI Approved :: BSD License", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | "Framework :: Sphinx", 37 | "Framework :: Sphinx :: Extension", 38 | "Topic :: Documentation", 39 | "Topic :: Documentation :: Sphinx", 40 | "Topic :: Text Processing", 41 | "Topic :: Utilities", 42 | ] 43 | dependencies = [ 44 | "jinja2", 45 | "Sphinx>=5", 46 | "sphinxcontrib-serializinghtml", 47 | ] 48 | dynamic = ["version"] 49 | 50 | [project.optional-dependencies] 51 | test = [ 52 | "pytest", 53 | ] 54 | whoosh = [ 55 | "sqlalchemy", 56 | "whoosh", 57 | ] 58 | lint = [ 59 | "ruff==0.5.5", 60 | "mypy", 61 | "types-docutils", 62 | ] 63 | 64 | [[project.authors]] 65 | name = "Georg Brandl" 66 | email = "georg@python.org" 67 | 68 | [project.entry-points] 69 | "sphinx.builders".websupport = "sphinxcontrib.websupport.builder:WebSupportBuilder" 70 | 71 | 72 | [tool.flit.module] 73 | name = "sphinxcontrib.websupport" 74 | 75 | [tool.flit.sdist] 76 | include = [ 77 | "CHANGES.rst", 78 | "LICENCE.rst", 79 | # Tests 80 | "tests/", 81 | "tox.ini", 82 | ] 83 | 84 | [tool.mypy] 85 | python_version = "3.9" 86 | packages = [ 87 | "sphinxcontrib", 88 | "tests", 89 | ] 90 | exclude = [ 91 | "tests/roots", 92 | ] 93 | check_untyped_defs = true 94 | #disallow_any_generics = true 95 | disallow_incomplete_defs = true 96 | disallow_subclassing_any = true 97 | #disallow_untyped_calls = true 98 | disallow_untyped_decorators = true 99 | #disallow_untyped_defs = true 100 | explicit_package_bases = true 101 | extra_checks = true 102 | no_implicit_reexport = true 103 | show_column_numbers = true 104 | show_error_context = true 105 | strict_optional = true 106 | warn_redundant_casts = true 107 | warn_unused_configs = true 108 | warn_unused_ignores = true 109 | enable_error_code = [ 110 | "type-arg", 111 | "redundant-self", 112 | "truthy-iterable", 113 | "ignore-without-code", 114 | "unused-awaitable", 115 | ] 116 | 117 | [[tool.mypy.overrides]] 118 | module = [ 119 | "pytest", 120 | "sqlalchemy", 121 | "sqlalchemy.orm", 122 | "sqlalchemy.sql", 123 | "sqlalchemy.sql.expression", 124 | "whoosh", 125 | "whoosh.analysis", 126 | "whoosh.fields", 127 | "whoosh.qparser", 128 | "xapian", 129 | ] 130 | ignore_missing_imports = true 131 | 132 | [tool.pytest.ini_options] 133 | markers = [ 134 | "support", 135 | ] 136 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/__init__.py: -------------------------------------------------------------------------------- 1 | """A Python API to easily integrate Sphinx documentation into Web applications.""" 2 | 3 | from __future__ import annotations 4 | 5 | from os import path 6 | 7 | __version__ = '2.0.0' 8 | __version_info__ = (2, 0, 0) 9 | 10 | package_dir = path.abspath(path.dirname(__file__)) 11 | 12 | # must be imported last to avoid circular import 13 | from sphinxcontrib.websupport.core import WebSupport as WebSupport # NoQA: E402 14 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/builder.py: -------------------------------------------------------------------------------- 1 | """Builder for the web support extension.""" 2 | 3 | from __future__ import annotations 4 | 5 | import html 6 | import os 7 | import posixpath 8 | import shutil 9 | from os import path 10 | from typing import TYPE_CHECKING, Any 11 | 12 | from docutils.io import StringOutput 13 | from sphinx.jinja2glue import BuiltinTemplateLoader 14 | from sphinx.util.osutil import copyfile, ensuredir, os_path, relative_uri 15 | 16 | from sphinxcontrib.serializinghtml import PickleHTMLBuilder 17 | from sphinxcontrib.websupport import package_dir 18 | from sphinxcontrib.websupport.utils import is_commentable 19 | from sphinxcontrib.websupport.writer import WebSupportTranslator 20 | 21 | if TYPE_CHECKING: 22 | from collections.abc import Callable, Iterable 23 | 24 | from docutils import nodes 25 | from sphinx.application import Sphinx 26 | from sphinx.builders.html._assets import _CascadingStyleSheet, _JavaScript 27 | 28 | from sphinxcontrib.websupport.search import BaseSearch 29 | 30 | RESOURCES = [ 31 | 'ajax-loader.gif', 32 | 'comment-bright.png', 33 | 'comment-close.png', 34 | 'comment.png', 35 | 'down-pressed.png', 36 | 'down.png', 37 | 'up-pressed.png', 38 | 'up.png', 39 | 'websupport.js', 40 | ] 41 | 42 | 43 | class WebSupportBuilder(PickleHTMLBuilder): 44 | """ 45 | Builds documents for the web support package. 46 | """ 47 | name = 'websupport' 48 | default_translator_class = WebSupportTranslator 49 | versioning_compare = True # for commentable node's uuid stability. 50 | 51 | def init(self) -> None: 52 | super().init() 53 | # templates are needed for this builder, but the serializing 54 | # builder does not initialize them 55 | self.init_templates() 56 | if not isinstance(self.templates, BuiltinTemplateLoader): 57 | msg = 'websupport builder must be used with the builtin templates' 58 | raise RuntimeError(msg) 59 | # add our custom JS 60 | self.add_js_file('websupport.js') 61 | 62 | @property 63 | def versioning_method(self) -> Callable[[nodes.Node], bool]: # type: ignore[override] 64 | return is_commentable 65 | 66 | def set_webinfo( 67 | self, 68 | staticdir: str, 69 | virtual_staticdir: str, 70 | search: BaseSearch, 71 | storage: str, 72 | ) -> None: 73 | self.staticdir = staticdir 74 | self.virtual_staticdir = virtual_staticdir 75 | self.search: BaseSearch = search # type: ignore[assignment] 76 | self.storage = storage 77 | 78 | def prepare_writing(self, docnames: Iterable[str]) -> None: 79 | super().prepare_writing(set(docnames)) 80 | self.globalcontext['no_search_suffix'] = True 81 | 82 | def write_doc(self, docname: str, doctree: nodes.document) -> None: 83 | destination = StringOutput(encoding='utf-8') 84 | doctree.settings = self.docsettings 85 | 86 | self.secnumbers = self.env.toc_secnumbers.get(docname, {}) 87 | self.fignumbers = self.env.toc_fignumbers.get(docname, {}) 88 | self.imgpath = '/' + posixpath.join(self.virtual_staticdir, self.imagedir) 89 | self.dlpath = '/' + posixpath.join(self.virtual_staticdir, '_downloads') 90 | self.current_docname = docname 91 | self.docwriter.write(doctree, destination) 92 | self.docwriter.assemble_parts() 93 | body = self.docwriter.parts['fragment'] 94 | metatags = self.docwriter.clean_meta 95 | 96 | ctx = self.get_doc_context(docname, body, metatags) 97 | self.handle_page(docname, ctx, event_arg=doctree) 98 | 99 | def write_doc_serialized(self, docname: str, doctree: nodes.document) -> None: 100 | self.imgpath = '/' + posixpath.join(self.virtual_staticdir, self.imagedir) 101 | self.post_process_images(doctree) 102 | title_node = self.env.longtitles.get(docname) 103 | title = title_node and self.render_partial(title_node)['title'] or '' 104 | self.index_page(docname, doctree, title) 105 | 106 | def load_indexer(self, docnames: Iterable[str]) -> None: 107 | self.indexer = self.search # type: ignore[assignment] 108 | self.indexer.init_indexing(changed=list(docnames)) # type: ignore[union-attr] 109 | 110 | def _render_page( 111 | self, 112 | pagename: str, 113 | addctx: dict, 114 | templatename: str, 115 | event_arg: Any = None, 116 | ) -> tuple[dict, dict]: 117 | # This is mostly copied from StandaloneHTMLBuilder. However, instead 118 | # of rendering the template and saving the html, create a context 119 | # dict and pickle it. 120 | ctx = self.globalcontext.copy() 121 | ctx['pagename'] = pagename 122 | 123 | def pathto(otheruri: str, resource: bool = False, 124 | baseuri: str = self.get_target_uri(pagename)) -> str: 125 | if resource and '://' in otheruri: 126 | return otheruri 127 | elif not resource: 128 | otheruri = self.get_target_uri(otheruri) 129 | return relative_uri(baseuri, otheruri) or '#' 130 | else: 131 | return '/' + posixpath.join(self.virtual_staticdir, otheruri) 132 | ctx['pathto'] = pathto 133 | ctx['hasdoc'] = lambda name: name in self.env.all_docs 134 | ctx['encoding'] = self.config.html_output_encoding 135 | ctx['toctree'] = lambda **kw: self._get_local_toctree(pagename, **kw) 136 | self.add_sidebars(pagename, ctx) 137 | ctx.update(addctx) 138 | 139 | def css_tag(css: _CascadingStyleSheet) -> str: 140 | attrs = [] 141 | for key, value in css.attributes.items(): 142 | if value is not None: 143 | attrs.append(f'{key}="{html.escape(value, quote=True)}"') 144 | uri = pathto(os.fspath(css.filename), resource=True) 145 | return f'' 146 | 147 | ctx['css_tag'] = css_tag 148 | 149 | def js_tag(js: _JavaScript) -> str: 150 | if not hasattr(js, 'filename'): 151 | # str value (old styled) 152 | return f'' # type: ignore[arg-type] 153 | 154 | attrs = [] 155 | body = js.attributes.get('body', '') 156 | for key, value in js.attributes.items(): 157 | if key == 'body': 158 | continue 159 | if value is not None: 160 | attrs.append(f'{key}="{html.escape(value, quote=True)}"') 161 | 162 | if not js.filename: 163 | if attrs: 164 | return f'' 165 | return f'' 166 | 167 | uri = pathto(os.fspath(js.filename), resource=True) 168 | if attrs: 169 | return f'' 170 | return f'' 171 | 172 | ctx['js_tag'] = js_tag 173 | 174 | newtmpl = self.app.emit_firstresult('html-page-context', pagename, 175 | templatename, ctx, event_arg) 176 | if newtmpl: 177 | templatename = newtmpl 178 | 179 | # create a dict that will be pickled and used by webapps 180 | doc_ctx = { 181 | 'body': ctx.get('body', ''), 182 | 'title': ctx.get('title', ''), 183 | 'css': ctx.get('css', ''), 184 | 'script': ctx.get('script', ''), 185 | } 186 | # partially render the html template to get at interesting macros 187 | template = self.templates.environment.get_template(templatename) 188 | template_module = template.make_module(ctx) 189 | for item in ['sidebar', 'relbar', 'script', 'css']: 190 | if hasattr(template_module, item): 191 | doc_ctx[item] = getattr(template_module, item)() 192 | 193 | return ctx, doc_ctx 194 | 195 | def handle_page(self, pagename: str, addctx: dict, templatename: str = 'page.html', 196 | outfilename: str | None = None, event_arg: Any = None) -> None: 197 | ctx, doc_ctx = self._render_page(pagename, addctx, 198 | templatename, event_arg) 199 | 200 | if not outfilename: 201 | outfilename = path.join(self.outdir, 'pickles', 202 | os_path(pagename) + self.out_suffix) 203 | ensuredir(path.dirname(outfilename)) 204 | self.dump_context(doc_ctx, outfilename) 205 | 206 | # if there is a source file, copy the source file for the 207 | # "show source" link 208 | if ctx.get('sourcename'): 209 | source_name = path.join(self.staticdir, 210 | '_sources', os_path(ctx['sourcename'])) 211 | ensuredir(path.dirname(source_name)) 212 | copyfile(self.env.doc2path(pagename), source_name) 213 | 214 | def handle_finish(self) -> None: 215 | # get global values for css and script files 216 | _, doc_ctx = self._render_page('tmp', {}, 'page.html') 217 | self.globalcontext['css'] = doc_ctx['css'] 218 | self.globalcontext['script'] = doc_ctx['script'] 219 | 220 | super().handle_finish() 221 | 222 | # move static stuff over to separate directory 223 | directories = [self.imagedir, '_static'] 224 | for directory in directories: 225 | src = path.join(self.outdir, directory) 226 | dst = path.join(self.staticdir, directory) 227 | if path.isdir(src): 228 | if path.isdir(dst): 229 | shutil.rmtree(dst) 230 | shutil.move(src, dst) 231 | self.copy_resources() 232 | 233 | def copy_resources(self) -> None: 234 | # copy resource files to static dir 235 | dst = path.join(self.staticdir, '_static') 236 | 237 | if path.isdir(dst): 238 | for resource in RESOURCES: 239 | src = path.join(package_dir, 'files', resource) 240 | shutil.copy(src, dst) 241 | 242 | def dump_search_index(self) -> None: 243 | self.indexer.finish_indexing() # type: ignore[union-attr] 244 | 245 | 246 | def setup(app: Sphinx) -> dict[str, Any]: 247 | app.add_builder(WebSupportBuilder) 248 | 249 | return { 250 | 'version': 'builtin', 251 | 'parallel_read_safe': True, 252 | 'parallel_write_safe': True, 253 | } 254 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/core.py: -------------------------------------------------------------------------------- 1 | """Base Module for web support functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import html 6 | import importlib 7 | import pickle 8 | import posixpath 9 | import sys 10 | from os import path 11 | 12 | from docutils.core import publish_parts 13 | from jinja2 import Environment, FileSystemLoader 14 | from sphinx.locale import _ 15 | from sphinx.util.docutils import docutils_namespace 16 | from sphinx.util.osutil import ensuredir 17 | 18 | from sphinxcontrib.websupport import errors, package_dir 19 | from sphinxcontrib.websupport.search import SEARCH_ADAPTERS, BaseSearch 20 | from sphinxcontrib.websupport.storage import StorageBackend 21 | 22 | try: 23 | from sphinxcontrib.serializinghtml.jsonimpl import dumps as dump_json 24 | except ImportError: 25 | from json import dumps as dump_json 26 | 27 | 28 | class WebSupport: 29 | """The main API class for the web support package. All interactions 30 | with the web support package should occur through this class. 31 | """ 32 | def __init__( 33 | self, 34 | srcdir=None, # only required for building 35 | builddir='', # the dir with data/static/doctrees subdirs 36 | datadir=None, # defaults to builddir/data 37 | staticdir=None, # defaults to builddir/static 38 | doctreedir=None, # defaults to builddir/doctrees 39 | search=None, # defaults to no search 40 | storage=None, # defaults to SQLite in datadir 41 | buildername='websupport', 42 | confoverrides=None, 43 | status=sys.stdout, 44 | warning=sys.stderr, 45 | moderation_callback=None, 46 | allow_anonymous_comments=True, 47 | docroot='', 48 | staticroot='static', 49 | ): 50 | # directories 51 | self.srcdir = srcdir 52 | self.builddir = builddir 53 | self.outdir = path.join(builddir, 'data') 54 | self.datadir = datadir or self.outdir 55 | self.staticdir = staticdir or path.join(self.builddir, 'static') 56 | self.doctreedir = doctreedir or path.join(self.builddir, 'doctrees') 57 | # web server virtual paths 58 | self.staticroot = staticroot.strip('/') 59 | self.docroot = docroot.strip('/') 60 | 61 | self.buildername = buildername 62 | self.confoverrides = confoverrides or {} 63 | 64 | self.status = status 65 | self.warning = warning 66 | self.moderation_callback = moderation_callback 67 | self.allow_anonymous_comments = allow_anonymous_comments 68 | 69 | self._init_templating() 70 | self._init_search(search) 71 | self._init_storage(storage) 72 | 73 | self._globalcontext = None 74 | 75 | self._make_base_comment_options() 76 | 77 | extensions = self.confoverrides.setdefault('extensions', []) 78 | extensions.append('sphinxcontrib.websupport.builder') 79 | 80 | def _init_storage(self, storage): 81 | if isinstance(storage, StorageBackend): 82 | self.storage = storage 83 | else: 84 | # If a StorageBackend isn't provided, use the default 85 | # SQLAlchemy backend. 86 | from sphinxcontrib.websupport.storage.sqlalchemystorage import SQLAlchemyStorage 87 | if not storage: 88 | # no explicit DB path given; create default sqlite database 89 | db_path = path.join(self.datadir, 'db', 'websupport.db') 90 | ensuredir(path.dirname(db_path)) 91 | storage = 'sqlite:///' + db_path 92 | self.storage = SQLAlchemyStorage(storage) 93 | 94 | def _init_templating(self): 95 | loader = FileSystemLoader(path.join(package_dir, 'templates')) 96 | self.template_env = Environment(loader=loader) 97 | 98 | def _init_search(self, search): 99 | if isinstance(search, BaseSearch): 100 | self.search: BaseSearch = search 101 | else: 102 | mod, cls = SEARCH_ADAPTERS[search or 'null'] 103 | mod = 'sphinxcontrib.websupport.search.' + mod 104 | SearchClass = getattr(importlib.import_module(mod), cls) 105 | search_path = path.join(self.datadir, 'search') 106 | self.search = SearchClass(search_path) 107 | self.results_template = \ 108 | self.template_env.get_template('searchresults.html') 109 | 110 | def build(self): 111 | """Build the documentation. Places the data into the `outdir` 112 | directory. Use it like this:: 113 | 114 | support = WebSupport(srcdir, builddir, search='xapian') 115 | support.build() 116 | 117 | This will read reStructured text files from `srcdir`. Then it will 118 | build the pickles and search index, placing them into `builddir`. 119 | It will also save node data to the database. 120 | """ 121 | if not self.srcdir: 122 | msg = 'No srcdir associated with WebSupport object' 123 | raise RuntimeError(msg) 124 | 125 | with docutils_namespace(): 126 | from sphinx.application import Sphinx 127 | app = Sphinx(self.srcdir, self.srcdir, self.outdir, self.doctreedir, 128 | self.buildername, self.confoverrides, status=self.status, 129 | warning=self.warning) 130 | app.builder.set_webinfo( # type: ignore[attr-defined] 131 | self.staticdir, 132 | self.staticroot, 133 | self.search, 134 | self.storage, 135 | ) 136 | 137 | self.storage.pre_build() 138 | app.build() 139 | self.storage.post_build() 140 | 141 | def get_globalcontext(self): 142 | """Load and return the "global context" pickle.""" 143 | if not self._globalcontext: 144 | infilename = path.join(self.datadir, 'globalcontext.pickle') 145 | with open(infilename, 'rb') as f: 146 | self._globalcontext = pickle.load(f) 147 | return self._globalcontext 148 | 149 | def get_document(self, docname, username='', moderator=False): 150 | """Load and return a document from a pickle. The document will 151 | be a dict object which can be used to render a template:: 152 | 153 | support = WebSupport(datadir=datadir) 154 | support.get_document('index', username, moderator) 155 | 156 | In most cases `docname` will be taken from the request path and 157 | passed directly to this function. In Flask, that would be something 158 | like this:: 159 | 160 | @app.route('/') 161 | def index(docname): 162 | username = g.user.name if g.user else '' 163 | moderator = g.user.moderator if g.user else False 164 | try: 165 | document = support.get_document(docname, username, 166 | moderator) 167 | except DocumentNotFoundError: 168 | abort(404) 169 | render_template('doc.html', document=document) 170 | 171 | The document dict that is returned contains the following items 172 | to be used during template rendering. 173 | 174 | * **body**: The main body of the document as HTML 175 | * **sidebar**: The sidebar of the document as HTML 176 | * **relbar**: A div containing links to related documents 177 | * **title**: The title of the document 178 | * **css**: Links to css files used by Sphinx 179 | * **script**: Javascript containing comment options 180 | 181 | This raises :class:`~sphinxcontrib.websupport.errors.DocumentNotFoundError` 182 | if a document matching `docname` is not found. 183 | 184 | :param docname: the name of the document to load. 185 | """ 186 | docpath = path.join(self.datadir, 'pickles', docname) 187 | if path.isdir(docpath): 188 | infilename = docpath + '/index.fpickle' 189 | if not docname: 190 | docname = 'index' 191 | else: 192 | docname += '/index' 193 | else: 194 | infilename = docpath + '.fpickle' 195 | 196 | try: 197 | with open(infilename, 'rb') as f: 198 | document = pickle.load(f) 199 | except OSError as err: 200 | msg = f'The document "{docname}" could not be found' 201 | raise errors.DocumentNotFoundError(msg) from err 202 | 203 | comment_opts = self._make_comment_options(username, moderator) 204 | comment_meta = self._make_metadata( 205 | self.storage.get_metadata(docname, moderator), 206 | ) 207 | 208 | document['script'] = comment_opts + comment_meta + document['script'] 209 | return document 210 | 211 | def get_search_results(self, q): 212 | """Perform a search for the query `q`, and create a set 213 | of search results. Then render the search results as html and 214 | return a context dict like the one created by 215 | :meth:`get_document`:: 216 | 217 | document = support.get_search_results(q) 218 | 219 | :param q: the search query 220 | """ 221 | results = self.search.query(q) 222 | ctx = { 223 | 'q': q, 224 | 'search_performed': True, 225 | 'search_results': results, 226 | 'docroot': '../', # XXX 227 | '_': _, 228 | } 229 | document = { 230 | 'body': self.results_template.render(ctx), 231 | 'title': 'Search Results', 232 | 'sidebar': '', 233 | 'relbar': '', 234 | } 235 | return document 236 | 237 | def get_data(self, node_id, username=None, moderator=False): 238 | """Get the comments and source associated with `node_id`. If 239 | `username` is given vote information will be included with the 240 | returned comments. The default CommentBackend returns a dict with 241 | two keys, *source*, and *comments*. *source* is raw source of the 242 | node and is used as the starting point for proposals a user can 243 | add. *comments* is a list of dicts that represent a comment, each 244 | having the following items: 245 | 246 | ============= ====================================================== 247 | Key Contents 248 | ============= ====================================================== 249 | text The comment text. 250 | username The username that was stored with the comment. 251 | id The comment's unique identifier. 252 | rating The comment's current rating. 253 | age The time in seconds since the comment was added. 254 | time A dict containing time information. It contains the 255 | following keys: year, month, day, hour, minute, second, 256 | iso, and delta. `iso` is the time formatted in ISO 257 | 8601 format. `delta` is a printable form of how old 258 | the comment is (e.g. "3 hours ago"). 259 | vote If `user_id` was given, this will be an integer 260 | representing the vote. 1 for an upvote, -1 for a 261 | downvote, or 0 if unvoted. 262 | node The id of the node that the comment is attached to. 263 | If the comment's parent is another comment rather than 264 | a node, this will be null. 265 | parent The id of the comment that this comment is attached 266 | to if it is not attached to a node. 267 | children A list of all children, in this format. 268 | proposal_diff An HTML representation of the differences between the 269 | the current source and the user's proposed source. 270 | ============= ====================================================== 271 | 272 | :param node_id: the id of the node to get comments for. 273 | :param username: the username of the user viewing the comments. 274 | :param moderator: whether the user is a moderator. 275 | """ 276 | return self.storage.get_data(node_id, username, moderator) 277 | 278 | def delete_comment(self, comment_id, username='', moderator=False): 279 | """Delete a comment. 280 | 281 | If `moderator` is True, the comment and all descendants will be deleted 282 | from the database, and the function returns ``True``. 283 | 284 | If `moderator` is False, the comment will be marked as deleted (but not 285 | removed from the database so as not to leave any comments orphaned), but 286 | only if the `username` matches the `username` on the comment. The 287 | username and text files are replaced with "[deleted]" . In this case, 288 | the function returns ``False``. 289 | 290 | This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError` 291 | if moderator is False and `username` doesn't match username on the 292 | comment. 293 | 294 | :param comment_id: the id of the comment to delete. 295 | :param username: the username requesting the deletion. 296 | :param moderator: whether the requestor is a moderator. 297 | """ 298 | return self.storage.delete_comment(comment_id, username, moderator) 299 | 300 | def add_comment(self, text, node_id='', parent_id='', displayed=True, 301 | username=None, time=None, proposal=None, 302 | moderator=False): 303 | """Add a comment to a node or another comment. Returns the comment 304 | in the same format as :meth:`get_comments`. If the comment is being 305 | attached to a node, pass in the node's id (as a string) with the 306 | node keyword argument:: 307 | 308 | comment = support.add_comment(text, node_id=node_id) 309 | 310 | If the comment is the child of another comment, provide the parent's 311 | id (as a string) with the parent keyword argument:: 312 | 313 | comment = support.add_comment(text, parent_id=parent_id) 314 | 315 | If you would like to store a username with the comment, pass 316 | in the optional `username` keyword argument:: 317 | 318 | comment = support.add_comment(text, node=node_id, 319 | username=username) 320 | 321 | :param parent_id: the prefixed id of the comment's parent. 322 | :param text: the text of the comment. 323 | :param displayed: for moderation purposes 324 | :param username: the username of the user making the comment. 325 | :param time: the time the comment was created, defaults to now. 326 | """ 327 | if username is None: 328 | if self.allow_anonymous_comments: 329 | username = 'Anonymous' 330 | else: 331 | raise errors.UserNotAuthorizedError() 332 | parsed = self._parse_comment_text(text) 333 | comment = self.storage.add_comment(parsed, displayed, username, 334 | time, proposal, node_id, 335 | parent_id, moderator) 336 | comment['original_text'] = text 337 | if not displayed and self.moderation_callback: 338 | self.moderation_callback(comment) 339 | return comment 340 | 341 | def process_vote(self, comment_id, username, value): 342 | """Process a user's vote. The web support package relies 343 | on the API user to perform authentication. The API user will 344 | typically receive a comment_id and value from a form, and then 345 | make sure the user is authenticated. A unique username must be 346 | passed in, which will also be used to retrieve the user's past 347 | voting data. An example, once again in Flask:: 348 | 349 | @app.route('/docs/process_vote', methods=['POST']) 350 | def process_vote(): 351 | if g.user is None: 352 | abort(401) 353 | comment_id = request.form.get('comment_id') 354 | value = request.form.get('value') 355 | if value is None or comment_id is None: 356 | abort(400) 357 | support.process_vote(comment_id, g.user.name, value) 358 | return "success" 359 | 360 | :param comment_id: the comment being voted on 361 | :param username: the unique username of the user voting 362 | :param value: 1 for an upvote, -1 for a downvote, 0 for an unvote. 363 | """ 364 | value = int(value) 365 | if not -1 <= value <= 1: 366 | msg = f'vote value {value} out of range (-1, 1)' 367 | raise ValueError(msg) 368 | self.storage.process_vote(comment_id, username, value) 369 | 370 | def update_username(self, old_username, new_username): 371 | """To remain decoupled from a webapp's authentication system, the 372 | web support package stores a user's username with each of their 373 | comments and votes. If the authentication system allows a user to 374 | change their username, this can lead to stagnate data in the web 375 | support system. To avoid this, each time a username is changed, this 376 | method should be called. 377 | 378 | :param old_username: The original username. 379 | :param new_username: The new username. 380 | """ 381 | self.storage.update_username(old_username, new_username) 382 | 383 | def accept_comment(self, comment_id, moderator=False): 384 | """Accept a comment that is pending moderation. 385 | 386 | This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError` 387 | if moderator is False. 388 | 389 | :param comment_id: The id of the comment that was accepted. 390 | :param moderator: Whether the user making the request is a moderator. 391 | """ 392 | if not moderator: 393 | raise errors.UserNotAuthorizedError() 394 | self.storage.accept_comment(comment_id) 395 | 396 | def _make_base_comment_options(self): 397 | """Helper method to create the part of the COMMENT_OPTIONS javascript 398 | that remains the same throughout the lifetime of the 399 | :class:`~sphinxcontrib.websupport.WebSupport` object. 400 | """ 401 | self.base_comment_opts: dict[str, str | bool] = {} 402 | 403 | if self.docroot != '': 404 | comment_urls = [ 405 | ('addCommentURL', '_add_comment'), 406 | ('getCommentsURL', '_get_comments'), 407 | ('processVoteURL', '_process_vote'), 408 | ('acceptCommentURL', '_accept_comment'), 409 | ('deleteCommentURL', '_delete_comment'), 410 | ] 411 | for key, value in comment_urls: 412 | self.base_comment_opts[key] = \ 413 | '/' + posixpath.join(self.docroot, value) 414 | if self.staticroot != 'static': 415 | static_urls = [ 416 | ('commentImage', 'comment.png'), 417 | ('closeCommentImage', 'comment-close.png'), 418 | ('loadingImage', 'ajax-loader.gif'), 419 | ('commentBrightImage', 'comment-bright.png'), 420 | ('upArrow', 'up.png'), 421 | ('upArrowPressed', 'up-pressed.png'), 422 | ('downArrow', 'down.png'), 423 | ('downArrowPressed', 'down-pressed.png'), 424 | ] 425 | for key, value in static_urls: 426 | self.base_comment_opts[key] = \ 427 | '/' + posixpath.join(self.staticroot, '_static', value) 428 | 429 | def _make_comment_options(self, username, moderator): 430 | """Helper method to create the parts of the COMMENT_OPTIONS 431 | javascript that are unique to each request. 432 | 433 | :param username: The username of the user making the request. 434 | :param moderator: Whether the user making the request is a moderator. 435 | """ 436 | rv = self.base_comment_opts.copy() 437 | if username: 438 | rv.update({ 439 | 'voting': True, 440 | 'username': username, 441 | 'moderator': moderator, 442 | }) 443 | return f'''\ 444 | 447 | ''' 448 | 449 | def _make_metadata(self, data): 450 | return f'''\ 451 | 454 | ''' 455 | 456 | def _parse_comment_text(self, text): 457 | settings = {'file_insertion_enabled': False, 458 | 'raw_enabled': False, 459 | 'output_encoding': 'unicode'} 460 | try: 461 | ret = publish_parts(text, writer_name='html', 462 | settings_overrides=settings)['fragment'] 463 | except Exception: 464 | ret = html.escape(text) 465 | return ret 466 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/errors.py: -------------------------------------------------------------------------------- 1 | """Contains Error classes for the web support package.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class DocumentNotFoundError(Exception): 7 | pass 8 | 9 | 10 | class UserNotAuthorizedError(Exception): 11 | pass 12 | 13 | 14 | class CommentNotAllowedError(Exception): 15 | pass 16 | 17 | 18 | class NullSearchException(Exception): 19 | pass 20 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/ajax-loader.gif -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/comment-bright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/comment-bright.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/comment-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/comment-close.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/comment.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/down-pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/down-pressed.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/down.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/up-pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/up-pressed.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/files/up.png -------------------------------------------------------------------------------- /sphinxcontrib/websupport/files/websupport.js: -------------------------------------------------------------------------------- 1 | /* 2 | * websupport.js 3 | * ~~~~~~~~~~~~~ 4 | * 5 | * sphinx.websupport utilities for all documentation. 6 | * 7 | * :copyright: Copyright 2007-2019 by the Sphinx team, see AUTHORS. 8 | * :license: BSD, see LICENSE for details. 9 | * 10 | */ 11 | 12 | (function($) { 13 | $.fn.autogrow = function() { 14 | return this.each(function() { 15 | var textarea = this; 16 | 17 | $.fn.autogrow.resize(textarea); 18 | 19 | $(textarea) 20 | .focus(function() { 21 | textarea.interval = setInterval(function() { 22 | $.fn.autogrow.resize(textarea); 23 | }, 500); 24 | }) 25 | .blur(function() { 26 | clearInterval(textarea.interval); 27 | }); 28 | }); 29 | }; 30 | 31 | $.fn.autogrow.resize = function(textarea) { 32 | var lineHeight = parseInt($(textarea).css('line-height'), 10); 33 | var lines = textarea.value.split('\n'); 34 | var columns = textarea.cols; 35 | var lineCount = 0; 36 | $.each(lines, function() { 37 | lineCount += Math.ceil(this.length / columns) || 1; 38 | }); 39 | var height = lineHeight * (lineCount + 1); 40 | $(textarea).css('height', height); 41 | }; 42 | })(jQuery); 43 | 44 | (function($) { 45 | var comp, by; 46 | 47 | function init() { 48 | initEvents(); 49 | initComparator(); 50 | } 51 | 52 | function initEvents() { 53 | $(document).on("click", 'a.comment-close', function(event) { 54 | event.preventDefault(); 55 | hide($(this).attr('id').substring(2)); 56 | }); 57 | $(document).on("click", 'a.vote', function(event) { 58 | event.preventDefault(); 59 | handleVote($(this)); 60 | }); 61 | $(document).on("click", 'a.reply', function(event) { 62 | event.preventDefault(); 63 | openReply($(this).attr('id').substring(2)); 64 | }); 65 | $(document).on("click", 'a.close-reply', function(event) { 66 | event.preventDefault(); 67 | closeReply($(this).attr('id').substring(2)); 68 | }); 69 | $(document).on("click", 'a.sort-option', function(event) { 70 | event.preventDefault(); 71 | handleReSort($(this)); 72 | }); 73 | $(document).on("click", 'a.show-proposal', function(event) { 74 | event.preventDefault(); 75 | showProposal($(this).attr('id').substring(2)); 76 | }); 77 | $(document).on("click", 'a.hide-proposal', function(event) { 78 | event.preventDefault(); 79 | hideProposal($(this).attr('id').substring(2)); 80 | }); 81 | $(document).on("click", 'a.show-propose-change', function(event) { 82 | event.preventDefault(); 83 | showProposeChange($(this).attr('id').substring(2)); 84 | }); 85 | $(document).on("click", 'a.hide-propose-change', function(event) { 86 | event.preventDefault(); 87 | hideProposeChange($(this).attr('id').substring(2)); 88 | }); 89 | $(document).on("click", 'a.accept-comment', function(event) { 90 | event.preventDefault(); 91 | acceptComment($(this).attr('id').substring(2)); 92 | }); 93 | $(document).on("click", 'a.delete-comment', function(event) { 94 | event.preventDefault(); 95 | deleteComment($(this).attr('id').substring(2)); 96 | }); 97 | $(document).on("click", 'a.comment-markup', function(event) { 98 | event.preventDefault(); 99 | toggleCommentMarkupBox($(this).attr('id').substring(2)); 100 | }); 101 | } 102 | 103 | /** 104 | * Set comp, which is a comparator function used for sorting and 105 | * inserting comments into the list. 106 | */ 107 | function setComparator() { 108 | // If the first three letters are "asc", sort in ascending order 109 | // and remove the prefix. 110 | if (by.substring(0,3) == 'asc') { 111 | var i = by.substring(3); 112 | comp = function(a, b) { return a[i] - b[i]; }; 113 | } else { 114 | // Otherwise sort in descending order. 115 | comp = function(a, b) { return b[by] - a[by]; }; 116 | } 117 | 118 | // Reset link styles and format the selected sort option. 119 | $('a.sel').attr('href', '#').removeClass('sel'); 120 | $('a.by' + by).removeAttr('href').addClass('sel'); 121 | } 122 | 123 | /** 124 | * Create a comp function. If the user has preferences stored in 125 | * the sortBy cookie, use those, otherwise use the default. 126 | */ 127 | function initComparator() { 128 | by = 'rating'; // Default to sort by rating. 129 | // If the sortBy cookie is set, use that instead. 130 | if (document.cookie.length > 0) { 131 | var start = document.cookie.indexOf('sortBy='); 132 | if (start != -1) { 133 | start = start + 7; 134 | var end = document.cookie.indexOf(";", start); 135 | if (end == -1) { 136 | end = document.cookie.length; 137 | by = unescape(document.cookie.substring(start, end)); 138 | } 139 | } 140 | } 141 | setComparator(); 142 | } 143 | 144 | /** 145 | * Show a comment div. 146 | */ 147 | function show(id) { 148 | $('#ao' + id).hide(); 149 | $('#ah' + id).show(); 150 | var context = $.extend({id: id}, opts); 151 | var popup = $(renderTemplate(popupTemplate, context)).hide(); 152 | popup.find('textarea[name="proposal"]').hide(); 153 | popup.find('a.by' + by).addClass('sel'); 154 | var form = popup.find('#cf' + id); 155 | form.submit(function(event) { 156 | event.preventDefault(); 157 | addComment(form); 158 | }); 159 | $('#s' + id).after(popup); 160 | popup.slideDown('fast', function() { 161 | getComments(id); 162 | }); 163 | } 164 | 165 | /** 166 | * Hide a comment div. 167 | */ 168 | function hide(id) { 169 | $('#ah' + id).hide(); 170 | $('#ao' + id).show(); 171 | var div = $('#sc' + id); 172 | div.slideUp('fast', function() { 173 | div.remove(); 174 | }); 175 | } 176 | 177 | /** 178 | * Perform an ajax request to get comments for a node 179 | * and insert the comments into the comments tree. 180 | */ 181 | function getComments(id) { 182 | $.ajax({ 183 | type: 'GET', 184 | url: opts.getCommentsURL, 185 | data: {node: id}, 186 | success: function(data, textStatus, request) { 187 | var ul = $('#cl' + id); 188 | var speed = 100; 189 | $('#cf' + id) 190 | .find('textarea[name="proposal"]') 191 | .data('source', data.source); 192 | 193 | if (data.comments.length === 0) { 194 | ul.html('
  • No comments yet.
  • '); 195 | ul.data('empty', true); 196 | } else { 197 | // If there are comments, sort them and put them in the list. 198 | var comments = sortComments(data.comments); 199 | speed = data.comments.length * 100; 200 | appendComments(comments, ul); 201 | ul.data('empty', false); 202 | } 203 | $('#cn' + id).slideUp(speed + 200); 204 | ul.slideDown(speed); 205 | }, 206 | error: function(request, textStatus, error) { 207 | showError('Oops, there was a problem retrieving the comments.'); 208 | }, 209 | dataType: 'json' 210 | }); 211 | } 212 | 213 | /** 214 | * Add a comment via ajax and insert the comment into the comment tree. 215 | */ 216 | function addComment(form) { 217 | var node_id = form.find('input[name="node"]').val(); 218 | var parent_id = form.find('input[name="parent"]').val(); 219 | var text = form.find('textarea[name="comment"]').val(); 220 | var proposal = form.find('textarea[name="proposal"]').val(); 221 | 222 | if (text == '') { 223 | showError('Please enter a comment.'); 224 | return; 225 | } 226 | 227 | // Disable the form that is being submitted. 228 | form.find('textarea,input').attr('disabled', 'disabled'); 229 | 230 | // Send the comment to the server. 231 | $.ajax({ 232 | type: "POST", 233 | url: opts.addCommentURL, 234 | dataType: 'json', 235 | data: { 236 | node: node_id, 237 | parent: parent_id, 238 | text: text, 239 | proposal: proposal 240 | }, 241 | success: function(data, textStatus, error) { 242 | // Reset the form. 243 | if (node_id) { 244 | hideProposeChange(node_id); 245 | } 246 | form.find('textarea') 247 | .val('') 248 | .add(form.find('input')) 249 | .removeAttr('disabled'); 250 | var ul = $('#cl' + (node_id || parent_id)); 251 | if (ul.data('empty')) { 252 | $(ul).empty(); 253 | ul.data('empty', false); 254 | } 255 | insertComment(data.comment); 256 | var ao = $('#ao' + node_id); 257 | ao.find('img').attr({'src': opts.commentBrightImage}); 258 | if (node_id) { 259 | // if this was a "root" comment, remove the commenting box 260 | // (the user can get it back by reopening the comment popup) 261 | $('#ca' + node_id).slideUp(); 262 | } 263 | }, 264 | error: function(request, textStatus, error) { 265 | form.find('textarea,input').removeAttr('disabled'); 266 | showError('Oops, there was a problem adding the comment.'); 267 | } 268 | }); 269 | } 270 | 271 | /** 272 | * Recursively append comments to the main comment list and children 273 | * lists, creating the comment tree. 274 | */ 275 | function appendComments(comments, ul) { 276 | $.each(comments, function() { 277 | var div = createCommentDiv(this); 278 | ul.append($(document.createElement('li')).html(div)); 279 | appendComments(this.children, div.find('ul.comment-children')); 280 | // To avoid stagnating data, don't store the comments children in data. 281 | this.children = null; 282 | div.data('comment', this); 283 | }); 284 | } 285 | 286 | /** 287 | * After adding a new comment, it must be inserted in the correct 288 | * location in the comment tree. 289 | */ 290 | function insertComment(comment) { 291 | var div = createCommentDiv(comment); 292 | 293 | // To avoid stagnating data, don't store the comments children in data. 294 | comment.children = null; 295 | div.data('comment', comment); 296 | 297 | var ul = $('#cl' + (comment.node || comment.parent)); 298 | var siblings = getChildren(ul); 299 | 300 | var li = $(document.createElement('li')); 301 | li.hide(); 302 | 303 | // Determine where in the parents children list to insert this comment. 304 | for(var i=0; i < siblings.length; i++) { 305 | if (comp(comment, siblings[i]) <= 0) { 306 | $('#cd' + siblings[i].id) 307 | .parent() 308 | .before(li.html(div)); 309 | li.slideDown('fast'); 310 | return; 311 | } 312 | } 313 | 314 | // If we get here, this comment rates lower than all the others, 315 | // or it is the only comment in the list. 316 | ul.append(li.html(div)); 317 | li.slideDown('fast'); 318 | } 319 | 320 | function acceptComment(id) { 321 | $.ajax({ 322 | type: 'POST', 323 | url: opts.acceptCommentURL, 324 | data: {id: id}, 325 | success: function(data, textStatus, request) { 326 | $('#cm' + id).fadeOut('fast'); 327 | $('#cd' + id).removeClass('moderate'); 328 | }, 329 | error: function(request, textStatus, error) { 330 | showError('Oops, there was a problem accepting the comment.'); 331 | } 332 | }); 333 | } 334 | 335 | function deleteComment(id) { 336 | $.ajax({ 337 | type: 'POST', 338 | url: opts.deleteCommentURL, 339 | data: {id: id}, 340 | success: function(data, textStatus, request) { 341 | var div = $('#cd' + id); 342 | if (data == 'delete') { 343 | // Moderator mode: remove the comment and all children immediately 344 | div.slideUp('fast', function() { 345 | div.remove(); 346 | }); 347 | return; 348 | } 349 | // User mode: only mark the comment as deleted 350 | div 351 | .find('span.user-id:first') 352 | .text('[deleted]').end() 353 | .find('div.comment-text:first') 354 | .text('[deleted]').end() 355 | .find('#cm' + id + ', #dc' + id + ', #ac' + id + ', #rc' + id + 356 | ', #sp' + id + ', #hp' + id + ', #cr' + id + ', #rl' + id) 357 | .remove(); 358 | var comment = div.data('comment'); 359 | comment.username = '[deleted]'; 360 | comment.text = '[deleted]'; 361 | div.data('comment', comment); 362 | }, 363 | error: function(request, textStatus, error) { 364 | showError('Oops, there was a problem deleting the comment.'); 365 | } 366 | }); 367 | } 368 | 369 | function showProposal(id) { 370 | $('#sp' + id).hide(); 371 | $('#hp' + id).show(); 372 | $('#pr' + id).slideDown('fast'); 373 | } 374 | 375 | function hideProposal(id) { 376 | $('#hp' + id).hide(); 377 | $('#sp' + id).show(); 378 | $('#pr' + id).slideUp('fast'); 379 | } 380 | 381 | function showProposeChange(id) { 382 | $('#pc' + id).hide(); 383 | $('#hc' + id).show(); 384 | var textarea = $('#pt' + id); 385 | textarea.val(textarea.data('source')); 386 | $.fn.autogrow.resize(textarea[0]); 387 | textarea.slideDown('fast'); 388 | } 389 | 390 | function hideProposeChange(id) { 391 | $('#hc' + id).hide(); 392 | $('#pc' + id).show(); 393 | var textarea = $('#pt' + id); 394 | textarea.val('').removeAttr('disabled'); 395 | textarea.slideUp('fast'); 396 | } 397 | 398 | function toggleCommentMarkupBox(id) { 399 | $('#mb' + id).toggle(); 400 | } 401 | 402 | /** Handle when the user clicks on a sort by link. */ 403 | function handleReSort(link) { 404 | var classes = link.attr('class').split(/\s+/); 405 | for (var i=0; iThank you! Your comment will show up ' 558 | + 'once it is has been approved by a moderator.'); 559 | } 560 | // Prettify the comment rating. 561 | comment.pretty_rating = comment.rating + ' point' + 562 | (comment.rating == 1 ? '' : 's'); 563 | // Make a class (for displaying not yet moderated comments differently) 564 | comment.css_class = comment.displayed ? '' : ' moderate'; 565 | // Create a div for this comment. 566 | var context = $.extend({}, opts, comment); 567 | var div = $(renderTemplate(commentTemplate, context)); 568 | 569 | // If the user has voted on this comment, highlight the correct arrow. 570 | if (comment.vote) { 571 | var direction = (comment.vote == 1) ? 'u' : 'd'; 572 | div.find('#' + direction + 'v' + comment.id).hide(); 573 | div.find('#' + direction + 'u' + comment.id).show(); 574 | } 575 | 576 | if (opts.moderator || comment.text != '[deleted]') { 577 | div.find('a.reply').show(); 578 | if (comment.proposal_diff) 579 | div.find('#sp' + comment.id).show(); 580 | if (opts.moderator && !comment.displayed) 581 | div.find('#cm' + comment.id).show(); 582 | if (opts.moderator || (opts.username == comment.username)) 583 | div.find('#dc' + comment.id).show(); 584 | } 585 | return div; 586 | } 587 | 588 | /** 589 | * A simple template renderer. Placeholders such as <%id%> are replaced 590 | * by context['id'] with items being escaped. Placeholders such as <#id#> 591 | * are not escaped. 592 | */ 593 | function renderTemplate(template, context) { 594 | var esc = $(document.createElement('div')); 595 | 596 | function handle(ph, escape) { 597 | var cur = context; 598 | $.each(ph.split('.'), function() { 599 | cur = cur[this]; 600 | }); 601 | return escape ? esc.text(cur || "").html() : cur; 602 | } 603 | 604 | return template.replace(/<([%#])([\w\.]*)\1>/g, function() { 605 | return handle(arguments[2], arguments[1] == '%' ? true : false); 606 | }); 607 | } 608 | 609 | /** Flash an error message briefly. */ 610 | function showError(message) { 611 | $(document.createElement('div')).attr({'class': 'popup-error'}) 612 | .append($(document.createElement('div')) 613 | .attr({'class': 'error-message'}).text(message)) 614 | .appendTo('body') 615 | .fadeIn("slow") 616 | .delay(2000) 617 | .fadeOut("slow"); 618 | } 619 | 620 | /** Add a link the user uses to open the comments popup. */ 621 | $.fn.comment = function() { 622 | return this.each(function() { 623 | var id = $(this).attr('id').substring(1); 624 | var count = COMMENT_METADATA[id]; 625 | var title = count + ' comment' + (count == 1 ? '' : 's'); 626 | var image = count > 0 ? opts.commentBrightImage : opts.commentImage; 627 | var addcls = count == 0 ? ' nocomment' : ''; 628 | $(this) 629 | .append( 630 | $(document.createElement('a')).attr({ 631 | href: '#', 632 | 'class': 'sphinx-comment-open' + addcls, 633 | id: 'ao' + id 634 | }) 635 | .append($(document.createElement('img')).attr({ 636 | src: image, 637 | alt: 'comment', 638 | title: title 639 | })) 640 | .click(function(event) { 641 | event.preventDefault(); 642 | show($(this).attr('id').substring(2)); 643 | }) 644 | ) 645 | .append( 646 | $(document.createElement('a')).attr({ 647 | href: '#', 648 | 'class': 'sphinx-comment-close hidden', 649 | id: 'ah' + id 650 | }) 651 | .append($(document.createElement('img')).attr({ 652 | src: opts.closeCommentImage, 653 | alt: 'close', 654 | title: 'close' 655 | })) 656 | .click(function(event) { 657 | event.preventDefault(); 658 | hide($(this).attr('id').substring(2)); 659 | }) 660 | ); 661 | }); 662 | }; 663 | 664 | var opts = { 665 | processVoteURL: '/_process_vote', 666 | addCommentURL: '/_add_comment', 667 | getCommentsURL: '/_get_comments', 668 | acceptCommentURL: '/_accept_comment', 669 | deleteCommentURL: '/_delete_comment', 670 | commentImage: '/static/_static/comment.png', 671 | closeCommentImage: '/static/_static/comment-close.png', 672 | loadingImage: '/static/_static/ajax-loader.gif', 673 | commentBrightImage: '/static/_static/comment-bright.png', 674 | upArrow: '/static/_static/up.png', 675 | downArrow: '/static/_static/down.png', 676 | upArrowPressed: '/static/_static/up-pressed.png', 677 | downArrowPressed: '/static/_static/down-pressed.png', 678 | voting: false, 679 | moderator: false 680 | }; 681 | 682 | if (typeof COMMENT_OPTIONS != "undefined") { 683 | opts = jQuery.extend(opts, COMMENT_OPTIONS); 684 | } 685 | 686 | var popupTemplate = '\ 687 |
    \ 688 |

    \ 689 | Sort by:\ 690 | best rated\ 691 | newest\ 692 | oldest\ 693 |

    \ 694 |
    Comments
    \ 695 |
    \ 696 | loading comments...
    \ 697 |
      \ 698 |
      \ 699 |

      Add a comment\ 700 | (markup):

      \ 701 |
      \ 702 | reStructured text markup: *emph*, **strong**, \ 703 | ``code``, \ 704 | code blocks: :: and an indented block after blank line
      \ 705 |
      \ 706 | \ 707 |

      \ 708 | \ 709 | Propose a change ▹\ 710 | \ 711 | \ 712 | Propose a change ▿\ 713 | \ 714 |

      \ 715 | \ 717 | \ 718 | \ 719 | \ 720 |
      \ 721 |
      \ 722 |
      '; 723 | 724 | var commentTemplate = '\ 725 |
      \ 726 |
      \ 727 |
      \ 728 | \ 729 | \ 730 | \ 731 | \ 732 | \ 733 | \ 734 |
      \ 735 |
      \ 736 | \ 737 | \ 738 | \ 739 | \ 740 | \ 741 | \ 742 |
      \ 743 |
      \ 744 |
      \ 745 |

      \ 746 | <%username%>\ 747 | <%pretty_rating%>\ 748 | <%time.delta%>\ 749 |

      \ 750 |
      <#text#>
      \ 751 |

      \ 752 | \ 753 | reply ▿\ 754 | proposal ▹\ 755 | proposal ▿\ 756 | \ 757 | \ 760 |

      \ 761 |
      \
      762 | <#proposal_diff#>\
      763 |         
      \ 764 |
        \ 765 |
        \ 766 |
        \ 767 |
        \ 768 | '; 769 | 770 | var replyTemplate = '\ 771 |
      • \ 772 |
        \ 773 |
        \ 774 | \ 775 | \ 776 | \ 777 | \ 778 | \ 779 |
        \ 780 |
        \ 781 |
      • '; 782 | 783 | $(document).ready(function() { 784 | init(); 785 | }); 786 | })(jQuery); 787 | 788 | $(document).ready(function() { 789 | // add comment anchors for all paragraphs that are commentable 790 | $('.sphinx-has-comment').comment(); 791 | 792 | // highlight search words in search results 793 | $("div.context").each(function() { 794 | var params = $.getQueryParameters(); 795 | var terms = (params.q) ? params.q[0].split(/\s+/) : []; 796 | var result = $(this); 797 | $.each(terms, function() { 798 | result.highlightText(this.toLowerCase(), 'highlighted'); 799 | }); 800 | }); 801 | 802 | // directly open comment window if requested 803 | var anchor = document.location.hash; 804 | if (anchor.substring(0, 9) == '#comment-') { 805 | $('#ao' + anchor.substring(9)).click(); 806 | document.location.hash = '#s' + anchor.substring(9); 807 | } 808 | }); 809 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/sphinxcontrib/websupport/py.typed -------------------------------------------------------------------------------- /sphinxcontrib/websupport/search/__init__.py: -------------------------------------------------------------------------------- 1 | """Server side search support for the web support package.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Sequence 10 | 11 | 12 | class BaseSearch: 13 | def __init__(self, path): 14 | pass 15 | 16 | def init_indexing(self, changed: Sequence[str] = ()) -> None: 17 | """Called by the builder to initialize the search indexer. `changed` 18 | is a list of pagenames that will be reindexed. You may want to remove 19 | these from the search index before indexing begins. 20 | 21 | :param changed: a list of pagenames that will be re-indexed 22 | """ 23 | 24 | def finish_indexing(self): 25 | """Called by the builder when writing has been completed. Use this 26 | to perform any finalization or cleanup actions after indexing is 27 | complete. 28 | """ 29 | 30 | def feed(self, pagename, filename, title, doctree): 31 | """Called by the builder to add a doctree to the index. Converts the 32 | `doctree` to text and passes it to :meth:`add_document`. You probably 33 | won't want to override this unless you need access to the `doctree`. 34 | Override :meth:`add_document` instead. 35 | 36 | :param pagename: the name of the page to be indexed 37 | :param filename: the name of the original source file 38 | :param title: the title of the page to be indexed 39 | :param doctree: is the docutils doctree representation of the page 40 | """ 41 | self.add_document(pagename, filename, title, doctree.astext()) 42 | 43 | def add_document(self, pagename, filename, title, text): 44 | """Called by :meth:`feed` to add a document to the search index. 45 | This method should should do everything necessary to add a single 46 | document to the search index. 47 | 48 | `pagename` is name of the page being indexed. It is the combination 49 | of the source files relative path and filename, 50 | minus the extension. For example, if the source file is 51 | "ext/builders.rst", the `pagename` would be "ext/builders". This 52 | will need to be returned with search results when processing a 53 | query. 54 | 55 | :param pagename: the name of the page being indexed 56 | :param filename: the name of the original source file 57 | :param title: the page's title 58 | :param text: the full text of the page 59 | """ 60 | raise NotImplementedError() 61 | 62 | def query(self, q): 63 | """Called by the web support api to get search results. This method 64 | compiles the regular expression to be used when :meth:`extracting 65 | context `, then calls :meth:`handle_query`. You 66 | won't want to override this unless you don't want to use the included 67 | :meth:`extract_context` method. Override :meth:`handle_query` instead. 68 | 69 | :param q: the search query string. 70 | """ 71 | self.context_re = re.compile('|'.join(q.split()), re.IGNORECASE) 72 | return self.handle_query(q) 73 | 74 | def handle_query(self, q): 75 | """Called by :meth:`query` to retrieve search results for a search 76 | query `q`. This should return an iterable containing tuples of the 77 | following format:: 78 | 79 | (, , <context>) 80 | 81 | `path` and `title` are the same values that were passed to 82 | :meth:`add_document`, and `context` should be a short text snippet 83 | of the text surrounding the search query in the document. 84 | 85 | The :meth:`extract_context` method is provided as a simple way 86 | to create the `context`. 87 | 88 | :param q: the search query 89 | """ 90 | raise NotImplementedError() 91 | 92 | def extract_context(self, text, length=240): 93 | """Extract the context for the search query from the document's 94 | full `text`. 95 | 96 | :param text: the full text of the document to create the context for 97 | :param length: the length of the context snippet to return. 98 | """ 99 | res = self.context_re.search(text) 100 | if res is None: 101 | return '' 102 | context_start = max(res.start() - int(length / 2), 0) 103 | context_end = context_start + length 104 | context = ''.join([context_start > 0 and '...' or '', 105 | text[context_start:context_end], 106 | context_end < len(text) and '...' or '']) 107 | 108 | return context 109 | 110 | def context_for_searchtool(self): 111 | """Required by the HTML builder.""" 112 | return {} 113 | 114 | def get_js_stemmer_rawcode(self): 115 | """Required by the HTML builder.""" 116 | return None 117 | 118 | 119 | # The built-in search adapters. 120 | SEARCH_ADAPTERS = { 121 | 'xapian': ('xapiansearch', 'XapianSearch'), 122 | 'whoosh': ('whooshsearch', 'WhooshSearch'), 123 | 'null': ('nullsearch', 'NullSearch'), 124 | } 125 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/search/nullsearch.py: -------------------------------------------------------------------------------- 1 | """The default search adapter, does nothing.""" 2 | 3 | from __future__ import annotations 4 | 5 | from sphinxcontrib.websupport.errors import NullSearchException 6 | from sphinxcontrib.websupport.search import BaseSearch 7 | 8 | 9 | class NullSearch(BaseSearch): 10 | """A search adapter that does nothing. Used when no search adapter 11 | is specified. 12 | """ 13 | def feed(self, pagename, filename, title, doctree): 14 | pass 15 | 16 | def query(self, q): 17 | msg = 'No search adapter specified.' 18 | raise NullSearchException(msg) 19 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/search/whooshsearch.py: -------------------------------------------------------------------------------- 1 | """Whoosh search adapter.""" 2 | 3 | from __future__ import annotations 4 | 5 | from sphinx.util.osutil import ensuredir 6 | from whoosh import index 7 | from whoosh.analysis import StemmingAnalyzer 8 | from whoosh.fields import ID, TEXT, Schema 9 | from whoosh.qparser import QueryParser 10 | 11 | from sphinxcontrib.websupport.search import BaseSearch 12 | 13 | 14 | class WhooshSearch(BaseSearch): 15 | """The whoosh search adapter for sphinx web support.""" 16 | 17 | # Define the Whoosh Schema for the search index. 18 | schema = Schema(path=ID(stored=True, unique=True), 19 | title=TEXT(field_boost=2.0, stored=True), 20 | text=TEXT(analyzer=StemmingAnalyzer(), stored=True)) 21 | 22 | def __init__(self, db_path): 23 | ensuredir(db_path) 24 | if index.exists_in(db_path): 25 | self.index = index.open_dir(db_path) 26 | else: 27 | self.index = index.create_in(db_path, schema=self.schema) 28 | self.qparser = QueryParser('text', self.schema) 29 | 30 | def init_indexing(self, changed=()): 31 | for changed_path in changed: 32 | self.index.delete_by_term('path', changed_path) 33 | self.index_writer = self.index.writer() 34 | 35 | def finish_indexing(self): 36 | self.index_writer.commit() 37 | 38 | def add_document(self, pagename, filename, title, text): 39 | self.index_writer.add_document(path=pagename, 40 | title=title, 41 | text=text) 42 | 43 | def handle_query(self, q): 44 | searcher = self.index.searcher() 45 | whoosh_results = searcher.search(self.qparser.parse(q)) 46 | results = [] 47 | for result in whoosh_results: 48 | context = self.extract_context(result['text']) 49 | results.append((result['path'], 50 | result.get('title', ''), 51 | context)) 52 | return results 53 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/search/xapiansearch.py: -------------------------------------------------------------------------------- 1 | """Xapian search adapter.""" 2 | 3 | from __future__ import annotations 4 | 5 | import xapian 6 | from sphinx.util.osutil import ensuredir 7 | 8 | from sphinxcontrib.websupport.search import BaseSearch 9 | 10 | 11 | class XapianSearch(BaseSearch): 12 | # Adapted from the GSOC 2009 webapp project. 13 | 14 | # Xapian metadata constants 15 | DOC_PATH = 0 16 | DOC_TITLE = 1 17 | 18 | def __init__(self, db_path): 19 | self.db_path = db_path 20 | 21 | def init_indexing(self, changed=()): 22 | ensuredir(self.db_path) 23 | self.database = xapian.WritableDatabase(self.db_path, 24 | xapian.DB_CREATE_OR_OPEN) 25 | self.indexer = xapian.TermGenerator() 26 | stemmer = xapian.Stem("english") 27 | self.indexer.set_stemmer(stemmer) 28 | 29 | def finish_indexing(self): 30 | # Ensure the db lock is removed. 31 | del self.database 32 | 33 | def add_document(self, pagename, filename, title, text): 34 | self.database.begin_transaction() 35 | # sphinx_page_path is used to easily retrieve documents by path. 36 | sphinx_page_path = f'"sphinxpagepath{pagename.replace("/", "_")}"' 37 | # Delete the old document if it exists. 38 | self.database.delete_document(sphinx_page_path) 39 | 40 | doc = xapian.Document() 41 | doc.set_data(text) 42 | doc.add_value(self.DOC_PATH, pagename) 43 | doc.add_value(self.DOC_TITLE, title) 44 | self.indexer.set_document(doc) 45 | self.indexer.index_text(text) 46 | doc.add_term(sphinx_page_path) 47 | for word in text.split(): 48 | doc.add_posting(word, 1) 49 | self.database.add_document(doc) 50 | self.database.commit_transaction() 51 | 52 | def handle_query(self, q): 53 | database = xapian.Database(self.db_path) 54 | enquire = xapian.Enquire(database) 55 | qp = xapian.QueryParser() 56 | stemmer = xapian.Stem("english") 57 | qp.set_stemmer(stemmer) 58 | qp.set_database(database) 59 | qp.set_stemming_strategy(xapian.QueryParser.STEM_SOME) 60 | query = qp.parse_query(q) 61 | 62 | # Find the top 100 results for the query. 63 | enquire.set_query(query) 64 | matches = enquire.get_mset(0, 100) 65 | 66 | results = [] 67 | 68 | for m in matches: 69 | data = m.document.get_data() 70 | if not isinstance(data, str): 71 | data = data.decode("utf-8") 72 | context = self.extract_context(data) 73 | results.append((m.document.get_value(self.DOC_PATH), 74 | m.document.get_value(self.DOC_TITLE), 75 | ''.join(context))) 76 | 77 | return results 78 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/storage/__init__.py: -------------------------------------------------------------------------------- 1 | """Storage for the websupport package.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class StorageBackend: 7 | def pre_build(self): 8 | """Called immediately before the build process begins. Use this 9 | to prepare the StorageBackend for the addition of nodes. 10 | """ 11 | 12 | def has_node(self, id): 13 | """Check to see if a node exists. 14 | 15 | :param id: the id to check for. 16 | """ 17 | raise NotImplementedError() 18 | 19 | def add_node(self, id, document, source): 20 | """Add a node to the StorageBackend. 21 | 22 | :param id: a unique id for the comment. 23 | :param document: the name of the document the node belongs to. 24 | :param source: the source files name. 25 | """ 26 | raise NotImplementedError() 27 | 28 | def post_build(self): 29 | """Called after a build has completed. Use this to finalize the 30 | addition of nodes if needed. 31 | """ 32 | 33 | def add_comment(self, text, displayed, username, time, 34 | proposal, node_id, parent_id, moderator): 35 | """Called when a comment is being added. 36 | 37 | :param text: the text of the comment 38 | :param displayed: whether the comment should be displayed 39 | :param username: the name of the user adding the comment 40 | :param time: a date object with the time the comment was added 41 | :param proposal: the text of the proposal the user made 42 | :param node_id: the id of the node that the comment is being added to 43 | :param parent_id: the id of the comment's parent comment. 44 | :param moderator: whether the user adding the comment is a moderator 45 | """ 46 | raise NotImplementedError() 47 | 48 | def delete_comment(self, comment_id, username, moderator): 49 | """Delete a comment. 50 | 51 | Raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError` 52 | if moderator is False and `username` doesn't match the username 53 | on the comment. 54 | 55 | :param comment_id: The id of the comment being deleted. 56 | :param username: The username of the user requesting the deletion. 57 | :param moderator: Whether the user is a moderator. 58 | """ 59 | raise NotImplementedError() 60 | 61 | def get_metadata(self, docname, moderator): 62 | """Get metadata for a document. This is currently just a dict 63 | of node_id's with associated comment counts. 64 | 65 | :param docname: the name of the document to get metadata for. 66 | :param moderator: whether the requester is a moderator. 67 | """ 68 | raise NotImplementedError() 69 | 70 | def get_data(self, node_id, username, moderator): 71 | """Called to retrieve all data for a node. This should return a 72 | dict with two keys, *source* and *comments* as described by 73 | :class:`~sphinxcontrib.websupport.WebSupport`'s 74 | :meth:`~sphinxcontrib.websupport.WebSupport.get_data` method. 75 | 76 | :param node_id: The id of the node to get data for. 77 | :param username: The name of the user requesting the data. 78 | :param moderator: Whether the requestor is a moderator. 79 | """ 80 | raise NotImplementedError() 81 | 82 | def process_vote(self, comment_id, username, value): 83 | """Process a vote that is being cast. `value` will be either -1, 0, 84 | or 1. 85 | 86 | :param comment_id: The id of the comment being voted on. 87 | :param username: The username of the user casting the vote. 88 | :param value: The value of the vote being cast. 89 | """ 90 | raise NotImplementedError() 91 | 92 | def update_username(self, old_username, new_username): 93 | """If a user is allowed to change their username this method should 94 | be called so that there is not stagnate data in the storage system. 95 | 96 | :param old_username: The username being changed. 97 | :param new_username: What the username is being changed to. 98 | """ 99 | raise NotImplementedError() 100 | 101 | def accept_comment(self, comment_id): 102 | """Called when a moderator accepts a comment. After the method is 103 | called the comment should be displayed to all users. 104 | 105 | :param comment_id: The id of the comment being accepted. 106 | """ 107 | raise NotImplementedError() 108 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/storage/differ.py: -------------------------------------------------------------------------------- 1 | """A differ for creating an HTML representations of proposal diffs.""" 2 | 3 | from __future__ import annotations 4 | 5 | import html 6 | import re 7 | from difflib import Differ 8 | 9 | 10 | class CombinedHtmlDiff: 11 | """Create an HTML representation of the differences between two pieces 12 | of text. 13 | """ 14 | highlight_regex = re.compile(r'([\+\-\^]+)') 15 | 16 | def __init__(self, source, proposal): 17 | proposal = html.escape(proposal) 18 | 19 | differ = Differ() 20 | self.diff: list[str] = list(differ.compare( 21 | source.splitlines(keepends=True), 22 | proposal.splitlines(keepends=True), 23 | )) 24 | 25 | def make_text(self) -> str: 26 | return '\n'.join(self.diff) 27 | 28 | def make_html(self) -> str: 29 | """Return the HTML representation of the differences between 30 | `source` and `proposal`. 31 | 32 | :param source: the original text 33 | :param proposal: the proposed text 34 | """ 35 | html = [] 36 | diff = self.diff[:] 37 | line = diff.pop(0) 38 | next = diff.pop(0) 39 | while True: 40 | html.append(self._handle_line(line, next)) 41 | line = next 42 | try: 43 | next = diff.pop(0) 44 | except IndexError: 45 | html.append(self._handle_line(line)) 46 | break 47 | return ''.join(html).rstrip() 48 | 49 | def _handle_line(self, line: str, next: str | None = None) -> str: 50 | """Handle an individual line in a diff.""" 51 | prefix = line[0] 52 | text = line[2:] 53 | 54 | if prefix == ' ': 55 | return text 56 | elif prefix == '?': 57 | return '' 58 | 59 | if next is not None and next[0] == '?': 60 | tag = prefix == '+' and 'ins' or 'del' 61 | text = self._highlight_text(text, next, tag) 62 | css_class = prefix == '+' and 'prop-added' or 'prop-removed' 63 | 64 | return f'<span class="{css_class}">{text.rstrip()}</span>\n' 65 | 66 | def _highlight_text(self, text: str, next: str, tag: str) -> str: 67 | """Highlight the specific changes made to a line by adding 68 | <ins> and <del> tags. 69 | """ 70 | next = next[2:] 71 | new_text: list[str] = [] 72 | start = 0 73 | for match in self.highlight_regex.finditer(next): 74 | new_text.extend(( 75 | text[start:match.start()], 76 | f'<{tag}>', 77 | text[match.start():match.end()], 78 | f'</{tag}>', 79 | )) 80 | start = match.end() 81 | new_text.append(text[start:]) 82 | return ''.join(new_text) 83 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/storage/sqlalchemy_db.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy table and mapper definitions used by the 3 | :py:class:`sphinxcontrib.websupport.storage.sqlalchemystorage.SQLAlchemyStorage`. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from datetime import datetime 9 | 10 | from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text 11 | from sqlalchemy.orm import aliased, declarative_base, relationship, sessionmaker 12 | from sqlalchemy.sql.expression import true 13 | 14 | Base = declarative_base() 15 | Session = sessionmaker() 16 | 17 | db_prefix = "sphinx_" 18 | 19 | 20 | class Node(Base): # type: ignore[misc,valid-type] 21 | """Data about a Node in a doctree.""" 22 | 23 | __tablename__ = db_prefix + "nodes" 24 | 25 | id = Column(String(32), primary_key=True) 26 | document = Column(String(256), nullable=False) 27 | source = Column(Text, nullable=False) 28 | 29 | def nested_comments(self, username, moderator): 30 | """Create a tree of comments. First get all comments that are 31 | descendants of this node, then convert them to a tree form. 32 | 33 | :param username: the name of the user to get comments for. 34 | :param moderator: whether the user is moderator. 35 | """ 36 | session = Session() 37 | 38 | if username: 39 | # If a username is provided, create a subquery to retrieve all 40 | # votes by this user. We will outerjoin with the comment query 41 | # with this subquery so we have a user's voting information. 42 | sq = session.query(CommentVote).filter(CommentVote.username == username).subquery() 43 | cvalias = aliased(CommentVote, sq) 44 | q = session.query(Comment, cvalias.value).outerjoin(cvalias) 45 | else: 46 | # If a username is not provided, we don't need to join with 47 | # CommentVote. 48 | q = session.query(Comment) 49 | 50 | # Filter out all comments not descending from this node. 51 | q = q.filter(Comment.path.like(str(self.id) + ".%")) 52 | 53 | # Filter out all comments that are not moderated yet. 54 | if not moderator: 55 | q = q.filter(Comment.displayed == true()) 56 | 57 | # Retrieve all results. Results must be ordered by Comment.path 58 | # so that we can easily transform them from a flat list to a tree. 59 | results = q.order_by(Comment.path).all() 60 | session.close() 61 | 62 | return self._nest_comments(results, username) 63 | 64 | def _nest_comments(self, results, username): 65 | """Given the flat list of results, convert the list into a 66 | tree. 67 | 68 | :param results: the flat list of comments 69 | :param username: the name of the user requesting the comments. 70 | """ 71 | comments: list[dict] = [] 72 | list_stack: list[list[dict]] = [comments] 73 | for r in results: 74 | if username: 75 | comment, vote = r 76 | else: 77 | comment, vote = (r, 0) 78 | 79 | inheritance_chain = comment.path.split(".")[1:] 80 | 81 | if len(inheritance_chain) == len(list_stack) + 1: 82 | parent = list_stack[-1][-1] 83 | list_stack.append(parent["children"]) 84 | elif len(inheritance_chain) < len(list_stack): 85 | while len(inheritance_chain) < len(list_stack): 86 | list_stack.pop() 87 | 88 | list_stack[-1].append(comment.serializable(vote=vote)) 89 | 90 | return comments 91 | 92 | def __init__(self, id, document, source): 93 | self.id = id 94 | self.document = document 95 | self.source = source 96 | 97 | 98 | class CommentVote(Base): # type: ignore[misc,valid-type] 99 | """A vote a user has made on a Comment.""" 100 | 101 | __tablename__ = db_prefix + "commentvote" 102 | 103 | username = Column(String(64), primary_key=True) 104 | comment_id = Column(Integer, ForeignKey(db_prefix + "comments.id"), primary_key=True) 105 | # -1 if downvoted, +1 if upvoted, 0 if voted then unvoted. 106 | value = Column(Integer, nullable=False) 107 | 108 | def __init__(self, comment_id, username, value): 109 | self.comment_id = comment_id 110 | self.username = username 111 | self.value = value 112 | 113 | 114 | class Comment(Base): # type: ignore[misc,valid-type] 115 | """An individual Comment being stored.""" 116 | 117 | __tablename__ = db_prefix + "comments" 118 | 119 | id = Column(Integer, primary_key=True) 120 | rating = Column(Integer, nullable=False) 121 | time = Column(DateTime, nullable=False) 122 | text = Column(Text, nullable=False) 123 | displayed = Column(Boolean, index=True, default=False) 124 | username = Column(String(64)) 125 | proposal = Column(Text) 126 | proposal_diff = Column(Text) 127 | path = Column(String(256), index=True) 128 | 129 | node_id = Column(String(32), ForeignKey(db_prefix + "nodes.id")) 130 | node = relationship(Node, backref="comments") 131 | 132 | votes = relationship(CommentVote, backref="comment", cascade="all") 133 | 134 | def __init__(self, text, displayed, username, rating, time, proposal, proposal_diff): 135 | self.text = text 136 | self.displayed = displayed 137 | self.username = username 138 | self.rating = rating 139 | self.time = time 140 | self.proposal = proposal 141 | self.proposal_diff = proposal_diff 142 | 143 | def set_path(self, node_id, parent_id): 144 | """Set the materialized path for this comment.""" 145 | # This exists because the path can't be set until the session has 146 | # been flushed and this Comment has an id. 147 | if node_id: 148 | self.node_id = node_id 149 | self.path = f"{node_id}.{self.id}" 150 | else: 151 | session = Session() 152 | parent_path = ( 153 | session.query(Comment.path).filter(Comment.id == parent_id).one().path 154 | ) 155 | session.close() 156 | self.node_id = parent_path.split(".")[0] 157 | self.path = f"{parent_path}.{self.id}" 158 | 159 | def serializable(self, vote=0): 160 | """Creates a serializable representation of the comment. This is 161 | converted to JSON, and used on the client side. 162 | """ 163 | delta = datetime.now() - self.time # noqa: DTZ005 164 | 165 | time = { 166 | "year": self.time.year, 167 | "month": self.time.month, 168 | "day": self.time.day, 169 | "hour": self.time.hour, 170 | "minute": self.time.minute, 171 | "second": self.time.second, 172 | "iso": self.time.isoformat(), 173 | "delta": self.pretty_delta(delta), 174 | } 175 | 176 | path = self.path.split(".") 177 | node = path[0] 178 | parent = path[-2] if len(path) > 2 else None 179 | 180 | return { 181 | "text": self.text, 182 | "username": self.username or "Anonymous", 183 | "id": self.id, 184 | "node": node, 185 | "parent": parent, 186 | "rating": self.rating, 187 | "displayed": self.displayed, 188 | "age": delta.seconds, 189 | "time": time, 190 | "vote": vote or 0, 191 | "proposal_diff": self.proposal_diff, 192 | "children": [], 193 | } 194 | 195 | def pretty_delta(self, delta): 196 | """Create a pretty representation of the Comment's age. 197 | (e.g. 2 minutes). 198 | """ 199 | days = delta.days 200 | seconds = delta.seconds 201 | hours = seconds / 3600 202 | minutes = seconds / 60 203 | 204 | if days == 0: 205 | dt = (minutes, "minute") if hours == 0 else (hours, "hour") 206 | else: 207 | dt = (days, "day") 208 | 209 | s = "" if dt[0] == 1 else "s" 210 | ret = f"{dt} {dt}{s} ago" 211 | 212 | return ret 213 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/storage/sqlalchemystorage.py: -------------------------------------------------------------------------------- 1 | """An SQLAlchemy storage backend.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | 7 | import sqlalchemy 8 | from sqlalchemy.orm import aliased 9 | from sqlalchemy.sql import func 10 | 11 | from sphinxcontrib.websupport.errors import CommentNotAllowedError, UserNotAuthorizedError 12 | from sphinxcontrib.websupport.storage import StorageBackend 13 | from sphinxcontrib.websupport.storage.differ import CombinedHtmlDiff 14 | from sphinxcontrib.websupport.storage.sqlalchemy_db import ( 15 | Base, 16 | Comment, 17 | CommentVote, 18 | Node, 19 | Session, 20 | ) 21 | 22 | if sqlalchemy.__version__[:3] < '1.4': 23 | msg = ( 24 | 'SQLAlchemy version 1.4 or greater is required for this storage backend; ' 25 | f'you have version {sqlalchemy.__version__}' 26 | ) 27 | raise ImportError(msg) 28 | 29 | 30 | class SQLAlchemyStorage(StorageBackend): 31 | """ 32 | A :class:`.StorageBackend` using SQLAlchemy. 33 | """ 34 | 35 | def __init__(self, uri): 36 | self.engine = sqlalchemy.create_engine(uri) 37 | Base.metadata.bind = self.engine 38 | Base.metadata.create_all(bind=self.engine) 39 | Session.configure(bind=self.engine) 40 | 41 | def pre_build(self): 42 | self.build_session = Session() 43 | 44 | def has_node(self, id): 45 | node = self.build_session.query(Node).filter(Node.id == id).first() 46 | return bool(node) 47 | 48 | def add_node(self, id, document, source): 49 | node = Node(id, document, source) 50 | self.build_session.add(node) 51 | self.build_session.flush() 52 | 53 | def post_build(self): 54 | self.build_session.commit() 55 | self.build_session.close() 56 | 57 | def add_comment(self, text, displayed, username, time, 58 | proposal, node_id, parent_id, moderator): 59 | session = Session() 60 | proposal_diff = None 61 | proposal_diff_text = None 62 | 63 | if node_id and proposal: 64 | node = session.query(Node).filter(Node.id == node_id).one() 65 | differ = CombinedHtmlDiff(node.source, proposal) 66 | proposal_diff = differ.make_html() 67 | proposal_diff_text = differ.make_text() 68 | elif parent_id: 69 | parent = session.query(Comment.displayed).filter(Comment.id == parent_id).one() 70 | if not parent.displayed: 71 | msg = "Can't add child to a parent that is not displayed" 72 | raise CommentNotAllowedError(msg) 73 | 74 | comment = Comment(text, displayed, username, 0, 75 | time or datetime.now(), proposal, proposal_diff) # noqa: DTZ005 76 | session.add(comment) 77 | session.flush() 78 | # We have to flush the session before setting the path so the 79 | # Comment has an id. 80 | comment.set_path(node_id, parent_id) 81 | session.commit() 82 | d = comment.serializable() 83 | d['document'] = comment.node.document 84 | d['proposal_diff_text'] = proposal_diff_text 85 | session.close() 86 | return d 87 | 88 | def delete_comment(self, comment_id, username, moderator): 89 | session = Session() 90 | comment = session.query(Comment).\ 91 | filter(Comment.id == comment_id).one() 92 | if moderator: 93 | # moderator mode: delete the comment and all descendants 94 | # find descendants via path 95 | session.query(Comment).filter( 96 | Comment.path.like(comment.path + '.%')).delete(False) 97 | session.delete(comment) 98 | session.commit() 99 | session.close() 100 | return True 101 | elif comment.username == username: 102 | # user mode: do not really delete, but remove text and proposal 103 | comment.username = '[deleted]' 104 | comment.text = '[deleted]' 105 | comment.proposal = '' 106 | session.commit() 107 | session.close() 108 | return False 109 | else: 110 | session.close() 111 | raise UserNotAuthorizedError() 112 | 113 | def get_metadata(self, docname, moderator): 114 | session = Session() 115 | subquery = session.query( 116 | Comment.node_id, 117 | func.count('*').label('comment_count')).group_by( 118 | Comment.node_id).subquery() 119 | nodes = session.query(Node.id, subquery.c.comment_count).outerjoin( 120 | subquery, Node.id == subquery.c.node_id).filter( 121 | Node.document == docname) 122 | session.close() 123 | session.commit() 124 | return {k: v or 0 for k, v in nodes} 125 | 126 | def get_data(self, node_id, username, moderator): 127 | session = Session() 128 | node = session.query(Node).filter(Node.id == node_id).one() 129 | session.close() 130 | comments = node.nested_comments(username, moderator) 131 | return {'source': node.source, 132 | 'comments': comments} 133 | 134 | def process_vote(self, comment_id, username, value): 135 | session = Session() 136 | 137 | subquery = session.query(CommentVote).filter( 138 | CommentVote.username == username).subquery() 139 | vote_alias = aliased(CommentVote, subquery) 140 | q = session.query(Comment, vote_alias).outerjoin(vote_alias).filter( 141 | Comment.id == comment_id) 142 | comment, vote = q.one() 143 | 144 | if vote is None: 145 | vote = CommentVote(comment_id, username, value) 146 | comment.rating += value 147 | else: 148 | comment.rating += value - vote.value 149 | vote.value = value 150 | 151 | session.add(vote) 152 | session.commit() 153 | session.close() 154 | 155 | def update_username(self, old_username, new_username): 156 | session = Session() 157 | 158 | session.query(Comment).filter(Comment.username == old_username).\ 159 | update({Comment.username: new_username}) 160 | session.query(CommentVote).\ 161 | filter(CommentVote.username == old_username).\ 162 | update({CommentVote.username: new_username}) 163 | 164 | session.commit() 165 | session.close() 166 | 167 | def accept_comment(self, comment_id): 168 | session = Session() 169 | session.query(Comment).filter(Comment.id == comment_id).update( 170 | {Comment.displayed: True}, 171 | ) 172 | 173 | session.commit() 174 | session.close() 175 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/templates/searchresults.html: -------------------------------------------------------------------------------- 1 | {# Template for the body of the search results page. #} 2 | <h1 id="search-documentation">{{ _('Search') }}</h1> 3 | <p> 4 | From here you can search these documents. Enter your search 5 | words into the box below and click "search". 6 | </p> 7 | <form action="" method="get"> 8 | <input type="text" name="q" value="" /> 9 | <input type="submit" value="{{ _('search') }}" /> 10 | <span id="search-progress" style="padding-left: 10px"></span> 11 | </form> 12 | {%- if search_performed %} 13 | <h2>{{ _('Search Results') }}</h2> 14 | {%- if not search_results %} 15 | <p>{{ _('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.') }}</p> 16 | {%- endif %} 17 | {%- endif %} 18 | <div id="search-results"> 19 | {%- if search_results %} 20 | <ul class="search"> 21 | {% for href, caption, context in search_results %} 22 | <li><a href="{{ docroot }}{{ href }}/?highlight={{ q }}">{{ caption }}</a> 23 | <div class="context">{{ context|e }}</div> 24 | </li> 25 | {% endfor %} 26 | </ul> 27 | {%- endif %} 28 | </div> 29 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from docutils import nodes 7 | 8 | 9 | def is_commentable(node: nodes.Node) -> bool: 10 | # return node.__class__.__name__ in ('paragraph', 'literal_block') 11 | return node.__class__.__name__ == 'paragraph' 12 | -------------------------------------------------------------------------------- /sphinxcontrib/websupport/writer.py: -------------------------------------------------------------------------------- 1 | """websupport writer that adds comment-related annotations.""" 2 | 3 | from __future__ import annotations 4 | 5 | from sphinx.writers.html import HTMLTranslator 6 | 7 | from sphinxcontrib.websupport.utils import is_commentable 8 | 9 | 10 | class WebSupportTranslator(HTMLTranslator): 11 | """ 12 | Our custom HTML translator. 13 | """ 14 | 15 | def __init__(self, builder, *args, **kwargs): 16 | HTMLTranslator.__init__(self, builder, *args, **kwargs) 17 | self.comment_class = 'sphinx-has-comment' 18 | 19 | def dispatch_visit(self, node): 20 | if is_commentable(node) and hasattr(node, 'uid'): 21 | self.handle_visit_commentable(node) 22 | HTMLTranslator.dispatch_visit(self, node) 23 | 24 | def handle_visit_commentable(self, node): 25 | # We will place the node in the HTML id attribute. If the node 26 | # already has an id (for indexing purposes) put an empty 27 | # span with the existing id directly before this node's HTML. 28 | self.add_db_node(node) 29 | if node.attributes['ids']: 30 | self.body.append(f'<span id="{node.attributes["ids"][0]}"></span>') 31 | node.attributes['ids'] = [f's{node.uid}'] 32 | node.attributes['classes'].append(self.comment_class) 33 | 34 | def add_db_node(self, node): 35 | storage = self.builder.storage # type: ignore[attr-defined] 36 | if not storage.has_node(node.uid): 37 | storage.add_node(id=node.uid, 38 | document=self.builder.current_docname, 39 | source=node.rawsource or node.astext()) 40 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | pytest_plugins = ( 8 | 'sphinx.testing.fixtures', 9 | ) 10 | 11 | 12 | @pytest.fixture(scope='session') # type: ignore[misc] 13 | def rootdir() -> Path: 14 | return Path(__file__).resolve().parent / 'roots' 15 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | 8 | # Internal variables. 9 | ALLSPHINXOPTS = -d _build/doctrees $(SPHINXOPTS) . 10 | 11 | .PHONY: help clean html web pickle htmlhelp latex changes linkcheck 12 | 13 | help: 14 | @echo "Please use \`make <target>' where <target> is one of" 15 | @echo " html to make standalone HTML files" 16 | @echo " pickle to make pickle files (usable by e.g. sphinx-web)" 17 | @echo " htmlhelp to make HTML files and an HTML help project" 18 | @echo " latex to make LaTeX files" 19 | @echo " changes to make an overview over all changed/added/deprecated items" 20 | @echo " linkcheck to check all external links for integrity" 21 | 22 | clean: 23 | rm -rf _build/* 24 | 25 | html: 26 | mkdir -p _build/html _build/doctrees 27 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html 28 | @echo 29 | @echo "Build finished. The HTML pages are in _build/html." 30 | 31 | pickle: 32 | mkdir -p _build/pickle _build/doctrees 33 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle 34 | @echo 35 | @echo "Build finished; now you can process the pickle files or run" 36 | @echo " sphinx-web _build/pickle" 37 | @echo "to start the sphinx-web server." 38 | 39 | web: pickle 40 | 41 | htmlhelp: 42 | mkdir -p _build/htmlhelp _build/doctrees 43 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp 44 | @echo 45 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 46 | ".hhp project file in _build/htmlhelp." 47 | 48 | latex: 49 | mkdir -p _build/latex _build/doctrees 50 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex 51 | @echo 52 | @echo "Build finished; the LaTeX files are in _build/latex." 53 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 54 | "run these through (pdf)latex." 55 | 56 | changes: 57 | mkdir -p _build/changes _build/doctrees 58 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes 59 | @echo 60 | @echo "The overview file is in _build/changes." 61 | 62 | linkcheck: 63 | mkdir -p _build/linkcheck _build/doctrees 64 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck 65 | @echo 66 | @echo "Link check complete; look for any errors in the above output " \ 67 | "or in _build/linkcheck/output.txt." 68 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_static/README: -------------------------------------------------------------------------------- 1 | This whole directory is there to test html_static_path. 2 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_static/excluded.css: -------------------------------------------------------------------------------- 1 | /* This file should be excluded from being copied over */ 2 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_static/subdir/foo.css: -------------------------------------------------------------------------------- 1 | /* Stub file */ 2 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_templates/contentssb.html: -------------------------------------------------------------------------------- 1 | {# sidebar only for contents document #} 2 | <h4>Contents sidebar</h4> 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_templates/customsb.html: -------------------------------------------------------------------------------- 1 | {# custom sidebar template #} 2 | <h4>Custom sidebar</h4> 3 | 4 | {{ toctree(titles_only=True, maxdepth=1) }} 5 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block extrahead %} 4 | {# html_context variable from conf.py #} 5 | <meta name="hc" content="{{ hckey }}" /> 6 | {# html_context variable from confoverrides (as if given on cmdline) #} 7 | <meta name="hc_co" content="{{ hckey_co }}" /> 8 | {{ super() }} 9 | {% endblock %} 10 | 11 | {% block sidebartoc %} 12 | {# display global TOC in addition to local TOC #} 13 | {{ super() }} 14 | {{ toctree(collapse=False, maxdepth=-1) }} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/autodoc_fodder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class MarkupError: 5 | """ 6 | .. note:: This is a docstring with a 7 | small markup error which should have 8 | correct location information. 9 | """ 10 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/autodoc_missing_imports.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from missing_module import missing_name 4 | from missing_package3.missing_module3 import missing_name # NoQA: F811 5 | 6 | 7 | @missing_name 8 | def decoratedFunction(): 9 | """decoratedFunction docstring""" 10 | return None 11 | 12 | 13 | class TestAutodoc: 14 | """TestAutodoc docstring.""" 15 | @missing_name 16 | def decoratedMethod(self): 17 | """TestAutodoc::decoratedMethod docstring""" 18 | return None 19 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/bom.po: -------------------------------------------------------------------------------- 1 | #, fuzzy 2 | msgid "" 3 | msgstr "" 4 | "MIME-Version: 1.0\n" 5 | "Content-Type: text/plain; charset=UTF-8\n" 6 | "Content-Transfer-Encoding: 8bit\n" 7 | 8 | msgid "File with UTF-8 BOM" 9 | msgstr "Datei mit UTF-8" 10 | 11 | msgid "This file has a UTF-8 \"BOM\"." 12 | msgstr "This file has umlauts: äöü." 13 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/bom.txt: -------------------------------------------------------------------------------- 1 | File with UTF-8 BOM 2 | =================== 3 | 4 | This file has a UTF-8 "BOM". 5 | 6 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | from docutils import nodes 8 | from docutils.parsers.rst import Directive, directives 9 | from sphinx import addnodes 10 | 11 | if TYPE_CHECKING: 12 | from sphinx.application import Sphinx 13 | from sphinx.util.typing import ExtensionMetadata 14 | 15 | sys.path.append(os.path.abspath('.')) 16 | 17 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.jsmath', 'sphinx.ext.todo', 18 | 'sphinx.ext.coverage', 'sphinx.ext.extlinks', 'ext'] 19 | 20 | jsmath_path = 'dummy.js' 21 | 22 | templates_path = ['_templates'] 23 | 24 | master_doc = 'contents' 25 | source_suffix = ['.txt', '.add', '.foo'] 26 | 27 | project = 'Sphinx <Tests>' 28 | copyright = '2010-2016, Georg Brandl & Team' 29 | # If this is changed, remember to update the versionchanges! 30 | version = '0.6' 31 | release = '0.6alpha1' 32 | today_fmt = '%B %d, %Y' 33 | exclude_patterns = ['_build', '**/excluded.*'] 34 | keep_warnings = True 35 | pygments_style = 'sphinx' 36 | show_authors = True 37 | numfig = True 38 | 39 | rst_epilog = '.. |subst| replace:: global substitution' 40 | 41 | html_theme = 'testtheme' 42 | html_theme_path = ['.'] 43 | html_theme_options = {'testopt': 'testoverride'} 44 | html_sidebars = {'**': ['customsb.html'], 45 | 'contents': ['contentssb.html', 'localtoc.html', 46 | 'globaltoc.html']} 47 | html_style = 'default.css' 48 | html_static_path = ['_static', 'templated.css_t'] 49 | html_extra_path = ['robots.txt'] 50 | html_last_updated_fmt = '%b %d, %Y' 51 | html_context = {'hckey': 'hcval', 'hckey_co': 'wrong_hcval_co'} 52 | 53 | htmlhelp_basename = 'SphinxTestsdoc' 54 | 55 | applehelp_bundle_id = 'org.sphinx-doc.Sphinx.help' 56 | applehelp_disable_external_tools = True 57 | 58 | latex_documents = [ 59 | ('contents', 'SphinxTests.tex', 'Sphinx Tests Documentation', 60 | 'Georg Brandl \\and someone else', 'manual'), 61 | ] 62 | 63 | latex_additional_files = ['svgimg.svg'] 64 | 65 | texinfo_documents = [ 66 | ('contents', 'SphinxTests', 'Sphinx Tests', 67 | 'Georg Brandl \\and someone else', 'Sphinx Testing', 'Miscellaneous'), 68 | ] 69 | 70 | man_pages = [ 71 | ('contents', 'SphinxTests', 'Sphinx Tests Documentation', 72 | 'Georg Brandl and someone else', 1), 73 | ] 74 | 75 | value_from_conf_py = 84 76 | 77 | coverage_c_path = ['special/*.h'] 78 | coverage_c_regexes = {'function': r'^PyAPI_FUNC\(.*\)\s+([^_][\w_]+)'} 79 | 80 | extlinks = {'issue': ('http://bugs.python.org/issue%s', 'issue '), 81 | 'pyurl': ('http://python.org/%s', None)} 82 | 83 | autodoc_mock_imports = [ 84 | 'missing_module', 85 | 'missing_package1.missing_module1', 86 | 'missing_package2.missing_module2', 87 | 'missing_package3.missing_module3', 88 | ] 89 | 90 | # modify tags from conf.py 91 | tags.add('confpytag') # NoQA: F821 92 | 93 | 94 | # -- extension API 95 | def userdesc_parse(env, sig, signode): 96 | x, y = sig.split(':') 97 | signode += addnodes.desc_name(x, x) 98 | signode += addnodes.desc_parameterlist() 99 | signode[-1] += addnodes.desc_parameter(y, y) 100 | return x 101 | 102 | 103 | class ClassDirective(Directive): 104 | option_spec = { 105 | 'opt': directives.unchanged, 106 | } 107 | 108 | def run(self): 109 | return [nodes.strong(text=f'from class: {self.options["opt"]}')] 110 | 111 | 112 | def setup(app: Sphinx) -> ExtensionMetadata: 113 | app.add_config_value('value_from_conf_py', 42, False) 114 | app.add_directive('clsdir', ClassDirective) 115 | app.add_object_type('userdesc', 'userdescrole', '%s (userdesc)', 116 | userdesc_parse, objname='user desc') 117 | app.add_js_file('file://moo.js') 118 | return {} 119 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/contents.txt: -------------------------------------------------------------------------------- 1 | .. Sphinx Tests documentation master file, created by sphinx-quickstart on Wed Jun 4 23:49:58 2008. 2 | You can adapt this file completely to your liking, but it should at least 3 | contain the root `toctree` directive. 4 | 5 | Welcome to Sphinx Tests's documentation! 6 | ======================================== 7 | 8 | Contents: 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :numbered: 13 | :caption: Table of Contents 14 | :name: mastertoc 15 | 16 | extapi 17 | images 18 | subdir/images 19 | subdir/includes 20 | includes 21 | markup 22 | objects 23 | bom 24 | math 25 | autodoc 26 | metadata 27 | extensions 28 | extensions 29 | footnote 30 | lists 31 | 32 | http://sphinx-doc.org/ 33 | Latest reference <http://sphinx-doc.org/latest/> 34 | Python <http://python.org/> 35 | 36 | self 37 | 38 | Indices and tables 39 | ================== 40 | 41 | * :ref:`genindex` 42 | * :ref:`modindex` 43 | * :ref:`search` 44 | 45 | References 46 | ========== 47 | 48 | .. [Ref1] Reference target. 49 | .. [Ref_1] Reference target 2. 50 | 51 | Test for issue #1157 52 | ==================== 53 | 54 | This used to crash: 55 | 56 | .. toctree:: 57 | 58 | .. toctree:: 59 | :hidden: 60 | 61 | Test for issue #1700 62 | ==================== 63 | 64 | :ref:`mastertoc` 65 | 66 | Test for indirect hyperlink targets 67 | =================================== 68 | 69 | :ref:`indirect hyperref <other-label>` 70 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/en.lproj/localized.txt: -------------------------------------------------------------------------------- 1 | This file should be included in the final bundle by the applehelp builder. 2 | It should be ignored by other builders. 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/ext.py: -------------------------------------------------------------------------------- 1 | # Test extension module 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from sphinx.application import Sphinx 9 | from sphinx.util.typing import ExtensionMetadata 10 | 11 | 12 | def setup(app: Sphinx) -> ExtensionMetadata: 13 | app.add_config_value('value_from_ext', [], False) 14 | return {} 15 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/extapi.txt: -------------------------------------------------------------------------------- 1 | Extension API tests 2 | =================== 3 | 4 | Testing directives: 5 | 6 | .. clsdir:: 7 | :opt: Bar 8 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/extensions.txt: -------------------------------------------------------------------------------- 1 | Test for diverse extensions 2 | =========================== 3 | 4 | extlinks 5 | -------- 6 | 7 | Test diverse links: :issue:`1000` and :pyurl:`dev/`, also with 8 | :issue:`explicit caption <1042>`. 9 | 10 | 11 | todo 12 | ---- 13 | 14 | .. todo:: 15 | 16 | Test the todo extension. 17 | 18 | .. todo:: 19 | 20 | Test with |sub| (see #286). 21 | 22 | .. |sub| replace:: substitution references 23 | 24 | 25 | list of all todos 26 | ^^^^^^^^^^^^^^^^^ 27 | 28 | .. todolist:: 29 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/footnote.txt: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Testing footnote and citation 4 | ================================ 5 | .. #1058 footnote-backlinks-do-not-work 6 | 7 | numbered footnote 8 | -------------------- 9 | 10 | [1]_ 11 | 12 | auto-numbered footnote 13 | ------------------------------ 14 | 15 | [#]_ 16 | 17 | named footnote 18 | -------------------- 19 | 20 | [#foo]_ 21 | 22 | citation 23 | -------------------- 24 | 25 | [bar]_ 26 | 27 | footnotes in table 28 | -------------------- 29 | 30 | .. list-table:: Table caption [#]_ 31 | :header-rows: 1 32 | 33 | * - name [#]_ 34 | - desription 35 | * - VIDIOC_CROPCAP 36 | - Information about VIDIOC_CROPCAP [#]_ 37 | 38 | footenotes 39 | -------------------- 40 | 41 | .. rubric:: Footnotes 42 | 43 | .. [1] numbered 44 | 45 | .. [#] auto numbered 46 | 47 | .. [#foo] named 48 | 49 | .. rubric:: Citations 50 | 51 | .. [bar] cite 52 | 53 | .. [#] footnote in table caption 54 | 55 | .. [#] footnote in table header 56 | 57 | .. [#] footnote in table not in header 58 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/images.txt: -------------------------------------------------------------------------------- 1 | Sphinx image handling 2 | ===================== 3 | 4 | .. first, a simple test with direct filename 5 | .. image:: img.png 6 | 7 | .. a non-existing image with direct filename 8 | .. image:: foo.png 9 | 10 | .. an image with path name (relative to this directory!) 11 | .. image:: subdir/img.png 12 | :height: 100 13 | :width: 200 14 | 15 | .. an image with unspecified extension 16 | .. image:: img.* 17 | 18 | .. a non-local image URI 19 | .. image:: https://www.python.org/static/img/python-logo.png 20 | 21 | .. an image with subdir and unspecified extension 22 | .. image:: subdir/simg.* 23 | 24 | .. an SVG image (for HTML at least) 25 | .. image:: svgimg.* 26 | 27 | .. an image with more than 1 dot in its file name 28 | .. image:: img.foo.png 29 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/img.foo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/img.foo.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/img.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/img.gif -------------------------------------------------------------------------------- /tests/roots/test-root/root/img.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/img.pdf -------------------------------------------------------------------------------- /tests/roots/test-root/root/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/img.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/includes.txt: -------------------------------------------------------------------------------- 1 | Testing downloadable files 2 | ========================== 3 | 4 | Download :download:`img.png` here. 5 | Download :download:`this <subdir/img.png>` there. 6 | Don't download :download:`this <nonexisting.png>`. 7 | 8 | Test file and literal inclusion 9 | =============================== 10 | 11 | .. include:: subdir/include.inc 12 | 13 | .. include:: /subdir/include.inc 14 | 15 | .. literalinclude:: literal.inc 16 | :language: python 17 | 18 | .. should succeed 19 | .. literalinclude:: wrongenc.inc 20 | :encoding: latin-1 21 | :language: none 22 | .. include:: wrongenc.inc 23 | :encoding: latin-1 24 | 25 | Literalinclude options 26 | ====================== 27 | 28 | .. highlight:: text 29 | 30 | .. cssclass:: inc-pyobj1 31 | .. literalinclude:: literal.inc 32 | :pyobject: Foo 33 | 34 | .. cssclass:: inc-pyobj2 35 | .. literalinclude:: literal.inc 36 | :pyobject: Bar.baz 37 | 38 | .. cssclass:: inc-lines 39 | .. literalinclude:: literal.inc 40 | :lines: 6-7,9 41 | :lineno-start: 6 42 | 43 | .. cssclass:: inc-startend 44 | .. literalinclude:: literal.inc 45 | :start-after: coding: utf-8 46 | :end-before: class Foo 47 | 48 | .. cssclass:: inc-preappend 49 | .. literalinclude:: literal.inc 50 | :prepend: START CODE 51 | :append: END CODE 52 | 53 | .. literalinclude:: literal.inc 54 | :start-after: utf-8 55 | 56 | .. literalinclude:: literal.inc 57 | :end-before: class Foo 58 | 59 | .. literalinclude:: literal.inc 60 | :diff: literal_orig.inc 61 | 62 | .. cssclass:: inc-tab3 63 | .. literalinclude:: tabs.inc 64 | :tab-width: 3 65 | :language: text 66 | 67 | .. cssclass:: inc-tab8 68 | .. literalinclude:: tabs.inc 69 | :tab-width: 8 70 | :language: python 71 | 72 | .. cssclass:: inc-pyobj-lines-match 73 | .. literalinclude:: literal.inc 74 | :pyobject: Foo 75 | :lineno-match: 76 | 77 | .. cssclass:: inc-lines-match 78 | .. literalinclude:: literal.inc 79 | :lines: 6-7,8 80 | :lineno-match: 81 | 82 | .. cssclass:: inc-startend-match 83 | .. literalinclude:: literal.inc 84 | :start-after: coding: utf-8 85 | :end-before: class Foo 86 | :lineno-match: 87 | 88 | Test if dedenting before parsing works. 89 | 90 | .. highlight:: python 91 | 92 | .. cssclass:: inc-pyobj-dedent 93 | .. literalinclude:: literal.inc 94 | :pyobject: Bar.baz 95 | 96 | Docutils include with "literal" 97 | =============================== 98 | 99 | While not recommended, it should work (and leave quotes alone). 100 | 101 | .. include:: quotes.inc 102 | :literal: 103 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/lists.txt: -------------------------------------------------------------------------------- 1 | Various kinds of lists 2 | ====================== 3 | 4 | 5 | nested enumerated lists 6 | ----------------------- 7 | 8 | #. one 9 | 10 | #. two 11 | 12 | #. two.1 13 | #. two.2 14 | 15 | #. three 16 | 17 | 18 | enumerated lists with non-default start values 19 | ---------------------------------------------- 20 | 21 | 0. zero 22 | #. one 23 | 24 | ---------------------------------------- 25 | 26 | 1. one 27 | #. two 28 | 29 | ---------------------------------------- 30 | 31 | 2. two 32 | #. three 33 | 34 | 35 | enumerated lists using letters 36 | ------------------------------ 37 | 38 | a. a 39 | 40 | b. b 41 | 42 | #. c 43 | 44 | #. d 45 | 46 | ---------------------------------------- 47 | 48 | x. x 49 | 50 | y. y 51 | 52 | #. z 53 | 54 | #. { 55 | 56 | definition lists 57 | ----------------- 58 | 59 | term1 60 | description 61 | 62 | term2 (**stronged partially**) 63 | description 64 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/literal.inc: -------------------------------------------------------------------------------- 1 | # Literally included file using Python highlighting 2 | # -*- coding: utf-8 -*- 3 | 4 | foo = "Including Unicode characters: üöä" 5 | 6 | class Foo: 7 | pass 8 | 9 | class Bar: 10 | def baz(): 11 | pass 12 | 13 | def bar(): pass 14 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/literal_orig.inc: -------------------------------------------------------------------------------- 1 | # Literally included file using Python highlighting 2 | # -*- coding: utf-8 -*- 3 | 4 | foo = "Including Unicode characters: üöä" # This will be changed 5 | 6 | class FooOrig: 7 | pass 8 | 9 | class BarOrig: 10 | def baz(): 11 | pass 12 | 13 | def bar(): pass 14 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/markup.txt: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. title:: set by title directive 4 | 5 | Testing various markup 6 | ====================== 7 | 8 | Meta markup 9 | ----------- 10 | 11 | .. sectionauthor:: Georg Brandl 12 | .. moduleauthor:: Georg Brandl 13 | 14 | .. contents:: TOC 15 | 16 | .. meta:: 17 | :author: Me 18 | :keywords: docs, sphinx 19 | 20 | 21 | Generic reST 22 | ------------ 23 | 24 | A |subst| (the definition is in rst_epilog). 25 | 26 | .. highlight:: none 27 | 28 | .. _label: 29 | 30 | :: 31 | 32 | some code 33 | 34 | Option list: 35 | 36 | -h help 37 | --help also help 38 | 39 | Line block: 40 | 41 | | line1 42 | | line2 43 | | line3 44 | | line4 45 | | line5 46 | | line6 47 | | line7 48 | 49 | 50 | Body directives 51 | ^^^^^^^^^^^^^^^ 52 | 53 | .. topic:: Title 54 | 55 | Topic body. 56 | 57 | .. sidebar:: Sidebar 58 | :subtitle: Sidebar subtitle 59 | 60 | Sidebar body. 61 | 62 | .. rubric:: Test rubric 63 | 64 | .. epigraph:: Epigraph title 65 | 66 | Epigraph body. 67 | 68 | -- Author 69 | 70 | .. highlights:: Highlights 71 | 72 | Highlights body. 73 | 74 | .. pull-quote:: Pull-quote 75 | 76 | Pull quote body. 77 | 78 | .. compound:: 79 | 80 | a 81 | 82 | b 83 | 84 | .. parsed-literal:: 85 | 86 | with some *markup* inside 87 | 88 | 89 | .. _admonition-section: 90 | 91 | Admonitions 92 | ^^^^^^^^^^^ 93 | 94 | .. admonition:: My Admonition 95 | 96 | Admonition text. 97 | 98 | .. note:: 99 | Note text. 100 | 101 | .. warning:: 102 | 103 | Warning text. 104 | 105 | .. _some-label: 106 | 107 | .. tip:: 108 | Tip text. 109 | 110 | Indirect hyperlink targets 111 | 112 | .. _other-label: some-label_ 113 | 114 | Inline markup 115 | ------------- 116 | 117 | *Generic inline markup* 118 | 119 | Adding \n to test unescaping. 120 | 121 | * :command:`command\\n` 122 | * :dfn:`dfn\\n` 123 | * :guilabel:`guilabel with &accelerator and \\n` 124 | * :kbd:`kbd\\n` 125 | * :mailheader:`mailheader\\n` 126 | * :makevar:`makevar\\n` 127 | * :manpage:`manpage\\n` 128 | * :mimetype:`mimetype\\n` 129 | * :newsgroup:`newsgroup\\n` 130 | * :program:`program\\n` 131 | * :regexp:`regexp\\n` 132 | * :menuselection:`File --> Close\\n` 133 | * :menuselection:`&File --> &Print` 134 | * :file:`a/{varpart}/b\\n` 135 | * :samp:`print {i}\\n` 136 | 137 | *Linking inline markup* 138 | 139 | * :pep:`8` 140 | * :pep:`Python Enhancement Proposal #8 <8>` 141 | * :rfc:`1` 142 | * :rfc:`Request for Comments #1 <1>` 143 | * :envvar:`HOME` 144 | * :keyword:`with` 145 | * :token:`try statement <try_stmt>` 146 | * :ref:`admonition-section` 147 | * :ref:`here <some-label>` 148 | * :ref:`there <other-label>` 149 | * :ref:`my-figure` 150 | * :ref:`my-figure-name` 151 | * :ref:`my-table` 152 | * :ref:`my-table-name` 153 | * :ref:`my-code-block` 154 | * :ref:`my-code-block-name` 155 | * :numref:`my-figure` 156 | * :numref:`my-figure-name` 157 | * :numref:`my-table` 158 | * :numref:`my-table-name` 159 | * :numref:`my-code-block` 160 | * :numref:`my-code-block-name` 161 | * :doc:`subdir/includes` 162 | * ``:download:`` is tested in includes.txt 163 | * :option:`Python -c option <python -c>` 164 | 165 | Test :abbr:`abbr (abbreviation)` and another :abbr:`abbr (abbreviation)`. 166 | 167 | Testing the :index:`index` role, also available with 168 | :index:`explicit <pair: title; explicit>` title. 169 | 170 | .. _with: 171 | 172 | With 173 | ---- 174 | 175 | (Empty section.) 176 | 177 | 178 | Tables 179 | ------ 180 | 181 | .. tabularcolumns:: |L|p{5cm}|R| 182 | 183 | .. _my-table: 184 | 185 | .. table:: my table 186 | :name: my-table-name 187 | 188 | +----+----------------+----+ 189 | | 1 | * Block elems | x | 190 | | | * In table | | 191 | +----+----------------+----+ 192 | | 2 | Empty cells: | | 193 | +----+----------------+----+ 194 | 195 | .. table:: empty cell in table header 196 | 197 | ===== ====== 198 | \ 199 | ===== ====== 200 | 1 2 201 | 3 4 202 | ===== ====== 203 | 204 | Tables with multirow and multicol: 205 | 206 | .. only:: latex 207 | 208 | +----+----------------+---------+ 209 | | 1 | test! | c | 210 | +----+---------+------+ | 211 | | 2 | col | col | | 212 | | y +---------+------+----+----+ 213 | | x | multi-column cell | x | 214 | +----+---------------------+----+ 215 | 216 | +----+ 217 | | 1 | 218 | + + 219 | | | 220 | +----+ 221 | 222 | .. list-table:: 223 | :header-rows: 0 224 | 225 | * - .. figure:: img.png 226 | 227 | figure in table 228 | 229 | 230 | Figures 231 | ------- 232 | 233 | .. _my-figure: 234 | 235 | .. figure:: img.png 236 | :name: my-figure-name 237 | 238 | My caption of the figure 239 | 240 | My description paragraph of the figure. 241 | 242 | Description paragraph is wraped with legend node. 243 | 244 | .. figure:: rimg.png 245 | :align: right 246 | 247 | figure with align option 248 | 249 | .. figure:: rimg.png 250 | :align: right 251 | :figwidth: 50% 252 | 253 | figure with align & figwidth option 254 | 255 | .. figure:: rimg.png 256 | :align: right 257 | :width: 3cm 258 | 259 | figure with align & width option 260 | 261 | Version markup 262 | -------------- 263 | 264 | .. versionadded:: 0.6 265 | Some funny **stuff**. 266 | 267 | .. versionchanged:: 0.6 268 | Even more funny stuff. 269 | 270 | .. deprecated:: 0.6 271 | Boring stuff. 272 | 273 | .. versionadded:: 1.2 274 | 275 | First paragraph of versionadded. 276 | 277 | .. versionchanged:: 1.2 278 | First paragraph of versionchanged. 279 | 280 | Second paragraph of versionchanged. 281 | 282 | 283 | Code blocks 284 | ----------- 285 | 286 | .. _my-code-block: 287 | 288 | .. code-block:: ruby 289 | :linenos: 290 | :caption: my ruby code 291 | :name: my-code-block-name 292 | 293 | def ruby? 294 | false 295 | end 296 | 297 | Misc stuff 298 | ---------- 299 | 300 | Stuff [#]_ 301 | 302 | Reference lookup: [Ref1]_ (defined in another file). 303 | Reference lookup underscore: [Ref_1]_ 304 | 305 | .. seealso:: something, something else, something more 306 | 307 | `Google <http://www.google.com>`_ 308 | For everything. 309 | 310 | .. hlist:: 311 | :columns: 4 312 | 313 | * This 314 | * is 315 | * a horizontal 316 | * list 317 | * with several 318 | * items 319 | 320 | .. rubric:: Side note 321 | 322 | This is a side note. 323 | 324 | This tests :CLASS:`role names in uppercase`. 325 | 326 | .. centered:: LICENSE AGREEMENT 327 | 328 | .. acks:: 329 | 330 | * Terry Pratchett 331 | * J. R. R. Tolkien 332 | * Monty Python 333 | 334 | .. glossary:: 335 | :sorted: 336 | 337 | boson 338 | Particle with integer spin. 339 | 340 | *fermion* 341 | Particle with half-integer spin. 342 | 343 | tauon 344 | myon 345 | electron 346 | Examples for fermions. 347 | 348 | über 349 | Gewisse 350 | 351 | änhlich 352 | Dinge 353 | 354 | .. productionlist:: 355 | try_stmt: `try1_stmt` | `try2_stmt` 356 | try1_stmt: "try" ":" `suite` 357 | : ("except" [`expression` ["," `target`]] ":" `suite`)+ 358 | : ["else" ":" `suite`] 359 | : ["finally" ":" `suite`] 360 | try2_stmt: "try" ":" `suite` 361 | : "finally" ":" `suite` 362 | 363 | 364 | Index markup 365 | ------------ 366 | 367 | .. index:: 368 | single: entry 369 | pair: entry; pair 370 | double: entry; double 371 | triple: index; entry; triple 372 | keyword: with 373 | see: from; to 374 | seealso: fromalso; toalso 375 | 376 | .. index:: 377 | !Main, !Other 378 | !single: entry; pair 379 | 380 | :index:`!Main` 381 | 382 | .. _ölabel: 383 | 384 | Ö... Some strange characters 385 | ---------------------------- 386 | 387 | Testing öäü... 388 | 389 | 390 | Only directive 391 | -------------- 392 | 393 | .. only:: html 394 | 395 | In HTML. 396 | 397 | .. only:: latex 398 | 399 | In LaTeX. 400 | 401 | .. only:: html or latex 402 | 403 | In both. 404 | 405 | .. only:: confpytag and (testtag or nonexisting_tag) 406 | 407 | Always present, because set through conf.py/command line. 408 | 409 | 410 | Any role 411 | -------- 412 | 413 | .. default-role:: any 414 | 415 | Test referencing to `headings <with>` and `objects <func_without_body>`. 416 | Also `modules <mod>` and `classes <Time>`. 417 | 418 | More domains: 419 | 420 | * `JS <bar.baz>` 421 | * `C <SphinxType>` 422 | * `myobj` (user markup) 423 | * `n::Array` 424 | * `perl -c` 425 | 426 | .. default-role:: 427 | 428 | 429 | .. rubric:: Footnotes 430 | 431 | .. [#] Like footnotes. 432 | 433 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/math.txt: -------------------------------------------------------------------------------- 1 | Test math extensions :math:`E = m c^2` 2 | ====================================== 3 | 4 | This is inline math: :math:`a^2 + b^2 = c^2`. 5 | 6 | .. math:: a^2 + b^2 = c^2 7 | 8 | .. math:: 9 | 10 | a + 1 < b 11 | 12 | .. math:: 13 | :label: foo 14 | 15 | e^{i\pi} = 1 16 | 17 | .. math:: 18 | :label: 19 | 20 | e^{ix} = \cos x + i\sin x 21 | 22 | .. math:: 23 | 24 | n \in \mathbb N 25 | 26 | .. math:: 27 | :nowrap: 28 | 29 | a + 1 < b 30 | 31 | Referencing equation :eq:`foo`. 32 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/metadata.add: -------------------------------------------------------------------------------- 1 | :Author: David Goodger 2 | :Address: 123 Example Street 3 | Example, EX Canada 4 | A1B 2C3 5 | :Contact: goodger@python.org 6 | :Authors: Me; Myself; I 7 | :organization: humankind 8 | :date: $Date: 2006-05-21 22:44:42 +0200 (Son, 21 Mai 2006) $ 9 | :status: This is a "work in progress" 10 | :revision: $Revision: 4564 $ 11 | :version: 1 12 | :copyright: This document has been placed in the public domain. You 13 | may do with it as you wish. You may copy, modify, 14 | redistribute, reattribute, sell, buy, rent, lease, 15 | destroy, or improve it, quote it at length, excerpt, 16 | incorporate, collate, fold, staple, or mutilate it, or do 17 | anything else to it that your or anyone else's heart 18 | desires. 19 | :field name: This is a generic bibliographic field. 20 | :field name 2: 21 | Generic bibliographic fields may contain multiple body elements. 22 | 23 | Like this. 24 | 25 | :Dedication: 26 | 27 | For Docutils users & co-developers. 28 | 29 | :abstract: 30 | 31 | This document is a demonstration of the reStructuredText markup 32 | language, containing examples of all basic reStructuredText 33 | constructs and many advanced constructs. 34 | 35 | :nocomments: 36 | :orphan: 37 | :tocdepth: 1 38 | 39 | .. meta:: 40 | :keywords: reStructuredText, demonstration, demo, parser 41 | :description lang=en: A demonstration of the reStructuredText 42 | markup language, containing examples of all basic 43 | constructs and many advanced constructs. 44 | 45 | ================================ 46 | reStructuredText Demonstration 47 | ================================ 48 | 49 | .. Above is the document title, and below is the subtitle. 50 | They are transformed from section titles after parsing. 51 | 52 | -------------------------------- 53 | Examples of Syntax Constructs 54 | -------------------------------- 55 | 56 | .. bibliographic fields (which also require a transform): 57 | 58 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/objects.txt: -------------------------------------------------------------------------------- 1 | Testing object descriptions 2 | =========================== 3 | 4 | .. function:: func_without_module(a, b, *c[, d]) 5 | 6 | Does something. 7 | 8 | .. function:: func_without_body() 9 | 10 | .. function:: func_with_unknown_field() 11 | 12 | : : 13 | 14 | : empty field name: 15 | 16 | :field_name: 17 | 18 | :field_name all lower: 19 | 20 | :FIELD_NAME: 21 | 22 | :FIELD_NAME ALL CAPS: 23 | 24 | :Field_Name: 25 | 26 | :Field_Name All Word Caps: 27 | 28 | :Field_name: 29 | 30 | :Field_name First word cap: 31 | 32 | :FIELd_name: 33 | 34 | :FIELd_name PARTial caps: 35 | 36 | .. function:: func_noindex 37 | :noindex: 38 | 39 | .. function:: func_with_module 40 | :module: foolib 41 | 42 | Referring to :func:`func with no index <func_noindex>`. 43 | Referring to :func:`nothing <>`. 44 | 45 | .. module:: mod 46 | :synopsis: Module synopsis. 47 | :platform: UNIX 48 | 49 | .. function:: func_in_module 50 | 51 | .. class:: Cls 52 | 53 | .. method:: meth1 54 | 55 | .. staticmethod:: meths 56 | 57 | .. attribute:: attr 58 | 59 | .. explicit class given 60 | .. method:: Cls.meth2 61 | 62 | .. explicit module given 63 | .. exception:: Error(arg1, arg2) 64 | :module: errmod 65 | 66 | .. data:: var 67 | 68 | 69 | .. currentmodule:: None 70 | 71 | .. function:: func_without_module2() -> annotation 72 | 73 | .. object:: long(parameter, \ 74 | list) 75 | another one 76 | 77 | .. class:: TimeInt 78 | 79 | Has only one parameter (triggers special behavior...) 80 | 81 | :param moo: |test| 82 | :type moo: |test| 83 | 84 | .. |test| replace:: Moo 85 | 86 | .. class:: Time(hour, minute, isdst) 87 | 88 | :param year: The year. 89 | :type year: TimeInt 90 | :param TimeInt minute: The minute. 91 | :param isdst: whether it's DST 92 | :type isdst: * some complex 93 | * expression 94 | :returns: a new :class:`Time` instance 95 | :rtype: Time 96 | :raises Error: if the values are out of range 97 | :ivar int hour: like *hour* 98 | :ivar minute: like *minute* 99 | :vartype minute: int 100 | :param hour: Some parameter 101 | :type hour: DuplicateType 102 | :param hour: Duplicate param. Should not lead to crashes. 103 | :type hour: DuplicateType 104 | :param .Cls extcls: A class from another module. 105 | 106 | 107 | C items 108 | ======= 109 | 110 | .. c:function:: Sphinx_DoSomething() 111 | 112 | .. c:member:: SphinxStruct.member 113 | 114 | .. c:macro:: SPHINX_USE_PYTHON 115 | 116 | .. c:type:: SphinxType 117 | 118 | .. c:var:: sphinx_global 119 | 120 | 121 | Javascript items 122 | ================ 123 | 124 | .. js:function:: foo() 125 | 126 | .. js:data:: bar 127 | 128 | .. documenting the method of any object 129 | .. js:function:: bar.baz(href, callback[, errback]) 130 | 131 | :param string href: The location of the resource. 132 | :param callback: Get's called with the data returned by the resource. 133 | :throws InvalidHref: If the `href` is invalid. 134 | :returns: `undefined` 135 | 136 | .. js:attribute:: bar.spam 137 | 138 | References 139 | ========== 140 | 141 | Referencing :class:`mod.Cls` or :Class:`mod.Cls` should be the same. 142 | 143 | With target: :c:func:`Sphinx_DoSomething()` (parentheses are handled), 144 | :c:member:`SphinxStruct.member`, :c:macro:`SPHINX_USE_PYTHON`, 145 | :c:type:`SphinxType *` (pointer is handled), :c:data:`sphinx_global`. 146 | 147 | Without target: :c:func:`CFunction`. :c:func:`!malloc`. 148 | 149 | :js:func:`foo()` 150 | :js:func:`foo` 151 | 152 | :js:data:`bar` 153 | :js:func:`bar.baz()` 154 | :js:func:`bar.baz` 155 | :js:func:`~bar.baz()` 156 | 157 | :js:attr:`bar.baz` 158 | 159 | 160 | Others 161 | ====== 162 | 163 | .. envvar:: HOME 164 | 165 | .. program:: python 166 | 167 | .. cmdoption:: -c command 168 | 169 | .. program:: perl 170 | 171 | .. cmdoption:: -c 172 | 173 | .. option:: +p 174 | 175 | .. option:: --ObjC++ 176 | 177 | .. option:: --plugin.option 178 | 179 | .. option:: create-auth-token 180 | 181 | .. option:: arg 182 | 183 | Link to :option:`perl +p`, :option:`--ObjC++`, :option:`--plugin.option`, :option:`create-auth-token` and :option:`arg` 184 | 185 | .. program:: hg 186 | 187 | .. option:: commit 188 | 189 | .. program:: git commit 190 | 191 | .. option:: -p 192 | 193 | Link to :option:`hg commit` and :option:`git commit -p`. 194 | 195 | 196 | User markup 197 | =========== 198 | 199 | .. userdesc:: myobj:parameter 200 | 201 | Description of userdesc. 202 | 203 | 204 | Referencing :userdescrole:`myobj`. 205 | 206 | 207 | CPP domain 208 | ========== 209 | 210 | .. cpp:class:: n::Array 211 | 212 | .. cpp:function:: T& operator[]( unsigned j ) 213 | const T& operator[]( unsigned j ) const 214 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/parsermod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from docutils import nodes 4 | from docutils.parsers import Parser 5 | 6 | 7 | class Parser(Parser): 8 | def parse(self, input, document): 9 | section = nodes.section(ids=['id1']) 10 | section += nodes.title('Generated section', 'Generated section') 11 | document += section 12 | 13 | def get_transforms(self): 14 | return [] 15 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/quotes.inc: -------------------------------------------------------------------------------- 1 | Testing "quotes" in literal 'included' text. 2 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/rimg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/rimg.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /cgi-bin/ 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/special/api.h: -------------------------------------------------------------------------------- 1 | PyAPI_FUNC(PyObject *) Py_SphinxTest(); 2 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/special/code.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | print("line 1") 4 | print("line 2") 5 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir.po: -------------------------------------------------------------------------------- 1 | #, fuzzy 2 | msgid "" 3 | msgstr "" 4 | "MIME-Version: 1.0\n" 5 | "Content-Type: text/plain; charset=UTF-8\n" 6 | "Content-Transfer-Encoding: 8bit\n" 7 | 8 | msgid "Including in subdir" 9 | msgstr "translation" 10 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/excluded.txt: -------------------------------------------------------------------------------- 1 | Excluded file -- should *not* be read as source 2 | ----------------------------------------------- 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/images.txt: -------------------------------------------------------------------------------- 1 | Image including source in subdir 2 | ================================ 3 | 4 | .. image:: img.* 5 | 6 | .. image:: /rimg.png 7 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/subdir/img.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/include.inc: -------------------------------------------------------------------------------- 1 | .. This file is included by contents.txt. 2 | 3 | .. Paths in included files are relative to the file that 4 | includes them 5 | .. image:: ../root/img.png 6 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/includes.txt: -------------------------------------------------------------------------------- 1 | Including in subdir 2 | =================== 3 | 4 | .. absolute filename 5 | .. literalinclude:: /special/code.py 6 | :lines: 1 7 | 8 | .. relative filename 9 | .. literalinclude:: ../special/code.py 10 | :lines: 2 11 | 12 | Absolute :download:`/img.png` download. 13 | 14 | .. absolute image filename 15 | .. image:: /img.png 16 | 17 | .. absolute include filename 18 | .. include:: /test.inc 19 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/subdir/simg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/subdir/simg.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/svgimg.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/svgimg.pdf -------------------------------------------------------------------------------- /tests/roots/test-root/root/svgimg.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg 3 | xmlns:dc="http://purl.org/dc/elements/1.1/" 4 | xmlns:cc="http://web.resource.org/cc/" 5 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 6 | xmlns:svg="http://www.w3.org/2000/svg" 7 | xmlns="http://www.w3.org/2000/svg" 8 | xmlns:xlink="http://www.w3.org/1999/xlink" 9 | xmlns:sodipodi="http://inkscape.sourceforge.net/DTD/sodipodi-0.dtd" 10 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 | height="60" 12 | width="60" 13 | _SVGFile__filename="oldscale/apps/warning.svg" 14 | version="1.0" 15 | y="0" 16 | x="0" 17 | id="svg1" 18 | sodipodi:version="0.32" 19 | inkscape:version="0.41" 20 | sodipodi:docname="exclamation.svg" 21 | sodipodi:docbase="/home/danny/work/icons/primary/scalable/actions"> 22 | <sodipodi:namedview 23 | id="base" 24 | pagecolor="#ffffff" 25 | bordercolor="#666666" 26 | borderopacity="1.0" 27 | inkscape:pageopacity="0.0000000" 28 | inkscape:pageshadow="2" 29 | inkscape:zoom="7.5136000" 30 | inkscape:cx="42.825186" 31 | inkscape:cy="24.316071" 32 | inkscape:window-width="1020" 33 | inkscape:window-height="691" 34 | inkscape:window-x="0" 35 | inkscape:window-y="0" 36 | inkscape:current-layer="svg1" /> 37 | <defs 38 | id="defs3"> 39 | <linearGradient 40 | id="linearGradient1160"> 41 | <stop 42 | style="stop-color: #000000;stop-opacity: 1.0;" 43 | id="stop1161" 44 | offset="0" /> 45 | <stop 46 | style="stop-color:#ffffff;stop-opacity:1;" 47 | id="stop1162" 48 | offset="1" /> 49 | </linearGradient> 50 | <linearGradient 51 | xlink:href="#linearGradient1160" 52 | id="linearGradient1163" /> 53 | </defs> 54 | <metadata 55 | id="metadata12"> 56 | <RDF 57 | id="RDF13"> 58 | <Work 59 | about="" 60 | id="Work14"> 61 | <title 62 | id="title15">Part of the Flat Icon Collection (Thu Aug 26 14:31:40 2004) 63 | 65 | 67 | 69 |
      • 71 | 72 | 73 | 75 | 78 | 80 | </Agent> 81 | </publisher> 82 | <creator 83 | id="creator24"> 84 | <Agent 85 | about="" 86 | id="Agent25"> 87 | <title 88 | id="title26">Danny Allen 89 | 90 | 91 | 93 | 96 | Danny Allen 98 | 99 | 100 | 102 | image/svg+xml 104 | 107 | 110 | 112 | 113 | en 115 | 116 | 117 | 119 | 122 | image/svg+xml 124 | 127 | 128 | 129 | 130 | 132 | 136 | 140 | 144 | 149 | 153 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/tabs.inc: -------------------------------------------------------------------------------- 1 | Tabs include file test 2 | ---------------------- 3 | 4 | The next line has a tab: 5 | -| |- 6 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/templated.css_t: -------------------------------------------------------------------------------- 1 | /* Stub file, templated */ 2 | {{ sphinx_version }} 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/test.inc: -------------------------------------------------------------------------------- 1 | .. This file is included from subdir/includes.txt. 2 | 3 | This is an include file. 4 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/testtheme/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "basic/layout.html" %} 2 | {% block extrahead %} 3 | 4 | {{ super() }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/testtheme/static/staticimg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/testtheme/static/staticimg.png -------------------------------------------------------------------------------- /tests/roots/test-root/root/testtheme/static/statictmpl.html_t: -------------------------------------------------------------------------------- 1 | 2 | {{ project|e }} 3 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/testtheme/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = default.css 4 | pygments_style = emacs 5 | 6 | [options] 7 | testopt = optdefault 8 | -------------------------------------------------------------------------------- /tests/roots/test-root/root/wrongenc.inc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/wrongenc.inc -------------------------------------------------------------------------------- /tests/roots/test-root/root/ziptheme.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-doc/sphinxcontrib-websupport/25dab08ff48c21a470f4b0658c2a85da17b4ad9b/tests/roots/test-root/root/ziptheme.zip -------------------------------------------------------------------------------- /tests/roots/test-searchadapters/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | master_doc = 'markup' 4 | source_suffix = '.txt' 5 | -------------------------------------------------------------------------------- /tests/roots/test-searchadapters/markup.txt: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. title:: set by title directive 4 | 5 | Testing various markup 6 | ====================== 7 | 8 | Meta markup 9 | ----------- 10 | 11 | .. sectionauthor:: Georg Brandl 12 | .. moduleauthor:: Georg Brandl 13 | 14 | .. contents:: TOC 15 | 16 | .. meta:: 17 | :author: Me 18 | :keywords: docs, sphinx 19 | 20 | 21 | Generic reST 22 | ------------ 23 | 24 | A |subst| (the definition is in rst_epilog). 25 | 26 | .. highlight:: none 27 | 28 | .. _label: 29 | 30 | :: 31 | 32 | some code 33 | 34 | Option list: 35 | 36 | -h help 37 | --help also help 38 | 39 | Line block: 40 | 41 | | line1 42 | | line2 43 | | line3 44 | | line4 45 | | line5 46 | | line6 47 | | line7 48 | 49 | 50 | Body directives 51 | ^^^^^^^^^^^^^^^ 52 | 53 | .. topic:: Title 54 | 55 | Topic body. 56 | 57 | .. sidebar:: Sidebar 58 | :subtitle: Sidebar subtitle 59 | 60 | Sidebar body. 61 | 62 | .. rubric:: Test rubric 63 | 64 | .. epigraph:: Epigraph title 65 | 66 | Epigraph body. 67 | 68 | -- Author 69 | 70 | .. highlights:: Highlights 71 | 72 | Highlights body. 73 | 74 | .. pull-quote:: Pull-quote 75 | 76 | Pull quote body. 77 | 78 | .. compound:: 79 | 80 | a 81 | 82 | b 83 | 84 | .. parsed-literal:: 85 | 86 | with some *markup* inside 87 | 88 | 89 | .. _admonition-section: 90 | 91 | Admonitions 92 | ^^^^^^^^^^^ 93 | 94 | .. admonition:: My Admonition 95 | 96 | Admonition text. 97 | 98 | .. note:: 99 | Note text. 100 | 101 | .. warning:: 102 | 103 | Warning text. 104 | 105 | .. _some-label: 106 | 107 | .. tip:: 108 | Tip text. 109 | 110 | Indirect hyperlink targets 111 | 112 | .. _other-label: some-label_ 113 | 114 | Inline markup 115 | ------------- 116 | 117 | *Generic inline markup* 118 | 119 | Adding \n to test unescaping. 120 | 121 | * :command:`command\\n` 122 | * :dfn:`dfn\\n` 123 | * :guilabel:`guilabel with &accelerator and \\n` 124 | * :kbd:`kbd\\n` 125 | * :mailheader:`mailheader\\n` 126 | * :makevar:`makevar\\n` 127 | * :manpage:`manpage\\n` 128 | * :mimetype:`mimetype\\n` 129 | * :newsgroup:`newsgroup\\n` 130 | * :program:`program\\n` 131 | * :regexp:`regexp\\n` 132 | * :menuselection:`File --> Close\\n` 133 | * :menuselection:`&File --> &Print` 134 | * :file:`a/{varpart}/b\\n` 135 | * :samp:`print {i}\\n` 136 | 137 | *Linking inline markup* 138 | 139 | * :pep:`8` 140 | * :pep:`Python Enhancement Proposal #8 <8>` 141 | * :rfc:`1` 142 | * :rfc:`Request for Comments #1 <1>` 143 | * :envvar:`HOME` 144 | * :keyword:`with` 145 | * :token:`try statement ` 146 | * :ref:`admonition-section` 147 | * :ref:`here ` 148 | * :ref:`there ` 149 | * :ref:`my-figure` 150 | * :ref:`my-figure-name` 151 | * :ref:`my-table` 152 | * :ref:`my-table-name` 153 | * :ref:`my-code-block` 154 | * :ref:`my-code-block-name` 155 | * :numref:`my-figure` 156 | * :numref:`my-figure-name` 157 | * :numref:`my-table` 158 | * :numref:`my-table-name` 159 | * :numref:`my-code-block` 160 | * :numref:`my-code-block-name` 161 | * :doc:`subdir/includes` 162 | * ``:download:`` is tested in includes.txt 163 | * :option:`Python -c option ` 164 | * This used to crash: :option:`&option` 165 | 166 | Test :abbr:`abbr (abbreviation)` and another :abbr:`abbr (abbreviation)`. 167 | 168 | Testing the :index:`index` role, also available with 169 | :index:`explicit ` title. 170 | 171 | .. _with: 172 | 173 | With 174 | ---- 175 | 176 | (Empty section.) 177 | 178 | 179 | Tables 180 | ------ 181 | 182 | .. tabularcolumns:: |L|p{5cm}|R| 183 | 184 | .. _my-table: 185 | 186 | .. table:: my table 187 | :name: my-table-name 188 | 189 | +----+----------------+----+ 190 | | 1 | * Block elems | x | 191 | | | * In table | | 192 | +----+----------------+----+ 193 | | 2 | Empty cells: | | 194 | +----+----------------+----+ 195 | 196 | .. table:: empty cell in table header 197 | 198 | ===== ====== 199 | \ 200 | ===== ====== 201 | 1 2 202 | 3 4 203 | ===== ====== 204 | 205 | Tables with multirow and multicol: 206 | 207 | .. only:: latex 208 | 209 | +----+----------------+---------+ 210 | | 1 | test! | c | 211 | +----+---------+------+ | 212 | | 2 | col | col | | 213 | | y +---------+------+----+----+ 214 | | x | multi-column cell | x | 215 | +----+---------------------+----+ 216 | 217 | +----+ 218 | | 1 | 219 | + + 220 | | | 221 | +----+ 222 | 223 | .. list-table:: 224 | :header-rows: 0 225 | 226 | * - .. figure:: img.png 227 | 228 | figure in table 229 | 230 | 231 | Figures 232 | ------- 233 | 234 | .. _my-figure: 235 | 236 | .. figure:: img.png 237 | :name: my-figure-name 238 | 239 | My caption of the figure 240 | 241 | My description paragraph of the figure. 242 | 243 | Description paragraph is wraped with legend node. 244 | 245 | .. figure:: rimg.png 246 | :align: right 247 | 248 | figure with align option 249 | 250 | .. figure:: rimg.png 251 | :align: right 252 | :figwidth: 50% 253 | 254 | figure with align & figwidth option 255 | 256 | .. figure:: rimg.png 257 | :align: right 258 | :width: 3cm 259 | 260 | figure with align & width option 261 | 262 | Version markup 263 | -------------- 264 | 265 | .. versionadded:: 0.6 266 | Some funny **stuff**. 267 | 268 | .. versionchanged:: 0.6 269 | Even more funny stuff. 270 | 271 | .. deprecated:: 0.6 272 | Boring stuff. 273 | 274 | .. versionadded:: 1.2 275 | 276 | First paragraph of versionadded. 277 | 278 | .. versionchanged:: 1.2 279 | First paragraph of versionchanged. 280 | 281 | Second paragraph of versionchanged. 282 | 283 | 284 | Code blocks 285 | ----------- 286 | 287 | .. _my-code-block: 288 | 289 | .. code-block:: ruby 290 | :linenos: 291 | :caption: my ruby code 292 | :name: my-code-block-name 293 | 294 | def ruby? 295 | false 296 | end 297 | 298 | .. code-block:: c 299 | 300 | import sys 301 | 302 | sys.stdout.write('hello world!\n') 303 | 304 | 305 | Misc stuff 306 | ---------- 307 | 308 | Stuff [#]_ 309 | 310 | Reference lookup: [Ref1]_ (defined in another file). 311 | Reference lookup underscore: [Ref_1]_ 312 | 313 | .. seealso:: something, something else, something more 314 | 315 | `Google `_ 316 | For everything. 317 | 318 | .. hlist:: 319 | :columns: 4 320 | 321 | * This 322 | * is 323 | * a horizontal 324 | * list 325 | * with several 326 | * items 327 | 328 | .. rubric:: Side note 329 | 330 | This is a side note. 331 | 332 | This tests :CLASS:`role names in uppercase`. 333 | 334 | .. centered:: LICENSE AGREEMENT 335 | 336 | .. acks:: 337 | 338 | * Terry Pratchett 339 | * J. R. R. Tolkien 340 | * Monty Python 341 | 342 | .. glossary:: 343 | :sorted: 344 | 345 | boson 346 | Particle with integer spin. 347 | 348 | *fermion* 349 | Particle with half-integer spin. 350 | 351 | tauon 352 | myon 353 | electron 354 | Examples for fermions. 355 | 356 | über 357 | Gewisse 358 | 359 | änhlich 360 | Dinge 361 | 362 | .. productionlist:: 363 | try_stmt: `try1_stmt` | `try2_stmt` 364 | try1_stmt: "try" ":" `suite` 365 | : ("except" [`expression` ["," `target`]] ":" `suite`)+ 366 | : ["else" ":" `suite`] 367 | : ["finally" ":" `suite`] 368 | try2_stmt: "try" ":" `suite` 369 | : "finally" ":" `suite` 370 | 371 | 372 | Index markup 373 | ------------ 374 | 375 | .. index:: 376 | single: entry 377 | pair: entry; pair 378 | double: entry; double 379 | triple: index; entry; triple 380 | keyword: with 381 | see: from; to 382 | seealso: fromalso; toalso 383 | 384 | Invalid index markup... 385 | 386 | .. index:: 387 | single: 388 | pair: 389 | keyword: 390 | 391 | .. index:: 392 | !Main, !Other 393 | !single: entry; pair 394 | 395 | :index:`!Main` 396 | 397 | .. _ölabel: 398 | 399 | Ö... Some strange characters 400 | ---------------------------- 401 | 402 | Testing öäü... 403 | 404 | 405 | Only directive 406 | -------------- 407 | 408 | .. only:: html 409 | 410 | In HTML. 411 | 412 | .. only:: latex 413 | 414 | In LaTeX. 415 | 416 | .. only:: html or latex 417 | 418 | In both. 419 | 420 | .. only:: confpytag and (testtag or nonexisting_tag) 421 | 422 | Always present, because set through conf.py/command line. 423 | 424 | 425 | Any role 426 | -------- 427 | 428 | .. default-role:: any 429 | 430 | Test referencing to `headings ` and `objects `. 431 | Also `modules ` and `classes