# # """
# # mkdocs-revealjs — plugin.py

# # Hooks
# # -----
# # on_page_markdown  : save raw slides + config, return placeholder
# # on_page_content   : replace placeholder with .reveal div
# # on_post_page      : inject <link> + <script> before </body>

# # Theming pipeline (per scheme: light / dark, per scope: theme / highlight / mermaid)
# # -------------------------------------------------------------------------------------
# # Each light/dark entry supports three ordered layers:

# #   1. starter / default  — base theme  (CDN built-in name or any URL)
# #   2. hack               — inline <style> CSS overrides on top of starter
# #   3. custom             — additional CSS file loaded after starter+hack
# #                           (mermaid also accepts  custom_js  for SVG patching)

# # All layers are optional and compose in that order.

# # Shorthand coercion:
# #   light: solarized  →  { starter: "solarized", hack: {}, custom: None }
# #   "starter" and "default" keywords are exact aliases.

# # Combinations:
# #   starter only        → load starter
# #   hack only           → apply overrides on top of plugin default
# #   custom only         → load custom CSS (no starter loaded)
# #   starter + hack      → starter then hack inline
# #   starter + custom    → starter then custom file
# #   all three           → starter → hack inline → custom file

# # hack selector rules (auto-prefix per scope, '&' = scope prefix):
# #   scope     auto-prefix
# #   theme     .reveal
# #   highlight .reveal .hljs
# #   mermaid   .reveal .mermaid

# #   h1                →  .reveal h1 { … }
# #   "& .slides"       →  .reveal .slides { … }
# #   "&.embedded"      →  .reveal.embedded { … }

# # mermaid custom_js convention:
# #   The JS file must assign:
# #     window._mkdocsRevealjsMermaidPatchLight = function(deckEl) { … }
# #     window._mkdocsRevealjsMermaidPatchDark  = function(deckEl) { … }
# #   The plugin calls the appropriate function after each mermaid.run().

# # Separators
# # ----------
# #   Horizontal slide    :  blank line + --- + blank line
# #   Vertical slide      :  blank line + ---- + blank line
# #   Speaker notes       :  Note: at start of line
# #   End of presentation :  blank line + ===== + blank line

# # Import mode
# # -----------
# #   revealjs:
# #     presentation: import
# #     path: ./slides.md

# # Reveal.js options
# # -----------------
# # Plugin-only (not forwarded to Reveal.initialize()):
# #   cdn, js, theme, highlight, mermaid
# # """

# # import json
# # import logging
# # import os
# # import re
# # import yaml
# # import markdown as _markdown_lib
# # from mkdocs.plugins import BasePlugin
# # from mkdocs.config import config_options
# # from mkdocs.config.base import Config
# # from mkdocs.structure.files import File

# # log = logging.getLogger("mkdocs.plugins.revealjs")

# # CDN_DEFAULT  = "https://cdn.jsdelivr.net/npm/reveal.js@5.1.0"
# # HLJS_CDN     = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0"
# # MERMAID_CDN  = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"

# # # ---------------------------------------------------------------------------
# # # Custom Mermaid themes — auto-discovered from the themes/ folder
# # # ---------------------------------------------------------------------------
# # # Any file named  mermaid-theme-NAME.js  placed in the themes/ folder is
# # # automatically available as theme name NAME in mkdocs.yml or per-page config.
# # #
# # # The JS file must set a global variable following this convention:
# # #   window._mkdocsRevealNAMEThemeVars = { primaryColor: '...', ... }
# # # where NAME is the theme name with each word Title-cased, e.g.:
# # #   mermaid-theme-sky.js        →  window._mkdocsRevealSkyThemeVars
# # #   mermaid-theme-fire.js       →  window._mkdocsRevealFireThemeVars
# # #   mermaid-theme-ocean-deep.js →  window._mkdocsRevealOceanDeepThemeVars
# # #
# # # To add a new theme: just drop  mermaid-theme-NAME.js  into the themes/ folder.
# # # No Python editing needed — it is picked up automatically on next build.
# # _THEMES_DIR    = os.path.join(os.path.dirname(__file__), "themes")
# # _BASE_CSS_PATH = os.path.join(os.path.dirname(__file__), "assets", "revealjs-base.css")

# # def _discover_mermaid_themes(themes_dir: str) -> dict:
# #     """
# #     Scan themes_dir for files matching  mermaid-theme-NAME.js  and return
# #       { "NAME": (abs_path, "_mkdocsRevealNAMEThemeVars"), ... }
# #     NAME may contain hyphens (e.g. "ocean-deep"); the global var is built by
# #     Title-casing each hyphen-separated word and joining them.
# #     """
# #     result = {}
# #     if not os.path.isdir(themes_dir):
# #         return result
# #     prefix = "mermaid-theme-"
# #     suffix = ".js"
# #     for fname in os.listdir(themes_dir):
# #         if fname.startswith(prefix) and fname.endswith(suffix):
# #             name = fname[len(prefix):-len(suffix)]   # e.g. "sky", "ocean-deep"
# #             if not name:
# #                 continue
# #             # "_mkdocsRevealOceanDeepThemeVars"
# #             camel = "".join(part.capitalize() for part in name.split("-"))
# #             js_var = f"_mkdocsReveal{camel}ThemeVars"
# #             result[name] = (os.path.join(themes_dir, fname), js_var)
# #     return result

# # _BUNDLED_MERMAID_THEMES = _discover_mermaid_themes(_THEMES_DIR)

# # # Official Mermaid built-in theme names — passed directly, no extra script.
# # _MERMAID_BUILTIN_THEMES = {"base", "default", "neutral", "forest", "dark"}

# # _LIGHT_BUILTIN = "white"
# # _DARK_BUILTIN  = "black"
# # _HLJS_LIGHT    = "atom-one-light"
# # _HLJS_DARK     = "atom-one-dark"
# # _MM_LIGHT      = "base"
# # _MM_DARK       = "dark"

# # _DIV_PLACEHOLDER = "MKDOCS_REVEALJS_DIV_7f3a9b2c"
# # _DEFAULT_HEIGHT  = 600

# # _PLUGIN_ONLY = {"cdn", "js", "theme", "highlight", "mermaid"}
# # _PAGE_ONLY   = _PLUGIN_ONLY | {"presentation", "path"}

# # _SEP_H    = r"^\n---\n$"
# # _SEP_V    = r"^\n----\n$"
# # _SEP_NOTE = r"^Note:"

# # _END_MARKER_RE = re.compile(r'\n\n={5,}\n\n', re.MULTILINE)

# # # CSS scope prefix used for hack auto-prefixing
# # _HACK_SCOPE_PREFIX = {
# #     "theme":     ".reveal",
# #     "highlight": ".reveal .hljs",
# #     "mermaid":   ".reveal .mermaid",
# # }


# # # ---------------------------------------------------------------------------
# # # SchemeDesc — normalised descriptor for one light-or-dark entry
# # # ---------------------------------------------------------------------------

# # class SchemeDesc:
# #     """Normalised, fully-resolved descriptor for a single light or dark entry."""
# #     __slots__ = ("starter", "hack", "custom", "custom_js")

# #     def __init__(self, starter=None, hack=None, custom=None, custom_js=None):
# #         self.starter   = starter        # str | None
# #         self.hack      = hack or {}     # dict  selector → {prop: val, …}
# #         self.custom    = custom         # str path/URL | None
# #         self.custom_js = custom_js      # str path/URL | None  (mermaid only)

# #     def __repr__(self):
# #         return (f"SchemeDesc(starter={self.starter!r}, "
# #                 f"hack_keys={list(self.hack.keys())}, "
# #                 f"custom={self.custom!r}, custom_js={self.custom_js!r})")


# # def _coerce_scheme(value, allow_custom_js: bool = False) -> SchemeDesc:
# #     """
# #     Coerce any user-supplied value for a light/dark key into a SchemeDesc.

# #     Accepted forms:
# #       None / missing   → SchemeDesc()
# #       "solarized"      → SchemeDesc(starter="solarized")
# #       { starter: …,    → full form  ("default" is an alias for "starter")
# #         hack: {…},
# #         custom: …,
# #         custom_js: … }
# #     """
# #     if value is None:
# #         return SchemeDesc()
# #     if isinstance(value, str):
# #         return SchemeDesc(starter=value.strip() or None)
# #     if not isinstance(value, dict):
# #         return SchemeDesc()

# #     # "default" and "starter" are aliases
# #     starter = value.get("starter") or value.get("default") or None
# #     if isinstance(starter, str):
# #         starter = starter.strip() or None

# #     hack = value.get("hack") or {}
# #     if not isinstance(hack, dict):
# #         hack = {}

# #     custom = value.get("custom") or None
# #     if isinstance(custom, str):
# #         custom = custom.strip() or None

# #     custom_js = None
# #     if allow_custom_js:
# #         custom_js = value.get("custom_js") or None
# #         if isinstance(custom_js, str):
# #             custom_js = custom_js.strip() or None

# #     return SchemeDesc(starter=starter, hack=hack, custom=custom, custom_js=custom_js)


# # # ---------------------------------------------------------------------------
# # # _AnyVal — MkDocs config option that accepts any YAML value
# # # ---------------------------------------------------------------------------

# # class _AnyVal(config_options.BaseConfigOption):
# #     """Accept any YAML value (str, dict, bool, None) without type coercion."""
# #     def run_validation(self, value):
# #         return value


# # # ---------------------------------------------------------------------------
# # # Sub-config schemas
# # # ---------------------------------------------------------------------------

# # class ThemeConfig(Config):
# #     light = _AnyVal()
# #     dark  = _AnyVal()


# # class HighlightConfig(Config):
# #     light = _AnyVal()
# #     dark  = _AnyVal()


# # class MermaidConfig(Config):
# #     activate = config_options.Type(bool, default=False)
# #     light    = _AnyVal()
# #     dark     = _AnyVal()


# # # ---------------------------------------------------------------------------
# # # Main plugin config schema
# # # ---------------------------------------------------------------------------

# # O = config_options.Optional
# # T = config_options.Type


# # class RevealjsPluginConfig(Config):
# #     # ── Plugin-only ──────────────────────────────────────────────────────────
# #     cdn       = O(T(str))
# #     theme     = config_options.SubConfig(ThemeConfig)
# #     highlight = config_options.SubConfig(HighlightConfig)
# #     js        = O(T(str))
# #     mermaid   = config_options.SubConfig(MermaidConfig)

# #     # ── Reveal.js options forwarded verbatim to Reveal.initialize() ──────────
# #     controls             = O(T(bool))
# #     controlsTutorial     = O(T(bool))
# #     controlsLayout       = O(T(str))
# #     controlsBackArrows   = O(T(str))
# #     progress             = O(T(bool))
# #     slideNumber          = O(T(bool))
# #     showSlideNumber      = O(T(str))
# #     hash                 = O(T(bool))
# #     history              = O(T(bool))
# #     keyboard             = O(T(bool))
# #     overview             = O(T(bool))
# #     center               = O(T(bool))
# #     touch                = O(T(bool))
# #     loop                 = O(T(bool))
# #     rtl                  = O(T(bool))
# #     navigationMode       = O(T(str))
# #     shuffle              = O(T(bool))
# #     fragments            = O(T(bool))
# #     fragmentInURL        = O(T(bool))
# #     help                 = O(T(bool))
# #     pause                = O(T(bool))
# #     showNotes            = O(T(bool))
# #     autoPlayMedia        = O(T(bool))
# #     preloadIframes       = O(T(bool))
# #     autoAnimate          = O(T(bool))
# #     autoAnimateEasing    = O(T(str))
# #     autoAnimateDuration  = O(T(float))
# #     autoAnimateUnmatched = O(T(bool))
# #     autoSlide            = O(T(int))
# #     autoSlideStoppable   = O(T(bool))
# #     defaultTiming        = O(T(int))
# #     mouseWheel           = O(T(bool))
# #     previewLinks         = O(T(bool))
# #     postMessage          = O(T(bool))
# #     postMessageEvents    = O(T(bool))
# #     transition           = O(T(str))
# #     transitionSpeed      = O(T(str))
# #     backgroundTransition = O(T(str))
# #     hideInactiveCursor   = O(T(bool))
# #     hideCursorTime       = O(T(int))
# #     minScale             = O(T(float))
# #     maxScale             = O(T(float))

# #     def load_dict(self, data):
# #         """Coerce  mermaid: true/false  shorthand into nested dict."""
# #         if "mermaid" in data and isinstance(data["mermaid"], bool):
# #             data = dict(data)
# #             data["mermaid"] = {"activate": data["mermaid"]}
# #         super().load_dict(data)


# # # ---------------------------------------------------------------------------
# # # Resolution helpers
# # # ---------------------------------------------------------------------------

# # def _safe_get(cfg, key):
# #     """Read a field from a SubConfig object (or plain dict), safely."""
# #     if cfg is None:
# #         return None
# #     try:
# #         return cfg.get(key)
# #     except Exception:
# #         return None


# # def _resolve_scope(global_cfg, page_cfg: dict, scope: str,
# #                    default_light=None, default_dark=None,
# #                    allow_custom_js=False):
# #     """
# #     Resolve one scope (theme / highlight / mermaid) into (light_desc, dark_desc).

# #     Priority chain per key: per-page > global > plugin default starter.
# #     Per-page wins on every sub-key it explicitly defines.
# #     """
# #     # ── global ──────────────────────────────────────────────────────────────
# #     g_light = _coerce_scheme(_safe_get(global_cfg, "light"), allow_custom_js)
# #     g_dark  = _coerce_scheme(_safe_get(global_cfg, "dark"),  allow_custom_js)

# #     # Apply plugin-level default starters where global didn't specify one
# #     if g_light.starter is None and default_light:
# #         g_light.starter = default_light
# #     if g_dark.starter is None and default_dark:
# #         g_dark.starter = default_dark

# #     # ── per-page ─────────────────────────────────────────────────────────────
# #     page_scope = page_cfg.get(scope) if isinstance(page_cfg, dict) else None
# #     if not isinstance(page_scope, dict):
# #         return g_light, g_dark

# #     def _merge(raw, base: SchemeDesc) -> SchemeDesc:
# #         """Merge per-page raw value with global base. Per-page wins per key."""
# #         if raw is None:
# #             return base
# #         p = _coerce_scheme(raw, allow_custom_js)
# #         return SchemeDesc(
# #             starter   = p.starter   if p.starter   is not None else base.starter,
# #             hack      = p.hack      if p.hack                  else base.hack,
# #             custom    = p.custom    if p.custom    is not None else base.custom,
# #             custom_js = p.custom_js if p.custom_js is not None else base.custom_js,
# #         )

# #     light = _merge(page_scope.get("light"), g_light)
# #     dark  = _merge(page_scope.get("dark"),  g_dark)
# #     return light, dark


# # def _resolve_mermaid_activate(global_cfg, page_cfg: dict) -> bool:
# #     """Resolve mermaid activate flag: per-page overrides global."""
# #     g = bool(_safe_get(global_cfg, "activate"))
# #     pm = page_cfg.get("mermaid") if isinstance(page_cfg, dict) else None
# #     if isinstance(pm, bool):
# #         return pm
# #     if isinstance(pm, dict) and "activate" in pm:
# #         return bool(pm["activate"])
# #     return g


# # # ---------------------------------------------------------------------------
# # # CSS hack renderer
# # # ---------------------------------------------------------------------------

# # def _render_hack_css(hack: dict, scope: str) -> str:
# #     """
# #     Render a hack dict into a CSS string scoped to the presentation.

# #     Selector rules:
# #       h1            →  <prefix> h1 { … }
# #       "& .slides"   →  <prefix> .slides { … }    (& = prefix)
# #       "&.embedded"  →  <prefix>.embedded { … }   (& with no space)
# #     """
# #     if not hack:
# #         return ""
# #     prefix = _HACK_SCOPE_PREFIX.get(scope, ".reveal")
# #     rules  = []
# #     for selector, props in hack.items():
# #         if not isinstance(props, dict) or not props:
# #             continue
# #         if selector.startswith("&"):
# #             full_sel = prefix + selector[1:]
# #         else:
# #             full_sel = f"{prefix} {selector}"
# #         declarations = "; ".join(f"{p}: {v}" for p, v in props.items())
# #         rules.append(f"  {full_sel} {{ {declarations} }}")
# #     return "\n".join(rules)


# # # ---------------------------------------------------------------------------
# # # URL helpers
# # # ---------------------------------------------------------------------------

# # def _resolve_url(value: str, cdn_pattern: str, cdn_base: str = None) -> str:
# #     """
# #     Resolve a bare theme name or path to a full URL.
# #       http(s):// or /path  → used as-is
# #       bare name            → cdn_pattern.format(name=value)
# #     cdn_base is substituted into cdn_pattern if present.
# #     """
# #     if not value:
# #         return ""
# #     if value.startswith("http") or value.startswith("/"):
# #         return value
# #     if cdn_base:
# #         return cdn_pattern.format(cdn=cdn_base, name=value)
# #     return cdn_pattern.format(name=value)


# # def _theme_url(cdn: str, starter, builtin: str) -> str:
# #     """Reveal.js theme: bare name → CDN URL, None → builtin."""
# #     s = starter or builtin
# #     if s.startswith("http") or s.startswith("/"):
# #         return s
# #     return f"{cdn}/dist/theme/{s}.css"


# # def _load_bundled_css(path: str, id_: str = "") -> str:
# #     """Read a bundled CSS file and return it as an inline <style> block.

# #     Used to inject the plugin's own base corrections (revealjs-base.css)
# #     unconditionally, before any user theme layers.
# #     """
# #     if not path or not os.path.isfile(path):
# #         log.debug(f"[revealjs] bundled CSS not found (skipping): {path!r}")
# #         return ""
# #     try:
# #         with open(path, encoding="utf-8") as fh:
# #             id_attr = f' id="{id_}"' if id_ else ""
# #             return f"<style{id_attr}>\n{fh.read()}\n</style>"
# #     except OSError:
# #         log.warning(f"[revealjs] could not read bundled CSS: {path!r}")
# #         return ""


# # def _load_bundled_script(path: str) -> str:
# #     """Read a bundled JS file and return its content wrapped in a <script> tag."""
# #     if not path or not os.path.isfile(path):
# #         return ""
# #     try:
# #         with open(path, encoding="utf-8") as fh:
# #             return f"\n<script>\n{fh.read()}\n</script>"
# #     except OSError:
# #         log.warning(f"[revealjs] could not read bundled theme script: {path!r}")
# #         return ""




# # def _resolve_mermaid_theme_name(starter: str, default: str) -> tuple:
# #     """
# #     Resolve a mermaid starter name.

# #     Returns (theme_name, js_file_path_or_None, js_global_var_or_None).

# #     - Official built-in (base, default, neutral, forest, dark):
# #         → ('name', None, None)   — passed directly to mermaid.initialize
# #     - Bundled custom name (sky, …):
# #         → ('base', '/path/to/file.js', '_mkdocsRevealXxxThemeVars')
# #           The plugin inlines the JS and calls:
# #             mermaid.initialize({ theme: 'base', themeVariables: window[var] })
# #     - None / empty → falls back to default (treated as built-in)
# #     - Unknown name → passed through as-is (user-managed custom)
# #     """
# #     name = (starter or default).strip()
# #     if name in _BUNDLED_MERMAID_THEMES:
# #         js_path, js_var = _BUNDLED_MERMAID_THEMES[name]
# #         return "base", js_path, js_var   # always use 'base' as foundation
# #     return name, None, None


# # def _hljs_url(starter) -> str:
# #     """Highlight.js theme: bare name → CDN URL."""
# #     s = starter or _HLJS_LIGHT
# #     if s.startswith("http") or s.startswith("/"):
# #         return s
# #     return f"{HLJS_CDN}/styles/{s}.min.css"


# # # ---------------------------------------------------------------------------
# # # Markdown helpers
# # # ---------------------------------------------------------------------------

# # # Matches the opening line of a fenced code block:  ```lang  or  ~~~lang
# # # We only need to detect when we enter/leave a fence, not parse its header.
# # _FENCE_OPEN_RE = re.compile(r'^(`{3,}|~{3,})', re.MULTILINE)


# # def _fence_aware_split(text: str, separator: str):
# #     """
# #     Split *text* on *separator* (a literal string) while ignoring any
# #     occurrence of that separator that falls inside a fenced code block
# #     (``` … ``` or ~~~ … ~~~).

# #     Returns a list of parts — the separators themselves are NOT included
# #     (unlike re.split with a capturing group).  This mirrors the behaviour
# #     needed by _preprocess_auto_animate and _split_at_end_marker.

# #     Algorithm: walk through the text line by line, tracking whether we are
# #     inside a fence.  When outside a fence and we see a line that is exactly
# #     the separator (stripped), we record a split point.
# #     """
# #     sep_stripped = separator.strip('\n')
# #     parts   = []
# #     current = []
# #     fence_char  = None   # '`' or '~' when inside a fence, None when outside
# #     fence_len   = 0      # length of the opening fence run

