Repository: pyscript/pyscript

Issue:
Examples code is great but hard to actually read the html files themselves
### Checklist

- [X] I added a descriptive title
- [X] I searched for other feature requests and couldn't find a duplicate (including also the ``type-feature`` tag)
- [X] I confirmed that it's not related to another project area (see the above section)

### What is the idea?

While really great for our users, the way we add the code section in all the example pages is very invasive. The `<script>` + `<section class="code">` more than double the code of the pages and make it hard to navigate and edit the examples.

Imho, we should instead create a less invasive plugin that does that ;)

### Why is this needed

To find a better compromise between value to users (that can easily see the page code) and devs having to work on examples code.

Also, a plugin would be way better to allow our users to reproduce the same in their apps without have to copy and paste any complicated HTML code

### What should happen?

We should create a plugin! I propose the following:

```
<py-tutor>
   //all the page code  here
   .....
</py-turor>

### Additional Context

_No response_


Discussion:

Resolving patch:
diff --git a/examples/d3.py b/examples/d3.py
new file mode 100644
--- /dev/null
+++ b/examples/d3.py
@@ -0,0 +1,64 @@
+import js
+from pyodide.ffi import create_proxy, to_js
+
+d3 = js.d3
+
+fruits = [
+    {"name": "🍊", "count": 21},
+    {"name": "🍇", "count": 13},
+    {"name": "🍏", "count": 8},
+    {"name": "🍌", "count": 5},
+    {"name": "🍐", "count": 3},
+    {"name": "🍋", "count": 2},
+    {"name": "🍎", "count": 1},
+    {"name": "🍉", "count": 1},
+]
+
+fn = create_proxy(lambda d, *_: d["count"])
+data = d3.pie().value(fn)(to_js(fruits))
+
+arc = (
+    d3.arc()
+    .innerRadius(210)
+    .outerRadius(310)
+    .padRadius(300)
+    .padAngle(2 / 300)
+    .cornerRadius(8)
+)
+
+py = d3.select("#py")
+py.select(".loading").remove()
+
+svg = (
+    py.append("svg")
+    .attr("viewBox", "-320 -320 640 640")
+    .attr("width", "400")
+    .attr("height", "400")
+)
+
+for d in data:
+    d_py = d.to_py()
+
+    (svg.append("path").style("fill", "steelblue").attr("d", arc(d)))
+
+    text = (
+        svg.append("text")
+        .style("fill", "white")
+        .attr("transform", f"translate({arc.centroid(d).join(',')})")
+        .attr("text-anchor", "middle")
+    )
+
+    (
+        text.append("tspan")
+        .style("font-size", "24")
+        .attr("x", "0")
+        .text(d_py["data"]["name"])
+    )
+
+    (
+        text.append("tspan")
+        .style("font-size", "18")
+        .attr("x", "0")
+        .attr("dy", "1.3em")
+        .text(d_py["value"])
+    )
diff --git a/pyscriptjs/src/plugins/python/py_tutor.py b/pyscriptjs/src/plugins/python/py_tutor.py
new file mode 100644
--- /dev/null
+++ b/pyscriptjs/src/plugins/python/py_tutor.py
@@ -0,0 +1,215 @@
+import html
+
+from pyscript import Plugin, js
+
+js.console.warn(
+    "WARNING: This plugin is still in a very experimental phase and will likely change"
+    " and potentially break in the future releases. Use it with caution."
+)
+
+plugin = Plugin("PyTutorial")
+
+# TODO: Part of the CSS is hidden in examples.css ---->> IMPORTANT: move it here!!
+
+# TODO: Python files running and <py-script src="bla.py"> not in the config are not available...
+
+# TODO: We can totally implement this in Python
+PAGE_SCRIPT = """
+const viewCodeButton = document.getElementById("view-code-button");
+
+const codeSection = document.getElementById("code-section");
+const handleClick = () => {
+    if (codeSection.classList.contains("code-section-hidden")) {
+    codeSection.classList.remove("code-section-hidden");
+    codeSection.classList.add("code-section-visible");
+    } else {
+    codeSection.classList.remove("code-section-visible");
+    codeSection.classList.add("code-section-hidden");
+    }
+}
+
+viewCodeButton.addEventListener("click", handleClick)
+viewCodeButton.addEventListener("keydown", (e) => {
+    if (e.key === " " || e.key === "Enter" || e.key === "Spacebar") {
+    handleClick();
+    }
+})
+"""
+
+TEMPLATE_CODE_SECTION = """
+<div id="view-code-button" role="button" aria-pressed="false" tabindex="0">View Code</div>
+<div id="code-section" class="code-section-hidden">
+<p>index.html</p>
+<pre class="prism-code language-html">
+    <code class="language-html">
+        {source}
+    </code>
+</pre>
+
+{modules_section}
+</div>
+"""
+
+TEMPLATE_PY_MODULE_SECTION = """
+<p>{module_name}</p>
+<pre class="prism-code language-python">
+    <code class="language-python">
+        {source}
+    </code>
+</pre>
+"""
+
+
+@plugin.register_custom_element("py-tutor")
+class PyTutor:
+    def __init__(self, element):
+        self.element = element
+
+    def append_script_to_page(self):
+        """
+        Append the JS script (PAGE_SCRIPT) to the page body in order to attach the
+        click and keydown events to show/hide the source code section on the page.
+        """
+        el = js.document.createElement("script")
+        el.type = "text/javascript"
+        try:
+            el.appendChild(js.document.createTextNode(PAGE_SCRIPT))
+        except BaseException:
+            el.text = PAGE_SCRIPT
+
+        js.document.body.appendChild(el)
+
+    def add_prism(self):
+        # Add The CSS
+        link = js.document.createElement("link")
+        link.type = "text/css"
+        link.rel = "stylesheet"
+        js.document.head.appendChild(link)
+        link.href = "./assets/prism/prism.css"
+
+        # Add the JS file
+        script = js.document.createElement("script")
+        script.type = "text/javascript"
+        try:
+            script.appendChild(js.document.createTextNode(PAGE_SCRIPT))
+        except BaseException:
+            script.text = PAGE_SCRIPT
+        script.src = "./assets/prism/prism.js"
+        js.document.head.appendChild(script)
+
+    def _create_code_section(self, source, module_paths=None, parent=None):
+        """
+        Get source and the path to modules to be displayed, create a new `code`
+        `section` where it's contents use TEMPLATE_CODE_SECTION with `source` and
+        `modules_paths` to display the information it needs.
+
+        Args:
+
+            source (str): source within a <py-tutor> tag that needs to be displaed
+            module_paths (list(str)): list of paths to modules that needs to be shown
+            parent(HTMLElement, optional): Element where the code section will be appended
+                                        to. I None is passed parent == document.body.
+                                        Defaults to None.
+
+        Returns:
+            (None)
+        """
+        if not parent:
+            parent = js.document.body
+
+        js.console.info("Creating code introspection section.")
+        modules_section = self.create_modules_section(module_paths)
+
+        js.console.info("Creating new code section element.")
+        el = js.document.createElement("section")
+        el.classList.add("code")
+
+        el.innerHTML = TEMPLATE_CODE_SECTION.format(
+            source=source, modules_section=modules_section
+        )
+        parent.appendChild(el)
+
+    @classmethod
+    def create_modules_section(cls, module_paths=None):
+        """Create the HTML content for all modules passed in `module_paths`. More specifically,
+        reads the content of each module and calls PyTytor.create_module_section
+
+        Args:
+
+            module_paths (list(str)): list of paths to modules that needs to be shown
+
+        Returns:
+            (str) HTML code with the content of each module in `module_path`, ready to be
+                attached to the DOM
+        """
+        js.console.info(f"Module paths to parse: {module_paths}")
+        if not module_paths:
+            return ""
+
+        return "\n\n".join([cls.create_module_section(m) for m in module_paths])
+
+    @staticmethod
+    def create_module_section(module_path):
+        """Create the HTML content for the module passed as `module_path`.
+        More specifically, reads the content of module and calls PyTytor.create_module_section
+
+        Args:
+
+            module_paths (list(str)): list of paths to modules that needs to be shown
+
+        Returns:
+            (str) HTML code with the content of each module in `module_path`, ready to be
+                attached to the DOM
+        """
+        js.console.info(f"Creating module section: {module_path}")
+        with open(module_path) as fp:
+            content = fp.read()
+        return TEMPLATE_PY_MODULE_SECTION.format(
+            module_name=module_path, source=content
+        )
+
+    def create_page_code_section(self):
+        """
+        Create all the code content to be displayed on a page. More specifically:
+
+        * get the HTML code within the <py-tutor> tag
+        * get the source code from all files specified in the py-tytor `modules` attribute
+        * create the HTML to be attached on the page using the content created in
+          the previous 2 items and apply them to TEMPLATE_CODE_SECTION
+
+        Returns:
+            (None)
+        """
+        # Get the content of all the modules that were passed to be documented
+        module_paths = self.element.getAttribute("modules")
+        if module_paths:
+            js.console.info(f"Module paths detected: {module_paths}")
+            module_paths = str(module_paths).split(";")
+
+        # Get the inner HTML content of the py-tutor tag and document that
+        tutor_tag_innerHTML = html.escape(self.element.innerHTML)
+
+        self._create_code_section(tutor_tag_innerHTML, module_paths)
+
+    def connect(self):
+        """
+        Handler meant to be called when the Plugin CE (Custom Element) is attached
+        to the page.
+
+        As so, it's the entry point that coordinates the whole plugin workflow and
+        is responsible for calling the right steps in order:
+
+        * identify what parts of the App (page) that are within the py-tutor tag
+          to be documented as well as any modules specified as attribute
+        * inject the button to show/hide button and related modal
+        * inject the JS code that attaches the click event to the button
+        * build the modal that shows/hides with the correct page/modules code
+        """
+        # Create the core do show the source code on the page
+        self.create_page_code_section()
+
+        # append the script needed to show source first...
+        self.append_script_to_page()
+
+        # inject the prism JS library dependency
+        self.add_prism()


Test patch:
diff --git a/pyscriptjs/tests/integration/support.py b/pyscriptjs/tests/integration/support.py
--- a/pyscriptjs/tests/integration/support.py
+++ b/pyscriptjs/tests/integration/support.py
@@ -303,6 +303,72 @@ def assert_no_banners(self):
             text = "\n".join(loc.all_inner_texts())
             raise AssertionError(f"Found {n} alert banners:\n" + text)
 
+    def check_tutor_generated_code(self, modules_to_check=None):
+        """
+        Ensure that the source code viewer injected by the PyTutor plugin
+        is presend. Raise AssertionError if not found.
+
+        Args:
+
+            modules_to_check(str): iterable with names of the python modules
+                                that have been included in the tutor config
+                                and needs to be checked (if they are included
+                                in the displayed source code)
+
+        Returns:
+            None
+        """
+        # Given: a page that has a <py-tutor> tag
+        assert self.page.locator("py-tutor").count()
+
+        # EXPECT that"
+        #
+        # the page has the "view-code-button"
+        view_code_button = self.page.locator("#view-code-button")
+        vcb_count = view_code_button.count()
+        if vcb_count != 1:
+            raise AssertionError(
+                f"Found {vcb_count} code view button. Should have been 1!"
+            )
+
+        # the page has the code-section element
+        code_section = self.page.locator("#code-section")
+        code_section_count = code_section.count()
+        code_msg = (
+            f"One (and only one) code section should exist. Found: {code_section_count}"
+        )
+        assert code_section_count == 1, code_msg
+
+        pyconfig_tag = self.page.locator("py-config")
+        code_section_inner_html = code_section.inner_html()
+
+        # the code_section has the index.html section
+        assert "<p>index.html</p>" in code_section_inner_html
+
+        # the section has the tags highlighting the HTML code
+        assert (
+            '<pre class="prism-code language-html" tabindex="0">'
+            '    <code class="language-html">' in code_section_inner_html
+        )
+
+        # if modules were included, these are also presented in the code section
+        if modules_to_check:
+            for module in modules_to_check:
+                assert f"{module}" in code_section_inner_html
+
+        # the section also includes the config
+        assert "&lt;</span>py-config</span>" in code_section_inner_html
+
+        # the contents of the py-config tag are included in the code section
+        assert pyconfig_tag.inner_html() in code_section_inner_html
+
+        # the code section to be invisible by default (by having the hidden class)
+        assert "code-section-hidden" in code_section.get_attribute("class")
+
+        # once the view_code_button is pressed, the code section becomes visible
+        view_code_button.click()
+        assert "code-section-visible" in code_section.get_attribute("class")
+
 
 # ============== Helpers and utility functions ==============
 
diff --git a/pyscriptjs/tests/integration/test_zz_examples.py b/pyscriptjs/tests/integration/test_zz_examples.py
--- a/pyscriptjs/tests/integration/test_zz_examples.py
+++ b/pyscriptjs/tests/integration/test_zz_examples.py
@@ -61,6 +61,7 @@ def test_hello_world(self):
         pattern = "\\d+/\\d+/\\d+, \\d+:\\d+:\\d+"  # e.g. 08/09/2022 15:57:32
         assert re.search(pattern, content)
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_simple_clock(self):
         self.goto("examples/simple_clock.html")
@@ -79,6 +80,7 @@ def test_simple_clock(self):
         else:
             assert False, "Espresso time not found :("
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_altair(self):
         self.goto("examples/altair.html")
@@ -97,6 +99,7 @@ def test_altair(self):
         assert save_as_png_link.is_visible()
         assert see_source_link.is_visible()
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_antigravity(self):
         self.goto("examples/antigravity.html")
@@ -120,6 +123,7 @@ def test_antigravity(self):
             re.match(ycoord_pattern, char.get_attribute("transform")).group("ycoord")
         )
         assert later_y_coord < starting_y_coord
+        self.check_tutor_generated_code(modules_to_check=["antigravity.py"])
 
     def test_bokeh(self):
         # XXX improve this test
@@ -128,6 +132,7 @@ def test_bokeh(self):
         assert self.page.title() == "Bokeh Example"
         wait_for_render(self.page, "*", '<div.*class=\\"bk\\".*>')
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_bokeh_interactive(self):
         # XXX improve this test
@@ -136,6 +141,7 @@ def test_bokeh_interactive(self):
         assert self.page.title() == "Bokeh Example"
         wait_for_render(self.page, "*", '<div.*?class=\\"bk\\".*?>')
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     @pytest.mark.skip("flaky, see issue 759")
     def test_d3(self):
@@ -152,6 +158,7 @@ def test_d3(self):
         # means that the chart rendered successfully and with the right text
         assert "🍊21\n🍇13\n🍏8\n🍌5\n🍐3\n🍋2\n🍎1\n🍉1" in pyscript_chart.inner_text()
         self.assert_no_banners()
+        self.check_tutor_generated_code(modules_to_check=["d3.py"])
 
     def test_folium(self):
         self.goto("examples/folium.html")
@@ -175,6 +182,7 @@ def test_folium(self):
         assert "−" in zoom_out.inner_text()
         zoom_out.click()
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_markdown_plugin(self):
         # Given the example page with:
@@ -186,6 +194,7 @@ def test_markdown_plugin(self):
         assert self.page.title() == "PyMarkdown"
         # ASSERT markdown is rendered to the corresponding HTML tag
         wait_for_render(self.page, "*", "<h1>Hello world!</h1>")
+        self.check_tutor_generated_code()
 
     def test_matplotlib(self):
         self.goto("examples/matplotlib.html")
@@ -213,6 +222,7 @@ def test_matplotlib(self):
         deviation = np.mean(np.abs(img_data - ref_data))
         assert deviation == 0.0
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_numpy_canvas_fractals(self):
         self.goto("examples/numpy_canvas_fractals.html")
@@ -260,6 +270,7 @@ def test_numpy_canvas_fractals(self):
         # Confirm that changing the input values, triggered a new computation
         assert self.console.log.lines[-1] == "Computing Newton set ..."
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_panel(self):
         self.goto("examples/panel.html")
@@ -278,6 +289,7 @@ def test_panel(self):
         # Let's confirm that slider title changed
         assert slider_title.inner_text() == "Amplitude: 5"
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_panel_deckgl(self):
         # XXX improve this test
@@ -286,6 +298,7 @@ def test_panel_deckgl(self):
         assert self.page.title() == "PyScript/Panel DeckGL Demo"
         wait_for_render(self.page, "*", "<div.*?class=['\"]bk-root['\"].*?>")
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_panel_kmeans(self):
         # XXX improve this test
@@ -294,6 +307,7 @@ def test_panel_kmeans(self):
         assert self.page.title() == "Pyscript/Panel KMeans Demo"
         wait_for_render(self.page, "*", "<div.*?class=['\"]bk-root['\"].*?>")
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_panel_stream(self):
         # XXX improve this test
@@ -302,6 +316,7 @@ def test_panel_stream(self):
         assert self.page.title() == "PyScript/Panel Streaming Demo"
         wait_for_render(self.page, "*", "<div.*?class=['\"]bk-root['\"].*?>")
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_repl(self):
         self.goto("examples/repl.html")
@@ -322,6 +337,7 @@ def test_repl(self):
         assert self.page.wait_for_selector("#my-repl-2-2", state="attached")
         assert self.page.locator("#my-repl-2-2").text_content() == "4"
         self.assert_no_banners()
+        self.check_tutor_generated_code(modules_to_check=["antigravity.py"])
 
     def test_repl2(self):
         self.goto("examples/repl2.html")
@@ -338,6 +354,7 @@ def test_repl2(self):
         pattern = "\\d+/\\d+/\\d+, \\d+:\\d+:\\d+"  # e.g. 08/09/2022 15:57:32
         assert re.search(pattern, content)
         self.assert_no_banners()
+        self.check_tutor_generated_code(modules_to_check=["antigravity.py"])
 
     def test_todo(self):
         self.goto("examples/todo.html")
@@ -366,6 +383,7 @@ def test_todo(self):
             in first_task.inner_html()
         )
         self.assert_no_banners()
+        self.check_tutor_generated_code(modules_to_check=["./utils.py", "./todo.py"])
 
     def test_todo_pylist(self):
         # XXX improve this test
@@ -374,6 +392,7 @@ def test_todo_pylist(self):
         assert self.page.title() == "Todo App"
         wait_for_render(self.page, "*", "<input.*?id=['\"]new-task-content['\"].*?>")
         self.assert_no_banners()
+        self.check_tutor_generated_code(modules_to_check=["utils.py", "pylist.py"])
 
     @pytest.mark.xfail(reason="To be moved to collective and updated, see issue #686")
     def test_toga_freedom(self):
@@ -392,6 +411,7 @@ def test_toga_freedom(self):
         result = self.page.locator("#toga_c_input")
         assert "40.555" in result.input_value()
         self.assert_no_banners()
+        self.check_tutor_generated_code()
 
     def test_webgl_raycaster_index(self):
         # XXX improve this test