diff --git a/_doc/api/index.rst b/_doc/api/index.rst index c386747..3bbee13 100644 --- a/_doc/api/index.rst +++ b/_doc/api/index.rst @@ -16,6 +16,7 @@ Extensions epkg gdot quote + runmermaid runpython tools rst_builder diff --git a/_doc/api/runmermaid.rst b/_doc/api/runmermaid.rst new file mode 100644 index 0000000..678fa4b --- /dev/null +++ b/_doc/api/runmermaid.rst @@ -0,0 +1,77 @@ +========== +runmermaid +========== + +This directive displays `Mermaid `_ diagrams in the documentation. +Diagrams are rendered client-side in *HTML* output via the Mermaid JavaScript library +and as verbatim text in *LaTeX* / *RST* output. + +Usage +===== + +In *conf.py*: + +:: + + extensions = [ ... + 'sphinx_runpython.runmermaid', + ] + +One example: + +:: + + .. runmermaid:: + + graph LR + A --> B --> C + +Which gives: + +.. runmermaid:: + + graph LR + A --> B --> C + +The diagram source can also be produced by a Python script. +Option *script* must be specified: + +:: + + .. runmermaid:: + :script: + + print(""" + graph LR + A --> B + """) + +.. runmermaid:: + :script: + + print(""" + graph LR + A --> B + """) + +When *script* is a non-empty string it is used as a split token: only +the output **after** the first occurrence of that string is interpreted +as Mermaid source: + +:: + + .. runmermaid:: + :script: AFTER-THIS + + print("preamble") + print("AFTER-THIS") + print("graph TD") + print(" P --> Q") + +Finally, the option ``:process:`` can be used to run the script in +a separate process. + +Directive +========= + +.. autoclass:: sphinx_runpython.runmermaid.sphinx_runmermaid_extension.RunMermaidDirective diff --git a/_doc/conf.py b/_doc/conf.py index bf4fd26..0a1a41b 100644 --- a/_doc/conf.py +++ b/_doc/conf.py @@ -25,6 +25,7 @@ "sphinx_runpython.docassert", "sphinx_runpython.gdot", "sphinx_runpython.epkg", + "sphinx_runpython.runmermaid", "sphinx_runpython.quote", "sphinx_runpython.runpython", "sphinx_runpython.sphinx_rst_builder", @@ -120,6 +121,7 @@ "git": "https://git-scm.com/", "Graphviz": "https://graphviz.org/", "HTML": "https://simple.wikipedia.org/wiki/HTML", + "Mermaid": "https://mermaid.js.org/", "nested_parse_with_titles": "https://www.sphinx-doc.org/en/master/extdev/markupapi.html#parsing-directive-content-as-rest", "numpy": ( "https://www.numpy.org/", diff --git a/_unittests/ut_runmermaid/test_runmermaid_extension.py b/_unittests/ut_runmermaid/test_runmermaid_extension.py new file mode 100644 index 0000000..d6fa250 --- /dev/null +++ b/_unittests/ut_runmermaid/test_runmermaid_extension.py @@ -0,0 +1,127 @@ +import unittest +import logging +from sphinx_runpython.process_rst import rst2html +from sphinx_runpython.ext_test_case import ( + ExtTestCase, + ignore_warnings, +) + + +class TestRunMermaidExtension(ExtTestCase): + def setUp(self): + logger = logging.getLogger("runmermaid") + logger.disabled = True + + @ignore_warnings(PendingDeprecationWarning) + def test_runmermaid_inline_rst(self): + """Inline runmermaid diagram is round-tripped through the RST writer.""" + content = """ +before + +.. runmermaid:: + + graph LR + A --> B --> C + +after +""" + content = rst2html( + content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"] + ) + self.assertIn("graph LR", content) + self.assertIn("A --> B --> C", content) + + @ignore_warnings(PendingDeprecationWarning) + def test_runmermaid_inline_html(self): + """Inline runmermaid diagram produces a
 element."""
+        content = """
+before
+
+.. runmermaid::
+
+    graph LR
+        A --> B
+
+after
+"""
+        html = rst2html(
+            content, writer_name="html", new_extensions=["sphinx_runpython.runmermaid"]
+        )
+        self.assertIn('class="mermaid"', html)
+        self.assertIn("graph LR", html)
+        self.assertIn("A --> B", html)
+
+    @ignore_warnings(PendingDeprecationWarning)
+    def test_runmermaid_script(self):
+        """Script-generated runmermaid diagram is included in the RST output."""
+        content = """
+before
+
+.. runmermaid::
+    :script:
+
+    print(\"\"\"graph LR
+    X --> Y\"\"\")
+
+after
+"""
+        content = rst2html(
+            content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+        )
+        self.assertIn("graph LR", content)
+        self.assertIn("X --> Y", content)
+
+    @ignore_warnings(PendingDeprecationWarning)
+    def test_runmermaid_script_split(self):
+        """When :script: has a value it is used as a split token."""
+        content = """
+before
+
+.. runmermaid::
+    :script: BEGIN
+
+    print("preamble")
+    print("BEGIN")
+    print("graph TD")
+    print("    P --> Q")
+
+after
+"""
+        content = rst2html(
+            content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+        )
+        self.assertNotIn("preamble", content)
+        self.assertNotIn("BEGIN", content)
+        self.assertIn("graph TD", content)
+        self.assertIn("P --> Q", content)
+
+    @ignore_warnings(PendingDeprecationWarning)
+    def test_runmermaid_script_cache(self):
+        """Identical scripts produce the same output and are cached."""
+        script_body = 'print("graph LR\\n    A --> B")'
+        content = f"""
+before
+
+.. runmermaid::
+    :script:
+
+    {script_body}
+
+middle
+
+.. runmermaid::
+    :script:
+
+    {script_body}
+
+after
+"""
+        content = rst2html(
+            content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+        )
+        count = content.count("graph LR")
+        self.assertEqual(count, 2, f"Expected diagram code twice, got {count}")
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)
diff --git a/sphinx_runpython/runmermaid/__init__.py b/sphinx_runpython/runmermaid/__init__.py
new file mode 100644
index 0000000..185687f
--- /dev/null
+++ b/sphinx_runpython/runmermaid/__init__.py
@@ -0,0 +1,3 @@
+from .sphinx_runmermaid_extension import setup
+
+__all__ = ["setup"]
diff --git a/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py b/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py
new file mode 100644
index 0000000..11f19b9
--- /dev/null
+++ b/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py
@@ -0,0 +1,242 @@
+import hashlib
+import logging
+from docutils import nodes
+from docutils.parsers.rst import directives, Directive
+import sphinx
+from ..ext_helper import get_env_state_info
+from ..runpython.sphinx_runpython_extension import run_python_script
+
+logger = logging.getLogger("runmermaid")
+
+#: Default CDN URL for the mermaid JavaScript library.
+_MERMAID_JS_URL = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"
+
+
+class runmermaid_node(nodes.General, nodes.Element):
+    """
+    Defines ``runmermaid`` node.
+    """
+
+    pass
+
+
+class RunMermaidDirective(Directive):
+    """
+    A ``runmermaid`` node displays a `Mermaid `_ diagram.
+
+    For *HTML* output the diagram is rendered client-side by embedding the
+    Mermaid JavaScript library (loaded from a CDN or a local copy).
+    For *LaTeX* / *text* / *RST* output the raw Mermaid source is included.
+
+    Supported options:
+
+    * *script*: boolean or a string that marks the beginning of the Mermaid
+      source in the standard output of the embedded Python script.  When this
+      option is present the directive body is interpreted as Python code whose
+      ``stdout`` contains the diagram definition.
+    * *process*: run the Python script in a separate process.
+
+    Example - inline diagram::
+
+        .. runmermaid::
+
+            graph LR
+                A --> B --> C
+
+    Which gives:
+
+    .. runmermaid::
+
+        graph LR
+            A --> B --> C
+
+    Example - script-generated diagram::
+
+        .. runmermaid::
+            :script:
+
+            print(\"\"\"
+            graph LR
+                A --> B
+            \"\"\")
+
+    .. runmermaid::
+        :script:
+
+        print(\"\"\"
+        graph LR
+            A --> B
+        \"\"\")
+    """
+
+    node_class = runmermaid_node
+    has_content = True
+    required_arguments = 0
+    optional_arguments = 0
+    final_argument_whitespace = False
+    option_spec = {
+        "script": directives.unchanged,
+        "process": directives.unchanged,
+    }
+
+    def run(self):
+        """Build the runmermaid node."""
+        bool_set_ = (True, 1, "True", "1", "true", "")
+        process = "process" in self.options and self.options["process"] in bool_set_
+
+        info = get_env_state_info(self)
+        docname = info["docname"]
+
+        if "script" in self.options:
+            script = self.options["script"]
+            if script in (0, "0", "False", "false"):
+                script = None
+            elif script in (1, "1", "True", "true", ""):
+                script = ""
+            # else: keep script as-is to use it as a split token
+        else:
+            script = False
+
+        # Execute the script and use its stdout as diagram source, if requested.
+        content = "\n".join(self.content)
+        if script or script == "":
+            env = info.get("env")
+            doc_prefix = docname.split("/")[-1] if docname else ""
+            cache_key = (
+                f"{doc_prefix}:"
+                + hashlib.sha256(f"{content}:{process}".encode()).hexdigest()
+            )
+            if env is not None:
+                if not hasattr(env, "runmermaid_script_cache"):
+                    env.runmermaid_script_cache = {}
+                cached = env.runmermaid_script_cache.get(cache_key, None)
+            else:
+                cached = None
+
+            if cached is not None:
+                stdout, stderr = cached
+            else:
+                stdout, stderr, _ = run_python_script(content, process=process)
+                if env is not None:
+                    env.runmermaid_script_cache[cache_key] = (stdout, stderr)
+
+            if stderr:
+                logger.warning(
+                    "[runmermaid] a diagram cannot be drawn due to %s", stderr
+                )
+            content = stdout
+            if script:
+                spl = content.split(script)
+                if len(spl) > 2:
+                    logger.warning("[runmermaid] too many output lines %s", content)
+                content = spl[-1]
+
+        node = runmermaid_node(code=content, options={"docname": docname})
+        return [node]
+
+
+# ---------------------------------------------------------------------------
+# Visitor helpers
+# ---------------------------------------------------------------------------
+
+
+def visit_runmermaid_node_html(self, node):
+    """Render the runmermaid node in HTML output."""
+    code = node["code"].strip()
+    # Emit a 
 block; mermaid.js will replace it at runtime.
+    self.body.append(
+        f'
' + f'
{self.encode(code)}
' + f"
\n" + ) + raise nodes.SkipNode + + +def depart_runmermaid_node_html(self, node): + """Depart the runmermaid HTML node. Not called because the visitor raises SkipNode.""" + + +def visit_runmermaid_node_rst(self, node): + """Render the runmermaid node in RST output.""" + self.new_state(0) + self.add_text(".. runmermaid::" + self.nl) + self.new_state(self.indent) + for row in node["code"].split("\n"): + self.add_text(row + self.nl) + + +def depart_runmermaid_node_rst(self, node): + """Depart runmermaid node in RST output.""" + self.end_state() + self.end_state(wrap=False) + + +def visit_runmermaid_node_text(self, node): + """Render the runmermaid node in plain-text output.""" + self.new_state(0) + self.add_text("[runmermaid diagram]\n") + self.new_state(self.indent) + for row in node["code"].split("\n"): + self.add_text(row + self.nl) + + +def depart_runmermaid_node_text(self, node): + """Depart runmermaid node in text output.""" + self.end_state() + self.end_state(wrap=False) + + +def visit_runmermaid_node_latex(self, node): + """Render the runmermaid node in LaTeX output (verbatim source).""" + code = node["code"].strip() + self.body.append("\n\\begin{verbatim}\n") + self.body.append(code) + self.body.append("\n\\end{verbatim}\n") + raise nodes.SkipNode + + +def depart_runmermaid_node_latex(self, node): + """Depart the runmermaid LaTeX node. Not called because the visitor raises SkipNode.""" + + +# --------------------------------------------------------------------------- +# JS injection +# --------------------------------------------------------------------------- + + +def add_mermaid_js(app): + """Inject the Mermaid JS library into HTML pages.""" + if app.builder.format != "html": + return + app.add_js_file(_MERMAID_JS_URL, loading_method="async") + # Initialise mermaid after the DOM is ready. + app.add_js_file( + None, + body="document.addEventListener('DOMContentLoaded', function() " + "{ mermaid.initialize({startOnLoad: true}); });", + ) + + +# --------------------------------------------------------------------------- +# Extension setup +# --------------------------------------------------------------------------- + + +def setup(app): + """ + setup for ``runmermaid`` (sphinx) + """ + app.connect("builder-inited", add_mermaid_js) + + app.add_node( + runmermaid_node, + html=(visit_runmermaid_node_html, depart_runmermaid_node_html), + epub=(visit_runmermaid_node_html, depart_runmermaid_node_html), + latex=(visit_runmermaid_node_latex, depart_runmermaid_node_latex), + text=(visit_runmermaid_node_text, depart_runmermaid_node_text), + rst=(visit_runmermaid_node_rst, depart_runmermaid_node_rst), + md=(visit_runmermaid_node_text, depart_runmermaid_node_text), + ) + + app.add_directive("runmermaid", RunMermaidDirective) + return {"version": sphinx.__display_version__, "parallel_read_safe": True}