# #     for line in text.split('\n'):
# #         if fence_char is None:
# #             # Check for fence opening
# #             m = _FENCE_OPEN_RE.match(line)
# #             if m:
# #                 fence_char = m.group(1)[0]
# #                 fence_len  = len(m.group(1))
# #                 current.append(line)
# #             elif line == sep_stripped:
# #                 # We are outside a fence and hit the separator → split here
# #                 parts.append('\n'.join(current))
# #                 current = []
# #             else:
# #                 current.append(line)
# #         else:
# #             # Inside a fence — look for the matching closing fence
# #             stripped = line.strip()
# #             if (stripped.startswith(fence_char * fence_len)
# #                     and all(c == fence_char for c in stripped)):
# #                 fence_char = None
# #                 fence_len  = 0
# #             current.append(line)

# #     parts.append('\n'.join(current))
# #     return parts


# # def _split_at_end_marker(markdown: str):
# #     """
# #     Split markdown at the ===== end-of-presentation marker, ignoring any
# #     ===== that appears inside a fenced code block.

# #     Returns (slides_part, tail_part) where tail_part is None if no marker
# #     was found outside a fenced block.
# #     """
# #     # Fast path: no ===== at all
# #     if not _END_MARKER_RE.search(markdown):
# #         return markdown, None

# #     parts = _fence_aware_split(markdown, "\n=====\n")
# #     if len(parts) == 1:
# #         # marker was only inside fences
# #         return markdown, None
# #     # Re-join everything after the first split point as the tail
# #     slides = parts[0]
# #     tail   = '\n'.join(parts[1:]).lstrip('\n') if len(parts) > 1 else None
# #     # _fence_aware_split strips the separator, so slides may have a trailing
# #     # blank line and tail a leading one — normalise:
# #     if tail == '':
# #         tail = None
# #     return slides, tail


# # def _load_import_file(host_src_path: str, rel_path: str):
# #     host_dir  = os.path.dirname(os.path.abspath(host_src_path))
# #     abs_path  = os.path.normpath(os.path.join(host_dir, rel_path))
# #     if not os.path.isfile(abs_path):
# #         raise FileNotFoundError(
# #             f"[revealjs] import path not found: {abs_path!r} "
# #             f"(referenced from {host_src_path!r})"
# #         )
# #     with open(abs_path, encoding="utf-8") as fh:
# #         raw = fh.read()
# #     fm_re    = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
# #     fm_match = fm_re.match(raw)
# #     if not fm_match:
# #         raise ValueError(
# #             f"[revealjs] import file has no YAML front-matter: {abs_path!r}"
# #         )
# #     try:
# #         meta = yaml.safe_load(fm_match.group(1)) or {}
# #     except yaml.YAMLError as exc:
# #         raise ValueError(f"[revealjs] YAML parse error in {abs_path!r}: {exc}")
# #     revealjs     = meta.get("revealjs", {})
# #     if not isinstance(revealjs, dict):
# #         revealjs = {}
# #     import_title = meta.get("title") or None
# #     body         = raw[fm_match.end():]
# #     return body, revealjs, import_title


# # # ---------------------------------------------------------------------------
# # # Auto-animate markdown pre-processor
# # # ---------------------------------------------------------------------------

# # _FENCE_RE = re.compile(
# #     r'^```'
# #     r'(\w*)'
# #     r'(?:\s*\{[^}]*data-id=["\']([^"\']+)["\'][^}]*\})?'
# #     r'(?:\s*\[([^\]]*)\])?'
# #     r'\s*$',
# #     re.MULTILINE,
# # )


# # def _slide_has_auto_animate(slide_text: str) -> bool:
# #     return 'data-auto-animate' in slide_text


# # def _neutralise_separators_in_fences(markdown: str) -> str:
# #     """
# #     Reveal.js splits slides on its separator regex  r"^\n---\n$"  (with /gm).
# #     With multiline flags this means it looks for the sequence \n---\n that is
# #     itself on its own "line" — which in practice means a blank line, then ---,
# #     then a blank line.

# #     Inside a fenced code block such blank-surrounded --- / ---- / ===== lines
# #     are display content (e.g. showing markdown slide syntax), NOT separators.
# #     But Reveal.js's markdown plugin is not fence-aware and splits on them anyway.

# #     The fix: inside fenced blocks, remove the blank lines that SURROUND a
# #     separator-looking line.  Without surrounding blank lines the separator regex
# #     no longer matches, so Reveal.js leaves the line alone.  The displayed code
# #     is identical — the blank lines inside a code block are purely cosmetic.

# #     Specifically, for any run of lines inside a fence that matches the pattern:
# #         (blank line)
# #         (separator line: ---, ----, or =====+)
# #         (blank line)
# #     we collapse it to just:
# #         (separator line)
# #     by dropping the surrounding blank lines.
# #     """
# #     _SEP_LINE_RE = re.compile(r'^(-{3,}|={5,})$')

# #     lines      = markdown.split('\n')
# #     out        = []
# #     fence_char = None
# #     fence_len  = 0
# #     i          = 0

# #     while i < len(lines):
# #         line = lines[i]

# #         if fence_char is None:
# #             m = _FENCE_OPEN_RE.match(line)
# #             if m:
# #                 fence_char = m.group(1)[0]
# #                 fence_len  = len(m.group(1))
# #             out.append(line)
# #             i += 1
# #         else:
# #             # Inside a fence — check for closing fence
# #             stripped = line.strip()
# #             if (stripped.startswith(fence_char * fence_len)
# #                     and all(c == fence_char for c in stripped)):
# #                 fence_char = None
# #                 fence_len  = 0
# #                 out.append(line)
# #                 i += 1
# #             else:
# #                 # Look ahead for the pattern: blank, separator, blank
# #                 # If found, emit only the separator (drop surrounding blanks)
# #                 if (line == ''
# #                         and i + 2 < len(lines)
# #                         and _SEP_LINE_RE.match(lines[i + 1])
# #                         and lines[i + 2] == ''):
# #                     # Drop the leading blank (current line), emit separator,
# #                     # drop the trailing blank (i+2), advance past all three.
# #                     out.append(lines[i + 1])
# #                     i += 3
# #                 else:
# #                     out.append(line)
# #                     i += 1

# #     return '\n'.join(out)


# # def _preprocess_auto_animate(markdown: str) -> str:
# #     """
# #     1. Escape any slide-separator-looking lines inside fenced code blocks so
# #        that Reveal.js's own markdown parser does not split on them.
# #     2. Split on real horizontal slide separators (fence-aware).
# #     3. On data-auto-animate slides, rewrite fenced code blocks into the raw
# #        HTML form Reveal.js requires.
# #     """
# #     # Step 1: neutralise separators inside fences before Reveal.js sees them
# #     markdown = _neutralise_separators_in_fences(markdown)

# #     # Step 2: split on real separators (now safe — fenced ones are escaped)
# #     slides = _fence_aware_split(markdown, "\n---\n")

# #     # Step 3: rewrite auto-animate fenced blocks
# #     result = []
# #     for i, slide in enumerate(slides):
# #         if _slide_has_auto_animate(slide):
# #             result.append(_rewrite_fenced_blocks(slide))
# #         else:
# #             result.append(slide)
# #         if i < len(slides) - 1:
# #             result.append('\n---\n')
# #     return ''.join(result)


# # def _rewrite_fenced_blocks(slide: str) -> str:
# #     lines = slide.split('\n')
# #     out   = []
# #     i     = 0
# #     block_index = 0
# #     while i < len(lines):
# #         m = _FENCE_RE.match(lines[i])
# #         if m:
# #             lang        = m.group(1) or ''
# #             data_id_val = m.group(2)
# #             line_spec   = m.group(3)
# #             i += 1
# #             body_lines = []
# #             while i < len(lines) and lines[i].strip() != '```':
# #                 body_lines.append(lines[i])
# #                 i += 1
# #             if i < len(lines):
# #                 i += 1
# #             if not data_id_val:
# #                 data_id_val = f'revealjs-code-{block_index}'
# #             block_index += 1
# #             ln_attr   = (f' data-line-numbers="{line_spec.strip()}"'
# #                          if line_spec is not None else ' data-line-numbers')
# #             lang_attr = f' class="language-{lang}"' if lang else ''
# #             body      = '\n'.join(body_lines)
# #             out.append(
# #                 f'<pre data-id="{data_id_val}">'
# #                 f'<code data-trim{ln_attr}{lang_attr}>'
# #                 f'{body}'
# #                 f'</code></pre>'
# #             )
# #         else:
# #             out.append(lines[i])
# #             i += 1
# #     return '\n'.join(out)




# # # ---------------------------------------------------------------------------
# # # Admonition pre-processor
# # # ---------------------------------------------------------------------------
# # # WHY THIS EXISTS:
# # #   Reveal.js's browser-side markdown parser is minimal — it does not know
# # #   about !!! / ??? admonition syntax (a Python-Markdown extension).
# # #   We therefore scan the slides string BEFORE it goes into the <textarea>
# # #   and replace every admonition block with its rendered HTML equivalent.
# # #   Everything else — headings, paragraphs, data-* slide attributes,
# # #   data-auto-animate markers, background directives, fenced code blocks,
# # #   column syntax — is left completely untouched.

# # _ADMON_HEADER_RE  = re.compile(r'^(!{3}|\?{3}\+?)[ \t]+\S+')
# # _ADMON_FENCE_RE   = re.compile(r'^(`{3,}|~{3,})')
# # _EMOJI_TOKEN_RE   = re.compile(r':[a-zA-Z0-9_+\-]+:')
# # _EMOJI_P_WRAP_RE  = re.compile(r'^<p>(.*?)</p>$', re.DOTALL)


# # def _build_emoji_substitutor(md_extensions: list, md_configs: dict):
# #     """
# #     Return a function  subst(text) -> text  that replaces :name: emoji tokens
# #     with their rendered HTML inline, using the user's configured emoji extension.

# #     Strategy: convert each :token: individually through a dedicated
# #     Python-Markdown instance so we get the correct HTML span without ever
# #     wrapping the surrounding line (heading, list item, HTML comment, …) in
# #     block-level tags.

# #     Returns None silently if no emoji extension is configured or if the
# #     extension cannot be loaded — emoji tokens are then left verbatim.

# #     Supported extension names (any of):
# #       pymdownx.emoji   material.extensions.emoji   materialx.emoji
# #     """
# #     _EMOJI_NAMES = {'pymdownx.emoji', 'material.extensions.emoji', 'materialx.emoji'}

# #     # Find the emoji extension name in the user's list
# #     emoji_ext_name = None
# #     for ext in (md_extensions or []):
# #         name = ext if isinstance(ext, str) else getattr(ext, 'extPath', None) or str(ext)
# #         if any(e in str(name) for e in _EMOJI_NAMES):
# #             emoji_ext_name = name
# #             break
# #     if not emoji_ext_name:
# #         return None

# #     # Build the extension configs for the emoji extension only
# #     ext_configs = {}
# #     for k, v in (md_configs or {}).items():
# #         if any(e in k for e in _EMOJI_NAMES):
# #             ext_configs[k] = v

# #     try:
# #         md = _markdown_lib.Markdown(
# #             extensions=[emoji_ext_name],
# #             extension_configs=ext_configs,
# #         )
# #     except Exception as exc:
# #         log.debug(f"[revealjs] emoji extension failed to load ({exc}), skipping")
# #         return None

# #     # Per-token rendering cache — avoids repeated md.convert() calls
# #     _cache: dict = {}

# #     def _render_token(token: str) -> str:
# #         if token in _cache:
# #             return _cache[token]
# #         md.reset()
# #         rendered = md.convert(token)          # always wraps in <p>…</p>
# #         m = _EMOJI_P_WRAP_RE.match(rendered.strip())
# #         result = m.group(1) if m else rendered
# #         _cache[token] = result
# #         return result

# #     def subst(text: str) -> str:
# #         if ':' not in text:
# #             return text
# #         return _EMOJI_TOKEN_RE.sub(lambda m: _render_token(m.group(0)), text)

# #     log.debug(f"[revealjs] emoji substitutor built from {emoji_ext_name!r}")
# #     return subst


# # def _extract_admonition_block(lines: list, start: int):
# #     """
# #     Starting at lines[start] (the admonition header), collect the full block:
# #     the header line + all immediately following lines that are indented 4+
# #     spaces or are blank (continuation lines).
# #     Trailing blank lines are NOT included — they stay in the surrounding text.
# #     Returns (end_index, raw_block_string).
# #     """
# #     i = start + 1
# #     while i < len(lines):
# #         l = lines[i]
# #         if l == '' or l.startswith('    ') or l.startswith('\t'):
# #             i += 1
# #         else:
# #             break
# #     end = i
# #     while end > start + 1 and lines[end - 1].strip() == '':
# #         end -= 1
# #     return end, '\n'.join(lines[start:end])


# # def _preprocess_admonitions(slides_md: str,
# #                              md_extensions: list,
# #                              md_configs: dict) -> str:
# #     """
# #     Walk through slides_md line by line.

# #     - Inside fenced code blocks (``` / ~~~): every line is emitted verbatim.
# #     - Outside fences:
# #         • Admonition headers trigger block extraction + HTML rendering via
# #           Python-Markdown (admonition extension).
# #         • :emoji: tokens on every non-fenced line are substituted with their
# #           rendered HTML inline (pymdownx.emoji / material.extensions.emoji),
# #           if that extension is configured in mkdocs.yml.
# #         • Everything else is emitted verbatim: headings, paragraphs, HTML
# #           comments (<!-- .slide: ... -->), data-auto-animate attributes,
# #           slide separators, column syntax, etc.

# #     Emoji substitution is done per-token (not per-line through md.convert)
# #     so the surrounding line structure — headings, list items, HTML comments —
# #     is never altered.  Unknown :tokens: that don't resolve to emojis are left
# #     verbatim.

# #     The rendered admonition HTML block is surrounded by blank lines so that
# #     Reveal.js's markdown plugin treats it as a raw HTML passthrough.
# #     """
# #     # ── Build a minimal Markdown instance for admonitions ───────────────────
# #     extensions = ['admonition']
# #     for ext in (md_extensions or []):
# #         name = ext if isinstance(ext, str) else getattr(ext, 'name', None)
# #         if name and 'detail' in name and name not in extensions:
# #             extensions.append(name)
# #     try:
# #         md = _markdown_lib.Markdown(
# #             extensions=extensions,
# #             extension_configs={
# #                 k: v for k, v in (md_configs or {}).items()
# #                 if any(k in str(e) for e in extensions)
# #             },
# #         )
# #     except Exception:
# #         md = _markdown_lib.Markdown(extensions=['admonition'])

# #     # ── Build emoji substitutor (None if no emoji extension configured) ──────
# #     emoji_subst = _build_emoji_substitutor(md_extensions, md_configs)

# #     lines      = slides_md.split('\n')
# #     out        = []
# #     fence_char = None
# #     fence_len  = 0
# #     i          = 0

# #     while i < len(lines):
# #         line = lines[i]

# #         # ── inside a fenced code block: emit verbatim (no emoji sub) ───────
# #         if fence_char is not None:
# #             out.append(line)
# #             stripped = line.strip()
# #             if (stripped.startswith(fence_char * fence_len)
# #                     and all(c == fence_char for c in stripped)):
# #                 fence_char = None
# #                 fence_len  = 0
# #             i += 1
# #             continue

# #         # ── fence opening ───────────────────────────────────────────────────
# #         m_fence = _ADMON_FENCE_RE.match(line)
# #         if m_fence:
# #             fence_char = m_fence.group(1)[0]
# #             fence_len  = len(m_fence.group(1))
# #             out.append(line)
# #             i += 1
# #             continue

# #         # ── admonition header → render block to HTML ────────────────────────
# #         if _ADMON_HEADER_RE.match(line):
# #             end, block = _extract_admonition_block(lines, i)
# #             # Apply emoji substitution inside admonition content before
# #             # rendering, so :emoji: tokens in titles and body are resolved.
# #             if emoji_subst:
# #                 block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
# #             md.reset()
# #             html = md.convert(block)
# #             # Blank lines around the HTML block ensure Reveal.js's markdown
# #             # parser treats it as a raw HTML passthrough
# #             out.append('')
# #             out.append(html)
# #             out.append('')
# #             i = end
# #             continue

# #         # ── normal line: apply emoji substitution then emit ─────────────────
# #         out.append(emoji_subst(line) if emoji_subst else line)
# #         i += 1

# #     return '\n'.join(out)


# # # ---------------------------------------------------------------------------
# # # Custom URL resolver
# # # ---------------------------------------------------------------------------

# # def _resolve_custom_url(value: str, page, config) -> str:
# #     """
# #     Resolve a user-supplied custom CSS/JS path to a URL safe to use as a
# #     <link href> from any page depth.

# #     Rules:
# #       - Already absolute (http/https) or protocol-relative → returned as-is.
# #       - Root-relative (/…) → returned as-is.
# #       - Bare path like "assets/stylesheets/light-theme.css" →
# #         prefixed with enough "../" to reach the site root from the current
# #         page, so the link resolves correctly from any page depth.
# #         e.g. a page at presentations/my-talk/ needs "../../" as prefix.

# #     This mirrors how MkDocs resolves extra_css links on nested pages.
# #     """
# #     if not value:
# #         return value
# #     # Already absolute or root-relative — leave untouched
# #     if value.startswith("http://") or value.startswith("https://") or value.startswith("/"):
# #         return value
# #     # Count nesting depth from page.url (e.g. "presentations/my-talk/" → depth 2)
# #     page_url = (page.url or "").rstrip("/")
# #     depth    = page_url.count("/") + (1 if page_url else 0)
# #     prefix   = "../" * depth
# #     return prefix + value


# # # ---------------------------------------------------------------------------
# # # Plugin
# # # ---------------------------------------------------------------------------

# # _H1_RE = re.compile(r'^(#[^#][^\n]*)\n?', re.MULTILINE)


# # class RevealjsPlugin(BasePlugin[RevealjsPluginConfig]):

# #     _cdn_base:    str  = CDN_DEFAULT
# #     _global_opts: dict = {}

# #     def on_config(self, config):
# #         cdn = self.config.get("cdn")
# #         self._cdn_base    = cdn.rstrip("/") if cdn else CDN_DEFAULT
# #         self._global_opts = {
# #             k: v for k, v in self.config.items()
# #             if k not in _PLUGIN_ONLY and v is not None
# #         }
# #         # Cache custom_dir so on_files can register custom CSS/JS files
# #         theme_cfg   = config.get("theme") or {}
# #         custom_dir  = getattr(theme_cfg, "custom_dir", None)
# #         self._custom_dir = custom_dir or None
# #         self._site_dir   = config.get("docs_dir", "docs")   # not used directly
# #         self._mkdocs_config = config
# #         return config

# #     # ---------------------------------------------------------------- files

# #     def on_files(self, files, config):
# #         """
# #         Register any custom CSS/JS paths that resolve to files inside the
# #         theme's custom_dir (e.g. overrides/assets/stylesheets/light-theme.css).

# #         MkDocs does NOT automatically serve new files from custom_dir unless
# #         they override an existing theme file. This hook adds them explicitly
# #         so they are copied to site/ and reachable as /assets/stylesheets/…
# #         """
# #         if not self._custom_dir or not os.path.isdir(self._custom_dir):
# #             return files

# #         # Collect all custom paths declared in global plugin config
# #         custom_paths = []
# #         for scope in ("theme", "highlight", "mermaid"):
# #             scope_cfg = self.config.get(scope)
# #             for side in ("light", "dark"):
# #                 val = _safe_get(scope_cfg, side)
# #                 desc = _coerce_scheme(val, allow_custom_js=(scope == "mermaid"))
# #                 if desc.custom:
# #                     custom_paths.append(desc.custom)
# #                 if desc.custom_js:
# #                     custom_paths.append(desc.custom_js)
# #         js_path = self.config.get("js")
# #         if js_path:
# #             custom_paths.append(js_path)

# #         for path in custom_paths:
# #             # Skip absolute URLs and root-relative paths — not local files
# #             if not path or path.startswith("http") or path.startswith("/"):
# #                 continue
# #             abs_path = os.path.join(self._custom_dir, path)
# #             if not os.path.isfile(abs_path):
# #                 # Also try directly as given (relative to mkdocs.yml dir)
# #                 abs_path = os.path.join(os.path.dirname(
# #                     config.get("config_file_path", "")), path)
# #                 if not os.path.isfile(abs_path):
# #                     continue
# #             # path is the dest URI (e.g. "assets/stylesheets/light-theme.css")
# #             # src_dir is custom_dir so File resolves abs_src_path correctly
# #             f = File(
# #                 path       = path,
# #                 src_dir    = self._custom_dir,
# #                 dest_dir   = config["site_dir"],
# #                 use_directory_urls = config.get("use_directory_urls", True),
# #             )
# #             # Only add if not already in the collection
# #             if not files.get_file_from_path(path):
# #                 files.append(f)
# #                 log.debug(f"[revealjs] registered custom file: {path!r}")

# #         return files

# #     # ---------------------------------------------------------------- markdown

# #     def on_page_markdown(self, markdown, page, config, files):
# #         meta     = page.meta or {}
# #         revealjs = meta.get("revealjs", {})
# #         if not isinstance(revealjs, dict):
# #             return markdown
# #         presentation = revealjs.get("presentation", False)

