Skip to content

Commit

Permalink
ENH: Lazy load thebe javascript (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
choldgraf authored Jan 17, 2022
1 parent c036ac4 commit cee16c6
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 73 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Documentation](https://readthedocs.org/projects/sphinx-thebe/badge/?version=latest)](https://sphinx-thebe.readthedocs.io/en/latest/?badge=latest)
[![PyPI](https://img.shields.io/pypi/v/sphinx-thebe.svg)](https://pypi.org/project/sphinx-thebe)

Integrate interactive code blocks into your documentation with Thebelab and Binder.
Integrate interactive code blocks into your documentation with Thebe and Binder.

See [the sphinx-thebe documentation](https://sphinx-thebe.readthedocs.io/en/latest/) for more details!

Expand Down
4 changes: 3 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
# "selector": ".thebe",
# "selector_input": ,
# "selector_output": ,
# "codemirror-theme": "blackboard" # Doesn't currently work
# "codemirror-theme": "blackboard", # Doesn't currently work
# "always_load": True, # To load thebe on every page
}

myst_enable_extensions = ["colon_fence"]
Expand Down Expand Up @@ -86,6 +87,7 @@
# a list of builtin themes.
#
html_theme = "sphinx_book_theme"
html_title = "sphinx-thebe"

# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
Expand Down
13 changes: 7 additions & 6 deletions docs/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,23 +202,24 @@ thebe_config = {
See [the CodeMirror theme demo](https://codemirror.net/demo/theme.html) for a list
of themes that you can use, and what they look like.

## Only load JS on certain pages
## Load `thebe` automatically on all pages

By default, `sphinx-thebe` will load the JS/CSS from `thebe` on all of your documentation's pages.
Alternatively, you may load `thebe` only on pages that use the `thebe-button` directive.
To do so, use the following configuration:
By default, `sphinx-thebe` will lazily load the JS/CSS from `thebe` when the `sphinx-thebe` initialization button is pressed.
This means that no Javascript is loaded until a person explicitly tries to start thebe, which reduces page load times.

If you want `thebe` to be loaded on every page, in an "eager" fashion, you may do so with the following configuration:

```python
thebe_config = {
"always_load": False
"always_load": True
}
```

## Configuration reference

Here's a reference of all of the configuration values avialable to `sphinx-thebe`.
Many of these eventually make their was into the `thebe` configuration. You can
find a [reference for `thebe` configuration here](https://thebelab.readthedocs.io/en/latest/config_reference.html).
find a [reference for `thebe` configuration here](https://thebe.readthedocs.io/en/latest/config_reference.html).

```python
thebe_config = {
Expand Down
5 changes: 2 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
:alt: PyPi page
```

Make your code cells interactive with a kernel provided by [Thebe](http://thebelab.readthedocs.org/)
and [Binder](https://mybinder.org).
Make your code cells interactive with a kernel provided by [Thebe](http://thebe.readthedocs.org/) and [Binder](https://mybinder.org).

For example, click the button below. Notice that the code block beneath becomes
editable and runnable!
Expand All @@ -29,7 +28,7 @@ print("hi")
See [](use.md) for more information about what you can do with `sphinx-thebe`.

```{note}
This package is a Sphinx wrapper around the excellent [thebe project](http://thebelab.readthedocs.org/),
This package is a Sphinx wrapper around the excellent [thebe project](http://thebe.readthedocs.org/),
a javascript tool to convert static code cells into interactive cells backed
by a kernel.
```
Expand Down
62 changes: 34 additions & 28 deletions sphinx_thebe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
from pathlib import Path
from textwrap import dedent

from docutils.parsers.rst import Directive, directives
from docutils import nodes
Expand All @@ -12,7 +13,7 @@

logger = logging.getLogger(__name__)

THEBE_VERSION = "0.5.1"
THEBE_VERSION = "0.8.2"


def st_static_path(app):
Expand All @@ -21,16 +22,22 @@ def st_static_path(app):


def init_thebe_default_config(app, env, docnames):
"""Create a default config for fields that aren't given by the user."""
thebe_config = app.config.thebe_config
defaults = {
"always_load": True,
"always_load": False,
"selector": ".thebe",
"selector_input": "pre",
"selector_output": ".output",
}
for key, val in defaults.items():
if key not in thebe_config:
thebe_config[key] = val

# Standardize types for certain values
BOOL_KEYS = ["always_load"]
for key in BOOL_KEYS:
thebe_config[key] = _bool(thebe_config[key])


def _bool(b):
Expand All @@ -42,46 +49,45 @@ def _bool(b):

def _do_load_thebe(doctree, config_thebe):
"""Decide whether to load thebe based on the page's context."""
# No doctree means there's no page content at all
if not doctree:
return False

# If we aren't properly configured
if not config_thebe:
logger.warning("Didn't find `thebe_config` in conf.py, add to use thebe")
logger.warning("[sphinx-thebe]: Didn't find `thebe_config` in conf.py, add to use thebe")
return False

return True

# Only load `thebe` if there is a thebe button somewhere
if doctree.traverse(ThebeButtonNode) or _bool(config_thebe.get("always_load")):
return True
else:
return False


def init_thebe_core(app, pagename, templatename, context, doctree):
"""Load thebe assets if there's a thebe button on this page."""
def init_thebe_core(app, env, docnames):
"""Add scripts to configure thebe, and optionally add thebe itself.
By default, defer loading the `thebe` JS bundle until bootstrap is called
in order to speed up page load times.
"""
config_thebe = app.config["thebe_config"]
if not _do_load_thebe(doctree, config_thebe):
return

# Add core libraries
opts = {"async": "async"}
app.add_js_file(
filename=f"https://unpkg.com/thebe@{THEBE_VERSION}/lib/index.js", **opts
)

# Add configuration variables
thebe_config = f"""
THEBE_JS_URL = f"https://unpkg.com/thebe@{THEBE_VERSION}/lib/index.js"
thebe_config = f"""\
const THEBE_JS_URL = "{ THEBE_JS_URL }"
const thebe_selector = "{ app.config.thebe_config['selector'] }"
const thebe_selector_input = "{ app.config.thebe_config['selector_input'] }"
const thebe_selector_output = "{ app.config.thebe_config['selector_output'] }"
"""
app.add_js_file(None, body=thebe_config)
app.add_js_file(filename="sphinx-thebe.js", **opts)
app.add_js_file(None, body=dedent(thebe_config))
app.add_js_file(filename="sphinx-thebe.js", **{"async": "async"})

if config_thebe.get("always_load") is True:
# If we've got `always load` on, then load thebe on every page.
app.add_js_file(THEBE_JS_URL, **{"async": "async"})

def update_thebe_context(app, doctree, docname):
"""Add thebe config nodes to this doctree."""
"""Add thebe config nodes to this doctree using page-dependent information."""
config_thebe = app.config["thebe_config"]
# Skip modifying the doctree if we don't need to load thebe
if not _do_load_thebe(doctree, config_thebe):
return

Expand All @@ -94,7 +100,6 @@ def update_thebe_context(app, doctree, docname):
)
codemirror_theme = config_thebe.get("codemirror-theme", "abcdef")

# Thebe configuration
# Choose the kernel we'll use
meta = app.env.metadata.get(docname, {})
kernel_name = meta.get("thebe-kernel")
Expand Down Expand Up @@ -142,6 +147,7 @@ def update_thebe_context(app, doctree, docname):
</script>
"""

# Append to the docutils doctree so it makes it into the build outputs
doctree.append(nodes.raw(text=thebe_html_config, format="html"))
doctree.append(
nodes.raw(text=f"<script>kernelName = '{kernel_name}'</script>", format="html")
Expand All @@ -154,7 +160,7 @@ def _split_repo_url(url):
end = url.split("github.com/")[-1]
org, repo = end.split("/")[:2]
else:
logger.warning(f"Currently Thebe repositories must be on GitHub, got {url}")
logger.warning(f"[sphinx-thebe]: Currently Thebe repositories must be on GitHub, got {url}")
org = repo = None
return org, repo

Expand Down Expand Up @@ -220,12 +226,12 @@ def setup(app):
# Set default values for the configuration
app.connect("env-before-read-docs", init_thebe_default_config)

# Load the JS/CSS assets for thebe if needed
app.connect("env-before-read-docs", init_thebe_core)

# Update the doctree with thebe-specific information if needed
app.connect("doctree-resolved", update_thebe_context)

# Load the JS/CSS assets for thebe if needed
app.connect("html-page-context", init_thebe_core)

# configuration for this tool
app.add_config_value("thebe_config", {}, "html")

Expand Down
47 changes: 31 additions & 16 deletions sphinx_thebe/_static/sphinx-thebe.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
/**
* Add attributes to Thebe blocks to initialize thebe properly
*/

var initThebe = () => {
// If Thebelab hasn't loaded, wait a bit and try again. This
// happens because we load ClipboardJS asynchronously.
if (window.thebelab === undefined) {
console.log("thebe not loaded, retrying...");
setTimeout(initThebe, 500)
return
}

console.log("Adding thebe to code cells...");

var configureThebe = () => {
// Load thebe config in case we want to update it as some point
console.log("[sphinx-thebe]: Loading thebe config...");
thebe_config = $('script[type="text/x-thebe-config"]')[0]


// If we already detect a Thebe cell, don't re-run
if (document.querySelectorAll('div.thebe-cell').length > 0) {
return;
Expand Down Expand Up @@ -56,8 +45,12 @@ var initThebe = () => {
});
}
});
}


/**
* Update the page DOM to use Thebe elements
*/
var modifyDOMForThebe = () => {
// Find all code cells, replace with Thebe interactive code cells
const codeCells = document.querySelectorAll(thebe_selector)
codeCells.forEach((codeCell, index) => {
Expand All @@ -80,9 +73,31 @@ var initThebe = () => {
}
}
});
}

// Init thebe
thebelab.bootstrap();
var initThebe = () => {
// Load thebe dynamically if it's not already loaded
if (typeof thebelab === "undefined") {
console.log("[sphinx-thebe]: Loading thebe from CDN...");
$(".thebe-launch-button ").text("Loading thebe from CDN...");

const script = document.createElement('script');
script.src = `${THEBE_JS_URL}`;
document.head.appendChild(script);

// Runs once the script has finished loading
script.addEventListener('load', () => {
console.log("[sphinx-thebe]: Finished loading thebe from CDN...");
configureThebe();
modifyDOMForThebe();
thebelab.bootstrap();
});
} else {
console.log("[sphinx-thebe]: thebe already loaded, not loading from CDN...");
configureThebe();
modifyDOMForThebe();
thebelab.bootstrap();
}
}

// Helper function to munge the language name
Expand Down
31 changes: 14 additions & 17 deletions tests/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ class SphinxBuild:
path_html = path_build.joinpath("html")
path_pg_index = path_html.joinpath("index.html")
path_pg_config = path_html.joinpath("configure.html")
path_pg_chglg = path_html.joinpath("changelog.html")
cmd_base = ["sphinx-build", ".", "_build/html", "-a", "-W"]

def copy(self, path=None):
Expand Down Expand Up @@ -70,25 +69,23 @@ def test_sphinx_thebe(file_regression, sphinx_build):
lb_text = "\n\n".join([ii.prettify() for ii in launch_buttons])
file_regression.check(lb_text, basename="launch_buttons", extension=".html")

# Changelog has no thebe button directive, but should have the JS anyway
soup_chlg = BeautifulSoup(
Path(sphinx_build.path_pg_chglg).read_text(), "html.parser"
)
assert "https://unpkg.com/thebe" in soup_chlg.prettify()


def test_always_load(file_regression, sphinx_build):
def test_lazy_load(file_regression, sphinx_build):
"""Test building with thebe."""
sphinx_build.copy()
url = "https://unpkg.com/[email protected]/lib/index.js" # URL to search for

# Basic build with defaults
sphinx_build.build(cmd=["-D", "thebe_config.always_load=false"])
# Thebe JS should not be loaded by default (is loaded lazily)
sphinx_build.build()
soup_ix = BeautifulSoup(Path(sphinx_build.path_pg_index).read_text(), "html.parser")
sources = [ii.attrs.get("src") for ii in soup_ix.select("script")]
thebe_source = [ii for ii in sources if ii == url]
assert len(thebe_source) == 0

# Thebe should be loaded on a page *with* the directive and not on pages w/o it
# always_load=True should force this script to load on all pages
sphinx_build.build(cmd=["-D", "thebe_config.always_load=true"])
soup_ix = BeautifulSoup(Path(sphinx_build.path_pg_index).read_text(), "html.parser")
assert "https://unpkg.com/thebe" in soup_ix.prettify()
# Changelog has no thebe button directive, so shouldn't have JS
soup_chlg = BeautifulSoup(
Path(sphinx_build.path_pg_chglg).read_text(), "html.parser"
)
assert "https://unpkg.com/thebe" not in soup_chlg.prettify()
sources = [ii.attrs.get("src") for ii in soup_ix.select("script")]
thebe_source = [ii for ii in sources if ii == url]
assert len(thebe_source) == 1

2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ envlist = py37-sphinx3
[testenv]
usedevelop = true

[testenv:py{36,37,38}-sphinx{2,3}]
[testenv:py{36,37,38,39}-sphinx{3,4}]
extras = sphinx,testing
deps =
sphinx3: sphinx>=3,<4
Expand Down

0 comments on commit cee16c6

Please sign in to comment.