diff --git a/CHANGELOG.md b/CHANGELOG.md index 069fcf4b6..6c99ed0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 64843dfcf..9befd507c 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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 | diff --git a/app/components/Page.tsx b/app/components/Page.tsx index ccc1e4d24..aefe0c9d1 100644 --- a/app/components/Page.tsx +++ b/app/components/Page.tsx @@ -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'; @@ -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); + const gateCompute = useCallback( + (options: any) => (liveCompute ? options : undefined), + [liveCompute] + ); return (
| undefined, + siteOptions: Record | undefined, +): boolean { + return asBoolean(pageOptions?.enable_live_compute) ?? asBoolean(siteOptions?.enable_live_compute) ?? true; +} diff --git a/app/types.ts b/app/types.ts index d0226061e..8a1b439f5 100644 --- a/app/types.ts +++ b/app/types.ts @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index a3d1222d5..0a631f6d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/notebooks.md b/docs/notebooks.md index 489eb321e..be9fe6db7 100644 --- a/docs/notebooks.md +++ b/docs/notebooks.md @@ -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. diff --git a/template.yml b/template.yml index 693773989..b6cd72ac2 100644 --- a/template.yml +++ b/template.yml @@ -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 diff --git a/tests/unit/live-compute.test.mjs b/tests/unit/live-compute.test.mjs new file mode 100644 index 000000000..81f7cd4f2 --- /dev/null +++ b/tests/unit/live-compute.test.mjs @@ -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); +}); diff --git a/tests/visual/README.md b/tests/visual/README.md index c62b462bc..5ddcff46c 100644 --- a/tests/visual/README.md +++ b/tests/visual/README.md @@ -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 diff --git a/tests/visual/fixture-rtl/myst.yml.in b/tests/visual/fixture-rtl/myst.yml.in index 99f4c5f82..f8a61c647 100644 --- a/tests/visual/fixture-rtl/myst.yml.in +++ b/tests/visual/fixture-rtl/myst.yml.in @@ -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: تغییر زبان diff --git a/tests/visual/fixture-rtl/notebook-live.ipynb b/tests/visual/fixture-rtl/notebook-live.ipynb new file mode 100644 index 000000000..d9d4ec649 --- /dev/null +++ b/tests/visual/fixture-rtl/notebook-live.ipynb @@ -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 +} \ No newline at end of file diff --git a/tests/visual/fixture-rtl/notebook.ipynb b/tests/visual/fixture-rtl/notebook.ipynb new file mode 100644 index 000000000..4179ef3b0 --- /dev/null +++ b/tests/visual/fixture-rtl/notebook.ipynb @@ -0,0 +1,81 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Notebook without 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 project sets `enable_live_compute: false` site-wide and this page does not override it, so no live-compute control renders (#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" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index df57c5893..61de4da96 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -323,6 +323,33 @@ test.describe("QuantEcon theme — visual regression", () => { await expect(page.getByRole("heading", { name: "Notebook outputs" })).toBeVisible(); await expect(page.getByRole("button", { name: /start compute/i })).toHaveCount(0); }); + + // Per-lecture gate (#114): the RTL fixture has `project.thebe` but sets + // `enable_live_compute: false` site-wide. Its plain notebook page inherits that and + // shows no control; notebook-live.ipynb sets `site: {enable_live_compute: true}` + // in its metadata and gets it back. The main fixture's `live-compute-toggle` + // above is the third case: no flag anywhere means on. + test("live-compute-per-page", async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== "desktop-chrome", + "the live-compute toggle lives in the desktop header toolbar" + ); + const rtlBase = `http://localhost:${process.env.RTL_PORT}`; + await page.goto(`${rtlBase}/notebook`, { waitUntil: "domcontentloaded" }); + await settle(page); + await expect(page.getByRole("heading", { name: "Notebook without compute" })).toBeVisible(); + await expect(page.getByRole("button", { name: /start compute/i })).toHaveCount(0); + // The gate is broad: the compute-enabled error tray is gone too, so + // nothing on the page can try to execute. + await expect(page.locator("#qe-compute-slot")).toBeEmpty(); + + await page.goto(`${rtlBase}/notebook-live`, { waitUntil: "domcontentloaded" }); + await settle(page); + await expect(page.getByRole("heading", { name: "Notebook opted into compute" })).toBeVisible(); + await expect( + page.locator("#qe-compute-slot").getByRole("button", { name: /start compute/i }) + ).toBeVisible(); + }); }); /**