From b1535878aef8233dfea9136fa8fa43c76a9b81e8 Mon Sep 17 00:00:00 2001 From: Albert Steppi Date: Fri, 22 Dec 2023 09:44:33 -0500 Subject: [PATCH] Add more configuration options to TryExamples directive and add documentation (#116) * Fix notebooks path * Add additional config options for try_examples * Put button in top right * Make TryExamples option configuration uniform * Fix path in example * Handle math at end of cell * Remove now unused toolbar option - This seems to be no longer relevant after switch to notebooklite * Make try_examples button position top or bottom configurable * Add documentation for TryExamples directive * Handle sphinx link syntax in TryExamples * Mention link conversion in try_examples docs * Black fix code --- docs/conf.py | 1 + docs/directives/try_examples.md | 168 +++++++++++++++++++++++ docs/index.md | 1 + jupyterlite_sphinx/_try_examples.py | 23 +++- jupyterlite_sphinx/jupyterlite_sphinx.js | 7 +- jupyterlite_sphinx/jupyterlite_sphinx.py | 122 ++++++++++++---- 6 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 docs/directives/try_examples.md diff --git a/docs/conf.py b/docs/conf.py index 7ead973..c149f08 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- extensions = [ + 'sphinx.ext.mathjax', 'jupyterlite_sphinx', 'myst_parser', ] diff --git a/docs/directives/try_examples.md b/docs/directives/try_examples.md new file mode 100644 index 0000000..9c389d8 --- /dev/null +++ b/docs/directives/try_examples.md @@ -0,0 +1,168 @@ +# TryExamples directive + +`jupyterlite-sphinx` provides the experimental `try_examples` directive which allows +docstring examples sections written in [doctestformat](https://docs.python.org/3/library/doctest.html) to be swapped with an embedded classic Notebook at the push of a button. + + +```rst +Examples +-------- +.. try_examples:: + :button_css: + background-color: #f7dc1e; + border: none; + padding: 5px 10px; + border-radius: 15px; + font-family: vibur; + font-size: x-large; + box-shadow: 0 2px 5px rgba(108,108,108,0.2); + :button_hover_css: + background-color: #fff221; + transform: scale(1.02); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + cursor: pointer; + :button_horizontal_position: right + :button_vertical_position: top + :button_text: Try it in a classic notebook! + :min_height: 200px + + + Doctest examples sections are parsed and converted to notebooks. Blocks of text + like this become markdown cells. Codeblocks begin with `>>>`. Contiguous blocks + of code are combined into a single code cell. + + >>> x = 2 + >>> y = 2 + >>> x + y + 4 + + `...` is used to continue multiline statements. + + >>> def f(x, y): + ... return x + y + >>> f(2, 2) + 4 + + Inline LaTeX like :math:`x + y = 4` is converted, as is block LaTeX like + + .. math:: + + \int_{x=-\infty}^{\infty}e^{-x^2}\mathrm{d}x = \sqrt{\pi} + + If you are displaying `math output `_ + with sphinx. Sphinx links such as the one in the previous sentence are also converted to + markdown format. +``` + + +```{eval-rst} +Examples +-------- +.. try_examples:: + :button_css: + background-color: #f7dc1e; + border: none; + padding: 5px 10px; + border-radius: 15px; + font-family: vibur; + font-size: x-large; + box-shadow: 0 2px 5px rgba(108,108,108,0.2); + :button_hover_css: + background-color: #fff221; + transform: scale(1.02); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + cursor: pointer; + :button_horizontal_position: right + :button_vertical_position: top + :button_text: Try it in a classic notebook! + :min_height: 200px + + Doctest examples sections are parsed and converted to notebooks. Blocks of text + like this become markdown cells. Codeblocks begin with `>>>`. Contiguous blocks + of code are combined into a single code cell. + + >>> x = 2 + >>> y = 2 + >>> x + y + 4 + + `...` is used to continue multiline statements. + + >>> def f(x, y): + ... return x + y + >>> f(2, 2) + 4 + + Inline LaTeX like :math:`x + y = 4` is converted, as is block LaTeX like + + .. math:: + + \int_{-\infty}^{\infty}e^{-x^2}\mathrm{d}x = \sqrt{\pi} + + If you are displaying `math output `_ + with sphinx. Sphinx links such as the one in the previous sentence are also converted to + markdown format. +``` + +## Configuration + +The button's text, position, and style can be configured to match your page design. The +text can be configured with the option `:button_text:`. The options `:button_css:` and +`:button_hover_css:` take lists of css properties as in the example above, and +apply them to the button. `:button_horizontal_position:` can be one of `left`, `right`, or +`center` and `:button_vertical_position:` can be one of `top` or `bottom`. The position +is with respect to the rendered examples block / embedded notebook +(depending on which is active). + +The height of the embedded notebook's iframe container is calculated to match the height +of the rendered doctest examples so that it takes up the same amount of space on the +page. The `:min_height:` option can be used to ensure that the embedded notebook will not +be unuseably small for very short examples blocks, though its use can cause the contents +of the page to shift when the button is pressed. + +the `:theme:` option available for other `jupyterlite-sphinx` directives is also +available. + +If you are using [sphinx.ext.autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) with [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) or [sphinx.ext.napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html), you +can set the option + +```python +global_enable_try_examples = True +``` + +in your sphinx `conf.py` in order to automatically insert the `try_examples` directive +in examples sections during the `"autodoc-process-docstring"` event. Configuration values +can be set globally for the inserted `try_examples` directives by setting the config values +`try_examples_global_button_css`, etc. as below. All valid config values are supported +by prepending `try_examples_global_`. + +```python +global_enable_try_examples = True +try_examples_global_button_css = """ +color: white; +background-color: #0054a6; +border: none; +padding: 5px 10px; +border-radius: 5px; +cursor: pointer; +""" +try_examples_global_button_hover_css = """ +background-color: #0066cc; +transform: scale(1.02); +box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +""" + +try_examples_global_button_text = "Try it in your browser!" +try_examples_global_min_height = "200px" +``` + +If an examples section already contains a `try_examples` directive, no additional +directives will be inserted, allowing for specific cases to be separately configured +if needed. Adding the comment `..! disable_try_examples` as the first non-empty line under +the section header for an examples section will prevent a directive from being inserted, +allowing for specification of examples sections which should not be made interactive. + +## Other considerations +If you are using the `TryExamples` directive in your documentation, you'll need to ensure +that the version of the package installed in the Jupyterlite kernel you are using +matches that of the version you are documenting. diff --git a/docs/index.md b/docs/index.md index 472645f..9557296 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,7 @@ directives/jupyterlite directives/notebooklite directives/replite directives/voici +directives/try_examples full changelog ``` diff --git a/jupyterlite_sphinx/_try_examples.py b/jupyterlite_sphinx/_try_examples.py index 64906c0..35cd146 100755 --- a/jupyterlite_sphinx/_try_examples.py +++ b/jupyterlite_sphinx/_try_examples.py @@ -18,7 +18,7 @@ def examples_to_notebook(input_lines): Examples -------- - >>> from jupyterlite_sphinx.generate_notebook import examples_to_notebook + >>> from jupyterlite_sphinx._try_examples import examples_to_notebook >>> input_lines = [ >>> "Add two numbers. This block of text will appear as a\n", @@ -136,11 +136,28 @@ def _append_markdown_cell_and_clear_lines(markdown_lines, notebook): # Convert blocks of LaTeX equations markdown_text = _process_latex(markdown_text) markdown_text = _strip_ref_identifiers(markdown_text) + markdown_text = _convert_links(markdown_text) notebook.cells.append(new_markdown_cell(markdown_text)) markdown_lines.clear() _ref_identifier_pattern = re.compile(r"\[R[a-f0-9]+-(?P\d+)\]_") +_link_pattern = re.compile(r"`(?P[^`<]+)<(?P[^`>]+)>`_") + + +def _convert_sphinx_link(match): + link_text = match.group("link_text").rstrip() + url = match.group("url") + return f"[{link_text}]({url})" + + +def _convert_links(md_text): + """Convert sphinx style links to markdown style links + + Sphinx style links have the form `link text `_. Converts to + markdown format [link text](url). + """ + return _link_pattern.sub(_convert_sphinx_link, md_text) def _strip_ref_identifiers(md_text): @@ -192,6 +209,10 @@ def _process_latex(md_text): equation_lines = [] wrapped_lines.append(line) + # Handle the case where the text ends with a math block + if in_math_block and equation_lines: + wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$") + return "\n".join(wrapped_lines) diff --git a/jupyterlite_sphinx/jupyterlite_sphinx.js b/jupyterlite_sphinx/jupyterlite_sphinx.js index 96c2862..d1d288d 100644 --- a/jupyterlite_sphinx/jupyterlite_sphinx.js +++ b/jupyterlite_sphinx/jupyterlite_sphinx.js @@ -42,7 +42,8 @@ window.jupyterliteConcatSearchParams = (iframeSrc, params) => { window.tryExamplesShowIframe = ( - examplesContainerId, iframeContainerId, iframeParentContainerId, iframeSrc + examplesContainerId, iframeContainerId, iframeParentContainerId, iframeSrc, + iframeMinHeight ) => { const examplesContainer = document.getElementById(examplesContainerId); const iframeParentContainer = document.getElementById(iframeParentContainerId); @@ -55,7 +56,9 @@ window.tryExamplesShowIframe = ( iframe = document.createElement('iframe'); iframe.src = iframeSrc; iframe.style.width = '100%'; - iframe.style.height = `${examples.offsetHeight}px`; + minHeight = parseInt(iframeMinHeight); + height = Math.max(minHeight, examples.offsetHeight); + iframe.style.height = `${height}px`; iframe.classList.add('jupyterlite_sphinx_raw_iframe'); examplesContainer.classList.add("hidden"); iframeContainer.appendChild(iframe); diff --git a/jupyterlite_sphinx/jupyterlite_sphinx.py b/jupyterlite_sphinx/jupyterlite_sphinx.py index f2aac20..3d2529b 100644 --- a/jupyterlite_sphinx/jupyterlite_sphinx.py +++ b/jupyterlite_sphinx/jupyterlite_sphinx.py @@ -368,8 +368,13 @@ class TryExamplesDirective(SphinxDirective): has_content = True required_arguments = 0 option_spec = { - "toolbar": directives.unchanged, "theme": directives.unchanged, + "button_text": directives.unchanged, + "button_css": directives.unchanged, + "button_hover_css": directives.unchanged, + "button_horizontal_position": directives.unchanged, + "button_vertical_position": directives.unchanged, + "min_height": directives.unchanged, } def run(self): @@ -381,6 +386,27 @@ def run(self): directive_key ) + button_text = self.options.pop("button_text", "Try it with Jupyterlite!") + button_css = self.options.pop("button_css", "") + button_hover_css = self.options.pop("button_hover_css", "") + button_horizontal_position = self.options.pop( + "button_horizontal_position", "right" + ) + button_vertical_position = self.options.pop("button_vertical_position", "top") + min_height = self.options.pop("min_height", "200px") + + if button_horizontal_position not in ["left", "center", "right"]: + raise RuntimeError( + "try_examples directive expects button_horizontal_position to be one of" + f" 'left', 'center', or 'right', received {button_horizontal_position}." + ) + + if button_vertical_position not in ["bottom", "top"]: + raise RuntimeError( + "try_examples directive expects button_vertical_position to be one of" + f" 'top' or 'bottom', received {button_vertical_position}." + ) + # We need to get the relative path back to the documentation root from # whichever file the docstring content is in. docname = self.env.docname @@ -389,7 +415,7 @@ def run(self): prefix = os.path.join(relative_path_to_root, JUPYTERLITE_DIR) lite_app = "tree/" - notebooks_path = "notebooks/" + notebooks_path = "../notebooks/" content_container_node = nodes.container( classes=["try_examples_outer_container"] @@ -400,7 +426,6 @@ def run(self): content_node = nodes.container() content_node["classes"].append("try_examples_content") self.state.nested_parse(self.content, self.content_offset, content_node) - content_container_node += content_node if notebook_unique_name is None: nb = examples_to_notebook(self.content) @@ -441,39 +466,68 @@ def run(self): # Button with the onclick event to swap embedded notebook back to examples. go_back_button_html = ( + '
' '" + "
" ) - # Combine everything - notebook_container_html = ( - iframe_parent_container_div_start - + iframe_container_div - + go_back_button_html - + iframe_parent_container_div_end - ) - notebook_container = nodes.raw("", notebook_container_html, format="html") - # Button with the onclick event to swap examples with embedded notebook. try_it_button_html = ( + '
' '" + f"'{iframe_div_id}','{iframe_parent_div_id}','{iframe_src}'," + f"'{min_height}')\">" + f"{button_text}" + "
" ) try_it_button_node = nodes.raw("", try_it_button_html, format="html") - # Add the button to the content_container_node - content_container_node += try_it_button_node - # Allow css for button to be specified in conf.py - config = self.state.document.settings.env.config - try_examples_button_css = config.try_examples_global_button_css + # Combine everything + if button_vertical_position == "top": + notebook_container_html = ( + iframe_parent_container_div_start + + go_back_button_html + + iframe_container_div + + iframe_parent_container_div_end + ) + content_container_node += try_it_button_node + content_container_node += content_node + else: + notebook_container_html = ( + iframe_parent_container_div_start + + iframe_container_div + + go_back_button_html + + iframe_parent_container_div_end + ) + content_container_node += content_node + content_container_node += try_it_button_node + + notebook_container = nodes.raw("", notebook_container_html, format="html") + + # Generate css for button based on options. + if button_css: + button_css = f".try_examples_button {{{button_css}}}" + if button_hover_css: + button_hover_css = f".try_examples_button:hover {{{button_hover_css}}}" + + justify = {"left": "flex-start", "center": "center", "right": "flex-end"}[ + button_horizontal_position + ] + + button_container_css = ( + ".try_examples_button_container {" + f"display: flex; justify-content: {justify}" + "}" + ) + + complete_button_css = button_css + button_hover_css + button_container_css - try_examples_button_css = f".try_examples_button {{{try_examples_button_css}}}" style_tag = nodes.raw( - "", f"", format="html" + "", f"", format="html" ) return [content_container_node, notebook_container, style_tag] @@ -487,8 +541,13 @@ def _process_docstring_examples(app, docname, source): def _process_autodoc_docstrings(app, what, name, obj, options, lines): try_examples_options = { - "toolbar": app.config.try_examples_global_toolbar, "theme": app.config.try_examples_global_theme, + "button_text": app.config.try_examples_global_button_text, + "button_css": app.config.try_examples_global_button_css, + "button_hover_css": app.config.try_examples_global_button_hover_css, + "button_horizontal_position": app.config.try_examples_global_button_horizontal_position, + "button_vertical_position": app.config.try_examples_global_button_vertical_position, + "min_height": app.config.try_examples_global_min_height, } try_examples_options = { key: value for key, value in try_examples_options.items() if value is not None @@ -594,12 +653,25 @@ def setup(app): app.add_config_value("jupyterlite_dir", app.srcdir, rebuild="html") app.add_config_value("jupyterlite_contents", None, rebuild="html") app.add_config_value("jupyterlite_bind_ipynb_suffix", True, rebuild="html") + app.add_config_value("global_enable_try_examples", default=False, rebuild=True) + app.add_config_value("try_examples_global_theme", default=None, rebuild=True) + app.add_config_value("try_examples_global_button_css", default=None, rebuild="html") app.add_config_value( - "try_examples_global_button_css", default="float: right;", rebuild="html" + "try_examples_global_button_hover_css", default=None, rebuild="html" + ) + app.add_config_value( + "try_examples_global_button_horizontal_position", default=None, rebuild="html" + ) + app.add_config_value( + "try_examples_global_button_vertical_position", default=None, rebuild="html" + ) + app.add_config_value("try_examples_global_min_height", default=None, rebuild="html") + app.add_config_value( + "try_examples_global_button_text", + default=None, + rebuild="html", ) - app.add_config_value("try_examples_global_toolbar", default=None, rebuild=True) - app.add_config_value("try_examples_global_theme", default=None, rebuild=True) # Initialize NotebookLite and JupyterLite directives app.add_node(