Skip to content
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 30 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<source>.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)

Expand Down Expand Up @@ -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)) |
Expand Down
4 changes: 2 additions & 2 deletions app/components/PageHeaderHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/, "")
Expand Down
45 changes: 23 additions & 22 deletions app/components/toolbar/LaunchButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -44,7 +45,7 @@ export function LaunchButton({ size, showLabel }: { size: number; showLabel?: bo
return (
<Tooltip label="Launch notebook in Google Colab" asChild>
<a
href={buildColabUrl(orgRepo, location, config)}
href={buildColabUrl(location, config)}
target="_blank"
rel="noopener noreferrer"
aria-label="Launch notebook"
Expand Down
4 changes: 3 additions & 1 deletion app/components/toolbar/MobileActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export function MobileActionsMenu({ sizeClasses, size }: { sizeClasses: string;
<li>
<DownloadsButton size={size} showLabel />
</li>
<li>
{/* Empty when the site configures no launch; `empty:hidden` keeps
it from taking a row in the menu's spacing. */}
<li className="qe-launch-slot empty:hidden">
<LaunchButton size={size} showLabel />
</li>
<li>
Expand Down
6 changes: 5 additions & 1 deletion app/components/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<li id="qe-compute-slot" className="hidden md:flex items-center empty:hidden" />
<li className="hidden md:block">
{/* 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. */}
<li className="qe-launch-slot hidden md:block empty:hidden">
<LaunchButton size={iconSize} />
</li>
<li className="hidden md:block">
Expand Down
74 changes: 34 additions & 40 deletions app/components/toolbar/launchUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LaunchConfig> = {}): 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//<path>`, 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}`;
}

24 changes: 15 additions & 9 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<github>.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 `<github> + 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
Expand Down
12 changes: 6 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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)) |
Loading
Loading