# #         # ── import mode ──────────────────────────────────────────────────────
# #         if presentation == "import":
# #             rel_path = revealjs.get("path")
# #             if not rel_path:
# #                 log.error(f"[revealjs] 'presentation: import' requires 'path:' "
# #                           f"in {page.file.src_path}")
# #                 return markdown
# #             try:
# #                 import_body, import_cfg, import_title = _load_import_file(
# #                     page.file.abs_src_path, rel_path)
# #             except (FileNotFoundError, ValueError) as exc:
# #                 log.error(str(exc))
# #                 return markdown

# #             host_tail  = markdown.strip() or None
# #             host_title = meta.get("title")
# #             h1_line    = None
# #             tail_body  = host_tail

# #             if host_tail:
# #                 m = _H1_RE.search(host_tail)
# #                 if m:
# #                     h1_line   = m.group(1).strip()
# #                     tail_body = (host_tail[:m.start()] + host_tail[m.end():]).strip() or None
# #                 elif not host_title and import_title:
# #                     h1_line   = f"# {import_title}"
# #             elif not host_title and import_title:
# #                 h1_line   = f"# {import_title}"
# #                 tail_body = None

# #             page._revealjs_slides = _preprocess_auto_animate(import_body.strip())
# #             page._revealjs_config = import_cfg
# #             page._revealjs_tail   = tail_body
# #             return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

# #         # ── inline mode ──────────────────────────────────────────────────────
# #         if not presentation:
# #             return markdown

# #         slides_part, tail_part = _split_at_end_marker(markdown)
# #         page._revealjs_slides = _preprocess_auto_animate(slides_part.strip())
# #         page._revealjs_config = revealjs

# #         if tail_part is None:
# #             page._revealjs_tail = None
# #             return _DIV_PLACEHOLDER

# #         tail_raw  = tail_part.strip()
# #         fm_title  = revealjs.get("title") or meta.get("title")
# #         h1_line   = None
# #         tail_body = tail_raw or None

# #         if tail_raw:
# #             m = _H1_RE.search(tail_raw)
# #             if m:
# #                 h1_line   = m.group(1).strip()
# #                 tail_body = (tail_raw[:m.start()] + tail_raw[m.end():]).strip() or None
# #             elif fm_title:
# #                 h1_line   = f"# {fm_title}"
# #         elif fm_title:
# #             h1_line   = f"# {fm_title}"
# #             tail_body = None

# #         page._revealjs_tail = tail_body
# #         return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

# #     # ---------------------------------------------------------------- content

# #     def on_page_content(self, html, page, config, files):
# #         if not hasattr(page, "_revealjs_slides"):
# #             return html
# #         placeholder_p   = f"<p>{_DIV_PLACEHOLDER}</p>"
# #         placeholder_raw = _DIV_PLACEHOLDER
# #         if placeholder_p not in html and placeholder_raw not in html:
# #             log.error(f"[revealjs] placeholder not found in {page.file.src_path}")
# #             return html
# #         revealjs = page._revealjs_config
# #         height   = revealjs.get("height", self._global_opts.get("height", _DEFAULT_HEIGHT))

# #         # Pre-process admonition blocks → HTML, leave everything else untouched
# #         md_extensions = config.get("markdown_extensions") or []
# #         md_configs    = config.get("mdx_configs") or {}
# #         slides = _preprocess_admonitions(
# #             page._revealjs_slides, md_extensions, md_configs)

# #         div_html = self._render_div(slides, height)
# #         if placeholder_p in html:
# #             return html.replace(placeholder_p, div_html, 1)
# #         return html.replace(placeholder_raw, div_html, 1)

# #     # ---------------------------------------------------------------- post page

# #     def on_post_page(self, output, page, config):
# #         if not hasattr(page, "_revealjs_slides"):
# #             return output
# #         revealjs = page._revealjs_config
# #         page_cdn = revealjs.get("cdn")
# #         cdn      = page_cdn.rstrip("/") if page_cdn else self._cdn_base
# #         extra_js = revealjs.get("js") or self.config.get("js")

# #         # Resolve all three scopes into (light_desc, dark_desc) pairs
# #         theme_l, theme_d = _resolve_scope(
# #             self.config.get("theme"), revealjs, "theme")
# #         hl_l, hl_d = _resolve_scope(
# #             self.config.get("highlight"), revealjs, "highlight",
# #             default_light=_HLJS_LIGHT, default_dark=_HLJS_DARK)
# #         mm_l, mm_d = _resolve_scope(
# #             self.config.get("mermaid"), revealjs, "mermaid",
# #             default_light=_MM_LIGHT, default_dark=_MM_DARK,
# #             allow_custom_js=True)
# #         mm_activate = _resolve_mermaid_activate(self.config.get("mermaid"), revealjs)

# #         page_opts   = {k: v for k, v in revealjs.items() if k not in _PAGE_ONLY}
# #         merged_opts = {**self._global_opts, **page_opts}
# #         height      = merged_opts.pop("height", _DEFAULT_HEIGHT)
# #         js_options  = self._build_js_options(merged_opts)

# #         # Resolve any bare-path custom URLs to be root-relative from any page depth
# #         def _rc(v): return _resolve_custom_url(v, page, config)
# #         if theme_l.custom: theme_l.custom = _rc(theme_l.custom)
# #         if theme_d.custom: theme_d.custom = _rc(theme_d.custom)
# #         if hl_l.custom:    hl_l.custom    = _rc(hl_l.custom)
# #         if hl_d.custom:    hl_d.custom    = _rc(hl_d.custom)
# #         if mm_l.custom:    mm_l.custom    = _rc(mm_l.custom)
# #         if mm_d.custom:    mm_d.custom    = _rc(mm_d.custom)
# #         if mm_l.custom_js: mm_l.custom_js = _rc(mm_l.custom_js)
# #         if mm_d.custom_js: mm_d.custom_js = _rc(mm_d.custom_js)
# #         if extra_js:       extra_js       = _rc(extra_js)

# #         assets = self._render_assets(
# #             cdn=cdn,
# #             theme_l=theme_l, theme_d=theme_d,
# #             hl_l=hl_l, hl_d=hl_d,
# #             mm_l=mm_l, mm_d=mm_d, mm_activate=mm_activate,
# #             js_opts=js_options, extra_js=extra_js, height=height,
# #         )
# #         if "</body>" in output:
# #             return output.replace("</body>", assets + "\n</body>", 1)
# #         return output + assets

# #     # ---------------------------------------------------------------- helpers

# #     @staticmethod
# #     def _render_div(slides: str, height: int) -> str:
# #         return (
# #             f'<div class="mkdocs-revealjs-wrapper" '
# #             f'style="width:100%;height:{height}px;margin:1.5rem 0;">\n'
# #             f'  <div class="reveal" id="mkdocs-revealjs" '
# #             f'style="width:100%;height:{height}px;">\n'
# #             f'    <div class="slides">\n'
# #             f'      <section\n'
# #             f'        data-markdown\n'
# #             f'        data-separator="{_SEP_H}"\n'
# #             f'        data-separator-vertical="{_SEP_V}"\n'
# #             f'        data-separator-notes="{_SEP_NOTE}"\n'
# #             f'      >\n'
# #             f'        <textarea data-template>\n'
# #             f'{slides}\n'
# #             f'        </textarea>\n'
# #             f'      </section>\n'
# #             f'    </div>\n'
# #             f'  </div>\n'
# #             f'</div>'
# #         )


# #     @staticmethod
# #     def _build_js_options(opts: dict) -> str:
# #         parts = []
# #         for key, value in opts.items():
# #             if isinstance(value, bool):
# #                 parts.append(f"      {key}: {'true' if value else 'false'}")
# #             elif isinstance(value, (int, float)):
# #                 parts.append(f"      {key}: {value}")
# #             else:
# #                 parts.append(
# #                     f"      {key}: '{str(value).replace(chr(39), chr(92)+chr(39))}'")
# #         return ",\n".join(parts)

# #     def _render_assets(self, cdn,
# #                        theme_l, theme_d,
# #                        hl_l, hl_d,
# #                        mm_l, mm_d, mm_activate,
# #                        js_opts, extra_js, height) -> str:

# #         # ── URLs for starter themes ──────────────────────────────────────────
# #         rv_light_url = _theme_url(cdn, theme_l.starter, _LIGHT_BUILTIN)
# #         rv_dark_url  = _theme_url(cdn, theme_d.starter, _DARK_BUILTIN)
# #         hl_light_url = _hljs_url(hl_l.starter or _HLJS_LIGHT)
# #         hl_dark_url  = _hljs_url(hl_d.starter or _HLJS_DARK)

# #         # ── hack CSS strings ─────────────────────────────────────────────────
# #         rv_hack_l = _render_hack_css(theme_l.hack, "theme")
# #         rv_hack_d = _render_hack_css(theme_d.hack, "theme")
# #         hl_hack_l = _render_hack_css(hl_l.hack, "highlight")
# #         hl_hack_d = _render_hack_css(hl_d.hack, "highlight")
# #         mm_hack_l = _render_hack_css(mm_l.hack, "mermaid") if mm_activate else ""
# #         mm_hack_d = _render_hack_css(mm_d.hack, "mermaid") if mm_activate else ""

# #         # ── helpers ──────────────────────────────────────────────────────────
# #         def link(href, id_, disabled=False):
# #             d = " disabled" if disabled else ""
# #             return f'<link rel="stylesheet" href="{href}" id="{id_}"{d}>'

# #         def opt_link(href, id_, disabled=False):
# #             return ("\n" + link(href, id_, disabled)) if href else ""

# #         def style_block(css, id_):
# #             if not css:
# #                 return ""
# #             return f'\n<style id="{id_}">\n{css}\n</style>'

# #         def script(src):
# #             return f'\n<script src="{src}"></script>' if src else ""

# #         # ── light/dark swap pairs for the MutationObserver ───────────────────
# #         # Every CSS pair (starter + hack + custom) must be listed here so all
# #         # are swapped atomically when the color scheme changes.
# #         swap_pairs = [
# #             ["revealjs-theme-light",    "revealjs-theme-dark"],
# #             ["revealjs-hljs-light",     "revealjs-hljs-dark"],
# #             ["revealjs-hack-rv-light",  "revealjs-hack-rv-dark"],
# #             ["revealjs-hack-hl-light",  "revealjs-hack-hl-dark"],
# #         ]
# #         if theme_l.custom or theme_d.custom:
# #             swap_pairs.append(["revealjs-custom-rv-light", "revealjs-custom-rv-dark"])
# #         if hl_l.custom or hl_d.custom:
# #             swap_pairs.append(["revealjs-custom-hl-light", "revealjs-custom-hl-dark"])
# #         if mm_activate:
# #             swap_pairs.append(["revealjs-hack-mm-light", "revealjs-hack-mm-dark"])
# #             if mm_l.custom or mm_d.custom:
# #                 swap_pairs.append(["revealjs-custom-mm-light", "revealjs-custom-mm-dark"])
# #         swap_pairs_js = json.dumps(swap_pairs)

# #         # ── JS options block ─────────────────────────────────────────────────
# #         base_opts     = (f"      embedded: true,\n"
# #                          f"      width: '100%',\n"
# #                          f"      height: {height},\n"
# #                          f"      margin: 0")
# #         options_block = base_opts + (",\n" + js_opts if js_opts else "")

# #         # ── Mermaid JS ───────────────────────────────────────────────────────
# #         if mm_activate:
# #             mm_light_theme, mm_light_reg, mm_light_var = _resolve_mermaid_theme_name(mm_l.starter, _MM_LIGHT)
# #             mm_dark_theme,  mm_dark_reg,  mm_dark_var  = _resolve_mermaid_theme_name(mm_d.starter, _MM_DARK)

# #             # Build the mermaid.initialize() config objects for light and dark.
# #             # For bundled custom themes: { theme: 'base', themeVariables: window[VAR] }
# #             # For official themes:       { theme: 'NAME' }
# #             if mm_light_var:
# #                 mm_light_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_light_var}"] || {{}} }}'
# #             else:
# #                 mm_light_init = f'{{ startOnLoad: false, theme: "{mm_light_theme}" }}'

# #             if mm_dark_var:
# #                 mm_dark_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_dark_var}"] || {{}} }}'
# #             else:
# #                 mm_dark_init = f'{{ startOnLoad: false, theme: "{mm_dark_theme}" }}'

# #             mermaid_js = f"""
# #   // ── Mermaid ─────────────────────────────────────────────────────────────
# #   function _rvIsDark() {{
# #     return (document.body.getAttribute("data-md-color-scheme") || "default") === "slate";
# #   }}
# #   function _rvMermaidConfig(dark) {{
# #     return dark ? {mm_dark_init} : {mm_light_init};
# #   }}
# #   function _rvStoreMermaidSrc() {{
# #     document.querySelectorAll(".mermaid:not([data-src])").forEach(function(el) {{
# #       el.dataset.src = el.textContent;
# #     }});
# #   }}
# #   function _rvCallMermaidPatch(dark) {{
# #     var fn = dark
# #       ? window._mkdocsRevealjsMermaidPatchDark
# #       : window._mkdocsRevealjsMermaidPatchLight;
# #     if (typeof fn === "function") {{
# #       var deckEl = document.getElementById("mkdocs-revealjs");
# #       try {{ fn(deckEl); }} catch(e) {{
# #         console.warn("[revealjs] mermaid patch error:", e);
# #       }}
# #     }}
# #   }}
# #   function _rvRenderMermaid(dark) {{
# #     mermaid.initialize(_rvMermaidConfig(dark));
# #     document.querySelectorAll(".mermaid[data-processed]").forEach(function(el) {{
# #       el.removeAttribute("data-processed");
# #       if (el.dataset.src) {{ el.textContent = el.dataset.src; }}
# #     }});
# #     mermaid.run({{ querySelector: ".mermaid" }});
# #     _rvCallMermaidPatch(dark);
# #   }}
# # """
# #             mermaid_init = """
# #     _rvStoreMermaidSrc();
# #     _rvRenderMermaid(_rvIsDark());
# #     deck.on("slidechanged", function() { _rvRenderMermaid(_rvIsDark()); });
# # """
# #             mermaid_theme_swap = "    _rvRenderMermaid(isDark);\n"
# #         else:
# #             mermaid_js         = ""
# #             mermaid_init       = ""
# #             mermaid_theme_swap = ""
# #             mm_light_reg       = None
# #             mm_dark_reg        = None
# #             mm_light_var       = None
# #             mm_dark_var        = None

# #         # ── Assemble HTML ────────────────────────────────────────────────────
# #         return f"""
# # <!-- mkdocs-revealjs ───────────────────────────────────────── -->

# # <!-- Plugin base corrections (always loaded, before any theme) -->
# # {_load_bundled_css(_BASE_CSS_PATH, "revealjs-base")}

# # <link rel="stylesheet" href="{cdn}/dist/reveal.css">

# # <!-- Reveal.js theme: starter (light / dark) -->
# # {link(rv_light_url, "revealjs-theme-light")}
# # {link(rv_dark_url,  "revealjs-theme-dark",  disabled=True)}

# # <!-- Reveal.js theme: hack overrides (light / dark) -->{style_block(rv_hack_l, "revealjs-hack-rv-light")}{style_block(rv_hack_d, "revealjs-hack-rv-dark")}

# # <!-- Reveal.js theme: custom CSS (light / dark) -->{opt_link(theme_l.custom, "revealjs-custom-rv-light")}{opt_link(theme_d.custom, "revealjs-custom-rv-dark", disabled=True)}

# # <!-- Highlight.js theme: starter (light / dark) -->
# # {link(hl_light_url, "revealjs-hljs-light")}
# # {link(hl_dark_url,  "revealjs-hljs-dark",  disabled=True)}

# # <!-- Highlight.js theme: hack overrides (light / dark) -->{style_block(hl_hack_l, "revealjs-hack-hl-light")}{style_block(hl_hack_d, "revealjs-hack-hl-dark")}

# # <!-- Highlight.js theme: custom CSS (light / dark) -->{opt_link(hl_l.custom, "revealjs-custom-hl-light")}{opt_link(hl_d.custom, "revealjs-custom-hl-dark", disabled=True)}

# # <!-- Mermaid theme: hack overrides (light / dark) -->{style_block(mm_hack_l, "revealjs-hack-mm-light") if mm_activate else ""}{style_block(mm_hack_d, "revealjs-hack-mm-dark") if mm_activate else ""}

# # <!-- Mermaid theme: custom CSS (light / dark) -->{opt_link(mm_l.custom, "revealjs-custom-mm-light") if mm_activate else ""}{opt_link(mm_d.custom, "revealjs-custom-mm-dark", disabled=True) if mm_activate else ""}

# # <script src="{cdn}/dist/reveal.js"></script>
# # <script src="{cdn}/plugin/markdown/markdown.js"></script>
# # <script src="{cdn}/plugin/highlight/highlight.js"></script>
# # <script src="{cdn}/plugin/notes/notes.js"></script>
# # <script src="{cdn}/plugin/math/math.js"></script>
# # {"<script src=" + repr(MERMAID_CDN) + "></script>" if mm_activate else ""}
# # {_load_bundled_script(mm_light_reg) if mm_activate and mm_light_reg else ""}
# # {_load_bundled_script(mm_dark_reg) if mm_activate and mm_dark_reg and mm_dark_reg != mm_light_reg else ""}
# # {script(mm_l.custom_js) if mm_activate and mm_l.custom_js else ""}
# # {script(mm_d.custom_js) if mm_activate and mm_d.custom_js and mm_d.custom_js != mm_l.custom_js else ""}
# # {script(extra_js) if extra_js else ""}
# # <script>
# # (function () {{
# # {mermaid_js}
# #   // ── Theme switcher ───────────────────────────────────────────────────────
# #   // Swaps every CSS pair atomically: starter + hack + custom all together.
# #   var _SWAP_PAIRS = {swap_pairs_js};

# #   function applyTheme(scheme) {{
# #     var isDark = (scheme === "slate");
# #     _SWAP_PAIRS.forEach(function(pair) {{
# #       var light = document.getElementById(pair[0]);
# #       var dark  = document.getElementById(pair[1]);
# #       if (light) light.disabled = isDark;
# #       if (dark)  dark.disabled  = !isDark;
# #     }});
# # {mermaid_theme_swap}  }}

# #   applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");

# #   new MutationObserver(function(mutations) {{
# #     mutations.forEach(function(m) {{
# #       if (m.attributeName === "data-md-color-scheme") {{
# #         applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");
# #       }}
# #     }});
# #   }}).observe(document.body, {{ attributes: true }});

# #   // ── Reveal.js init ──────────────────────────────────────────────────────
# #   function initReveal() {{
# #     var el = document.getElementById("mkdocs-revealjs");
# #     if (!el) return;
# #     var deck = new Reveal(el, {{
# # {options_block},
# #       highlight: {{
# #         highlightOnLoad: true
# #       }},
# #       plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealMath]
# #     }});
# #     deck.initialize().then(function() {{
# #       deck.layout();
# # {mermaid_init}    }});
# #     window._mkdocsRevealDeck = deck;
# #   }}

# #   if (document.readyState === "loading") {{
# #     document.addEventListener("DOMContentLoaded", initReveal);
# #   }} else {{
# #     initReveal();
# #   }}
# # }})();
# # </script>
# # <!-- ─────────────────────────────────────── mkdocs-revealjs -->"""

# # ========================================================================================================
# # ========================================================================================================
# # ========================================================================================================

# """
# mkdocs-revealjs — plugin.py

# Hooks
# -----
# on_page_markdown  : save raw slides + config, return placeholder
# on_page_content   : replace placeholder with .reveal div
# on_post_page      : inject <link> + <script> before </body>

# Theming pipeline (per scheme: light / dark, per scope: theme / highlight / mermaid)
# -------------------------------------------------------------------------------------
# Each light/dark entry supports three ordered layers:

#   1. starter / default  — base theme  (CDN built-in name or any URL)
#   2. hack               — inline <style> CSS overrides on top of starter
#   3. custom             — additional CSS file loaded after starter+hack
#                           (mermaid also accepts  custom_js  for SVG patching)

# All layers are optional and compose in that order.

# Shorthand coercion:
#   light: solarized  →  { starter: "solarized", hack: {}, custom: None }
#   "starter" and "default" keywords are exact aliases.

# Combinations:
#   starter only        → load starter
#   hack only           → apply overrides on top of plugin default
#   custom only         → load custom CSS (no starter loaded)
#   starter + hack      → starter then hack inline
#   starter + custom    → starter then custom file
#   all three           → starter → hack inline → custom file

# hack selector rules (auto-prefix per scope, '&' = scope prefix):
#   scope     auto-prefix
#   theme     .reveal
#   highlight .reveal .hljs
#   mermaid   .reveal .mermaid

#   h1                →  .reveal h1 { … }
#   "& .slides"       →  .reveal .slides { … }
#   "&.embedded"      →  .reveal.embedded { … }

# mermaid custom_js convention:
#   The JS file must assign:
#     window._mkdocsRevealjsMermaidPatchLight = function(deckEl) { … }
#     window._mkdocsRevealjsMermaidPatchDark  = function(deckEl) { … }
#   The plugin calls the appropriate function after each mermaid.run().

