From 0f7389babb9cea40fe4db6246986cf8f34312677 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 17 Nov 2025 14:07:08 +0000 Subject: [PATCH 1/2] Add Markdown examples for new directive support --- sample/source/markdown_sample.md | 56 ++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/sample/source/markdown_sample.md b/sample/source/markdown_sample.md index bc6836db..4b519d26 100644 --- a/sample/source/markdown_sample.md +++ b/sample/source/markdown_sample.md @@ -86,30 +86,60 @@ Inline ``:substitution-code:`` {substitution-download}`Script by {{author}} <../source/Eleanor.txt>` -``image`` ---------- +``literalinclude`` +------------------ -Path substitutions -~~~~~~~~~~~~~~~~~~ +### Content substitutions ```{code-block} markdown - ```{image} sample_image.png - :alt: Sample image - ``` + ```{literalinclude} sample_include.txt + ``` + + ```{literalinclude} sample_include.txt + :content-substitutions: + ``` +``` + +=> + +```{literalinclude} sample_include.txt +``` + +```{literalinclude} sample_include.txt +:content-substitutions: +``` + +### Path substitutions + +```{code-block} markdown - ```{image} {{author}}_diagram.png - :path-substitutions: - :alt: Diagram for {{author}} - ``` + ```{literalinclude} {{author}}.txt + :path-substitutions: + ``` ``` => -```{image} sample_image.png -:alt: Sample image +```{literalinclude} {{author}}.txt +:path-substitutions: +``` + +``image`` +--------- + +### Path substitutions + +```{code-block} markdown + + ```{image} {{author}}_diagram.png + :path-substitutions: + :alt: Diagram for {{author}} + ``` ``` +=> + ```{image} {{author}}_diagram.png :path-substitutions: :alt: Diagram for {{author}} From c36377667298df31e8ef91c00eb51bb7e0dc4f5b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 17 Nov 2025 14:17:39 +0000 Subject: [PATCH 2/2] Add include directive --- README.rst | 36 ++- sample/source/index.rst | 9 + sample/source/markdown_sample.md | 21 ++ .../__init__.py | 55 ++++ tests/test_substitution_extensions.py | 243 ++++++++++++++++++ 5 files changed, 363 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 877e8a5e..b54fb8b6 100644 --- a/README.rst +++ b/README.rst @@ -90,6 +90,18 @@ Replace substitutions in the file path: .. literalinclude:: path/to/|author|_file.txt :path-substitutions: +``include`` +~~~~~~~~~~~ + +This adds a ``:path-substitutions:`` option to docutils' built-in `include`_ directive. + +Replace substitutions in the file path: + +.. code-block:: rst + + .. include:: path/to/|author|_file.txt + :path-substitutions: + ``image`` ~~~~~~~~~ @@ -134,7 +146,7 @@ This will replace ``|release|`` in the new directives with ``0.1``, and ``|autho Enabling substitutions by default ---------------------------------- -By default, you need to explicitly add the ``:substitutions:`` flag to ``code-block`` directives, and ``:content-substitutions:`` or ``:path-substitutions:`` flags to ``literalinclude`` directives. +By default, you need to explicitly add the ``:substitutions:`` flag to ``code-block`` directives, and ``:path-substitutions:`` flags to ``literalinclude``, ``include``, and ``image`` directives (or ``:content-substitutions:`` for ``literalinclude``). If you want substitutions to be applied by default without needing these flags, you can set the following in ``conf.py``: @@ -148,6 +160,8 @@ When this is enabled: - All ``code-block`` directives will have substitutions applied automatically - All ``literalinclude`` directives will have both content and path substitutions applied automatically +- All ``include`` directives will have path substitutions applied automatically +- All ``image`` directives will have path substitutions applied automatically You can disable substitutions for specific directives when the default is enabled: @@ -164,6 +178,12 @@ You can disable substitutions for specific directives when the default is enable .. literalinclude:: path/to/|literal|_file.txt :nopath-substitutions: + .. include:: path/to/|literal|_file.txt + :nopath-substitutions: + + .. image:: path/to/|literal|_diagram.png + :nopath-substitutions: + Using substitutions in MyST Markdown ------------------------------------ @@ -218,6 +238,19 @@ Replace substitutions in the file path: :path-substitutions: ``` +``include`` +~~~~~~~~~~~ + +This adds a ``:path-substitutions:`` option to docutils' built-in `include`_ directive. + +Replace substitutions in the file path: + +.. code-block:: markdown + + ```{include} path/to/|author|_file.txt + :path-substitutions: + ``` + ``image`` ~~~~~~~~~ @@ -250,6 +283,7 @@ See `CONTRIBUTING.rst <./CONTRIBUTING.rst>`_. :target: https://github.com/adamtheturtle/sphinx-substitution-extensions/actions .. _code-block: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block .. _literalinclude: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude +.. _include: https://docutils.sourceforge.io/docs/ref/rst/directives.html#including-an-external-document-fragment .. _image: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-image .. |PyPI| image:: https://badge.fury.io/py/Sphinx-Substitution-Extensions.svg :target: https://badge.fury.io/py/Sphinx-Substitution-Extensions diff --git a/sample/source/index.rst b/sample/source/index.rst index d5af4fa8..67188405 100644 --- a/sample/source/index.rst +++ b/sample/source/index.rst @@ -62,6 +62,15 @@ Path substitutions .. literalinclude:: |author|.txt :path-substitutions: +``include`` +----------- + +Path substitutions +~~~~~~~~~~~~~~~~~~ + +.. include:: |author|.txt + :path-substitutions: + ``image`` --------- diff --git a/sample/source/markdown_sample.md b/sample/source/markdown_sample.md index 4b519d26..7e6f819c 100644 --- a/sample/source/markdown_sample.md +++ b/sample/source/markdown_sample.md @@ -125,6 +125,27 @@ Inline ``:substitution-code:`` :path-substitutions: ``` +``include`` +----------- + +### Path substitutions + +```{code-block} markdown + + ```{include} {{author}}.txt + :path-substitutions: + ``` +``` + + + + + ``image`` --------- diff --git a/src/sphinx_substitution_extensions/__init__.py b/src/sphinx_substitution_extensions/__init__.py index a2433b03..7d43f453 100644 --- a/src/sphinx_substitution_extensions/__init__.py +++ b/src/sphinx_substitution_extensions/__init__.py @@ -15,6 +15,7 @@ ) from docutils.parsers.rst import directives from docutils.parsers.rst.directives.images import Image +from docutils.parsers.rst.directives.misc import Include from docutils.parsers.rst.roles import code_role from docutils.parsers.rst.states import Inliner from docutils.statemachine import StringList @@ -353,6 +354,56 @@ def run(self) -> list[Node]: return nodes_list +@beartype +class SubstitutionInclude(Include): + """ + Similar to Include but replaces placeholders with variables in the path. + """ + + option_spec: ClassVar[OptionSpec] = Include.option_spec.copy() + option_spec[PATH_SUBSTITUTION_OPTION_NAME] = directives.flag + option_spec[NO_PATH_SUBSTITUTION_OPTION_NAME] = directives.flag + + def run(self) -> list[Node]: + """ + Replace placeholders with given variables in the file path. + """ + env = self.state.document.settings.env + + if env is not None: + config = env.config + + should_apply_path_substitutions = _should_apply_substitutions( + options=self.options, + config=config, + yes_flag=PATH_SUBSTITUTION_OPTION_NAME, + no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, + ) + + if should_apply_path_substitutions: + substitution_defs = _get_substitution_defs( + env=env, + config=config, + substitution_defs=self.state.document.substitution_defs, + ) + + delimiter_pairs = _get_delimiter_pairs( + env=env, + config=config, + ) + + for argument_index, argument in enumerate( + iterable=self.arguments + ): + self.arguments[argument_index] = _apply_substitutions( + text=argument, + substitution_defs=substitution_defs, + delimiter_pairs=delimiter_pairs, + ) + + return list(super().run()) + + @beartype class SubstitutionImage(Image): """ @@ -484,6 +535,10 @@ def setup(app: Sphinx) -> ExtensionMetadata: name="literalinclude", directive=SubstitutionLiteralInclude, ) + directives.register_directive( + name="include", + directive=SubstitutionInclude, + ) directives.register_directive( name="image", directive=SubstitutionImage, diff --git a/tests/test_substitution_extensions.py b/tests/test_substitution_extensions.py index 6b7f7b1a..88bb927c 100644 --- a/tests/test_substitution_extensions.py +++ b/tests/test_substitution_extensions.py @@ -1301,6 +1301,249 @@ def test_myst_substitutions( assert content_html == expected_content_html +def test_no_substitution_include( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """ + The ``include`` directive does not replace placeholders by default. + """ + source_directory = tmp_path / "source" + source_directory.mkdir() + source_file = source_directory / "index.rst" + (source_directory / "conf.py").touch() + + include_file = source_directory / "example.txt" + include_file.write_text(data="Included content") + + source_file_content = dedent( + text="""\ + .. |a| replace:: example + + .. include:: example.txt + """, + ) + source_file.write_text(data=source_file_content) + app = make_app( + srcdir=source_directory, + exception_on_warning=True, + confoverrides={"extensions": ["sphinx_substitution_extensions"]}, + ) + app.build() + assert app.statuscode == 0 + content_html = (app.outdir / "index.html").read_text() + app.cleanup() + + app_expected = make_app( + srcdir=source_directory, + exception_on_warning=True, + freshenv=True, + ) + + app_expected.build() + assert app_expected.statuscode == 0 + + expected_content_html = (app_expected.outdir / "index.html").read_text() + + assert content_html == expected_content_html + + +def test_substitution_include_path( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """ + The ``include`` directive replaces placeholders in the file path when the + ``:path-substitutions:`` flag is set. + """ + source_directory = tmp_path / "source" + source_directory.mkdir() + source_file = source_directory / "index.rst" + (source_directory / "conf.py").touch() + + # Create a file with substitution in the name + include_file = source_directory / "example_substitution.txt" + include_file.write_text(data="Included file content") + + source_file_content = dedent( + text="""\ + .. |a| replace:: example_substitution + + .. include:: |a|.txt + :path-substitutions: + """, + ) + source_file.write_text(data=source_file_content) + app = make_app( + srcdir=source_directory, + exception_on_warning=True, + confoverrides={"extensions": ["sphinx_substitution_extensions"]}, + ) + app.build() + assert app.statuscode == 0 + content_html = (app.outdir / "index.html").read_text() + app.cleanup() + + # Compare with directly using the filename + equivalent_source = dedent( + text="""\ + .. include:: example_substitution.txt + """, + ) + + source_file.write_text(data=equivalent_source) + app_expected = make_app( + srcdir=source_directory, + exception_on_warning=True, + ) + app_expected.build() + assert app_expected.statuscode == 0 + + expected_content_html = (app_expected.outdir / "index.html").read_text() + assert content_html == expected_content_html + + +def test_default_substitutions_include_path( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """ + When ``substitutions_default_enabled`` is True, ``include`` should apply + path substitutions by default without requiring the ``:path- + substitutions:`` flag. + """ + source_directory = tmp_path / "source" + source_directory.mkdir() + source_file = source_directory / "index.rst" + (source_directory / "conf.py").touch() + + include_file = source_directory / "example_substitution.txt" + include_file.write_text(data="Included file content") + + source_file_content = dedent( + text="""\ + .. |a| replace:: example_substitution + + .. include:: |a|.txt + """, + ) + source_file.write_text(data=source_file_content) + app = make_app( + srcdir=source_directory, + exception_on_warning=True, + confoverrides={ + "extensions": ["sphinx_substitution_extensions"], + "substitutions_default_enabled": True, + }, + ) + app.build() + assert app.statuscode == 0 + content_html = (app.outdir / "index.html").read_text() + app.cleanup() + + equivalent_source = dedent( + text="""\ + .. include:: example_substitution.txt + """, + ) + + source_file.write_text(data=equivalent_source) + app_expected = make_app( + srcdir=source_directory, + exception_on_warning=True, + ) + app_expected.build() + assert app_expected.statuscode == 0 + + expected_content_html = (app_expected.outdir / "index.html").read_text() + assert content_html == expected_content_html + + +def test_default_substitutions_include_disabled_path( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """When ``substitutions_default_enabled`` is True but ``include`` has the + ``:nopath-substitutions:`` flag, path substitutions should not be applied. + + Note: This test uses MyST format with custom delimiters because the `|` + character cannot be used in Windows file paths. + """ + source_directory = tmp_path / "source" + source_directory.mkdir() + index_source_file = source_directory / "index.rst" + markdown_source_file = source_directory / "markdown_document.md" + (source_directory / "conf.py").touch() + + # Use custom delimiters [[a]] instead of |a| because | is not allowed + # in Windows file paths + include_file = source_directory / "[[a]].txt" + include_file.write_text(data="File content") + + index_source_file_content = dedent( + text="""\ + .. toctree:: + + markdown_document + """, + ) + markdown_source_file_content = dedent( + text="""\ + # Title + + ```{include} [[a]].txt + :nopath-substitutions: + ``` + """, + ) + index_source_file.write_text(data=index_source_file_content) + markdown_source_file.write_text(data=markdown_source_file_content) + app = make_app( + srcdir=source_directory, + exception_on_warning=True, + confoverrides={ + "extensions": [ + "myst_parser", + "sphinx_substitution_extensions", + ], + "myst_enable_extensions": ["substitution"], + "myst_substitutions": { + "a": "example_substitution", + }, + "myst_sub_delimiters": ("[", "]"), + "substitutions_default_enabled": True, + }, + ) + app.build() + assert app.statuscode == 0 + content_html = (app.outdir / "markdown_document.html").read_text() + app.cleanup() + + equivalent_source = dedent( + text="""\ + # Title + + ```{include} [[a]].txt + ``` + """, + ) + + markdown_source_file.write_text(data=equivalent_source) + app_expected = make_app( + srcdir=source_directory, + exception_on_warning=True, + confoverrides={"extensions": ["myst_parser"]}, + freshenv=True, + ) + app_expected.build() + assert app_expected.statuscode == 0 + + expected_content_html = ( + app_expected.outdir / "markdown_document.html" + ).read_text() + assert content_html == expected_content_html + + def test_no_substitution_image( tmp_path: Path, make_app: Callable[..., SphinxTestApp],