From 0c7964dde8ea86fa6aca60db95697bf682396912 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 10 Sep 2026 22:17:28 +1000 Subject: [PATCH 1/3] feat(seo): full OpenGraph/Twitter set, collapsible stderr, docs reference Phase 6 of the book-theme parity plan. Meta tags: app/seo.ts adds, on top of upstream's article tags, what the Sphinx lecture sites emit and upstream leaves out -- og:type, og:site_name, og:url (from a new `site_url` option; myst's `site.domains` never reaches the theme's manifest), a site-level og:image / twitter:image when the page has no thumbnail (`og_logo_url` / `twitter_logo_url`, the book theme's names, so a lecture repo copies its values across), twitter:site (upstream puts it in the root route's meta, which the article route's replaces under Remix v2 semantics, so it never rendered) and og:locale from current_language. Same-key upstream tags are replaced, not duplicated. Collapsible stderr: upstream renders a stderr stream as a plain
;
verified, and ported as a native 
around the stderr `output` node (STDERR_RENDERERS, layered after JUPYTER_RENDERERS) rather than the Sphinx build's post-load DOM surgery, so the fold holds in the server-rendered HTML with no script. Styled in the book theme's amber. Docs: a docs/ reference with one page per book-theme user page (configuration, layout, authors, launch, notebooks, git metadata, code highlighting, text colour schemes, dark mode, RTL and editions, announcements, the stderr fold), each naming its myst.yml keys and what has no counterpart. Tests: `social-meta` asserts the full set on the no-thebe fixture, which gains site_url, twitter and both logo URLs; `stderr-collapsed` asserts the fold is closed by default, stdout stays visible, and it opens. Six unit tests cover the helpers. No baseline moves. Closes #92 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 ++++ PLAN.md | 16 ++- README.md | 22 ++++ app/renderers.tsx | 36 ++++++ app/root.tsx | 4 +- app/routes/$.tsx | 14 ++- app/routes/_index.tsx | 14 ++- app/seo.ts | 123 +++++++++++++++++++ app/types.ts | 3 + docs/announcements.md | 7 ++ docs/authors.md | 36 ++++++ docs/code-highlighting.md | 14 +++ docs/configuration.md | 54 ++++++++ docs/dark-mode.md | 8 ++ docs/features/stderr-warnings.md | 21 ++++ docs/git-metadata.md | 31 +++++ docs/index.md | 45 +++++++ docs/launch.md | 23 ++++ docs/layout.md | 51 ++++++++ docs/notebooks.md | 36 ++++++ docs/rtl-support.md | 37 ++++++ docs/text-color-schemes.md | 18 +++ styles/quantecon.css | 50 ++++++++ template.yml | 16 +++ tests/unit/seo.test.mjs | 95 ++++++++++++++ tests/visual/fixture-no-thebe/myst.yml.in | 7 ++ tests/visual/fixture-no-thebe/notebook.ipynb | 29 ++++- tests/visual/theme.spec.ts | 49 ++++++++ 28 files changed, 868 insertions(+), 12 deletions(-) create mode 100644 app/seo.ts create mode 100644 docs/announcements.md create mode 100644 docs/authors.md create mode 100644 docs/code-highlighting.md create mode 100644 docs/configuration.md create mode 100644 docs/dark-mode.md create mode 100644 docs/features/stderr-warnings.md create mode 100644 docs/git-metadata.md create mode 100644 docs/index.md create mode 100644 docs/launch.md create mode 100644 docs/layout.md create mode 100644 docs/notebooks.md create mode 100644 docs/rtl-support.md create mode 100644 docs/text-color-schemes.md create mode 100644 tests/unit/seo.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffb367be..9d67ff8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- The full OpenGraph / Twitter card set the Sphinx lecture sites emit, on every + page: `og:type`, `og:site_name`, `og:url` (from a new `site_url` option; myst's `site.domains` never reaches the theme), a site-level + `og:image` / `twitter:image` when the page has no thumbnail (new + `og_logo_url` / `twitter_logo_url` options, named as in the book theme), + `twitter:site`, and `og:locale` from `current_language`. `twitter:site` had + never rendered: upstream puts it in the root route's meta, which the article + route's replaces under Remix v2 semantics. Phase 6 of the book-theme parity + plan ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). +- Collapsible stderr: a notebook cell's stderr stream is folded behind a + "⚠ Code warnings" disclosure, closed by default, as the Sphinx build's + `stderr-warnings.js` does. A native `
` around the stream at render + time rather than DOM surgery after load, so it holds in server-rendered HTML + without a script; stdout in the same cell stays visible + ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). +- A `docs/` feature reference mirroring the book theme's `docs/user/*` pages + (configuration, layout, authors, launch, notebooks, git metadata, code + highlighting, text colour schemes, dark mode, RTL and editions, + announcements), each naming its `myst.yml` keys and what has no counterpart + ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). + ### 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 diff --git a/PLAN.md b/PLAN.md index 5da89b850..bec511f84 100644 --- a/PLAN.md +++ b/PLAN.md @@ -483,16 +483,20 @@ for the same translated sites). **Goal:** close the smaller gaps and verify assumptions. -- [ ] **OpenGraph/Twitter parity:** the book-theme emits a full OG + Twitter card set; +- [x] **OpenGraph/Twitter parity:** the book-theme emits a full OG + Twitter card set; `root.tsx` currently uses `getMetaTagsForSite` (title/description/twitter). Add `og:image`/`twitter:image` (logo), `og:type`, `og:site_name`, etc., driven from - site config. -- [ ] **Collapsible stderr warnings:** confirm whether `@myst-theme/jupyter` already + site config. *Done (#92): `app/seo.ts` adds `og:type`, `og:site_name`, `og:url` + (from a `site_url` option), site-level images (`og_logo_url` / `twitter_logo_url`), + `twitter:site` and `og:locale` on top of upstream's article tags.* +- [x] **Collapsible stderr warnings:** confirm whether `@myst-theme/jupyter` already renders notebook stderr in a collapsible/styled way (it may — verify before - porting). If not, add an output transform/renderer. -- [ ] **Docs:** add a `docs/`-style feature reference for the MyST theme mirroring the + porting). If not, add an output transform/renderer. *Verified (#92): upstream + renders stderr as a plain `
`; ported as a native
+      `
` around the stderr `output` node (`STDERR_RENDERERS`).* +- [x] **Docs:** add a `docs/`-style feature reference for the MyST theme mirroring the book-theme's `docs/user/*` set, so downstream lecture maintainers have parity - documentation. + documentation. *Done (#92): `docs/` with one page per book-theme user page.* **Effort:** S–M. **Risk:** low. **Deps:** none. diff --git a/README.md b/README.md index 8a0ef1375..45ab53863 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ QuantEcon lectures and books, distributed as a zip attached to each - Notebook launch (Google Colab) with configurable repo, branch and paths - In-page live compute via Thebe (JupyterLite / Pyodide by default) - Git history in page headers ("Last changed" plus an inline changelog) +- Full OpenGraph / Twitter card meta tags, and collapsible stderr on notebook cells +- A `docs/` feature reference mirroring the book theme's user guide - Page Footer based on MyST Content - Bundled QuantEcon brand assets @@ -205,6 +207,24 @@ 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. +### Meta tags + +Every page carries the OpenGraph and Twitter card set the Sphinx lecture sites +emit: `og:title`, `og:description`, `og:type` (`website`), `og:site_name`, +`og:url`, `og:image`, `og:locale`, and `twitter:card`, `twitter:site`, +`twitter:creator`, `twitter:title`, `twitter:description`, `twitter:image`. +Four `site.options` keys feed them: `site_url` (the public URL, for +`og:url`; myst's `site.domains` does not reach the theme), `twitter`, and +the two image URLs `og_logo_url` / `twitter_logo_url`, named as in the book +theme so a lecture repo copies its values across. A page's own thumbnail takes precedence for +`og:image`. + +### Collapsible stderr + +A notebook cell's stderr stream is folded behind a "⚠ Code warnings" +disclosure, closed by default, as on the Sphinx sites; stdout in the same cell +stays visible. It is a native `
`, so it works without JavaScript. + ## Site options Everything the theme reads from `site.options` is declared in @@ -218,6 +238,8 @@ block inside a string (`key: |`), which the theme parses. | Option | Scope | Purpose | | ------ | ----- | ------- | | `twitter` | site | Handle for the `twitter:site` / `twitter:creator` card meta tags | +| `site_url` | site | The site's public URL, for `og:url` ([Meta tags](#meta-tags)) | +| `og_logo_url`, `twitter_logo_url` | site | Site-level images for `og:image` / `twitter:image` when a page has no thumbnail ([Meta tags](#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 | diff --git a/app/renderers.tsx b/app/renderers.tsx index def6ee2f3..dd9cca95b 100644 --- a/app/renderers.tsx +++ b/app/renderers.tsx @@ -1,6 +1,7 @@ import type { GenericNode } from 'myst-common'; import type { NodeRenderers } from '@myst-theme/providers'; import { MyST } from 'myst-to-react'; +import { OUTPUT_RENDERERS } from '@myst-theme/jupyter'; /** * Fancy ordered lists (QuantEcon/mystmd#50): `list` nodes carry `style` @@ -65,3 +66,38 @@ export const LIST_RENDERERS: NodeRenderers = { ); }, }; + +/** + * Collapsible stderr (Phase 6, #92). The Sphinx build's `stderr-warnings.js` + * folds a cell's stderr streams behind a "Code warnings" button after page + * load; upstream @myst-theme/jupyter renders them as a plain `
`. Same fold, done at render: a stderr stream
+ * `output` node is wrapped in a native `
`, closed by default, so it + * works in the server-rendered HTML with no script and no DOM surgery. Every + * other output goes to upstream's renderer untouched. Styled by the + * `.qe-stderr` block in styles/quantecon.css. + */ +const UpstreamOutput = OUTPUT_RENDERERS.output as (props: { + node: GenericNode; + className?: string; +}) => JSX.Element | null; + +export const STDERR_RENDERERS: NodeRenderers = { + output(props: { node: GenericNode; className?: string }) { + const data = props.node.jupyter_data as { output_type?: string; name?: string } | undefined; + if (data?.output_type === 'stream' && data?.name === 'stderr') { + return ( +
+ + {' '} + Code warnings + + +
+ ); + } + return ; + }, +}; diff --git a/app/root.tsx b/app/root.tsx index 054365c08..ed770c13f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -20,7 +20,7 @@ import type { ISearch, MystSearchIndex } from '@myst-theme/search'; import { SEARCH_ATTRIBUTES_ORDERED } from '@myst-theme/search'; import { useCallback } from 'react'; import { JUPYTER_RENDERERS } from '@myst-theme/jupyter'; -import { LIST_RENDERERS } from './renderers'; +import { LIST_RENDERERS, STDERR_RENDERERS } from './renderers'; import { Document } from './components/Document'; import { htmlDir, htmlLang } from './i18n'; import type { TemplateOptions } from './types'; @@ -33,6 +33,8 @@ const RENDERERS: NodeRenderers = mergeRenderers([ defaultRenderers, JUPYTER_RENDERERS, LIST_RENDERERS, + // After JUPYTER_RENDERERS: wraps upstream's `output` renderer (#92). + STDERR_RENDERERS, ]); export const meta: V2_MetaFunction = ({ data }) => { diff --git a/app/routes/$.tsx b/app/routes/$.tsx index ca1e80b45..27f884d85 100644 --- a/app/routes/$.tsx +++ b/app/routes/$.tsx @@ -13,6 +13,7 @@ import type { SiteManifest } from 'myst-config'; import { ErrorPage } from '~/components/ErrorPage'; import { Page } from '~/components/Page'; import { hreflangLinks } from '~/i18n'; +import { mergeMeta, socialMetaTags } from '~/seo'; // Never re-run the loader on a navigation that changes neither pathname nor // search (Back off an in-page anchor on a static build) -- #186. @@ -37,8 +38,17 @@ export const meta: V2_MetaFunction = ({ data, matches, location } ); const baseurl = rootMatch?.data?.BASE_URL; + // Phase 6 (#92): the Sphinx sites' full OpenGraph / Twitter set on top of + // upstream's article tags -- see app/seo.ts for what upstream leaves out. + const social = socialMetaTags({ + domains: config?.domains, + siteTitle: config?.title ?? project?.title, + pageImage: (page?.thumbnailOptimized || page?.thumbnail) ?? (project?.thumbnailOptimized || project?.thumbnail) ?? undefined, + pathname: `${baseurl ?? ''}${location.pathname}`, + options: config?.options as any, + }); return [ - ...getMetaTagsForArticle({ + ...mergeMeta(getMetaTagsForArticle({ origin: '', url: location.pathname, title: page?.title ? `${page.title}${siteTitle ? ` - ${siteTitle}` : ''}` : siteTitle, @@ -49,7 +59,7 @@ export const meta: V2_MetaFunction = ({ data, matches, location } undefined, twitter: config?.options?.twitter, keywords: page?.keywords ?? project?.keywords ?? config?.keywords ?? [], - }), + }), social), // hreflang alternates for the translated editions (Phase 4, #90). ...hreflangLinks(config?.options, location.pathname, baseurl), ]; diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 431252552..eb302778c 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -9,6 +9,7 @@ import { getProject } from '@myst-theme/common'; import { Page } from '~/components/Page'; import { hreflangLinks } from '~/i18n'; +import { mergeMeta, socialMetaTags } from '~/seo'; // Never re-run the loader on a navigation that changes neither pathname nor // search (Back off an in-page anchor on a static build) -- #186. @@ -27,8 +28,17 @@ export const meta: V2_MetaFunction = ({ data, matches, location } ); const baseurl = rootMatch?.data?.BASE_URL; + // Phase 6 (#92): the Sphinx sites' full OpenGraph / Twitter set on top of + // upstream's article tags -- see app/seo.ts for what upstream leaves out. + const social = socialMetaTags({ + domains: config?.domains, + siteTitle: config?.title ?? project?.title, + pageImage: (project.thumbnailOptimized || project.thumbnail) ?? undefined, + pathname: `${baseurl ?? ''}${location.pathname}`, + options: config?.options as any, + }); return [ - ...getMetaTagsForArticle({ + ...mergeMeta(getMetaTagsForArticle({ origin: '', url: location.pathname, title: config?.title ?? project.title, @@ -36,7 +46,7 @@ export const meta: V2_MetaFunction = ({ data, matches, location } image: (project.thumbnailOptimized || project.thumbnail) ?? undefined, keywords: config.keywords ?? project.keywords ?? [], twitter: config?.options?.twitter, - }), + }), social), // hreflang alternates for the translated editions (Phase 4, #90). ...hreflangLinks(config?.options, location.pathname, baseurl), ]; diff --git a/app/seo.ts b/app/seo.ts new file mode 100644 index 000000000..c1cf5a72a --- /dev/null +++ b/app/seo.ts @@ -0,0 +1,123 @@ +/** + * Social / SEO meta tags (Phase 6, #92) -- the full OpenGraph and Twitter + * card set the Sphinx lecture sites emit, on top of what @myst-theme/site's + * `getMetaTagsForArticle` already produces (title, description, keywords, + * og:title/description/url/image, twitter:card/creator/title/description/ + * image/alt). + * + * What upstream leaves out, and the Sphinx sites ship on every page: + * + * og:type "website" (Sphinx: every lecture page) + * og:site_name the site title + * og:url upstream needs an `origin`, which the routes never had. + * It comes from the `site_url` option (the Sphinx sites' + * `html_baseurl`); `site.domains` would be the natural + * source, but the CLI's site manifest does not carry it, so + * it is only a fallback should that change + * og:image / a site-level image when the page has no thumbnail -- + * twitter:image `og_logo_url` and `twitter_logo_url`, the book theme's + * option names, so a lecture repo copies its values across + * twitter:site upstream puts it in the root route's meta, which Remix v2 + * replaces with the article route's, so it never rendered + * og:locale from `current_language` (Phase 4), when set + * + * Pure TypeScript with no React; tests/unit/seo.test.mjs runs it under + * `node --test` with type stripping like the other helpers. + */ +import type { V2_MetaDescriptor } from '@remix-run/react'; + +export interface SeoSiteOptions { + site_url?: string; + twitter?: string; + og_logo_url?: string; + twitter_logo_url?: string; + current_language?: string; +} + +export interface SeoInput { + /** `site.domains` from myst.yml, if the manifest ever carries it; `options.site_url` is the real source. */ + domains?: string[]; + /** Site title, for og:site_name. */ + siteTitle?: string; + /** The page's own image, if any (thumbnail); site-level images fill in. */ + pageImage?: string; + /** Path of the page, including the static build's base URL. */ + pathname: string; + options?: SeoSiteOptions; +} + +/** + * The canonical origin: `site_url` (an absolute URL, trailing slash and path + * dropped to the origin), else `https://` -- the CLI validates + * domains as bare hosts. + */ +export function siteOrigin(siteUrl?: string, domains?: string[]): string | undefined { + const fromOption = siteUrl?.trim(); + if (fromOption) { + try { + return new URL(/^https?:\/\//i.test(fromOption) ? fromOption : `https://${fromOption}`).origin; + } catch { + // fall through to domains + } + } + const host = domains?.find((d) => typeof d === 'string' && d.trim()); + if (!host) return undefined; + const h = host.trim().replace(/\/+$/, ''); + return /^https?:\/\//i.test(h) ? h : `https://${h}`; +} + +function handle(twitter?: string): string | undefined { + const t = twitter?.trim().replace(/^@/, ''); + return t ? `@${t}` : undefined; +} + +/** BCP 47 code to an OpenGraph locale (`en` -> `en_US`, `zh-cn` -> `zh_CN`). */ +export function ogLocale(code?: string): string | undefined { + const c = code?.trim(); + if (!c) return undefined; + const [lang, region] = c.split(/[-_]/); + if (!lang) return undefined; + if (region) return `${lang.toLowerCase()}_${region.toUpperCase()}`; + const defaults: Record = { en: 'en_US', fa: 'fa_IR', fr: 'fr_FR', zh: 'zh_CN', es: 'es_ES', ja: 'ja_JP' }; + return defaults[lang.toLowerCase()] ?? lang.toLowerCase(); +} + +/** + * The tags to add to (or replace in) upstream's article set. Applied by + * `mergeMeta`, which drops an upstream tag with the same name/property first, + * so a site-level image does not sit beside a missing page image and + * twitter:image follows the site's Twitter logo when one is configured. + */ +export function socialMetaTags({ domains, siteTitle, pageImage, pathname, options }: SeoInput): V2_MetaDescriptor[] { + const origin = siteOrigin(options?.site_url, domains); + const image = pageImage || options?.og_logo_url; + const twitterImage = options?.twitter_logo_url || image; + const tags: V2_MetaDescriptor[] = [{ property: 'og:type', content: 'website' }]; + if (siteTitle) tags.push({ property: 'og:site_name', content: siteTitle }); + if (origin) tags.push({ property: 'og:url', content: `${origin}${pathname}` }); + if (image) tags.push({ property: 'og:image', content: image }); + const site = handle(options?.twitter); + if (site) { + tags.push({ name: 'twitter:site', content: site }); + tags.push({ name: 'twitter:card', content: 'summary' }); + } + if (twitterImage && (site || twitterImage !== pageImage)) { + tags.push({ name: 'twitter:image', content: twitterImage }); + } + const locale = ogLocale(options?.current_language); + if (locale) tags.push({ property: 'og:locale', content: locale }); + return tags; +} + +function key(tag: V2_MetaDescriptor): string | undefined { + const t = tag as Record; + if (typeof t.property === 'string') return `property:${t.property}`; + if (typeof t.name === 'string') return `name:${t.name}`; + return undefined; +} + +/** `additions` win over `base` on the same name/property; order otherwise kept. */ +export function mergeMeta(base: V2_MetaDescriptor[], additions: V2_MetaDescriptor[]): V2_MetaDescriptor[] { + const replaced = new Set(additions.map(key).filter((k): k is string => !!k)); + return [...base.filter((t) => !replaced.has(key(t) ?? '')), ...additions]; +} diff --git a/app/types.ts b/app/types.ts index e52e5b873..d0226061e 100644 --- a/app/types.ts +++ b/app/types.ts @@ -12,6 +12,9 @@ export interface TemplateOptions { // Meta / SEO and analytics, passed through to @myst-theme/site. twitter?: string; // handle for twitter:site / twitter:creator, `@` optional + site_url?: string; // public URL, for og:url (#92); site.domains never reaches the manifest + og_logo_url?: string; // og:image when the page has no thumbnail (#92) + twitter_logo_url?: string; // twitter:image; falls back to og_logo_url (#92) favicon?: string; // declared `file`: the CLI copies it and rewrites this to its served path analytics_google?: string; analytics_plausible?: string; diff --git a/docs/announcements.md b/docs/announcements.md new file mode 100644 index 000000000..cfb0ad7f8 --- /dev/null +++ b/docs/announcements.md @@ -0,0 +1,7 @@ +# Announcement banner + +The Sphinx theme can show a dismissible site-wide banner (`announcement` in +`html_theme_options`). This theme does not offer one: no deployed lecture site +configures it, and the parity work covers the book-theme feature set as the +lecture sites actually use it. If a series needs a banner, open a request on +the theme repository with the use case; the header is the natural place for it. diff --git a/docs/authors.md b/docs/authors.md new file mode 100644 index 000000000..ebdd4645a --- /dev/null +++ b/docs/authors.md @@ -0,0 +1,36 @@ +# Authors and translators + +## Authors + +Page-level `authors` frontmatter renders as the byline in the page header, as on +the Sphinx sites: + +```yaml +--- +authors: + - name: Thomas J. Sargent + - name: John Stachurski +--- +``` + +Project-level authors in `myst.yml` apply to every page. + +## Translators + +A translated edition credits its translators after the byline. Set them +site-wide under `site.options`, as a YAML block (template options are +scalar-only), with an optional label in the edition's language: + +```yaml +site: + options: + translators: | + - name: مترجم نمونه + url: https://example.org/translator + translators_label: ترجمهٔ +``` + +A page overrides the list under `site:` in its frontmatter (replace, never +merge); an empty value there suppresses the credit on that page. The label +defaults to "Translated by"; an empty string hides it. See +[rtl-support](rtl-support.md) for the rest of the multilingual configuration. diff --git a/docs/code-highlighting.md b/docs/code-highlighting.md new file mode 100644 index 000000000..5e42f0821 --- /dev/null +++ b/docs/code-highlighting.md @@ -0,0 +1,14 @@ +# Code highlighting + +Code blocks use QuantEcon's own token palette, the Sphinx lecture sites' +default `qetheme_code_style`, in light and dark mode. MyST tokenises with +highlight.js rather than Pygments, so the palette is mapped scope by scope onto +the `hljs-*` classes; the mapping, and the two Pygments classes highlight.js has +no scope for (operators and module names), are documented in +`styles/quantecon.css`. + +Only the default style ships. No lecture repository sets a different +`qetheme_code_style`, so the alternative palettes and the Pygments toggle the +Sphinx theme offers are deferred until a consumer asks. Five of the light-mode +token colours fall short of WCAG AA on white; whether to darken them is an open +two-theme decision (theme issue #172), since they are the deployed sites' values. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 000000000..a3d1222d5 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,54 @@ +# Configuration + +Everything the theme reads from `site.options` is declared in +[`template.yml`](../template.yml). The MyST CLI validates `site.options` against +that list and **drops any key the template does not declare**, so the list is +the contract, not documentation. Template options are scalar-only (boolean, +string, number, choice, file), so structured values are written as a YAML block +inside a string (`key: |`), which the theme parses. + +No option has a default in `template.yml`, deliberately: a declared default is +written into every page's validated `site:` block and would override the +site-wide value. Defaults live in the code that reads each option and are given +below. + +## Options + +| Option | Type | Scope | Default | Purpose | +| --- | --- | --- | --- | --- | +| `site_url` | string | site | — | the site's public URL, for `og:url` | +| `twitter` | string | site | — | handle for `twitter:site` / `twitter:creator`; `@` optional | +| `og_logo_url` | string | site | — | `og:image` when a page has no thumbnail | +| `twitter_logo_url` | string | site | — | `twitter:image`; falls back to `og_logo_url` | +| `favicon` | file | site | the QuantEcon lectures favicon | served at `/favicon.ico` | +| `analytics_google` | string | site | — | Google Analytics measurement ID | +| `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 | +| `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` | +| `language_switcher_label` | string | site | "Switch language" | the switcher's accessible name | +| `translators` | YAML block | site or page | — | translator credit | +| `translators_label` | string | site or page | "Translated by" | label before the credit | +| `git_metadata` | YAML block | page | — | pins the "Last changed" control by hand | + +## Page-level values + +A per-page value goes under `site:` in the page's frontmatter (for a notebook, +under `"site"` in the notebook metadata) and is validated against the same +list. Only the keys marked "site or page" or "page" are read per page. + +## Other `site` keys the theme uses + +| Key | Purpose | +| --- | --- | +| `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.thebe` | in-page live compute ([notebooks](notebooks.md)) | diff --git a/docs/dark-mode.md b/docs/dark-mode.md new file mode 100644 index 000000000..f72c44d06 --- /dev/null +++ b/docs/dark-mode.md @@ -0,0 +1,8 @@ +# Dark mode + +The header's theme toggle switches between light and dark, honouring the +system preference until the reader chooses. The dark theme uses the Sphinx +sites' dark ground (`#222`) and white text, with dark values for every colour +token: links white, the code palette's dark variant, the `seoul256` dark +colours, `#6cb6ff` for the active "On this page" entry and the amber stderr +fold. The choice persists in the browser. diff --git a/docs/features/stderr-warnings.md b/docs/features/stderr-warnings.md new file mode 100644 index 000000000..74f8e6852 --- /dev/null +++ b/docs/features/stderr-warnings.md @@ -0,0 +1,21 @@ +# Collapsible stderr warnings + +When a notebook cell produces stderr output (typically warnings), the theme +folds it behind a compact "⚠ Code warnings" control, closed by default, so the +reader sees the cell's result without the noise and can open the warnings when +troubleshooting. The Sphinx theme does the same with a script after page load; +here it is a native `
` around the stderr stream, done at render, so it +holds in the server-rendered HTML with no JavaScript and survives re-renders. + +- Applies per stderr stream; stdout and results in the same cell stay visible. +- Keyboard and screen-reader accessible through the native disclosure. +- Dark mode supported. + +## Customisation + +Target these classes: + +- `.qe-stderr` — the disclosure +- `.qe-stderr > summary` — the control +- `.qe-stderr__icon` — the warning icon +- `.qe-stderr pre.jupyter-error` — the stream itself diff --git a/docs/git-metadata.md b/docs/git-metadata.md new file mode 100644 index 000000000..40509f99b --- /dev/null +++ b/docs/git-metadata.md @@ -0,0 +1,31 @@ +# Git history in page headers + +The page header shows "Last changed: ⟨date⟩", expanding to an inline changelog +of the most recent commits touching the page, with commit links and a link to +the file's full history, as the Sphinx sites do. It opens in place above the +header's blue rule and renders nothing when no data is present. + +The data is injected at build time by the git-metadata plugin, a MyST transform +that runs `git log --follow` per source file and attaches +`{ last_modified, changelog: [{hash, short_hash, author, date, message}] }` to +the page. Load it by URL pinned to the theme release the site builds with: + +```yaml +project: + github: https://github.com/QuantEcon/lecture-python.myst + plugins: + - https://raw.githubusercontent.com/QuantEcon/quantecon-theme.mystmd/vX.Y.Z/plugins/git-metadata.mjs +``` + +The plugin is moving to the shared `QuantEcon/quantecon-plugins.mystmd` +repository; the pinned URL form stays supported until that release exists. + +Notes: + +- Shallow CI clones (`fetch-depth: 1`) yield truncated history; build with + `fetch-depth: 0`. +- `QE_GIT_METADATA_MAX` caps entries per page (default 6); the panel grows to + fit rather than scrolling. +- A page can pin the control by hand with the same shape under `site:` in its + frontmatter as a YAML block string (`git_metadata: |`), which takes precedence + over the injected data. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..6758c223e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,45 @@ +# QuantEcon MyST theme — user guide + +The feature reference for the QuantEcon lecture theme for MyST / Jupyter Book 2, +organised the way the Sphinx `quantecon-book-theme` documents its own features so +a lecture maintainer moving between the two finds the same headings. Each page +names the `myst.yml` keys involved; the [README](../README.md) has the narrative +version and the [CHANGELOG](../CHANGELOG.md) the history. + +## Quick start + +Point `site.template` at a pinned release asset: + +```yaml +# myst.yml +site: + template: https://github.com/QuantEcon/quantecon-theme.mystmd/releases/download/vX.Y.Z/quantecon-theme.zip +``` + +Then `myst start` for a live server or `myst build --html` for a static site. +Every theme option lives under `site.options` and is listed in +[configuration](configuration.md). + +## Pages + +| Page | Covers | +| --- | --- | +| [configuration](configuration.md) | every `site.options` key, its scope and its default | +| [layout](layout.md) | header, contents drawer, "On this page" panel, back-to-top, footer | +| [authors](authors.md) | author line and translator credit | +| [launch](launch.md) | notebook launch buttons (Colab) and the notebook repo conventions | +| [notebooks](notebooks.md) | notebook output rendering, live compute, collapsible stderr | +| [git-metadata](git-metadata.md) | "Last changed" and the inline changelog | +| [code-highlighting](code-highlighting.md) | the code token palette | +| [text-color-schemes](text-color-schemes.md) | emphasis, strong and definition colours | +| [dark-mode](dark-mode.md) | the dark theme and its tokens | +| [rtl-support](rtl-support.md) | translated editions, the language switcher, right-to-left layout | +| [announcements](announcements.md) | site-wide banner (not offered; see the page) | +| [features/stderr-warnings](features/stderr-warnings.md) | the stderr fold in detail | + +## How this relates to the Sphinx theme + +The theme reproduces the deployed lecture sites' behaviour as those sites configure +the Sphinx theme, not every Sphinx option. Where a Sphinx option has no +counterpart, its page says so and why. The parity work is tracked in the +repository's `PLAN.md`. diff --git a/docs/launch.md b/docs/launch.md new file mode 100644 index 000000000..31745c8a0 --- /dev/null +++ b/docs/launch.md @@ -0,0 +1,23 @@ +# Launch buttons + +The header's launch control opens the page's notebook in **Google Colab**, the +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: + +| 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`. diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 000000000..6d8af3988 --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,51 @@ +# Layout + +The page chrome, matched to the deployed Sphinx lecture sites. + +## Header + +A fixed 50px toolbar: the QuantEcon logo and site title, the contents-drawer +toggle, and on the right the search trigger, launch button, downloads, dark-mode +toggle, full-screen toggle and, when configured, the language switcher and the +live-compute control. Below it, the page header carries the title, the author +line, the translator credit and the "Last changed" control, above a QuantEcon +blue rule. + +## Contents drawer + +The site table of contents is an off-canvas drawer, toggled from the header. It +is built on the native Popover API, so it opens without JavaScript and closes +when the search dialog opens. `hide_toc: true` (site-wide or on a page) removes +it and its toggle. + +## "On this page" + +The right-hand outline is pinned (`position: fixed` in the margin column), +lists h2 and h3 headings with their section numbers when numbering is on, and +marks the section being read in QuantEcon blue, bold, with an inset rule +(`aria-current="location"`). The rule is the Sphinx scrollspy's: a section is +current once its heading has passed 120px from the top, and the last section is +current at the bottom of the page. Past the viewport height the panel scrolls +internally behind a fade. Subsections stay expanded rather than collapsing to +the active branch as the Sphinx panel does under `contents_autoexpand`. + +## Back to top + +A "↑ Top" link in the margin column, visible after 80px of scrolling, as a plain +`#top` fragment link. + +## Footer + +`site.parts.footer` names a Markdown file rendered as the site footer, matching +the Sphinx sites' footer block: + +```yaml +site: + parts: + footer: footer.md +``` + +## Widths + +An 800px body column with a 200px margin column on each side at 1280px and up; +below `lg` (1024px) the outline column is hidden and the layout is single-column. diff --git a/docs/notebooks.md b/docs/notebooks.md new file mode 100644 index 000000000..489eb321e --- /dev/null +++ b/docs/notebooks.md @@ -0,0 +1,36 @@ +# Notebooks + +## Output rendering + +Notebook outputs render through `@myst-theme/jupyter`: streams and errors as +`
`, execute results and display data by MIME type (text, HTML, images,
+Plotly and widgets). Long outputs are clipped with a scroll region.
+
+## Collapsible stderr
+
+A cell's stderr stream is folded behind a "⚠ Code warnings" disclosure, closed
+by default, so verbose warnings do not break the reading flow; stdout in the
+same cell stays visible. It is a native `
`, so it works in the +server-rendered HTML without a script. Details in +[features/stderr-warnings](features/stderr-warnings.md). + +## Live compute + +Cells can run in place through Thebe, opt-in per project under the standard +`project.thebe` key; the QuantEcon default is JupyterLite, so Python runs in the +browser via Pyodide with nothing to host: + +```yaml +project: + thebe: + lite: true +``` + +A Power control then appears in the header on notebook pages; connecting swaps +it for Run / Restart / Clear. Pyodide runs pure Python and the packages built +for it (numpy, scipy, pandas, matplotlib, sympy); numba and JAX do not import. +`binder:` and `server:` backends are available through the same key for +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. diff --git a/docs/rtl-support.md b/docs/rtl-support.md new file mode 100644 index 000000000..2194dde77 --- /dev/null +++ b/docs/rtl-support.md @@ -0,0 +1,37 @@ +# Translated editions and right-to-left support + +## Editions and the language switcher + +An edition declares its language and its siblings under `site.options`; with +two or more entries the header shows a language switcher linking to the same +page in each edition, and every page carries `hreflang` alternates (the first +entry is `x-default`): + +```yaml +site: + options: + current_language: fa + language_switcher_label: تغییر زبان + languages: | + - code: en + name: English + url: https://python-programming.quantecon.org + - code: fa + name: فارسی + url: https://quantecon.github.io/lecture-python-programming.fa +``` + +`languages` is a YAML block in a string because template options are +scalar-only. `current_language` also sets the document `lang`, marks the active +switcher entry and drives `og:locale`. + +## Right-to-left + +`enable_rtl: true` renders the document `dir="rtl"`. The layout uses logical +properties throughout, so the header, the contents drawer, the outline, list +markers (including parenthesised fancy markers) and the translator credit +mirror. A Persian fixture in the repository's visual tests keeps it that way. + +## Translator credit + +See [authors](authors.md). diff --git a/docs/text-color-schemes.md b/docs/text-color-schemes.md new file mode 100644 index 000000000..c98be6d34 --- /dev/null +++ b/docs/text-color-schemes.md @@ -0,0 +1,18 @@ +# Text colour schemes + +Emphasis, strong text and definition terms render in the Sphinx sites' default +`seoul256` scheme: teal `em`, amber `strong` and `dt`, both upright at weight +550, alongside the existing inline-literal colour. They are custom properties +with dark-mode values: + +| Property | Light | Dark | +| --- | --- | --- | +| `--qe-emphasis-color` | `#005f5f` | `#5fafaf` | +| `--qe-strong-color` | `#875f00` | `#d7af5f` | +| `--qe-definition-color` | strong | strong | +| `--qe-literal-color` | `#af5f5f` | `#d78787` | +| `--qe-link-color` | `#0072bc` | `#fff` | + +Only the default scheme ships: no lecture repository sets `color_scheme`, so the +`gruvbox` and `none` switches and the custom-CSS hook are deferred until a +consumer asks for one. diff --git a/styles/quantecon.css b/styles/quantecon.css index 80424890d..d90d00cb1 100644 --- a/styles/quantecon.css +++ b/styles/quantecon.css @@ -507,6 +507,56 @@ there rather than painting this blue on a dark background. --------------------------------------------------------------------------- */ @layer components { + /* Collapsible stderr (#92): the Sphinx build's amber "Code warnings" + fold (_stderr.scss), as a native
. Colours are the book + theme's: #ffc107 border and icon, #856404 label (7.0:1 on white). */ + .qe-stderr { + margin: 0.5rem 0; + border: 1px solid #ffc107; + border-radius: 4px; + overflow: hidden; + } + .qe-stderr > summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + color: #856404; + list-style: none; + } + .qe-stderr > summary::-webkit-details-marker { + display: none; + } + .qe-stderr > summary::after { + content: "\25B6"; /* the chevron, rotated when open */ + margin-inline-start: auto; + font-size: 0.7rem; + transition: transform 0.2s ease; + } + .qe-stderr[open] > summary::after { + transform: rotate(90deg); + } + .qe-stderr > summary:hover { + background-color: #fff9e6; + } + .qe-stderr > summary:focus-visible { + outline: 2px solid #ffc107; + outline-offset: -2px; + } + .qe-stderr__icon { + color: #ffc107; + font-size: 1.1rem; + } + .dark .qe-stderr > summary { + color: #ffd54f; + } + .dark .qe-stderr > summary:hover { + background-color: rgba(255, 193, 7, 0.08); + } + .qe-back-to-top a { /* `rem` for the same reason as the footer: pinned to the Sphinx button's size, not the content base. The awkward figure is exactly .85rem of the diff --git a/template.yml b/template.yml index e543e4a2d..693773989 100644 --- a/template.yml +++ b/template.yml @@ -68,6 +68,22 @@ options: description: > Twitter/X handle for the `twitter:site` and `twitter:creator` card meta tags, with or without the leading `@`. + - id: site_url + type: string + description: > + The site's public URL (the Sphinx sites' `html_baseurl`), for the + absolute `og:url` on every page. `site.domains` would be the natural + source but does not reach the theme. + - id: og_logo_url + type: string + description: > + Absolute URL of the image for `og:image` when a page has no thumbnail + (the book theme's `og_logo_url`). + - id: twitter_logo_url + type: string + description: > + Absolute URL of the image for `twitter:image` (the book theme's + `twitter_logo_url`); falls back to the OpenGraph image. - id: favicon type: file description: > diff --git a/tests/unit/seo.test.mjs b/tests/unit/seo.test.mjs new file mode 100644 index 000000000..2c2c2d315 --- /dev/null +++ b/tests/unit/seo.test.mjs @@ -0,0 +1,95 @@ +/** + * Unit tests for the social/SEO meta helpers (app/seo.ts, #92). 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 { mergeMeta, ogLocale, siteOrigin, socialMetaTags } from '../../app/seo.ts'; + +const byKey = (tags) => Object.fromEntries(tags.map((t) => [t.property ?? t.name, t.content])); + +test('siteOrigin: site_url wins and is reduced to an origin; domains are the fallback', () => { + assert.equal(siteOrigin('https://python-programming.quantecon.org/'), 'https://python-programming.quantecon.org'); + assert.equal(siteOrigin('https://quantecon.github.io/lecture-python-programming.fa/'), 'https://quantecon.github.io'); + assert.equal(siteOrigin('example.org'), 'https://example.org'); + assert.equal(siteOrigin(undefined, ['python-programming.quantecon.org']), 'https://python-programming.quantecon.org'); + assert.equal(siteOrigin('', ['', ' example.org/ ']), 'https://example.org'); + assert.equal(siteOrigin(undefined, ['http://localhost:3000']), 'http://localhost:3000'); + assert.equal(siteOrigin(undefined, undefined), undefined); + assert.equal(siteOrigin(' ', []), undefined); +}); + +test('ogLocale: BCP 47 to OpenGraph', () => { + assert.equal(ogLocale('en'), 'en_US'); + assert.equal(ogLocale('zh-cn'), 'zh_CN'); + assert.equal(ogLocale('fa'), 'fa_IR'); + assert.equal(ogLocale('pt-BR'), 'pt_BR'); + assert.equal(ogLocale('xx'), 'xx'); + assert.equal(ogLocale(undefined), undefined); +}); + +test('the Sphinx set, on a lecture page with the site-level images', () => { + const tags = byKey( + socialMetaTags({ + siteTitle: 'Python Programming for Economics and Finance', + pathname: '/python-by-example/', + options: { + site_url: 'https://python-programming.quantecon.org', + twitter: 'quantecon', + og_logo_url: 'https://assets.quantecon.org/img/qe-og-logo.png', + twitter_logo_url: 'https://assets.quantecon.org/img/qe-twitter-logo.png', + current_language: 'en', + }, + }), + ); + assert.deepEqual(tags, { + 'og:type': 'website', + 'og:site_name': 'Python Programming for Economics and Finance', + 'og:url': 'https://python-programming.quantecon.org/python-by-example/', + 'og:image': 'https://assets.quantecon.org/img/qe-og-logo.png', + 'twitter:site': '@quantecon', + 'twitter:card': 'summary', + 'twitter:image': 'https://assets.quantecon.org/img/qe-twitter-logo.png', + 'og:locale': 'en_US', + }); +}); + +test('a page thumbnail beats the site image for og:image; twitter:image follows the Twitter logo', () => { + const tags = byKey( + socialMetaTags({ + pathname: '/p', + pageImage: '/thumb.png', + options: { og_logo_url: 'https://x/og.png', twitter_logo_url: 'https://x/tw.png', twitter: '@qe' }, + }), + ); + assert.equal(tags['og:image'], '/thumb.png'); + assert.equal(tags['twitter:image'], 'https://x/tw.png'); + assert.equal(tags['twitter:site'], '@qe'); +}); + +test('nothing configured: only og:type, and no dangling twitter tags', () => { + const tags = socialMetaTags({ pathname: '/p' }); + assert.deepEqual(tags, [{ property: 'og:type', content: 'website' }]); +}); + +test('mergeMeta replaces same-key upstream tags and keeps the rest in order', () => { + const base = [ + { title: 'T' }, + { property: 'og:title', content: 'T' }, + { name: 'twitter:card', content: 'summary_large_image' }, + { property: 'og:image', content: '/old.png' }, + ]; + const merged = mergeMeta(base, [ + { property: 'og:image', content: '/new.png' }, + { name: 'twitter:card', content: 'summary' }, + { property: 'og:type', content: 'website' }, + ]); + assert.deepEqual(merged, [ + { title: 'T' }, + { property: 'og:title', content: 'T' }, + { property: 'og:image', content: '/new.png' }, + { name: 'twitter:card', content: 'summary' }, + { property: 'og:type', content: 'website' }, + ]); +}); diff --git a/tests/visual/fixture-no-thebe/myst.yml.in b/tests/visual/fixture-no-thebe/myst.yml.in index 862b58e2f..9921d5510 100644 --- a/tests/visual/fixture-no-thebe/myst.yml.in +++ b/tests/visual/fixture-no-thebe/myst.yml.in @@ -14,3 +14,10 @@ site: title: QE Theme No-Thebe Fixture template: __THEME__ options: + # Social meta (#92), asserted in theme.spec.ts ("social-meta"). `site_url` + # feeds og:url; myst's own `site.domains` never reaches the theme's manifest. + site_url: https://example.org + twitter: quantecon + og_logo_url: https://assets.example.org/qe-og-logo.png + twitter_logo_url: https://assets.example.org/qe-twitter-logo.png + current_language: en diff --git a/tests/visual/fixture-no-thebe/notebook.ipynb b/tests/visual/fixture-no-thebe/notebook.ipynb index cd2ffd450..2bc895283 100644 --- a/tests/visual/fixture-no-thebe/notebook.ipynb +++ b/tests/visual/fixture-no-thebe/notebook.ipynb @@ -68,6 +68,33 @@ "source": [ "raise ValueError(\"a deliberate error to render the traceback\")" ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "W0910 10:15:26.780179 cuda_executor.cc:1802] GPU interconnect information not available\n", + "UserWarning: a deliberate warning on stderr, folded behind 'Code warnings' (#92)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "and a normal line on stdout, left visible\n" + ] + } + ], + "source": [ + "import warnings\n", + "warnings.warn('a deliberate warning on stderr')\n", + "print('and a normal line on stdout, left visible')" + ] } ], "metadata": { @@ -83,4 +110,4 @@ }, "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 46f7df340..a2a61e84c 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -332,6 +332,55 @@ test.describe("QuantEcon theme — visual regression", () => { * translator with a page-level override on `/` and a suppression on * `/lists`; `fixture-rtl` is the Persian edition with `enable_rtl`. */ +test.describe("Meta/SEO and notebook output polish (#92)", () => { + const noThebeBase = `http://localhost:${process.env.NO_THEBE_PORT || "3112"}`; + const meta = (page: Page, sel: string) => page.locator(`head meta[${sel}]`); + + // The Sphinx lecture sites' OpenGraph / Twitter set, on a lecture page. The + // no-thebe fixture declares `site_url`, `twitter`, both logo URLs and + // `current_language`; nothing here depends on the page having a thumbnail. + test("social-meta", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent"); + await page.goto(`${noThebeBase}/notebook`, { waitUntil: "domcontentloaded" }); + const expectTag = async (sel: string, content: string | RegExp) => + expect(meta(page, sel)).toHaveAttribute("content", content); + await expectTag('property="og:type"', "website"); + await expectTag('property="og:site_name"', "QE Theme No-Thebe Fixture"); + await expectTag('property="og:url"', "https://example.org/notebook"); + await expectTag('property="og:image"', "https://assets.example.org/qe-og-logo.png"); + await expectTag('property="og:locale"', "en_US"); + await expectTag('name="twitter:site"', "@quantecon"); + await expectTag('name="twitter:creator"', "@quantecon"); + await expectTag('name="twitter:card"', "summary"); + await expectTag('name="twitter:image"', "https://assets.example.org/qe-twitter-logo.png"); + // Replaced, not duplicated: one og:image, one twitter:card. + await expect(meta(page, 'property="og:image"')).toHaveCount(1); + await expect(meta(page, 'name="twitter:card"')).toHaveCount(1); + }); + + // A cell's stderr stream is folded behind a "Code warnings" disclosure, + // closed by default, as the Sphinx build's stderr-warnings.js does; stdout + // in the same cell stays visible. A native
, so it holds in the + // server-rendered HTML too. + test("stderr-collapsed", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent"); + await page.goto(`${noThebeBase}/notebook`, { waitUntil: "domcontentloaded" }); + await settle(page); + const fold = page.locator("details.qe-stderr"); + await expect(fold).toHaveCount(1); + await expect(fold).not.toHaveAttribute("open", ""); + await expect(fold.locator("summary")).toContainText("Code warnings"); + await expect(fold.locator("pre.jupyter-error")).toBeHidden(); + // The output, not the source cell that also carries the string. + await expect( + page.locator("pre.jupyter-output", { hasText: "and a normal line on stdout, left visible" }) + ).toBeVisible(); + await fold.locator("summary").click(); + await expect(fold.locator("pre.jupyter-error")).toBeVisible(); + await expect(fold.locator("pre.jupyter-error")).toContainText("a deliberate warning on stderr"); + }); +}); + test.describe("Site options reach the theme (#173)", () => { // The CLI validates `site.options` against template.yml and DROPS every key // the template does not declare, so a theme that reads an undeclared option From 67b955f41f267842ff2084c7fa4bfa0d912bff5d Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 10 Sep 2026 22:22:47 +1000 Subject: [PATCH 2/3] docs(layout): the outline collapses sub-lists to the current branch Matches the autoexpand behaviour #196 now ships. Co-Authored-By: Claude Fable 5.1 --- docs/layout.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/layout.md b/docs/layout.md index 6d8af3988..00d18fd8b 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -26,8 +26,10 @@ marks the section being read in QuantEcon blue, bold, with an inset rule (`aria-current="location"`). The rule is the Sphinx scrollspy's: a section is current once its heading has passed 120px from the top, and the last section is current at the bottom of the page. Past the viewport height the panel scrolls -internally behind a fade. Subsections stay expanded rather than collapsing to -the active branch as the Sphinx panel does under `contents_autoexpand`. +internally behind a fade. Subsections collapse to the current branch as the +Sphinx panel does under `contents_autoexpand`: only the sections show until +you scroll into one, its subsections then expand, and the parent of a current +subsection is expanded but not marked. ## Back to top From 62aad7216c686ff243ef83fbb89b8cc6d976892f Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 10 Sep 2026 22:31:41 +1000 Subject: [PATCH 3/3] docs(changelog): link #197 from the Phase 6 entries Copilot review on #197: each Unreleased entry links its PR. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d67ff8b8..d091c0b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,18 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `twitter:site`, and `og:locale` from `current_language`. `twitter:site` had never rendered: upstream puts it in the root route's meta, which the article route's replaces under Remix v2 semantics. Phase 6 of the book-theme parity - plan ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). + plan ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)). - Collapsible stderr: a notebook cell's stderr stream is folded behind a "⚠ Code warnings" disclosure, closed by default, as the Sphinx build's `stderr-warnings.js` does. A native `
` around the stream at render time rather than DOM surgery after load, so it holds in server-rendered HTML without a script; stdout in the same cell stays visible - ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). + ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)). - A `docs/` feature reference mirroring the book theme's `docs/user/*` pages (configuration, layout, authors, launch, notebooks, git metadata, code highlighting, text colour schemes, dark mode, RTL and editions, announcements), each naming its `myst.yml` keys and what has no counterpart - ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)). + ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)). ### Fixed - Every site option the theme reads is now declared in `template.yml`, so it