# Separators
# ----------
#   Horizontal slide    :  blank line + --- + blank line
#   Vertical slide      :  blank line + ---- + blank line
#   Speaker notes       :  Note: at start of line
#   End of presentation :  blank line + ===== + blank line

# Import mode
# -----------
#   revealjs:
#     presentation: import
#     path: ./slides.md

# Reveal.js options
# -----------------
# Plugin-only (not forwarded to Reveal.initialize()):
#   cdn, js, theme, highlight, mermaid

# Compatible plugin API
# ---------------------
# Any MkDocs plugin that transforms admonitions by injecting HTML markers
# into their body can declare itself compatible with mkdocs-revealjs by
# exposing a class-level attribute:

#     class MyPlugin(BasePlugin):
#         REVEALJS_MANAGED_ADMONITIONS = [
#             re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
#         ]

# revealjs will detect this attribute at on_config time, collect all
# declared patterns, and protect any admonition whose body matches one of
# them — passing it through verbatim instead of re-rendering it via
# Python-Markdown.

# Additionally, any HTML comment of the form <!-- MKDOCS_PLUGIN_XXX_ -->
# is protected by the built-in fallback convention, with no configuration
# required.

# Plugin ordering requirement:
#   Compatible plugins MUST be declared BEFORE revealjs in mkdocs.yml so
#   their on_page_markdown runs first and rewrites admonitions before
#   revealjs stores page._revealjs_slides.
# """

# import json
# import logging
# import os
# import re
# import yaml
# import markdown as _markdown_lib
# from mkdocs.plugins import BasePlugin
# from mkdocs.config import config_options
# from mkdocs.config.base import Config
# from mkdocs.structure.files import File

# log = logging.getLogger("mkdocs.plugins.revealjs")

# CDN_DEFAULT  = "https://cdn.jsdelivr.net/npm/reveal.js@5.1.0"
# HLJS_CDN     = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0"
# MERMAID_CDN  = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"

# # ---------------------------------------------------------------------------
# # Custom Mermaid themes — auto-discovered from the themes/ folder
# # ---------------------------------------------------------------------------
# _THEMES_DIR    = os.path.join(os.path.dirname(__file__), "themes")
# _BASE_CSS_PATH = os.path.join(os.path.dirname(__file__), "assets", "revealjs-base.css")

# def _discover_mermaid_themes(themes_dir: str) -> dict:
#     result = {}
#     if not os.path.isdir(themes_dir):
#         return result
#     prefix = "mermaid-theme-"
#     suffix = ".js"
#     for fname in os.listdir(themes_dir):
#         if fname.startswith(prefix) and fname.endswith(suffix):
#             name = fname[len(prefix):-len(suffix)]
#             if not name:
#                 continue
#             camel = "".join(part.capitalize() for part in name.split("-"))
#             js_var = f"_mkdocsReveal{camel}ThemeVars"
#             result[name] = (os.path.join(themes_dir, fname), js_var)
#     return result

# _BUNDLED_MERMAID_THEMES = _discover_mermaid_themes(_THEMES_DIR)
# _MERMAID_BUILTIN_THEMES = {"base", "default", "neutral", "forest", "dark"}

# _LIGHT_BUILTIN = "white"
# _DARK_BUILTIN  = "black"
# _HLJS_LIGHT    = "atom-one-light"
# _HLJS_DARK     = "atom-one-dark"
# _MM_LIGHT      = "base"
# _MM_DARK       = "dark"

# _DIV_PLACEHOLDER = "MKDOCS_REVEALJS_DIV_7f3a9b2c"
# _DEFAULT_HEIGHT  = 600

# _PLUGIN_ONLY = {"cdn", "js", "theme", "highlight", "mermaid"}
# _PAGE_ONLY   = _PLUGIN_ONLY | {"presentation", "path"}

# _SEP_H    = r"^\n---\n$"
# _SEP_V    = r"^\n----\n$"
# _SEP_NOTE = r"^Note:"

# _END_MARKER_RE = re.compile(r'\n\n={5,}\n\n', re.MULTILINE)

# # CSS scope prefix used for hack auto-prefixing
# _HACK_SCOPE_PREFIX = {
#     "theme":     ".reveal",
#     "highlight": ".reveal .hljs",
#     "mermaid":   ".reveal .mermaid",
# }

# # ---------------------------------------------------------------------------
# # Compatible plugin API — protected admonition marker detection
# # ---------------------------------------------------------------------------
# #
# # Built-in fallback convention: any HTML comment <!-- MKDOCS_PLUGIN_XXX_ -->
# # inside an admonition body is automatically protected without any config.
# #
# # Plugin-declared patterns: collected at on_config time from any active plugin
# # that exposes REVEALJS_MANAGED_ADMONITIONS (list of compiled re.Pattern).
# #
# # The combined list is stored in _compatible_plugins (instance attribute
# # on RevealjsPlugin, built in on_config).

# def _collect_compatible_plugins(plugins_map: dict) -> list:
#     """
#     Scan all active MkDocs plugins for REVEALJS_MANAGED_ADMONITIONS.

#     A compatible plugin declares:
#         class MyPlugin(BasePlugin):
#             REVEALJS_MANAGED_ADMONITIONS = [
#                 re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
#             ]

#     This signals two things to revealjs:
#       1. In _preprocess_admonitions: don't re-render admonitions whose body
#          matches one of these patterns — pass them verbatim so the external
#          plugin's markers reach the final HTML.
#       2. In on_page_content: after building the reveal <div>, call the
#          external plugin's on_page_content on that HTML fragment so it can
#          find its markers and inject the fully-rendered widget HTML.

#     Returns a list of (plugin_name, plugin_instance, [patterns]) tuples,
#     one entry per compatible plugin found.
#     """
#     result = []
#     for name, plugin in plugins_map.items():
#         declared = getattr(plugin, "REVEALJS_MANAGED_ADMONITIONS", None)
#         if not declared or not isinstance(declared, (list, tuple)):
#             continue
#         patterns = [p for p in declared if hasattr(p, "search")]
#         if patterns:
#             result.append((name, plugin, patterns))
#             log.debug(
#                 f"[revealjs] compatible plugin registered: '{name}' "
#                 f"with {len(patterns)} admonition pattern(s)"
#             )
#     return result


# def _admonition_is_managed_externally(block: str,
#                                       compatible_plugins: list) -> bool:
#     """
#     Return True if the admonition block has already been transformed by
#     a compatible external plugin and must be passed through verbatim.
#     """
#     for _name, _plugin, patterns in compatible_plugins:
#         for pattern in patterns:
#             if pattern.search(block):
#                 return True
#     return False


# # ---------------------------------------------------------------------------
# # SchemeDesc — normalised descriptor for one light-or-dark entry
# # ---------------------------------------------------------------------------

# class SchemeDesc:
#     """Normalised, fully-resolved descriptor for a single light or dark entry."""
#     __slots__ = ("starter", "hack", "custom", "custom_js")

#     def __init__(self, starter=None, hack=None, custom=None, custom_js=None):
#         self.starter   = starter
#         self.hack      = hack or {}
#         self.custom    = custom
#         self.custom_js = custom_js

#     def __repr__(self):
#         return (f"SchemeDesc(starter={self.starter!r}, "
#                 f"hack_keys={list(self.hack.keys())}, "
#                 f"custom={self.custom!r}, custom_js={self.custom_js!r})")


# def _coerce_scheme(value, allow_custom_js: bool = False) -> SchemeDesc:
#     if value is None:
#         return SchemeDesc()
#     if isinstance(value, str):
#         return SchemeDesc(starter=value.strip() or None)
#     if not isinstance(value, dict):
#         return SchemeDesc()

#     starter = value.get("starter") or value.get("default") or None
#     if isinstance(starter, str):
#         starter = starter.strip() or None

#     hack = value.get("hack") or {}
#     if not isinstance(hack, dict):
#         hack = {}

#     custom = value.get("custom") or None
#     if isinstance(custom, str):
#         custom = custom.strip() or None

#     custom_js = None
#     if allow_custom_js:
#         custom_js = value.get("custom_js") or None
#         if isinstance(custom_js, str):
#             custom_js = custom_js.strip() or None

#     return SchemeDesc(starter=starter, hack=hack, custom=custom, custom_js=custom_js)


# # ---------------------------------------------------------------------------
# # _AnyVal — MkDocs config option that accepts any YAML value
# # ---------------------------------------------------------------------------

# class _AnyVal(config_options.BaseConfigOption):
#     """Accept any YAML value (str, dict, bool, None) without type coercion."""
#     def run_validation(self, value):
#         return value


# # ---------------------------------------------------------------------------
# # Sub-config schemas
# # ---------------------------------------------------------------------------

# class ThemeConfig(Config):
#     light = _AnyVal()
#     dark  = _AnyVal()


# class HighlightConfig(Config):
#     light = _AnyVal()
#     dark  = _AnyVal()


# class MermaidConfig(Config):
#     activate = config_options.Type(bool, default=False)
#     light    = _AnyVal()
#     dark     = _AnyVal()


# # ---------------------------------------------------------------------------
# # Main plugin config schema
# # ---------------------------------------------------------------------------

# O = config_options.Optional
# T = config_options.Type


# class RevealjsPluginConfig(Config):
#     # ── Plugin-only ──────────────────────────────────────────────────────────
#     cdn       = O(T(str))
#     theme     = config_options.SubConfig(ThemeConfig)
#     highlight = config_options.SubConfig(HighlightConfig)
#     js        = O(T(str))
#     mermaid   = config_options.SubConfig(MermaidConfig)

#     # ── Reveal.js options forwarded verbatim to Reveal.initialize() ──────────
#     controls             = O(T(bool))
#     controlsTutorial     = O(T(bool))
#     controlsLayout       = O(T(str))
#     controlsBackArrows   = O(T(str))
#     progress             = O(T(bool))
#     slideNumber          = O(T(bool))
#     showSlideNumber      = O(T(str))
#     hash                 = O(T(bool))
#     history              = O(T(bool))
#     keyboard             = O(T(bool))
#     overview             = O(T(bool))
#     center               = O(T(bool))
#     touch                = O(T(bool))
#     loop                 = O(T(bool))
#     rtl                  = O(T(bool))
#     navigationMode       = O(T(str))
#     shuffle              = O(T(bool))
#     fragments            = O(T(bool))
#     fragmentInURL        = O(T(bool))
#     help                 = O(T(bool))
#     pause                = O(T(bool))
#     showNotes            = O(T(bool))
#     autoPlayMedia        = O(T(bool))
#     preloadIframes       = O(T(bool))
#     autoAnimate          = O(T(bool))
#     autoAnimateEasing    = O(T(str))
#     autoAnimateDuration  = O(T(float))
#     autoAnimateUnmatched = O(T(bool))
#     autoSlide            = O(T(int))
#     autoSlideStoppable   = O(T(bool))
#     defaultTiming        = O(T(int))
#     mouseWheel           = O(T(bool))
#     previewLinks         = O(T(bool))
#     postMessage          = O(T(bool))
#     postMessageEvents    = O(T(bool))
#     transition           = O(T(str))
#     transitionSpeed      = O(T(str))
#     backgroundTransition = O(T(str))
#     hideInactiveCursor   = O(T(bool))
#     hideCursorTime       = O(T(int))
#     minScale             = O(T(float))
#     maxScale             = O(T(float))

#     def load_dict(self, data):
#         """Coerce  mermaid: true/false  shorthand into nested dict."""
#         if "mermaid" in data and isinstance(data["mermaid"], bool):
#             data = dict(data)
#             data["mermaid"] = {"activate": data["mermaid"]}
#         super().load_dict(data)


# # ---------------------------------------------------------------------------
# # Resolution helpers
# # ---------------------------------------------------------------------------

# def _safe_get(cfg, key):
#     if cfg is None:
#         return None
#     try:
#         return cfg.get(key)
#     except Exception:
#         return None


# def _resolve_scope(global_cfg, page_cfg: dict, scope: str,
#                    default_light=None, default_dark=None,
#                    allow_custom_js=False):
#     g_light = _coerce_scheme(_safe_get(global_cfg, "light"), allow_custom_js)
#     g_dark  = _coerce_scheme(_safe_get(global_cfg, "dark"),  allow_custom_js)

#     if g_light.starter is None and default_light:
#         g_light.starter = default_light
#     if g_dark.starter is None and default_dark:
#         g_dark.starter = default_dark

#     page_scope = page_cfg.get(scope) if isinstance(page_cfg, dict) else None
#     if not isinstance(page_scope, dict):
#         return g_light, g_dark

#     def _merge(raw, base: SchemeDesc) -> SchemeDesc:
#         if raw is None:
#             return base
#         p = _coerce_scheme(raw, allow_custom_js)
#         return SchemeDesc(
#             starter   = p.starter   if p.starter   is not None else base.starter,
#             hack      = p.hack      if p.hack                  else base.hack,
#             custom    = p.custom    if p.custom    is not None else base.custom,
#             custom_js = p.custom_js if p.custom_js is not None else base.custom_js,
#         )

#     light = _merge(page_scope.get("light"), g_light)
#     dark  = _merge(page_scope.get("dark"),  g_dark)
#     return light, dark


# def _resolve_mermaid_activate(global_cfg, page_cfg: dict) -> bool:
#     g = bool(_safe_get(global_cfg, "activate"))
#     pm = page_cfg.get("mermaid") if isinstance(page_cfg, dict) else None
#     if isinstance(pm, bool):
#         return pm
#     if isinstance(pm, dict) and "activate" in pm:
#         return bool(pm["activate"])
#     return g


# # ---------------------------------------------------------------------------
# # CSS hack renderer
# # ---------------------------------------------------------------------------

# def _render_hack_css(hack: dict, scope: str) -> str:
#     if not hack:
#         return ""
#     prefix = _HACK_SCOPE_PREFIX.get(scope, ".reveal")
#     rules  = []
#     for selector, props in hack.items():
#         if not isinstance(props, dict) or not props:
#             continue
#         if selector.startswith("&"):
#             full_sel = prefix + selector[1:]
#         else:
#             full_sel = f"{prefix} {selector}"
#         declarations = "; ".join(f"{p}: {v}" for p, v in props.items())
#         rules.append(f"  {full_sel} {{ {declarations} }}")
#     return "\n".join(rules)


# # ---------------------------------------------------------------------------
# # URL helpers
# # ---------------------------------------------------------------------------

# def _resolve_url(value: str, cdn_pattern: str, cdn_base: str = None) -> str:
#     if not value:
#         return ""
#     if value.startswith("http") or value.startswith("/"):
#         return value
#     if cdn_base:
#         return cdn_pattern.format(cdn=cdn_base, name=value)
#     return cdn_pattern.format(name=value)


# def _theme_url(cdn: str, starter, builtin: str) -> str:
#     s = starter or builtin
#     if s.startswith("http") or s.startswith("/"):
#         return s
#     return f"{cdn}/dist/theme/{s}.css"


# def _load_bundled_css(path: str, id_: str = "") -> str:
#     if not path or not os.path.isfile(path):
#         log.debug(f"[revealjs] bundled CSS not found (skipping): {path!r}")
#         return ""
#     try:
#         with open(path, encoding="utf-8") as fh:
#             id_attr = f' id="{id_}"' if id_ else ""
#             return f"<style{id_attr}>\n{fh.read()}\n</style>"
#     except OSError:
#         log.warning(f"[revealjs] could not read bundled CSS: {path!r}")
#         return ""


# def _load_bundled_script(path: str) -> str:
#     if not path or not os.path.isfile(path):
#         return ""
#     try:
#         with open(path, encoding="utf-8") as fh:
#             return f"\n<script>\n{fh.read()}\n</script>"
#     except OSError:
#         log.warning(f"[revealjs] could not read bundled theme script: {path!r}")
#         return ""


# def _resolve_mermaid_theme_name(starter: str, default: str) -> tuple:
#     name = (starter or default).strip()
#     if name in _BUNDLED_MERMAID_THEMES:
#         js_path, js_var = _BUNDLED_MERMAID_THEMES[name]
#         return "base", js_path, js_var
#     return name, None, None


# def _hljs_url(starter) -> str:
#     s = starter or _HLJS_LIGHT
#     if s.startswith("http") or s.startswith("/"):
#         return s
#     return f"{HLJS_CDN}/styles/{s}.min.css"


# # ---------------------------------------------------------------------------
# # Markdown helpers
# # ---------------------------------------------------------------------------

# _FENCE_OPEN_RE = re.compile(r'^(`{3,}|~{3,})', re.MULTILINE)


# def _fence_aware_split(text: str, separator: str):
#     sep_stripped = separator.strip('\n')
#     parts   = []
#     current = []
#     fence_char  = None
#     fence_len   = 0

#     for line in text.split('\n'):
#         if fence_char is None:
#             m = _FENCE_OPEN_RE.match(line)
#             if m:
#                 fence_char = m.group(1)[0]
#                 fence_len  = len(m.group(1))
#                 current.append(line)
#             elif line == sep_stripped:
#                 parts.append('\n'.join(current))
#                 current = []
#             else:
#                 current.append(line)
#         else:
#             stripped = line.strip()
#             if (stripped.startswith(fence_char * fence_len)
#                     and all(c == fence_char for c in stripped)):
#                 fence_char = None
#                 fence_len  = 0
#             current.append(line)

#     parts.append('\n'.join(current))
#     return parts


# def _split_at_end_marker(markdown: str):
#     if not _END_MARKER_RE.search(markdown):
#         return markdown, None

#     parts = _fence_aware_split(markdown, "\n=====\n")
#     if len(parts) == 1:
#         return markdown, None
#     slides = parts[0]
#     tail   = '\n'.join(parts[1:]).lstrip('\n') if len(parts) > 1 else None
#     if tail == '':
#         tail = None
#     return slides, tail


# def _load_import_file(host_src_path: str, rel_path: str):
#     host_dir  = os.path.dirname(os.path.abspath(host_src_path))
#     abs_path  = os.path.normpath(os.path.join(host_dir, rel_path))
#     if not os.path.isfile(abs_path):
#         raise FileNotFoundError(
#             f"[revealjs] import path not found: {abs_path!r} "
#             f"(referenced from {host_src_path!r})"
#         )
#     with open(abs_path, encoding="utf-8") as fh:
#         raw = fh.read()
#     fm_re    = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
#     fm_match = fm_re.match(raw)
#     if not fm_match:
#         raise ValueError(
#             f"[revealjs] import file has no YAML front-matter: {abs_path!r}"
#         )
#     try:
#         meta = yaml.safe_load(fm_match.group(1)) or {}
#     except yaml.YAMLError as exc:
#         raise ValueError(f"[revealjs] YAML parse error in {abs_path!r}: {exc}")
#     revealjs     = meta.get("revealjs", {})
#     if not isinstance(revealjs, dict):
#         revealjs = {}
#     import_title = meta.get("title") or None
#     body         = raw[fm_match.end():]
#     return body, revealjs, import_title


# # ---------------------------------------------------------------------------
# # Auto-animate markdown pre-processor
# # ---------------------------------------------------------------------------

# _FENCE_RE = re.compile(
#     r'^```'
#     r'(\w*)'
#     r'(?:\s*\{[^}]*data-id=["\']([^"\']+)["\'][^}]*\})?'
#     r'(?:\s*\[([^\]]*)\])?'
#     r'\s*$',
#     re.MULTILINE,
# )


# def _slide_has_auto_animate(slide_text: str) -> bool:
#     return 'data-auto-animate' in slide_text


# def _neutralise_separators_in_fences(markdown: str) -> str:
#     _SEP_LINE_RE = re.compile(r'^(-{3,}|={5,})$')

#     lines      = markdown.split('\n')
#     out        = []
#     fence_char = None
#     fence_len  = 0
#     i          = 0

#     while i < len(lines):
#         line = lines[i]

#         if fence_char is None:
#             m = _FENCE_OPEN_RE.match(line)
#             if m:
#                 fence_char = m.group(1)[0]
#                 fence_len  = len(m.group(1))
#             out.append(line)
#             i += 1
#         else:
#             stripped = line.strip()
#             if (stripped.startswith(fence_char * fence_len)
#                     and all(c == fence_char for c in stripped)):
#                 fence_char = None
#                 fence_len  = 0
#                 out.append(line)
#                 i += 1
#             else:
#                 if (line == ''
#                         and i + 2 < len(lines)
#                         and _SEP_LINE_RE.match(lines[i + 1])
#                         and lines[i + 2] == ''):
#                     out.append(lines[i + 1])
#                     i += 3
#                 else:
#                     out.append(line)
#                     i += 1

#     return '\n'.join(out)


# def _preprocess_auto_animate(markdown: str) -> str:
#     markdown = _neutralise_separators_in_fences(markdown)
#     slides = _fence_aware_split(markdown, "\n---\n")

#     result = []
#     for i, slide in enumerate(slides):
#         if _slide_has_auto_animate(slide):
#             result.append(_rewrite_fenced_blocks(slide))
#         else:
#             result.append(slide)
#         if i < len(slides) - 1:
#             result.append('\n---\n')
#     return ''.join(result)


