From 00fe88abf71a02a2c6c19f29812e5d1347c99b4a Mon Sep 17 00:00:00 2001
From: Martin Donath
Date: Mon, 5 Oct 2026 17:01:09 +0200
Subject: [PATCH] feature: add audio and video media extension
Signed-off-by: squidfunk
---
.../tests/integration/test_api_generators.py | 8 +-
python/tests/integration/test_media.py | 129 +++++++
python/tests/unit/extensions/test_media.py | 239 ++++++++++++
python/tests/unit/test_config.py | 72 ++++
python/zensical/config.py | 70 ++++
python/zensical/extensions/media.py | 351 ++++++++++++++++++
6 files changed, 867 insertions(+), 2 deletions(-)
create mode 100644 python/tests/integration/test_media.py
create mode 100644 python/tests/unit/extensions/test_media.py
create mode 100644 python/zensical/extensions/media.py
diff --git a/python/tests/integration/test_api_generators.py b/python/tests/integration/test_api_generators.py
index 9d3ffa0..67d55c9 100644
--- a/python/tests/integration/test_api_generators.py
+++ b/python/tests/integration/test_api_generators.py
@@ -482,8 +482,12 @@ def test_serve_discovers_added_renamed_and_removed_modules(
def wait_for(condition: Callable[[], bool]) -> None:
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
- if condition():
- return
+ try:
+ if condition():
+ return
+ except FileNotFoundError:
+ # A rebuild can remove output between checking and reading.
+ pass
if process.poll() is not None:
break
time.sleep(0.02)
diff --git a/python/tests/integration/test_media.py b/python/tests/integration/test_media.py
new file mode 100644
index 0000000..6caa068
--- /dev/null
+++ b/python/tests/integration/test_media.py
@@ -0,0 +1,129 @@
+# 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 typing import TYPE_CHECKING, Any
+
+from bs4 import BeautifulSoup
+
+import zensical
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+_BUILD_OPTS: dict[str, Any] = {"clean": False, "strict": False}
+
+
+def test_both_legacy_plugins_render_through_media_extension(
+ tmp_path: Path,
+) -> None:
+ docs = tmp_path / "docs"
+ docs.mkdir()
+ (docs / "index.md").write_text(
+ "# Media\n\n"
+ "\n\n"
+ "\n",
+ encoding="utf-8",
+ )
+ config = tmp_path / "mkdocs.yml"
+ config.write_text(
+ "site_name: Media\n"
+ "plugins:\n"
+ " - mkdocs-video:\n"
+ " is_video: true\n"
+ " video_type: ogg\n"
+ " - mkdocs-audio:\n"
+ " audio_controls: false\n",
+ encoding="utf-8",
+ )
+
+ zensical.build(str(config), _BUILD_OPTS)
+
+ html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
+ page = BeautifulSoup(html, "html.parser")
+ video = page.select_one(".video-container video")
+ audio = page.select_one(".audio-container audio")
+ assert video is not None
+ assert audio is not None
+ assert video.source is not None
+ assert audio.source is not None
+ assert video.source["src"] == "https://example.com/movie.ogg"
+ assert video.source["type"] == "video/ogg"
+ assert audio.source["src"] == "sound.mp3"
+ assert not audio.has_attr("controls")
+
+
+def test_native_media_config_enables_audio_independently(
+ tmp_path: Path,
+) -> None:
+ docs = tmp_path / "docs"
+ docs.mkdir()
+ (docs / "index.md").write_text(
+ "# Media\n\n\n\n\n",
+ encoding="utf-8",
+ )
+ config = tmp_path / "zensical.toml"
+ config.write_text(
+ "[project]\n"
+ 'site_name = "Media"\n'
+ '[project.markdown_extensions."zensical.extensions.media".video]\n'
+ "enabled = false\n"
+ '[project.markdown_extensions."zensical.extensions.media".audio]\n'
+ "enabled = true\n",
+ encoding="utf-8",
+ )
+
+ zensical.build(str(config), _BUILD_OPTS)
+
+ html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
+ page = BeautifulSoup(html, "html.parser")
+ assert page.select_one(".audio-container audio") is not None
+ assert page.select_one(".video-container") is None
+ assert page.select_one('img[alt="type:video"]') is not None
+
+
+def test_shared_marker_uses_legacy_plugin_order(tmp_path: Path) -> None:
+ docs = tmp_path / "docs"
+ docs.mkdir()
+ (docs / "index.md").write_text(
+ "# Media\n\n\n", encoding="utf-8"
+ )
+ config = tmp_path / "mkdocs.yml"
+ config.write_text(
+ "site_name: Media\n"
+ "plugins:\n"
+ " - mkdocs-audio:\n"
+ " mark: shared\n"
+ " - mkdocs-video:\n"
+ " mark: shared\n",
+ encoding="utf-8",
+ )
+
+ zensical.build(str(config), _BUILD_OPTS)
+
+ html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
+ page = BeautifulSoup(html, "html.parser")
+ assert page.select_one(".audio-container audio") is not None
+ assert page.select_one(".video-container") is None
diff --git a/python/tests/unit/extensions/test_media.py b/python/tests/unit/extensions/test_media.py
new file mode 100644
index 0000000..97e2645
--- /dev/null
+++ b/python/tests/unit/extensions/test_media.py
@@ -0,0 +1,239 @@
+# 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 pytest
+from markdown import Markdown
+from markdown.postprocessors import Postprocessor
+
+from tests.unit.extensions.conftest import soup
+from zensical.extensions.glightbox import GlightboxExtension
+from zensical.extensions.links import LinksExtension
+from zensical.extensions.media import MediaExtension
+
+
+def _markdown(**config: object) -> Markdown:
+ return Markdown(extensions=["attr_list", MediaExtension(**config)])
+
+
+def test_default_video_and_audio_embeds() -> None:
+ md = _markdown()
+ page = soup(
+ md.convert(
+ "\n\n"
+ ""
+ )
+ )
+
+ iframe = page.select_one(".video-container > iframe")
+ assert iframe is not None
+ assert iframe["src"] == "https://example.com/embed/1"
+ assert iframe["style"] == "position:relative;width:100%;height:22.172vw"
+ assert iframe["frameborder"] == "0"
+ assert iframe.has_attr("allowfullscreen")
+
+ audio = page.select_one(".audio-container > audio")
+ assert audio is not None
+ assert audio.has_attr("controls")
+ assert audio["style"] == "width:100%"
+ source = audio.find("source")
+ assert source is not None
+ assert source["src"] == "sound.mp3"
+ assert source["type"] == "audio/mp3"
+
+
+@pytest.mark.parametrize("kind", ["video", "audio"])
+def test_media_can_be_enabled_independently(kind: str) -> None:
+ config = {
+ "video": {"enabled": kind == "video"},
+ "audio": {"enabled": kind == "audio"},
+ }
+ page = soup(
+ _markdown(**config).convert(
+ "\n\n"
+ )
+ )
+
+ assert (page.select_one(".video-container") is not None) == (
+ kind == "video"
+ )
+ assert (page.select_one(".audio-container") is not None) == (
+ kind == "audio"
+ )
+ assert len(page.find_all("img")) == 1
+
+
+def test_video_element_options_and_attribute_overrides() -> None:
+ md = _markdown(
+ video={
+ "is_video": True,
+ "video_type": " OGG ",
+ "video_muted": True,
+ "video_loop": True,
+ "video_controls": False,
+ "video_autoplay": True,
+ "css_style": {"width": "50%"},
+ },
+ audio={"enabled": False},
+ )
+ page = soup(
+ md.convert(
+ ""
+ "{: src='other.ogg' style='width: 80%' .demo}"
+ )
+ )
+
+ video = page.select_one(".video-container > video.demo")
+ assert video is not None
+ assert video["style"] == "width: 80%"
+ assert video.has_attr("muted")
+ assert video.has_attr("loop")
+ assert video.has_attr("autoplay")
+ assert not video.has_attr("controls")
+ assert not video.has_attr("src")
+ assert video.source is not None
+ assert video.source["src"] == "other.ogg"
+ assert video.source["type"] == "video/ogg"
+
+
+def test_audio_options_and_disabling_globals() -> None:
+ md = _markdown(
+ video={"enabled": False},
+ audio={
+ "mark": "sound",
+ "audio_type": " OGG ",
+ "audio_controls": False,
+ "audio_loop": True,
+ "audio_autoplay": True,
+ },
+ )
+ page = soup(
+ md.convert(
+ "\n\n"
+ "{: disable-global-config style='width: 60%'}"
+ )
+ )
+
+ first, second = page.select(".audio-container > audio")
+ assert not first.has_attr("controls")
+ assert first.has_attr("loop")
+ assert first.has_attr("autoplay")
+ assert first.source is not None
+ assert first.source["type"] == "audio/ogg"
+ assert second["style"] == "width: 60%"
+ assert not second.has_attr("loop")
+ assert not second.has_attr("autoplay")
+ assert not second.has_attr("disable-global-config")
+
+
+def test_raw_html_and_markdown_media_precede_image_and_url_processing() -> None:
+ md = Markdown(
+ extensions=[
+ "attr_list",
+ GlightboxExtension(),
+ MediaExtension(),
+ LinksExtension(path="guide/page.md", use_directory_urls=True),
+ ]
+ )
+ html = md.convert(
+ '
\n\n'
+ "\n\n"
+ )
+ page = soup(html)
+
+ audio_source = page.select_one(".audio-container source")
+ video_frame = page.select_one(".video-container iframe")
+ ordinary_img = page.select_one("a.glightbox img")
+ assert audio_source is not None
+ assert video_frame is not None
+ assert ordinary_img is not None
+ assert audio_source["src"] == "../sound.mp3"
+ assert video_frame["src"] == "../movie.mp4"
+ assert len(page.select("a.glightbox")) == 1
+ assert ordinary_img["src"] == "../picture.png"
+
+
+def test_raw_html_preserves_unrelated_markup_and_inline_text() -> None:
+ md = _markdown()
+ html = md.convert(
+ "\n\n"
+ 'before
after
'
+ )
+ assert (
+ ""
+ in html
+ )
+ assert 'class="demo"' in html
+ assert "after
" in html
+ frame = soup(html).select_one(".video-container iframe")
+ assert frame is not None
+ assert frame["src"] == "a>b.mp4"
+
+
+def test_tree_preserves_text_after_inline_image() -> None:
+ html = _markdown().convert("before  after")
+ assert "after" in html
+
+
+def test_source_url_restores_markdown_escapes() -> None:
+ html = _markdown().convert(r"")
+ source = soup(html).select_one(".audio-container source")
+ assert source is not None
+ assert source["src"] == "a_b.mp3?x=1&y=2"
+
+
+def test_markdown_entities_match_media_markers_and_sources() -> None:
+ html = _markdown().convert("")
+ source = soup(html).select_one(".audio-container source")
+ assert source is not None
+ assert source["src"] == "a.mp3"
+
+
+def test_raw_html_entities_are_decoded_only_once() -> None:
+ html = _markdown().convert('
')
+ source = soup(html).select_one(".audio-container source")
+ assert source is not None
+ assert source["src"] == "a&b.mp3"
+
+
+def test_media_from_late_markdown_postprocessor() -> None:
+ class LateImage(Postprocessor):
+ def run(self, text: str) -> str:
+ return text + '
'
+
+ md = _markdown()
+ md.postprocessors.register(LateImage(md), "late_image", 25)
+
+ source = soup(md.convert("# Media")).select_one(".audio-container source")
+ assert source is not None
+ assert source["src"] == "late.mp3"
+
+
+def test_markdown_instance_can_be_reset() -> None:
+ md = _markdown()
+ assert "audio-container" in md.convert('
')
+ md.reset()
+ assert "audio-container" in md.convert('
')
diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py
index 58ced6c..4d19038 100644
--- a/python/tests/unit/test_config.py
+++ b/python/tests/unit/test_config.py
@@ -41,6 +41,7 @@ from zensical.config import (
from zensical.extensions.autorefs import AutorefsExtension
from zensical.extensions.glightbox import GlightboxExtension
from zensical.extensions.macros import MacrosExtension
+from zensical.extensions.media import MediaExtension
from zensical.extensions.mkdocstrings import MkdocstringsExtension
from zensical.extensions.table_reader import TableReaderExtension
@@ -618,6 +619,77 @@ class TestPluginShimming:
"width": "80%"
}
+ @pytest.mark.parametrize(
+ ("plugin", "section", "other"),
+ [
+ ("mkdocs-video", "video", "audio"),
+ ("mkdocs-audio", "audio", "video"),
+ ],
+ )
+ def test_media_plugin_enables_only_its_section(
+ self, tmp_path: Path, plugin: str, section: str, other: str
+ ) -> None:
+ config = self._parse_yaml(
+ tmp_path, plugins={plugin: {"mark": "custom"}}
+ )
+
+ assert config["markdown_extensions"].count(MediaExtension.name) == 1
+ media = config["mdx_configs"][MediaExtension.name]
+ assert media[section] == {"enabled": True, "mark": "custom"}
+ assert media[other] == {"enabled": False}
+
+ def test_both_media_plugins_share_one_extension(
+ self, tmp_path: Path
+ ) -> None:
+ config = self._parse_yaml(
+ tmp_path,
+ plugins=[
+ {"mkdocs-video": {"is_video": True}},
+ {"mkdocs-audio": {"audio_loop": True}},
+ ],
+ )
+
+ assert config["markdown_extensions"].count(MediaExtension.name) == 1
+ media = config["mdx_configs"][MediaExtension.name]
+ assert media["order"] == ["video", "audio"]
+ assert media["video"] == {"enabled": True, "is_video": True}
+ assert media["audio"] == {"enabled": True, "audio_loop": True}
+
+ def test_media_extension_settings_override_plugin_defaults(
+ self, tmp_path: Path
+ ) -> None:
+ config = self._parse_yaml(
+ tmp_path,
+ markdown_extensions={
+ MediaExtension.name: {
+ "video": {"enabled": False, "mark": "direct"},
+ "audio": {"enabled": True},
+ }
+ },
+ plugins={"mkdocs-video": {"mark": "plugin", "video_loop": True}},
+ )
+
+ assert config["markdown_extensions"].count(MediaExtension.name) == 1
+ media = config["mdx_configs"][MediaExtension.name]
+ assert media["video"] == {
+ "enabled": False,
+ "mark": "direct",
+ "video_loop": True,
+ }
+ assert media["audio"] == {"enabled": True}
+
+ def test_disabled_media_plugin_does_not_enable_other_section(
+ self, tmp_path: Path
+ ) -> None:
+ config = self._parse_yaml(
+ tmp_path, plugins={"mkdocs-video": {"enabled": False}}
+ )
+ media = config["mdx_configs"][MediaExtension.name]
+ assert media == {
+ "video": {"enabled": False},
+ "audio": {"enabled": False},
+ }
+
@pytest.mark.parametrize(
("plugin", "options"),
[
diff --git a/python/zensical/config.py b/python/zensical/config.py
index 4c73ee1..ad023e1 100644
--- a/python/zensical/config.py
+++ b/python/zensical/config.py
@@ -50,6 +50,7 @@ from zensical.extensions.autorefs import AutorefsExtension
from zensical.extensions.emoji import to_svg, twemoji
from zensical.extensions.glightbox import GlightboxExtension
from zensical.extensions.macros import MacrosExtension
+from zensical.extensions.media import MediaExtension
from zensical.extensions.mkdocstrings import MkdocstringsExtension
from zensical.extensions.table_reader import TABLE_READERS, TableReaderExtension
@@ -149,7 +150,9 @@ _PLUGIN_UNSUPPORTED_OPTIONS = {
"javascript_dir",
),
"minify": (),
+ "mkdocs-audio": (),
"mkdocs-autoapi": (),
+ "mkdocs-video": (),
"mkdocstrings": (
# TODO: Merge the removed plugin watch setting into project.watch.
"watch",
@@ -813,6 +816,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
_shim_gh_admonitions(config)
_shim_markdown_exec(config)
_shim_glightbox(config)
+ _shim_media(config)
_shim_macros(config)
_shim_table_reader(config)
@@ -1096,6 +1100,40 @@ def _shim_glightbox(config: dict[str, Any]) -> None:
config["mdx_configs"][GlightboxExtension.name] = plugin
+def _shim_media(config: dict[str, Any]) -> None:
+ """Map the audio and video plugins onto one Markdown extension."""
+ plugins = config["plugins"]
+ present = any(name in plugins for name in ("mkdocs-video", "mkdocs-audio"))
+ if not present:
+ return
+
+ name = MediaExtension.name
+ direct = name in config["markdown_extensions"]
+ if not direct:
+ config["markdown_extensions"].append(name)
+ media = config["mdx_configs"].setdefault(name, {})
+ if "mkdocs-video" in plugins and "mkdocs-audio" in plugins:
+ media.setdefault(
+ "order",
+ [
+ "video" if plugin_name == "mkdocs-video" else "audio"
+ for plugin_name in plugins
+ if plugin_name in {"mkdocs-video", "mkdocs-audio"}
+ ],
+ )
+
+ for kind, plugin_name in (
+ ("video", "mkdocs-video"),
+ ("audio", "mkdocs-audio"),
+ ):
+ settings = media.setdefault(kind, {})
+ if plugin_name in plugins:
+ for option, value in plugins[plugin_name]["config"].items():
+ settings.setdefault(option, value)
+ if not direct:
+ settings.setdefault("enabled", plugin_name in plugins)
+
+
def _shim_macros(config: dict[str, Any]) -> None:
# The Markdown extension is already enabled
if MacrosExtension.name in config["markdown_extensions"]:
@@ -2468,6 +2506,38 @@ def _convert_plugins(value: Any, config: dict) -> dict:
"glightbox manual must be a boolean or null"
)
+ # These plugins only transform rendered Markdown images. Their settings
+ # are forwarded to the corresponding section of the media extension.
+ for name, string_options, boolean_options in (
+ (
+ "mkdocs-video",
+ {"mark", "video_type"},
+ {
+ "enabled",
+ "is_video",
+ "video_muted",
+ "video_loop",
+ "video_controls",
+ "video_autoplay",
+ },
+ ),
+ (
+ "mkdocs-audio",
+ {"mark", "audio_type"},
+ {"enabled", "audio_loop", "audio_controls", "audio_autoplay"},
+ ),
+ ):
+ if name not in plugins:
+ continue
+ media = plugins[name]
+ _reject_unknown_options(
+ name, media, string_options | boolean_options | {"css_style"}
+ )
+ _validate_string_options(name, media, string_options)
+ _validate_boolean_options(name, media, boolean_options)
+ if "css_style" in media and not isinstance(media["css_style"], dict):
+ raise ConfigurationError(f"{name} css_style must be a mapping")
+
if "macros" in plugins:
macros = plugins["macros"]
string_options = {
diff --git a/python/zensical/extensions/media.py b/python/zensical/extensions/media.py
new file mode 100644
index 0000000..c0e90be
--- /dev/null
+++ b/python/zensical/extensions/media.py
@@ -0,0 +1,351 @@
+# Copyright (c) 2025-2026 Zensical and contributors
+# Media behavior adapted from mkdocs-video, copyright (c) 2023 Mikalai Lisitsa,
+# and mkdocs-audio, copyright (c) 2024 Jean-François Cartier.
+#
+# 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 dataclasses import dataclass, field
+from html import unescape
+from html.parser import HTMLParser
+from typing import TYPE_CHECKING, Any
+from xml.etree.ElementTree import Element, tostring
+
+from markdown import Extension, Markdown
+from markdown.postprocessors import Postprocessor
+from markdown.treeprocessors import Treeprocessor, UnescapeTreeprocessor
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+
+@dataclass
+class VideoConfig:
+ """Settings compatible with mkdocs-video."""
+
+ enabled: bool = True
+ mark: str = "type:video"
+ is_video: bool = False
+ video_type: str = "mp4"
+ video_muted: bool = False
+ video_loop: bool = False
+ video_controls: bool = True
+ video_autoplay: bool = False
+ css_style: dict[str, Any] = field(
+ default_factory=lambda: {
+ "position": "relative",
+ "width": "100%",
+ "height": "22.172vw",
+ }
+ )
+
+
+@dataclass
+class AudioConfig:
+ """Settings compatible with mkdocs-audio."""
+
+ enabled: bool = True
+ mark: str = "type:audio"
+ audio_type: str = "mp3"
+ audio_loop: bool = False
+ audio_controls: bool = True
+ audio_autoplay: bool = False
+ css_style: dict[str, Any] = field(default_factory=lambda: {"width": "100%"})
+
+
+@dataclass
+class MediaConfig:
+ """Shared settings for the media processors."""
+
+ video: VideoConfig
+ audio: AudioConfig
+ order: tuple[str, str]
+
+
+def _media_type(kind: str, subtype: str) -> str:
+ """Validate the upstream MIME subtype syntax."""
+ subtype = subtype.lower().strip()
+ if any(char in subtype for char in (" ", "/")):
+ raise ValueError(f"Unsupported {kind} type")
+ return f"{kind}/{subtype}"
+
+
+def _style(values: Mapping[str, Any]) -> str:
+ return ";".join(f"{key}:{value}" for key, value in values.items())
+
+
+def _raw_source(source: Element) -> str:
+ """Serialize the HTML void element absent from Markdown's tag catalog."""
+ return tostring(source, encoding="unicode", method="html").removesuffix(
+ ""
+ )
+
+
+def _replace_image(
+ attrs: Mapping[str, str], config: MediaConfig
+) -> Element | None:
+ """Build a media element from decoded image attributes."""
+ src = attrs.get("src")
+ if not src:
+ return None
+
+ for kind in config.order:
+ settings = config.video if kind == "video" else config.audio
+ if settings.enabled and attrs.get("alt") == settings.mark:
+ break
+ else:
+ return None
+
+ global_config = "disable-global-config" not in attrs
+ tag = "iframe" if kind == "video" and not config.video.is_video else kind
+ media = Element(tag)
+
+ if tag == "iframe":
+ media.set("src", src)
+ else:
+ source = Element("source", {"src": src})
+ subtype = (
+ config.video.video_type
+ if kind == "video"
+ else config.audio.audio_type
+ )
+ source.set("type", _media_type(kind, subtype))
+ media.append(source)
+
+ # Audio's controls setting is independent of disable-global-config. The
+ # upstream plugin always emits controls, even when configured false; omit
+ # it for false so browsers actually hide the controls.
+ if kind == "audio" and config.audio.audio_controls:
+ media.set("controls", "")
+
+ if global_config:
+ media.set("style", _style(settings.css_style))
+ if kind == "video":
+ if tag == "iframe":
+ media.set("frameborder", "0")
+ media.set("allowfullscreen", "")
+ else:
+ for enabled, name in (
+ (config.video.video_loop, "loop"),
+ (config.video.video_muted, "muted"),
+ (config.video.video_controls, "controls"),
+ (config.video.video_autoplay, "autoplay"),
+ ):
+ if enabled:
+ media.set(name, "")
+ else:
+ if config.audio.audio_loop:
+ media.set("loop", "")
+ if config.audio.audio_autoplay:
+ media.set("autoplay", "")
+
+ # Attribute lists have already updated the image, and take precedence over
+ # the global defaults. The image source belongs to iframe or source only.
+ for name, value in attrs.items():
+ if name not in {"src", "disable-global-config"}:
+ media.set(name, value)
+
+ wrapper = Element("div", {"class": f"{kind}-container"})
+ wrapper.append(media)
+ return wrapper
+
+
+class MediaTreeprocessor(Treeprocessor):
+ """Transform Markdown image nodes after attr_list has run."""
+
+ name = "media"
+
+ def __init__(self, md: Markdown, config: MediaConfig):
+ super().__init__(md)
+ self.config = config
+ self._unescape = UnescapeTreeprocessor(md).unescape
+
+ def run(self, root: Element) -> None:
+ for parent in root.iter():
+ for index, img in enumerate(list(parent)):
+ if img.tag != "img":
+ continue
+ # Markdown keeps entities in image attributes until its
+ # serialization step; HTMLParser has already decoded them.
+ attrs = {
+ name: unescape(value) for name, value in img.attrib.items()
+ }
+ replacement = _replace_image(attrs, self.config)
+ if replacement is not None:
+ # Python Markdown does not serialize as a void
+ # element. Stash its HTML so raw_html restores the proper
+ # tag after the URL postprocessor has rewritten its src.
+ source = replacement.find(".//source")
+ if source is not None:
+ media = replacement[0]
+ media.remove(source)
+ source.set("src", self._unescape(source.get("src", "")))
+ media.text = self.md.htmlStash.store(
+ _raw_source(source)
+ )
+ replacement.tail = img.tail
+ parent[index] = replacement
+
+
+class _RawMediaParser(HTMLParser):
+ """Find image tag spans without changing unrelated raw HTML."""
+
+ def __init__(self, source: str, config: MediaConfig):
+ super().__init__(convert_charrefs=False)
+ self.source = source
+ self.config = config
+ self.replacements: list[tuple[int, int, str]] = []
+ self.line_starts = [0]
+ self.line_starts.extend(
+ index + 1 for index, char in enumerate(source) if char == "\n"
+ )
+
+ def handle_starttag(
+ self, tag: str, attrs: list[tuple[str, str | None]]
+ ) -> None:
+ if tag != "img":
+ return
+ decoded = {name: value or "" for name, value in attrs}
+ replacement = _replace_image(decoded, self.config)
+ if replacement is not None:
+ line, column = self.getpos()
+ start = self.line_starts[line - 1] + column
+ raw = self.get_starttag_text()
+ if raw is not None:
+ html = tostring(replacement, encoding="unicode", method="html")
+ html = html.replace("", "")
+ self.replacements.append(
+ (
+ start,
+ start + len(raw),
+ html,
+ )
+ )
+
+ def handle_startendtag(
+ self, tag: str, attrs: list[tuple[str, str | None]]
+ ) -> None:
+ self.handle_starttag(tag, attrs)
+
+ def convert(self) -> str:
+ self.feed(self.source)
+ if not self.replacements:
+ return self.source
+ parts: list[str] = []
+ cursor = 0
+ for start, end, replacement in self.replacements:
+ parts.extend((self.source[cursor:start], replacement))
+ cursor = end
+ parts.append(self.source[cursor:])
+ return "".join(parts)
+
+
+class MediaPostprocessor(Postprocessor):
+ """Transform images in Python Markdown's raw HTML stash."""
+
+ name = "media"
+
+ def __init__(self, md: Markdown, config: MediaConfig):
+ super().__init__(md)
+ self.config = config
+ self._cursor = 0
+ self._blocks = md.htmlStash.rawHtmlBlocks
+
+ def run(self, text: str) -> str:
+ blocks = self.md.htmlStash.rawHtmlBlocks
+ if blocks is not self._blocks:
+ self._blocks = blocks
+ self._cursor = 0
+ while self._cursor < len(blocks):
+ block = blocks[self._cursor]
+ if isinstance(block, str) and "
str:
+ if "
None:
+ self.enabled = kwargs.pop("enabled", True)
+ video = kwargs.pop("video", {})
+ audio = kwargs.pop("audio", {})
+ order = kwargs.pop("order", ("video", "audio"))
+ if kwargs:
+ raise ValueError(
+ f"Unknown media options: {', '.join(sorted(kwargs))}"
+ )
+ if not isinstance(video, dict) or not isinstance(audio, dict):
+ raise TypeError("Media video and audio settings must be mappings")
+ if not isinstance(order, (list, tuple)) or tuple(order) not in (
+ ("video", "audio"),
+ ("audio", "video"),
+ ):
+ raise ValueError("Media order must contain video and audio once")
+ self.media_config = MediaConfig(
+ VideoConfig(**video), AudioConfig(**audio), tuple(order)
+ )
+
+ def extendMarkdown(self, md: Markdown) -> None:
+ if not self.enabled or not (
+ self.media_config.video.enabled or self.media_config.audio.enabled
+ ):
+ return
+ md.registerExtension(self)
+
+ # attr_list runs at 8, glightbox at 7, and URL rewriting at 0.
+ tree = MediaTreeprocessor(md, self.media_config)
+ md.treeprocessors.register(tree, tree.name, 7.5)
+
+ # Run before glightbox and URL rewriting (31), then raw_html (30).
+ post = MediaPostprocessor(md, self.media_config)
+ md.postprocessors.register(post, post.name, 32)
+
+ # The MkDocs plugins run on final page HTML. Catch images emitted by
+ # other postprocessors after the tree and raw HTML stash were visited.
+ final = MediaFinalPostprocessor(md, self.media_config)
+ md.postprocessors.register(final, final.name, 19)
+
+
+def makeExtension(**kwargs: Any) -> MediaExtension:
+ """Register the media Markdown extension."""
+ return MediaExtension(**kwargs)