From 4ee0a0cbd7efb1180aedcb580b950c36b12e2219 Mon Sep 17 00:00:00 2001 From: gghez <6266247+gghez@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:59:42 +0200 Subject: [PATCH] Expose ammonia's id_prefix option via id_prefix kwarg Adds an `id_prefix` keyword argument to `clean()` and `Cleaner()` that maps to ammonia's `Builder::id_prefix`, prepending the given string to every allowed `id` attribute value. This namespaces user-generated `id`s so they cannot collide with the `id`s already present on the host page. Semantics mirror ammonia: - Defaults to None (no prefixing). - The tag and the `id` attribute must still be whitelisted via `attributes`. - Values already starting with the prefix are left unchanged. Closes #133 --- nh3.pyi | 2 ++ src/lib.rs | 32 ++++++++++++++++++++++++++++++-- tests/test_nh3.py | 27 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/nh3.pyi b/nh3.pyi index c06bfdb..9cbccf5 100644 --- a/nh3.pyi +++ b/nh3.pyi @@ -38,6 +38,7 @@ class Cleaner: allowed_classes: Optional[Mapping[str, AbstractSet[str]]] = None, filter_style_properties: Optional[AbstractSet[str]] = None, url_relative: Optional[UrlRelative] = None, + id_prefix: Optional[str] = None, ) -> None: ... def clean(self, html: str) -> str: ... @@ -56,6 +57,7 @@ def clean( allowed_classes: Optional[Mapping[str, AbstractSet[str]]] = None, filter_style_properties: Optional[AbstractSet[str]] = None, url_relative: Optional[UrlRelative] = None, + id_prefix: Optional[str] = None, ) -> str: ... def clean_text(html: str, tags: Optional[AbstractSet[str]] = None) -> str: ... def escape(html: str, tags: Optional[AbstractSet[str]] = None) -> str: ... diff --git a/src/lib.rs b/src/lib.rs index a4faf84..b84ee80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ struct Config { allowed_classes: Option>>, filter_style_properties: Option>, url_relative: Option, + id_prefix: Option, } impl Default for Config { @@ -51,6 +52,7 @@ impl Default for Config { allowed_classes: None, filter_style_properties: None, url_relative: None, + id_prefix: None, } } } @@ -203,6 +205,11 @@ struct Inner { /// returns a non-string, non-``None`` value) strips the URL, and the error /// is reported via ``sys.unraisablehook``. /// :type url_relative: ``str | tuple | Callable[[str], str | None]``, optional +/// :param id_prefix: Prepends the given string to every allowed ``id`` attribute value, +/// which helps avoid collisions with ``id``\ s already present on the host page. +/// The tag and the ``id`` attribute must still be whitelisted (via ``attributes``); +/// values that already start with the prefix are left unchanged. Defaults to ``None``. +/// :type id_prefix: ``str``, optional /// /// Example usage: /// @@ -418,6 +425,9 @@ impl Cleaner { }; builder.url_relative(value); } + if let Some(id_prefix) = config.id_prefix.as_ref() { + builder.id_prefix(Some(id_prefix.as_str())); + } builder } @@ -444,7 +454,8 @@ impl Cleaner { url_schemes = None, allowed_classes = None, filter_style_properties = None, - url_relative = None + url_relative = None, + id_prefix = None ))] fn py_new( py: Python, @@ -461,6 +472,7 @@ impl Cleaner { allowed_classes: Option>>, filter_style_properties: Option>, url_relative: Option>, + id_prefix: Option, ) -> PyResult { if let Some(callback) = attribute_filter.as_ref() { if !callback.bind(py).is_callable() { @@ -522,6 +534,7 @@ impl Cleaner { allowed_classes, filter_style_properties, url_relative, + id_prefix, }; Ok(Self::new(config)) } @@ -678,6 +691,18 @@ impl Cleaner { /// ... url_relative=lambda url: f"https://cdn.example.com{url}", /// ... ) /// '' +/// +/// ``id_prefix`` namespaces ``id`` attributes (which must be whitelisted) so they +/// cannot collide with ``id``\ s on the surrounding page: +/// +/// .. code-block:: pycon +/// +/// >>> nh3.clean( +/// ... 'hi', +/// ... attributes={"b": {"id"}}, +/// ... id_prefix="user-content-", +/// ... ) +/// 'hi' #[pyfunction(signature = ( html, @@ -693,7 +718,8 @@ impl Cleaner { url_schemes = None, allowed_classes = None, filter_style_properties = None, - url_relative = None + url_relative = None, + id_prefix = None ))] #[allow(clippy::too_many_arguments)] fn clean( @@ -712,6 +738,7 @@ fn clean( allowed_classes: Option>>, filter_style_properties: Option>, url_relative: Option>, + id_prefix: Option, ) -> PyResult { let cleaner = Cleaner::py_new( py, @@ -728,6 +755,7 @@ fn clean( allowed_classes, filter_style_properties, url_relative, + id_prefix, )?; Ok(py.detach(|| cleaner.clean(html))) } diff --git a/tests/test_nh3.py b/tests/test_nh3.py index 352ef76..d6639dc 100644 --- a/tests/test_nh3.py +++ b/tests/test_nh3.py @@ -314,6 +314,33 @@ def test_cleaner_url_relative_reusable(): ) +def test_clean_id_prefix(): + # id_prefix prepends the given string to every allowed `id` value. + assert ( + nh3.clean("x", attributes={"b": {"id"}}, id_prefix="safe-") + == 'x' + ) + # Values already carrying the prefix are left untouched (no double prefix). + assert ( + nh3.clean("x", attributes={"b": {"id"}}, id_prefix="safe-") + == 'x' + ) + # The `id` attribute must still be whitelisted; otherwise it is stripped and + # the prefix is irrelevant. + assert nh3.clean("x", id_prefix="safe-") == "x" + # Omitting id_prefix keeps `id` values unchanged (ammonia default). + assert ( + nh3.clean("x", attributes={"b": {"id"}}) + == 'x' + ) + + +def test_cleaner_id_prefix_reusable(): + cleaner = nh3.Cleaner(attributes={"b": {"id"}}, id_prefix="safe-") + assert cleaner.clean("x") == 'x' + assert cleaner.clean("y") == 'y' + + def test_is_html(): assert not nh3.is_html("plain text") assert nh3.is_html("

html!

")