# def _rewrite_fenced_blocks(slide: str) -> str:
#     lines = slide.split('\n')
#     out   = []
#     i     = 0
#     block_index = 0
#     while i < len(lines):
#         m = _FENCE_RE.match(lines[i])
#         if m:
#             lang        = m.group(1) or ''
#             data_id_val = m.group(2)
#             line_spec   = m.group(3)
#             i += 1
#             body_lines = []
#             while i < len(lines) and lines[i].strip() != '```':
#                 body_lines.append(lines[i])
#                 i += 1
#             if i < len(lines):
#                 i += 1
#             if not data_id_val:
#                 data_id_val = f'revealjs-code-{block_index}'
#             block_index += 1
#             ln_attr   = (f' data-line-numbers="{line_spec.strip()}"'
#                          if line_spec is not None else ' data-line-numbers')
#             lang_attr = f' class="language-{lang}"' if lang else ''
#             body      = '\n'.join(body_lines)
#             out.append(
#                 f'<pre data-id="{data_id_val}">'
#                 f'<code data-trim{ln_attr}{lang_attr}>'
#                 f'{body}'
#                 f'</code></pre>'
#             )
#         else:
#             out.append(lines[i])
#             i += 1
#     return '\n'.join(out)


# # ---------------------------------------------------------------------------
# # Admonition pre-processor
# # ---------------------------------------------------------------------------

# _ADMON_HEADER_RE  = re.compile(r'^(!{3}|\?{3}\+?)[ \t]+\S+')
# _ADMON_FENCE_RE   = re.compile(r'^(`{3,}|~{3,})')
# _EMOJI_TOKEN_RE   = re.compile(r':[a-zA-Z0-9_+\-]+:')
# _EMOJI_P_WRAP_RE  = re.compile(r'^<p>(.*?)</p>$', re.DOTALL)


# def _build_emoji_substitutor(md_extensions: list, md_configs: dict):
#     _EMOJI_NAMES = {'pymdownx.emoji', 'material.extensions.emoji', 'materialx.emoji'}

#     emoji_ext_name = None
#     for ext in (md_extensions or []):
#         name = ext if isinstance(ext, str) else getattr(ext, 'extPath', None) or str(ext)
#         if any(e in str(name) for e in _EMOJI_NAMES):
#             emoji_ext_name = name
#             break
#     if not emoji_ext_name:
#         return None

#     ext_configs = {}
#     for k, v in (md_configs or {}).items():
#         if any(e in k for e in _EMOJI_NAMES):
#             ext_configs[k] = v

#     try:
#         md = _markdown_lib.Markdown(
#             extensions=[emoji_ext_name],
#             extension_configs=ext_configs,
#         )
#     except Exception as exc:
#         log.debug(f"[revealjs] emoji extension failed to load ({exc}), skipping")
#         return None

#     _cache: dict = {}

#     def _render_token(token: str) -> str:
#         if token in _cache:
#             return _cache[token]
#         md.reset()
#         rendered = md.convert(token)
#         m = _EMOJI_P_WRAP_RE.match(rendered.strip())
#         result = m.group(1) if m else rendered
#         _cache[token] = result
#         return result

#     def subst(text: str) -> str:
#         if ':' not in text:
#             return text
#         return _EMOJI_TOKEN_RE.sub(lambda m: _render_token(m.group(0)), text)

#     log.debug(f"[revealjs] emoji substitutor built from {emoji_ext_name!r}")
#     return subst


# def _extract_admonition_block(lines: list, start: int):
#     i = start + 1
#     while i < len(lines):
#         l = lines[i]
#         if l == '' or l.startswith('    ') or l.startswith('\t'):
#             i += 1
#         else:
#             break
#     end = i
#     while end > start + 1 and lines[end - 1].strip() == '':
#         end -= 1
#     return end, '\n'.join(lines[start:end])


# def _preprocess_admonitions(slides_md: str,
#                              md_extensions: list,
#                              md_configs: dict,
#                              compatible_plugins: list = None) -> str:
#     """
#     Walk through slides_md line by line.

#     - Inside fenced code blocks (``` / ~~~): every line is emitted verbatim.
#     - Outside fences:
#         • If an admonition body matches a pattern from a compatible plugin
#           (declared via REVEALJS_MANAGED_ADMONITIONS), the entire block is
#           emitted verbatim — the responsible plugin will process it later via
#           a direct on_page_content call on the rendered slide HTML fragment.
#         • Other admonition headers trigger block extraction + HTML rendering
#           via Python-Markdown (admonition extension).
#         • :emoji: tokens on every non-fenced line are substituted with their
#           rendered HTML inline if pymdownx.emoji is configured.
#         • Everything else is emitted verbatim.

#     Parameters
#     ----------
#     slides_md          : raw markdown content of the slides
#     md_extensions      : list of MkDocs markdown_extensions
#     md_configs         : dict of MkDocs mdx_configs
#     compatible_plugins : list of (name, plugin, patterns) tuples collected
#                          by _collect_compatible_plugins at on_config
#     """
#     if compatible_plugins is None:
#         compatible_plugins = []

#     # ── Build a minimal Markdown instance for admonitions ───────────────────
#     extensions = ['admonition']
#     for ext in (md_extensions or []):
#         name = ext if isinstance(ext, str) else getattr(ext, 'name', None)
#         if name and 'detail' in name and name not in extensions:
#             extensions.append(name)
#     try:
#         md = _markdown_lib.Markdown(
#             extensions=extensions,
#             extension_configs={
#                 k: v for k, v in (md_configs or {}).items()
#                 if any(k in str(e) for e in extensions)
#             },
#         )
#     except Exception:
#         md = _markdown_lib.Markdown(extensions=['admonition'])

#     emoji_subst = _build_emoji_substitutor(md_extensions, md_configs)

#     lines      = slides_md.split('\n')
#     out        = []
#     fence_char = None
#     fence_len  = 0
#     i          = 0

#     while i < len(lines):
#         line = lines[i]

#         # ── inside a fenced code block: emit verbatim (no emoji sub) ───────
#         if fence_char is not None:
#             out.append(line)
#             stripped = line.strip()
#             if (stripped.startswith(fence_char * fence_len)
#                     and all(c == fence_char for c in stripped)):
#                 fence_char = None
#                 fence_len  = 0
#             i += 1
#             continue

#         # ── fence opening ───────────────────────────────────────────────────
#         m_fence = _ADMON_FENCE_RE.match(line)
#         if m_fence:
#             fence_char = m_fence.group(1)[0]
#             fence_len  = len(m_fence.group(1))
#             out.append(line)
#             i += 1
#             continue

#         # ── admonition header ───────────────────────────────────────────────
#         if _ADMON_HEADER_RE.match(line):
#             end, block = _extract_admonition_block(lines, i)

#             # ── Compatible plugin: render admonition then delegate ────────────────
#             # If a compatible plugin owns this admonition (detected via
#             # REVEALJS_MANAGED_ADMONITIONS patterns), render it via standard
#             # Python-Markdown so the HTML structure (div.admonition + MQS
#             # markers) ends up in the slide HTML. revealjs then calls the
#             # plugin's on_page_content directly on the slide div fragment
#             # in on_page_content, so it can inject the final widget HTML.
#             if _admonition_is_managed_externally(block, compatible_plugins):
#                 log.debug(
#                     f"[revealjs] admonition at line {i} owned by compatible "
#                     f"plugin — rendering via standard admonition"
#                 )
#                 if emoji_subst:
#                     block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
#                 md.reset()
#                 html_block = md.convert(block)
#                 out.append('')
#                 out.append(html_block)
#                 out.append('')
#                 i = end
#                 continue
#             # ── end compatible plugin ───────────────────────────────────────────────────────

#             # Standard path: render admonition via Python-Markdown
#             if emoji_subst:
#                 block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
#             md.reset()
#             html = md.convert(block)
#             # Blank lines ensure Reveal.js markdown plugin treats it as raw HTML
#             out.append('')
#             out.append(html)
#             out.append('')
#             i = end
#             continue

#         # ── normal line: apply emoji substitution then emit ─────────────────
#         out.append(emoji_subst(line) if emoji_subst else line)
#         i += 1

#     return '\n'.join(out)


# # ---------------------------------------------------------------------------
# # Custom URL resolver
# # ---------------------------------------------------------------------------

# def _resolve_custom_url(value: str, page, config) -> str:
#     if not value:
#         return value
#     if value.startswith("http://") or value.startswith("https://") or value.startswith("/"):
#         return value
#     page_url = (page.url or "").rstrip("/")
#     depth    = page_url.count("/") + (1 if page_url else 0)
#     prefix   = "../" * depth
#     return prefix + value


# # ---------------------------------------------------------------------------
# # Plugin
# # ---------------------------------------------------------------------------

# _H1_RE = re.compile(r'^(#[^#][^\n]*)\n?', re.MULTILINE)


# class RevealjsPlugin(BasePlugin[RevealjsPluginConfig]):
#     """
#     MkDocs plugin that embeds Reveal.js presentations in MkDocs pages.

#     Compatible plugin API
#     ---------------------
#     Any MkDocs plugin that transforms admonitions can declare itself
#     compatible by adding a class-level attribute:

#         class MyPlugin(BasePlugin):
#             REVEALJS_MANAGED_ADMONITIONS = [
#                 re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
#             ]

#     revealjs collects these patterns at on_config time and uses them in
#     _preprocess_admonitions to skip re-rendering those admonitions.

#     Additionally, any HTML comment matching <!-- MKDOCS_PLUGIN_XXX_ --> is
#     protected automatically by built-in convention, without any declaration.

#     Plugin ordering requirement
#     ---------------------------
#     Compatible plugins MUST appear BEFORE revealjs in mkdocs.yml plugins:
#     so their on_page_markdown runs first and rewrites admonitions before
#     revealjs stores page._revealjs_slides.
#     """

#     _cdn_base:               str  = CDN_DEFAULT
#     _global_opts:            dict = {}
#     _compatible_plugins:     list = []   # collected at on_config

#     def on_config(self, config):
#         cdn = self.config.get("cdn")
#         self._cdn_base    = cdn.rstrip("/") if cdn else CDN_DEFAULT
#         self._global_opts = {
#             k: v for k, v in self.config.items()
#             if k not in _PLUGIN_ONLY and v is not None
#         }

#         # ── Collect compatible plugins once per build ───────────────────────
#         plugins_map = config.get("plugins", {})
#         self._compatible_plugins = _collect_compatible_plugins(plugins_map)
#         if self._compatible_plugins:
#             names = ', '.join(n for n, _, _ in self._compatible_plugins)
#             log.info(f"[revealjs] compatible plugins detected: {names}")

#         # Cache custom_dir so on_files can register custom CSS/JS files
#         theme_cfg   = config.get("theme") or {}
#         custom_dir  = getattr(theme_cfg, "custom_dir", None)
#         self._custom_dir    = custom_dir or None
#         self._mkdocs_config = config
#         return config

#     # ---------------------------------------------------------------- files

#     def on_files(self, files, config):
#         if not self._custom_dir or not os.path.isdir(self._custom_dir):
#             return files

#         custom_paths = []
#         for scope in ("theme", "highlight", "mermaid"):
#             scope_cfg = self.config.get(scope)
#             for side in ("light", "dark"):
#                 val = _safe_get(scope_cfg, side)
#                 desc = _coerce_scheme(val, allow_custom_js=(scope == "mermaid"))
#                 if desc.custom:
#                     custom_paths.append(desc.custom)
#                 if desc.custom_js:
#                     custom_paths.append(desc.custom_js)
#         js_path = self.config.get("js")
#         if js_path:
#             custom_paths.append(js_path)

#         for path in custom_paths:
#             if not path or path.startswith("http") or path.startswith("/"):
#                 continue
#             abs_path = os.path.join(self._custom_dir, path)
#             if not os.path.isfile(abs_path):
#                 abs_path = os.path.join(os.path.dirname(
#                     config.get("config_file_path", "")), path)
#                 if not os.path.isfile(abs_path):
#                     continue
#             f = File(
#                 path       = path,
#                 src_dir    = self._custom_dir,
#                 dest_dir   = config["site_dir"],
#                 use_directory_urls = config.get("use_directory_urls", True),
#             )
#             if not files.get_file_from_path(path):
#                 files.append(f)
#                 log.debug(f"[revealjs] registered custom file: {path!r}")

#         return files

#     # ---------------------------------------------------------------- markdown

#     def on_page_markdown(self, markdown, page, config, files):
#         meta     = page.meta or {}
#         revealjs = meta.get("revealjs", {})
#         if not isinstance(revealjs, dict):
#             return markdown
#         presentation = revealjs.get("presentation", False)

#         # ── import mode ──────────────────────────────────────────────────────
#         if presentation == "import":
#             rel_path = revealjs.get("path")
#             if not rel_path:
#                 log.error(f"[revealjs] 'presentation: import' requires 'path:' "
#                           f"in {page.file.src_path}")
#                 return markdown
#             try:
#                 import_body, import_cfg, import_title = _load_import_file(
#                     page.file.abs_src_path, rel_path)
#             except (FileNotFoundError, ValueError) as exc:
#                 log.error(str(exc))
#                 return markdown

#             host_tail  = markdown.strip() or None
#             host_title = meta.get("title")
#             h1_line    = None
#             tail_body  = host_tail

#             if host_tail:
#                 m = _H1_RE.search(host_tail)
#                 if m:
#                     h1_line   = m.group(1).strip()
#                     tail_body = (host_tail[:m.start()] + host_tail[m.end():]).strip() or None
#                 elif not host_title and import_title:
#                     h1_line   = f"# {import_title}"
#             elif not host_title and import_title:
#                 h1_line   = f"# {import_title}"
#                 tail_body = None

#             page._revealjs_slides = _preprocess_auto_animate(import_body.strip())
#             page._revealjs_config = import_cfg
#             page._revealjs_tail   = tail_body
#             return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

#         # ── inline mode ──────────────────────────────────────────────────────
#         if not presentation:
#             return markdown

#         slides_part, tail_part = _split_at_end_marker(markdown)
#         page._revealjs_slides = _preprocess_auto_animate(slides_part.strip())
#         page._revealjs_config = revealjs

#         if tail_part is None:
#             page._revealjs_tail = None
#             return _DIV_PLACEHOLDER

#         tail_raw  = tail_part.strip()
#         fm_title  = revealjs.get("title") or meta.get("title")
#         h1_line   = None
#         tail_body = tail_raw or None

#         if tail_raw:
#             m = _H1_RE.search(tail_raw)
#             if m:
#                 h1_line   = m.group(1).strip()
#                 tail_body = (tail_raw[:m.start()] + tail_raw[m.end():]).strip() or None
#             elif fm_title:
#                 h1_line   = f"# {fm_title}"
#         elif fm_title:
#             h1_line   = f"# {fm_title}"
#             tail_body = None

#         page._revealjs_tail = tail_body
#         return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

#     # ---------------------------------------------------------------- content

#     def on_page_content(self, html, page, config, files):
#         if not hasattr(page, "_revealjs_slides"):
#             return html
#         placeholder_p   = f"<p>{_DIV_PLACEHOLDER}</p>"
#         placeholder_raw = _DIV_PLACEHOLDER
#         if placeholder_p not in html and placeholder_raw not in html:
#             log.error(f"[revealjs] placeholder not found in {page.file.src_path}")
#             return html
#         revealjs = page._revealjs_config
#         height   = revealjs.get("height", self._global_opts.get("height", _DEFAULT_HEIGHT))

#         md_extensions = config.get("markdown_extensions") or []
#         md_configs    = config.get("mdx_configs") or {}

#         # _preprocess_admonitions renders compatible-plugin admonitions via
#         # standard Python-Markdown (so markers appear in HTML), then the
#         # delegation loop below calls each plugin's on_page_content.
#         slides = _preprocess_admonitions(
#             page._revealjs_slides,
#             md_extensions,
#             md_configs,
#             compatible_plugins=self._compatible_plugins,
#         )

#         div_html = self._render_div(slides, height)

#         # ── Delegate to compatible plugins ───────────────────────────────────
#         # Each compatible plugin's on_page_content is called on the slide div
#         # HTML fragment. The plugin finds its markers inside the <textarea>,
#         # extracts them, renders its widget, and returns the modified fragment.
#         # We pass a synthetic html argument that contains ONLY the div_html so
#         # the plugin operates exclusively on the slides content and not on the
#         # rest of the page (which has not yet been assembled at this point).
#         for _name, ext_plugin, _patterns in self._compatible_plugins:
#             if not hasattr(ext_plugin, "on_page_content"):
#                 continue
#             if not hasattr(page, "_mkdocs_quiz_data"):
#                 # Compatible plugin has no data for this page — skip
#                 continue
#             try:
#                 result = ext_plugin.on_page_content(div_html, page=page,
#                                                      config=config, files=files)
#                 if result is not None:
#                     div_html = result
#                     log.debug(f"[revealjs] on_page_content delegated to '{_name}'")
#             except Exception as exc:
#                 log.warning(f"[revealjs] on_page_content delegation to "
#                             f"'{_name}' failed: {exc}")
#         # ── end delegation ───────────────────────────────────────────────────

#         if placeholder_p in html:
#             return html.replace(placeholder_p, div_html, 1)
#         return html.replace(placeholder_raw, div_html, 1)

#     # ---------------------------------------------------------------- post page

#     def on_post_page(self, output, page, config):
#         if not hasattr(page, "_revealjs_slides"):
#             return output
#         revealjs = page._revealjs_config
#         page_cdn = revealjs.get("cdn")
#         cdn      = page_cdn.rstrip("/") if page_cdn else self._cdn_base
#         extra_js = revealjs.get("js") or self.config.get("js")

#         theme_l, theme_d = _resolve_scope(
#             self.config.get("theme"), revealjs, "theme")
#         hl_l, hl_d = _resolve_scope(
#             self.config.get("highlight"), revealjs, "highlight",
#             default_light=_HLJS_LIGHT, default_dark=_HLJS_DARK)
#         mm_l, mm_d = _resolve_scope(
#             self.config.get("mermaid"), revealjs, "mermaid",
#             default_light=_MM_LIGHT, default_dark=_MM_DARK,
#             allow_custom_js=True)
#         mm_activate = _resolve_mermaid_activate(self.config.get("mermaid"), revealjs)

#         page_opts   = {k: v for k, v in revealjs.items() if k not in _PAGE_ONLY}
#         merged_opts = {**self._global_opts, **page_opts}
#         height      = merged_opts.pop("height", _DEFAULT_HEIGHT)
#         js_options  = self._build_js_options(merged_opts)

#         def _rc(v): return _resolve_custom_url(v, page, config)
#         if theme_l.custom: theme_l.custom = _rc(theme_l.custom)
#         if theme_d.custom: theme_d.custom = _rc(theme_d.custom)
#         if hl_l.custom:    hl_l.custom    = _rc(hl_l.custom)
#         if hl_d.custom:    hl_d.custom    = _rc(hl_d.custom)
#         if mm_l.custom:    mm_l.custom    = _rc(mm_l.custom)
#         if mm_d.custom:    mm_d.custom    = _rc(mm_d.custom)
#         if mm_l.custom_js: mm_l.custom_js = _rc(mm_l.custom_js)
#         if mm_d.custom_js: mm_d.custom_js = _rc(mm_d.custom_js)
#         if extra_js:       extra_js       = _rc(extra_js)

#         assets = self._render_assets(
#             cdn=cdn,
#             theme_l=theme_l, theme_d=theme_d,
#             hl_l=hl_l, hl_d=hl_d,
#             mm_l=mm_l, mm_d=mm_d, mm_activate=mm_activate,
#             js_opts=js_options, extra_js=extra_js, height=height,
#         )
#         if "</body>" in output:
#             return output.replace("</body>", assets + "\n</body>", 1)
#         return output + assets

#     # ---------------------------------------------------------------- helpers

#     @staticmethod
#     def _render_div(slides: str, height: int) -> str:
#         return (
#             f'<div class="mkdocs-revealjs-wrapper" '
#             f'style="width:100%;height:{height}px;margin:1.5rem 0;">\n'
#             f'  <div class="reveal" id="mkdocs-revealjs" '
#             f'style="width:100%;height:{height}px;">\n'
#             f'    <div class="slides">\n'
#             f'      <section\n'
#             f'        data-markdown\n'
#             f'        data-separator="{_SEP_H}"\n'
#             f'        data-separator-vertical="{_SEP_V}"\n'
#             f'        data-separator-notes="{_SEP_NOTE}"\n'
#             f'      >\n'
#             f'        <textarea data-template>\n'
#             f'{slides}\n'
#             f'        </textarea>\n'
#             f'      </section>\n'
#             f'    </div>\n'
#             f'  </div>\n'
#             f'</div>'
#         )

#     @staticmethod
#     def _build_js_options(opts: dict) -> str:
#         parts = []
#         for key, value in opts.items():
#             if isinstance(value, bool):
#                 parts.append(f"      {key}: {'true' if value else 'false'}")
#             elif isinstance(value, (int, float)):
#                 parts.append(f"      {key}: {value}")
#             else:
#                 parts.append(
#                     f"      {key}: '{str(value).replace(chr(39), chr(92)+chr(39))}'")
#         return ",\n".join(parts)

