Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Per-lecture live compute: an `enable_live_compute` site option, set `false`
under `site:` in a lecture's frontmatter (or notebook metadata) to withhold
the in-page compute control on lectures Pyodide cannot run, or site-wide to
make the default opt-in. Page value over site value over on, so projects
without the flag change nothing. The gate is broad: a gated page loses the
toolbar toggle, the execute scope and the error tray together
([#114](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/114)).

## [2.7.0] - 2026-09-11

### Added
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ behaviour above, so existing projects need no changes:
In addition to launching a notebook elsewhere (Colab/Hub), the theme can run
notebook cells **in place** via [Thebe](https://thebe.readthedocs.io). This is
opt-in per project through the standard MyST `thebe` config, set under
`project.thebe` in `myst.yml` (the theme reads it from the project manifest, so
it is project-level — not per-page frontmatter). The QuantEcon default is
`project.thebe` in `myst.yml` (the theme reads it from the project manifest;
per-lecture gating is the separate `enable_live_compute` option, [below](#per-lecture-live-compute)). The QuantEcon default is
**JupyterLite** — Python runs entirely in the browser via Pyodide, with no
server or Binder to host:

Expand All @@ -112,6 +112,33 @@ stack is Pyodide-compatible. Other backends are available through the same
`thebe` config (`binder:` for BinderHub, `server:` for a hosted Jupyter
server) if a project needs a full environment.

#### Per-lecture live compute

Because Pyodide cannot run every lecture, the `enable_live_compute` site option gates
the control per page. Mark a lecture that will not run under the configured
kernel in its frontmatter:

```yaml
---
site:
enable_live_compute: false
---
```

(for a notebook, `"site": {"enable_live_compute": false}` in the notebook metadata).
Resolution is page value, then the site-wide `site.options.enable_live_compute`, then
on: with no flag anywhere the control appears wherever `project.thebe` is set,
so existing projects change nothing and a series adopts the flag by marking its
known-incompatible lectures `false`. A series that would rather certify one
lecture at a time sets `enable_live_compute: false` site-wide and opts pages in with
`true`. The gate is broad: a gated page loses the toolbar toggle, the execute
scope and the error tray together, so nothing on it tries to run.

The flag should come from testing rather than guesswork: run each lecture's
cells under the Pyodide kernel and record pass/fail per lecture, then write
the flags from the record; re-run when a lecture's package usage changes. That
certification job lives in the lecture repo, not in the theme.

### Git history in page headers

The page header shows a "Last changed: ⟨date⟩" control (aligned to the right of
Expand Down Expand Up @@ -243,6 +270,7 @@ block inside a string (`key: |`), which the theme parses.
| `favicon` | site | Favicon file, relative to `myst.yml`; served at `/favicon.ico` (the QuantEcon lectures favicon when unset) |
| `analytics_google`, `analytics_plausible` | site | Analytics IDs, rendered by `@myst-theme/site` |
| `hide_toc`, `hide_search` | site or page | Hide the contents drawer / the search control |
| `enable_live_compute` | site or page | Offer in-page live compute on a page ([Per-lecture live compute](#per-lecture-live-compute)) |
| `launch_repo_url`, `launch_repo_suffix`, `launch_branch`, `launch_notebooks_path`, `launch_source_path` | site | Notebook launcher conventions ([Launch buttons](#launch-buttons)) |
| `current_language`, `enable_rtl`, `languages`, `language_switcher_label` | site | Multilingual editions ([below](#multilingual-editions)) |
| `translators`, `translators_label` | site or page | Translator credit in the page header |
Expand Down
14 changes: 14 additions & 0 deletions app/components/Page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PageLoader } from '@myst-theme/common';
import { useLoaderData } from '@remix-run/react';
import { useCallback } from 'react';
import type { SiteManifest } from 'myst-config';
import { useBaseurl, useSiteManifest, ProjectProvider } from '@myst-theme/providers';
import { ComputeOptionsProvider, ThebeLoaderAndServer } from '@myst-theme/jupyter';
import { PageContent } from '~/components/PageContent';
import type { TemplateOptions } from '~/types';
import { resolveLiveCompute } from '~/liveCompute';
import { NavigationAndArticleWrapper } from './NavigationAndArticleWrapper';
import { PageProvider } from './PageProvider';

Expand All @@ -23,12 +25,24 @@ export function Page() {
...siteDesign,
...pageDesign,
};
// Per-lecture live compute (#114). Returning `undefined` from the override
// makes ComputeOptionsProvider report `enabled: false`, which takes the
// toolbar toggle, the error tray and the execute scope down together -- the
// broad gate, since "not compatible" means nothing on the page should run.
// Memoised: the provider recomputes its options whenever this identity
// changes.
const liveCompute = resolveLiveCompute(pageDesign as any, siteDesign as any);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, non-blocking: both as any casts go away if resolveLiveCompute takes Partial<TemplateOptions> | undefined (or { enable_live_compute?: unknown } | undefined) instead of Record<string, unknown>. TemplateOptions has no index signature, which is the only reason the cast is needed. No behaviour change, and asBoolean already accepts unknown.


Generated by Claude Code

const gateCompute = useCallback(
(options: any) => (liveCompute ? options : undefined),
[liveCompute]
);
return (
<div className="relative bg-white dark:bg-qepage-dark">
<ProjectProvider project={data.project}>
<PageProvider page={data.page}>
<NavigationAndArticleWrapper hide_toc={hide_toc} hideSearch={hide_search}>
<ComputeOptionsProvider
optionOverrideFn={gateCompute}
features={{
notebookCompute: true,
figureCompute: true,
Expand Down
42 changes: 42 additions & 0 deletions app/liveCompute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Per-lecture live compute (#114).
*
* `project.thebe` turns in-page compute on for a whole project, but Pyodide
* cannot run every lecture (numba and JAX do not import), so the `enable_live_compute`
* site option gates the control per page. Resolution, most specific first:
*
* 1. `site.enable_live_compute` in the page's frontmatter
* 2. `site.options.enable_live_compute` in myst.yml (the project-wide default)
* 3. `true` -- absent means today's behaviour: compute wherever `project.thebe`
* is set, so existing projects change nothing, and a series can adopt the
* flag incrementally by marking its known-incompatible lectures `false`.
* A series that would rather certify lectures one at a time sets the
* site-wide value to `false` and opts pages in.
*
* A gated page loses the whole compute surface, not just the toolbar toggle:
* Page.tsx turns the resolved value into the `optionOverrideFn` of
* `ComputeOptionsProvider`, which flips `compute.enabled` and with it the
* toolbar slot, the error tray and the execute scope in one place. "Not
* compatible" means nothing on the page should try to run.
*
* Pure TypeScript, no React, so tests/unit/live-compute.test.mjs runs it under
* `node --test` with type stripping like the other helpers.
*/

/** Reads a boolean option leniently: YAML booleans, or the strings a hand edit produces. */
function asBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === 'yes' || v === 'on') return true;
if (v === 'false' || v === 'no' || v === 'off') return false;
}
Comment on lines +27 to +33

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, non-blocking: the yes/no/on/off branches are unreachable in practice. The option is declared type: boolean in template.yml, and the CLI's validateBoolean (simple-validators) accepts only true/false (case-insensitive) and rejects everything else with a validation error before the theme sees it. The page-level site: block goes through the same validation (that is what #194 relies on), so a hand-edited enable_live_compute: yes is dropped upstream, not parsed here.

Either trim asBoolean to true/false and drop the matching assertions in tests/unit/live-compute.test.mjs, or keep it and note in the comment that it only matters for a value that bypasses template validation.


Generated by Claude Code

return undefined;
}

export function resolveLiveCompute(
pageOptions: Record<string, unknown> | undefined,
siteOptions: Record<string, unknown> | undefined,
): boolean {
return asBoolean(pageOptions?.enable_live_compute) ?? asBoolean(siteOptions?.enable_live_compute) ?? true;
}
3 changes: 3 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface TemplateOptions {
// the site-wide options.
hide_toc?: boolean;
hide_search?: boolean;
// Per-lecture live compute (#114): gates the whole in-page compute surface
// on this page. Page value over site value over `true`; see app/liveCompute.ts.
enable_live_compute?: boolean;

// Meta / SEO and analytics, passed through to @myst-theme/site.
twitter?: string; // handle for twitter:site / twitter:creator, `@` optional
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ below.
| `analytics_plausible` | string | site | — | Plausible domain |
| `hide_toc` | boolean | site or page | `false` | hide the contents drawer and its toggle |
| `hide_search` | boolean | site or page | `false` | hide the search control |
| `enable_live_compute` | boolean | site or page | `true` | offer in-page live compute on a page ([notebooks](notebooks.md#per-lecture-live-compute)) |
| `launch_repo_url` | string | site | derived | explicit notebook repository |
| `launch_repo_suffix` | string | site | `.notebooks` | suffix locating the notebook repo |
| `launch_branch` | string | site | `main` | notebook repo branch |
Expand Down
24 changes: 24 additions & 0 deletions docs/notebooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,27 @@ projects that need a full environment.

The deployed Sphinx lecture sites set `thebe: false`, so a series moving from
them changes nothing by leaving `project.thebe` unset.

### Per-lecture live compute

Because Pyodide cannot run every lecture, the `enable_live_compute` site
option gates the control per page. A lecture that will not run under the
configured kernel sets it under `site:` in its frontmatter (for a notebook,
`"site": {"enable_live_compute": false}` in the notebook metadata):

```yaml
---
site:
enable_live_compute: false
---
```

Resolution is page value, then the site-wide `site.options.enable_live_compute`,
then on: with no flag anywhere the control appears wherever `project.thebe` is
set, so a series adopts the flag by marking its known-incompatible lectures
`false`. A series that would rather certify one lecture at a time sets
`enable_live_compute: false` site-wide and opts pages in with `true`. The gate
is broad: a gated page loses the toolbar toggle, the execute scope and the
error tray together, so nothing on it tries to run. The flag should come from
running each lecture's cells under the kernel and recording pass/fail, a job
that lives in the lecture repo rather than the theme.
8 changes: 8 additions & 0 deletions template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ options:
- id: hide_search
type: boolean
description: Hide the toolbar search control.
- id: enable_live_compute
type: boolean
description: >
Whether in-page live compute (`project.thebe`) is offered on a page. Set
`false` under `site:` in a lecture's frontmatter when it cannot run under
the configured kernel (numba, JAX and other packages Pyodide lacks); set
it site-wide to change the default for every page. Unset means on
wherever `project.thebe` is configured.
# --- Notebook launcher (see README "Launch buttons") ---
- id: launch_repo_url
type: string
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/live-compute.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Unit tests for the per-lecture live-compute resolver (app/liveCompute.ts,
* #114): page value over site value over an enabled default. Run with
* `npm run test:unit` (node --test with type stripping, Node >= 23.6).
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { resolveLiveCompute } from '../../app/liveCompute.ts';

test('absent everywhere: enabled (today\'s behaviour, project.thebe decides)', () => {
assert.equal(resolveLiveCompute(undefined, undefined), true);
assert.equal(resolveLiveCompute({}, {}), true);
});

test('site-wide false turns the default off; a page opts back in', () => {
assert.equal(resolveLiveCompute({}, { enable_live_compute: false }), false);
assert.equal(resolveLiveCompute({ enable_live_compute: true }, { enable_live_compute: false }), true);
});

test('page false wins over an enabled site (incremental adoption)', () => {
assert.equal(resolveLiveCompute({ enable_live_compute: false }, {}), false);
assert.equal(resolveLiveCompute({ enable_live_compute: false }, { enable_live_compute: true }), false);
});

test('a page that sets other site keys but not this one inherits the site value', () => {
assert.equal(resolveLiveCompute({ hide_search: true }, { enable_live_compute: false }), false);
assert.equal(resolveLiveCompute({ hide_search: true }, {}), true);
});

test('string spellings from hand edits are read; garbage falls through', () => {
assert.equal(resolveLiveCompute({ enable_live_compute: 'false' }, {}), false);
assert.equal(resolveLiveCompute({ enable_live_compute: 'no' }, {}), false);
assert.equal(resolveLiveCompute({ enable_live_compute: 'TRUE' }, { enable_live_compute: false }), true);
assert.equal(resolveLiveCompute({ enable_live_compute: 'maybe' }, { enable_live_compute: false }), false);
assert.equal(resolveLiveCompute({ enable_live_compute: 'maybe' }, {}), true);
});
5 changes: 4 additions & 1 deletion tests/visual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ THEME_TEMPLATE="$PWD/.deploy/quantecon-theme" \
- `fixture-no-thebe/` — the same without `project.thebe`, for the absent live-compute
toggle (second port)
- `fixture-rtl/` — a Persian edition with `enable_rtl` (third port), for the `rtl`
snapshot and the right-to-left assertions (#91)
snapshot and the right-to-left assertions (#91); also carries the per-lecture
live-compute cases (#114) — `project.thebe` on, `enable_live_compute: false` site-wide,
`notebook.ipynb` inheriting it and `notebook-live.ipynb` opting back in — because
its only snapshot is the landing page, so the extra pages move no baseline
- `fixture/myst.yml.in` — template; `serve.sh` writes `myst.yml` from it
- `serve.sh` — `myst start` with the chosen `THEME_TEMPLATE`
- `serve-static.sh` / `static-server.mjs` — `myst build --html` of the fixture behind
Expand Down
11 changes: 11 additions & 0 deletions tests/visual/fixture-rtl/myst.yml.in
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@ project:
# theme's parenthesised markers in RTL whichever mystmd parses it.
plugins:
- ../fixture/fancy-lists.mjs
# Live compute is on for the project, but the site-wide `enable_live_compute: false`
# below turns it off by default; notebook-live.ipynb opts back in per page.
# This fixture carries the per-lecture gate tests (#114) because its only
# snapshot is the landing page, so adding notebook pages moves no baseline.
thebe:
lite: true
toc:
- file: intro.md
- file: notebook.ipynb
- file: notebook-live.ipynb
site:
title: QE Theme RTL Fixture
template: __THEME__
options:
# Per-lecture live compute (#114): off by default for this project; a
# page opts in with `site: {enable_live_compute: true}` (notebook-live.ipynb).
enable_live_compute: false
current_language: fa
enable_rtl: true
language_switcher_label: تغییر زبان
Expand Down
84 changes: 84 additions & 0 deletions tests/visual/fixture-rtl/notebook-live.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Notebook opted into compute\n\nThis page exercises **notebook cell output rendering** — the area changed by the\n`@myst-theme` v1.0.0 output-node AST change. Outputs are baked in (no execution).\n\nThis page sets `site: {enable_live_compute: true}` in its notebook metadata, overriding the site-wide `false` (#114).\n"
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"hello from a stream output\n"
]
}
],
"source": [
"print(\"hello from a stream output\")"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
" value\n",
"count 3.0\n",
"mean 2.0\n",
"std 1.0"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"summary # a plain-text execute_result"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"ename": "ValueError",
"evalue": "a deliberate error to render the traceback",
"output_type": "error",
"traceback": [
"Traceback (most recent call last):",
"ValueError: a deliberate error to render the traceback"
]
}
],
"source": [
"raise ValueError(\"a deliberate error to render the traceback\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
},
"site": {
"enable_live_compute": true
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading