From 17c67a2f62181c85220275e8f87c5985135bd62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 14 May 2026 11:30:12 +0000 Subject: [PATCH] fix: remove abbreviations from table of contents (#669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Mazzucotelli --- python/tests/integration/__init__.py | 22 ++++ python/tests/integration/conftest.py | 63 +++++++++++ python/tests/integration/markdown/__init__.py | 22 ++++ .../tests/integration/markdown/test_render.py | 106 ++++++++++++++++++ python/tests/unit/markdown/__init__.py | 22 ++++ python/tests/unit/markdown/test_render.py | 99 ++++++++++++++++ python/zensical/config.py | 2 +- python/zensical/markdown/render.py | 12 +- 8 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 python/tests/integration/__init__.py create mode 100644 python/tests/integration/conftest.py create mode 100644 python/tests/integration/markdown/__init__.py create mode 100644 python/tests/integration/markdown/test_render.py create mode 100644 python/tests/unit/markdown/__init__.py create mode 100644 python/tests/unit/markdown/test_render.py diff --git a/python/tests/integration/__init__.py b/python/tests/integration/__init__.py new file mode 100644 index 0000000..576d3ea --- /dev/null +++ b/python/tests/integration/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/python/tests/integration/conftest.py b/python/tests/integration/conftest.py new file mode 100644 index 0000000..4360022 --- /dev/null +++ b/python/tests/integration/conftest.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import copy +from typing import Any + +import pytest + +from zensical import config + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(name="base_config", scope="session") +def _fixture_base_config( + tmp_path_factory: pytest.TempPathFactory, +) -> dict[str, Any]: + """Build a fully-processed config once per session.""" + root = tmp_path_factory.mktemp("integration_base") + (root / "docs").mkdir() + return config._apply_defaults( + { + "site_name": "Test", + "markdown_extensions": config.DEFAULT_MARKDOWN_EXTENSIONS, + }, + str(root / "zensical.toml"), + ) + + +@pytest.fixture(autouse=True) +def _fixture_set_config(base_config: dict[str, Any]) -> Any: + """Give each test a fresh copy of the config and restore state after. + + render() mutates the global config (it appends/updates ContextExtension), + so every test must start from an isolated copy. + """ + config._CONFIG = copy.deepcopy(base_config) + yield + config._CONFIG = None diff --git a/python/tests/integration/markdown/__init__.py b/python/tests/integration/markdown/__init__.py new file mode 100644 index 0000000..576d3ea --- /dev/null +++ b/python/tests/integration/markdown/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/python/tests/integration/markdown/test_render.py b/python/tests/integration/markdown/test_render.py new file mode 100644 index 0000000..cffb27b --- /dev/null +++ b/python/tests/integration/markdown/test_render.py @@ -0,0 +1,106 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from zensical.markdown.render import render + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _toc_contents(toc: list[dict]) -> list[str]: + """Recursively collect the `content` field of every TOC item.""" + result = [] + for item in toc: + result.append(item["content"]) + result.extend(_toc_contents(item["children"])) + return result + + +# --------------------------------------------------------------------------- +# TOC cleanup +# --------------------------------------------------------------------------- + + +class TestTocCleanup: + def test_abbreviations_stripped_from_toc_content(self) -> None: + """Abbreviations defined in the page must not appear as in TOC. + + The abbr Markdown extension runs before the TOC tree processor, so + heading HTML stored in toc_tokens already contains elements. + _cleanup_toc_label must remove them, keeping only the plain text. + """ + result = render( + content=( + "# Working with HTML\n" + "\n" + "Some content here.\n" + "\n" + "*[HTML]: HyperText Markup Language\n" + ), + path="index.md", + url="/", + ) + + # Sanity-check: the rendered page body must contain to confirm + # the extension is actually active and expanding abbreviations. + assert " tags. + assert " None: + """Abbreviation stripping must apply at every nesting level.""" + result = render( + content=( + "# Top level\n" + "\n" + "## Using CSS\n" + "\n" + "Some content here.\n" + "\n" + "*[CSS]: Cascading Style Sheets\n" + ), + path="index.md", + url="/", + ) + + # The rendered page body must contain . + assert ". + assert all("Heading', + "Heading", + id="anchor_with_id_attr", # id= is stripped before + ), + pytest.param( + 'Hello world', + "Hello world", + id="anchor_preserves_inner_content", + ), + pytest.param( + '\nLine one\nLine two\n', + "\nLine one\nLine two\n", + id="multiline_anchor", + ), + pytest.param( + 'First and Second', + "First and Second", + id="multiple_anchors", + ), + # Abbreviations ----------------------------------------------- + pytest.param( + 'HTML', + "HTML", + id="abbr_tag", + ), + pytest.param( + 'Use CSS for style', + "Use CSS for style", + id="abbr_preserves_surrounding_text", + ), + pytest.param( + 'HTML' + " and " + 'CSS', + "HTML and CSS", + id="multiple_abbreviations", + ), + pytest.param( + '\nAbbr\n', + "\nAbbr\n", + id="multiline_abbr", + ), + # Combined and passthrough ------------------------------------ + pytest.param( + 'Intro to ' + 'HTML' + "", + "Intro to HTML", + id="links_and_abbreviations", + ), + pytest.param( + "Just plain text", + "Just plain text", + id="plain_text_unchanged", + ), + ], + ) + def test_cleans(self, html: str, expected: str) -> None: + assert _cleanup_toc_label(html) == expected diff --git a/python/zensical/config.py b/python/zensical/config.py index 54b6b8a..bdb5cc7 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -56,7 +56,7 @@ if TYPE_CHECKING: # ---------------------------------------------------------------------------- -_CONFIG = None +_CONFIG: dict[str, Any] | None = None """ Global configuration to pick up later for parsing Markdown. diff --git a/python/zensical/markdown/render.py b/python/zensical/markdown/render.py index 1d9feda..dfe0f71 100644 --- a/python/zensical/markdown/render.py +++ b/python/zensical/markdown/render.py @@ -158,7 +158,7 @@ def _convert_toc(item: Any) -> dict: """Convert a table of contents item to navigation item format.""" toc_item = { "title": item["data-toc-label"] or item["name"], - "content": item["data-toc-label"] or _remove_links(item["html"]), + "content": item["data-toc-label"] or _cleanup_toc_label(item["html"]), "id": item["id"], "url": f"#{item['id']}", "children": [], @@ -173,7 +173,11 @@ def _convert_toc(item: Any) -> dict: return toc_item -def _remove_links(html: str) -> str: - """Remove links from HTML string.""" +def _cleanup_toc_label(html: str) -> str: + """Clean up a TOC label.""" + # Remove links html = re.sub(r"id=\"?[^\">]+\"?", "", html) - return re.sub(r"]+>(.*?)", r"\1", html, flags=re.DOTALL) + html = re.sub(r"]+>(.*?)", r"\1", html, flags=re.DOTALL) + # Remove abbreviations + html = re.sub(r"]+>(.*?)", r"\1", html, flags=re.DOTALL) + return html # noqa: RET504