#     def _render_assets(self, cdn,
#                        theme_l, theme_d,
#                        hl_l, hl_d,
#                        mm_l, mm_d, mm_activate,
#                        js_opts, extra_js, height) -> str:

#         rv_light_url = _theme_url(cdn, theme_l.starter, _LIGHT_BUILTIN)
#         rv_dark_url  = _theme_url(cdn, theme_d.starter, _DARK_BUILTIN)
#         hl_light_url = _hljs_url(hl_l.starter or _HLJS_LIGHT)
#         hl_dark_url  = _hljs_url(hl_d.starter or _HLJS_DARK)

#         rv_hack_l = _render_hack_css(theme_l.hack, "theme")
#         rv_hack_d = _render_hack_css(theme_d.hack, "theme")
#         hl_hack_l = _render_hack_css(hl_l.hack, "highlight")
#         hl_hack_d = _render_hack_css(hl_d.hack, "highlight")
#         mm_hack_l = _render_hack_css(mm_l.hack, "mermaid") if mm_activate else ""
#         mm_hack_d = _render_hack_css(mm_d.hack, "mermaid") if mm_activate else ""

#         def link(href, id_, disabled=False):
#             d = " disabled" if disabled else ""
#             return f'<link rel="stylesheet" href="{href}" id="{id_}"{d}>'

#         def opt_link(href, id_, disabled=False):
#             return ("\n" + link(href, id_, disabled)) if href else ""

#         def style_block(css, id_):
#             if not css:
#                 return ""
#             return f'\n<style id="{id_}">\n{css}\n</style>'

#         def script(src):
#             return f'\n<script src="{src}"></script>' if src else ""

#         swap_pairs = [
#             ["revealjs-theme-light",    "revealjs-theme-dark"],
#             ["revealjs-hljs-light",     "revealjs-hljs-dark"],
#             ["revealjs-hack-rv-light",  "revealjs-hack-rv-dark"],
#             ["revealjs-hack-hl-light",  "revealjs-hack-hl-dark"],
#         ]
#         if theme_l.custom or theme_d.custom:
#             swap_pairs.append(["revealjs-custom-rv-light", "revealjs-custom-rv-dark"])
#         if hl_l.custom or hl_d.custom:
#             swap_pairs.append(["revealjs-custom-hl-light", "revealjs-custom-hl-dark"])
#         if mm_activate:
#             swap_pairs.append(["revealjs-hack-mm-light", "revealjs-hack-mm-dark"])
#             if mm_l.custom or mm_d.custom:
#                 swap_pairs.append(["revealjs-custom-mm-light", "revealjs-custom-mm-dark"])
#         swap_pairs_js = json.dumps(swap_pairs)

#         base_opts     = (f"      embedded: true,\n"
#                          f"      width: '100%',\n"
#                          f"      height: {height},\n"
#                          f"      margin: 0")
#         options_block = base_opts + (",\n" + js_opts if js_opts else "")

#         if mm_activate:
#             mm_light_theme, mm_light_reg, mm_light_var = _resolve_mermaid_theme_name(mm_l.starter, _MM_LIGHT)
#             mm_dark_theme,  mm_dark_reg,  mm_dark_var  = _resolve_mermaid_theme_name(mm_d.starter, _MM_DARK)

#             if mm_light_var:
#                 mm_light_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_light_var}"] || {{}} }}'
#             else:
#                 mm_light_init = f'{{ startOnLoad: false, theme: "{mm_light_theme}" }}'

#             if mm_dark_var:
#                 mm_dark_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_dark_var}"] || {{}} }}'
#             else:
#                 mm_dark_init = f'{{ startOnLoad: false, theme: "{mm_dark_theme}" }}'

#             mermaid_js = f"""
#   // ── Mermaid ─────────────────────────────────────────────────────────────
#   function _rvIsDark() {{
#     return (document.body.getAttribute("data-md-color-scheme") || "default") === "slate";
#   }}
#   function _rvMermaidConfig(dark) {{
#     return dark ? {mm_dark_init} : {mm_light_init};
#   }}
#   function _rvStoreMermaidSrc() {{
#     document.querySelectorAll(".mermaid:not([data-src])").forEach(function(el) {{
#       el.dataset.src = el.textContent;
#     }});
#   }}
#   function _rvCallMermaidPatch(dark) {{
#     var fn = dark
#       ? window._mkdocsRevealjsMermaidPatchDark
#       : window._mkdocsRevealjsMermaidPatchLight;
#     if (typeof fn === "function") {{
#       var deckEl = document.getElementById("mkdocs-revealjs");
#       try {{ fn(deckEl); }} catch(e) {{
#         console.warn("[revealjs] mermaid patch error:", e);
#       }}
#     }}
#   }}
#   function _rvRenderMermaid(dark) {{
#     mermaid.initialize(_rvMermaidConfig(dark));
#     document.querySelectorAll(".mermaid[data-processed]").forEach(function(el) {{
#       el.removeAttribute("data-processed");
#       if (el.dataset.src) {{ el.textContent = el.dataset.src; }}
#     }});
#     mermaid.run({{ querySelector: ".mermaid" }});
#     _rvCallMermaidPatch(dark);
#   }}
# """
#             mermaid_init = """
#     _rvStoreMermaidSrc();
#     _rvRenderMermaid(_rvIsDark());
#     deck.on("slidechanged", function() { _rvRenderMermaid(_rvIsDark()); });
# """
#             mermaid_theme_swap = "    _rvRenderMermaid(isDark);\n"
#         else:
#             mermaid_js         = ""
#             mermaid_init       = ""
#             mermaid_theme_swap = ""
#             mm_light_reg       = None
#             mm_dark_reg        = None
#             mm_light_var       = None
#             mm_dark_var        = None

#         return f"""
# <!-- mkdocs-revealjs ───────────────────────────────────────── -->

# <!-- Plugin base corrections (always loaded, before any theme) -->
# {_load_bundled_css(_BASE_CSS_PATH, "revealjs-base")}

# <link rel="stylesheet" href="{cdn}/dist/reveal.css">

# <!-- Reveal.js theme: starter (light / dark) -->
# {link(rv_light_url, "revealjs-theme-light")}
# {link(rv_dark_url,  "revealjs-theme-dark",  disabled=True)}

# <!-- Reveal.js theme: hack overrides (light / dark) -->{style_block(rv_hack_l, "revealjs-hack-rv-light")}{style_block(rv_hack_d, "revealjs-hack-rv-dark")}

# <!-- Reveal.js theme: custom CSS (light / dark) -->{opt_link(theme_l.custom, "revealjs-custom-rv-light")}{opt_link(theme_d.custom, "revealjs-custom-rv-dark", disabled=True)}

# <!-- Highlight.js theme: starter (light / dark) -->
# {link(hl_light_url, "revealjs-hljs-light")}
# {link(hl_dark_url,  "revealjs-hljs-dark",  disabled=True)}

# <!-- Highlight.js theme: hack overrides (light / dark) -->{style_block(hl_hack_l, "revealjs-hack-hl-light")}{style_block(hl_hack_d, "revealjs-hack-hl-dark")}

# <!-- Highlight.js theme: custom CSS (light / dark) -->{opt_link(hl_l.custom, "revealjs-custom-hl-light")}{opt_link(hl_d.custom, "revealjs-custom-hl-dark", disabled=True)}

# <!-- Mermaid theme: hack overrides (light / dark) -->{style_block(mm_hack_l, "revealjs-hack-mm-light") if mm_activate else ""}{style_block(mm_hack_d, "revealjs-hack-mm-dark") if mm_activate else ""}

# <!-- Mermaid theme: custom CSS (light / dark) -->{opt_link(mm_l.custom, "revealjs-custom-mm-light") if mm_activate else ""}{opt_link(mm_d.custom, "revealjs-custom-mm-dark", disabled=True) if mm_activate else ""}

# <script src="{cdn}/dist/reveal.js"></script>
# <script src="{cdn}/plugin/markdown/markdown.js"></script>
# <script src="{cdn}/plugin/highlight/highlight.js"></script>
# <script src="{cdn}/plugin/notes/notes.js"></script>
# <script src="{cdn}/plugin/math/math.js"></script>
# {"<script src=" + repr(MERMAID_CDN) + "></script>" if mm_activate else ""}
# {_load_bundled_script(mm_light_reg) if mm_activate and mm_light_reg else ""}
# {_load_bundled_script(mm_dark_reg) if mm_activate and mm_dark_reg and mm_dark_reg != mm_light_reg else ""}
# {script(mm_l.custom_js) if mm_activate and mm_l.custom_js else ""}
# {script(mm_d.custom_js) if mm_activate and mm_d.custom_js and mm_d.custom_js != mm_l.custom_js else ""}
# {script(extra_js) if extra_js else ""}
# <script>
# (function () {{
# {mermaid_js}
#   // ── Theme switcher ───────────────────────────────────────────────────────
#   var _SWAP_PAIRS = {swap_pairs_js};

#   function applyTheme(scheme) {{
#     var isDark = (scheme === "slate");
#     _SWAP_PAIRS.forEach(function(pair) {{
#       var light = document.getElementById(pair[0]);
#       var dark  = document.getElementById(pair[1]);
#       if (light) light.disabled = isDark;
#       if (dark)  dark.disabled  = !isDark;
#     }});
# {mermaid_theme_swap}  }}

#   applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");

#   new MutationObserver(function(mutations) {{
#     mutations.forEach(function(m) {{
#       if (m.attributeName === "data-md-color-scheme") {{
#         applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");
#       }}
#     }});
#   }}).observe(document.body, {{ attributes: true }});

#   // ── Reveal.js init ──────────────────────────────────────────────────────
#   function initReveal() {{
#     var el = document.getElementById("mkdocs-revealjs");
#     if (!el) return;
#     var deck = new Reveal(el, {{
# {options_block},
#       highlight: {{
#         highlightOnLoad: true
#       }},
#       plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealMath]
#     }});
#     deck.initialize().then(function() {{
#       deck.layout();
# {mermaid_init}    }});
#     window._mkdocsRevealDeck = deck;
#   }}

#   if (document.readyState === "loading") {{
#     document.addEventListener("DOMContentLoaded", initReveal);
#   }} else {{
#     initReveal();
#   }}
# }})();
# </script>
# <!-- ─────────────────────────────────────── mkdocs-revealjs -->"""

# =======================================================================================================
# =======================================================================================================
# =======================================================================================================


"""
mkdocs-revealjs — plugin.py

Hooks
-----
on_page_markdown  : save raw slides + config, return placeholder
on_page_content   : replace placeholder with .reveal div
on_post_page      : inject <link> + <script> before </body>

Theming pipeline (per scheme: light / dark, per scope: theme / highlight / mermaid)
-------------------------------------------------------------------------------------
Each light/dark entry supports three ordered layers:

  1. starter / default  — base theme  (CDN built-in name or any URL)
  2. hack               — inline <style> CSS overrides on top of starter
  3. custom             — additional CSS file loaded after starter+hack
                          (mermaid also accepts  custom_js  for SVG patching)

All layers are optional and compose in that order.

Shorthand coercion:
  light: solarized  →  { starter: "solarized", hack: {}, custom: None }
  "starter" and "default" keywords are exact aliases.

Combinations:
  starter only        → load starter
  hack only           → apply overrides on top of plugin default
  custom only         → load custom CSS (no starter loaded)
  starter + hack      → starter then hack inline
  starter + custom    → starter then custom file
  all three           → starter → hack inline → custom file

hack selector rules (auto-prefix per scope, '&' = scope prefix):
  scope     auto-prefix
  theme     .reveal
  highlight .reveal .hljs
  mermaid   .reveal .mermaid

  h1                →  .reveal h1 { … }
  "& .slides"       →  .reveal .slides { … }
  "&.embedded"      →  .reveal.embedded { … }

mermaid custom_js convention:
  The JS file must assign:
    window._mkdocsRevealjsMermaidPatchLight = function(deckEl) { … }
    window._mkdocsRevealjsMermaidPatchDark  = function(deckEl) { … }
  The plugin calls the appropriate function after each mermaid.run().

Separators
----------
  Horizontal slide    :  blank line + --- + blank line
  Vertical slide      :  blank line + ---- + blank line
  Speaker notes       :  Note: at start of line
  End of presentation :  blank line + ===== + blank line

Import mode
-----------
  revealjs:
    presentation: import
    path: ./slides.md

Reveal.js options
-----------------
Plugin-only (not forwarded to Reveal.initialize()):
  cdn, js, theme, highlight, mermaid

Compatible plugin API
---------------------
Any MkDocs plugin that transforms admonitions by injecting HTML markers
into their body can declare itself compatible with mkdocs-revealjs by
exposing a class-level attribute:

    class MyPlugin(BasePlugin):
        REVEALJS_MANAGED_ADMONITIONS = [
            re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
        ]

revealjs will detect this attribute at on_config time, collect all
declared patterns, and protect any admonition whose body matches one of
them — passing it through verbatim instead of re-rendering it via
Python-Markdown.

Additionally, any HTML comment of the form <!-- MKDOCS_PLUGIN_XXX_ -->
is protected by the built-in fallback convention, with no configuration
required.

Plugin ordering requirement:
  Compatible plugins MUST be declared BEFORE revealjs in mkdocs.yml so
  their on_page_markdown runs first and rewrites admonitions before
  revealjs stores page._revealjs_slides.
"""

import json
import logging
import os
import re
import yaml
import markdown as _markdown_lib
from mkdocs.plugins import BasePlugin
from mkdocs.config import config_options
from mkdocs.config.base import Config
from mkdocs.structure.files import File

log = logging.getLogger("mkdocs.plugins.revealjs")

CDN_DEFAULT  = "https://cdn.jsdelivr.net/npm/reveal.js@5.1.0"
HLJS_CDN     = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0"
MERMAID_CDN  = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"

# ---------------------------------------------------------------------------
# Custom Mermaid themes — auto-discovered from the themes/ folder
# ---------------------------------------------------------------------------
_THEMES_DIR    = os.path.join(os.path.dirname(__file__), "themes")
_BASE_CSS_PATH = os.path.join(os.path.dirname(__file__), "assets", "revealjs-base.css")

def _discover_mermaid_themes(themes_dir: str) -> dict:
    result = {}
    if not os.path.isdir(themes_dir):
        return result
    prefix = "mermaid-theme-"
    suffix = ".js"
    for fname in os.listdir(themes_dir):
        if fname.startswith(prefix) and fname.endswith(suffix):
            name = fname[len(prefix):-len(suffix)]
            if not name:
                continue
            camel = "".join(part.capitalize() for part in name.split("-"))
            js_var = f"_mkdocsReveal{camel}ThemeVars"
            result[name] = (os.path.join(themes_dir, fname), js_var)
    return result

_BUNDLED_MERMAID_THEMES = _discover_mermaid_themes(_THEMES_DIR)
_MERMAID_BUILTIN_THEMES = {"base", "default", "neutral", "forest", "dark"}

_LIGHT_BUILTIN = "white"
_DARK_BUILTIN  = "black"
_HLJS_LIGHT    = "atom-one-light"
_HLJS_DARK     = "atom-one-dark"
_MM_LIGHT      = "base"
_MM_DARK       = "dark"

_DIV_PLACEHOLDER = "MKDOCS_REVEALJS_DIV_7f3a9b2c"
_DEFAULT_HEIGHT  = 600

_PLUGIN_ONLY = {"cdn", "js", "theme", "highlight", "mermaid"}
_PAGE_ONLY   = _PLUGIN_ONLY | {"presentation", "path"}

_SEP_H    = r"^\n---\n$"
_SEP_V    = r"^\n----\n$"
_SEP_NOTE = r"^Note:"

_END_MARKER_RE = re.compile(r'\n\n={5,}\n\n', re.MULTILINE)

# CSS scope prefix used for hack auto-prefixing
_HACK_SCOPE_PREFIX = {
    "theme":     ".reveal",
    "highlight": ".reveal .hljs",
    "mermaid":   ".reveal .mermaid",
}

# ---------------------------------------------------------------------------
# Compatible plugin API — protected admonition marker detection
# ---------------------------------------------------------------------------
#
# Built-in fallback convention: any HTML comment <!-- MKDOCS_PLUGIN_XXX_ -->
# inside an admonition body is automatically protected without any config.
#
# Plugin-declared patterns: collected at on_config time from any active plugin
# that exposes REVEALJS_MANAGED_ADMONITIONS (list of compiled re.Pattern).
#
# The combined list is stored in _compatible_plugins (instance attribute
# on RevealjsPlugin, built in on_config).

def _collect_compatible_plugins(plugins_map: dict) -> list:
    """
    Scan all active MkDocs plugins for REVEALJS_MANAGED_ADMONITIONS.

    A compatible plugin declares:
        class MyPlugin(BasePlugin):
            REVEALJS_MANAGED_ADMONITIONS = [
                re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
            ]

    This signals two things to revealjs:
      1. In _preprocess_admonitions: don't re-render admonitions whose body
         matches one of these patterns — pass them verbatim so the external
         plugin's markers reach the final HTML.
      2. In on_page_content: after building the reveal <div>, call the
         external plugin's on_page_content on that HTML fragment so it can
         find its markers and inject the fully-rendered widget HTML.

    Returns a list of (plugin_name, plugin_instance, [patterns]) tuples,
    one entry per compatible plugin found.
    """
    result = []
    for name, plugin in plugins_map.items():
        declared = getattr(plugin, "REVEALJS_MANAGED_ADMONITIONS", None)
        if not declared or not isinstance(declared, (list, tuple)):
            continue
        patterns = [p for p in declared if hasattr(p, "search")]
        if patterns:
            result.append((name, plugin, patterns))
            log.debug(
                f"[revealjs] compatible plugin registered: '{name}' "
                f"with {len(patterns)} admonition pattern(s)"
            )
    return result


def _admonition_is_managed_externally(block: str,
                                      compatible_plugins: list) -> bool:
    """
    Return True if the admonition block has already been transformed by
    a compatible external plugin and must be passed through verbatim.
    """
    for _name, _plugin, patterns in compatible_plugins:
        for pattern in patterns:
            if pattern.search(block):
                return True
    return False


# ---------------------------------------------------------------------------
# SchemeDesc — normalised descriptor for one light-or-dark entry
# ---------------------------------------------------------------------------

class SchemeDesc:
    """Normalised, fully-resolved descriptor for a single light or dark entry."""
    __slots__ = ("starter", "hack", "custom", "custom_js")

    def __init__(self, starter=None, hack=None, custom=None, custom_js=None):
        self.starter   = starter
        self.hack      = hack or {}
        self.custom    = custom
        self.custom_js = custom_js

    def __repr__(self):
        return (f"SchemeDesc(starter={self.starter!r}, "
                f"hack_keys={list(self.hack.keys())}, "
                f"custom={self.custom!r}, custom_js={self.custom_js!r})")


def _coerce_scheme(value, allow_custom_js: bool = False) -> SchemeDesc:
    if value is None:
        return SchemeDesc()
    if isinstance(value, str):
        return SchemeDesc(starter=value.strip() or None)
    if not isinstance(value, dict):
        return SchemeDesc()

    starter = value.get("starter") or value.get("default") or None
    if isinstance(starter, str):
        starter = starter.strip() or None

    hack = value.get("hack") or {}
    if not isinstance(hack, dict):
        hack = {}

    custom = value.get("custom") or None
    if isinstance(custom, str):
        custom = custom.strip() or None

    custom_js = None
    if allow_custom_js:
        custom_js = value.get("custom_js") or None
        if isinstance(custom_js, str):
            custom_js = custom_js.strip() or None

    return SchemeDesc(starter=starter, hack=hack, custom=custom, custom_js=custom_js)


# ---------------------------------------------------------------------------
# _AnyVal — MkDocs config option that accepts any YAML value
# ---------------------------------------------------------------------------

class _AnyVal(config_options.BaseConfigOption):
    """Accept any YAML value (str, dict, bool, None) without type coercion."""
    def run_validation(self, value):
        return value


# ---------------------------------------------------------------------------
# Sub-config schemas
# ---------------------------------------------------------------------------

class ThemeConfig(Config):
    light = _AnyVal()
    dark  = _AnyVal()


class HighlightConfig(Config):
    light = _AnyVal()
    dark  = _AnyVal()


class MermaidConfig(Config):
    activate = config_options.Type(bool, default=False)
    light    = _AnyVal()
    dark     = _AnyVal()


# ---------------------------------------------------------------------------
# Main plugin config schema
# ---------------------------------------------------------------------------

O = config_options.Optional
T = config_options.Type


