diff --git a/CHANGELOG.md b/CHANGELOG.md index 142d4fc2c..357296f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 builds, so previews show post-cutover sources and every theme PR exercises it ([#204](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/204)) ([#222](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/222)). +### Changed +- **Breaking: the Launch control is now opt-in and explicitly configured.** It + renders only when `launch_notebook_repo` names a notebook repository *and* + `launch_colab` is true; with either unset there is no control and no gap + where it sat. Nothing is derived from `project.github` any more, so a site + without a notebooks repository no longer sends readers to a repository whose + name was guessed from the source one and may not exist — as + `QuantEcon/lecture-wasm.notebooks`, a 404, was on every lecture-wasm page. + `launch_repo_url`, `launch_repo_suffix`, `launch_branch`, + `launch_notebooks_path` and `launch_source_path` are replaced by + `launch_notebook_repo`, `launch_notebook_branch`, `launch_notebook_dir` and + `launch_notebook_source_dir`; `launch_repo_suffix` and the `.myst` rule are + gone. A site that relied on the guessed repository keeps its Launch link by + naming that repository in `launch_notebook_repo` and setting + `launch_colab: true`. The flat names are the nested paths these become once + mystmd supports structured template options, so that migration is mechanical + ([#205](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/205)) ([#224](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/224)). + ## [2.7.0] - 2026-09-11 ### Added diff --git a/README.md b/README.md index 64843dfcf..666a6a98e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,10 @@ downloads: ### Launch Notebooks -The launch notebooks capability has been developed to mirror capabilities in the previous QuantEcon theme. By default it assumes the `.notebooks` suffix convention for repository naming when locating the notebook to launch. +The launch notebooks capability mirrors the previous QuantEcon theme's, which +shows the control only for a site that names a notebook repository. Nothing is +assumed about repository naming: a site says where its notebooks are, or gets +no Launch control. The **Launch** control in the toolbar is a direct link to Google Colab, the single launch target — it provides GPU access for the lectures that need it. @@ -68,18 +71,34 @@ private JupyterHub, whose launcher entry was removed in lecture repo had ever configured one. For running cells without leaving the page, see [Live compute](#live-compute-thebe--jupyterlite) below. -The repo/branch/path conventions are configurable under `site.options` in -`myst.yml` (MyST's theme-options section; see [Site options](#site-options) -for the full list). All keys are optional and the defaults reproduce the -behaviour above, so existing projects need no changes: +Launch is **opt-in**: it appears only once a site names a notebook repository +*and* turns a launch service on, under `site.options` in `myst.yml` (MyST's +theme-options section; see [Site options](#site-options) for the full list). + +```yaml +site: + options: + launch_notebook_repo: QuantEcon/lecture-foo.notebooks + launch_colab: true +``` + +With either unset there is no Launch control. Nothing is derived from +`project.github`, so a site without a notebooks repository cannot link readers +to one that does not exist; set `launch_notebook_repo` only when the repository +really is there. | Option | Default | Purpose | | ------ | ------- | ------- | -| `launch_repo_suffix` | `.notebooks` | Suffix appended to the source repo to locate the notebook repo | -| `launch_branch` | `main` | Branch in the notebook repo to launch from | -| `launch_repo_url` | _(derived from `github` + suffix)_ | Explicit notebook repo, for when it isn't `.notebooks` | -| `launch_notebooks_path` | _(none)_ | Sub-directory within the notebook repo where the notebooks live | -| `launch_source_path` | _(none)_ | Path prefix stripped from the page location (e.g. a `lectures/` source dir) | +| `launch_notebook_repo` | _(none)_ | Notebook repository, as a full URL or `org/repo` | +| `launch_notebook_branch` | `main` | Branch in the notebook repo to launch from | +| `launch_notebook_dir` | _(none)_ | Sub-directory within the notebook repo where the notebooks live | +| `launch_notebook_source_dir` | _(none)_ | Path prefix stripped from the page location (e.g. a `lectures/` source dir) | +| `launch_colab` | _(off)_ | Offer Google Colab | + +Coming from `quantecon-book-theme`: `nb_repository_url` → `launch_notebook_repo`, +`nb_branch` → `launch_notebook_branch`, `nb_path_to_notebooks` → +`launch_notebook_dir`, `path_to_docs` → `launch_notebook_source_dir`, and +`launch_buttons.colab_url` → `launch_colab: true`. ### Live compute (Thebe / JupyterLite) @@ -243,7 +262,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 | -| `launch_repo_url`, `launch_repo_suffix`, `launch_branch`, `launch_notebooks_path`, `launch_source_path` | site | Notebook launcher conventions ([Launch buttons](#launch-buttons)) | +| `launch_notebook_repo`, `launch_notebook_branch`, `launch_notebook_dir`, `launch_notebook_source_dir`, `launch_colab` | site | Notebook launcher ([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 | | `git_metadata` | page | YAML block pinning the "Last changed" control by hand ([Git history](#git-history-in-page-headers)) | diff --git a/app/components/PageHeaderHistory.tsx b/app/components/PageHeaderHistory.tsx index f477b7377..339cae24b 100644 --- a/app/components/PageHeaderHistory.tsx +++ b/app/components/PageHeaderHistory.tsx @@ -119,8 +119,8 @@ export function PageHeaderHistory({ alignEnd = true }: { alignEnd?: boolean } = const lastModified = meta?.last_modified ?? changelog[0]?.date; if (!lastModified) return null; - // Commit links target the source repository itself, so unlike - // LaunchButton's notebook URLs the `.myst` suffix must be kept. + // Commit links target the source repository itself, exactly as + // `project.github` gives it -- a `.myst` suffix included. const github: string | undefined = frontmatter?.github; const repoUrl = github?.startsWith("https://github.com/") ? github.replace(/\/$/, "") diff --git a/app/components/toolbar/LaunchButton.tsx b/app/components/toolbar/LaunchButton.tsx index b0d216ed8..f01bb2a91 100644 --- a/app/components/toolbar/LaunchButton.tsx +++ b/app/components/toolbar/LaunchButton.tsx @@ -1,4 +1,4 @@ -import { useProjectManifest, useSiteManifest } from '@myst-theme/providers'; +import { useSiteManifest } from '@myst-theme/providers'; import { CirclePlay } from 'lucide-react'; import type { SiteManifest } from 'myst-config'; import { usePage } from '../PageProvider'; @@ -7,35 +7,36 @@ import { buildColabUrl, type LaunchConfig } from './launchUrls'; import { Tooltip } from './Tooltip'; export function LaunchButton({ size, showLabel }: { size: number; showLabel?: boolean }) { - const project = useProjectManifest(); const page = usePage(); const launchOptions: TemplateOptions = (useSiteManifest() as SiteManifest & TemplateOptions)?.options ?? {}; - // Source org/repo from `project.github`, minus the `.myst` suffix if present. - const orgRepo = project?.github - ? new URL(project.github).pathname.slice(1).replace(/\.myst$/, '') - : undefined; - const location = page?.location; - const { - launch_repo_url, - launch_repo_suffix, - launch_branch, - launch_notebooks_path, - launch_source_path, + launch_notebook_repo, + launch_notebook_branch, + launch_notebook_dir, + launch_notebook_source_dir, + launch_colab, } = launchOptions; + const location = page?.location; - // Without a source repo or a page path there is no notebook to open, so the - // control is not rendered at all rather than shown as an inert affordance. - if (!orgRepo || !location) return null; + // Launch is opt-in on both axes: `launch_notebook_repo` says where the + // notebook lives, `launch_colab` says something can open it, and neither is + // inferred. A site that has no notebooks repository gets no control at all, + // rather than a link to a repository name that was guessed from the source + // one and may not exist. + // + // A blank string counts as unset: the CLI validates an empty option as a + // string and passes it through, and a control linking to `github//` helps + // nobody. + const repo = launch_notebook_repo?.trim(); + if (!repo || !launch_colab || !location) return null; const config: LaunchConfig = { - repoUrl: launch_repo_url, - repoSuffix: launch_repo_suffix, - branch: launch_branch, - notebooksPath: launch_notebooks_path, - sourcePath: launch_source_path, + repo, + branch: launch_notebook_branch, + dir: launch_notebook_dir, + sourceDir: launch_notebook_source_dir, }; // The tooltip merges onto the anchor (`asChild`) rather than rendering its @@ -44,7 +45,7 @@ export function LaunchButton({ size, showLabel }: { size: number; showLabel?: bo return ( -
  • + {/* Empty when the site configures no launch; `empty:hidden` keeps + it from taking a row in the menu's spacing. */} +
  • diff --git a/app/components/toolbar/Toolbar.tsx b/app/components/toolbar/Toolbar.tsx index 99fedb979..6489f2463 100644 --- a/app/components/toolbar/Toolbar.tsx +++ b/app/components/toolbar/Toolbar.tsx @@ -78,7 +78,11 @@ export function Toolbar() { {/* Portal target for the live-compute toggle (see ComputeToolbarSlot). `empty:hidden` keeps it from adding a gap on non-notebook pages. */}
  • -
  • + {/* Launch is opt-in, so this slot is empty on any site that configures + no notebook repository or service. `empty:hidden` (`.class:empty`, + which outranks `md:block` on specificity) keeps it from taking a + gap there, as the compute and language slots do. */} +
  • diff --git a/app/components/toolbar/launchUrls.ts b/app/components/toolbar/launchUrls.ts index 91a51ed9b..b6020c690 100644 --- a/app/components/toolbar/launchUrls.ts +++ b/app/components/toolbar/launchUrls.ts @@ -3,74 +3,68 @@ // Kept free of React so the logic can be unit-tested in isolation // (see tests/unit/launch-urls.test.mjs). Ported from the URL construction in // quantecon-book-theme's `launch.py` (`nb_path_to_notebooks`, `path_to_docs`). +// +// The notebook repository is always configured, never derived: a guessed name +// that happens not to exist sends the reader to a 404 on a site that never +// asked for the control. export interface LaunchConfig { - repoUrl?: string; // launch_repo_url — explicit notebook repo, overrides the derived one - repoSuffix?: string; // launch_repo_suffix — appended to the source repo (default ".notebooks") - branch?: string; // launch_branch — notebook repo branch (default "main") - notebooksPath?: string; // launch_notebooks_path — subdir within the notebook repo - sourcePath?: string; // launch_source_path — prefix stripped from the page path + repo: string; // launch_notebook_repo — full URL or `org/repo` + branch?: string; // launch_notebook_branch — notebook repo branch (default "main") + dir?: string; // launch_notebook_dir — subdir within the notebook repo + sourceDir?: string; // launch_notebook_source_dir — prefix stripped from the page path } -export const DEFAULT_REPO_SUFFIX = '.notebooks'; export const DEFAULT_BRANCH = 'main'; const COLAB_BASE_URL = 'https://colab.research.google.com/github/'; +/** Normalises an option value: surrounding whitespace, then surrounding slashes. */ function trimSlashes(value: string): string { - return value.replace(/^\/+|\/+$/g, ''); + return value.trim().replace(/^\/+|\/+$/g, ''); } /** - * org/repo for the notebook repository. Derived from the source repo plus the - * configured suffix, unless an explicit `launch_repo_url` (full URL or - * `org/repo` string) is given. + * org/repo for the notebook repository, from a full URL or a bare `org/repo`. */ -export function notebookOrgRepo(sourceOrgRepo: string, config: LaunchConfig = {}): string { - const { repoUrl } = config; - if (repoUrl) { - let path = repoUrl; - try { - path = new URL(repoUrl).pathname; - } catch { - // Not a full URL — treat the value as a bare `org/repo` string. - } - return trimSlashes(path).replace(/\.git$/, ''); +export function notebookOrgRepo(repo: string): string { + let path = repo; + try { + path = new URL(repo).pathname; + } catch { + // Not a full URL — treat the value as a bare `org/repo` string. } - const suffix = config.repoSuffix ?? DEFAULT_REPO_SUFFIX; - return `${sourceOrgRepo}${suffix}`; + return trimSlashes(path).replace(/\.git$/, ''); } /** * Path of the notebook within the notebook repo, relative to its root * (no leading slash). Strips the source file extension robustly (handles dots - * in directory names), removes the `source_path` prefix, and prepends - * `notebooks_path`. + * in directory names), removes the `source_dir` prefix, and prepends `dir`. */ -export function notebookRelPath(location: string, config: LaunchConfig = {}): string { +export function notebookRelPath(location: string, config: Partial = {}): string { // Strip leading slash and the trailing source extension only (not every dot). let path = location.replace(/^\/+/, '').replace(/\.[^/.]+$/, ''); - // Strip the source_path prefix if the page lives under it. - const sourcePath = trimSlashes(config.sourcePath ?? ''); - if (sourcePath && (path === sourcePath || path.startsWith(`${sourcePath}/`))) { - path = trimSlashes(path.slice(sourcePath.length)); + // Strip the source_dir prefix if the page lives under it. + const sourceDir = trimSlashes(config.sourceDir ?? ''); + if (sourceDir && (path === sourceDir || path.startsWith(`${sourceDir}/`))) { + path = trimSlashes(path.slice(sourceDir.length)); } - // Prepend the notebooks_path subdir. - const notebooksPath = trimSlashes(config.notebooksPath ?? ''); - const prefix = notebooksPath ? `${notebooksPath}/` : ''; + // Prepend the notebook subdir. + const dir = trimSlashes(config.dir ?? ''); + const prefix = dir ? `${dir}/` : ''; return `${prefix}${path}.ipynb`; } /** Public Google Colab launch URL for the given page. */ -export function buildColabUrl( - sourceOrgRepo: string, - location: string, - config: LaunchConfig = {}, -): string { - const orgRepo = notebookOrgRepo(sourceOrgRepo, config); - const branch = config.branch ?? DEFAULT_BRANCH; +export function buildColabUrl(location: string, config: LaunchConfig): string { + const orgRepo = notebookOrgRepo(config.repo); + // An option set to an empty (or blank) string reaches the theme as one -- + // the CLI validates it as a string and passes it through -- so falling back + // on nullish alone would build `blob//`, a 404. Trimmed like the other + // path options, which a copied `/main/` needs. + const branch = trimSlashes(config.branch ?? '') || DEFAULT_BRANCH; const relPath = notebookRelPath(location, config); return `${COLAB_BASE_URL}${orgRepo}/blob/${branch}/${relPath}`; } - diff --git a/app/types.ts b/app/types.ts index 7460c74b9..3191d6786 100644 --- a/app/types.ts +++ b/app/types.ts @@ -28,15 +28,21 @@ export interface TemplateOptions { */ git_metadata?: string | GitMetadata; - // Notebook launcher configuration (set under `site.options` in myst.yml), - // so non-default branches, repo naming and nested lecture dirs work. All - // keys are optional; the defaults are `.notebooks`, branch `main`, - // and no source/notebooks path prefixing — the page path is used as-is. - launch_repo_url?: string; // explicit notebook repo URL; overrides ` + launch_repo_suffix` - launch_repo_suffix?: string; // suffix appended to the source repo to locate the notebook repo (default ".notebooks") - launch_branch?: string; // notebook repo branch (default "main") - launch_notebooks_path?: string; // subdir within the notebook repo where notebooks live - launch_source_path?: string; // prefix stripped from the page path + // Notebook launcher configuration (set under `site.options` in myst.yml). + // Launch renders only with `launch_notebook_repo` set and `launch_colab` + // true: nothing is derived from `project.github`, so a site with no + // notebooks repository shows no control instead of a dead link. + // + // The names are the nested paths these become once mystmd supports + // structured template options (QuantEcon/mystmd#112) -- `launch.notebook.repo`, + // `launch.colab` -- with underscores, so that migration is mechanical. The + // `launch_notebook_*` group says where the notebook is; the service keys say + // what can open it, because one source serves every service. + launch_notebook_repo?: string; // notebook repository, full URL or `org/repo` + launch_notebook_branch?: string; // notebook repo branch (default "main") + launch_notebook_dir?: string; // subdir within the notebook repo where notebooks live + launch_notebook_source_dir?: string; // prefix stripped from the page path + launch_colab?: boolean; // offer Google Colab // Multilingual editions and translator credit. Declared in template.yml -- // the CLI drops undeclared site options. The two lists arrive as strings diff --git a/docs/configuration.md b/docs/configuration.md index a3d1222d5..b803656df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,11 +25,11 @@ 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 | -| `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 | -| `launch_notebooks_path` | string | site | — | sub-directory of the notebook repo | -| `launch_source_path` | string | site | — | prefix stripped from the page path | +| `launch_notebook_repo` | string | site | — | notebook repository (full URL or `org/repo`); no Launch control without it | +| `launch_notebook_branch` | string | site | `main` | notebook repo branch | +| `launch_notebook_dir` | string | site | — | sub-directory of the notebook repo | +| `launch_notebook_source_dir` | string | site | — | prefix stripped from the page path | +| `launch_colab` | boolean | site | `false` | offer Google Colab ([launch](launch.md)) | | `current_language` | string | site | — | BCP 47 code of this edition | | `enable_rtl` | boolean | site | `false` | right-to-left layout | | `languages` | YAML block | site | — | the editions, for the language switcher and `hreflang` | @@ -50,5 +50,5 @@ list. Only the keys marked "site or page" or "page" are read per page. | --- | --- | | `site.title` | the site name in the header and `og:site_name` | | `site.parts.footer` | the footer content ([layout](layout.md)) | -| `project.github` | commit and edit links, and the notebook repo derivation | +| `project.github` | commit and edit links (the notebook repository is named by `launch_notebook_repo`, never derived from this) | | `project.thebe` | in-page live compute ([notebooks](notebooks.md)) | diff --git a/docs/launch.md b/docs/launch.md index 31745c8a0..bfdd2065e 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -5,19 +5,43 @@ single launch target the deployed lecture sites offer. BinderHub and a private JupyterHub are deliberately not offered (theme issues #26 and #87). Running cells in place is a separate feature; see [notebooks](notebooks.md). -The notebook is looked up in a companion repository, by convention -`.notebooks` on branch `main`, with the page path used as-is. -Five `site.options` keys adjust that, mirroring the book theme's -`launch_buttons` settings; all are optional and the defaults reproduce the -convention: +**Launch is opt-in.** It renders only when a site both names a notebook +repository and turns a launch service on: + +```yaml +site: + options: + launch_notebook_repo: QuantEcon/lecture-foo.notebooks + launch_colab: true +``` + +With either unset there is no Launch control, on the desktop toolbar or in the +mobile overflow menu. Nothing is derived from `project.github`, so a site +without a notebooks repository cannot end up linking readers to one that does +not exist. Set `launch_notebook_repo` only when that repository really exists. | Option | Default | Purpose | | --- | --- | --- | -| `launch_repo_suffix` | `.notebooks` | suffix appended to the source repo to locate the notebook repo | -| `launch_branch` | `main` | branch in the notebook repo | -| `launch_repo_url` | derived | explicit notebook repository (full URL or `org/repo`) | -| `launch_notebooks_path` | — | sub-directory of the notebook repo holding the notebooks | -| `launch_source_path` | — | prefix stripped from the page path (e.g. `lectures/`) | - -A `.myst` suffix on the source repository is dropped when deriving the notebook -repository, so `lecture-python.myst` launches from `lecture-python.notebooks`. +| `launch_notebook_repo` | — | notebook repository, as a full URL or `org/repo` | +| `launch_notebook_branch` | `main` | branch in the notebook repo | +| `launch_notebook_dir` | — | sub-directory of the notebook repo holding the notebooks | +| `launch_notebook_source_dir` | — | prefix stripped from the page path (e.g. `lectures/`) | +| `launch_colab` | off | offer Google Colab | + +The `launch_notebook_*` group says where the notebook lives; `launch_colab` +says what can open it. They are kept apart because one notebook source serves +every service, so a service is not a property of the source. + +The names are flat only because template options cannot yet be structured. +Each is the nested path it becomes when they can (`launch.notebook.repo`, +`launch.colab` — QuantEcon/mystmd#112), so that migration is mechanical. + +## Coming from quantecon-book-theme + +| Sphinx key | Theme option | +| --- | --- | +| `nb_repository_url` | `launch_notebook_repo` | +| `nb_branch` | `launch_notebook_branch` | +| `nb_path_to_notebooks` | `launch_notebook_dir` | +| `path_to_docs` | `launch_notebook_source_dir` | +| `launch_buttons.colab_url` | `launch_colab: true` | diff --git a/template.yml b/template.yml index 8d519df55..725205c69 100644 --- a/template.yml +++ b/template.yml @@ -104,23 +104,29 @@ options: type: boolean description: Hide the toolbar search control. # --- Notebook launcher (see docs/launch.md) --- - - id: launch_repo_url + # Opt-in on both axes: the control renders only with `launch_notebook_repo` + # set and `launch_colab` true. Names are flat because template options are + # scalar-only; each is the nested path it becomes once mystmd supports + # structured options (QuantEcon/mystmd#112). + - id: launch_notebook_repo type: string description: > - Explicit notebook repository (full URL or `org/repo`); overrides - `` + `launch_repo_suffix`. - - id: launch_repo_suffix - type: string - description: Suffix appended to the source repo to locate the notebook repo (`.notebooks` when unset). - - id: launch_branch + Notebook repository (full URL or `org/repo`). Set it only when that + repository exists: with it unset there is no Launch control. + - id: launch_notebook_branch type: string description: Branch of the notebook repo to launch from (`main` when unset). - - id: launch_notebooks_path + - id: launch_notebook_dir type: string description: Sub-directory of the notebook repo where the notebooks live. - - id: launch_source_path + - id: launch_notebook_source_dir type: string description: Path prefix stripped from the page location (e.g. `lectures/`). + - id: launch_colab + type: boolean + description: > + Offer Google Colab as a launch target. Off when unset, so a site shows + Launch only once it has both a notebook repository and a service. # --- Git history (per-page override of plugins/git-metadata.mjs) --- - id: git_metadata type: string diff --git a/tests/unit/launch-urls.test.mjs b/tests/unit/launch-urls.test.mjs index 6a1ff2662..0562090fb 100644 --- a/tests/unit/launch-urls.test.mjs +++ b/tests/unit/launch-urls.test.mjs @@ -4,8 +4,9 @@ * * The module is plain (erasable) TypeScript and free of React, so Node's * built-in type stripping runs it directly under `node --test` — no build - * step. Covers the `.notebooks` suffix and `main` branch defaults, each - * `launch_*` override, and the nested lecture-dir path handling. + * step. Covers the configured notebook repository in both spellings, the + * `main` branch default, each `launch_notebook_*` option, and the nested + * lecture-dir path handling. * * Requires Node >= 23.6 (type stripping of the imported `.ts` is on by * default), matching the CI Node 24 runner. The theme runtime itself still @@ -23,95 +24,79 @@ import { notebookRelPath, } from '../../app/components/toolbar/launchUrls.ts'; -const SOURCE = 'QuantEcon/lecture-foo'; +const NOTEBOOKS = 'QuantEcon/lecture-foo.notebooks'; -test('defaults reproduce the historical Colab URL (backward-compat)', () => { +test('the configured repository and the main branch default', () => { assert.equal( - buildColabUrl(SOURCE, '/notebook.ipynb'), - 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/main/notebook.ipynb', + buildColabUrl('/notebook.ipynb', { repo: NOTEBOOKS }), + 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/main/notebook.ipynb' ); }); -test('a source markdown page maps to the .ipynb in the notebook repo', () => { - assert.equal( - buildColabUrl(SOURCE, '/intro.md'), - 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/main/intro.ipynb', - ); -}); - -test('nested lecture dirs are preserved in the path', () => { - assert.equal( - notebookRelPath('/dynamic_programming/mccall_model.md'), - 'dynamic_programming/mccall_model.ipynb', - ); - assert.equal( - buildColabUrl(SOURCE, '/dynamic_programming/mccall_model.md'), - 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/main/dynamic_programming/mccall_model.ipynb', - ); +test('notebookOrgRepo accepts a bare org/repo and a full URL', () => { + assert.equal(notebookOrgRepo(NOTEBOOKS), NOTEBOOKS); + assert.equal(notebookOrgRepo(`https://github.com/${NOTEBOOKS}`), NOTEBOOKS); + assert.equal(notebookOrgRepo(`https://github.com/${NOTEBOOKS}.git`), NOTEBOOKS); + assert.equal(notebookOrgRepo(`https://github.com/${NOTEBOOKS}/`), NOTEBOOKS); + assert.equal(notebookOrgRepo(`/${NOTEBOOKS}/`), NOTEBOOKS); }); -test('only the trailing extension is stripped (dots in dir names survive)', () => { - // Splitting at the first dot (`location.split('.')[0]`) would truncate - // this to `/python`. - assert.equal(notebookRelPath('/python.programming/intro.md'), 'python.programming/intro.ipynb'); +test('nothing is derived from the source repository', () => { + // The `.myst` -> `.notebooks` rule is gone: whatever is configured is used + // verbatim, so a site cannot end up pointing at a repository it never named. + assert.equal(notebookOrgRepo('QuantEcon/lecture-python.myst'), 'QuantEcon/lecture-python.myst'); }); -test('launch_source_path strips the path_to_docs prefix', () => { - const config = { sourcePath: 'lectures' }; - assert.equal(notebookRelPath('/lectures/dynamic/mccall.md', config), 'dynamic/mccall.ipynb'); - // A page outside the prefix is left untouched. - assert.equal(notebookRelPath('/other/page.md', config), 'other/page.ipynb'); - // Slashes around the configured prefix are tolerated. - assert.equal(notebookRelPath('/lectures/intro.md', { sourcePath: '/lectures/' }), 'intro.ipynb'); +test('the page extension is replaced, not every dot', () => { + assert.equal(notebookRelPath('/lectures/v1.2/intro.md'), 'lectures/v1.2/intro.ipynb'); + assert.equal(notebookRelPath('intro.md'), 'intro.ipynb'); + assert.equal(notebookRelPath('/intro'), 'intro.ipynb'); }); -test('launch_notebooks_path prepends the nb_path_to_notebooks subdir', () => { - assert.equal( - notebookRelPath('/intro.md', { notebooksPath: 'notebooks' }), - 'notebooks/intro.ipynb', - ); +test('launch_notebook_branch overrides the default branch', () => { assert.equal( - notebookRelPath('/intro.md', { notebooksPath: '/notebooks/' }), - 'notebooks/intro.ipynb', + buildColabUrl('/intro.md', { repo: NOTEBOOKS, branch: 'publish' }), + 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/publish/intro.ipynb' ); }); -test('launch_branch overrides the default branch', () => { +test('an empty or slash-wrapped branch still resolves to the default', () => { + // The CLI passes an empty string through, so `??` alone would build `blob//`. + for (const branch of ['', '/', ' ']) { + assert.equal( + buildColabUrl('/intro.md', { repo: NOTEBOOKS, branch }), + 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/main/intro.ipynb' + ); + } assert.equal( - buildColabUrl(SOURCE, '/intro.md', { branch: 'dev' }), - 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/dev/intro.ipynb', + buildColabUrl('/intro.md', { repo: NOTEBOOKS, branch: '/publish/' }), + 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/publish/intro.ipynb' ); }); -test('launch_repo_suffix overrides the .notebooks suffix', () => { - assert.equal(notebookOrgRepo(SOURCE, { repoSuffix: '-notebooks' }), 'QuantEcon/lecture-foo-notebooks'); - // An empty suffix means the notebook repo is the source repo itself. - assert.equal(notebookOrgRepo(SOURCE, { repoSuffix: '' }), 'QuantEcon/lecture-foo'); +test('launch_notebook_dir prefixes the path inside the notebook repo', () => { + assert.equal(notebookRelPath('/intro.md', { dir: 'notebooks' }), 'notebooks/intro.ipynb'); + assert.equal(notebookRelPath('/intro.md', { dir: '/notebooks/' }), 'notebooks/intro.ipynb'); }); -test('launch_repo_url overrides the derived notebook repo (full URL)', () => { +test('launch_notebook_source_dir is stripped from the page path', () => { + assert.equal(notebookRelPath('/lectures/intro.md', { sourceDir: 'lectures' }), 'intro.ipynb'); + assert.equal(notebookRelPath('/lectures/intro.md', { sourceDir: '/lectures/' }), 'intro.ipynb'); + // A prefix that only looks like the source dir is left alone. assert.equal( - notebookOrgRepo(SOURCE, { repoUrl: 'https://github.com/OtherOrg/custom-nb' }), - 'OtherOrg/custom-nb', + notebookRelPath('/lectures-extra/intro.md', { sourceDir: 'lectures' }), + 'lectures-extra/intro.ipynb' ); - assert.equal( - notebookOrgRepo(SOURCE, { repoUrl: 'https://github.com/OtherOrg/custom-nb.git' }), - 'OtherOrg/custom-nb', - ); -}); - -test('launch_repo_url also accepts a bare org/repo string', () => { - assert.equal(notebookOrgRepo(SOURCE, { repoUrl: 'OtherOrg/custom-nb' }), 'OtherOrg/custom-nb'); }); -test('combined config: source_path strip + notebooks_path + branch + nested dir', () => { - const config = { - sourcePath: 'lectures', - notebooksPath: 'nb', - branch: 'release', - }; +test('combined: source_dir strip + dir + branch + nested lecture dir', () => { assert.equal( - buildColabUrl(SOURCE, '/lectures/topic/page.md', config), - 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/release/nb/topic/page.ipynb', + buildColabUrl('/lectures/part1/intro.md', { + repo: 'https://github.com/QuantEcon/lecture-foo.notebooks', + branch: 'publish', + dir: 'notebooks', + sourceDir: 'lectures', + }), + 'https://colab.research.google.com/github/QuantEcon/lecture-foo.notebooks/blob/publish/notebooks/part1/intro.ipynb' ); }); diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png b/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png index 7d1254edb..34ee641d0 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png index 32817c577..8b10bbe6e 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png and b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png differ diff --git a/tests/visual/fixture/myst.yml.in b/tests/visual/fixture/myst.yml.in index 06d412390..c7f9f1ca5 100644 --- a/tests/visual/fixture/myst.yml.in +++ b/tests/visual/fixture/myst.yml.in @@ -26,6 +26,11 @@ site: # one reaches the theme. twitter: quantecon favicon: cc-by-sa-4.0-80x15.png + # Launch is opt-in on both axes, so the `launch-colab` test needs both a + # notebook repository and a service. `fixture-no-thebe` sets neither, which + # is what `launch-absent-without-config` asserts. + launch_notebook_repo: QuantEcon/quantecon-theme.notebooks + launch_colab: true # Multilingual editions. Two editions make the toolbar's language # switcher and the hreflang alternates render on every page; the lists # are YAML blocks because template options are scalar-only (see diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index 6054912a6..47a63d784 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -260,9 +260,8 @@ test.describe("QuantEcon theme — visual regression", () => { // Launch is a direct link to Colab, the only launch target by design: Binder // and JupyterHub are not offered. Asserting the anchor's href rather than a // stubbed window.open keeps this offline and deterministic, and pins that the - // control is a *link*, so a chooser in its place would fail here. The repo - // part comes from the fixture's `github` field, so only the stable pieces - // (host, .notebooks convention, branch, path) are matched. + // control is a *link*, so a chooser in its place would fail here. The repo is + // the fixture's own `launch_notebook_repo`: nothing is derived from `github`. test("launch-colab", async ({ page }, testInfo) => { test.skip( testInfo.project.name !== "desktop-chrome", @@ -275,10 +274,43 @@ test.describe("QuantEcon theme — visual regression", () => { await expect(launch).toHaveAttribute("target", "_blank"); await expect(launch).toHaveAttribute( "href", - /^https:\/\/colab\.research\.google\.com\/github\/QuantEcon\/[\w.-]+\.notebooks\/blob\/main\/notebook\.ipynb$/ + "https://colab.research.google.com/github/QuantEcon/quantecon-theme.notebooks/blob/main/notebook.ipynb" ); }); + // The opt-in default. `fixture-no-thebe` sets `project.github` but neither + // launch option, which is the shape of a lecture repo with no notebooks + // repository: there must be no control and no gap where one would sit, on + // the toolbar or in the mobile overflow menu. + test("launch-absent-without-config", async ({ page }, testInfo) => { + const noThebeBase = `http://localhost:${process.env.NO_THEBE_PORT || "3112"}`; + await page.goto(`${noThebeBase}/notebook`, { waitUntil: "domcontentloaded" }); + await settle(page); + await expect(page.getByRole("link", { name: "Launch notebook" })).toHaveCount(0); + // No dead link to a repository the site never named, either. + await expect(page.locator('a[href*="colab.research.google.com"]')).toHaveCount(0); + + // And no gap where the control would have been. Asserted on the computed + // `display`, because both of the obvious signals are blind here: an empty + // `
  • ` is a zero-width flex item whether or not it is displayed, so + // measuring its width proves nothing -- and Playwright calls a zero-size + // element hidden, so `toBeHidden()` passes just the same. What an + // un-collapsed slot actually costs is the row's own `gap-x`. + const slot = page.locator(".qe-launch-slot"); + expect(await slot.count()).toBeGreaterThan(0); + await expect(slot.first()).toHaveCSS("display", "none"); + + if (testInfo.project.name === "mobile-chrome") { + // Below `md` the toolbar slot is display:none regardless, so the mobile + // case is only really tested inside the overflow menu. + await page.getByRole("button", { name: "More actions" }).click(); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("link", { name: "Launch notebook" })).toHaveCount(0); + await expect(menu.locator(".qe-launch-slot")).toHaveCSS("display", "none"); + } + }); + // Live compute: the fixture sets `project.thebe: { lite: true }`, which // surfaces the @myst-theme/jupyter NotebookToolbar. It's portaled into the // desktop header toolbar (next to Launch), so assert the Power toggle