diff --git a/layerindex/utils.py b/layerindex/utils.py
index fd29de0..8e06463 100644
--- a/layerindex/utils.py
+++ b/layerindex/utils.py
@@ -544,13 +544,13 @@ def run_command_interruptible(cmd):
 
 def sanitise_html(html):
     soup = BeautifulSoup(html, "html.parser")
-    for tag in soup.findAll(True):
+    for tag in soup.find_all(True):
         if tag.name not in ['strong', 'em', 'b', 'i', 'p', 'ul', 'ol', 'li', 'br', 'p']:
             tag.hidden = True
         elif tag.attrs:
-            tag.attrs = []
+            tag.attrs = {}
 
-    return soup.renderContents()
+    return soup.decode_contents()
 
 def squashspaces(string):
     return re.sub(r"\s+", " ", string).strip()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 95b5e04..248b562 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -85,3 +85,16 @@ def test_in_nested_layer(repo_with_nested_layers, path, expected):
     assert utils.in_nested_layer(path, nested, repodir) is expected
     # Absolute paths give the same answer
     assert utils.in_nested_layer(os.path.join(repodir, path), nested, repodir) is expected
+
+
+@pytest.mark.parametrize('html, expected', [
+    # Allowed tags are kept, but lose their attributes
+    ('<p class="x" onclick="y">Hi <b>there</b></p>', '<p>Hi <b>there</b></p>'),
+    ('<ul><li>one</li></ul>', '<ul><li>one</li></ul>'),
+    # Other tags are dropped, but their text stays (escaped)
+    ('<a href="http://example.com">link</a>', 'link'),
+    ('<script>alert(1)</script>', 'alert(1)'),
+    ('<div><i>x</i> &lt;y&gt;</div>', '<i>x</i> &lt;y&gt;'),
+])
+def test_sanitise_html(html, expected):
+    assert utils.sanitise_html(html) == expected