class RevealjsPluginConfig(Config):
    # ── Plugin-only ──────────────────────────────────────────────────────────
    cdn       = O(T(str))
    theme     = config_options.SubConfig(ThemeConfig)
    highlight = config_options.SubConfig(HighlightConfig)
    js        = O(T(str))
    mermaid   = config_options.SubConfig(MermaidConfig)

    # ── Reveal.js options forwarded verbatim to Reveal.initialize() ──────────
    controls             = O(T(bool))
    controlsTutorial     = O(T(bool))
    controlsLayout       = O(T(str))
    controlsBackArrows   = O(T(str))
    progress             = O(T(bool))
    slideNumber          = O(T(bool))
    showSlideNumber      = O(T(str))
    hash                 = O(T(bool))
    history              = O(T(bool))
    keyboard             = O(T(bool))
    overview             = O(T(bool))
    center               = O(T(bool))
    touch                = O(T(bool))
    loop                 = O(T(bool))
    rtl                  = O(T(bool))
    navigationMode       = O(T(str))
    shuffle              = O(T(bool))
    fragments            = O(T(bool))
    fragmentInURL        = O(T(bool))
    help                 = O(T(bool))
    pause                = O(T(bool))
    showNotes            = O(T(bool))
    autoPlayMedia        = O(T(bool))
    preloadIframes       = O(T(bool))
    autoAnimate          = O(T(bool))
    autoAnimateEasing    = O(T(str))
    autoAnimateDuration  = O(T(float))
    autoAnimateUnmatched = O(T(bool))
    autoSlide            = O(T(int))
    autoSlideStoppable   = O(T(bool))
    defaultTiming        = O(T(int))
    mouseWheel           = O(T(bool))
    previewLinks         = O(T(bool))
    postMessage          = O(T(bool))
    postMessageEvents    = O(T(bool))
    transition           = O(T(str))
    transitionSpeed      = O(T(str))
    backgroundTransition = O(T(str))
    hideInactiveCursor   = O(T(bool))
    hideCursorTime       = O(T(int))
    minScale             = O(T(float))
    maxScale             = O(T(float))

    def load_dict(self, data):
        """Coerce  mermaid: true/false  shorthand into nested dict."""
        if "mermaid" in data and isinstance(data["mermaid"], bool):
            data = dict(data)
            data["mermaid"] = {"activate": data["mermaid"]}
        super().load_dict(data)


# ---------------------------------------------------------------------------
# Resolution helpers
# ---------------------------------------------------------------------------

def _safe_get(cfg, key):
    if cfg is None:
        return None
    try:
        return cfg.get(key)
    except Exception:
        return None


def _resolve_scope(global_cfg, page_cfg: dict, scope: str,
                   default_light=None, default_dark=None,
                   allow_custom_js=False):
    g_light = _coerce_scheme(_safe_get(global_cfg, "light"), allow_custom_js)
    g_dark  = _coerce_scheme(_safe_get(global_cfg, "dark"),  allow_custom_js)

    if g_light.starter is None and default_light:
        g_light.starter = default_light
    if g_dark.starter is None and default_dark:
        g_dark.starter = default_dark

    page_scope = page_cfg.get(scope) if isinstance(page_cfg, dict) else None
    if not isinstance(page_scope, dict):
        return g_light, g_dark

    def _merge(raw, base: SchemeDesc) -> SchemeDesc:
        if raw is None:
            return base
        p = _coerce_scheme(raw, allow_custom_js)
        return SchemeDesc(
            starter   = p.starter   if p.starter   is not None else base.starter,
            hack      = p.hack      if p.hack                  else base.hack,
            custom    = p.custom    if p.custom    is not None else base.custom,
            custom_js = p.custom_js if p.custom_js is not None else base.custom_js,
        )

    light = _merge(page_scope.get("light"), g_light)
    dark  = _merge(page_scope.get("dark"),  g_dark)
    return light, dark


def _resolve_mermaid_activate(global_cfg, page_cfg: dict) -> bool:
    g = bool(_safe_get(global_cfg, "activate"))
    pm = page_cfg.get("mermaid") if isinstance(page_cfg, dict) else None
    if isinstance(pm, bool):
        return pm
    if isinstance(pm, dict) and "activate" in pm:
        return bool(pm["activate"])
    return g


# ---------------------------------------------------------------------------
# CSS hack renderer
# ---------------------------------------------------------------------------

def _render_hack_css(hack: dict, scope: str) -> str:
    if not hack:
        return ""
    prefix = _HACK_SCOPE_PREFIX.get(scope, ".reveal")
    rules  = []
    for selector, props in hack.items():
        if not isinstance(props, dict) or not props:
            continue
        if selector.startswith("&"):
            full_sel = prefix + selector[1:]
        else:
            full_sel = f"{prefix} {selector}"
        declarations = "; ".join(f"{p}: {v}" for p, v in props.items())
        rules.append(f"  {full_sel} {{ {declarations} }}")
    return "\n".join(rules)


# ---------------------------------------------------------------------------
# URL helpers
# ---------------------------------------------------------------------------

def _resolve_url(value: str, cdn_pattern: str, cdn_base: str = None) -> str:
    if not value:
        return ""
    if value.startswith("http") or value.startswith("/"):
        return value
    if cdn_base:
        return cdn_pattern.format(cdn=cdn_base, name=value)
    return cdn_pattern.format(name=value)


def _theme_url(cdn: str, starter, builtin: str) -> str:
    s = starter or builtin
    if s.startswith("http") or s.startswith("/"):
        return s
    return f"{cdn}/dist/theme/{s}.css"


def _load_bundled_css(path: str, id_: str = "") -> str:
    if not path or not os.path.isfile(path):
        log.debug(f"[revealjs] bundled CSS not found (skipping): {path!r}")
        return ""
    try:
        with open(path, encoding="utf-8") as fh:
            id_attr = f' id="{id_}"' if id_ else ""
            return f"<style{id_attr}>\n{fh.read()}\n</style>"
    except OSError:
        log.warning(f"[revealjs] could not read bundled CSS: {path!r}")
        return ""


def _load_bundled_script(path: str) -> str:
    if not path or not os.path.isfile(path):
        return ""
    try:
        with open(path, encoding="utf-8") as fh:
            return f"\n<script>\n{fh.read()}\n</script>"
    except OSError:
        log.warning(f"[revealjs] could not read bundled theme script: {path!r}")
        return ""


def _resolve_mermaid_theme_name(starter: str, default: str) -> tuple:
    name = (starter or default).strip()
    if name in _BUNDLED_MERMAID_THEMES:
        js_path, js_var = _BUNDLED_MERMAID_THEMES[name]
        return "base", js_path, js_var
    return name, None, None


def _hljs_url(starter) -> str:
    s = starter or _HLJS_LIGHT
    if s.startswith("http") or s.startswith("/"):
        return s
    return f"{HLJS_CDN}/styles/{s}.min.css"


# ---------------------------------------------------------------------------
# Markdown helpers
# ---------------------------------------------------------------------------

_FENCE_OPEN_RE = re.compile(r'^(`{3,}|~{3,})', re.MULTILINE)


def _fence_aware_split(text: str, separator: str):
    sep_stripped = separator.strip('\n')
    parts   = []
    current = []
    fence_char  = None
    fence_len   = 0

    for line in text.split('\n'):
        if fence_char is None:
            m = _FENCE_OPEN_RE.match(line)
            if m:
                fence_char = m.group(1)[0]
                fence_len  = len(m.group(1))
                current.append(line)
            elif line == sep_stripped:
                parts.append('\n'.join(current))
                current = []
            else:
                current.append(line)
        else:
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
            current.append(line)

    parts.append('\n'.join(current))
    return parts


def _split_at_end_marker(markdown: str):
    if not _END_MARKER_RE.search(markdown):
        return markdown, None

    parts = _fence_aware_split(markdown, "\n=====\n")
    if len(parts) == 1:
        return markdown, None
    slides = parts[0]
    tail   = '\n'.join(parts[1:]).lstrip('\n') if len(parts) > 1 else None
    if tail == '':
        tail = None
    return slides, tail


def _load_import_file(host_src_path: str, rel_path: str):
    host_dir  = os.path.dirname(os.path.abspath(host_src_path))
    abs_path  = os.path.normpath(os.path.join(host_dir, rel_path))
    if not os.path.isfile(abs_path):
        raise FileNotFoundError(
            f"[revealjs] import path not found: {abs_path!r} "
            f"(referenced from {host_src_path!r})"
        )
    with open(abs_path, encoding="utf-8") as fh:
        raw = fh.read()
    fm_re    = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
    fm_match = fm_re.match(raw)
    if not fm_match:
        raise ValueError(
            f"[revealjs] import file has no YAML front-matter: {abs_path!r}"
        )
    try:
        meta = yaml.safe_load(fm_match.group(1)) or {}
    except yaml.YAMLError as exc:
        raise ValueError(f"[revealjs] YAML parse error in {abs_path!r}: {exc}")
    revealjs     = meta.get("revealjs", {})
    if not isinstance(revealjs, dict):
        revealjs = {}
    import_title = meta.get("title") or None
    body         = raw[fm_match.end():]
    return body, revealjs, import_title


# ---------------------------------------------------------------------------
# Auto-animate markdown pre-processor
# ---------------------------------------------------------------------------

_FENCE_RE = re.compile(
    r'^```'
    r'(\w*)'
    r'(?:\s*\{[^}]*data-id=["\']([^"\']+)["\'][^}]*\})?'
    r'(?:\s*\[([^\]]*)\])?'
    r'\s*$',
    re.MULTILINE,
)


def _slide_has_auto_animate(slide_text: str) -> bool:
    return 'data-auto-animate' in slide_text


def _neutralise_separators_in_fences(markdown: str) -> str:
    _SEP_LINE_RE = re.compile(r'^(-{3,}|={5,})$')

    lines      = markdown.split('\n')
    out        = []
    fence_char = None
    fence_len  = 0
    i          = 0

    while i < len(lines):
        line = lines[i]

        if fence_char is None:
            m = _FENCE_OPEN_RE.match(line)
            if m:
                fence_char = m.group(1)[0]
                fence_len  = len(m.group(1))
            out.append(line)
            i += 1
        else:
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
                out.append(line)
                i += 1
            else:
                if (line == ''
                        and i + 2 < len(lines)
                        and _SEP_LINE_RE.match(lines[i + 1])
                        and lines[i + 2] == ''):
                    out.append(lines[i + 1])
                    i += 3
                else:
                    out.append(line)
                    i += 1

    return '\n'.join(out)


def _preprocess_auto_animate(markdown: str) -> str:
    markdown = _neutralise_separators_in_fences(markdown)
    slides = _fence_aware_split(markdown, "\n---\n")

    result = []
    for i, slide in enumerate(slides):
        if _slide_has_auto_animate(slide):
            result.append(_rewrite_fenced_blocks(slide))
        else:
            result.append(slide)
        if i < len(slides) - 1:
            result.append('\n---\n')
    return ''.join(result)


def _rewrite_fenced_blocks(slide: str) -> str:
    lines = slide.split('\n')
    out   = []
    i     = 0
    block_index = 0
    while i < len(lines):
        m = _FENCE_RE.match(lines[i])
        if m:
            lang        = m.group(1) or ''
            data_id_val = m.group(2)
            line_spec   = m.group(3)
            i += 1
            body_lines = []
            while i < len(lines) and lines[i].strip() != '```':
                body_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            if not data_id_val:
                data_id_val = f'revealjs-code-{block_index}'
            block_index += 1
            ln_attr   = (f' data-line-numbers="{line_spec.strip()}"'
                         if line_spec is not None else ' data-line-numbers')
            lang_attr = f' class="language-{lang}"' if lang else ''
            body      = '\n'.join(body_lines)
            out.append(
                f'<pre data-id="{data_id_val}">'
                f'<code data-trim{ln_attr}{lang_attr}>'
                f'{body}'
                f'</code></pre>'
            )
        else:
            out.append(lines[i])
            i += 1
    return '\n'.join(out)


# ---------------------------------------------------------------------------
# Admonition pre-processor
# ---------------------------------------------------------------------------

_ADMON_HEADER_RE  = re.compile(r'^(!{3}|\?{3}\+?)[ \t]+\S+')
_ADMON_FENCE_RE   = re.compile(r'^(`{3,}|~{3,})')
_EMOJI_TOKEN_RE   = re.compile(r':[a-zA-Z0-9_+\-]+:')
_EMOJI_P_WRAP_RE  = re.compile(r'^<p>(.*?)</p>$', re.DOTALL)


def _build_emoji_substitutor(md_extensions: list, md_configs: dict):
    _EMOJI_NAMES = {'pymdownx.emoji', 'material.extensions.emoji', 'materialx.emoji'}

    emoji_ext_name = None
    for ext in (md_extensions or []):
        name = ext if isinstance(ext, str) else getattr(ext, 'extPath', None) or str(ext)
        if any(e in str(name) for e in _EMOJI_NAMES):
            emoji_ext_name = name
            break
    if not emoji_ext_name:
        return None

    ext_configs = {}
    for k, v in (md_configs or {}).items():
        if any(e in k for e in _EMOJI_NAMES):
            ext_configs[k] = v

    try:
        md = _markdown_lib.Markdown(
            extensions=[emoji_ext_name],
            extension_configs=ext_configs,
        )
    except Exception as exc:
        log.debug(f"[revealjs] emoji extension failed to load ({exc}), skipping")
        return None

    _cache: dict = {}

    def _render_token(token: str) -> str:
        if token in _cache:
            return _cache[token]
        md.reset()
        rendered = md.convert(token)
        m = _EMOJI_P_WRAP_RE.match(rendered.strip())
        result = m.group(1) if m else rendered
        _cache[token] = result
        return result

    def subst(text: str) -> str:
        if ':' not in text:
            return text
        return _EMOJI_TOKEN_RE.sub(lambda m: _render_token(m.group(0)), text)

    log.debug(f"[revealjs] emoji substitutor built from {emoji_ext_name!r}")
    return subst


def _extract_admonition_block(lines: list, start: int):
    i = start + 1
    while i < len(lines):
        l = lines[i]
        if l == '' or l.startswith('    ') or l.startswith('\t'):
            i += 1
        else:
            break
    end = i
    while end > start + 1 and lines[end - 1].strip() == '':
        end -= 1
    return end, '\n'.join(lines[start:end])


def _fix_admonition_title_spans(html: str) -> str:
    """
    Repair admonition-title paragraphs broken by Python-Markdown or
    Reveal.js's browser-side markdown parser.

    TWO problems are fixed here:

    Problem 1 — Python-Markdown splits the <p> at blank lines:
        BROKEN:  <p class="admonition-title">Title</p>
                 <p><span class="mkq-lock-indicator">✅</span></p>
                 <p></p>
        Fix: re-merge the span back inside the title <p>.

    Problem 2 — Reveal.js re-parses the <textarea> content as markdown.
        Even after fix 1, a blank line INSIDE the title <p> causes
        Reveal.js's own markdown parser (browser-side) to split the
        paragraph again.  Fix: remove blank lines inside the title <p>.

    Both fixes are applied here, after compatible-plugin delegation in
    on_page_content.
    """
    import re as _re

    # Step 1: repair Python-Markdown breakage (span ejected into separate <p>)
    html = _re.sub(
        r'(<p\b[^>]*class="admonition-title"[^>]*>)(.*?)</p>'
        r'\s*<p>((?:\s*<span\b[\s\S]*?</span>\s*)+)</p>'
        r'(\s*<p>\s*</p>)?',
        lambda m: m.group(1) + m.group(2) + m.group(3) + '</p>',
        html,
        flags=_re.DOTALL | _re.IGNORECASE,
    )

    # Step 2: remove blank lines inside admonition-title <p> so Reveal.js's
    # browser-side markdown parser does not re-split the paragraph.
    def _compact_title(m):
        inner = m.group(2)
        lines = inner.split('\n')
        compacted = '\n'.join(line.rstrip() for line in lines if line.strip())
        return m.group(1) + '\n' + compacted + '\n</p>'

    html = _re.sub(
        r'(<p\b[^>]*class="admonition-title"[^>]*>)([\s\S]*?)</p>',
        _compact_title,
        html,
        flags=_re.IGNORECASE,
    )
    return html



def _preprocess_admonitions(slides_md: str,
                             md_extensions: list,
                             md_configs: dict,
                             compatible_plugins: list = None) -> str:
    """
    Walk through slides_md line by line.

    - Inside fenced code blocks (``` / ~~~): every line is emitted verbatim.
    - Outside fences:
        • If an admonition body matches a pattern from a compatible plugin
          (declared via REVEALJS_MANAGED_ADMONITIONS), the entire block is
          emitted verbatim — the responsible plugin will process it later via
          a direct on_page_content call on the rendered slide HTML fragment.
        • Other admonition headers trigger block extraction + HTML rendering
          via Python-Markdown (admonition extension).
        • :emoji: tokens on every non-fenced line are substituted with their
          rendered HTML inline if pymdownx.emoji is configured.
        • Everything else is emitted verbatim.

    Parameters
    ----------
    slides_md          : raw markdown content of the slides
    md_extensions      : list of MkDocs markdown_extensions
    md_configs         : dict of MkDocs mdx_configs
    compatible_plugins : list of (name, plugin, patterns) tuples collected
                         by _collect_compatible_plugins at on_config
    """
    if compatible_plugins is None:
        compatible_plugins = []

    # ── Build a minimal Markdown instance for admonitions ───────────────────
    extensions = ['admonition']
    for ext in (md_extensions or []):
        name = ext if isinstance(ext, str) else getattr(ext, 'name', None)
        if name and 'detail' in name and name not in extensions:
            extensions.append(name)
    try:
        md = _markdown_lib.Markdown(
            extensions=extensions,
            extension_configs={
                k: v for k, v in (md_configs or {}).items()
                if any(k in str(e) for e in extensions)
            },
        )
    except Exception:
        md = _markdown_lib.Markdown(extensions=['admonition'])

    emoji_subst = _build_emoji_substitutor(md_extensions, md_configs)

    lines      = slides_md.split('\n')
    out        = []
    fence_char = None
    fence_len  = 0
    i          = 0

    while i < len(lines):
        line = lines[i]

        # ── inside a fenced code block: emit verbatim (no emoji sub) ───────
        if fence_char is not None:
            out.append(line)
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
            i += 1
            continue

        # ── fence opening ───────────────────────────────────────────────────
        m_fence = _ADMON_FENCE_RE.match(line)
        if m_fence:
            fence_char = m_fence.group(1)[0]
            fence_len  = len(m_fence.group(1))
            out.append(line)
            i += 1
            continue

        # ── admonition header ───────────────────────────────────────────────
        if _ADMON_HEADER_RE.match(line):
            end, block = _extract_admonition_block(lines, i)

            # ── Compatible plugin: render admonition then delegate ────────────────
            # If a compatible plugin owns this admonition (detected via
            # REVEALJS_MANAGED_ADMONITIONS patterns), render it via standard
            # Python-Markdown so the HTML structure (div.admonition + MQS
            # markers) ends up in the slide HTML. revealjs then calls the
            # plugin's on_page_content directly on the slide div fragment
            # in on_page_content, so it can inject the final widget HTML.
            if _admonition_is_managed_externally(block, compatible_plugins):
                log.debug(
                    f"[revealjs] admonition at line {i} owned by compatible "
                    f"plugin — rendering via standard admonition"
                )
                if emoji_subst:
                    block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
                md.reset()
                html_block = md.convert(block)
                # Repair spans ejected from admonition-title by Python-Markdown
                # when a blank line separates the title text from the span.
                html_block = _fix_admonition_title_spans(html_block)
                out.append('')
                out.append(html_block)
                out.append('')
                i = end
                continue
            # ── end compatible plugin ───────────────────────────────────────────────────────

            # Standard path: render admonition via Python-Markdown
            if emoji_subst:
                block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
            md.reset()
            html = md.convert(block)
            # Blank lines ensure Reveal.js markdown plugin treats it as raw HTML
            out.append('')
            out.append(html)
            out.append('')
            i = end
            continue

        # ── normal line: apply emoji substitution then emit ─────────────────
        out.append(emoji_subst(line) if emoji_subst else line)
        i += 1

    return '\n'.join(out)


# ---------------------------------------------------------------------------
# Custom URL resolver
# ---------------------------------------------------------------------------

def _resolve_custom_url(value: str, page, config) -> str:
    if not value:
        return value
    if value.startswith("http://") or value.startswith("https://") or value.startswith("/"):
        return value
    page_url = (page.url or "").rstrip("/")
    depth    = page_url.count("/") + (1 if page_url else 0)
    prefix   = "../" * depth
    return prefix + value


# ---------------------------------------------------------------------------
# Plugin
# ---------------------------------------------------------------------------

_H1_RE = re.compile(r'^(#[^#][^\n]*)\n?', re.MULTILINE)


