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

## [Unreleased]

### Fixed
- Every site option the theme reads is now declared in `template.yml`, so it
actually arrives. The MyST CLI validates `site.options` against the
template's declarations and drops every undeclared key, and until now only
the six multilingual options were declared: `twitter`, `favicon`,
`analytics_google`, `analytics_plausible`, `hide_toc`, `hide_search` and the
five `launch_*` launcher keys were read by the theme but never reached it
from `myst.yml`. `favicon` is a `file` option, so the CLI copies it into the
build. A fixture assertion (`site-options` in `theme.spec.ts`) proves a
declared option reaches the rendered page. Options the theme never read
(`hide_outline`, `hide_title_block`, `hide_footer_links`, `outline_maxdepth`)
are dropped from its types rather than declared as dead knobs
([#173](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/173)) ([#194](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/194)).
- The `favicon` option now takes effect at all. Beyond being undeclared, it
was shadowed by a static `public/favicon.ico`, served ahead of the theme's
favicon route on every request. That file is the QuantEcon lectures favicon
(byte-identical to the Sphinx sites'), so it moves to
`public/logos/lectures-favicon.png` and becomes the route's default when no
option is set; a configured favicon is fetched from the content server as
the CLI copied it ([#173](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/173)) ([#194](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/194)).
- The page-level `git_metadata` override is a declared option too, written as
a YAML block string (template options are scalar-only). It previously
survived only because the CLI leaves a page's raw `site:` block alone when
nothing in it validates, so a page that also set any declared key (a
translator credit, `hide_search`, and the per-lecture live-compute flag to
come) silently lost its override. An object is still accepted
([#173](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/173)) ([#194](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/194)).

### Changed
- The release bundle's manifest now declares Node `>=20` (it said `>=16`,
contradicting CONTRIBUTING.md and every real floor in the repo) and installs
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ 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). All keys are optional and the
defaults reproduce the behaviour above, so existing projects need no changes:
`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:

| Option | Default | Purpose |
| ------ | ------- | ------- |
Expand Down Expand Up @@ -143,6 +144,10 @@ Notes:

- The header control renders nothing when no metadata is present, so projects
without the plugin are unaffected.
- A page can pin the control by hand with the same shape under `site:` in its
frontmatter, as a YAML block string (`git_metadata: |` then the indented
`last_modified` and `changelog`); it takes precedence over the injected data.
`tests/visual/fixture/features.md` is an example.
- The plugin is a silent no-op for untracked files, non-git checkouts, missing
`git`, or a `git log` timeout (5s). Shallow CI clones (`fetch-depth: 1`)
produce truncated history — use `fetch-depth: 0` when building for deploy.
Expand Down Expand Up @@ -200,6 +205,32 @@ One trap: a page whose `site:` frontmatter sets any of these keys has its whole
`site:` block replaced by the validated keys, so an undeclared `site.git_metadata`
on the same page is lost. Keep the two on different pages.

## Site options

Everything the theme reads from `site.options` is declared in
[`template.yml`](./template.yml). That list is not documentation only: the MyST
CLI validates `site.options` against it and **drops any key the template does
not declare**, so an undeclared option never reaches the theme
([#173](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/173)).
Template options are scalar-only, so the structured ones are written as a YAML
block inside a string (`key: |`), which the theme parses.

| Option | Scope | Purpose |
| ------ | ----- | ------- |
| `twitter` | site | Handle for the `twitter:site` / `twitter:creator` card meta tags |
| `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)) |
| `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)) |

A page-level value goes under `site:` in the page's frontmatter and is
validated against the same list. Options carry no defaults in `template.yml`
(a declared default would be written into every page's validated block and
override the site-wide value); defaults live in the code that reads each one.

## Usage with MyST

Point your project's `site.template` at a **pinned release** zip:
Expand Down
44 changes: 43 additions & 1 deletion app/backend/loaders.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import fetch from "node-fetch";
import type { SiteManifest } from "myst-config";
import {
Expand Down Expand Up @@ -131,13 +133,53 @@ export async function getMystSearchJson(): Promise<MystSearchIndex | null> {
return await response.json();
}

/**
* The QuantEcon lectures favicon (byte-identical to the Sphinx sites'
* `_static/lectures-favicon.ico`), served when no `favicon` option is set.
* It lives under public/logos/ rather than at public/favicon.ico: a static
* file at that path is served ahead of the `[favicon.ico]` route, which is
* why the `favicon` site option never took effect before #173.
*/
const DEFAULT_FAVICON = "public/logos/lectures-favicon.png";

// Read once: the file is static, and this runs on every /favicon.ico request.
let defaultFavicon: { contentType: string; buffer: Buffer } | null | undefined;

function readDefaultFavicon(): { contentType: string; buffer: Buffer } | null {
if (defaultFavicon !== undefined) return defaultFavicon;
// The theme server runs from the bundle directory (`build.start` in
// template.yml), where public/ sits beside build/; the second candidate
// covers being launched from elsewhere.
const candidates = [
path.resolve(process.cwd(), DEFAULT_FAVICON),
path.resolve(__dirname, "..", DEFAULT_FAVICON),
];
defaultFavicon = null;
for (const file of candidates) {
try {
defaultFavicon = { contentType: "image/png", buffer: fs.readFileSync(file) };
break;
} catch {
// try the next location
}
}
return defaultFavicon;
}

export async function getFavicon(): Promise<{
contentType: string | null;
buffer: Buffer;
} | null> {
// We are always fetching this at run time, so we don't want the rewritten links
const config = await getConfig({ rewriteStaticFolder: false });
const url = config.options?.favicon || "https://mystmd.org/favicon.ico";
// A declared `file` option: the CLI copied the site's file into its public
// folder and getConfig rewrote the path onto the content server.
const url = config.options?.favicon;
if (!url) return readDefaultFavicon() ?? fetchFavicon("https://mystmd.org/favicon.ico");
return fetchFavicon(url);
}

async function fetchFavicon(url: string) {
const response = await fetch(url).catch(() => null);
if (!response || response.status === 404) return null;
return {
Expand Down
30 changes: 28 additions & 2 deletions app/components/PageHeaderHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseStructured } from "~/i18n";
import { ChevronDown } from "lucide-react";
import React from "react";
import { usePage } from "./PageProvider";
Expand Down Expand Up @@ -67,12 +68,36 @@ const COPY = "text-[0.85rem]";
*
* Data sources, in order of precedence:
* 1. `site.git_metadata` in the page frontmatter (manual override, and how
* the visual fixture pins deterministic data), then
* the visual fixture pins deterministic data) -- a declared template
* option, so it arrives as a string holding a YAML block (template
* options are scalar-only; see app/i18n.ts) or, from older content, as
* an object; then
* 2. `mdast.data.git_metadata` injected at build time by
* plugins/git-metadata.mjs.
*
* Renders nothing when neither is present.
*/

/**
* The parsed override only counts when it has the plugin's shape, and only
* changelog entries the renderer can use (an object with string `hash` and
* `date`) are kept, so a malformed hand-written block degrades to fewer rows
* rather than a throw.
*/
function asGitMetadata(value: unknown): GitMetadata | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const v = value as Partial<GitMetadata>;
const changelog = (Array.isArray(v.changelog) ? v.changelog : []).filter(
(entry): entry is GitChangelogEntry =>
!!entry &&
typeof entry === "object" &&
typeof (entry as any).hash === "string" &&
typeof (entry as any).date === "string"
);
if (!v.last_modified && changelog.length === 0) return undefined;
return { last_modified: v.last_modified, changelog } as GitMetadata;
}

export function PageHeaderHistory({ alignEnd = true }: { alignEnd?: boolean } = {}) {
const page = usePage();
// `ms-auto` pushes the control to the end of the header row. When the
Expand All @@ -89,7 +114,8 @@ export function PageHeaderHistory({ alignEnd = true }: { alignEnd?: boolean } =

const frontmatter = page?.frontmatter as any;
const meta: GitMetadata | undefined =
frontmatter?.site?.git_metadata ?? (page?.mdast as any)?.data?.git_metadata;
asGitMetadata(parseStructured(frontmatter?.site?.git_metadata)) ??
(page?.mdast as any)?.data?.git_metadata;
const changelog = meta?.changelog ?? [];
const lastModified = meta?.last_modified ?? changelog[0]?.date;
if (!lastModified) return null;
Expand Down
31 changes: 21 additions & 10 deletions app/types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import type { GitMetadata } from './components/PageHeaderHistory';

export interface TemplateOptions {
// Every key here is declared in template.yml: the CLI drops any
// `site.options` key the template does not declare (#173), so an option
// that is read but not declared never arrives. Keep the two in step.

// Layout. Both are per-page: Page.tsx merges a page's `site:` block over
// the site-wide options.
hide_toc?: boolean;
hide_outline?: boolean;
hide_search?: boolean;
hide_footer_links?: boolean;
outline_maxdepth?: number;
hide_title_block?: boolean;

// Meta / SEO and analytics, passed through to @myst-theme/site.
twitter?: string; // handle for twitter:site / twitter:creator, `@` optional
favicon?: string; // declared `file`: the CLI copies it and rewrites this to its served path
analytics_google?: string;
analytics_plausible?: string;

/**
* Page-level override for the "Last changed" header control, normally
* injected at build time by plugins/git-metadata.mjs (set under `site:` in
* page frontmatter).
* page frontmatter). Declared as a string holding a YAML block, like the
* lists below, because template options are scalar-only; a real object is
* accepted too (app/i18n.ts `parseStructured`).
*/
git_metadata?: GitMetadata;
git_metadata?: string | GitMetadata;

// Notebook launcher configuration (set under `site.options` in myst.yml).
// Generalises the previously hardcoded Colab launch URLs so
Expand All @@ -35,10 +46,10 @@ export interface TemplateOptions {
// PageContent); the other four are site-wide -- `current_language` and
// `enable_rtl` come from the root loader's config, `languages` and the
// switcher label from the site manifest -- and a page value is ignored.
// Caution: a page that sets any declared key here has its whole `site:`
// block replaced by the validated keys, so an undeclared `git_metadata` on
// the same page is lost -- keep the two on different pages, as the visual
// fixture does.
// A page that sets any declared key has its whole `site:` block replaced
// by the validated keys, which is why `git_metadata` above is declared too:
// undeclared, it was silently lost from any page that also set one of
// these.
current_language?: string; // BCP 47 code of this edition; document `lang`, active switcher entry
enable_rtl?: boolean; // dir="rtl" on the document
languages?: string | unknown[]; // YAML block of `{code, name, url}`; needs 2+ entries to render
Expand Down
4 changes: 3 additions & 1 deletion plugins/git-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
* The QuantEcon theme renders this as a "Last changed" header control with a
* changelog dropdown (app/components/PageHeaderHistory.tsx). Pages can also
* set the same shape manually under `site.git_metadata` in their frontmatter,
* which takes precedence over the injected data.
* which takes precedence over the injected data. Write it as a YAML block
* string (`git_metadata: |` followed by the indented YAML): it is a declared
* template option, and the CLI can only declare scalar types (#173).
*
* Mirrors quantecon-book-theme's get_git_last_modified/get_git_changelog:
* per-file `git log --follow`, a hard timeout, and a silent no-op when the
Expand Down
File renamed without changes
71 changes: 60 additions & 11 deletions template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ parts:
- id: footer
description: The site wide footer
# Site options (`site.options` in myst.yml). The CLI validates against this
# list and DROPS any key not declared here, and it can only declare scalar
# types -- so the two lists (`languages`, `translators`) are strings holding a
# YAML block; app/i18n.ts parses them. Only `translators` and
# `translators_label` are also read per page (under `site:` in page
# frontmatter); the rest are site-wide. See README "Multilingual editions".
# list and DROPS any key not declared here (#173), so every option the theme
# reads is declared below -- and it can only declare scalar types, so the
# structured ones (`languages`, `translators`, `git_metadata`) are strings
# holding a YAML block; app/i18n.ts `parseStructured` turns them back into
# data. Site-wide unless marked per-page; a per-page value goes under `site:`
# in the page's frontmatter and is validated against this same list.
#
# No `default:` on any option, deliberately. The CLI validates every page's
# `site:` frontmatter against this list too, and a declared default is written
# into that validated block even when the page set nothing -- which then
# replaces the page's raw `site:` block, overriding the site-wide value and
# discarding undeclared keys such as `git_metadata`. Defaults live in
# app/i18n.ts instead.
# No `default:` on any option, deliberately. A declared default is written
# into every page's validated `site:` block even when the page set nothing,
# which then overrides the site-wide value. Defaults live in code.
options:
- id: current_language
type: string
Expand Down Expand Up @@ -64,6 +62,57 @@ options:
description: >
Accessible name and tooltip of the language switcher, in the edition's own
language ("Switch language" when unset).
# --- Meta / SEO ---
- id: twitter
type: string
description: >
Twitter/X handle for the `twitter:site` and `twitter:creator` card meta
tags, with or without the leading `@`.
- id: favicon
type: file
description: >
Site favicon, a path relative to myst.yml. Served at `/favicon.ico`
(the QuantEcon lectures favicon when unset).
# --- Analytics (rendered by @myst-theme/site's Analytics component) ---
- id: analytics_google
type: string
description: Google Analytics measurement ID (e.g. `G-XXXXXXXXXX`).
- id: analytics_plausible
type: string
description: Plausible analytics domain (e.g. `python.quantecon.org`).
# --- Layout (per-page: a page may set either under `site:`) ---
- id: hide_toc
type: boolean
description: Hide the contents drawer and its toolbar toggle.
- id: hide_search
type: boolean
description: Hide the toolbar search control.
# --- Notebook launcher (see README "Launch buttons") ---
- id: launch_repo_url
type: string
description: >
Explicit notebook repository (full URL or `org/repo`); overrides
`<github>` + `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
type: string
description: Branch of the notebook repo to launch from (`main` when unset).
- id: launch_notebooks_path
type: string
description: Sub-directory of the notebook repo where the notebooks live.
- id: launch_source_path
type: string
description: Path prefix stripped from the page location (e.g. `lectures/`).
# --- Git history (per-page override of plugins/git-metadata.mjs) ---
- id: git_metadata
type: string
description: >
YAML block with `last_modified` and a `changelog` list of `{hash,
short_hash, author, date, message}`, set under `site:` in a page's
frontmatter to pin the "Last changed" control by hand. Normally injected
at build time by the git-metadata plugin instead.
build:
# `npm ci`, not `npm install`: the release asset ships a lockfile (release.yml
# generates it with `npm install --package-lock-only` and it is in `files`
Expand Down
1 change: 0 additions & 1 deletion tests/visual/fixture-no-thebe/myst.yml.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ site:
title: QE Theme No-Thebe Fixture
template: __THEME__
options:
logo_text: QE No-Thebe Fixture
1 change: 0 additions & 1 deletion tests/visual/fixture-rtl/myst.yml.in
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ site:
title: QE Theme RTL Fixture
template: __THEME__
options:
logo_text: QE RTL Fixture
current_language: fa
enable_rtl: true
language_switcher_label: تغییر زبان
Expand Down
9 changes: 7 additions & 2 deletions tests/visual/fixture/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ authors:
site:
# Deterministic stand-in for plugins/git-metadata.mjs output, so the header
# history control renders identically on every run (real git dates would
# change with each commit and churn the snapshots).
git_metadata:
# change with each commit and churn the snapshots). A YAML block string,
# because it is a declared template option and those are scalar-only
# (#173); PageHeaderHistory parses it.
git_metadata: |
last_modified: '2026-01-15T10:30:00Z'
changelog:
- hash: 3f9d2c41b8a7e6f5d4c3b2a1908f7e6d5c4b3a29
Expand All @@ -26,6 +28,9 @@ site:
author: Matt McKay
date: '2025-09-20T14:45:00Z'
message: Initial features fixture
# A declared per-page option on the same page: before #173 this would have
# replaced the page's `site:` block and silently dropped the override above.
hide_search: false
---

# Features
Expand Down
Loading
Loading