├── src └── sphinx_substitution_extensions │ ├── py.typed │ ├── spelling_private_dict.txt │ ├── shared.py │ └── __init__.py ├── .git_archival.txt ├── sample └── source │ ├── one.rst │ ├── two.rst │ ├── Talya.txt │ ├── five.rst │ ├── four.rst │ ├── three.rst │ ├── __init__.py │ ├── Eleanor.txt │ ├── sample_include.txt │ ├── Talya_diagram.png │ ├── sample_image.png │ ├── Eleanor_diagram.png │ ├── conf.py │ ├── index.rst │ └── markdown_sample.md ├── .gitattributes ├── tests ├── __init__.py ├── conftest.py └── test_substitution_extensions.py ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── spelling_private_dict.txt ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-merge.yml │ ├── ci.yml │ └── release.yml ├── LICENSE ├── CONTRIBUTING.rst ├── .gitignore ├── CHANGELOG.rst ├── README.rst ├── .pre-commit-config.yaml └── pyproject.toml /src/sphinx_substitution_extensions/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | ref-names: HEAD -> main 2 | -------------------------------------------------------------------------------- /sample/source/one.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | One 3 | ===== 4 | -------------------------------------------------------------------------------- /sample/source/two.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Two 3 | ===== 4 | -------------------------------------------------------------------------------- /sample/source/Talya.txt: -------------------------------------------------------------------------------- 1 | This is a text file for Talya. 2 | -------------------------------------------------------------------------------- /sample/source/five.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Five 3 | ======= 4 | -------------------------------------------------------------------------------- /sample/source/four.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Four 3 | ======= 4 | -------------------------------------------------------------------------------- /sample/source/three.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Three 3 | ======= 4 | -------------------------------------------------------------------------------- /sample/source/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Documentation. 3 | """ 4 | -------------------------------------------------------------------------------- /src/sphinx_substitution_extensions/spelling_private_dict.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample/source/Eleanor.txt: -------------------------------------------------------------------------------- 1 | This is a downloadable text document. 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the substitution extension. 3 | """ 4 | -------------------------------------------------------------------------------- /sample/source/sample_include.txt: -------------------------------------------------------------------------------- 1 | This is a sample file for demonstrating literalinclude. 2 | The author is |author|. 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.python" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /sample/source/Talya_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamtheturtle/sphinx-substitution-extensions/HEAD/sample/source/Talya_diagram.png -------------------------------------------------------------------------------- /sample/source/sample_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamtheturtle/sphinx-substitution-extensions/HEAD/sample/source/sample_image.png -------------------------------------------------------------------------------- /sample/source/Eleanor_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamtheturtle/sphinx-substitution-extensions/HEAD/sample/source/Eleanor_diagram.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["*.yaml", "*.yml"], 5 | "options": { 6 | "singleQuote": true 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spelling_private_dict.txt: -------------------------------------------------------------------------------- 1 | admin 2 | beartype 3 | changelog 4 | conf 5 | hardcoded 6 | inline 7 | linters 8 | py 9 | pyright 10 | pytest 11 | reportUnknownMemberType 12 | reportUnknownParameterType 13 | reportUnknownVariableType 14 | rst 15 | str 16 | tuple 17 | whitespace 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: pip 6 | directory: / 7 | schedule: 8 | interval: daily 9 | open-pull-requests-limit: 10 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: daily 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": "explicit" 5 | }, 6 | "editor.defaultFormatter": "charliermarsh.ruff", 7 | "editor.formatOnSave": true 8 | }, 9 | "python.testing.pytestArgs": [ 10 | "." 11 | ], 12 | "python.testing.unittestEnabled": false, 13 | "python.testing.pytestEnabled": true 14 | } 15 | -------------------------------------------------------------------------------- /sample/source/conf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sample ``conf.py``. 3 | """ 4 | 5 | extensions = [ 6 | "myst_parser", 7 | "sphinx_substitution_extensions", 8 | "sphinx_toolbox.rest_example", 9 | ] 10 | 11 | rst_prolog = """ 12 | .. |author| replace:: Eleanor 13 | .. |MixedCaseReplacement| replace:: UnusedReplacement 14 | """ 15 | 16 | myst_enable_extensions = ["substitution"] 17 | myst_substitutions = { 18 | "author": "Talya", 19 | } 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for pytest. 3 | """ 4 | 5 | import pytest 6 | from beartype import beartype 7 | 8 | pytest_plugins = "sphinx.testing.fixtures" # pylint: disable=invalid-name 9 | 10 | 11 | def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: 12 | """ 13 | Apply the beartype decorator to all collected test functions. 14 | """ 15 | for item in items: 16 | # All our tests are functions, for now 17 | assert isinstance(item, pytest.Function) 18 | item.obj = beartype(obj=item.obj) 19 | -------------------------------------------------------------------------------- /src/sphinx_substitution_extensions/shared.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants and functions shared between modules. 3 | """ 4 | 5 | # This is hardcoded in doc8 as a valid option so be wary that changing this 6 | # may break doc8 linting. 7 | # See https://github.com/PyCQA/doc8/pull/34. 8 | SUBSTITUTION_OPTION_NAME = "substitutions" 9 | 10 | CONTENT_SUBSTITUTION_OPTION_NAME = "content-substitutions" 11 | PATH_SUBSTITUTION_OPTION_NAME = "path-substitutions" 12 | NO_SUBSTITUTION_OPTION_NAME = "nosubstitutions" 13 | NO_CONTENT_SUBSTITUTION_OPTION_NAME = "nocontent-substitutions" 14 | NO_PATH_SUBSTITUTION_OPTION_NAME = "nopath-substitutions" 15 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-merge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Dependabot auto-merge 4 | on: pull_request 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | dependabot: 12 | runs-on: ubuntu-latest 13 | if: github.actor == 'dependabot[bot]' 14 | steps: 15 | - name: Dependabot metadata 16 | id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: ${{ secrets.GITHUB_TOKEN }} 20 | - name: Enable auto-merge for Dependabot PRs 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adam Dangoor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions to this repository must pass tests and linting. 5 | 6 | CI is the canonical source of truth. 7 | 8 | Install contribution dependencies 9 | --------------------------------- 10 | 11 | Install Python dependencies in a virtual environment. 12 | 13 | .. code-block:: shell 14 | 15 | pip install --editable '.[dev]' 16 | 17 | Spell checking requires ``enchant``. 18 | This can be installed on macOS, for example, with `Homebrew`_: 19 | 20 | .. code-block:: shell 21 | 22 | brew install enchant 23 | 24 | and on Ubuntu with ``apt``: 25 | 26 | .. code-block:: shell 27 | 28 | apt-get install -y enchant 29 | 30 | Install ``pre-commit`` hooks: 31 | 32 | .. code-block:: shell 33 | 34 | pre-commit install 35 | 36 | Linting 37 | ------- 38 | 39 | Run lint tools either by committing, or with: 40 | 41 | .. code-block:: shell 42 | 43 | pre-commit run --all-files --hook-stage pre-commit --verbose 44 | pre-commit run --all-files --hook-stage pre-push --verbose 45 | pre-commit run --all-files --hook-stage manual --verbose 46 | 47 | .. _Homebrew: https://brew.sh 48 | 49 | Running tests 50 | ------------- 51 | 52 | Run ``pytest``: 53 | 54 | .. code-block:: shell 55 | 56 | pytest 57 | 58 | Continuous integration 59 | ---------------------- 60 | 61 | Tests are run on GitHub Actions. 62 | The configuration for this is in ```.github/workflows/``. 63 | 64 | Release Process 65 | --------------- 66 | 67 | Outcomes 68 | ~~~~~~~~ 69 | 70 | * A new ``git`` tag available to install. 71 | * A new package on PyPI. 72 | 73 | Perform a Release 74 | ~~~~~~~~~~~~~~~~~ 75 | 76 | #. `Install GitHub CLI`_. 77 | 78 | #. Perform a release: 79 | 80 | .. code-block:: shell 81 | 82 | $ gh workflow run release.yml --repo adamtheturtle/sphinx-substitution-extensions 83 | 84 | .. _Install GitHub CLI: https://cli.github.com/manual/installation 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # direnv file 94 | .envrc 95 | 96 | # IDEA ide 97 | .idea/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | 112 | # setuptools_scm 113 | src/*/_setuptools_scm_version.txt 114 | 115 | uv.lock 116 | 117 | # Ignore Mac DS_Store files 118 | .DS_Store 119 | **/.DS_Store 120 | -------------------------------------------------------------------------------- /sample/source/index.rst: -------------------------------------------------------------------------------- 1 | Samples for substitution directives and roles 2 | ============================================= 3 | 4 | Configuration 5 | ------------- 6 | 7 | .. literalinclude:: conf.py 8 | :language: python 9 | 10 | ``code-block`` 11 | -------------- 12 | 13 | .. rest-example:: 14 | 15 | .. code-block:: shell 16 | 17 | echo "The author is |author|" 18 | 19 | .. code-block:: shell 20 | :substitutions: 21 | 22 | echo "The author is |author|" 23 | 24 | Inline ``:code:`` 25 | ----------------- 26 | 27 | .. rest-example:: 28 | 29 | :code:`echo "The author is |author|"` 30 | 31 | :substitution-code:`echo "The author is |author|"` 32 | 33 | Inline ``:download:`` 34 | --------------------- 35 | 36 | .. rest-example:: 37 | 38 | .. We cannot use the substitution in the download target, because the download directive will error if the file does not exist. 39 | 40 | :download:`Script by |author| <../source/Eleanor.txt>`. 41 | 42 | :substitution-download:`Script by |author| <../source/|author|.txt>`. 43 | 44 | ``literalinclude`` 45 | ------------------ 46 | 47 | Content substitutions 48 | ~~~~~~~~~~~~~~~~~~~~~ 49 | 50 | .. rest-example:: 51 | 52 | .. literalinclude:: sample_include.txt 53 | 54 | .. literalinclude:: sample_include.txt 55 | :content-substitutions: 56 | 57 | Path substitutions 58 | ~~~~~~~~~~~~~~~~~~ 59 | 60 | .. rest-example:: 61 | 62 | .. literalinclude:: |author|.txt 63 | :path-substitutions: 64 | 65 | ``image`` 66 | --------- 67 | 68 | Path substitutions 69 | ~~~~~~~~~~~~~~~~~~ 70 | 71 | .. rest-example:: 72 | 73 | .. image:: |author|_diagram.png 74 | :path-substitutions: 75 | :alt: Diagram for |author| 76 | 77 | .. 78 | This is a test of parallel document builds. You need at least 5 79 | documents. See: 80 | https://github.com/adamtheturtle/sphinx-substitution-extensions/pull/173 81 | 82 | .. toctree:: 83 | :hidden: 84 | 85 | one 86 | two 87 | three 88 | four 89 | five 90 | 91 | 92 | .. toctree:: 93 | markdown_sample 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | schedule: 10 | # * is a special character in YAML so you have to quote this string 11 | # Run at 1:00 every day 12 | - cron: 0 1 * * * 13 | 14 | jobs: 15 | build: 16 | strategy: 17 | matrix: 18 | python-version: ['3.10', '3.11', '3.12', '3.13'] 19 | uv-resolution: [highest, lowest-direct] 20 | platform: [ubuntu-latest, windows-latest] 21 | 22 | runs-on: ${{ matrix.platform }} 23 | 24 | steps: 25 | - uses: actions/checkout@v6 26 | 27 | - name: Install uv 28 | uses: astral-sh/setup-uv@v7 29 | with: 30 | enable-cache: true 31 | cache-dependency-glob: '**/pyproject.toml' 32 | 33 | - name: Lint 34 | run: | 35 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose 36 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose 37 | uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose 38 | env: 39 | UV_PYTHON: ${{ matrix.python-version }} 40 | UV_RESOLUTION: ${{ matrix.uv-resolution }} 41 | 42 | - name: Freeze for debugging 43 | run: | 44 | uv pip freeze 45 | 46 | - name: Build sample 47 | run: | 48 | uv run --extra=dev sphinx-build -W -b html sample/source sample/build 49 | env: 50 | UV_PYTHON: ${{ matrix.python-version }} 51 | UV_RESOLUTION: ${{ matrix.uv-resolution }} 52 | 53 | - name: Build sample parallel 54 | run: | 55 | uv run --extra=dev sphinx-build -j 2 -W -b html sample/source sample/build 56 | env: 57 | UV_PYTHON: ${{ matrix.python-version }} 58 | UV_RESOLUTION: ${{ matrix.uv-resolution }} 59 | 60 | - name: Run tests 61 | run: | 62 | uv run --extra=dev pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests . 63 | env: 64 | UV_PYTHON: ${{ matrix.python-version }} 65 | UV_RESOLUTION: ${{ matrix.uv-resolution }} 66 | 67 | - uses: pre-commit-ci/lite-action@v1.1.0 68 | if: always() 69 | 70 | completion-ci: 71 | needs: build 72 | runs-on: ubuntu-latest 73 | if: always() # Run even if one matrix job fails 74 | steps: 75 | - name: Check matrix job status 76 | run: |- 77 | if ! ${{ needs.build.result == 'success' }}; then 78 | echo "One or more matrix jobs failed" 79 | exit 1 80 | fi 81 | -------------------------------------------------------------------------------- /sample/source/markdown_sample.md: -------------------------------------------------------------------------------- 1 | Samples for substitution directives in Markdown 2 | =============================================== 3 | 4 | Configuration 5 | ------------- 6 | 7 | ```{literalinclude} conf.py 8 | :language: python 9 | ``` 10 | 11 | ``code-block`` 12 | -------------- 13 | 14 | ```{code-block} markdown 15 | 16 | ```{code-block} markdown 17 | 18 | echo "The author is |author|" 19 | ``` 20 | 21 | ```{code-block} markdown 22 | :substitutions: 23 | 24 | echo "The author is |author|" 25 | ``` 26 | 27 | or, with the value of the `myst_sub_delimiters` `conf.py` setting: 28 | 29 | ```{code-block} markdown 30 | 31 | echo "The author is {{author}}" 32 | ``` 33 | 34 | ```{code-block} markdown 35 | :substitutions: 36 | 37 | echo "The author is {{author}}" 38 | ``` 39 | ``` 40 | 41 | => 42 | 43 | ```{code-block} markdown 44 | 45 | echo "The author is |author|" 46 | ``` 47 | 48 | ```{code-block} markdown 49 | :substitutions: 50 | 51 | echo "The author is |author|" 52 | ``` 53 | 54 | ```{code-block} markdown 55 | 56 | echo "The author is {{author}}" 57 | ``` 58 | 59 | ```{code-block} markdown 60 | :substitutions: 61 | 62 | echo "The author is {{author}}" 63 | ``` 64 | 65 | Inline ``:substitution-code:`` 66 | ------------------------------ 67 | 68 | ```{code-block} markdown 69 | 70 | {substitution-code}`The author is {{author}}` 71 | ``` 72 | 73 | => 74 | 75 | {substitution-code}`The author is {{author}}` 76 | 77 | ``substitution-download`` 78 | ------------------------- 79 | 80 | ```{code-block} markdown 81 | 82 | {substitution-download}`Script by {{author}} <../source/Eleanor.txt>` 83 | ``` 84 | 85 | => 86 | 87 | {substitution-download}`Script by {{author}} <../source/Eleanor.txt>` 88 | 89 | ``literalinclude`` 90 | ------------------ 91 | 92 | ### Content substitutions 93 | 94 | ```{code-block} markdown 95 | 96 | ```{literalinclude} sample_include.txt 97 | ``` 98 | 99 | ```{literalinclude} sample_include.txt 100 | :content-substitutions: 101 | ``` 102 | ``` 103 | 104 | => 105 | 106 | ```{literalinclude} sample_include.txt 107 | ``` 108 | 109 | ```{literalinclude} sample_include.txt 110 | :content-substitutions: 111 | ``` 112 | 113 | ### Path substitutions 114 | 115 | ```{code-block} markdown 116 | 117 | ```{literalinclude} {{author}}.txt 118 | :path-substitutions: 119 | ``` 120 | ``` 121 | 122 | => 123 | 124 | ```{literalinclude} {{author}}.txt 125 | :path-substitutions: 126 | ``` 127 | 128 | ``image`` 129 | --------- 130 | 131 | ### Path substitutions 132 | 133 | ```{code-block} markdown 134 | 135 | ```{image} {{author}}_diagram.png 136 | :path-substitutions: 137 | :alt: Diagram for {{author}} 138 | ``` 139 | ``` 140 | 141 | => 142 | 143 | ```{image} {{author}}_diagram.png 144 | :path-substitutions: 145 | :alt: Diagram for {{author}} 146 | ``` 147 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Release 4 | 5 | on: workflow_dispatch 6 | 7 | jobs: 8 | build: 9 | name: Publish a release 10 | runs-on: ubuntu-latest 11 | 12 | # Specifying an environment is strongly recommended by PyPI. 13 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 14 | environment: release 15 | 16 | permissions: 17 | # This is needed for PyPI publishing. 18 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 19 | id-token: write 20 | # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. 21 | contents: write 22 | 23 | steps: 24 | - uses: actions/checkout@v6 25 | with: 26 | # See 27 | # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches 28 | token: ${{ secrets.RELEASE_PAT }} 29 | # Fetch all history including tags. 30 | # Needed to find the latest tag. 31 | # 32 | # Also, avoids 33 | # https://github.com/stefanzweifel/git-auto-commit-action/issues/99. 34 | fetch-depth: 0 35 | 36 | - name: Install uv 37 | uses: astral-sh/setup-uv@v7 38 | with: 39 | enable-cache: true 40 | cache-dependency-glob: '**/pyproject.toml' 41 | 42 | - name: Calver calculate version 43 | uses: StephaneBour/actions-calver@master 44 | id: calver 45 | with: 46 | date_format: '%Y.%m.%d' 47 | release: false 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Get the changelog underline 52 | id: changelog_underline 53 | run: | 54 | underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" 55 | echo "underline=${underline}" >> "$GITHUB_OUTPUT" 56 | 57 | - name: Update changelog 58 | id: update_changelog 59 | uses: jacobtomlinson/gha-find-replace@v3 60 | with: 61 | find: "Next\n----" 62 | replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ 63 | \ }}" 64 | include: CHANGELOG.rst 65 | regex: false 66 | 67 | - name: Check Update changelog was modified 68 | run: | 69 | if [ "${{ steps.update_changelog.outputs.modifiedFiles }}" = "0" ]; then 70 | echo "Error: No files were modified when updating changelog" 71 | exit 1 72 | fi 73 | - uses: stefanzweifel/git-auto-commit-action@v7 74 | id: commit 75 | with: 76 | commit_message: Bump CHANGELOG 77 | file_pattern: CHANGELOG.rst 78 | # Error if there are no changes. 79 | skip_dirty_check: true 80 | 81 | - name: Bump version and push tag 82 | id: tag_version 83 | uses: mathieudutour/github-tag-action@v6.2 84 | with: 85 | github_token: ${{ secrets.GITHUB_TOKEN }} 86 | custom_tag: ${{ steps.calver.outputs.release }} 87 | tag_prefix: '' 88 | commit_sha: ${{ steps.commit.outputs.commit_hash }} 89 | 90 | - name: Create a GitHub release 91 | uses: ncipollo/release-action@v1 92 | with: 93 | tag: ${{ steps.tag_version.outputs.new_tag }} 94 | makeLatest: true 95 | name: Release ${{ steps.tag_version.outputs.new_tag }} 96 | body: ${{ steps.tag_version.outputs.changelog }} 97 | 98 | - name: Build a binary wheel and a source tarball 99 | run: | 100 | git fetch --tags 101 | git checkout ${{ steps.tag_version.outputs.new_tag }} 102 | uv build --sdist --wheel --out-dir dist/ 103 | uv run --extra=release check-wheel-contents dist/*.whl 104 | 105 | # We use PyPI trusted publishing rather than a PyPI API token. 106 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 107 | - name: Publish distribution 📦 to PyPI 108 | uses: pypa/gh-action-pypi-publish@release/v1 109 | with: 110 | verbose: true 111 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. contents:: 5 | 6 | Next 7 | ---- 8 | 9 | 2025.12.15 10 | ---------- 11 | 12 | 2025.11.17 13 | ---------- 14 | 15 | - Give version in extension metadata. 16 | - ``literalinclude`` directive now supports the following options: 17 | 18 | - ``:content-substitutions:`` - Performs substitutions on the included file content. 19 | - ``:path-substitutions:`` - Performs substitutions on the file path. 20 | 21 | - ``image`` directive now supports the following option: 22 | 23 | - ``:path-substitutions:`` - Performs substitutions on the image file path. 24 | 25 | - Add ``substitutions_default_enabled`` configuration option to enable substitutions by default. 26 | When set to ``True`` in ``conf.py``: 27 | 28 | - Substitutions are applied to all ``code-block`` directives without requiring the ``:substitutions:`` flag. 29 | Use the ``:nosubstitutions:`` flag on individual code blocks to disable substitutions when the default is enabled. 30 | - Substitutions are applied to all ``literalinclude`` directives (both content and path) without requiring the ``:content-substitutions:`` or ``:path-substitutions:`` flags. 31 | Use the ``:nocontent-substitutions:`` or ``:nopath-substitutions:`` flags on individual literalinclude directives to disable substitutions when the default is enabled. 32 | - Substitutions are applied to all ``image`` directives (path) without requiring the ``:path-substitutions:`` flag. 33 | Use the ``:nopath-substitutions:`` flag on individual image directives to disable substitutions when the default is enabled. 34 | 35 | 2025.10.24 36 | ---------- 37 | 38 | 2025.06.06 39 | ---------- 40 | 41 | 2025.04.03 42 | ---------- 43 | 44 | 2025.03.03 45 | ---------- 46 | 47 | - Add support for Python 3.10. 48 | 49 | 2025.02.19 50 | ---------- 51 | 52 | - Support the ``substitution-code`` role in MyST documents. 53 | - Support the ``substitution-download`` role in MyST documents. 54 | - Drop support for Python 3.10. 55 | 56 | 2025.01.02 57 | ---------- 58 | 59 | - Supports situations where there is no source file name available to the extension, such as when using ``sphinx_toolbox.rest_example``. 60 | 61 | 2024.10.17 62 | ---------- 63 | 64 | - Support Python 3.13. 65 | - In MyST documents, support the ``myst_sub_delimiters`` option. 66 | This means you can use the ``{{replace-me}}`` syntax in MyST documents. 67 | 68 | 2024.08.06 69 | ------------ 70 | 71 | - Bump the minimum supported version of Sphinx to 7.3.5. 72 | - Remove support for ``sphinx-prompt``. 73 | Please create a GitHub issue if you have a use case for this extension which is not covered by the built-in Sphinx functionality. 74 | 75 | 2024.02.25 76 | ------------ 77 | 78 | - Add ``substitution-download`` role. 79 | 80 | 2024.02.24.1 81 | ------------ 82 | 83 | - Add support for MyST. 84 | Thanks to Václav Votípka (@eNcacz) for the contribution. 85 | 86 | 2024.02.24 87 | ------------ 88 | 89 | - Bump the minimum supported version of Sphinx to 7.2.0. 90 | - Bump the minimum supported version of docutils to 0.19. 91 | - ``sphinx-prompt`` is no longer an optional dependency, meaning you can remove the ``[prompt]`` extras dependency specification. 92 | - Remove the need to specify the ``sphinx-prompt`` extension in ``conf.py`` in order to use the ``prompt`` directive. 93 | - Support Python 3.12 94 | - Drop support for Python 3.9 95 | 96 | 2022.02.16 97 | ------------ 98 | 99 | - Breaking change: The required Sphinx version is at least 4.0. 100 | - ``sphinx-prompt`` is now an optional dependency. 101 | Thanks go to @dgarcia360 for this change. 102 | 103 | 2020.09.30.0 104 | ------------ 105 | 106 | 2020.07.04.1 107 | ------------ 108 | 109 | - Ensure non-lower-case replacements can also be substituted in the inline substitution code role. 110 | 111 | 2020.07.04.0 112 | ------------ 113 | 114 | - Ensure non-lower-case replacements can also be substituted. 115 | Thanks go to @Julian for this change. 116 | 117 | 2020.05.30.0 118 | ------------ 119 | 120 | 2020.05.27.0 121 | ------------ 122 | 123 | - Breaking change: Use ``:substitutions:`` option on ``code-block`` or ``prompt`` rather than new directives. 124 | 125 | 2020.05.23.0 126 | ------------ 127 | 128 | - Breaking change: Use the default Sphinx replacements, rather than a custom variable. 129 | Thanks go to @sbaudoin for the original code for this change. 130 | Please make a GitHub issue if you have a use case which this does not suit. 131 | 132 | 2020.04.05.0 133 | ------------ 134 | 135 | 2020.02.21.0 136 | ------------ 137 | 138 | 2019.12.28.1 139 | ------------ 140 | 141 | 2019.12.28.0 142 | ------------ 143 | 144 | 2019.06.15.0 145 | ------------ 146 | 147 | 2019.04.04.1 148 | ------------ 149 | 150 | 2019.04.04.0 151 | ------------ 152 | 153 | - Support Sphinx 2.0.0. 154 | 155 | 2018.11.12.3 156 | ------------ 157 | 158 | - Make ``substitution`` a list, not a tuple. 159 | 160 | 2018.11.12.2 161 | ------------ 162 | 163 | - Add ``substitution-code-block`` directive. 164 | 165 | 2018.11.12.0 166 | ------------ 167 | 168 | - Initial release with ``substitution-prompt``. 169 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build Status| |PyPI| 2 | 3 | Sphinx Substitution Extensions 4 | ============================== 5 | 6 | Extensions for Sphinx which allow substitutions within code blocks. 7 | 8 | .. contents:: 9 | 10 | Installation 11 | ------------ 12 | 13 | Sphinx Substitution Extensions is compatible with Sphinx 8.2.0+ using Python |minimum-python-version|\+. 14 | 15 | .. code-block:: console 16 | 17 | $ pip install Sphinx-Substitution-Extensions 18 | 19 | rST setup 20 | --------- 21 | 22 | 1. Add the following to ``conf.py`` to enable the extension: 23 | 24 | .. code-block:: python 25 | 26 | """Configuration for Sphinx.""" 27 | 28 | extensions = ["sphinxcontrib.spelling"] # Example existing extensions 29 | 30 | extensions += ["sphinx_substitution_extensions"] 31 | 32 | 2. Set the following variable in ``conf.py`` to define substitutions: 33 | 34 | .. code-block:: python 35 | 36 | """Configuration for Sphinx.""" 37 | 38 | rst_prolog = """ 39 | .. |release| replace:: 0.1 40 | .. |author| replace:: Eleanor 41 | """ 42 | 43 | This will replace ``|release|`` in the new directives with ``0.1``, and ``|author|`` with ``Eleanor``. 44 | 45 | Using substitutions in rST documents 46 | ------------------------------------ 47 | 48 | ``code-block`` 49 | ~~~~~~~~~~~~~~ 50 | 51 | This adds a ``:substitutions:`` option to Sphinx's built-in `code-block`_ directive. 52 | 53 | .. code-block:: rst 54 | 55 | .. code-block:: shell 56 | :substitutions: 57 | 58 | echo "|author| released version |release|" 59 | 60 | Inline ``:substitution-code:`` 61 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 62 | 63 | .. code-block:: rst 64 | 65 | :substitution-code:`echo "|author| released version |release|"` 66 | 67 | ``substitution-download`` 68 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 69 | 70 | .. code-block:: rst 71 | 72 | :substitution-download:`|author|'s manuscript <|author|_manuscript.txt>` 73 | 74 | ``literalinclude`` 75 | ~~~~~~~~~~~~~~~~~~ 76 | 77 | This adds ``:content-substitutions:`` and ``:path-substitutions:`` options to Sphinx's built-in `literalinclude`_ directive. 78 | 79 | Replace substitutions in the content of the included file: 80 | 81 | .. code-block:: rst 82 | 83 | .. literalinclude:: path/to/file.txt 84 | :content-substitutions: 85 | 86 | Replace substitutions in the file path: 87 | 88 | .. code-block:: rst 89 | 90 | .. literalinclude:: path/to/|author|_file.txt 91 | :path-substitutions: 92 | 93 | ``image`` 94 | ~~~~~~~~~ 95 | 96 | This adds a ``:path-substitutions:`` option to Sphinx's built-in `image`_ directive. 97 | 98 | Replace substitutions in the image path: 99 | 100 | .. code-block:: rst 101 | 102 | .. image:: path/to/|author|_diagram.png 103 | :path-substitutions: 104 | :alt: Diagram 105 | 106 | MyST Markdown setup 107 | ------------------- 108 | 109 | 1. Add ``sphinx_substitution_extensions`` to ``extensions`` in ``conf.py`` to enable the extension: 110 | 111 | .. code-block:: python 112 | 113 | """Configuration for Sphinx.""" 114 | 115 | extensions = ["myst_parser"] # Example existing extensions 116 | 117 | extensions += ["sphinx_substitution_extensions"] 118 | 119 | 2. Set the following variables in ``conf.py`` to define substitutions: 120 | 121 | .. code-block:: python 122 | 123 | """Configuration for Sphinx.""" 124 | 125 | myst_enable_extensions = ["substitution"] 126 | 127 | myst_substitutions = { 128 | "release": "0.1", 129 | "author": "Eleanor", 130 | } 131 | 132 | This will replace ``|release|`` in the new directives with ``0.1``, and ``|author|`` with ``Eleanor``. 133 | 134 | Enabling substitutions by default 135 | ---------------------------------- 136 | 137 | By default, you need to explicitly add the ``:substitutions:`` flag to ``code-block`` directives, and ``:content-substitutions:`` or ``:path-substitutions:`` flags to ``literalinclude`` directives. 138 | 139 | If you want substitutions to be applied by default without needing these flags, you can set the following in ``conf.py``: 140 | 141 | .. code-block:: python 142 | 143 | """Configuration for Sphinx.""" 144 | 145 | substitutions_default_enabled = True 146 | 147 | When this is enabled: 148 | 149 | - All ``code-block`` directives will have substitutions applied automatically 150 | - All ``literalinclude`` directives will have both content and path substitutions applied automatically 151 | 152 | You can disable substitutions for specific directives when the default is enabled: 153 | 154 | .. code-block:: rst 155 | 156 | .. code-block:: shell 157 | :nosubstitutions: 158 | 159 | echo "This |will| not be substituted" 160 | 161 | .. literalinclude:: path/to/file.txt 162 | :nocontent-substitutions: 163 | 164 | .. literalinclude:: path/to/|literal|_file.txt 165 | :nopath-substitutions: 166 | 167 | Using substitutions in MyST Markdown 168 | ------------------------------------ 169 | 170 | ``code-block`` 171 | ~~~~~~~~~~~~~~ 172 | 173 | This adds a ``:substitutions:`` option to Sphinx's built-in `code-block`_ directive. 174 | 175 | .. code-block:: markdown 176 | 177 | ```{code-block} bash 178 | :substitutions: 179 | 180 | echo "|author| released version |release|" 181 | ``` 182 | 183 | As well as using ``|author|``, you can also use ``{{author}}``. 184 | This will respect the value of ``myst_sub_delimiters`` as set in ``conf.py``. 185 | 186 | Inline ``:substitution-code:`` 187 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 188 | 189 | .. code-block:: rst 190 | 191 | {substitution-code}`echo "|author| released version |release|"` 192 | 193 | ``substitution-download`` 194 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 195 | 196 | .. code-block:: rst 197 | 198 | {substitution-download}`|author|'s manuscript <|author|_manuscript.txt>` 199 | 200 | ``literalinclude`` 201 | ~~~~~~~~~~~~~~~~~~ 202 | 203 | This adds ``:content-substitutions:`` and ``:path-substitutions:`` options to Sphinx's built-in `literalinclude`_ directive. 204 | 205 | Replace substitutions in the content of the included file: 206 | 207 | .. code-block:: markdown 208 | 209 | ```{literalinclude} path/to/file.txt 210 | :content-substitutions: 211 | ``` 212 | 213 | Replace substitutions in the file path: 214 | 215 | .. code-block:: markdown 216 | 217 | ```{literalinclude} path/to/|author|_file.txt 218 | :path-substitutions: 219 | ``` 220 | 221 | ``image`` 222 | ~~~~~~~~~ 223 | 224 | This adds a ``:path-substitutions:`` option to Sphinx's built-in `image`_ directive. 225 | 226 | Replace substitutions in the image path: 227 | 228 | .. code-block:: markdown 229 | 230 | ```{image} path/to/|author|_diagram.png 231 | :path-substitutions: 232 | :alt: Diagram 233 | ``` 234 | 235 | Credits 236 | ------- 237 | 238 | ClusterHQ Developers 239 | ~~~~~~~~~~~~~~~~~~~~ 240 | 241 | This package is largely inspired by code written for Flocker by ClusterHQ. 242 | Developers of the relevant code include, at least, Jon Giddy and Tom Prince. 243 | 244 | Contributing 245 | ------------ 246 | 247 | See `CONTRIBUTING.rst <./CONTRIBUTING.rst>`_. 248 | 249 | .. |Build Status| image:: https://github.com/adamtheturtle/sphinx-substitution-extensions/actions/workflows/ci.yml/badge.svg?branch=main 250 | :target: https://github.com/adamtheturtle/sphinx-substitution-extensions/actions 251 | .. _code-block: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block 252 | .. _literalinclude: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude 253 | .. _image: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-image 254 | .. |PyPI| image:: https://badge.fury.io/py/Sphinx-Substitution-Extensions.svg 255 | :target: https://badge.fury.io/py/Sphinx-Substitution-Extensions 256 | .. |minimum-python-version| replace:: 3.10 257 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fail_fast: true 3 | 4 | # We use system Python, with required dependencies specified in pyproject.toml. 5 | # We therefore cannot use those dependencies in pre-commit CI. 6 | ci: 7 | skip: 8 | - actionlint 9 | - sphinx-lint 10 | - check-manifest 11 | - deptry 12 | - doc8 13 | - interrogate 14 | - interrogate-docs 15 | - mypy 16 | - mypy-docs 17 | - pylint 18 | - pyproject-fmt-fix 19 | - pyright 20 | - pyright-docs 21 | - pyright-verifytypes 22 | - ty 23 | - ty-docs 24 | - pyroma 25 | - ruff-check-fix 26 | - ruff-check-fix-docs 27 | - ruff-format-fix 28 | - ruff-format-fix-docs 29 | - docformatter 30 | - shellcheck 31 | - shellcheck-docs 32 | - shfmt 33 | - shfmt-docs 34 | - vulture 35 | - vulture-docs 36 | - yamlfix 37 | 38 | # See https://pre-commit.com for more information 39 | # See https://pre-commit.com/hooks.html for more hooks 40 | default_install_hook_types: [pre-commit, pre-push, commit-msg] 41 | 42 | repos: 43 | - repo: meta 44 | hooks: 45 | - id: check-useless-excludes 46 | stages: [pre-commit] 47 | - repo: https://github.com/pre-commit/pre-commit-hooks 48 | rev: v6.0.0 49 | hooks: 50 | - id: check-added-large-files 51 | stages: [pre-commit] 52 | - id: check-case-conflict 53 | stages: [pre-commit] 54 | - id: check-executables-have-shebangs 55 | stages: [pre-commit] 56 | - id: check-merge-conflict 57 | stages: [pre-commit] 58 | - id: check-shebang-scripts-are-executable 59 | stages: [pre-commit] 60 | - id: check-symlinks 61 | stages: [pre-commit] 62 | - id: check-json 63 | stages: [pre-commit] 64 | - id: check-toml 65 | stages: [pre-commit] 66 | - id: check-vcs-permalinks 67 | stages: [pre-commit] 68 | - id: check-yaml 69 | stages: [pre-commit] 70 | - id: end-of-file-fixer 71 | stages: [pre-commit] 72 | - id: file-contents-sorter 73 | files: spelling_private_dict\.txt$ 74 | stages: [pre-commit] 75 | - id: trailing-whitespace 76 | stages: [pre-commit] 77 | - repo: https://github.com/pre-commit/pygrep-hooks 78 | rev: v1.10.0 79 | hooks: 80 | - id: rst-directive-colons 81 | stages: [pre-commit] 82 | - id: rst-inline-touching-normal 83 | stages: [pre-commit] 84 | - id: text-unicode-replacement-char 85 | stages: [pre-commit] 86 | - id: rst-backticks 87 | 88 | stages: [pre-commit] 89 | - repo: local 90 | hooks: 91 | - id: actionlint 92 | name: actionlint 93 | entry: uv run --extra=dev actionlint 94 | language: python 95 | pass_filenames: false 96 | types_or: [yaml] 97 | additional_dependencies: [uv==0.9.5] 98 | stages: [pre-commit] 99 | 100 | - id: docformatter 101 | name: docformatter 102 | entry: uv run --extra=dev -m docformatter --in-place 103 | language: python 104 | types_or: [python] 105 | additional_dependencies: [uv==0.9.5] 106 | stages: [pre-commit] 107 | 108 | - id: shellcheck 109 | name: shellcheck 110 | entry: uv run --extra=dev shellcheck --shell=bash 111 | language: python 112 | types_or: [shell] 113 | additional_dependencies: [uv==0.9.5] 114 | stages: [pre-commit] 115 | 116 | - id: shellcheck-docs 117 | name: shellcheck-docs 118 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=shell 119 | --language=console --command="shellcheck --shell=bash" 120 | language: python 121 | types_or: [markdown, rst] 122 | additional_dependencies: [uv==0.9.5] 123 | stages: [pre-commit] 124 | 125 | - id: shfmt 126 | name: shfmt 127 | entry: shfmt --write --space-redirects --indent=4 128 | language: python 129 | types_or: [shell] 130 | additional_dependencies: [uv==0.9.5] 131 | stages: [pre-commit] 132 | 133 | - id: shfmt-docs 134 | name: shfmt-docs 135 | entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt 136 | --no-pad-file --command="shfmt --write --space-redirects --indent=4" 137 | language: python 138 | types_or: [markdown, rst] 139 | additional_dependencies: [uv==0.9.5] 140 | stages: [pre-commit] 141 | 142 | - id: mypy 143 | name: mypy 144 | stages: [pre-push] 145 | entry: uv run --extra=dev -m mypy 146 | language: python 147 | types_or: [python, toml] 148 | pass_filenames: false 149 | additional_dependencies: [uv==0.9.5] 150 | 151 | - id: mypy-docs 152 | name: mypy-docs 153 | stages: [pre-push] 154 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 155 | --command="mypy" 156 | language: python 157 | types_or: [markdown, rst] 158 | 159 | - id: check-manifest 160 | name: check-manifest 161 | stages: [pre-push] 162 | entry: uv run --extra=dev -m check_manifest 163 | language: python 164 | pass_filenames: false 165 | additional_dependencies: [uv==0.9.5] 166 | 167 | - id: pyright 168 | name: pyright 169 | stages: [pre-push] 170 | entry: uv run --extra=dev -m pyright . 171 | language: python 172 | types_or: [python, toml] 173 | pass_filenames: false 174 | additional_dependencies: [uv==0.9.5] 175 | 176 | - id: pyright-docs 177 | name: pyright-docs 178 | stages: [pre-push] 179 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 180 | --command="pyright" 181 | language: python 182 | types_or: [markdown, rst] 183 | 184 | - id: pyright-verifytypes 185 | name: pyright-verifytypes 186 | stages: [pre-push] 187 | # Use `--ignoreexternal` because we expose parts of the Sphinx API and Sphinx is not 188 | # thoroughly typed enough. 189 | entry: uv run --extra=dev -m pyright --ignoreexternal --verifytypes sphinx_substitution_extensions 190 | language: python 191 | pass_filenames: false 192 | types_or: [python] 193 | additional_dependencies: [uv==0.9.5] 194 | 195 | - id: ty 196 | name: ty 197 | stages: [pre-push] 198 | entry: uv run --extra=dev ty check 199 | language: python 200 | types_or: [python, toml] 201 | pass_filenames: false 202 | additional_dependencies: [uv==0.9.5] 203 | 204 | - id: ty-docs 205 | name: ty-docs 206 | stages: [pre-push] 207 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 208 | --command="ty check" 209 | language: python 210 | types_or: [markdown, rst] 211 | additional_dependencies: [uv==0.9.5] 212 | 213 | - id: vulture 214 | name: vulture 215 | entry: uv run --extra=dev -m vulture . 216 | language: python 217 | types_or: [python] 218 | pass_filenames: false 219 | additional_dependencies: [uv==0.9.5] 220 | stages: [pre-commit] 221 | 222 | - id: vulture-docs 223 | name: vulture docs 224 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 225 | --command="vulture" 226 | language: python 227 | types_or: [markdown, rst] 228 | additional_dependencies: [uv==0.9.5] 229 | stages: [pre-commit] 230 | 231 | - id: pyroma 232 | name: pyroma 233 | entry: uv run --extra=dev -m pyroma --min 10 . 234 | language: python 235 | pass_filenames: false 236 | types_or: [toml] 237 | additional_dependencies: [uv==0.9.5] 238 | stages: [pre-commit] 239 | 240 | - id: deptry 241 | name: deptry 242 | entry: uv run --extra=dev -m deptry src/ 243 | language: python 244 | pass_filenames: false 245 | additional_dependencies: [uv==0.9.5] 246 | stages: [pre-commit] 247 | 248 | - id: pylint 249 | name: pylint 250 | entry: uv run --extra=dev -m pylint src/ tests/ 251 | language: python 252 | stages: [manual] 253 | pass_filenames: false 254 | additional_dependencies: [uv==0.9.5] 255 | 256 | - id: ruff-check-fix 257 | name: Ruff check fix 258 | entry: uv run --extra=dev -m ruff check --fix 259 | language: python 260 | types_or: [python] 261 | additional_dependencies: [uv==0.9.5] 262 | stages: [pre-commit] 263 | 264 | - id: ruff-check-fix-docs 265 | name: Ruff check fix docs 266 | entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" 267 | language: python 268 | types_or: [markdown, rst] 269 | additional_dependencies: [uv==0.9.5] 270 | stages: [pre-commit] 271 | 272 | - id: ruff-format-fix 273 | name: Ruff format 274 | entry: uv run --extra=dev -m ruff format 275 | language: python 276 | types_or: [python] 277 | additional_dependencies: [uv==0.9.5] 278 | stages: [pre-commit] 279 | 280 | - id: ruff-format-fix-docs 281 | name: Ruff format docs 282 | entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff 283 | format" 284 | language: python 285 | types_or: [markdown, rst] 286 | additional_dependencies: [uv==0.9.5] 287 | stages: [pre-commit] 288 | 289 | - id: doc8 290 | name: doc8 291 | entry: uv run --extra=dev -m doc8 292 | language: python 293 | types_or: [rst] 294 | additional_dependencies: [uv==0.9.5] 295 | stages: [pre-commit] 296 | 297 | - id: interrogate 298 | name: interrogate 299 | entry: uv run --extra=dev -m interrogate 300 | language: python 301 | types_or: [python] 302 | additional_dependencies: [uv==0.9.5] 303 | stages: [pre-commit] 304 | 305 | - id: interrogate-docs 306 | name: interrogate docs 307 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 308 | --command="interrogate" 309 | language: python 310 | types_or: [markdown, rst] 311 | additional_dependencies: [uv==0.9.5] 312 | stages: [pre-commit] 313 | 314 | - id: pyproject-fmt-fix 315 | name: pyproject-fmt 316 | entry: uv run --extra=dev pyproject-fmt 317 | language: python 318 | types_or: [toml] 319 | files: pyproject.toml 320 | 321 | stages: [pre-commit] 322 | - id: yamlfix 323 | name: yamlfix 324 | entry: uv run --extra=dev yamlfix 325 | language: python 326 | types_or: [yaml] 327 | additional_dependencies: [uv==0.9.5] 328 | stages: [pre-commit] 329 | 330 | - id: sphinx-lint 331 | name: sphinx-lint 332 | entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long 333 | language: python 334 | types_or: [rst] 335 | additional_dependencies: [uv==0.9.5] 336 | stages: [pre-commit] 337 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools", 5 | "setuptools-scm>=8.1.0", 6 | ] 7 | 8 | [project] 9 | name = "sphinx-substitution-extensions" 10 | description = "Extensions for Sphinx which allow for substitutions." 11 | readme = { file = "README.rst", content-type = "text/x-rst" } 12 | keywords = [ 13 | "documentation", 14 | "rst", 15 | "sphinx", 16 | ] 17 | license = "MIT" 18 | authors = [ 19 | { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, 20 | ] 21 | requires-python = ">=3.10" 22 | classifiers = [ 23 | "Development Status :: 5 - Production/Stable", 24 | "Environment :: Web Environment", 25 | "Framework :: Pytest", 26 | "Operating System :: Microsoft :: Windows", 27 | "Operating System :: POSIX", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | ] 34 | dynamic = [ 35 | "version", 36 | ] 37 | dependencies = [ 38 | "beartype>=0.22.9", 39 | "docutils>=0.19", 40 | "myst-parser>=4.0.0", 41 | "sphinx>=8.1.0", 42 | ] 43 | optional-dependencies.dev = [ 44 | "actionlint-py==1.7.9.24", 45 | "check-manifest==0.51", 46 | "deptry==0.24.0", 47 | "doc8==2.0.0", 48 | "doccmd==2025.12.13", 49 | "docformatter==1.7.7", 50 | "interrogate==1.7.0", 51 | "mypy[faster-cache]==1.19.1", 52 | "mypy-strict-kwargs==2025.4.3", 53 | "pre-commit==4.5.1", 54 | "pylint[spelling]==4.0.4", 55 | "pyproject-fmt==2.11.1", 56 | "pyright==1.1.407", 57 | "pyroma==5.0.1", 58 | "pytest==9.0.2", 59 | "pytest-cov==7.0.0", 60 | "ruff==0.14.10", 61 | # We add shellcheck-py not only for shell scripts and shell code blocks, 62 | # but also because having it installed means that ``actionlint-py`` will 63 | # use it to lint shell commands in GitHub workflow files. 64 | "shellcheck-py==0.11.0.1", 65 | "shfmt-py==3.12.0.2", 66 | "sphinx-lint==1.0.2", 67 | "sphinx-toolbox==4.1.0", 68 | "ty==0.0.5", 69 | "types-docutils==0.22.3.20251115", 70 | "vulture==2.14", 71 | "yamlfix==1.19.1", 72 | ] 73 | optional-dependencies.release = [ "check-wheel-contents==0.6.3" ] 74 | urls.Source = "https://github.com/adamtheturtle/sphinx-substitution-extensions" 75 | 76 | [tool.setuptools] 77 | zip-safe = false 78 | 79 | [tool.setuptools.packages.find] 80 | where = [ 81 | "src", 82 | ] 83 | 84 | [tool.setuptools.package-data] 85 | sphinx_substitution_extensions = [ 86 | "py.typed", 87 | ] 88 | 89 | [tool.distutils.bdist_wheel] 90 | universal = true 91 | 92 | [tool.setuptools_scm] 93 | 94 | # This keeps the start of the version the same as the last release. 95 | # This is useful for our documentation to include e.g. binary links 96 | # to the latest released binary. 97 | # 98 | # Code to match this is in ``conf.py``. 99 | version_scheme = "post-release" 100 | 101 | [tool.ruff] 102 | line-length = 79 103 | lint.select = [ 104 | "ALL", 105 | ] 106 | 107 | lint.ignore = [ 108 | # Ruff warns that this conflicts with the formatter. 109 | "COM812", 110 | # Allow our chosen docstring line-style - no one-line summary. 111 | "D200", 112 | "D205", 113 | "D212", 114 | "D415", 115 | # Ruff warns that this conflicts with the formatter. 116 | "ISC001", 117 | # Ignore "too-many-*" errors as they seem to get in the way more than 118 | # helping. 119 | "PLR0913", 120 | # Allow 'assert' as we use it for tests. 121 | "S101", 122 | ] 123 | 124 | # Do not automatically remove commented out code. 125 | # We comment out code during development, and with VSCode auto-save, this code 126 | # is sometimes annoyingly removed. 127 | lint.unfixable = [ 128 | "ERA001", 129 | ] 130 | lint.pydocstyle.convention = "google" 131 | 132 | [tool.pylint] 133 | 134 | [tool.pylint.'MASTER'] 135 | 136 | # Pickle collected data for later comparisons. 137 | persistent = true 138 | 139 | # Use multiple processes to speed up Pylint. 140 | jobs = 0 141 | 142 | # List of plugins (as comma separated values of python modules names) to load, 143 | # usually to register additional checkers. 144 | # See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. 145 | # We do not use the plugins: 146 | # - pylint.extensions.code_style 147 | # - pylint.extensions.magic_value 148 | # - pylint.extensions.while_used 149 | # as they seemed to get in the way. 150 | load-plugins = [ 151 | 'pylint.extensions.bad_builtin', 152 | 'pylint.extensions.comparison_placement', 153 | 'pylint.extensions.consider_refactoring_into_while_condition', 154 | 'pylint.extensions.docparams', 155 | 'pylint.extensions.dunder', 156 | 'pylint.extensions.eq_without_hash', 157 | 'pylint.extensions.for_any_all', 158 | 'pylint.extensions.mccabe', 159 | 'pylint.extensions.no_self_use', 160 | 'pylint.extensions.overlapping_exceptions', 161 | 'pylint.extensions.private_import', 162 | 'pylint.extensions.redefined_loop_name', 163 | 'pylint.extensions.redefined_variable_type', 164 | 'pylint.extensions.set_membership', 165 | 'pylint.extensions.typing', 166 | ] 167 | 168 | # Allow loading of arbitrary C extensions. Extensions are imported into the 169 | # active Python interpreter and may run arbitrary code. 170 | unsafe-load-any-extension = false 171 | 172 | [tool.pylint.'MESSAGES CONTROL'] 173 | 174 | # Enable the message, report, category or checker with the given id(s). You can 175 | # either give multiple identifier separated by comma (,) or put this option 176 | # multiple time (only on the command line, not in the configuration file where 177 | # it should appear only once). See also the "--disable" option for examples. 178 | enable = [ 179 | 'bad-inline-option', 180 | 'deprecated-pragma', 181 | 'file-ignored', 182 | 'spelling', 183 | 'use-symbolic-message-instead', 184 | 'useless-suppression', 185 | ] 186 | 187 | # Disable the message, report, category or checker with the given id(s). You 188 | # can either give multiple identifiers separated by comma (,) or put this 189 | # option multiple times (only on the command line, not in the configuration 190 | # file where it should appear only once).You can also use "--disable=all" to 191 | # disable everything first and then reenable specific checks. For example, if 192 | # you want to run only the similarities checker, you can use "--disable=all 193 | # --enable=similarities". If you want to run only the classes checker, but have 194 | # no Warning level messages displayed, use"--disable=all --enable=classes 195 | # --disable=W" 196 | 197 | disable = [ 198 | 'too-few-public-methods', 199 | 'too-many-positional-arguments', 200 | 'too-many-locals', 201 | 'too-many-arguments', 202 | 'too-many-instance-attributes', 203 | 'too-many-return-statements', 204 | 'too-many-lines', 205 | 'locally-disabled', 206 | # Let flake8 handle long lines 207 | 'line-too-long', 208 | # Let ruff handle unused imports 209 | 'unused-import', 210 | # Let ruff deal with sorting 211 | 'ungrouped-imports', 212 | # We don't need everything to be documented because of mypy 213 | 'missing-type-doc', 214 | 'missing-return-type-doc', 215 | # Too difficult to please 216 | 'duplicate-code', 217 | # Let ruff handle imports 218 | 'wrong-import-order', 219 | # Let ruff find protected member access. 220 | 'protected-access', 221 | ] 222 | 223 | [tool.pylint.'FORMAT'] 224 | 225 | # Allow the body of an if to be on the same line as the test if there is no 226 | # else. 227 | single-line-if-stmt = false 228 | 229 | [tool.pylint.'SPELLING'] 230 | 231 | # Spelling dictionary name. Available dictionaries: none. To make it working 232 | # install python-enchant package. 233 | spelling-dict = 'en_US' 234 | 235 | # A path to a file that contains private dictionary; one word per line. 236 | spelling-private-dict-file = 'spelling_private_dict.txt' 237 | 238 | # Tells whether to store unknown words to indicated private dictionary in 239 | # --spelling-private-dict-file option instead of raising a message. 240 | spelling-store-unknown-words = 'no' 241 | 242 | [tool.docformatter] 243 | make-summary-multi-line = true 244 | 245 | [tool.check-manifest] 246 | 247 | ignore = [ 248 | ".checkmake-config.ini", 249 | ".prettierrc", 250 | ".yamlfmt", 251 | "*.enc", 252 | ".pre-commit-config.yaml", 253 | "readthedocs.yaml", 254 | "CHANGELOG.rst", 255 | "CODE_OF_CONDUCT.rst", 256 | "CONTRIBUTING.rst", 257 | "LICENSE", 258 | "Makefile", 259 | "ci", 260 | "ci/**", 261 | "docs", 262 | "docs/**", 263 | ".git_archival.txt", 264 | "sample", 265 | "sample/**", 266 | "spelling_private_dict.txt", 267 | "tests", 268 | "tests-pylintrc", 269 | "tests/**", 270 | "lint.mk", 271 | ] 272 | 273 | [tool.deptry] 274 | pep621_dev_dependency_groups = [ 275 | "dev", 276 | "release", 277 | ] 278 | 279 | [tool.pyproject-fmt] 280 | indent = 4 281 | keep_full_version = true 282 | max_supported_python = "3.13" 283 | 284 | [tool.pytest.ini_options] 285 | 286 | xfail_strict = true 287 | log_cli = true 288 | 289 | [tool.coverage.run] 290 | 291 | branch = true 292 | 293 | [tool.coverage.report] 294 | exclude_also = [ 295 | "if TYPE_CHECKING:", 296 | ] 297 | 298 | [tool.mypy] 299 | 300 | strict = true 301 | files = [ "." ] 302 | exclude = [ "build" ] 303 | follow_untyped_imports = true 304 | plugins = [ 305 | "mypy_strict_kwargs", 306 | ] 307 | 308 | [tool.pyright] 309 | enableTypeIgnoreComments = false 310 | reportUnnecessaryTypeIgnoreComment = true 311 | typeCheckingMode = "strict" 312 | 313 | [tool.interrogate] 314 | fail-under = 100 315 | omit-covered-files = true 316 | verbose = 2 317 | 318 | [tool.doc8] 319 | 320 | max_line_length = 2000 321 | ignore_path = [ 322 | "./.eggs", 323 | "./docs/build", 324 | "./docs/build/spelling/output.txt", 325 | "./node_modules", 326 | "./sample/build", 327 | "./src/*.egg-info/", 328 | "./src/*/_setuptools_scm_version.txt", 329 | ] 330 | 331 | [tool.vulture] 332 | # Ideally we would limit the paths to the source code where we want to ignore names, 333 | # but Vulture does not enable this. 334 | ignore_names = [ 335 | # pytest configuration 336 | "pytest_collect_file", 337 | "pytest_collection_modifyitems", 338 | "pytest_plugins", 339 | # pytest fixtures - we name fixtures like this for this purpose 340 | "fixture_*", 341 | # Sphinx 342 | "autoclass_content", 343 | "autoclass_content", 344 | "autodoc_member_order", 345 | "copybutton_exclude", 346 | "extensions", 347 | "html_show_copyright", 348 | "html_show_sourcelink", 349 | "html_show_sphinx", 350 | "html_theme", 351 | "html_theme_options", 352 | "html_title", 353 | "htmlhelp_basename", 354 | "intersphinx_mapping", 355 | "language", 356 | "linkcheck_ignore", 357 | "linkcheck_retries", 358 | "master_doc", 359 | "myst_enable_extensions", 360 | "myst_substitutions", 361 | "nitpicky", 362 | "project_copyright", 363 | "pygments_style", 364 | "rst_prolog", 365 | "setup", 366 | "source_suffix", 367 | "spelling_word_list_filename", 368 | "substitutions_default_enabled", 369 | "templates_path", 370 | "warning_is_error", 371 | ] 372 | 373 | # Duplicate some of .gitignore 374 | exclude = [ ".venv" ] 375 | 376 | [tool.yamlfix] 377 | section_whitelines = 1 378 | whitelines = 1 379 | -------------------------------------------------------------------------------- /src/sphinx_substitution_extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom Sphinx extensions. 3 | """ 4 | 5 | from importlib.metadata import version 6 | from typing import Any, ClassVar 7 | 8 | from beartype import beartype 9 | from docutils.nodes import ( 10 | Element, 11 | Node, 12 | Text, 13 | substitution_definition, 14 | system_message, 15 | ) 16 | from docutils.parsers.rst import directives 17 | from docutils.parsers.rst.directives.images import Image 18 | from docutils.parsers.rst.roles import code_role 19 | from docutils.parsers.rst.states import Inliner 20 | from docutils.statemachine import StringList 21 | from myst_parser.mocking import MockInliner 22 | from sphinx import addnodes 23 | from sphinx.application import Sphinx 24 | from sphinx.config import Config 25 | from sphinx.directives.code import CodeBlock, LiteralInclude 26 | from sphinx.environment import BuildEnvironment 27 | from sphinx.roles import XRefRole 28 | from sphinx.util.typing import ExtensionMetadata, OptionSpec 29 | 30 | from sphinx_substitution_extensions.shared import ( 31 | CONTENT_SUBSTITUTION_OPTION_NAME, 32 | NO_CONTENT_SUBSTITUTION_OPTION_NAME, 33 | NO_PATH_SUBSTITUTION_OPTION_NAME, 34 | NO_SUBSTITUTION_OPTION_NAME, 35 | PATH_SUBSTITUTION_OPTION_NAME, 36 | SUBSTITUTION_OPTION_NAME, 37 | ) 38 | 39 | 40 | @beartype 41 | def _get_delimiter_pairs( 42 | env: BuildEnvironment, 43 | config: Config, 44 | ) -> set[tuple[str, str]]: 45 | """ 46 | Get the delimiter pairs for substitution. 47 | """ 48 | markdown_suffixes = { 49 | key.lstrip(".") 50 | for key, value in config.source_suffix.items() 51 | if value == "markdown" 52 | } 53 | 54 | # Use `| |` on reST as it is the default substitution syntax. 55 | # Use `| |` on MyST for backwards compatibility as this is what we 56 | # originally shipped with. 57 | delimiter_pairs = {("|", "|")} 58 | parser_supported_formats = set(env.parser.supported) 59 | if parser_supported_formats.intersection(markdown_suffixes): 60 | opening_delimiter, closing_delimiter = config.myst_sub_delimiters 61 | new_delimiter_pair = ( 62 | opening_delimiter + opening_delimiter, 63 | closing_delimiter + closing_delimiter, 64 | ) 65 | delimiter_pairs = {*delimiter_pairs, new_delimiter_pair} 66 | 67 | return delimiter_pairs 68 | 69 | 70 | @beartype 71 | def _get_substitution_defs( 72 | env: BuildEnvironment, 73 | config: Config, 74 | substitution_defs: dict[str, substitution_definition], 75 | ) -> dict[str, str]: 76 | """ 77 | Get the substitution definitions from the environment. 78 | """ 79 | markdown_suffixes = { 80 | key.lstrip(".") 81 | for key, value in config.source_suffix.items() 82 | if value == "markdown" 83 | } 84 | 85 | parser_supported_formats = set(env.parser.supported) 86 | if parser_supported_formats.intersection(markdown_suffixes): 87 | if "substitution" in config.myst_enable_extensions: 88 | return dict(config.myst_substitutions) 89 | else: 90 | return { 91 | key: value.astext() for key, value in substitution_defs.items() 92 | } 93 | 94 | return {} 95 | 96 | 97 | @beartype 98 | def _apply_substitutions( 99 | text: str, 100 | substitution_defs: dict[str, str], 101 | delimiter_pairs: set[tuple[str, str]], 102 | ) -> str: 103 | """ 104 | Apply substitutions to text using the given delimiter pairs. 105 | """ 106 | new_text = text 107 | for name, replacement in substitution_defs.items(): 108 | for delimiter_pair in delimiter_pairs: 109 | opening_delimiter, closing_delimiter = delimiter_pair 110 | new_text = new_text.replace( 111 | f"{opening_delimiter}{name}{closing_delimiter}", 112 | replacement, 113 | ) 114 | return new_text 115 | 116 | 117 | @beartype 118 | def _should_apply_substitutions( 119 | options: dict[str, Any], 120 | config: Config, 121 | yes_flag: str, 122 | no_flag: str, 123 | ) -> bool: 124 | """ 125 | Whether substitutions should be applied based on flags and configuration. 126 | """ 127 | if no_flag in options: 128 | return False 129 | if yes_flag in options: 130 | return True 131 | return bool(config.substitutions_default_enabled) 132 | 133 | 134 | @beartype 135 | def _process_node( 136 | node: Node, 137 | substitution_defs: dict[str, str], 138 | delimiter_pairs: set[tuple[str, str]], 139 | ) -> None: 140 | """ 141 | Recursively process nodes to apply substitutions. 142 | """ 143 | if isinstance(node, Element): 144 | new_text = _apply_substitutions( 145 | text=node.rawsource, 146 | substitution_defs=substitution_defs, 147 | delimiter_pairs=delimiter_pairs, 148 | ) 149 | node.rawsource = new_text 150 | if node.children: 151 | first_child = node.children[0] 152 | if isinstance(first_child, Text): 153 | node.replace(old=first_child, new=Text(data=new_text)) 154 | 155 | for child in node.children: 156 | _process_node( 157 | node=child, 158 | substitution_defs=substitution_defs, 159 | delimiter_pairs=delimiter_pairs, 160 | ) 161 | 162 | 163 | @beartype 164 | class SubstitutionCodeBlock(CodeBlock): 165 | """ 166 | Similar to CodeBlock but replaces placeholders with variables. 167 | """ 168 | 169 | option_spec: ClassVar[OptionSpec] = ( 170 | CodeBlock.option_spec.copy() if CodeBlock.option_spec else {} 171 | ) 172 | option_spec[SUBSTITUTION_OPTION_NAME] = directives.flag 173 | option_spec[NO_SUBSTITUTION_OPTION_NAME] = directives.flag 174 | 175 | def run(self) -> list[Node]: 176 | """ 177 | Replace placeholders with given variables. 178 | """ 179 | new_content = StringList() 180 | existing_content = self.content 181 | substitution_defs = _get_substitution_defs( 182 | env=self.env, 183 | config=self.config, 184 | substitution_defs=self.state.document.substitution_defs, 185 | ) 186 | 187 | delimiter_pairs = _get_delimiter_pairs( 188 | env=self.env, 189 | config=self.config, 190 | ) 191 | 192 | should_apply_substitutions = _should_apply_substitutions( 193 | options=self.options, 194 | config=self.config, 195 | yes_flag=SUBSTITUTION_OPTION_NAME, 196 | no_flag=NO_SUBSTITUTION_OPTION_NAME, 197 | ) 198 | 199 | for item in existing_content: 200 | new_item = item 201 | if should_apply_substitutions: 202 | new_item = _apply_substitutions( 203 | text=item, 204 | substitution_defs=substitution_defs, 205 | delimiter_pairs=delimiter_pairs, 206 | ) 207 | new_item_string_list = StringList(initlist=[new_item]) 208 | new_content.extend(other=new_item_string_list) 209 | 210 | self.content = new_content 211 | return super().run() 212 | 213 | 214 | @beartype 215 | class SubstitutionCodeRole: 216 | """ 217 | Custom role for substitution code. 218 | """ 219 | 220 | options: ClassVar[dict[str, Any]] = { 221 | "class": directives.class_option, 222 | "language": directives.unchanged, 223 | } 224 | 225 | def __call__( # pylint: disable=dangerous-default-value 226 | self, 227 | typ: str, 228 | rawtext: str, 229 | text: str, 230 | lineno: int, 231 | inliner: Inliner | MockInliner, 232 | # We allow mutable defaults as the Sphinx implementation requires it. 233 | options: dict[Any, Any] = {}, # noqa: B006 234 | content: list[str] = [], # noqa: B006 235 | ) -> tuple[list[Node], list[system_message]]: 236 | """ 237 | Replace placeholders with given variables. 238 | """ 239 | settings = inliner.document.settings 240 | env = settings.env 241 | substitution_defs = _get_substitution_defs( 242 | env=env, 243 | config=env.config, 244 | substitution_defs=inliner.document.substitution_defs, 245 | ) 246 | 247 | delimiter_pairs = _get_delimiter_pairs( 248 | env=env, 249 | config=env.config, 250 | ) 251 | 252 | text = _apply_substitutions( 253 | text=text, 254 | substitution_defs=substitution_defs, 255 | delimiter_pairs=delimiter_pairs, 256 | ) 257 | rawtext = _apply_substitutions( 258 | text=rawtext, 259 | substitution_defs=substitution_defs, 260 | delimiter_pairs=delimiter_pairs, 261 | ) 262 | 263 | # ``types-docutils`` says that ``code_role`` requires an ``Inliner`` 264 | # for ``inliner``. 265 | # 266 | # We can remove this when 267 | # https://github.com/executablebooks/MyST-Parser/issues/1017 268 | # is resolved by typing ``inliner`` as ``Inliner``. 269 | if isinstance(inliner, MockInliner): 270 | new_inliner = Inliner() 271 | new_inliner.document = inliner.document 272 | inliner = new_inliner 273 | 274 | return code_role( 275 | role=typ, 276 | rawtext=rawtext, 277 | text=text, 278 | lineno=lineno, 279 | inliner=inliner, 280 | options=options, 281 | content=content, 282 | ) 283 | 284 | 285 | @beartype 286 | class SubstitutionLiteralInclude(LiteralInclude): 287 | """ 288 | Similar to LiteralInclude but replaces placeholders with variables. 289 | """ 290 | 291 | option_spec: ClassVar[OptionSpec] = ( 292 | LiteralInclude.option_spec.copy() if LiteralInclude.option_spec else {} 293 | ) 294 | option_spec[CONTENT_SUBSTITUTION_OPTION_NAME] = directives.flag 295 | option_spec[PATH_SUBSTITUTION_OPTION_NAME] = directives.flag 296 | option_spec[NO_CONTENT_SUBSTITUTION_OPTION_NAME] = directives.flag 297 | option_spec[NO_PATH_SUBSTITUTION_OPTION_NAME] = directives.flag 298 | 299 | def run(self) -> list[Node]: 300 | """ 301 | Replace placeholders with given variables in the file path and/or 302 | included file content. 303 | """ 304 | should_apply_path_substitutions = _should_apply_substitutions( 305 | options=self.options, 306 | config=self.config, 307 | yes_flag=PATH_SUBSTITUTION_OPTION_NAME, 308 | no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, 309 | ) 310 | 311 | if should_apply_path_substitutions: 312 | substitution_defs = _get_substitution_defs( 313 | env=self.env, 314 | config=self.config, 315 | substitution_defs=self.state.document.substitution_defs, 316 | ) 317 | 318 | delimiter_pairs = _get_delimiter_pairs( 319 | env=self.env, 320 | config=self.config, 321 | ) 322 | 323 | for argument_index, argument in enumerate(iterable=self.arguments): 324 | self.arguments[argument_index] = _apply_substitutions( 325 | text=argument, 326 | substitution_defs=substitution_defs, 327 | delimiter_pairs=delimiter_pairs, 328 | ) 329 | 330 | nodes_list = super().run() 331 | 332 | should_apply_content_substitutions = _should_apply_substitutions( 333 | options=self.options, 334 | config=self.config, 335 | yes_flag=CONTENT_SUBSTITUTION_OPTION_NAME, 336 | no_flag=NO_CONTENT_SUBSTITUTION_OPTION_NAME, 337 | ) 338 | 339 | if should_apply_content_substitutions: 340 | substitution_defs = _get_substitution_defs( 341 | env=self.env, 342 | config=self.config, 343 | substitution_defs=self.state.document.substitution_defs, 344 | ) 345 | 346 | delimiter_pairs = _get_delimiter_pairs( 347 | env=self.env, 348 | config=self.config, 349 | ) 350 | 351 | for node in nodes_list: 352 | _process_node( 353 | node=node, 354 | substitution_defs=substitution_defs, 355 | delimiter_pairs=delimiter_pairs, 356 | ) 357 | 358 | return nodes_list 359 | 360 | 361 | @beartype 362 | class SubstitutionImage(Image): 363 | """ 364 | Similar to Image but replaces placeholders with variables in the path. 365 | """ 366 | 367 | _new_option_spec = Image.option_spec.copy() if Image.option_spec else {} 368 | _new_option_spec[PATH_SUBSTITUTION_OPTION_NAME] = directives.flag 369 | _new_option_spec[NO_PATH_SUBSTITUTION_OPTION_NAME] = directives.flag 370 | option_spec: ClassVar[OptionSpec | None] = _new_option_spec 371 | 372 | def run(self) -> list[Node]: 373 | """ 374 | Replace placeholders with given variables in the image path. 375 | """ 376 | env = self.state.document.settings.env 377 | config = env.config 378 | 379 | should_apply_path_substitutions = _should_apply_substitutions( 380 | options=self.options, 381 | config=config, 382 | yes_flag=PATH_SUBSTITUTION_OPTION_NAME, 383 | no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, 384 | ) 385 | 386 | if should_apply_path_substitutions: 387 | substitution_defs = _get_substitution_defs( 388 | env=env, 389 | config=config, 390 | substitution_defs=self.state.document.substitution_defs, 391 | ) 392 | 393 | delimiter_pairs = _get_delimiter_pairs( 394 | env=env, 395 | config=config, 396 | ) 397 | 398 | for argument_index, argument in enumerate(iterable=self.arguments): 399 | self.arguments[argument_index] = _apply_substitutions( 400 | text=argument, 401 | substitution_defs=substitution_defs, 402 | delimiter_pairs=delimiter_pairs, 403 | ) 404 | 405 | return list(super().run()) 406 | 407 | 408 | @beartype 409 | class SubstitutionXRefRole(XRefRole): 410 | """ 411 | Custom role for XRefs. 412 | """ 413 | 414 | def create_xref_node(self) -> tuple[list[Node], list[system_message]]: 415 | """Override parent method to set classes. 416 | 417 | This is a bit of a hack because it assumes that the role name 418 | will be `substitution-` and that we want to remove 419 | the `substitution-`. 420 | """ 421 | for index, class_name in enumerate(iterable=self.classes): 422 | self.classes[index] = class_name.replace("substitution-", "") 423 | 424 | return super().create_xref_node() 425 | 426 | def process_link( 427 | self, 428 | env: BuildEnvironment, 429 | refnode: Element, 430 | # We allow a boolean-typed positional argument as we are matching the 431 | # method signature of the parent class. 432 | has_explicit_title: bool, # noqa: FBT001 433 | title: str, 434 | target: str, 435 | ) -> tuple[str, str]: 436 | """ 437 | Override parent method to replace placeholders with given variables. 438 | """ 439 | assert isinstance(env, BuildEnvironment) 440 | substitution_defs = _get_substitution_defs( 441 | env=env, 442 | config=env.config, 443 | substitution_defs=self.inliner.document.substitution_defs, 444 | ) 445 | 446 | delimiter_pairs = _get_delimiter_pairs( 447 | env=env, 448 | config=env.config, 449 | ) 450 | 451 | title = _apply_substitutions( 452 | text=title, 453 | substitution_defs=substitution_defs, 454 | delimiter_pairs=delimiter_pairs, 455 | ) 456 | target = _apply_substitutions( 457 | text=target, 458 | substitution_defs=substitution_defs, 459 | delimiter_pairs=delimiter_pairs, 460 | ) 461 | 462 | # Use the default implementation to process the link 463 | # as it handles whitespace in target text. 464 | return super().process_link( 465 | env=env, 466 | refnode=refnode, 467 | has_explicit_title=has_explicit_title, 468 | title=title, 469 | target=target, 470 | ) 471 | 472 | 473 | @beartype 474 | def setup(app: Sphinx) -> ExtensionMetadata: 475 | """ 476 | Add the custom directives to Sphinx. 477 | """ 478 | app.add_config_value(name="substitutions", default=[], rebuild="html") 479 | app.add_config_value( 480 | name="substitutions_default_enabled", 481 | default=False, 482 | rebuild="html", 483 | ) 484 | directives.register_directive( 485 | name="code-block", 486 | directive=SubstitutionCodeBlock, 487 | ) 488 | directives.register_directive( 489 | name="literalinclude", 490 | directive=SubstitutionLiteralInclude, 491 | ) 492 | directives.register_directive( 493 | name="image", 494 | directive=SubstitutionImage, 495 | ) 496 | app.add_role(name="substitution-code", role=SubstitutionCodeRole()) 497 | substitution_download_role = SubstitutionXRefRole( 498 | nodeclass=addnodes.download_reference, 499 | ) 500 | app.add_role(name="substitution-download", role=substitution_download_role) 501 | return { 502 | "parallel_read_safe": True, 503 | "version": version(distribution_name="sphinx-substitution-extensions"), 504 | } 505 | -------------------------------------------------------------------------------- /tests/test_substitution_extensions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Sphinx extensions. 3 | """ 4 | 5 | from collections.abc import Callable 6 | from importlib.metadata import version 7 | from pathlib import Path 8 | from textwrap import dedent 9 | 10 | from sphinx.testing.util import SphinxTestApp 11 | 12 | import sphinx_substitution_extensions 13 | 14 | 15 | def test_setup( 16 | tmp_path: Path, 17 | make_app: Callable[..., SphinxTestApp], 18 | ) -> None: 19 | """ 20 | Test that the setup function returns the expected metadata. 21 | """ 22 | source_directory = tmp_path / "source" 23 | source_directory.mkdir() 24 | (source_directory / "conf.py").touch() 25 | 26 | app = make_app( 27 | srcdir=source_directory, 28 | ) 29 | setup_result = sphinx_substitution_extensions.setup(app=app) 30 | pkg_version = version(distribution_name="sphinx-substitution-extensions") 31 | assert setup_result == { 32 | "parallel_read_safe": True, 33 | "version": pkg_version, 34 | } 35 | 36 | 37 | def test_no_substitution_code_block( 38 | tmp_path: Path, 39 | make_app: Callable[..., SphinxTestApp], 40 | ) -> None: 41 | """ 42 | The ``code-block`` directive does not replace placeholders. 43 | """ 44 | source_directory = tmp_path / "source" 45 | source_directory.mkdir() 46 | source_file = source_directory / "index.rst" 47 | (source_directory / "conf.py").touch() 48 | 49 | source_file_content = dedent( 50 | text="""\ 51 | .. |a| replace:: example_substitution 52 | 53 | .. code-block:: shell 54 | 55 | $ PRE-|a|-POST 56 | """, 57 | ) 58 | source_file.write_text(data=source_file_content) 59 | app = make_app( 60 | srcdir=source_directory, 61 | exception_on_warning=True, 62 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 63 | ) 64 | app.build() 65 | assert app.statuscode == 0 66 | content_html = (app.outdir / "index.html").read_text() 67 | app.cleanup() 68 | 69 | app_expected = make_app( 70 | srcdir=source_directory, 71 | exception_on_warning=True, 72 | freshenv=True, 73 | ) 74 | 75 | app_expected.build() 76 | assert app_expected.statuscode == 0 77 | 78 | expected_content_html = (app_expected.outdir / "index.html").read_text() 79 | 80 | assert content_html == expected_content_html 81 | 82 | 83 | def test_substitution_code_block( 84 | tmp_path: Path, 85 | make_app: Callable[..., SphinxTestApp], 86 | ) -> None: 87 | """ 88 | The ``code-block`` directive replaces the placeholders defined in 89 | ``conf.py`` as specified. 90 | """ 91 | source_directory = tmp_path / "source" 92 | source_directory.mkdir() 93 | source_file = source_directory / "index.rst" 94 | (source_directory / "conf.py").touch() 95 | 96 | source_file_content = dedent( 97 | text="""\ 98 | .. |a| replace:: example_substitution 99 | 100 | .. code-block:: shell 101 | :substitutions: 102 | 103 | $ PRE-|a|-POST 104 | """, 105 | ) 106 | source_file.write_text(data=source_file_content) 107 | app = make_app( 108 | srcdir=source_directory, 109 | exception_on_warning=True, 110 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 111 | ) 112 | app.build() 113 | assert app.statuscode == 0 114 | content_html = (app.outdir / "index.html").read_text() 115 | app.cleanup() 116 | 117 | equivalent_source = dedent( 118 | text="""\ 119 | .. code-block:: shell 120 | 121 | $ PRE-example_substitution-POST 122 | """, 123 | ) 124 | 125 | source_file.write_text(data=equivalent_source) 126 | app_expected = make_app( 127 | srcdir=source_directory, 128 | exception_on_warning=True, 129 | ) 130 | app_expected.build() 131 | assert app_expected.statuscode == 0 132 | 133 | expected_content_html = (app_expected.outdir / "index.html").read_text() 134 | assert content_html == expected_content_html 135 | 136 | 137 | def test_substitution_code_block_case_preserving( 138 | tmp_path: Path, 139 | make_app: Callable[..., SphinxTestApp], 140 | ) -> None: 141 | """ 142 | The ``code-block`` directive respects the original case of replacements. 143 | """ 144 | source_directory = tmp_path / "source" 145 | source_directory.mkdir() 146 | source_file = source_directory / "index.rst" 147 | (source_directory / "conf.py").touch() 148 | 149 | source_file_content = dedent( 150 | text="""\ 151 | .. |aBcD_eFgH| replace:: example_substitution 152 | 153 | .. code-block:: shell 154 | :substitutions: 155 | 156 | $ PRE-|aBcD_eFgH|-POST 157 | """, 158 | ) 159 | source_file.write_text(data=source_file_content) 160 | 161 | app = make_app( 162 | srcdir=source_directory, 163 | exception_on_warning=True, 164 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 165 | ) 166 | app.build() 167 | content_html = (app.outdir / "index.html").read_text() 168 | app.cleanup() 169 | 170 | equivalent_source = dedent( 171 | text="""\ 172 | .. code-block:: shell 173 | 174 | $ PRE-example_substitution-POST 175 | """, 176 | ) 177 | 178 | source_file.write_text(data=equivalent_source) 179 | app_expected = make_app( 180 | srcdir=source_directory, 181 | exception_on_warning=True, 182 | ) 183 | app_expected.build() 184 | assert app_expected.statuscode == 0 185 | 186 | expected_content_html = (app_expected.outdir / "index.html").read_text() 187 | assert content_html == expected_content_html 188 | 189 | 190 | def test_default_substitutions_enabled( 191 | tmp_path: Path, 192 | make_app: Callable[..., SphinxTestApp], 193 | ) -> None: 194 | """ 195 | When ``substitutions_default_enabled`` is set to True in conf.py, code 196 | blocks should apply substitutions by default without needing the 197 | ``:substitutions:`` flag. 198 | """ 199 | source_directory = tmp_path / "source" 200 | source_directory.mkdir() 201 | source_file = source_directory / "index.rst" 202 | (source_directory / "conf.py").touch() 203 | 204 | source_file_content = dedent( 205 | text="""\ 206 | .. |a| replace:: example_substitution 207 | 208 | .. code-block:: shell 209 | 210 | $ PRE-|a|-POST 211 | """, 212 | ) 213 | source_file.write_text(data=source_file_content) 214 | app = make_app( 215 | srcdir=source_directory, 216 | exception_on_warning=True, 217 | confoverrides={ 218 | "extensions": ["sphinx_substitution_extensions"], 219 | "substitutions_default_enabled": True, 220 | }, 221 | ) 222 | app.build() 223 | assert app.statuscode == 0 224 | content_html = (app.outdir / "index.html").read_text() 225 | app.cleanup() 226 | 227 | equivalent_source = dedent( 228 | text="""\ 229 | .. code-block:: shell 230 | 231 | $ PRE-example_substitution-POST 232 | """, 233 | ) 234 | 235 | source_file.write_text(data=equivalent_source) 236 | app_expected = make_app( 237 | srcdir=source_directory, 238 | exception_on_warning=True, 239 | ) 240 | app_expected.build() 241 | assert app_expected.statuscode == 0 242 | 243 | expected_content_html = (app_expected.outdir / "index.html").read_text() 244 | assert content_html == expected_content_html 245 | 246 | 247 | def test_default_substitutions_disabled_with_flag( 248 | tmp_path: Path, 249 | make_app: Callable[..., SphinxTestApp], 250 | ) -> None: 251 | """ 252 | When ``substitutions_default_enabled`` is True but a code block has the 253 | ``:nosubstitutions:`` flag, substitutions should not be applied. 254 | """ 255 | source_directory = tmp_path / "source" 256 | source_directory.mkdir() 257 | source_file = source_directory / "index.rst" 258 | (source_directory / "conf.py").touch() 259 | 260 | source_file_content = dedent( 261 | text="""\ 262 | .. |a| replace:: example_substitution 263 | 264 | .. code-block:: shell 265 | :nosubstitutions: 266 | 267 | $ PRE-|a|-POST 268 | """, 269 | ) 270 | source_file.write_text(data=source_file_content) 271 | app = make_app( 272 | srcdir=source_directory, 273 | exception_on_warning=True, 274 | confoverrides={ 275 | "extensions": ["sphinx_substitution_extensions"], 276 | "substitutions_default_enabled": True, 277 | }, 278 | ) 279 | app.build() 280 | assert app.statuscode == 0 281 | content_html = (app.outdir / "index.html").read_text() 282 | app.cleanup() 283 | 284 | equivalent_source = dedent( 285 | text="""\ 286 | .. code-block:: shell 287 | 288 | $ PRE-|a|-POST 289 | """, 290 | ) 291 | 292 | source_file.write_text(data=equivalent_source) 293 | app_expected = make_app( 294 | srcdir=source_directory, 295 | exception_on_warning=True, 296 | freshenv=True, 297 | ) 298 | 299 | app_expected.build() 300 | assert app_expected.statuscode == 0 301 | 302 | expected_content_html = (app_expected.outdir / "index.html").read_text() 303 | 304 | assert content_html == expected_content_html 305 | 306 | 307 | def test_substitution_inline( 308 | tmp_path: Path, 309 | make_app: Callable[..., SphinxTestApp], 310 | ) -> None: 311 | """ 312 | The ``substitution-code`` role replaces the placeholders defined in 313 | ``conf.py`` as specified. 314 | """ 315 | source_directory = tmp_path / "source" 316 | source_directory.mkdir() 317 | source_file = source_directory / "index.rst" 318 | (source_directory / "conf.py").touch() 319 | 320 | source_file_content = dedent( 321 | text="""\ 322 | .. |a| replace:: example_substitution 323 | 324 | Example :substitution-code:`PRE-|a|-POST` 325 | """, 326 | ) 327 | source_file.write_text(data=source_file_content) 328 | app = make_app( 329 | srcdir=source_directory, 330 | exception_on_warning=True, 331 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 332 | ) 333 | app.build() 334 | assert app.statuscode == 0 335 | content_html = (app.outdir / "index.html").read_text() 336 | app.cleanup() 337 | 338 | equivalent_source = dedent( 339 | text="""\ 340 | Example :code:`PRE-example_substitution-POST` 341 | """, 342 | ) 343 | 344 | source_file.write_text(data=equivalent_source) 345 | app_expected = make_app( 346 | srcdir=source_directory, 347 | exception_on_warning=True, 348 | ) 349 | app_expected.build() 350 | assert app_expected.statuscode == 0 351 | 352 | expected_content_html = (app_expected.outdir / "index.html").read_text() 353 | assert content_html == expected_content_html 354 | 355 | 356 | def test_substitution_inline_case_preserving( 357 | tmp_path: Path, 358 | make_app: Callable[..., SphinxTestApp], 359 | ) -> None: 360 | """ 361 | The ``substitution-code`` role respects the original case of replacements. 362 | """ 363 | source_directory = tmp_path / "source" 364 | source_directory.mkdir() 365 | source_file = source_directory / "index.rst" 366 | (source_directory / "conf.py").touch() 367 | 368 | source_file_content = dedent( 369 | text="""\ 370 | .. |aBcD_eFgH| replace:: example_substitution 371 | 372 | Example :substitution-code:`PRE-|aBcD_eFgH|-POST` 373 | """, 374 | ) 375 | source_file.write_text(data=source_file_content) 376 | app = make_app( 377 | srcdir=source_directory, 378 | exception_on_warning=True, 379 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 380 | ) 381 | app.build() 382 | assert app.statuscode == 0 383 | content_html = (app.outdir / "index.html").read_text() 384 | app.cleanup() 385 | 386 | equivalent_source = dedent( 387 | text="""\ 388 | Example :code:`PRE-example_substitution-POST` 389 | """, 390 | ) 391 | 392 | source_file.write_text(data=equivalent_source) 393 | app_expected = make_app( 394 | srcdir=source_directory, 395 | exception_on_warning=True, 396 | ) 397 | app_expected.build() 398 | assert app_expected.statuscode == 0 399 | 400 | expected_content_html = (app_expected.outdir / "index.html").read_text() 401 | assert content_html == expected_content_html 402 | 403 | 404 | def test_substitution_download( 405 | tmp_path: Path, 406 | make_app: Callable[..., SphinxTestApp], 407 | ) -> None: 408 | """ 409 | The ``substitution-download`` role replaces the placeholders defined in 410 | ``conf.py`` as specified in both the download text and the download target. 411 | """ 412 | source_directory = tmp_path / "source" 413 | source_directory.mkdir() 414 | source_file = source_directory / "index.rst" 415 | (source_directory / "conf.py").touch() 416 | 417 | # Importantly we have a non-space whitespace character in the target name. 418 | downloadable_file = ( 419 | source_directory / "tgt_pre-example_substitution-tgt_post .py" 420 | ) 421 | downloadable_file.write_text(data="Sample") 422 | source_file_content = dedent( 423 | # Importantly we have a substitution in the download text and the 424 | # target. 425 | text="""\ 426 | .. |a| replace:: example_substitution 427 | 428 | :substitution-download:`txt_pre-|a|-txt_post ` 429 | """, 430 | ) 431 | source_file.write_text(data=source_file_content) 432 | app = make_app( 433 | srcdir=source_directory, 434 | exception_on_warning=True, 435 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 436 | ) 437 | app.build() 438 | assert app.statuscode == 0 439 | content_html = (app.outdir / "index.html").read_text() 440 | app.cleanup() 441 | 442 | equivalent_source = dedent( 443 | text="""\ 444 | :download:`txt_pre-example_substitution-txt_post ` 445 | """, # noqa: E501 446 | ) 447 | 448 | source_file.write_text(data=equivalent_source) 449 | app_expected = make_app( 450 | srcdir=source_directory, 451 | exception_on_warning=True, 452 | ) 453 | app_expected.build() 454 | assert app_expected.statuscode == 0 455 | 456 | expected_content_html = (app_expected.outdir / "index.html").read_text() 457 | assert content_html == expected_content_html 458 | 459 | 460 | def test_no_substitution_literal_include( 461 | tmp_path: Path, 462 | make_app: Callable[..., SphinxTestApp], 463 | ) -> None: 464 | """ 465 | The ``literalinclude`` directive does not replace placeholders. 466 | """ 467 | source_directory = tmp_path / "source" 468 | source_directory.mkdir() 469 | source_file = source_directory / "index.rst" 470 | (source_directory / "conf.py").touch() 471 | 472 | include_file = source_directory / "example.txt" 473 | include_file.write_text(data="Content with |a| placeholder") 474 | 475 | source_file_content = dedent( 476 | text="""\ 477 | .. |a| replace:: example_substitution 478 | 479 | .. literalinclude:: example.txt 480 | """, 481 | ) 482 | source_file.write_text(data=source_file_content) 483 | app = make_app( 484 | srcdir=source_directory, 485 | exception_on_warning=True, 486 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 487 | ) 488 | app.build() 489 | assert app.statuscode == 0 490 | content_html = (app.outdir / "index.html").read_text() 491 | app.cleanup() 492 | 493 | app_expected = make_app( 494 | srcdir=source_directory, 495 | exception_on_warning=True, 496 | freshenv=True, 497 | ) 498 | 499 | app_expected.build() 500 | assert app_expected.statuscode == 0 501 | 502 | expected_content_html = (app_expected.outdir / "index.html").read_text() 503 | 504 | assert content_html == expected_content_html 505 | 506 | 507 | def test_substitution_literal_include( 508 | tmp_path: Path, 509 | make_app: Callable[..., SphinxTestApp], 510 | ) -> None: 511 | """ 512 | The ``literalinclude`` directive replaces the placeholders defined in 513 | ``conf.py`` as specified when the `:content-substitutions:` flag is set. 514 | """ 515 | source_directory = tmp_path / "source" 516 | source_directory.mkdir() 517 | source_file = source_directory / "index.rst" 518 | (source_directory / "conf.py").touch() 519 | 520 | include_file = source_directory / "example.txt" 521 | include_file.write_text(data="Content with |a| placeholder") 522 | 523 | source_file_content = dedent( 524 | text="""\ 525 | .. |a| replace:: example_substitution 526 | 527 | .. literalinclude:: example.txt 528 | :content-substitutions: 529 | """, 530 | ) 531 | source_file.write_text(data=source_file_content) 532 | app = make_app( 533 | srcdir=source_directory, 534 | exception_on_warning=True, 535 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 536 | ) 537 | app.build() 538 | assert app.statuscode == 0 539 | content_html = (app.outdir / "index.html").read_text() 540 | app.cleanup() 541 | 542 | include_file.write_text( 543 | data="Content with example_substitution placeholder" 544 | ) 545 | 546 | equivalent_source = dedent( 547 | text="""\ 548 | .. literalinclude:: example.txt 549 | """, 550 | ) 551 | 552 | source_file.write_text(data=equivalent_source) 553 | app_expected = make_app( 554 | srcdir=source_directory, 555 | exception_on_warning=True, 556 | ) 557 | app_expected.build() 558 | assert app_expected.statuscode == 0 559 | 560 | expected_content_html = (app_expected.outdir / "index.html").read_text() 561 | assert content_html == expected_content_html 562 | 563 | 564 | def test_substitution_literal_include_empty_file( 565 | tmp_path: Path, 566 | make_app: Callable[..., SphinxTestApp], 567 | ) -> None: 568 | """ 569 | The ``literalinclude`` directive handles empty files without crashing. 570 | """ 571 | source_directory = tmp_path / "source" 572 | source_directory.mkdir() 573 | source_file = source_directory / "index.rst" 574 | (source_directory / "conf.py").touch() 575 | 576 | # Create an empty file 577 | include_file = source_directory / "empty.txt" 578 | include_file.write_text(data="") 579 | 580 | source_file_content = dedent( 581 | text="""\ 582 | .. |a| replace:: example_substitution 583 | 584 | .. literalinclude:: empty.txt 585 | :content-substitutions: 586 | """, 587 | ) 588 | source_file.write_text(data=source_file_content) 589 | app = make_app( 590 | srcdir=source_directory, 591 | exception_on_warning=True, 592 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 593 | ) 594 | app.build() 595 | assert app.statuscode == 0 596 | content_html = (app.outdir / "index.html").read_text() 597 | app.cleanup() 598 | 599 | equivalent_source = dedent( 600 | text="""\ 601 | .. literalinclude:: empty.txt 602 | """, 603 | ) 604 | 605 | source_file.write_text(data=equivalent_source) 606 | app_expected = make_app( 607 | srcdir=source_directory, 608 | exception_on_warning=True, 609 | ) 610 | app_expected.build() 611 | assert app_expected.statuscode == 0 612 | 613 | expected_content_html = (app_expected.outdir / "index.html").read_text() 614 | assert content_html == expected_content_html 615 | 616 | 617 | def test_substitution_literal_include_multiple( 618 | tmp_path: Path, 619 | make_app: Callable[..., SphinxTestApp], 620 | ) -> None: 621 | """ 622 | The ``literalinclude`` directive replaces multiple placeholders. 623 | """ 624 | source_directory = tmp_path / "source" 625 | source_directory.mkdir() 626 | source_file = source_directory / "index.rst" 627 | (source_directory / "conf.py").touch() 628 | 629 | include_file = source_directory / "example.txt" 630 | include_file.write_text(data="PRE-|a|-MID-|b|-POST") 631 | 632 | source_file_content = dedent( 633 | text="""\ 634 | .. |a| replace:: first_substitution 635 | .. |b| replace:: second_substitution 636 | 637 | .. literalinclude:: example.txt 638 | :content-substitutions: 639 | """, 640 | ) 641 | source_file.write_text(data=source_file_content) 642 | app = make_app( 643 | srcdir=source_directory, 644 | exception_on_warning=True, 645 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 646 | ) 647 | app.build() 648 | assert app.statuscode == 0 649 | content_html = (app.outdir / "index.html").read_text() 650 | app.cleanup() 651 | 652 | include_file.write_text( 653 | data="PRE-first_substitution-MID-second_substitution-POST", 654 | ) 655 | 656 | equivalent_source = dedent( 657 | text="""\ 658 | .. literalinclude:: example.txt 659 | """, 660 | ) 661 | 662 | source_file.write_text(data=equivalent_source) 663 | app_expected = make_app( 664 | srcdir=source_directory, 665 | exception_on_warning=True, 666 | ) 667 | app_expected.build() 668 | assert app_expected.statuscode == 0 669 | 670 | expected_content_html = (app_expected.outdir / "index.html").read_text() 671 | assert content_html == expected_content_html 672 | 673 | 674 | def test_substitution_literal_include_with_caption( 675 | tmp_path: Path, 676 | make_app: Callable[..., SphinxTestApp], 677 | ) -> None: 678 | """ 679 | The ``literalinclude`` directive works with captions. 680 | """ 681 | source_directory = tmp_path / "source" 682 | source_directory.mkdir() 683 | source_file = source_directory / "index.rst" 684 | (source_directory / "conf.py").touch() 685 | 686 | include_file = source_directory / "example.txt" 687 | include_file.write_text(data="Content with |a| placeholder") 688 | 689 | source_file_content = dedent( 690 | text="""\ 691 | .. |a| replace:: example_substitution 692 | 693 | .. literalinclude:: example.txt 694 | :caption: Example caption 695 | :content-substitutions: 696 | """, 697 | ) 698 | source_file.write_text(data=source_file_content) 699 | app = make_app( 700 | srcdir=source_directory, 701 | exception_on_warning=True, 702 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 703 | ) 704 | app.build() 705 | assert app.statuscode == 0 706 | content_html = (app.outdir / "index.html").read_text() 707 | app.cleanup() 708 | 709 | include_file.write_text( 710 | data="Content with example_substitution placeholder" 711 | ) 712 | 713 | equivalent_source = dedent( 714 | text="""\ 715 | .. literalinclude:: example.txt 716 | :caption: Example caption 717 | """, 718 | ) 719 | 720 | source_file.write_text(data=equivalent_source) 721 | app_expected = make_app( 722 | srcdir=source_directory, 723 | exception_on_warning=True, 724 | ) 725 | app_expected.build() 726 | assert app_expected.statuscode == 0 727 | 728 | expected_content_html = (app_expected.outdir / "index.html").read_text() 729 | assert content_html == expected_content_html 730 | 731 | 732 | def test_substitution_literal_include_in_rest_example( 733 | tmp_path: Path, 734 | make_app: Callable[..., SphinxTestApp], 735 | ) -> None: 736 | """ 737 | The ``literalinclude`` directive works inside rest-example. 738 | """ 739 | source_directory = tmp_path / "source" 740 | source_directory.mkdir() 741 | source_file = source_directory / "index.rst" 742 | (source_directory / "conf.py").touch() 743 | 744 | include_file = source_directory / "example.txt" 745 | include_file.write_text(data="Content with |a| placeholder") 746 | 747 | source_file_content = dedent( 748 | text="""\ 749 | .. |a| replace:: example_substitution 750 | 751 | .. rest-example:: 752 | 753 | .. literalinclude:: example.txt 754 | :content-substitutions: 755 | """, 756 | ) 757 | source_file.write_text(data=source_file_content) 758 | app = make_app( 759 | srcdir=source_directory, 760 | warningiserror=True, 761 | confoverrides={ 762 | "extensions": [ 763 | "sphinx_substitution_extensions", 764 | "sphinx_toolbox.rest_example", 765 | ], 766 | }, 767 | ) 768 | app.build() 769 | assert app.statuscode == 0 770 | content_html = (app.outdir / "index.html").read_text() 771 | assert "example_substitution" in content_html 772 | 773 | 774 | def test_substitution_literal_include_path( 775 | tmp_path: Path, 776 | make_app: Callable[..., SphinxTestApp], 777 | ) -> None: 778 | """ 779 | The ``literalinclude`` directive replaces placeholders in the file path 780 | when the `:path-substitutions:` flag is set. 781 | """ 782 | source_directory = tmp_path / "source" 783 | source_directory.mkdir() 784 | source_file = source_directory / "index.rst" 785 | (source_directory / "conf.py").touch() 786 | 787 | # Create a file with substitution in the name 788 | include_file = source_directory / "example_substitution.txt" 789 | include_file.write_text(data="File content") 790 | 791 | source_file_content = dedent( 792 | text="""\ 793 | .. |a| replace:: example_substitution 794 | 795 | .. literalinclude:: |a|.txt 796 | :path-substitutions: 797 | """, 798 | ) 799 | source_file.write_text(data=source_file_content) 800 | app = make_app( 801 | srcdir=source_directory, 802 | exception_on_warning=True, 803 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 804 | ) 805 | app.build() 806 | assert app.statuscode == 0 807 | content_html = (app.outdir / "index.html").read_text() 808 | app.cleanup() 809 | 810 | # Compare with directly using the filename 811 | equivalent_source = dedent( 812 | text="""\ 813 | .. literalinclude:: example_substitution.txt 814 | """, 815 | ) 816 | 817 | source_file.write_text(data=equivalent_source) 818 | app_expected = make_app( 819 | srcdir=source_directory, 820 | exception_on_warning=True, 821 | ) 822 | app_expected.build() 823 | assert app_expected.statuscode == 0 824 | 825 | expected_content_html = (app_expected.outdir / "index.html").read_text() 826 | assert content_html == expected_content_html 827 | 828 | 829 | def test_substitution_literal_include_both_path_and_content( 830 | tmp_path: Path, 831 | make_app: Callable[..., SphinxTestApp], 832 | ) -> None: 833 | """ 834 | The ``literalinclude`` directive can use both path and content 835 | substitutions at the same time. 836 | """ 837 | source_directory = tmp_path / "source" 838 | source_directory.mkdir() 839 | source_file = source_directory / "index.rst" 840 | (source_directory / "conf.py").touch() 841 | 842 | # Create a file with substitution in the name and content 843 | include_file = source_directory / "example_substitution.txt" 844 | include_file.write_text(data="Content with |b| placeholder") 845 | 846 | source_file_content = dedent( 847 | text="""\ 848 | .. |a| replace:: example_substitution 849 | .. |b| replace:: test_value 850 | 851 | .. literalinclude:: |a|.txt 852 | :path-substitutions: 853 | :content-substitutions: 854 | """, 855 | ) 856 | source_file.write_text(data=source_file_content) 857 | app = make_app( 858 | srcdir=source_directory, 859 | exception_on_warning=True, 860 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 861 | ) 862 | app.build() 863 | assert app.statuscode == 0 864 | content_html = (app.outdir / "index.html").read_text() 865 | app.cleanup() 866 | 867 | # Create equivalent file with substituted content 868 | include_file.write_text(data="Content with test_value placeholder") 869 | 870 | equivalent_source = dedent( 871 | text="""\ 872 | .. literalinclude:: example_substitution.txt 873 | """, 874 | ) 875 | 876 | source_file.write_text(data=equivalent_source) 877 | app_expected = make_app( 878 | srcdir=source_directory, 879 | exception_on_warning=True, 880 | ) 881 | app_expected.build() 882 | assert app_expected.statuscode == 0 883 | 884 | expected_content_html = (app_expected.outdir / "index.html").read_text() 885 | assert content_html == expected_content_html 886 | 887 | app_expected.build() 888 | assert app_expected.statuscode == 0 889 | 890 | expected_content_html = (app_expected.outdir / "index.html").read_text() 891 | assert content_html == expected_content_html 892 | 893 | 894 | def test_default_substitutions_literal_include_content( 895 | tmp_path: Path, 896 | make_app: Callable[..., SphinxTestApp], 897 | ) -> None: 898 | """ 899 | When ``substitutions_default_enabled`` is True, ``literalinclude`` should 900 | apply content substitutions by default without requiring the ``:content- 901 | substitutions:`` flag. 902 | """ 903 | source_directory = tmp_path / "source" 904 | source_directory.mkdir() 905 | source_file = source_directory / "index.rst" 906 | (source_directory / "conf.py").touch() 907 | 908 | include_file = source_directory / "example.txt" 909 | include_file.write_text(data="Content with |a| placeholder") 910 | 911 | source_file_content = dedent( 912 | text="""\ 913 | .. |a| replace:: example_substitution 914 | 915 | .. literalinclude:: example.txt 916 | """, 917 | ) 918 | source_file.write_text(data=source_file_content) 919 | app = make_app( 920 | srcdir=source_directory, 921 | exception_on_warning=True, 922 | confoverrides={ 923 | "extensions": ["sphinx_substitution_extensions"], 924 | "substitutions_default_enabled": True, 925 | }, 926 | ) 927 | app.build() 928 | assert app.statuscode == 0 929 | content_html = (app.outdir / "index.html").read_text() 930 | app.cleanup() 931 | 932 | include_file.write_text( 933 | data="Content with example_substitution placeholder" 934 | ) 935 | 936 | equivalent_source = dedent( 937 | text="""\ 938 | .. literalinclude:: example.txt 939 | """, 940 | ) 941 | 942 | source_file.write_text(data=equivalent_source) 943 | app_expected = make_app( 944 | srcdir=source_directory, 945 | exception_on_warning=True, 946 | ) 947 | app_expected.build() 948 | assert app_expected.statuscode == 0 949 | 950 | expected_content_html = (app_expected.outdir / "index.html").read_text() 951 | assert content_html == expected_content_html 952 | 953 | 954 | def test_default_substitutions_literal_include_path( 955 | tmp_path: Path, 956 | make_app: Callable[..., SphinxTestApp], 957 | ) -> None: 958 | """ 959 | When ``substitutions_default_enabled`` is True, ``literalinclude`` should 960 | apply path substitutions by default without requiring the ``:path- 961 | substitutions:`` flag. 962 | """ 963 | source_directory = tmp_path / "source" 964 | source_directory.mkdir() 965 | source_file = source_directory / "index.rst" 966 | (source_directory / "conf.py").touch() 967 | 968 | include_file = source_directory / "example_substitution.txt" 969 | include_file.write_text(data="File content") 970 | 971 | source_file_content = dedent( 972 | text="""\ 973 | .. |a| replace:: example_substitution 974 | 975 | .. literalinclude:: |a|.txt 976 | """, 977 | ) 978 | source_file.write_text(data=source_file_content) 979 | app = make_app( 980 | srcdir=source_directory, 981 | exception_on_warning=True, 982 | confoverrides={ 983 | "extensions": ["sphinx_substitution_extensions"], 984 | "substitutions_default_enabled": True, 985 | }, 986 | ) 987 | app.build() 988 | assert app.statuscode == 0 989 | content_html = (app.outdir / "index.html").read_text() 990 | app.cleanup() 991 | 992 | equivalent_source = dedent( 993 | text="""\ 994 | .. literalinclude:: example_substitution.txt 995 | """, 996 | ) 997 | 998 | source_file.write_text(data=equivalent_source) 999 | app_expected = make_app( 1000 | srcdir=source_directory, 1001 | exception_on_warning=True, 1002 | ) 1003 | app_expected.build() 1004 | assert app_expected.statuscode == 0 1005 | 1006 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1007 | assert content_html == expected_content_html 1008 | 1009 | 1010 | def test_default_substitutions_literal_include_disabled_content( 1011 | tmp_path: Path, 1012 | make_app: Callable[..., SphinxTestApp], 1013 | ) -> None: 1014 | """ 1015 | When ``substitutions_default_enabled`` is True but ``literalinclude`` has 1016 | the ``:nocontent-substitutions:`` flag, content substitutions should not be 1017 | applied. 1018 | """ 1019 | source_directory = tmp_path / "source" 1020 | source_directory.mkdir() 1021 | source_file = source_directory / "index.rst" 1022 | (source_directory / "conf.py").touch() 1023 | 1024 | include_file = source_directory / "example.txt" 1025 | include_file.write_text(data="Content with |a| placeholder") 1026 | 1027 | source_file_content = dedent( 1028 | text="""\ 1029 | .. |a| replace:: example_substitution 1030 | 1031 | .. literalinclude:: example.txt 1032 | :nocontent-substitutions: 1033 | """, 1034 | ) 1035 | source_file.write_text(data=source_file_content) 1036 | app = make_app( 1037 | srcdir=source_directory, 1038 | exception_on_warning=True, 1039 | confoverrides={ 1040 | "extensions": ["sphinx_substitution_extensions"], 1041 | "substitutions_default_enabled": True, 1042 | }, 1043 | ) 1044 | app.build() 1045 | assert app.statuscode == 0 1046 | content_html = (app.outdir / "index.html").read_text() 1047 | app.cleanup() 1048 | 1049 | equivalent_source = dedent( 1050 | text="""\ 1051 | .. literalinclude:: example.txt 1052 | """, 1053 | ) 1054 | 1055 | source_file.write_text(data=equivalent_source) 1056 | app_expected = make_app( 1057 | srcdir=source_directory, 1058 | exception_on_warning=True, 1059 | freshenv=True, 1060 | ) 1061 | app_expected.build() 1062 | assert app_expected.statuscode == 0 1063 | 1064 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1065 | assert content_html == expected_content_html 1066 | 1067 | 1068 | def test_default_substitutions_literal_include_disabled_path( 1069 | tmp_path: Path, 1070 | make_app: Callable[..., SphinxTestApp], 1071 | ) -> None: 1072 | """When ``substitutions_default_enabled`` is True but ``literalinclude`` 1073 | has the ``:nopath-substitutions:`` flag, path substitutions should not be 1074 | applied. 1075 | 1076 | Note: This test uses MyST format with custom delimiters because the `|` 1077 | character cannot be used in Windows file paths. 1078 | """ 1079 | source_directory = tmp_path / "source" 1080 | source_directory.mkdir() 1081 | index_source_file = source_directory / "index.rst" 1082 | markdown_source_file = source_directory / "markdown_document.md" 1083 | (source_directory / "conf.py").touch() 1084 | 1085 | # Use custom delimiters [[a]] instead of |a| because | is not allowed 1086 | # in Windows file paths 1087 | include_file = source_directory / "[[a]].txt" 1088 | include_file.write_text(data="File content") 1089 | 1090 | index_source_file_content = dedent( 1091 | text="""\ 1092 | .. toctree:: 1093 | 1094 | markdown_document 1095 | """, 1096 | ) 1097 | markdown_source_file_content = dedent( 1098 | text="""\ 1099 | # Title 1100 | 1101 | ```{literalinclude} [[a]].txt 1102 | :nopath-substitutions: 1103 | ``` 1104 | """, 1105 | ) 1106 | index_source_file.write_text(data=index_source_file_content) 1107 | markdown_source_file.write_text(data=markdown_source_file_content) 1108 | app = make_app( 1109 | srcdir=source_directory, 1110 | exception_on_warning=True, 1111 | confoverrides={ 1112 | "extensions": [ 1113 | "myst_parser", 1114 | "sphinx_substitution_extensions", 1115 | ], 1116 | "myst_enable_extensions": ["substitution"], 1117 | "myst_substitutions": { 1118 | "a": "example_substitution", 1119 | }, 1120 | "myst_sub_delimiters": ("[", "]"), 1121 | "substitutions_default_enabled": True, 1122 | }, 1123 | ) 1124 | app.build() 1125 | assert app.statuscode == 0 1126 | content_html = (app.outdir / "markdown_document.html").read_text() 1127 | app.cleanup() 1128 | 1129 | equivalent_source = dedent( 1130 | text="""\ 1131 | # Title 1132 | 1133 | ```{literalinclude} [[a]].txt 1134 | ``` 1135 | """, 1136 | ) 1137 | 1138 | markdown_source_file.write_text(data=equivalent_source) 1139 | app_expected = make_app( 1140 | srcdir=source_directory, 1141 | exception_on_warning=True, 1142 | confoverrides={"extensions": ["myst_parser"]}, 1143 | freshenv=True, 1144 | ) 1145 | app_expected.build() 1146 | assert app_expected.statuscode == 0 1147 | 1148 | expected_content_html = ( 1149 | app_expected.outdir / "markdown_document.html" 1150 | ).read_text() 1151 | assert content_html == expected_content_html 1152 | 1153 | 1154 | class TestMyst: 1155 | """ 1156 | Tests for MyST documents. 1157 | """ 1158 | 1159 | @staticmethod 1160 | def test_myst_substitutions_ignored_given_rst_definition( 1161 | tmp_path: Path, 1162 | make_app: Callable[..., SphinxTestApp], 1163 | ) -> None: 1164 | """ 1165 | MyST substitutions are ignored in rST documents with a rST substitution 1166 | definition. 1167 | """ 1168 | source_directory = tmp_path / "source" 1169 | source_directory.mkdir() 1170 | source_file = source_directory / "index.rst" 1171 | (source_directory / "conf.py").touch() 1172 | index_source_file_content = dedent( 1173 | text="""\ 1174 | .. |a| replace:: rst_prolog_substitution 1175 | 1176 | .. code-block:: shell 1177 | :substitutions: 1178 | 1179 | $ PRE-|a|-POST 1180 | """, 1181 | ) 1182 | source_file.write_text(data=index_source_file_content) 1183 | 1184 | app = make_app( 1185 | srcdir=source_directory, 1186 | exception_on_warning=True, 1187 | confoverrides={ 1188 | "extensions": [ 1189 | "myst_parser", 1190 | "sphinx_substitution_extensions", 1191 | ], 1192 | "myst_enable_extensions": ["substitution"], 1193 | "myst_substitutions": { 1194 | "a": "myst_substitution", 1195 | }, 1196 | }, 1197 | ) 1198 | app.build() 1199 | assert app.statuscode == 0 1200 | content_html = (app.outdir / "index.html").read_text() 1201 | app.cleanup() 1202 | 1203 | equivalent_source = dedent( 1204 | text="""\ 1205 | .. code-block:: shell 1206 | 1207 | $ PRE-rst_prolog_substitution-POST 1208 | """, 1209 | ) 1210 | 1211 | source_file.write_text(data=equivalent_source) 1212 | app_expected = make_app( 1213 | srcdir=source_directory, 1214 | exception_on_warning=True, 1215 | ) 1216 | app_expected.build() 1217 | assert app_expected.statuscode == 0 1218 | 1219 | expected_content_html = ( 1220 | app_expected.outdir / "index.html" 1221 | ).read_text() 1222 | assert content_html == expected_content_html 1223 | 1224 | @staticmethod 1225 | def test_myst_substitutions_ignored_without_rst_definition( 1226 | tmp_path: Path, 1227 | make_app: Callable[..., SphinxTestApp], 1228 | ) -> None: 1229 | """ 1230 | MyST substitutions are ignored in rST documents without a rST 1231 | substitution definition. 1232 | """ 1233 | source_directory = tmp_path / "source" 1234 | source_directory.mkdir() 1235 | source_file = source_directory / "index.rst" 1236 | (source_directory / "conf.py").touch() 1237 | source_file_content = dedent( 1238 | text="""\ 1239 | .. code-block:: shell 1240 | :substitutions: 1241 | 1242 | $ PRE-|a|-POST 1243 | """, 1244 | ) 1245 | source_file.write_text(data=source_file_content) 1246 | 1247 | app = make_app( 1248 | srcdir=source_directory, 1249 | exception_on_warning=True, 1250 | confoverrides={ 1251 | "extensions": [ 1252 | "myst_parser", 1253 | "sphinx_substitution_extensions", 1254 | ], 1255 | "myst_enable_extensions": ["substitution"], 1256 | "myst_substitutions": { 1257 | "a": "myst_substitution", 1258 | }, 1259 | }, 1260 | ) 1261 | app.build() 1262 | assert app.statuscode == 0 1263 | content_html = (app.outdir / "index.html").read_text() 1264 | app.cleanup() 1265 | 1266 | equivalent_source = dedent( 1267 | text="""\ 1268 | .. code-block:: shell 1269 | 1270 | $ PRE-|a|-POST 1271 | """, 1272 | ) 1273 | 1274 | source_file.write_text(data=equivalent_source) 1275 | app_expected = make_app( 1276 | srcdir=source_directory, 1277 | exception_on_warning=True, 1278 | ) 1279 | app_expected.build() 1280 | assert app_expected.statuscode == 0 1281 | 1282 | expected_content_html = ( 1283 | app_expected.outdir / "index.html" 1284 | ).read_text() 1285 | assert content_html == expected_content_html 1286 | 1287 | @staticmethod 1288 | def test_myst_substitutions( 1289 | tmp_path: Path, 1290 | make_app: Callable[..., SphinxTestApp], 1291 | ) -> None: 1292 | """ 1293 | MyST substitutions are respected in MyST documents. 1294 | """ 1295 | source_directory = tmp_path / "source" 1296 | source_directory.mkdir() 1297 | index_source_file = source_directory / "index.rst" 1298 | markdown_source_file = source_directory / "markdown_document.md" 1299 | (source_directory / "conf.py").touch() 1300 | index_source_file_content = dedent( 1301 | text="""\ 1302 | .. toctree:: 1303 | 1304 | markdown_document 1305 | """, 1306 | ) 1307 | markdown_source_file_content = dedent( 1308 | text="""\ 1309 | # Title 1310 | 1311 | ```{code-block} 1312 | :substitutions: 1313 | 1314 | $ PRE-|a|-POST 1315 | ``` 1316 | """, 1317 | ) 1318 | index_source_file.write_text(data=index_source_file_content) 1319 | markdown_source_file.write_text(data=markdown_source_file_content) 1320 | 1321 | app = make_app( 1322 | srcdir=source_directory, 1323 | exception_on_warning=True, 1324 | confoverrides={ 1325 | "extensions": [ 1326 | "myst_parser", 1327 | "sphinx_substitution_extensions", 1328 | ], 1329 | "myst_enable_extensions": ["substitution"], 1330 | "myst_substitutions": { 1331 | "a": "example_substitution", 1332 | }, 1333 | }, 1334 | ) 1335 | app.build() 1336 | assert app.statuscode == 0 1337 | content_html = (app.outdir / "markdown_document.html").read_text() 1338 | app.cleanup() 1339 | 1340 | equivalent_source = dedent( 1341 | text="""\ 1342 | # Title 1343 | 1344 | ```{code-block} 1345 | 1346 | $ PRE-example_substitution-POST 1347 | ``` 1348 | """, 1349 | ) 1350 | 1351 | markdown_source_file.write_text(data=equivalent_source) 1352 | app_expected = make_app( 1353 | srcdir=source_directory, 1354 | exception_on_warning=True, 1355 | confoverrides={"extensions": ["myst_parser"]}, 1356 | ) 1357 | app_expected.build() 1358 | assert app_expected.statuscode == 0 1359 | 1360 | expected_content_html = ( 1361 | app_expected.outdir / "markdown_document.html" 1362 | ).read_text() 1363 | assert content_html == expected_content_html 1364 | 1365 | 1366 | def test_no_substitution_image( 1367 | tmp_path: Path, 1368 | make_app: Callable[..., SphinxTestApp], 1369 | ) -> None: 1370 | """The ``image`` directive does not replace custom placeholders by default. 1371 | 1372 | Note: reST by default processes |substitutions| in image paths, but 1373 | our extension adds the ability to use custom delimiters like {{var}}. 1374 | """ 1375 | source_directory = tmp_path / "source" 1376 | source_directory.mkdir() 1377 | source_file = source_directory / "index.rst" 1378 | (source_directory / "conf.py").touch() 1379 | 1380 | image_file = source_directory / "test_image.png" 1381 | png_data = ( 1382 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1383 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1384 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1385 | ) 1386 | image_file.write_bytes(data=png_data) 1387 | 1388 | source_file_content = dedent( 1389 | text="""\ 1390 | .. |a| replace:: test_image 1391 | 1392 | .. image:: test_image.png 1393 | """, 1394 | ) 1395 | source_file.write_text(data=source_file_content) 1396 | app = make_app( 1397 | srcdir=source_directory, 1398 | exception_on_warning=True, 1399 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 1400 | ) 1401 | app.build() 1402 | assert app.statuscode == 0 1403 | content_html = (app.outdir / "index.html").read_text() 1404 | app.cleanup() 1405 | 1406 | app_expected = make_app( 1407 | srcdir=source_directory, 1408 | exception_on_warning=True, 1409 | freshenv=True, 1410 | ) 1411 | 1412 | app_expected.build() 1413 | assert app_expected.statuscode == 0 1414 | 1415 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1416 | 1417 | # The behavior should be the same with or without our extension 1418 | # when not using :path-substitutions: 1419 | assert content_html == expected_content_html 1420 | 1421 | 1422 | def test_substitution_image_path( 1423 | tmp_path: Path, 1424 | make_app: Callable[..., SphinxTestApp], 1425 | ) -> None: 1426 | """ 1427 | The ``image`` directive replaces placeholders in the file path when the 1428 | ``:path-substitutions:`` flag is set. 1429 | """ 1430 | source_directory = tmp_path / "source" 1431 | source_directory.mkdir() 1432 | source_file = source_directory / "index.rst" 1433 | (source_directory / "conf.py").touch() 1434 | 1435 | # Create a simple image file with substitution in the name 1436 | image_file = source_directory / "test_image.png" 1437 | png_data = ( 1438 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1439 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1440 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1441 | ) 1442 | image_file.write_bytes(data=png_data) 1443 | 1444 | source_file_content = dedent( 1445 | text="""\ 1446 | .. |a| replace:: test_image 1447 | 1448 | .. image:: |a|.png 1449 | :path-substitutions: 1450 | """, 1451 | ) 1452 | source_file.write_text(data=source_file_content) 1453 | app = make_app( 1454 | srcdir=source_directory, 1455 | exception_on_warning=True, 1456 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 1457 | ) 1458 | app.build() 1459 | assert app.statuscode == 0 1460 | content_html = (app.outdir / "index.html").read_text() 1461 | app.cleanup() 1462 | 1463 | # Compare with directly using the filename 1464 | equivalent_source = dedent( 1465 | text="""\ 1466 | .. image:: test_image.png 1467 | """, 1468 | ) 1469 | 1470 | source_file.write_text(data=equivalent_source) 1471 | app_expected = make_app( 1472 | srcdir=source_directory, 1473 | exception_on_warning=True, 1474 | ) 1475 | app_expected.build() 1476 | assert app_expected.statuscode == 0 1477 | 1478 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1479 | assert content_html == expected_content_html 1480 | 1481 | 1482 | def test_substitution_image_path_multiple( 1483 | tmp_path: Path, 1484 | make_app: Callable[..., SphinxTestApp], 1485 | ) -> None: 1486 | """ 1487 | The ``image`` directive replaces multiple placeholders in the file path. 1488 | """ 1489 | source_directory = tmp_path / "source" 1490 | source_directory.mkdir() 1491 | source_file = source_directory / "index.rst" 1492 | (source_directory / "conf.py").touch() 1493 | 1494 | # Create an image file with multiple substitutions in the name 1495 | image_file = source_directory / "pre_test_mid_image_post.png" 1496 | png_data = ( 1497 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1498 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1499 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1500 | ) 1501 | image_file.write_bytes(data=png_data) 1502 | 1503 | source_file_content = dedent( 1504 | text="""\ 1505 | .. |a| replace:: test 1506 | .. |b| replace:: image 1507 | 1508 | .. image:: pre_|a|_mid_|b|_post.png 1509 | :path-substitutions: 1510 | """, 1511 | ) 1512 | source_file.write_text(data=source_file_content) 1513 | app = make_app( 1514 | srcdir=source_directory, 1515 | exception_on_warning=True, 1516 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 1517 | ) 1518 | app.build() 1519 | assert app.statuscode == 0 1520 | content_html = (app.outdir / "index.html").read_text() 1521 | app.cleanup() 1522 | 1523 | # Compare with directly using the filename 1524 | equivalent_source = dedent( 1525 | text="""\ 1526 | .. image:: pre_test_mid_image_post.png 1527 | """, 1528 | ) 1529 | 1530 | source_file.write_text(data=equivalent_source) 1531 | app_expected = make_app( 1532 | srcdir=source_directory, 1533 | exception_on_warning=True, 1534 | ) 1535 | app_expected.build() 1536 | assert app_expected.statuscode == 0 1537 | 1538 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1539 | assert content_html == expected_content_html 1540 | 1541 | 1542 | def test_substitution_image_with_options( 1543 | tmp_path: Path, 1544 | make_app: Callable[..., SphinxTestApp], 1545 | ) -> None: 1546 | """ 1547 | The ``image`` directive works with standard image options. 1548 | """ 1549 | source_directory = tmp_path / "source" 1550 | source_directory.mkdir() 1551 | source_file = source_directory / "index.rst" 1552 | (source_directory / "conf.py").touch() 1553 | 1554 | image_file = source_directory / "test_image.png" 1555 | png_data = ( 1556 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1557 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1558 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1559 | ) 1560 | image_file.write_bytes(data=png_data) 1561 | 1562 | source_file_content = dedent( 1563 | text="""\ 1564 | .. |a| replace:: test_image 1565 | 1566 | .. image:: |a|.png 1567 | :path-substitutions: 1568 | :alt: Test image alt text 1569 | :width: 100px 1570 | """, 1571 | ) 1572 | source_file.write_text(data=source_file_content) 1573 | app = make_app( 1574 | srcdir=source_directory, 1575 | exception_on_warning=True, 1576 | confoverrides={"extensions": ["sphinx_substitution_extensions"]}, 1577 | ) 1578 | app.build() 1579 | assert app.statuscode == 0 1580 | content_html = (app.outdir / "index.html").read_text() 1581 | app.cleanup() 1582 | 1583 | equivalent_source = dedent( 1584 | text="""\ 1585 | .. image:: test_image.png 1586 | :alt: Test image alt text 1587 | :width: 100px 1588 | """, 1589 | ) 1590 | 1591 | source_file.write_text(data=equivalent_source) 1592 | app_expected = make_app( 1593 | srcdir=source_directory, 1594 | exception_on_warning=True, 1595 | ) 1596 | app_expected.build() 1597 | assert app_expected.statuscode == 0 1598 | 1599 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1600 | assert content_html == expected_content_html 1601 | 1602 | 1603 | def test_default_substitutions_image_path( 1604 | tmp_path: Path, 1605 | make_app: Callable[..., SphinxTestApp], 1606 | ) -> None: 1607 | """ 1608 | When ``substitutions_default_enabled`` is True, ``image`` should apply path 1609 | substitutions by default without requiring the ``:path-substitutions:`` 1610 | flag. 1611 | """ 1612 | source_directory = tmp_path / "source" 1613 | source_directory.mkdir() 1614 | source_file = source_directory / "index.rst" 1615 | (source_directory / "conf.py").touch() 1616 | 1617 | image_file = source_directory / "test_image.png" 1618 | png_data = ( 1619 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1620 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1621 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1622 | ) 1623 | image_file.write_bytes(data=png_data) 1624 | 1625 | source_file_content = dedent( 1626 | text="""\ 1627 | .. |a| replace:: test_image 1628 | 1629 | .. image:: |a|.png 1630 | """, 1631 | ) 1632 | source_file.write_text(data=source_file_content) 1633 | app = make_app( 1634 | srcdir=source_directory, 1635 | exception_on_warning=True, 1636 | confoverrides={ 1637 | "extensions": ["sphinx_substitution_extensions"], 1638 | "substitutions_default_enabled": True, 1639 | }, 1640 | ) 1641 | app.build() 1642 | assert app.statuscode == 0 1643 | content_html = (app.outdir / "index.html").read_text() 1644 | app.cleanup() 1645 | 1646 | equivalent_source = dedent( 1647 | text="""\ 1648 | .. image:: test_image.png 1649 | """, 1650 | ) 1651 | 1652 | source_file.write_text(data=equivalent_source) 1653 | app_expected = make_app( 1654 | srcdir=source_directory, 1655 | exception_on_warning=True, 1656 | ) 1657 | app_expected.build() 1658 | assert app_expected.statuscode == 0 1659 | 1660 | expected_content_html = (app_expected.outdir / "index.html").read_text() 1661 | assert content_html == expected_content_html 1662 | 1663 | 1664 | def test_default_substitutions_image_disabled_path( 1665 | tmp_path: Path, 1666 | make_app: Callable[..., SphinxTestApp], 1667 | ) -> None: 1668 | """When ``substitutions_default_enabled`` is True but ``image`` has the 1669 | ``:nopath-substitutions:`` flag, path substitutions should not be applied. 1670 | 1671 | Note: This test uses MyST format with custom delimiters because the `|` 1672 | character cannot be used in Windows file paths. 1673 | """ 1674 | source_directory = tmp_path / "source" 1675 | source_directory.mkdir() 1676 | index_source_file = source_directory / "index.rst" 1677 | markdown_source_file = source_directory / "markdown_document.md" 1678 | (source_directory / "conf.py").touch() 1679 | 1680 | # Create an image file with the literal [[a]] in the filename 1681 | image_file = source_directory / "[[a]].png" 1682 | png_data = ( 1683 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1684 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1685 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1686 | ) 1687 | image_file.write_bytes(data=png_data) 1688 | 1689 | index_source_file_content = dedent( 1690 | text="""\ 1691 | .. toctree:: 1692 | 1693 | markdown_document 1694 | """, 1695 | ) 1696 | markdown_source_file_content = dedent( 1697 | text="""\ 1698 | # Title 1699 | 1700 | ```{image} [[a]].png 1701 | :nopath-substitutions: 1702 | ``` 1703 | """, 1704 | ) 1705 | index_source_file.write_text(data=index_source_file_content) 1706 | markdown_source_file.write_text(data=markdown_source_file_content) 1707 | app = make_app( 1708 | srcdir=source_directory, 1709 | exception_on_warning=True, 1710 | confoverrides={ 1711 | "extensions": [ 1712 | "myst_parser", 1713 | "sphinx_substitution_extensions", 1714 | ], 1715 | "myst_enable_extensions": ["substitution"], 1716 | "myst_substitutions": { 1717 | "a": "example_substitution", 1718 | }, 1719 | "myst_sub_delimiters": ("[", "]"), 1720 | "substitutions_default_enabled": True, 1721 | }, 1722 | ) 1723 | app.build() 1724 | assert app.statuscode == 0 1725 | content_html = (app.outdir / "markdown_document.html").read_text() 1726 | app.cleanup() 1727 | 1728 | equivalent_source = dedent( 1729 | text="""\ 1730 | # Title 1731 | 1732 | ```{image} [[a]].png 1733 | ``` 1734 | """, 1735 | ) 1736 | 1737 | markdown_source_file.write_text(data=equivalent_source) 1738 | app_expected = make_app( 1739 | srcdir=source_directory, 1740 | exception_on_warning=True, 1741 | confoverrides={"extensions": ["myst_parser"]}, 1742 | freshenv=True, 1743 | ) 1744 | app_expected.build() 1745 | assert app_expected.statuscode == 0 1746 | 1747 | expected_content_html = ( 1748 | app_expected.outdir / "markdown_document.html" 1749 | ).read_text() 1750 | assert content_html == expected_content_html 1751 | 1752 | 1753 | class TestImageMyst: 1754 | """ 1755 | Tests for image directive with MyST documents. 1756 | """ 1757 | 1758 | @staticmethod 1759 | def test_myst_substitutions_image( 1760 | tmp_path: Path, 1761 | make_app: Callable[..., SphinxTestApp], 1762 | ) -> None: 1763 | """ 1764 | MyST substitutions are respected in image paths in MyST documents. 1765 | """ 1766 | source_directory = tmp_path / "source" 1767 | source_directory.mkdir() 1768 | index_source_file = source_directory / "index.rst" 1769 | markdown_source_file = source_directory / "markdown_document.md" 1770 | (source_directory / "conf.py").touch() 1771 | 1772 | # Create an image file 1773 | image_file = source_directory / "test_image.png" 1774 | png_data = ( 1775 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1776 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1777 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1778 | ) 1779 | image_file.write_bytes(data=png_data) 1780 | 1781 | index_source_file_content = dedent( 1782 | text="""\ 1783 | .. toctree:: 1784 | 1785 | markdown_document 1786 | """, 1787 | ) 1788 | markdown_source_file_content = dedent( 1789 | text="""\ 1790 | # Title 1791 | 1792 | ```{image} |a|.png 1793 | :path-substitutions: 1794 | ``` 1795 | """, 1796 | ) 1797 | index_source_file.write_text(data=index_source_file_content) 1798 | markdown_source_file.write_text(data=markdown_source_file_content) 1799 | 1800 | app = make_app( 1801 | srcdir=source_directory, 1802 | exception_on_warning=True, 1803 | confoverrides={ 1804 | "extensions": [ 1805 | "myst_parser", 1806 | "sphinx_substitution_extensions", 1807 | ], 1808 | "myst_enable_extensions": ["substitution"], 1809 | "myst_substitutions": { 1810 | "a": "test_image", 1811 | }, 1812 | }, 1813 | ) 1814 | app.build() 1815 | assert app.statuscode == 0 1816 | content_html = (app.outdir / "markdown_document.html").read_text() 1817 | app.cleanup() 1818 | 1819 | equivalent_source = dedent( 1820 | text="""\ 1821 | # Title 1822 | 1823 | ```{image} test_image.png 1824 | ``` 1825 | """, 1826 | ) 1827 | 1828 | markdown_source_file.write_text(data=equivalent_source) 1829 | app_expected = make_app( 1830 | srcdir=source_directory, 1831 | exception_on_warning=True, 1832 | confoverrides={"extensions": ["myst_parser"]}, 1833 | ) 1834 | app_expected.build() 1835 | assert app_expected.statuscode == 0 1836 | 1837 | expected_content_html = ( 1838 | app_expected.outdir / "markdown_document.html" 1839 | ).read_text() 1840 | assert content_html == expected_content_html 1841 | 1842 | @staticmethod 1843 | def test_myst_substitutions_image_default_delimiters( 1844 | tmp_path: Path, 1845 | make_app: Callable[..., SphinxTestApp], 1846 | ) -> None: 1847 | """ 1848 | The default MyST substitution delimiters {{}} are respected for images. 1849 | """ 1850 | source_directory = tmp_path / "source" 1851 | source_directory.mkdir() 1852 | index_source_file = source_directory / "index.rst" 1853 | markdown_source_file = source_directory / "markdown_document.md" 1854 | (source_directory / "conf.py").touch() 1855 | 1856 | image_file = source_directory / "test_image.png" 1857 | png_data = ( 1858 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" 1859 | b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" 1860 | b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" 1861 | ) 1862 | image_file.write_bytes(data=png_data) 1863 | 1864 | index_source_file_content = dedent( 1865 | text="""\ 1866 | .. toctree:: 1867 | 1868 | markdown_document 1869 | """, 1870 | ) 1871 | markdown_source_file_content = dedent( 1872 | text="""\ 1873 | # Title 1874 | 1875 | ```{image} {{a}}.png 1876 | :path-substitutions: 1877 | ``` 1878 | """, 1879 | ) 1880 | index_source_file.write_text(data=index_source_file_content) 1881 | markdown_source_file.write_text(data=markdown_source_file_content) 1882 | 1883 | app = make_app( 1884 | srcdir=source_directory, 1885 | exception_on_warning=True, 1886 | confoverrides={ 1887 | "extensions": [ 1888 | "myst_parser", 1889 | "sphinx_substitution_extensions", 1890 | ], 1891 | "myst_enable_extensions": ["substitution"], 1892 | "myst_substitutions": { 1893 | "a": "test_image", 1894 | }, 1895 | }, 1896 | ) 1897 | app.build() 1898 | assert app.statuscode == 0 1899 | content_html = (app.outdir / "markdown_document.html").read_text() 1900 | app.cleanup() 1901 | 1902 | equivalent_source = dedent( 1903 | text="""\ 1904 | # Title 1905 | 1906 | ```{image} test_image.png 1907 | ``` 1908 | """, 1909 | ) 1910 | 1911 | markdown_source_file.write_text(data=equivalent_source) 1912 | app_expected = make_app( 1913 | srcdir=source_directory, 1914 | exception_on_warning=True, 1915 | confoverrides={"extensions": ["myst_parser"]}, 1916 | ) 1917 | app_expected.build() 1918 | assert app_expected.statuscode == 0 1919 | 1920 | expected_content_html = ( 1921 | app_expected.outdir / "markdown_document.html" 1922 | ).read_text() 1923 | assert content_html == expected_content_html 1924 | 1925 | @staticmethod 1926 | def test_myst_substitutions_not_enabled( 1927 | tmp_path: Path, 1928 | make_app: Callable[..., SphinxTestApp], 1929 | ) -> None: 1930 | """ 1931 | MyST substitutions are not respected in MyST documents when 1932 | ``myst_enable_extensions`` does not contain ``substitutions``. 1933 | """ 1934 | source_directory = tmp_path / "source" 1935 | source_directory.mkdir() 1936 | index_source_file = source_directory / "index.rst" 1937 | markdown_source_file = source_directory / "markdown_document.md" 1938 | (source_directory / "conf.py").touch() 1939 | index_source_file_content = dedent( 1940 | text="""\ 1941 | .. toctree:: 1942 | 1943 | markdown_document 1944 | """, 1945 | ) 1946 | markdown_source_file_content = dedent( 1947 | text="""\ 1948 | # Title 1949 | 1950 | ```{code-block} 1951 | :substitutions: 1952 | 1953 | $ PRE-|a|-POST 1954 | ``` 1955 | """, 1956 | ) 1957 | index_source_file.write_text(data=index_source_file_content) 1958 | markdown_source_file.write_text(data=markdown_source_file_content) 1959 | 1960 | app = make_app( 1961 | srcdir=source_directory, 1962 | exception_on_warning=True, 1963 | confoverrides={ 1964 | "extensions": [ 1965 | "myst_parser", 1966 | "sphinx_substitution_extensions", 1967 | ], 1968 | "myst_substitutions": { 1969 | "a": "example_substitution", 1970 | }, 1971 | }, 1972 | ) 1973 | app.build() 1974 | assert app.statuscode == 0 1975 | content_html = (app.outdir / "markdown_document.html").read_text() 1976 | app.cleanup() 1977 | 1978 | equivalent_source = dedent( 1979 | text="""\ 1980 | # Title 1981 | 1982 | ```{code-block} 1983 | 1984 | $ PRE-|a|-POST 1985 | ``` 1986 | """, 1987 | ) 1988 | 1989 | markdown_source_file.write_text(data=equivalent_source) 1990 | app_expected = make_app( 1991 | srcdir=source_directory, 1992 | exception_on_warning=True, 1993 | confoverrides={"extensions": ["myst_parser"]}, 1994 | ) 1995 | app_expected.build() 1996 | assert app_expected.statuscode == 0 1997 | 1998 | expected_content_html = ( 1999 | app_expected.outdir / "markdown_document.html" 2000 | ).read_text() 2001 | assert content_html == expected_content_html 2002 | 2003 | @staticmethod 2004 | def test_myst_substitutions_custom_markdown_suffix( 2005 | tmp_path: Path, 2006 | make_app: Callable[..., SphinxTestApp], 2007 | ) -> None: 2008 | """ 2009 | Custom markdown suffixes are respected in MyST documents. 2010 | """ 2011 | source_directory = tmp_path / "source" 2012 | source_directory.mkdir() 2013 | index_source_file = source_directory / "index.rst" 2014 | markdown_source_file = source_directory / "markdown_document.txt" 2015 | (source_directory / "conf.py").touch() 2016 | index_source_file_content = dedent( 2017 | text="""\ 2018 | .. toctree:: 2019 | 2020 | markdown_document 2021 | """, 2022 | ) 2023 | markdown_source_file_content = dedent( 2024 | text="""\ 2025 | # Title 2026 | 2027 | ```{code-block} 2028 | :substitutions: 2029 | 2030 | $ PRE-|a|-POST 2031 | ``` 2032 | """, 2033 | ) 2034 | index_source_file.write_text(data=index_source_file_content) 2035 | markdown_source_file.write_text(data=markdown_source_file_content) 2036 | 2037 | app = make_app( 2038 | srcdir=source_directory, 2039 | exception_on_warning=True, 2040 | confoverrides={ 2041 | "extensions": [ 2042 | "myst_parser", 2043 | "sphinx_substitution_extensions", 2044 | ], 2045 | "myst_enable_extensions": ["substitution"], 2046 | "myst_substitutions": { 2047 | "a": "example_substitution", 2048 | }, 2049 | "source_suffix": { 2050 | ".rst": "restructuredtext", 2051 | ".txt": "markdown", 2052 | }, 2053 | }, 2054 | ) 2055 | app.build() 2056 | assert app.statuscode == 0 2057 | content_html = (app.outdir / "markdown_document.html").read_text() 2058 | app.cleanup() 2059 | 2060 | equivalent_source = dedent( 2061 | text="""\ 2062 | # Title 2063 | 2064 | ```{code-block} 2065 | 2066 | $ PRE-example_substitution-POST 2067 | ``` 2068 | """, 2069 | ) 2070 | 2071 | markdown_source_file.write_text(data=equivalent_source) 2072 | app_expected = make_app( 2073 | srcdir=source_directory, 2074 | exception_on_warning=True, 2075 | confoverrides={ 2076 | "extensions": ["myst_parser"], 2077 | "source_suffix": { 2078 | ".rst": "restructuredtext", 2079 | ".txt": "markdown", 2080 | }, 2081 | }, 2082 | ) 2083 | app_expected.build() 2084 | assert app_expected.statuscode == 0 2085 | 2086 | expected_content_html = ( 2087 | app_expected.outdir / "markdown_document.html" 2088 | ).read_text() 2089 | assert content_html == expected_content_html 2090 | 2091 | @staticmethod 2092 | def test_default_myst_sub_delimiters_code_block( 2093 | tmp_path: Path, 2094 | make_app: Callable[..., SphinxTestApp], 2095 | ) -> None: 2096 | """ 2097 | The default MyST substitution delimiters are respected. 2098 | """ 2099 | source_directory = tmp_path / "source" 2100 | source_directory.mkdir() 2101 | index_source_file = source_directory / "index.rst" 2102 | markdown_source_file = source_directory / "markdown_document.md" 2103 | (source_directory / "conf.py").touch() 2104 | index_source_file_content = dedent( 2105 | text="""\ 2106 | .. toctree:: 2107 | 2108 | markdown_document 2109 | """, 2110 | ) 2111 | markdown_source_file_content = dedent( 2112 | text="""\ 2113 | # Title 2114 | 2115 | ```{code-block} 2116 | :substitutions: 2117 | 2118 | $ PRE-{{a}}-POST 2119 | ``` 2120 | """, 2121 | ) 2122 | index_source_file.write_text(data=index_source_file_content) 2123 | markdown_source_file.write_text(data=markdown_source_file_content) 2124 | 2125 | app = make_app( 2126 | srcdir=source_directory, 2127 | exception_on_warning=True, 2128 | confoverrides={ 2129 | "extensions": [ 2130 | "myst_parser", 2131 | "sphinx_substitution_extensions", 2132 | ], 2133 | "myst_enable_extensions": ["substitution"], 2134 | "myst_substitutions": { 2135 | "a": "example_substitution", 2136 | }, 2137 | }, 2138 | ) 2139 | app.build() 2140 | assert app.statuscode == 0 2141 | content_html = (app.outdir / "markdown_document.html").read_text() 2142 | app.cleanup() 2143 | 2144 | equivalent_source = dedent( 2145 | text="""\ 2146 | # Title 2147 | 2148 | ```{code-block} 2149 | 2150 | $ PRE-example_substitution-POST 2151 | ``` 2152 | """, 2153 | ) 2154 | 2155 | markdown_source_file.write_text(data=equivalent_source) 2156 | app_expected = make_app( 2157 | srcdir=source_directory, 2158 | exception_on_warning=True, 2159 | confoverrides={"extensions": ["myst_parser"]}, 2160 | ) 2161 | app_expected.build() 2162 | assert app_expected.statuscode == 0 2163 | 2164 | expected_content_html = ( 2165 | app_expected.outdir / "markdown_document.html" 2166 | ).read_text() 2167 | assert content_html == expected_content_html 2168 | 2169 | @staticmethod 2170 | def test_custom_myst_sub_delimiters_code_block( 2171 | tmp_path: Path, 2172 | make_app: Callable[..., SphinxTestApp], 2173 | ) -> None: 2174 | """ 2175 | Custom MyST substitution delimiters are respected. 2176 | """ 2177 | source_directory = tmp_path / "source" 2178 | source_directory.mkdir() 2179 | index_source_file = source_directory / "index.rst" 2180 | markdown_source_file = source_directory / "markdown_document.md" 2181 | (source_directory / "conf.py").touch() 2182 | index_source_file_content = dedent( 2183 | text="""\ 2184 | .. toctree:: 2185 | 2186 | markdown_document 2187 | """, 2188 | ) 2189 | markdown_source_file_content = dedent( 2190 | text="""\ 2191 | # Title 2192 | 2193 | ```{code-block} 2194 | :substitutions: 2195 | 2196 | $ PRE-[[a]]-POST 2197 | ``` 2198 | """, 2199 | ) 2200 | index_source_file.write_text(data=index_source_file_content) 2201 | markdown_source_file.write_text(data=markdown_source_file_content) 2202 | 2203 | app = make_app( 2204 | srcdir=source_directory, 2205 | exception_on_warning=True, 2206 | confoverrides={ 2207 | "extensions": [ 2208 | "myst_parser", 2209 | "sphinx_substitution_extensions", 2210 | ], 2211 | "myst_enable_extensions": ["substitution"], 2212 | "myst_substitutions": { 2213 | "a": "example_substitution", 2214 | }, 2215 | "myst_sub_delimiters": ("[", "]"), 2216 | }, 2217 | ) 2218 | app.build() 2219 | assert app.statuscode == 0 2220 | content_html = (app.outdir / "markdown_document.html").read_text() 2221 | app.cleanup() 2222 | 2223 | equivalent_source = dedent( 2224 | text="""\ 2225 | # Title 2226 | 2227 | ```{code-block} 2228 | 2229 | $ PRE-example_substitution-POST 2230 | ``` 2231 | """, 2232 | ) 2233 | 2234 | markdown_source_file.write_text(data=equivalent_source) 2235 | app_expected = make_app( 2236 | srcdir=source_directory, 2237 | exception_on_warning=True, 2238 | confoverrides={"extensions": ["myst_parser"]}, 2239 | ) 2240 | app_expected.build() 2241 | assert app_expected.statuscode == 0 2242 | 2243 | expected_content_html = ( 2244 | app_expected.outdir / "markdown_document.html" 2245 | ).read_text() 2246 | assert content_html == expected_content_html 2247 | 2248 | @staticmethod 2249 | def test_substitution_code_role( 2250 | tmp_path: Path, 2251 | make_app: Callable[..., SphinxTestApp], 2252 | ) -> None: 2253 | """ 2254 | The ``substitution-code`` role replaces the placeholders defined in 2255 | ``conf.py`` as specified. 2256 | """ 2257 | source_directory = tmp_path / "source" 2258 | source_directory.mkdir() 2259 | index_source_file = source_directory / "index.rst" 2260 | markdown_source_file = source_directory / "markdown_document.md" 2261 | (source_directory / "conf.py").touch() 2262 | 2263 | index_source_file_content = dedent( 2264 | text="""\ 2265 | .. toctree:: 2266 | 2267 | markdown_document 2268 | """, 2269 | ) 2270 | markdown_source_file_content = dedent( 2271 | text="""\ 2272 | # Title 2273 | 2274 | Example {substitution-code}`PRE-|a|-POST` 2275 | """, 2276 | ) 2277 | index_source_file.write_text(data=index_source_file_content) 2278 | markdown_source_file.write_text(data=markdown_source_file_content) 2279 | app = make_app( 2280 | srcdir=source_directory, 2281 | exception_on_warning=True, 2282 | confoverrides={ 2283 | "extensions": [ 2284 | "myst_parser", 2285 | "sphinx_substitution_extensions", 2286 | ], 2287 | "myst_enable_extensions": ["substitution"], 2288 | "myst_substitutions": { 2289 | "a": "example_substitution", 2290 | }, 2291 | }, 2292 | ) 2293 | app.build() 2294 | assert app.statuscode == 0 2295 | content_html = (app.outdir / "markdown_document.html").read_text() 2296 | app.cleanup() 2297 | 2298 | equivalent_source = dedent( 2299 | text="""\ 2300 | # Title 2301 | 2302 | Example {code}`PRE-example_substitution-POST` 2303 | """, 2304 | ) 2305 | 2306 | markdown_source_file.write_text(data=equivalent_source) 2307 | app_expected = make_app( 2308 | srcdir=source_directory, 2309 | exception_on_warning=True, 2310 | confoverrides={"extensions": ["myst_parser"]}, 2311 | ) 2312 | app_expected.build() 2313 | assert app_expected.statuscode == 0 2314 | 2315 | expected_content_html = ( 2316 | app_expected.outdir / "markdown_document.html" 2317 | ).read_text() 2318 | assert content_html == expected_content_html 2319 | 2320 | @staticmethod 2321 | def test_substitution_download( 2322 | tmp_path: Path, 2323 | make_app: Callable[..., SphinxTestApp], 2324 | ) -> None: 2325 | """ 2326 | The ``substitution-download`` role replaces the placeholders defined in 2327 | ``conf.py`` as specified. 2328 | """ 2329 | source_directory = tmp_path / "source" 2330 | source_directory.mkdir() 2331 | index_source_file = source_directory / "index.rst" 2332 | markdown_source_file = source_directory / "markdown_document.md" 2333 | (source_directory / "conf.py").touch() 2334 | 2335 | index_source_file_content = dedent( 2336 | text="""\ 2337 | .. toctree:: 2338 | 2339 | markdown_document 2340 | """, 2341 | ) 2342 | markdown_source_file_content = dedent( 2343 | text="""\ 2344 | # Title 2345 | 2346 | {substitution-download}`txt_pre-|a|-txt_post ` 2347 | """, 2348 | ) 2349 | # Importantly we have a non-space whitespace character in the target 2350 | # name. 2351 | downloadable_file = ( 2352 | source_directory / "tgt_pre-example_substitution-tgt_post .py" 2353 | ) 2354 | downloadable_file.write_text(data="Sample") 2355 | index_source_file.write_text(data=index_source_file_content) 2356 | markdown_source_file.write_text(data=markdown_source_file_content) 2357 | app = make_app( 2358 | srcdir=source_directory, 2359 | exception_on_warning=True, 2360 | confoverrides={ 2361 | "extensions": [ 2362 | "myst_parser", 2363 | "sphinx_substitution_extensions", 2364 | ], 2365 | "myst_enable_extensions": ["substitution"], 2366 | "myst_substitutions": { 2367 | "a": "example_substitution", 2368 | }, 2369 | }, 2370 | ) 2371 | app.build() 2372 | assert app.statuscode == 0 2373 | content_html = (app.outdir / "markdown_document.html").read_text() 2374 | app.cleanup() 2375 | 2376 | equivalent_source = dedent( 2377 | text="""\ 2378 | # Title 2379 | 2380 | {download}`txt_pre-example_substitution-txt_post ` 2381 | """, # noqa: E501 2382 | ) 2383 | 2384 | markdown_source_file.write_text(data=equivalent_source) 2385 | app_expected = make_app( 2386 | srcdir=source_directory, 2387 | exception_on_warning=True, 2388 | confoverrides={"extensions": ["myst_parser"]}, 2389 | ) 2390 | app_expected.build() 2391 | assert app_expected.statuscode == 0 2392 | 2393 | expected_content_html = ( 2394 | app_expected.outdir / "markdown_document.html" 2395 | ).read_text() 2396 | assert content_html == expected_content_html 2397 | --------------------------------------------------------------------------------