mirror of
https://github.com/zensical/zensical.git
synced 2026-07-11 10:47:01 +00:00
5ef9ee4ee3
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
516 lines
17 KiB
Python
Vendored
516 lines
17 KiB
Python
Vendored
# 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 textwrap import dedent
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from tests.unit.extensions.conftest import soup
|
|
from zensical.extensions.autorefs import get_autorefs_store, reset
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
|
|
from markdown import Markdown
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _autorefs(exts: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
"""Return md fixture params with AutorefsExtension configured."""
|
|
return {
|
|
"config": {
|
|
"markdown_extensions": {
|
|
**(exts or {}),
|
|
"zensical.extensions.autorefs": {},
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def _autorefs_toc(*, page: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Return md fixture params with attr_list, toc, and AutorefsExtension."""
|
|
param: dict[str, Any] = _autorefs({"attr_list": {}, "toc": {}})
|
|
if page is not None:
|
|
param["page"] = page
|
|
return param
|
|
|
|
|
|
def _page(url: str = "page") -> dict[str, str]:
|
|
"""Return a page-override dict that sets the page URL."""
|
|
return {"url": url, "path": f"{url}.html"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_autorefs_store() -> Generator[None, None, None]:
|
|
"""Reset the global AutorefsStore around each test."""
|
|
reset()
|
|
yield
|
|
reset()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inline processor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInlineProcessor:
|
|
"""Tests for AutorefsInlineProcessor.
|
|
|
|
The extension converts unresolved Markdown reference-style links into
|
|
`<autoref identifier="...">` elements that the Rust side resolves later.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_implicit_reference(self, md: Markdown) -> None:
|
|
"""`[Foo][]` produces an `<autoref>` element with identifier="Foo"."""
|
|
html = soup(md.convert("[Foo][]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "Foo"
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_explicit_reference_with_formatted_text(self, md: Markdown) -> None:
|
|
"""`[**Foo**][Foo]` wraps the bold text inside the autoref element."""
|
|
html = soup(md.convert("[**Foo**][Foo]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "Foo"
|
|
strong = autoref.find("strong")
|
|
assert strong is not None
|
|
assert strong.get_text() == "Foo"
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_implicit_backtick_reference_is_exact(self, md: Markdown) -> None:
|
|
"""``[`Foo`][]`` uses the code content as exact identifier (no slug)."""
|
|
html = soup(md.convert("[`Foo`][]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "Foo"
|
|
code = autoref.find("code")
|
|
assert code is not None
|
|
assert code.get_text() == "Foo"
|
|
assert "slug" not in autoref.attrs
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs(
|
|
{"pymdownx.highlight": {}, "pymdownx.inlinehilite": {}}
|
|
),
|
|
id="with_inlinehilite",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_implicit_code_inlinehilite_plain_is_exact(
|
|
self, md: Markdown
|
|
) -> None:
|
|
"""``[`pathlib.Path`][]`` with inlinehilite keep exact identifier."""
|
|
html = soup(md.convert("[`pathlib.Path`][]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "pathlib.Path"
|
|
assert "slug" not in autoref.attrs
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs(
|
|
{
|
|
"pymdownx.highlight": {},
|
|
"pymdownx.inlinehilite": {"style_plain_text": "python"},
|
|
}
|
|
),
|
|
id="with_inlinehilite_python_styled",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_implicit_code_inlinehilite_styled_is_exact(
|
|
self, md: Markdown
|
|
) -> None:
|
|
"""``[`pathlib.Path`][]`` with inlinehilite is still exact."""
|
|
html = soup(md.convert("[`pathlib.Path`][]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "pathlib.Path"
|
|
assert "slug" not in autoref.attrs
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_reference_inside_code_not_converted(self, md: Markdown) -> None:
|
|
"""`` `[Foo][]` `` is not converted into an autoref."""
|
|
html = soup(md.convert("`[Foo][]`"))
|
|
assert html.find("autoref") is None
|
|
code = html.find("code")
|
|
assert code is not None
|
|
assert code.get_text() == "[Foo][]"
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_multiline_reference_uses_explicit_identifier(
|
|
self, md: Markdown
|
|
) -> None:
|
|
"""References spanning two lines use explicit identifiers (no slug)."""
|
|
html = soup(md.convert("[Foo\nbar][foo-bar]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "foo-bar"
|
|
assert "slug" not in autoref.attrs
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[pytest.param(_autorefs(), id="default")],
|
|
indirect=["md"],
|
|
)
|
|
def test_implicit_reference_with_space_is_slugified(
|
|
self, md: Markdown
|
|
) -> None:
|
|
"""`[Foo bar][]` uses the text as identifier and adds a slug."""
|
|
html = soup(md.convert("[Foo bar][]"))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
assert autoref["identifier"] == "Foo bar"
|
|
assert autoref["slug"] == "foo-bar"
|
|
|
|
@pytest.mark.parametrize(
|
|
("md", "markdown_ref", "exact_expected"),
|
|
[
|
|
pytest.param(_autorefs(), "[Foo][]", False, id="bare_implicit"),
|
|
pytest.param(
|
|
_autorefs(), "[\\`Foo][]", False, id="escaped_backtick_start"
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[\\`\\`Foo][]",
|
|
False,
|
|
id="two_escaped_backticks_start",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[\\`\\`Foo\\`][]",
|
|
False,
|
|
id="mixed_escaped_backticks",
|
|
),
|
|
pytest.param(
|
|
_autorefs(), "[Foo\\`][]", False, id="escaped_backtick_end"
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[Foo\\`\\`][]",
|
|
False,
|
|
id="two_escaped_backticks_end",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[\\`Foo\\`\\`][]",
|
|
False,
|
|
id="outer_escaped_backticks",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[`Foo` `Bar`][]",
|
|
False,
|
|
id="two_separate_code_spans",
|
|
),
|
|
pytest.param(
|
|
_autorefs(), "[Foo][Foo]", True, id="explicit_identifier"
|
|
),
|
|
pytest.param(_autorefs(), "[`Foo`][]", True, id="single_code_span"),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[`Foo``Bar`][]",
|
|
True,
|
|
id="code_span_two_backticks",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[`Foo```Bar`][]",
|
|
True,
|
|
id="code_span_three_backticks",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[``Foo```Bar``][]",
|
|
True,
|
|
id="double_backtick_three",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[``Foo`Bar``][]",
|
|
True,
|
|
id="double_backtick_single",
|
|
),
|
|
pytest.param(
|
|
_autorefs(),
|
|
"[```Foo``Bar```][]",
|
|
True,
|
|
id="triple_backtick_double",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_mark_identifiers_as_exact(
|
|
self, md: Markdown, markdown_ref: str, exact_expected: bool
|
|
) -> None:
|
|
"""Code/explicit identifiers have no slug; bare-text identifiers do."""
|
|
html = soup(md.convert(markdown_ref))
|
|
autoref = html.find("autoref")
|
|
assert autoref is not None
|
|
if exact_expected:
|
|
assert "slug" not in autoref.attrs
|
|
else:
|
|
assert "slug" in autoref.attrs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Anchors tree processor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAnchorsTreeprocessor:
|
|
"""Tests for AutorefsAnchorsTreeprocessor.
|
|
|
|
The processor scans `<a id="...">` elements produced by the
|
|
`attr_list` extension (e.g. `[](){#foo}`) and registers them as
|
|
anchors or heading aliases in the `AutorefsStore`.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs_toc(page=_page()),
|
|
id="with_toc_attr_list",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_register_anchors_and_aliases(self, md: Markdown) -> None:
|
|
"""Anchors preceding a heading become aliases for that heading."""
|
|
md.convert(
|
|
dedent("""\
|
|
[](){#foo}
|
|
## Heading foo
|
|
|
|
Paragraph 1.
|
|
|
|
[](){#bar}
|
|
Paragraph 2.
|
|
|
|
[](){#alias1}
|
|
[](){#alias2}
|
|
## Heading bar
|
|
|
|
[](){#alias3}
|
|
Text.
|
|
[](){#alias4}
|
|
## Heading baz
|
|
|
|
[](){#alias5}
|
|
[](){#alias6}
|
|
Decoy.
|
|
## Heading more1
|
|
|
|
[](){#alias7}
|
|
[decoy](){#alias8}
|
|
[](){#alias9}
|
|
## Heading more2 {#heading-custom2}
|
|
|
|
[](){#aliasSame}
|
|
## Same heading 1
|
|
[](){#aliasSame}
|
|
## Same heading 2
|
|
|
|
[](){#alias10}
|
|
"""),
|
|
)
|
|
store = get_autorefs_store()
|
|
assert store._primary_url_map == {
|
|
"foo": ["page#heading-foo"],
|
|
"heading-foo": ["page#heading-foo"],
|
|
"bar": ["page#bar"],
|
|
"heading-bar": ["page#heading-bar"],
|
|
"alias1": ["page#heading-bar"],
|
|
"alias2": ["page#heading-bar"],
|
|
"alias3": ["page#alias3"],
|
|
"alias4": ["page#heading-baz"],
|
|
"heading-baz": ["page#heading-baz"],
|
|
"alias5": ["page#alias5"],
|
|
"alias6": ["page#alias6"],
|
|
"heading-more1": ["page#heading-more1"],
|
|
"alias7": ["page#alias7"],
|
|
"alias8": ["page#alias8"],
|
|
"alias9": ["page#heading-custom2"],
|
|
"heading-custom2": ["page#heading-custom2"],
|
|
"alias10": ["page#alias10"],
|
|
"aliasSame": ["page#same-heading-1", "page#same-heading-2"],
|
|
"same-heading-1": ["page#same-heading-1"],
|
|
"same-heading-2": ["page#same-heading-2"],
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"attr_list": {},
|
|
"toc": {},
|
|
"admonition": {},
|
|
"zensical.extensions.autorefs": {},
|
|
}
|
|
},
|
|
"page": _page(),
|
|
},
|
|
id="with_admonition",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_register_anchors_inside_admonition(self, md: Markdown) -> None:
|
|
"""Anchors inside a nested block element are registered separately."""
|
|
md.convert(
|
|
dedent("""\
|
|
[](){#alias1}
|
|
!!! note
|
|
## Heading foo
|
|
|
|
[](){#alias2}
|
|
## Heading bar
|
|
|
|
[](){#alias3}
|
|
## Heading baz
|
|
"""),
|
|
)
|
|
store = get_autorefs_store()
|
|
assert store._primary_url_map == {
|
|
"heading-foo": ["page#heading-foo"],
|
|
"heading-bar": ["page#heading-bar"],
|
|
"heading-baz": ["page#heading-baz"],
|
|
"alias1": ["page#alias1"],
|
|
"alias2": ["page#heading-bar"],
|
|
"alias3": ["page#alias3"],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Headings tree processor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHeadingsTreeprocessor:
|
|
"""Tests for AutorefsHeadingsTreeprocessor.
|
|
|
|
The processor scans headings that received an `id` attribute from the
|
|
`toc` extension and registers them in the `AutorefsStore`.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs_toc(page=_page()),
|
|
id="with_toc",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_register_heading(self, md: Markdown) -> None:
|
|
"""A single heading is registered under its toc-generated slug."""
|
|
md.convert("## Foo")
|
|
store = get_autorefs_store()
|
|
assert "foo" in store._primary_url_map
|
|
assert store._primary_url_map["foo"] == ["page#foo"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs_toc(page=_page()),
|
|
id="with_toc",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_register_multiple_headings_at_different_levels(
|
|
self, md: Markdown
|
|
) -> None:
|
|
"""All headings across all levels are individually registered."""
|
|
md.convert("# Top\n\n## Middle\n\n### Bottom")
|
|
store = get_autorefs_store()
|
|
assert "top" in store._primary_url_map
|
|
assert "middle" in store._primary_url_map
|
|
assert "bottom" in store._primary_url_map
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
_autorefs_toc(page=_page()),
|
|
id="with_toc",
|
|
)
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_heading_not_registered_without_id(self, md: Markdown) -> None:
|
|
"""Headings without an `id` attribute (no toc) are not registered."""
|
|
# Use a raw HTML heading that has no id attribute.
|
|
md.convert("<h2>No ID heading</h2>")
|
|
store = get_autorefs_store()
|
|
assert "no-id-heading" not in store._primary_url_map
|
|
assert store._primary_url_map == {}
|