diff --git a/CHANGELOG.md b/CHANGELOG.md index df289d500..25fef7462 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)) ([#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)) ([#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)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)). + ### Fixed - The "On this page" panel is pinned, tracks scrolling and lists subsections, as the Sphinx sites' `sticky_contents` panel does. It scrolled off with the diff --git a/PLAN.md b/PLAN.md index b2ccd0797..517314fe1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -484,16 +484,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..00d18fd8b --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,53 @@ +# 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 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 + +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 b206dec26..2391a4b69 100644 --- a/styles/quantecon.css +++ b/styles/quantecon.css @@ -601,6 +601,56 @@ border-inline-start-color: #6cb6ff; } + /* 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 d48c9b7e3..65461bb81 100644 --- a/tests/visual/fixture-no-thebe/myst.yml.in +++ b/tests/visual/fixture-no-thebe/myst.yml.in @@ -22,3 +22,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 b1efa8eee..0c545c5f2 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -430,6 +430,55 @@ test.describe("On this page outline (#182)", () => { }); }); +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