class RevealjsPlugin(BasePlugin[RevealjsPluginConfig]):
    """
    MkDocs plugin that embeds Reveal.js presentations in MkDocs pages.

    Compatible plugin API
    ---------------------
    Any MkDocs plugin that transforms admonitions can declare itself
    compatible by adding a class-level attribute:

        class MyPlugin(BasePlugin):
            REVEALJS_MANAGED_ADMONITIONS = [
                re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
            ]

    revealjs collects these patterns at on_config time and uses them in
    _preprocess_admonitions to skip re-rendering those admonitions.

    Additionally, any HTML comment matching <!-- MKDOCS_PLUGIN_XXX_ --> is
    protected automatically by built-in convention, without any declaration.

    Plugin ordering requirement
    ---------------------------
    Compatible plugins MUST appear BEFORE revealjs in mkdocs.yml plugins:
    so their on_page_markdown runs first and rewrites admonitions before
    revealjs stores page._revealjs_slides.
    """

    _cdn_base:               str  = CDN_DEFAULT
    _global_opts:            dict = {}
    _compatible_plugins:     list = []   # collected at on_config

    def on_config(self, config):
        cdn = self.config.get("cdn")
        self._cdn_base    = cdn.rstrip("/") if cdn else CDN_DEFAULT
        self._global_opts = {
            k: v for k, v in self.config.items()
            if k not in _PLUGIN_ONLY and v is not None
        }

        # ── Collect compatible plugins once per build ───────────────────────
        plugins_map = config.get("plugins", {})
        self._compatible_plugins = _collect_compatible_plugins(plugins_map)
        if self._compatible_plugins:
            names = ', '.join(n for n, _, _ in self._compatible_plugins)
            log.info(f"[revealjs] compatible plugins detected: {names}")

        # Cache custom_dir so on_files can register custom CSS/JS files
        theme_cfg   = config.get("theme") or {}
        custom_dir  = getattr(theme_cfg, "custom_dir", None)
        self._custom_dir    = custom_dir or None
        self._mkdocs_config = config
        return config

    # ---------------------------------------------------------------- files

    def on_files(self, files, config):
        if not self._custom_dir or not os.path.isdir(self._custom_dir):
            return files

        custom_paths = []
        for scope in ("theme", "highlight", "mermaid"):
            scope_cfg = self.config.get(scope)
            for side in ("light", "dark"):
                val = _safe_get(scope_cfg, side)
                desc = _coerce_scheme(val, allow_custom_js=(scope == "mermaid"))
                if desc.custom:
                    custom_paths.append(desc.custom)
                if desc.custom_js:
                    custom_paths.append(desc.custom_js)
        js_path = self.config.get("js")
        if js_path:
            custom_paths.append(js_path)

        for path in custom_paths:
            if not path or path.startswith("http") or path.startswith("/"):
                continue
            abs_path = os.path.join(self._custom_dir, path)
            if not os.path.isfile(abs_path):
                abs_path = os.path.join(os.path.dirname(
                    config.get("config_file_path", "")), path)
                if not os.path.isfile(abs_path):
                    continue
            f = File(
                path       = path,
                src_dir    = self._custom_dir,
                dest_dir   = config["site_dir"],
                use_directory_urls = config.get("use_directory_urls", True),
            )
            if not files.get_file_from_path(path):
                files.append(f)
                log.debug(f"[revealjs] registered custom file: {path!r}")

        return files

    # ---------------------------------------------------------------- markdown

    def on_page_markdown(self, markdown, page, config, files):
        meta     = page.meta or {}
        revealjs = meta.get("revealjs", {})
        if not isinstance(revealjs, dict):
            return markdown
        presentation = revealjs.get("presentation", False)

        # ── import mode ──────────────────────────────────────────────────────
        if presentation == "import":
            rel_path = revealjs.get("path")
            if not rel_path:
                log.error(f"[revealjs] 'presentation: import' requires 'path:' "
                          f"in {page.file.src_path}")
                return markdown
            try:
                import_body, import_cfg, import_title = _load_import_file(
                    page.file.abs_src_path, rel_path)
            except (FileNotFoundError, ValueError) as exc:
                log.error(str(exc))
                return markdown

            host_tail  = markdown.strip() or None
            host_title = meta.get("title")
            h1_line    = None
            tail_body  = host_tail

            if host_tail:
                m = _H1_RE.search(host_tail)
                if m:
                    h1_line   = m.group(1).strip()
                    tail_body = (host_tail[:m.start()] + host_tail[m.end():]).strip() or None
                elif not host_title and import_title:
                    h1_line   = f"# {import_title}"
            elif not host_title and import_title:
                h1_line   = f"# {import_title}"
                tail_body = None

            page._revealjs_slides = _preprocess_auto_animate(import_body.strip())
            page._revealjs_config = import_cfg
            page._revealjs_tail   = tail_body
            return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

        # ── inline mode ──────────────────────────────────────────────────────
        if not presentation:
            return markdown

        slides_part, tail_part = _split_at_end_marker(markdown)
        page._revealjs_slides = _preprocess_auto_animate(slides_part.strip())
        page._revealjs_config = revealjs

        if tail_part is None:
            page._revealjs_tail = None
            return _DIV_PLACEHOLDER

        tail_raw  = tail_part.strip()
        fm_title  = revealjs.get("title") or meta.get("title")
        h1_line   = None
        tail_body = tail_raw or None

        if tail_raw:
            m = _H1_RE.search(tail_raw)
            if m:
                h1_line   = m.group(1).strip()
                tail_body = (tail_raw[:m.start()] + tail_raw[m.end():]).strip() or None
            elif fm_title:
                h1_line   = f"# {fm_title}"
        elif fm_title:
            h1_line   = f"# {fm_title}"
            tail_body = None

        page._revealjs_tail = tail_body
        return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

    # ---------------------------------------------------------------- content

    def on_page_content(self, html, page, config, files):
        if not hasattr(page, "_revealjs_slides"):
            return html
        placeholder_p   = f"<p>{_DIV_PLACEHOLDER}</p>"
        placeholder_raw = _DIV_PLACEHOLDER
        if placeholder_p not in html and placeholder_raw not in html:
            log.error(f"[revealjs] placeholder not found in {page.file.src_path}")
            return html
        revealjs = page._revealjs_config
        height   = revealjs.get("height", self._global_opts.get("height", _DEFAULT_HEIGHT))

        md_extensions = config.get("markdown_extensions") or []
        md_configs    = config.get("mdx_configs") or {}

        # _preprocess_admonitions renders compatible-plugin admonitions via
        # standard Python-Markdown (so markers appear in HTML), then the
        # delegation loop below calls each plugin's on_page_content.
        slides = _preprocess_admonitions(
            page._revealjs_slides,
            md_extensions,
            md_configs,
            compatible_plugins=self._compatible_plugins,
        )

        div_html = self._render_div(slides, height)

        # ── Delegate to compatible plugins ───────────────────────────────────
        # Each compatible plugin's on_page_content is called on the slide div
        # HTML fragment. The plugin finds its markers inside the <textarea>,
        # extracts them, renders its widget, and returns the modified fragment.
        # We pass a synthetic html argument that contains ONLY the div_html so
        # the plugin operates exclusively on the slides content and not on the
        # rest of the page (which has not yet been assembled at this point).
        for _name, ext_plugin, _patterns in self._compatible_plugins:
            if not hasattr(ext_plugin, "on_page_content"):
                continue
            if not hasattr(page, "_mkdocs_quiz_data"):
                # Compatible plugin has no data for this page — skip
                continue
            try:
                result = ext_plugin.on_page_content(div_html, page=page,
                                                     config=config, files=files)
                if result is not None:
                    div_html = result
                    log.debug(f"[revealjs] on_page_content delegated to '{_name}'")
            except Exception as exc:
                log.warning(f"[revealjs] on_page_content delegation to "
                            f"'{_name}' failed: {exc}")
        # ── end delegation ───────────────────────────────────────────────────

        # After delegation, compatible plugins may have produced broken
        # admonition-title paragraphs: Python-Markdown splits the <p> at
        # blank lines, ejecting injected <span>s (e.g. mkq-lock-indicator)
        # into a separate <p>.  Fix that here, after all plugins have run.
        if self._compatible_plugins:
            div_html = _fix_admonition_title_spans(div_html)

        if placeholder_p in html:
            return html.replace(placeholder_p, div_html, 1)
        return html.replace(placeholder_raw, div_html, 1)

    # ---------------------------------------------------------------- post page

    def on_post_page(self, output, page, config):
        if not hasattr(page, "_revealjs_slides"):
            return output
        revealjs = page._revealjs_config
        page_cdn = revealjs.get("cdn")
        cdn      = page_cdn.rstrip("/") if page_cdn else self._cdn_base
        extra_js = revealjs.get("js") or self.config.get("js")

        theme_l, theme_d = _resolve_scope(
            self.config.get("theme"), revealjs, "theme")
        hl_l, hl_d = _resolve_scope(
            self.config.get("highlight"), revealjs, "highlight",
            default_light=_HLJS_LIGHT, default_dark=_HLJS_DARK)
        mm_l, mm_d = _resolve_scope(
            self.config.get("mermaid"), revealjs, "mermaid",
            default_light=_MM_LIGHT, default_dark=_MM_DARK,
            allow_custom_js=True)
        mm_activate = _resolve_mermaid_activate(self.config.get("mermaid"), revealjs)

        page_opts   = {k: v for k, v in revealjs.items() if k not in _PAGE_ONLY}
        merged_opts = {**self._global_opts, **page_opts}
        height      = merged_opts.pop("height", _DEFAULT_HEIGHT)
        js_options  = self._build_js_options(merged_opts)

        def _rc(v): return _resolve_custom_url(v, page, config)
        if theme_l.custom: theme_l.custom = _rc(theme_l.custom)
        if theme_d.custom: theme_d.custom = _rc(theme_d.custom)
        if hl_l.custom:    hl_l.custom    = _rc(hl_l.custom)
        if hl_d.custom:    hl_d.custom    = _rc(hl_d.custom)
        if mm_l.custom:    mm_l.custom    = _rc(mm_l.custom)
        if mm_d.custom:    mm_d.custom    = _rc(mm_d.custom)
        if mm_l.custom_js: mm_l.custom_js = _rc(mm_l.custom_js)
        if mm_d.custom_js: mm_d.custom_js = _rc(mm_d.custom_js)
        if extra_js:       extra_js       = _rc(extra_js)

        assets = self._render_assets(
            cdn=cdn,
            theme_l=theme_l, theme_d=theme_d,
            hl_l=hl_l, hl_d=hl_d,
            mm_l=mm_l, mm_d=mm_d, mm_activate=mm_activate,
            js_opts=js_options, extra_js=extra_js, height=height,
        )
        if "</body>" in output:
            return output.replace("</body>", assets + "\n</body>", 1)
        return output + assets

    # ---------------------------------------------------------------- helpers

    @staticmethod
    def _render_div(slides: str, height: int) -> str:
        return (
            f'<div class="mkdocs-revealjs-wrapper" '
            f'style="width:100%;height:{height}px;margin:1.5rem 0;">\n'
            f'  <div class="reveal" id="mkdocs-revealjs" '
            f'style="width:100%;height:{height}px;">\n'
            f'    <div class="slides">\n'
            f'      <section\n'
            f'        data-markdown\n'
            f'        data-separator="{_SEP_H}"\n'
            f'        data-separator-vertical="{_SEP_V}"\n'
            f'        data-separator-notes="{_SEP_NOTE}"\n'
            f'      >\n'
            f'        <textarea data-template>\n'
            f'{slides}\n'
            f'        </textarea>\n'
            f'      </section>\n'
            f'    </div>\n'
            f'  </div>\n'
            f'</div>'
        )

    @staticmethod
    def _build_js_options(opts: dict) -> str:
        parts = []
        for key, value in opts.items():
            if isinstance(value, bool):
                parts.append(f"      {key}: {'true' if value else 'false'}")
            elif isinstance(value, (int, float)):
                parts.append(f"      {key}: {value}")
            else:
                parts.append(
                    f"      {key}: '{str(value).replace(chr(39), chr(92)+chr(39))}'")
        return ",\n".join(parts)

    def _render_assets(self, cdn,
                       theme_l, theme_d,
                       hl_l, hl_d,
                       mm_l, mm_d, mm_activate,
                       js_opts, extra_js, height) -> str:

        rv_light_url = _theme_url(cdn, theme_l.starter, _LIGHT_BUILTIN)
        rv_dark_url  = _theme_url(cdn, theme_d.starter, _DARK_BUILTIN)
        hl_light_url = _hljs_url(hl_l.starter or _HLJS_LIGHT)
        hl_dark_url  = _hljs_url(hl_d.starter or _HLJS_DARK)

        rv_hack_l = _render_hack_css(theme_l.hack, "theme")
        rv_hack_d = _render_hack_css(theme_d.hack, "theme")
        hl_hack_l = _render_hack_css(hl_l.hack, "highlight")
        hl_hack_d = _render_hack_css(hl_d.hack, "highlight")
        mm_hack_l = _render_hack_css(mm_l.hack, "mermaid") if mm_activate else ""
        mm_hack_d = _render_hack_css(mm_d.hack, "mermaid") if mm_activate else ""

        def link(href, id_, disabled=False):
            d = " disabled" if disabled else ""
            return f'<link rel="stylesheet" href="{href}" id="{id_}"{d}>'

        def opt_link(href, id_, disabled=False):
            return ("\n" + link(href, id_, disabled)) if href else ""

        def style_block(css, id_):
            if not css:
                return ""
            return f'\n<style id="{id_}">\n{css}\n</style>'

        def script(src):
            return f'\n<script src="{src}"></script>' if src else ""

        swap_pairs = [
            ["revealjs-theme-light",    "revealjs-theme-dark"],
            ["revealjs-hljs-light",     "revealjs-hljs-dark"],
            ["revealjs-hack-rv-light",  "revealjs-hack-rv-dark"],
            ["revealjs-hack-hl-light",  "revealjs-hack-hl-dark"],
        ]
        if theme_l.custom or theme_d.custom:
            swap_pairs.append(["revealjs-custom-rv-light", "revealjs-custom-rv-dark"])
        if hl_l.custom or hl_d.custom:
            swap_pairs.append(["revealjs-custom-hl-light", "revealjs-custom-hl-dark"])
        if mm_activate:
            swap_pairs.append(["revealjs-hack-mm-light", "revealjs-hack-mm-dark"])
            if mm_l.custom or mm_d.custom:
                swap_pairs.append(["revealjs-custom-mm-light", "revealjs-custom-mm-dark"])
        swap_pairs_js = json.dumps(swap_pairs)

        base_opts     = (f"      embedded: true,\n"
                         f"      width: '100%',\n"
                         f"      height: {height},\n"
                         f"      margin: 0")
        options_block = base_opts + (",\n" + js_opts if js_opts else "")

        if mm_activate:
            mm_light_theme, mm_light_reg, mm_light_var = _resolve_mermaid_theme_name(mm_l.starter, _MM_LIGHT)
            mm_dark_theme,  mm_dark_reg,  mm_dark_var  = _resolve_mermaid_theme_name(mm_d.starter, _MM_DARK)

            if mm_light_var:
                mm_light_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_light_var}"] || {{}} }}'
            else:
                mm_light_init = f'{{ startOnLoad: false, theme: "{mm_light_theme}" }}'

            if mm_dark_var:
                mm_dark_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_dark_var}"] || {{}} }}'
            else:
                mm_dark_init = f'{{ startOnLoad: false, theme: "{mm_dark_theme}" }}'

            mermaid_js = f"""
  // ── Mermaid ─────────────────────────────────────────────────────────────
  function _rvIsDark() {{
    return (document.body.getAttribute("data-md-color-scheme") || "default") === "slate";
  }}
  function _rvMermaidConfig(dark) {{
    return dark ? {mm_dark_init} : {mm_light_init};
  }}
  function _rvStoreMermaidSrc() {{
    document.querySelectorAll(".mermaid:not([data-src])").forEach(function(el) {{
      el.dataset.src = el.textContent;
    }});
  }}
  function _rvCallMermaidPatch(dark) {{
    var fn = dark
      ? window._mkdocsRevealjsMermaidPatchDark
      : window._mkdocsRevealjsMermaidPatchLight;
    if (typeof fn === "function") {{
      var deckEl = document.getElementById("mkdocs-revealjs");
      try {{ fn(deckEl); }} catch(e) {{
        console.warn("[revealjs] mermaid patch error:", e);
      }}
    }}
  }}
  function _rvRenderMermaid(dark) {{
    mermaid.initialize(_rvMermaidConfig(dark));
    document.querySelectorAll(".mermaid[data-processed]").forEach(function(el) {{
      el.removeAttribute("data-processed");
      if (el.dataset.src) {{ el.textContent = el.dataset.src; }}
    }});
    mermaid.run({{ querySelector: ".mermaid" }});
    _rvCallMermaidPatch(dark);
  }}
"""
            mermaid_init = """
    _rvStoreMermaidSrc();
    _rvRenderMermaid(_rvIsDark());
    deck.on("slidechanged", function() { _rvRenderMermaid(_rvIsDark()); });
"""
            mermaid_theme_swap = "    _rvRenderMermaid(isDark);\n"
        else:
            mermaid_js         = ""
            mermaid_init       = ""
            mermaid_theme_swap = ""
            mm_light_reg       = None
            mm_dark_reg        = None
            mm_light_var       = None
            mm_dark_var        = None

        return f"""
<!-- mkdocs-revealjs ───────────────────────────────────────── -->

<!-- Plugin base corrections (always loaded, before any theme) -->
{_load_bundled_css(_BASE_CSS_PATH, "revealjs-base")}

<link rel="stylesheet" href="{cdn}/dist/reveal.css">

<!-- Reveal.js theme: starter (light / dark) -->
{link(rv_light_url, "revealjs-theme-light")}
{link(rv_dark_url,  "revealjs-theme-dark",  disabled=True)}

<!-- Reveal.js theme: hack overrides (light / dark) -->{style_block(rv_hack_l, "revealjs-hack-rv-light")}{style_block(rv_hack_d, "revealjs-hack-rv-dark")}

<!-- Reveal.js theme: custom CSS (light / dark) -->{opt_link(theme_l.custom, "revealjs-custom-rv-light")}{opt_link(theme_d.custom, "revealjs-custom-rv-dark", disabled=True)}

<!-- Highlight.js theme: starter (light / dark) -->
{link(hl_light_url, "revealjs-hljs-light")}
{link(hl_dark_url,  "revealjs-hljs-dark",  disabled=True)}

<!-- Highlight.js theme: hack overrides (light / dark) -->{style_block(hl_hack_l, "revealjs-hack-hl-light")}{style_block(hl_hack_d, "revealjs-hack-hl-dark")}

<!-- Highlight.js theme: custom CSS (light / dark) -->{opt_link(hl_l.custom, "revealjs-custom-hl-light")}{opt_link(hl_d.custom, "revealjs-custom-hl-dark", disabled=True)}

<!-- Mermaid theme: hack overrides (light / dark) -->{style_block(mm_hack_l, "revealjs-hack-mm-light") if mm_activate else ""}{style_block(mm_hack_d, "revealjs-hack-mm-dark") if mm_activate else ""}

<!-- Mermaid theme: custom CSS (light / dark) -->{opt_link(mm_l.custom, "revealjs-custom-mm-light") if mm_activate else ""}{opt_link(mm_d.custom, "revealjs-custom-mm-dark", disabled=True) if mm_activate else ""}

<script src="{cdn}/dist/reveal.js"></script>
<script src="{cdn}/plugin/markdown/markdown.js"></script>
<script src="{cdn}/plugin/highlight/highlight.js"></script>
<script src="{cdn}/plugin/notes/notes.js"></script>
<script src="{cdn}/plugin/math/math.js"></script>
{"<script src=" + repr(MERMAID_CDN) + "></script>" if mm_activate else ""}
{_load_bundled_script(mm_light_reg) if mm_activate and mm_light_reg else ""}
{_load_bundled_script(mm_dark_reg) if mm_activate and mm_dark_reg and mm_dark_reg != mm_light_reg else ""}
{script(mm_l.custom_js) if mm_activate and mm_l.custom_js else ""}
{script(mm_d.custom_js) if mm_activate and mm_d.custom_js and mm_d.custom_js != mm_l.custom_js else ""}
{script(extra_js) if extra_js else ""}
<script>
(function () {{
{mermaid_js}
  // ── Theme switcher ───────────────────────────────────────────────────────
  var _SWAP_PAIRS = {swap_pairs_js};

  function applyTheme(scheme) {{
    var isDark = (scheme === "slate");
    _SWAP_PAIRS.forEach(function(pair) {{
      var light = document.getElementById(pair[0]);
      var dark  = document.getElementById(pair[1]);
      if (light) light.disabled = isDark;
      if (dark)  dark.disabled  = !isDark;
    }});
{mermaid_theme_swap}  }}

  applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");

  new MutationObserver(function(mutations) {{
    mutations.forEach(function(m) {{
      if (m.attributeName === "data-md-color-scheme") {{
        applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");
      }}
    }});
  }}).observe(document.body, {{ attributes: true }});

  // ── Reveal.js init ──────────────────────────────────────────────────────
  function initReveal() {{
    var el = document.getElementById("mkdocs-revealjs");
    if (!el) return;
    var deck = new Reveal(el, {{
{options_block},
      highlight: {{
        highlightOnLoad: true
      }},
      plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealMath]
    }});
    deck.initialize().then(function() {{
      deck.layout();
{mermaid_init}    }});
    window._mkdocsRevealDeck = deck;
  }}

  if (document.readyState === "loading") {{
    document.addEventListener("DOMContentLoaded", initReveal);
  }} else {{
    initReveal();
  }}
}})();
</script>
<!-- ─────────────────────────────────────── mkdocs-revealjs -->"""
