Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Lazy load thebe javascript #41

Merged
merged 6 commits into from
Jan 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/thebe@0.8.2/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