# """
# 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
# from mkdocs.plugins import BasePlugin
# from mkdocs.config import config_options
# from mkdocs.config.base import Config

# 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 bundled with the plugin
# # ---------------------------------------------------------------------------
# # Maps theme name → (js_file_path, js_global_var_name)
# #
# # How bundled themes work (using the only public Mermaid CDN API):
# #   1. The JS file is inlined into the page and sets a global variable, e.g.:
# #        window._mkdocsRevealSkyThemeVars = { primaryColor: '...', ... }
# #   2. When mermaid.initialize() is called, the plugin uses:
# #        { theme: 'base', themeVariables: window._mkdocsRevealSkyThemeVars }
# #      'base' is Mermaid's fully-customizable foundation theme.
# #
# # To add a new bundled theme:
# #   1. Create  mkdocs_revealjs/themes/mermaid-theme-NAME.js  that sets
# #        window._mkdocsRevealNAMEThemeVars = { ... }
# #   2. Add one line here:
# #        "NAME": (os.path.join(_THEMES_DIR, "mermaid-theme-NAME.js"), "_mkdocsRevealNAMEThemeVars"),
# _THEMES_DIR = os.path.join(os.path.dirname(__file__), "themes")

# _BUNDLED_MERMAID_THEMES = {
#     #  name   →  (js_file_path,                                         js_global_var)
#     "sky":       (os.path.join(_THEMES_DIR, "mermaid-theme-sky.js"),   "_mkdocsRevealSkyThemeVars"),
#     "fire":      (os.path.join(_THEMES_DIR, "mermaid-theme-fire.js"),  "_mkdocsRevealFireThemeVars"),
# }

# # 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_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)


# # ---------------------------------------------------------------------------
# # 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
#         }
#         return config

#     # ---------------------------------------------------------------- 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))
#         div_html = self._render_div(page._revealjs_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)

#         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 ───────────────────────────────────────── -->
# <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
"""

import json
import logging
import os
import re
import yaml
from mkdocs.plugins import BasePlugin
from mkdocs.config import config_options
from mkdocs.config.base import Config

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")

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_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)


# ---------------------------------------------------------------------------
# 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
        }
        return config

    # ---------------------------------------------------------------- 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))
        div_html = self._render_div(page._revealjs_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)

        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 ───────────────────────────────────────── -->
<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 -->"""


