diff --git a/.gitignore b/.gitignore
index ec2b26352..d3af3b2d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,5 @@ remix.config.js
/tests/visual/fixture/_build
/tests/visual/fixture-no-thebe/myst.yml
/tests/visual/fixture-no-thebe/_build
+/tests/visual/fixture-rtl/myst.yml
+/tests/visual/fixture-rtl/_build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fabf869a8..591fb6029 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the existing `--qe-literal-color`. Only the default scheme ships: no lecture
repo sets `color_scheme`, so the `gruvbox` / `none` switches and the
custom-CSS hook are deferred until a consumer asks for one. Phase 3, Goal B. ([#171](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/171))
+- Language switcher and `hreflang` alternates for translated editions: with
+ two or more entries in the new `languages` site option the toolbar gains a
+ globe-icon dropdown linking to the same page in each edition, the entry
+ matching `current_language` marked current, and every page carries
+ `` tags with the first entry as `x-default`.
+ Phase 4 of the book-theme parity plan
+ ([#90](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/90)). ([#174](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/174))
+- Right-to-left editions: `enable_rtl` sets `dir="rtl"` on the document (and
+ `current_language` now sets its `lang`, in place of the hard-coded `en`),
+ with the drawer, upstream content accents and spacing mirrored and code and
+ maths kept left-to-right. Phase 5
+ ([#91](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/91)). ([#174](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/174))
+- Translator credit in the page header from the new `translators` site option,
+ with a localisable `translators_label` (default "Translated by") and a
+ per-page override under `site:` in page frontmatter, on the shape settled
+ across the themes on QuantEcon/workspace-themes#3
+ ([#143](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/143)). ([#174](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/174))
+- The site options above are declared in `template.yml`. The MyST CLI drops
+ any `site.options` key the template does not declare, and can only declare
+ scalar types, so `languages` and `translators` are YAML written inside a
+ block string; see the README's "Multilingual editions". ([#174](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/174))
## [2.5.0] - 2026-09-05
diff --git a/PLAN.md b/PLAN.md
index b28434287..016bacf62 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -50,8 +50,9 @@ Derived from `quantecon-book-theme` v0.20.3 (see its `README.md`, `docs/user/*`,
| Launch parity — Thebe (live compute) in addition to Colab / private hub (BinderHub dropped, #26) | ✅ | ✅ | **2** |
| Code highlighting in the QE token palette (the Pygments-style toggle has no user; deferred) | ✅ | ✅ | **3** |
| Text colour scheme `seoul256` (default; `gruvbox` / `none` / custom have no user; deferred) | ✅ | ✅ | **3** |
-| Language switcher (multilingual) + `hreflang` SEO tags | ✅ | ❌ | **4** |
-| RTL support (`dir="rtl"`) | ✅ | ❌ | **5** |
+| Language switcher (multilingual) + `hreflang` SEO tags | ✅ | ✅ | **4** |
+| RTL support (`dir="rtl"`) | ✅ | ✅ | **5** |
+| Translator credit (`translators` + localisable label; #143, QuantEcon/workspace-themes#3) | ✅ | ✅ | **4** |
| Collapsible stderr warnings in notebook cells | ✅ | ❓ verify | **6** |
| Full OpenGraph / Twitter card meta tags | ✅ | ⚠️ partial | **6** |
| **Already at parity:** dark mode, font scaling, fullscreen, search, "On this page" TOC + back-to-top, contents sidebar, downloads (PDF/notebook), Colab launch, edit-on-GitHub, author header, content-driven footer, responsive/mobile | ✅ | ✅ | — |
@@ -60,7 +61,8 @@ Derived from `quantecon-book-theme` v0.20.3 (see its `README.md`, `docs/user/*`,
[v2.1.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.1.0)–[v2.2.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.2.0); Phase 1 and the Thebe half of Phase 2
shipped in [v2.3.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.3.0), and Phase 2's launch-config half shipped in
[v2.2.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.2.0). Phase 3 landed on `main`
-on 2026-09-07 (#89), unreleased. **Phases 4–5 are next.**
+on 2026-09-10 (#89, #171), unreleased; Phases 4–5 and the translator credit (#143) follow in
+#174. **Phase 6 is next.**
---
@@ -371,7 +373,7 @@ properly is tracked as future work — see the enhancement issues linked from #8
## Phase 3 — Code highlighting + text colour schemes
-**Status: complete** *(2026-09-07, #89 via #171)* — defaults only, by design. Before building,
+**Status: complete** *(2026-09-10, #89 via #171)* — defaults only, by design. Before building,
the scoping pass on #89 found that no lecture repo sets `qetheme_code_style` or
`color_scheme`: every live site runs the defaults, so parity is the default rendering,
applied unconditionally, and the switch surface (Pygments toggle, `gruvbox` / `none`,
@@ -410,6 +412,14 @@ between custom QuantEcon token colours and any built-in Pygments style.
## Phase 4 — Internationalisation (language switcher + hreflang)
+**Status: complete** *(#90 via #174; translator credit #143 shipped with it)*. One
+engine fact shaped the configuration: the MyST CLI validates `site.options` against the
+template's declared options and **drops every undeclared key** (myst-templates
+`validateTemplateOptions`), and it can declare only scalar types. So the options are
+declared in `template.yml`, and the two lists (`languages`, `translators`) are YAML written
+inside a block string, parsed by `app/i18n.ts`; a real list is accepted too. See README
+"Multilingual editions".
+
**Goal:** book-theme v0.20.0 globe-icon dropdown to switch between translated lecture
sites, plus `` head tags for SEO. Only renders with 2+
languages configured.
@@ -418,12 +428,20 @@ hreflang block + language-switcher markup, `assets/scripts/language-switcher.js`
`assets/styles/_language-switcher.scss`, `docs/user/rtl-support.md`,
`docs/developer/multilingual.md` + `infrastructure.md`.
-- [ ] Add a `languages` config (list of `{code, name, url}`) to `myst.yml`/site config,
- surfaced to the theme via the site manifest loader (`loaders.server.ts`).
-- [ ] New toolbar `LanguageSwitcher.tsx` (Radix dropdown), placed consistently in
- `Toolbar.tsx` / `MobileActionsMenu.tsx`, with keyboard nav + active-language marker.
-- [ ] Inject `hreflang` alternates in `root.tsx` `links`/`meta` for each language +
- `x-default`.
+- [x] `languages` (`{code, name, url}` list, 2+ entries to render) and `current_language`
+ under `site.options`, declared in `template.yml`; no loader change was needed once
+ declared, since the site manifest carries validated options.
+- [x] `LanguageSwitcher.tsx` (Radix dropdown, globe icon) at the far end of `Toolbar.tsx` at
+ every width, rather than inside the overflow menu: it is the one action a reader of a
+ translated edition reaches for. Keyboard navigation from Radix; the current edition
+ carries `aria-current` and a check mark; items are real anchors with `hreflang`.
+- [x] `hreflang` alternates (+ `x-default` on the first entry) from the two page routes'
+ `meta` (Remix v2 `tagName: 'link'`), not `root.tsx`: the alternates need the page path.
+- [x] Translator credit (#143): `translators` / `translators_label` site options with a
+ per-page `site:` override (replace, never merge; explicit empty suppresses), rendered
+ at the end of the "Last changed" row as the book theme does since v0.22.0. Labels
+ (`translators_label`, `language_switcher_label`) are the localisation mechanism; the
+ list connector (", " / " and ") stays English, the same gap the book theme records.
**Effort:** M. **Risk:** low. **Deps:** none (independent of 1–3).
@@ -431,14 +449,22 @@ hreflang block + language-switcher markup, `assets/scripts/language-switcher.js`
## Phase 5 — RTL support
+**Status: complete** *(#91 via #174)*.
+
**Goal:** book-theme `enable_rtl` sets `dir="rtl"` on `
` and ships `_rtl.scss`.
**Reference:** book-theme `layout.html` `body_tag` block + `assets/styles/_rtl.scss` +
`docs/user/rtl-support.md`.
-- [ ] Add a config flag; set `dir="rtl"` on the document in `root.tsx`.
-- [ ] Audit Tailwind utilities for logical-property / RTL correctness (margins, the blue
- left/right accents, toolbar ordering); add RTL overrides where physical properties
- leak.
+- [x] `enable_rtl` sets `dir="rtl"` (and `current_language` sets `lang`) on `` in the
+ server render. Upstream's `Document` hard-codes `lang="en"` and has no `dir`, so the
+ theme carries a local copy (`app/components/Document.tsx`) that adds the two props; an
+ upstream prop is the candidate in `UPSTREAM-CANDIDATES.yml`. Radix's floating
+ positioning reads the CSS `direction`, so no `DirectionProvider` is needed.
+- [x] Audit: the theme's own components now use logical utilities (`ms-`, `me-`, `pe-`), and
+ `styles/rtl.css` mirrors the physical utilities upstream content markup uses
+ (`border-l-4` accents, `pl-*`/`ml-*` spacing, the copy button), flips the drawer's
+ slide, and keeps code and maths left-to-right. Snapshot-tested on a Persian fixture
+ (`tests/visual/fixture-rtl`).
**Effort:** S–M. **Risk:** low. **Deps:** ideally after Phase 4 (often shipped together
for the same translated sites).
@@ -471,13 +497,13 @@ Phase 0 (hygiene/deploy + preview harness) ── prerequisite ✅ shipped
│
├─▶ Phase 1 Git history in headers ⭐ ✅ shipped v2.3.0
├─▶ Phase 2 Launch parity (Thebe + config) ✅ shipped v2.2.0 / v2.3.0
- ├─▶ Phase 3 Code highlight + colour schemes ✅ on main 2026-09-07 (unreleased)
- ├─▶ Phase 4 i18n (language switcher) ──▶ Phase 5 RTL (commonly shipped together) ← next
- └─▶ Phase 6 Meta/SEO + stderr + docs
+ ├─▶ Phase 3 Code highlight + colour schemes ✅ on main 2026-09-10 (unreleased)
+ ├─▶ Phase 4 i18n (language switcher) ──▶ Phase 5 RTL ✅ via #174 (unreleased)
+ └─▶ Phase 6 Meta/SEO + stderr + docs ← next
```
Suggested order: **0 → 1 → 2 → 3 → (4 → 5) → 6**. Phases 0–2 are shipped as of
-[v2.3.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.3.0) (2026-08-20) and Phase 3 is on `main`, so **Phases 4–5 are next**. Phases 4–6 are largely
+[v2.3.0](https://github.com/QuantEcon/quantecon-theme.mystmd/releases/tag/v2.3.0) (2026-08-20) and Phases 3–5 are on `main`, so **Phase 6 is next**. Phases 3–6 are largely
independent of one another and can be parallelised across contributors. Per open
question 4's resolution below, all of Phases 3–6 gate the all-at-once lecture
migration — they are cutover blockers, not optional polish.
diff --git a/README.md b/README.md
index 86044fd17..df936f9f1 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,52 @@ Notes:
`site.git_metadata` in the page frontmatter, which takes precedence over the
injected data (this is how the visual fixture keeps snapshots deterministic).
+### Multilingual editions
+
+A translated edition sets its language, direction and translators, and lists the
+sibling editions, under `site.options` (all declared in `template.yml`):
+
+```yaml
+# myst.yml
+site:
+ options:
+ current_language: fa # BCP 47 code: document lang, current entry in the switcher
+ enable_rtl: true # dir="rtl" on the document (Arabic, Persian, Hebrew, Urdu)
+ language_switcher_label: تغییر زبان # tooltip / accessible name (default "Switch language")
+ languages: |
+ - code: en
+ name: English
+ url: https://python-programming.quantecon.org
+ - code: fa
+ name: فارسی
+ url: https://quantecon.github.io/lecture-python-programming.fa
+ translators: |
+ - name: Adisankar Manoj Thanuja
+ url: https://www.linkedin.com/in/adisankar-m-t/
+ translators_label: ترجمهٔ # default "Translated by"; an empty string hides the label
+```
+
+- **`languages`** — with two or more entries the toolbar shows a globe-icon
+ switcher linking to the *same page* in each edition (that edition's `url` plus
+ the page path), and every page carries `` tags,
+ with the first entry as `x-default`. The entry matching `current_language` is
+ marked current.
+- **`translators`** — credited in the page header, at the end of the "Last
+ changed" row, with the label in front. A page overrides the list in its own
+ frontmatter under `site:`; the page value replaces the site value (never
+ merges), and an explicitly empty value (`translators: ''`) suppresses the
+ credit on that page. The same values configure `quantecon-book-theme`.
+
+`languages` and `translators` are written as YAML *inside a block string*
+(`|`). The MyST CLI validates `site.options` against the template's declared
+options and can only declare scalar types, so a bare list would be dropped; the
+theme parses the block. A real list is accepted too, should the engine ever pass
+one through.
+
+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.
+
## Usage with MyST
Point your project's `site.template` at a **pinned release** zip:
diff --git a/UPSTREAM-CANDIDATES.yml b/UPSTREAM-CANDIDATES.yml
index 778cf9e18..bcde76bdb 100644
--- a/UPSTREAM-CANDIDATES.yml
+++ b/UPSTREAM-CANDIDATES.yml
@@ -44,20 +44,56 @@ upstream_candidates:
- id: language-switcher
title: Language switcher + hreflang alternates
- status: planned
+ description: |
+ `languages` / `current_language` site options; a globe-icon Radix
+ dropdown in the toolbar linking to the same page in each edition
+ (app/components/toolbar/LanguageSwitcher.tsx), and `` tags emitted from the page routes' `meta` (app/i18n.ts).
+ status: pending
target: jupyter-book/myst-theme
- provenance: []
+ provenance:
+ - local_pr: 174
+ note: Phase 4 implementation, with Phase 5 and the translator credit
upstream:
pr: null
notes: |
- Phase 4 (#90); not yet built. Record local PRs here as they land.
+ Phase 4 (#90). The option shape is constrained by the CLI: template
+ options are scalar-only, so the list is a YAML block in a string. An
+ upstream version would want a real list type on template options
+ (jupyter-book/mystmd) before the theme half is worth porting.
- id: rtl-support
title: RTL rendering (dir="rtl" config + logical-property audit)
- status: planned
+ description: |
+ `enable_rtl` site option; `dir` and `lang` set on in the server
+ render; styles/rtl.css mirrors the physical utilities upstream content
+ markup uses and keeps code and maths LTR.
+ status: pending
+ target: jupyter-book/myst-theme
+ provenance:
+ - local_pr: 174
+ note: Phase 5 implementation
+ upstream:
+ pr: null
+ notes: |
+ Phase 5 (#91). The most reusable piece upstream is the audit itself:
+ myst-to-react and @myst-theme/jupyter use physical `border-l-*` /
+ `pl-*` / `ml-*` utilities where logical ones (`border-s-*`, `ps-*`,
+ `ms-*`) would make RTL free. Shipped with language-switcher.
+
+ - id: document-lang-dir
+ title: Document accepts `lang` and `dir` props
+ description: |
+ @myst-theme/site's `Document` hard-codes `` and sets no
+ `dir`. The theme carries a local copy (app/components/Document.tsx) that
+ adds the two props.
+ status: pending
target: jupyter-book/myst-theme
- provenance: []
+ provenance:
+ - local_pr: 174
+ note: Local Document copy, taken for Phase 5
upstream:
pr: null
notes: |
- Phase 5 (#91); not yet built. Commonly shipped with language-switcher.
+ A two-prop change to src/pages/Root.tsx upstream; once accepted, the
+ local copy can be deleted. Small enough to open ahead of the rest.
diff --git a/app/components/Document.tsx b/app/components/Document.tsx
new file mode 100644
index 000000000..530d5f4b6
--- /dev/null
+++ b/app/components/Document.tsx
@@ -0,0 +1,164 @@
+import type { SiteManifest } from 'myst-config';
+import type { NodeRenderers, Theme } from '@myst-theme/providers';
+import { BaseUrlProvider, SiteProvider, ThemeProvider, useThemeSwitcher } from '@myst-theme/providers';
+import {
+ Link,
+ Links,
+ LiveReload,
+ Meta,
+ NavLink,
+ Scripts,
+ ScrollRestoration,
+ useNavigate,
+} from '@remix-run/react';
+import {
+ Analytics,
+ BlockingThemeLoader,
+ DEFAULT_NAV_HEIGHT,
+ renderers as defaultRenderers,
+ useTheme,
+} from '@myst-theme/site';
+import classNames from 'classnames';
+
+/**
+ * The document shell: ``, `` and ``.
+ *
+ * A local copy of `Document` / `DocumentWithoutProviders` from
+ * `@myst-theme/site` (src/pages/Root.tsx), taken so the theme can set two
+ * attributes upstream hard-codes: `lang`, which upstream pins to "en", and
+ * `dir`, which upstream does not set at all. A translated edition needs both
+ * on the root element -- `lang` for screen readers, hyphenation and search
+ * engines, `dir="rtl"` for the layout of a Persian, Arabic, Hebrew or Urdu
+ * edition -- and they have to be there in the server render: setting them
+ * after hydration would paint the first frame left-to-right and then flip it.
+ *
+ * No Radix `DirectionProvider` is needed: the floating positioning under
+ * dropdown menus and tooltips reads the computed CSS `direction`, which
+ * `dir` on sets, so they mirror on their own. (A provider was tried
+ * and found inert: the theme's copy of `@radix-ui/react-direction` was a
+ * different instance from the one the menu and select packages pin, so its
+ * context never reached them.)
+ *
+ * Everything else is upstream's, unchanged, so a future upstream `lang` /
+ * `dir` prop (see UPSTREAM-CANDIDATES.yml) lets this file be deleted again.
+ * Kept in step with @myst-theme/site 1.3.0.
+ */
+export function Document({
+ children,
+ scripts,
+ theme: ssrTheme,
+ config,
+ title,
+ staticBuild,
+ baseurl,
+ top = DEFAULT_NAV_HEIGHT,
+ renderers = defaultRenderers,
+ head,
+ lang,
+ dir,
+}: {
+ children: React.ReactNode;
+ scripts?: React.ReactNode;
+ theme?: Theme;
+ config?: SiteManifest;
+ title?: string;
+ staticBuild?: boolean;
+ baseurl?: string;
+ top?: number;
+ renderers?: NodeRenderers;
+ head?: React.ReactNode;
+ lang?: string;
+ dir?: 'ltr' | 'rtl';
+}) {
+ const navigate = useNavigate();
+ const links = staticBuild
+ ? {
+ Link: (props: any) => ,
+ NavLink: (props: any) => ,
+ }
+ : {
+ Link: Link as any,
+ NavLink: NavLink as any,
+ navigate,
+ };
+
+ // (Local) theme state driven by SSR and cookie/localStorage
+ const [theme, setTheme] = useTheme({ ssrTheme: ssrTheme, useLocalStorage: staticBuild });
+
+ // Inject blocking element to set proper pre-hydration state
+ const headAndLoader = (
+ <>
+ {head}
+ {ssrTheme ? undefined : }
+ >
+ );
+
+ return (
+
+
+
+ );
+}
+
+export function DocumentWithoutProviders({
+ children,
+ scripts,
+ head,
+ config,
+ title,
+ baseurl,
+ top = DEFAULT_NAV_HEIGHT,
+ liveReloadListener,
+ lang = 'en',
+ dir,
+}: {
+ children: React.ReactNode;
+ scripts?: React.ReactNode;
+ head?: React.ReactNode;
+ config?: SiteManifest;
+ title?: string;
+ baseurl?: string;
+ top?: number;
+ liveReloadListener?: boolean;
+ lang?: string;
+ dir?: 'ltr' | 'rtl';
+}) {
+ // Theme value from theme context; see upstream for the SSR / BlockingThemeLoader handshake.
+ const { theme } = useThemeSwitcher();
+ return (
+
+
+
+
+ {title && {title}}
+
+
+
+ {head}
+
+
+
+ {children}
+
+
+
+ {liveReloadListener && }
+ {scripts}
+
+
+ );
+}
diff --git a/app/components/PageContent.tsx b/app/components/PageContent.tsx
index 6734c3728..1be5ad870 100644
--- a/app/components/PageContent.tsx
+++ b/app/components/PageContent.tsx
@@ -27,6 +27,7 @@ import { ProjectFrontmatter } from './ProjectFrontmatter.js';
import { BackToTop, Outline } from './Outline.js';
import { SiteFooter } from './SiteFooter.js';
import { ComputeToolbarSlot } from './ComputeToolbarSlot.js';
+import { DEFAULT_TRANSLATORS_LABEL, resolveLabel, resolvePeople } from '~/i18n';
export const PageContent = React.memo(function ({ article }: { article: PageLoader }) {
const config = useSiteManifest();
@@ -36,6 +37,20 @@ export const PageContent = React.memo(function ({ article }: { article: PageLoad
const keywords = article.frontmatter?.keywords ?? [];
const parts = extractKnownParts(tree, article.frontmatter?.parts);
const projectParts = config?.parts ?? {};
+ // Translator credit (#143): the site option, overridden per page under
+ // `site:` in the page's frontmatter (replace, never merge; an explicit empty
+ // value suppresses). See app/i18n.ts for the rules and the string form.
+ const siteOptions = ((config as any)?.options ?? {}) as Record;
+ const pageOptions = ((article.frontmatter as any)?.site ?? undefined) as
+ | Record
+ | undefined;
+ const { people: translators } = resolvePeople(pageOptions, siteOptions, 'translators');
+ const translatorsLabel = resolveLabel(
+ pageOptions,
+ siteOptions,
+ 'translators_label',
+ DEFAULT_TRANSLATORS_LABEL,
+ );
return (
@@ -54,6 +69,8 @@ export const PageContent = React.memo(function ({ article }: { article: PageLoad
pageTitle={manifest?.index !== article.slug ? article.frontmatter.title : undefined}
authors={article.frontmatter.authors}
affiliations={article.frontmatter.affiliations}
+ translators={translators}
+ translatorsLabel={translatorsLabel}
/>
(null);
@@ -108,7 +112,7 @@ export function PageHeaderHistory() {
if (changelog.length === 0) {
return (
Last changed: {formatDate(lastModified)}
@@ -133,7 +137,7 @@ export function PageHeaderHistory() {
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
- className={`group ml-auto flex items-center gap-1 ${COPY} cursor-pointer
+ className={`group ${align} flex items-center gap-1 ${COPY} cursor-pointer
text-qetext-light/70 dark:text-qetext-dark-muted
hover:text-qeborder-blue dark:hover:text-qeborder-blue`}
>
diff --git a/app/components/ProjectFrontmatter.tsx b/app/components/ProjectFrontmatter.tsx
index 154607a40..9db6133c2 100644
--- a/app/components/ProjectFrontmatter.tsx
+++ b/app/components/ProjectFrontmatter.tsx
@@ -4,6 +4,34 @@ import type { Affiliation, Contributor } from 'myst-frontmatter';
import React from 'react';
import { useBaseurl, useLinkProvider } from '@myst-theme/providers';
import { PageHeaderHistory } from './PageHeaderHistory';
+import type { Person } from '~/i18n';
+
+/**
+ * Names joined the way the authors line joins them: "A", "A and B",
+ * "A, B and C". Linked when the person has a URL; no `rel="author"`, which
+ * is reserved for the authors so crawlers can tell the two apart.
+ */
+function PeopleList({ people, linkClassName }: { people: Person[]; linkClassName?: string }) {
+ return (
+ <>
+ {people.map((person, i) => {
+ const separator = i === 0 ? '' : i === people.length - 1 ? ' and ' : ', ';
+ return (
+
+ {separator}
+ {person.url ? (
+
+ {person.name}
+
+ ) : (
+ person.name
+ )}
+
+ );
+ })}
+ >
+ );
+}
export function ProjectFrontmatter({
className,
@@ -11,13 +39,20 @@ export function ProjectFrontmatter({
pageTitle,
authors,
affiliations,
+ translators,
+ translatorsLabel,
}: {
className?: string;
projectTitle: string;
pageTitle?: string;
authors?: Contributor[];
affiliations?: Affiliation[];
+ /** Translators of this page's edition (#143); nothing renders when empty. */
+ translators?: Person[];
+ /** Label introducing the translators, in the edition's language; '' omits it. */
+ translatorsLabel?: string;
}) {
+ const hasTranslators = !!translators && translators.length > 0;
const baseurl = useBaseurl();
const Link = useLinkProvider();
return (
@@ -38,7 +73,7 @@ export function ProjectFrontmatter({
className={classNames('block font-bold lg:inline prose-a:text-inherit', {
'text-lg': pageTitle,
'text-4xl': !pageTitle,
- 'mr-4': pageTitle,
+ 'me-4': pageTitle,
})}
>
{projectTitle}
@@ -84,7 +119,26 @@ export function ProjectFrontmatter({
}, '')}
)}
-
+ {/* Translators (#143). Same row as the "Last changed" control, at the
+ end, as the book theme places them since v0.22.0 -- a fourth
+ stacked header line read as clutter there. This block takes the
+ `ms-auto` and the history control sits beside it; on narrow
+ viewports the row wraps. Distinct class from the authors line and
+ no `rel="author"` on the links, so the two are never confused. A
+
, not a
: `.article p` sets a 1em margin that a utility
+ cannot beat. */}
+ {hasTranslators && (
+
);
diff --git a/app/components/toolbar/DownloadButton.tsx b/app/components/toolbar/DownloadButton.tsx
index 963de2b97..3a7e9003d 100644
--- a/app/components/toolbar/DownloadButton.tsx
+++ b/app/components/toolbar/DownloadButton.tsx
@@ -48,7 +48,7 @@ export function DownloadsButton({ size, showLabel }: { size: number; showLabel?:
className="flex items-center cursor-pointer"
>
- {showLabel && Downloads}
+ {showLabel && Downloads}
diff --git a/app/components/toolbar/GitHubButton.tsx b/app/components/toolbar/GitHubButton.tsx
index 7e881fe5a..24a4b2d96 100644
--- a/app/components/toolbar/GitHubButton.tsx
+++ b/app/components/toolbar/GitHubButton.tsx
@@ -30,7 +30,7 @@ export function GitHubButton({
className={classNames('opacity-90 hover:scale-110', sizeClasses)}
/>
)}
- {showLabel && Edit}
+ {showLabel && Edit}
>
);
diff --git a/app/components/toolbar/LanguageSwitcher.tsx b/app/components/toolbar/LanguageSwitcher.tsx
new file mode 100644
index 000000000..adb12d38a
--- /dev/null
+++ b/app/components/toolbar/LanguageSwitcher.tsx
@@ -0,0 +1,87 @@
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { useBaseurl, useSiteManifest } from '@myst-theme/providers';
+import { useLocation } from '@remix-run/react';
+import classNames from 'classnames';
+import { Check, Globe } from 'lucide-react';
+import type { SiteManifest } from 'myst-config';
+import type { TemplateOptions } from '~/types';
+import { DEFAULT_SWITCHER_LABEL, languageHref, normaliseLanguages, resolveLabel } from '~/i18n';
+import { Tooltip } from './Tooltip';
+
+/**
+ * Globe-icon dropdown that switches between the translated editions of a
+ * site (Phase 4, #90), the MyST port of the book theme's language switcher.
+ * Each entry links to the *same page* in the other edition -- that edition's
+ * site root plus this page's path -- and the entry whose code matches
+ * `current_language` is marked as the current one.
+ *
+ * Renders nothing unless `languages` lists two or more editions; a single
+ * edition has nothing to switch to.
+ *
+ * Radix's dropdown supplies the menu semantics and keyboard handling
+ * (arrow keys, Escape, focus return), the same as MobileActionsMenu. The
+ * items are real anchors (`asChild`), so Enter and a plain click both
+ * navigate, and they carry `hreflang` and `lang` for assistive tech.
+ */
+export function LanguageSwitcher({ size }: { size: number }) {
+ const options: TemplateOptions =
+ (useSiteManifest() as SiteManifest & TemplateOptions)?.options ?? {};
+ const location = useLocation();
+ const baseurl = useBaseurl();
+ const languages = normaliseLanguages(options.languages);
+ if (languages.length < 2) return null;
+
+ const label =
+ resolveLabel(undefined, options as Record, 'language_switcher_label', DEFAULT_SWITCHER_LABEL) ||
+ DEFAULT_SWITCHER_LABEL;
+ const current = options.current_language?.trim();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {languages.map((language) => {
+ const active = !!current && language.code === current;
+ return (
+
+
+ {language.name}
+ {active && }
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/components/toolbar/LaunchButton.tsx b/app/components/toolbar/LaunchButton.tsx
index cf3de088f..b0d216ed8 100644
--- a/app/components/toolbar/LaunchButton.tsx
+++ b/app/components/toolbar/LaunchButton.tsx
@@ -51,7 +51,7 @@ export function LaunchButton({ size, showLabel }: { size: number; showLabel?: bo
className="flex items-center cursor-pointer"
>
- {showLabel && Launch}
+ {showLabel && Launch}
);
diff --git a/app/components/toolbar/Toolbar.tsx b/app/components/toolbar/Toolbar.tsx
index 529863f38..d486307eb 100644
--- a/app/components/toolbar/Toolbar.tsx
+++ b/app/components/toolbar/Toolbar.tsx
@@ -12,6 +12,7 @@ import { FullScreenButton } from './FullscreenButton';
import { FontScaleListItems } from './FontScaleListItems';
import { Tooltip } from './Tooltip';
import { MobileActionsMenu } from './MobileActionsMenu';
+import { LanguageSwitcher } from './LanguageSwitcher';
import { useBaseurl, useLinkProvider } from '@myst-theme/providers';
export function Toolbar() {
@@ -31,8 +32,14 @@ export function Toolbar() {
control set switches on at `md`, and at 20px spacing it does not fit
between 768px and ~856px — the last icons get pushed off the right
edge. Keep the tighter spacing for that band when adding controls here.
+
+ `gap-x-*`, not `space-x-*`: the latter is a physical `margin-left` on
+ every item after the first, which a right-to-left edition (#91) does
+ not mirror, so the first two icons touch and the margin lands on the
+ outer edge. `gap` is direction-neutral. An empty
still takes a
+ gap, hence the `empty:hidden` on the two slots that may render nothing.
*/}
-
+
@@ -62,7 +69,7 @@ export function Toolbar() {
{/* Separator between the view controls and the actions cluster; scaled
down in the narrow desktop band for the same reason as the gap. */}
-
+
@@ -77,6 +84,16 @@ export function Toolbar() {
+ {/* Language switcher (#90): far end of the toolbar, as in the book
+ theme, and at every width -- it is the one action a reader of a
+ translated edition reaches for, so it stays out of the overflow
+ menu. Renders nothing unless two or more editions are configured,
+ and `empty:hidden` keeps the wrapper from taking a gap when it does
+ not -- every single-edition site would otherwise carry dead space
+ here. */}
+
+
+
diff --git a/app/i18n.ts b/app/i18n.ts
new file mode 100644
index 000000000..a1426fe15
--- /dev/null
+++ b/app/i18n.ts
@@ -0,0 +1,246 @@
+/**
+ * Multilingual editions: the language switcher and `hreflang` alternates
+ * (Phase 4, #90), document direction (Phase 5, #91) and translator credit
+ * (#143). Pure TypeScript with no React, so tests/unit/i18n.test.mjs runs it
+ * under `node --test` with type stripping, the way launchUrls.ts is tested.
+ *
+ * WHY THE LISTS ARE STRINGS
+ *
+ * The CLI validates `site.options` against template.yml and drops every key
+ * the template does not declare (myst-templates `validateTemplateOptions`),
+ * and the only types a template can declare are boolean, string, number,
+ * choice and file. A list of languages cannot be declared, so `languages` and
+ * `translators` are declared as strings and written as a YAML block:
+ *
+ * languages: |
+ * - code: en
+ * name: English
+ * url: https://python-programming.quantecon.org
+ *
+ * `parseStructured` turns that back into a list. A real list is accepted as
+ * it is, so nothing here changes if the engine ever passes lists through. A
+ * page's `site:` frontmatter goes through the same validation, so the same
+ * form applies there.
+ *
+ * The shapes and the resolution rules are quantecon-book-theme's
+ * (`_process_languages`, `_normalise_people`, `_resolve_people`,
+ * `_resolve_label` in its __init__.py), so a translated edition configures the
+ * two themes with the same values.
+ */
+import { parse as parseYaml } from 'yaml';
+
+export interface Language {
+ code: string;
+ name: string;
+ url: string;
+}
+
+export interface Person {
+ name: string;
+ url?: string;
+}
+
+export interface I18nOptions {
+ current_language?: string;
+ enable_rtl?: boolean;
+ languages?: unknown;
+ translators?: unknown;
+ translators_label?: string;
+ language_switcher_label?: string;
+}
+
+export const DEFAULT_TRANSLATORS_LABEL = 'Translated by';
+export const DEFAULT_SWITCHER_LABEL = 'Switch language';
+
+/**
+ * A structured option as the theme receives it: a list or object passes
+ * through, a string is parsed as YAML (JSON is valid YAML), an empty string
+ * is an explicit empty list, and unparseable text is `undefined`.
+ */
+export function parseStructured(raw: unknown): unknown {
+ if (raw === undefined || raw === null) return undefined;
+ if (typeof raw !== 'string') return raw;
+ const text = raw.trim();
+ if (!text) return [];
+ try {
+ return parseYaml(text);
+ } catch {
+ return undefined;
+ }
+}
+
+function asString(value: unknown): string {
+ if (value === undefined || value === null) return '';
+ return typeof value === 'string' ? value : String(value);
+}
+
+/**
+ * The languages to offer. Entries need all of `code`, `name` and `url`;
+ * trailing slashes come off the URL so page paths join cleanly. Fewer than two
+ * valid entries means no switcher and no alternates, as in the book theme.
+ */
+export function normaliseLanguages(raw: unknown): Language[] {
+ const value = parseStructured(raw);
+ if (!Array.isArray(value)) return [];
+ const languages: Language[] = [];
+ for (const item of value) {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
+ const { code, name, url } = item as Record;
+ if (typeof code !== 'string' || typeof name !== 'string' || typeof url !== 'string') continue;
+ if (!code.trim() || !name.trim() || !url.trim()) continue;
+ languages.push({
+ code: code.trim(),
+ name: name.trim(),
+ url: url.trim().replace(/\/+$/, ''),
+ });
+ }
+ return languages.length > 1 ? languages : [];
+}
+
+/**
+ * Coerce a people value into `{name, url?}` entries: the documented list of
+ * mappings, a list of plain names, a single mapping or a single name. Entries
+ * without a usable name are dropped.
+ */
+export function normalisePeople(raw: unknown): Person[] {
+ let value = parseStructured(raw);
+ if (value === undefined || value === null) return [];
+ if (typeof value === 'string' || (typeof value === 'object' && !Array.isArray(value))) {
+ value = [value];
+ }
+ if (!Array.isArray(value)) return [];
+ const people: Person[] = [];
+ for (const item of value) {
+ let name: unknown;
+ let url: unknown;
+ if (typeof item === 'string') {
+ name = item;
+ } else if (item && typeof item === 'object' && !Array.isArray(item)) {
+ name = (item as Record).name;
+ url = (item as Record).url;
+ } else {
+ continue;
+ }
+ const n = asString(name);
+ const u = asString(url);
+ if (!n.trim()) continue;
+ people.push(u ? { name: n, url: u } : { name: n });
+ }
+ return people;
+}
+
+/**
+ * A page-level people value, or `undefined` when the page value is not one
+ * this theme should act on. Stricter than the site option on purpose: only an
+ * explicit empty value suppresses the block, and only a list of mappings that
+ * all carry names counts as an override. Anything else is left alone so the
+ * project-level credit still renders.
+ */
+export function frontmatterPeople(value: unknown): Person[] | undefined {
+ if (value === undefined || value === null) return [];
+ if (typeof value === 'string' && !value.trim()) return [];
+ const parsed = parseStructured(value);
+ if (parsed === undefined || parsed === null) return undefined;
+ if (Array.isArray(parsed) && parsed.length === 0) return [];
+ if (!Array.isArray(parsed)) return undefined;
+ if (!parsed.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
+ return undefined;
+ }
+ const people = normalisePeople(parsed);
+ return people.length === parsed.length ? people : undefined;
+}
+
+/**
+ * The people to credit on one page. A page value replaces the site value
+ * outright (never merges); an absent key inherits. `suppressed` is true when
+ * the page said "nobody", which keeps the block off that page rather than
+ * falling back to the site-wide credit.
+ */
+export function resolvePeople(
+ pageOptions: Record | undefined,
+ siteOptions: Record | undefined,
+ key: string,
+): { people: Person[]; suppressed: boolean } {
+ if (pageOptions && key in pageOptions) {
+ const people = frontmatterPeople(pageOptions[key]);
+ if (people !== undefined) return { people, suppressed: people.length === 0 };
+ }
+ return { people: normalisePeople(siteOptions?.[key]), suppressed: false };
+}
+
+/**
+ * A label, page value first, then the site option, then the fallback. An
+ * explicit empty value is an empty label (rendered as no label at all).
+ */
+export function resolveLabel(
+ pageOptions: Record | undefined,
+ siteOptions: Record | undefined,
+ key: string,
+ fallback: string,
+): string {
+ const source =
+ pageOptions && key in pageOptions ? pageOptions : siteOptions && key in siteOptions ? siteOptions : undefined;
+ if (!source) return fallback;
+ return asString(source[key]);
+}
+
+/** The document language: the edition's code, or English when unset. */
+export function htmlLang(options?: I18nOptions): string {
+ const code = options?.current_language?.trim();
+ return code || 'en';
+}
+
+/** The document direction: `rtl` when the edition opts in, else unset. */
+export function htmlDir(options?: I18nOptions): 'rtl' | undefined {
+ return options?.enable_rtl ? 'rtl' : undefined;
+}
+
+/**
+ * The site-relative path of the current page. Static builds prefix every
+ * route with the site's base URL, which is not part of the page's identity
+ * across editions.
+ */
+export function stripBaseurl(pathname: string, baseurl?: string): string {
+ let path = pathname || '/';
+ const base = baseurl?.replace(/\/+$/, '');
+ if (base && (path === base || path.startsWith(`${base}/`))) {
+ path = path.slice(base.length) || '/';
+ }
+ return path.startsWith('/') ? path : `/${path}`;
+}
+
+/** The same page in another edition: that edition's site root plus the page path. */
+export function languageHref(language: Language, pathname: string, baseurl?: string): string {
+ return `${language.url}${stripBaseurl(pathname, baseurl)}`;
+}
+
+export interface AlternateLink {
+ tagName: 'link';
+ rel: 'alternate';
+ hrefLang: string;
+ href: string;
+ // Remix's meta descriptor type is an open record; the index signature lets
+ // these spread into a route's `meta` return without a cast.
+ [key: string]: unknown;
+}
+
+/**
+ * `` descriptors for the page, one per
+ * edition plus `x-default` on the first, in the shape Remix's v2 `meta`
+ * renders. Empty when fewer than two editions are configured.
+ */
+export function hreflangLinks(
+ options: I18nOptions | undefined,
+ pathname: string,
+ baseurl?: string,
+): AlternateLink[] {
+ const languages = normaliseLanguages(options?.languages);
+ if (languages.length === 0) return [];
+ const link = (hrefLang: string, language: Language): AlternateLink => ({
+ tagName: 'link',
+ rel: 'alternate',
+ hrefLang,
+ href: languageHref(language, pathname, baseurl),
+ });
+ return [...languages.map((l) => link(l.code, l)), link('x-default', languages[0])];
+}
diff --git a/app/root.tsx b/app/root.tsx
index e86135e03..efb2606ab 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -5,7 +5,6 @@ import { PTSerifCSS, SourceSans3CSS } from '~/links';
import { getConfig } from '~/backend/loaders.server';
import type { SiteLoader } from '@myst-theme/common';
import {
- Document,
responseNoSite,
getMetaTagsForSite,
getThemeSession,
@@ -22,6 +21,9 @@ 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 { Document } from './components/Document';
+import { htmlDir, htmlLang } from './i18n';
+import type { TemplateOptions } from './types';
export { AppErrorBoundary as ErrorBoundary } from '@myst-theme/site';
const RENDERERS: NodeRenderers = mergeRenderers([
@@ -217,6 +219,9 @@ export default function AppWithReload() {
const { theme, config, CONTENT_CDN_PORT, MODE, BASE_URL } = useLoaderData();
const searchFactory = useCallback((index: MystSearchIndex) => createSearch(index), []);
+ // Edition language and direction, from the declared site options (see
+ // app/i18n.ts). Set on in the server render by the local Document.
+ const options: TemplateOptions = (config as any)?.options ?? {};
return (
@@ -234,6 +239,8 @@ export default function AppWithReload() {
// inside ` would emit an invalid
// selector and silently drop the body-column rule. Keep this as-is.
head={}
+ lang={htmlLang(options)}
+ dir={htmlDir(options)}
>
['projects'][0];
@@ -24,18 +25,30 @@ export const meta: V2_MetaFunction = ({ data, matches, location }
const siteTitle = config?.title ?? project?.title ?? '';
- return getMetaTagsForArticle({
- origin: '',
- url: location.pathname,
- title: page?.title ? `${page.title}${siteTitle ? ` - ${siteTitle}` : ''}` : siteTitle,
- description: page?.description ?? project?.description ?? config?.description ?? undefined,
- image:
- (page?.thumbnailOptimized || page?.thumbnail) ??
- (project?.thumbnailOptimized || project?.thumbnail) ??
- undefined,
- twitter: config?.options?.twitter,
- keywords: page?.keywords ?? project?.keywords ?? config?.keywords ?? [],
- });
+ // The root loader carries the static build's base URL, which is not part of
+ // the page path other editions share.
+ // `matches` is typed `never` under this generic; the root match's data is the root loader's.
+ const rootMatch = (matches as Array<{ id: string; data?: { BASE_URL?: string } }>).find(
+ (m) => m.id === 'root',
+ );
+ const baseurl = rootMatch?.data?.BASE_URL;
+
+ return [
+ ...getMetaTagsForArticle({
+ origin: '',
+ url: location.pathname,
+ title: page?.title ? `${page.title}${siteTitle ? ` - ${siteTitle}` : ''}` : siteTitle,
+ description: page?.description ?? project?.description ?? config?.description ?? undefined,
+ image:
+ (page?.thumbnailOptimized || page?.thumbnail) ??
+ (project?.thumbnailOptimized || project?.thumbnail) ??
+ undefined,
+ twitter: config?.options?.twitter,
+ keywords: page?.keywords ?? project?.keywords ?? config?.keywords ?? [],
+ }),
+ // hreflang alternates for the translated editions (Phase 4, #90).
+ ...hreflangLinks(config?.options, location.pathname, baseurl),
+ ];
};
export const links: LinksFunction = () => [KatexCSS];
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx
index 32bf0e77f..4fe3a4e20 100644
--- a/app/routes/_index.tsx
+++ b/app/routes/_index.tsx
@@ -8,24 +8,34 @@ import type { SiteManifest } from 'myst-config';
import { getProject } from '@myst-theme/common';
import { Page } from '~/components/Page';
+import { hreflangLinks } from '~/i18n';
type ManifestProject = Required['projects'][0];
-export const meta: V2_MetaFunction = ({ data, location }) => {
+export const meta: V2_MetaFunction = ({ data, matches, location }) => {
if (!data) return [];
const config: SiteManifest = data.config;
const project: ManifestProject = data.project;
-
- return getMetaTagsForArticle({
- origin: '',
- url: location.pathname,
- title: config?.title ?? project.title,
- description: config.description ?? project.description ?? undefined,
- image: (project.thumbnailOptimized || project.thumbnail) ?? undefined,
- keywords: config.keywords ?? project.keywords ?? [],
- twitter: config?.options?.twitter,
- });
+ // `matches` is typed `never` under this generic; the root match's data is the root loader's.
+ const rootMatch = (matches as Array<{ id: string; data?: { BASE_URL?: string } }>).find(
+ (m) => m.id === 'root',
+ );
+ const baseurl = rootMatch?.data?.BASE_URL;
+
+ return [
+ ...getMetaTagsForArticle({
+ origin: '',
+ url: location.pathname,
+ title: config?.title ?? project.title,
+ description: config.description ?? project.description ?? undefined,
+ image: (project.thumbnailOptimized || project.thumbnail) ?? undefined,
+ keywords: config.keywords ?? project.keywords ?? [],
+ twitter: config?.options?.twitter,
+ }),
+ // hreflang alternates for the translated editions (Phase 4, #90).
+ ...hreflangLinks(config?.options, location.pathname, baseurl),
+ ];
};
export const links: LinksFunction = () => [KatexCSS];
diff --git a/app/types.ts b/app/types.ts
index aa05a78e0..1de51f27e 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -26,4 +26,23 @@ export interface TemplateOptions {
launch_branch?: string; // notebook repo branch (default "main")
launch_notebooks_path?: string; // subdir within the notebook repo where notebooks live (book-theme nb_path_to_notebooks)
launch_source_path?: string; // prefix stripped from the page path (book-theme path_to_docs)
+
+ // Multilingual editions (Phases 4-5, #90 / #91, and translator credit,
+ // #143). Declared in template.yml -- the CLI drops undeclared site options.
+ // The two lists arrive as strings holding a YAML block, because template
+ // options are scalar-only; app/i18n.ts parses either form. Only the two
+ // translator keys are read per page (under `site:` in page frontmatter, by
+ // PageContent); the other four are site-wide -- `current_language` and
+ // `enable_rtl` come from the root loader's config, `languages` and the
+ // switcher label from the site manifest -- and a page value is ignored.
+ // Caution: a page that sets any declared key here has its whole `site:`
+ // block replaced by the validated keys, so an undeclared `git_metadata` on
+ // the same page is lost -- keep the two on different pages, as the visual
+ // fixture does.
+ current_language?: string; // BCP 47 code of this edition; document `lang`, active switcher entry
+ enable_rtl?: boolean; // dir="rtl" on the document
+ languages?: string | unknown[]; // YAML block of `{code, name, url}`; needs 2+ entries to render
+ translators?: string | unknown[]; // YAML block of `{name, url?}`; '' suppresses on a page
+ translators_label?: string; // default "Translated by"; '' hides the label
+ language_switcher_label?: string; // default "Switch language"
}
diff --git a/package-lock.json b/package-lock.json
index ae499d6a1..84e80d34a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,7 +35,8 @@
"myst-to-react": "^1.3.0",
"node-fetch": "^2.7.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "yaml": "^2.9.0"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
@@ -50,7 +51,7 @@
"@vercel/node": "^2.15.1",
"concurrently": "^9.1.2",
"patch-package": "^8.0.0",
- "prettier": "*",
+ "prettier": "latest",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.0"
},
@@ -7928,21 +7929,6 @@
}
}
},
- "node_modules/@radix-ui/react-direction": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
- "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
@@ -8188,6 +8174,21 @@
}
}
},
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@@ -8359,6 +8360,21 @@
}
}
},
+ "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -8402,6 +8418,21 @@
}
}
},
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
@@ -32003,10 +32034,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "dev": true,
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/package.json b/package.json
index d483e97af..2b59e86ca 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,8 @@
"myst-to-react": "^1.3.0",
"node-fetch": "^2.7.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "yaml": "^2.9.0"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index c40c0a4ed..345bf19c5 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -28,6 +28,14 @@ if (NO_THEBE_PORT === PORT) {
// Write the resolved value back so test workers read one source of truth
// (tests/visual/theme.spec.ts builds the no-thebe URL from this).
process.env.NO_THEBE_PORT = NO_THEBE_PORT;
+// Third fixture: a right-to-left Persian edition (`enable_rtl`, `fa` current
+// in the language switcher, translator credit) for the multilingual tests.
+const RTL_PORT = process.env.RTL_PORT || "3113";
+const rtlURL = `http://localhost:${RTL_PORT}`;
+if (RTL_PORT === PORT || RTL_PORT === NO_THEBE_PORT) {
+ throw new Error(`RTL_PORT (${RTL_PORT}) must differ from PORT and NO_THEBE_PORT`);
+}
+process.env.RTL_PORT = RTL_PORT;
export default defineConfig({
testDir: "./tests/visual",
@@ -82,5 +90,13 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 300 * 1000,
},
+ {
+ // Right-to-left fixture (tests/visual/fixture-rtl) for the `rtl`
+ // snapshot and the multilingual assertions in theme.spec.ts.
+ command: `FIXTURE_DIR=fixture-rtl PORT=${RTL_PORT} bash tests/visual/serve.sh`,
+ url: rtlURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 300 * 1000,
+ },
],
});
diff --git a/styles/app.css b/styles/app.css
index d4368e8c9..6cfcfe499 100644
--- a/styles/app.css
+++ b/styles/app.css
@@ -7,6 +7,9 @@
/* QuantEcon content styles. Last, so it has the final say among the
imports -- see the file's header for the specificity rules. */
@import './quantecon.css';
+/* Right-to-left editions: mirrors the physical utilities upstream markup
+ uses and keeps code and maths LTR. After quantecon.css, and unlayered. */
+@import './rtl.css';
@layer base {
.grid-gap {
diff --git a/styles/rtl.css b/styles/rtl.css
new file mode 100644
index 000000000..1d292b42d
--- /dev/null
+++ b/styles/rtl.css
@@ -0,0 +1,110 @@
+/* Right-to-left editions (Phase 5, #91).
+ *
+ * `enable_rtl` puts `dir="rtl"` on (app/components/Document.tsx), and
+ * almost everything follows from that on its own: flex rows and the grid
+ * mirror, the outline column moves to the left, `text-align: start` flips,
+ * the typography plugin indents lists with `padding-inline-start`, and the
+ * theme's own components use logical utilities (`ms-`, `me-`, `pe-`,
+ * `inset-inline-start`). The rules here cover what does not follow:
+ *
+ * 1. The contents drawer slides in from its edge with a *physical*
+ * `translate` (a transform has no logical form); mirrored here.
+ * 2. Upstream content markup (myst-to-react, @myst-theme/jupyter) uses
+ * physical utilities -- `border-l-4` on executable cells and
+ * admonitions, `pl-*` / `ml-*` / `mr-*` spacing, `right-1` on the copy
+ * button. Each one used is mirrored below with Tailwind's own values.
+ * 3. Code and mathematics stay left-to-right, as in the book theme: code
+ * is LTR by nature, and KaTeX lays out LTR.
+ * 4. The theme's own parenthesised list markers (styles/lists.css) hang in
+ * the gutter with a physical `left` and translate; hung from the right.
+ *
+ * Unlayered on purpose: every rule targets a class that Tailwind only sees
+ * in node_modules or generates at build time, and `@layer` would purge them;
+ * being unlayered also lets them beat the layered rules they override.
+ * `[dir='rtl']` is (0,1,0), so `[dir='rtl'] .x` at (0,2,0) beats the (0,1,0)
+ * utility it mirrors regardless of order.
+ */
+
+/* 1. Contents drawer: slide from the inline-start edge, now on the right. */
+[dir='rtl'] .qe-toc:not(:popover-open) {
+ translate: 100% 0;
+}
+
+@starting-style {
+ [dir='rtl'] .qe-toc:popover-open {
+ translate: 100% 0;
+ }
+}
+
+/* 2. Physical utilities in upstream content markup, mirrored. */
+[dir='rtl'] .border-l-2 {
+ border-left-width: 0;
+ border-right-width: 2px;
+}
+
+[dir='rtl'] .border-l-4 {
+ border-left-width: 0;
+ border-right-width: 4px;
+}
+
+/* `dir` and `.dark` both sit on , so the dark variant is a compound
+ selector on the same element, not a descendant. (Executable cells carry
+ the light class too, so the first selector already colours them in dark
+ mode; the second keeps the pair explicit.) */
+[dir='rtl'] .border-l-blue-400,
+[dir='rtl'].dark .dark\:border-l-blue-400 {
+ border-right-color: #60a5fa;
+}
+
+[dir='rtl'] .border-l-blue-500 {
+ border-right-color: #3b82f6;
+}
+
+[dir='rtl'] .pl-2 { padding-left: 0; padding-right: 0.5rem; }
+[dir='rtl'] .pl-3 { padding-left: 0; padding-right: 0.75rem; }
+[dir='rtl'] .pl-4 { padding-left: 0; padding-right: 1rem; }
+[dir='rtl'] .pl-8 { padding-left: 0; padding-right: 2rem; }
+[dir='rtl'] .pr-2 { padding-right: 0; padding-left: 0.5rem; }
+[dir='rtl'] .ml-1 { margin-left: 0; margin-right: 0.25rem; }
+[dir='rtl'] .ml-2 { margin-left: 0; margin-right: 0.5rem; }
+[dir='rtl'] .ml-3 { margin-left: 0; margin-right: 0.75rem; }
+[dir='rtl'] .ml-4 { margin-left: 0; margin-right: 1rem; }
+[dir='rtl'] .mr-1 { margin-right: 0; margin-left: 0.25rem; }
+[dir='rtl'] .mr-2 { margin-right: 0; margin-left: 0.5rem; }
+[dir='rtl'] .mr-3 { margin-right: 0; margin-left: 0.75rem; }
+[dir='rtl'] .text-left { text-align: right; }
+[dir='rtl'] .text-right { text-align: left; }
+[dir='rtl'] .left-1 { left: auto; right: 0.25rem; }
+[dir='rtl'] .left-2 { left: auto; right: 0.5rem; }
+[dir='rtl'] .right-1 { right: auto; left: 0.25rem; }
+[dir='rtl'] .right-2 { right: auto; left: 0.5rem; }
+
+/* 3. Code and mathematics keep reading left-to-right. `unicode-bidi: isolate`
+ makes `direction` take effect on inline maths inside RTL prose. The copy
+ button stays at the code block's right edge, where LTR code expects it:
+ (0,3,0) beats the (0,2,0) mirror of `.right-1` above. */
+[dir='rtl'] pre,
+[dir='rtl'] .myst-code,
+[dir='rtl'] .katex-display {
+ direction: ltr;
+ text-align: left;
+}
+
+[dir='rtl'] .katex {
+ direction: ltr;
+ unicode-bidi: isolate;
+}
+
+[dir='rtl'] .myst-code .right-1 {
+ right: 0.25rem;
+ left: auto;
+}
+
+/* 4. Parenthesised markers -- (a), (i), A) -- sit outside the list's start
+ edge, which is now the right one. Same offset as lists.css, mirrored. */
+[dir='rtl'] ol.delimiter-parens > li::before,
+[dir='rtl'] ol.delimiter-paren > li::before {
+ left: auto;
+ right: -0.4em;
+ transform: translateX(100%);
+}
diff --git a/template.yml b/template.yml
index 1f0736b4b..ec70ed932 100644
--- a/template.yml
+++ b/template.yml
@@ -20,6 +20,50 @@ tags:
parts:
- id: footer
description: The site wide footer
+# Site options (`site.options` in myst.yml). The CLI validates against this
+# list and DROPS any key not declared here, and it can only declare scalar
+# types -- so the two lists (`languages`, `translators`) are strings holding a
+# YAML block; app/i18n.ts parses them. Only `translators` and
+# `translators_label` are also read per page (under `site:` in page
+# frontmatter); the rest are site-wide. See README "Multilingual editions".
+#
+# No `default:` on any option, deliberately. The CLI validates every page's
+# `site:` frontmatter against this list too, and a declared default is written
+# into that validated block even when the page set nothing -- which then
+# replaces the page's raw `site:` block, overriding the site-wide value and
+# discarding undeclared keys such as `git_metadata`. Defaults live in
+# app/i18n.ts instead.
+options:
+ - id: current_language
+ type: string
+ description: >
+ BCP 47 code of this edition's language (e.g. `en`, `fa`, `zh-cn`). Sets the
+ document `lang` and marks the active entry in the language switcher.
+ - id: enable_rtl
+ type: boolean
+ description: Render the site right-to-left (`dir="rtl"`) for Arabic, Persian, Hebrew, Urdu editions.
+ - id: languages
+ type: string
+ description: >
+ YAML block listing every edition of this site as `- code`, `name`, `url`
+ entries. With two or more entries the toolbar shows a language switcher and
+ every page carries `hreflang` alternates; the first entry is `x-default`.
+ - id: translators
+ type: string
+ description: >
+ YAML block listing the edition's translators as `- name` (and optional
+ `url`) entries, credited in the page header. A page overrides it under
+ `site:` in its frontmatter; an empty value there suppresses the credit.
+ - id: translators_label
+ type: string
+ description: >
+ Label introducing the translators, in the edition's own language
+ ("Translated by" when unset; an empty string hides the label).
+ - id: language_switcher_label
+ type: string
+ description: >
+ Accessible name and tooltip of the language switcher, in the edition's own
+ language ("Switch language" when unset).
build:
install: npm install
start: npm run start
diff --git a/tests/unit/i18n.test.mjs b/tests/unit/i18n.test.mjs
new file mode 100644
index 000000000..25a1c3aa7
--- /dev/null
+++ b/tests/unit/i18n.test.mjs
@@ -0,0 +1,146 @@
+/**
+ * Unit tests for the multilingual helpers (app/i18n.ts): option parsing for
+ * the YAML-block string form, the book-theme resolution rules for people and
+ * labels, and the cross-edition URLs behind the language switcher and the
+ * hreflang alternates. Plain TypeScript with no React, run under `node --test`
+ * with type stripping like launch-urls.test.mjs (Node >= 23.6).
+ *
+ * Run with: npm run test:unit
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+ frontmatterPeople,
+ hreflangLinks,
+ htmlDir,
+ htmlLang,
+ languageHref,
+ normaliseLanguages,
+ normalisePeople,
+ parseStructured,
+ resolveLabel,
+ resolvePeople,
+ stripBaseurl,
+} from '../../app/i18n.ts';
+
+const LANGS_YAML = `
+- code: en
+ name: English
+ url: https://python-programming.quantecon.org/
+- code: fa
+ name: فارسی
+ url: https://quantecon.github.io/lecture-python-programming.fa
+`;
+
+test('parseStructured: strings are YAML, lists pass through, empty is []', () => {
+ assert.deepEqual(parseStructured('- a\n- b'), ['a', 'b']);
+ assert.deepEqual(parseStructured('[{"name": "x"}]'), [{ name: 'x' }]);
+ assert.deepEqual(parseStructured([{ name: 'x' }]), [{ name: 'x' }]);
+ assert.deepEqual(parseStructured(' '), []);
+ assert.equal(parseStructured(undefined), undefined);
+ assert.equal(parseStructured('- a\n b: [unclosed'), undefined);
+});
+
+test('normaliseLanguages: needs code, name and url, two or more entries, no trailing slash', () => {
+ const langs = normaliseLanguages(LANGS_YAML);
+ assert.equal(langs.length, 2);
+ assert.equal(langs[0].url, 'https://python-programming.quantecon.org');
+ assert.equal(langs[1].name, 'فارسی');
+ // A real list works the same way.
+ assert.equal(normaliseLanguages(parseStructured(LANGS_YAML)).length, 2);
+ // One valid entry is not a switcher.
+ assert.deepEqual(normaliseLanguages('- code: en\n name: English\n url: https://x'), []);
+ // Entries missing a field are dropped, which can take the count below two.
+ assert.deepEqual(
+ normaliseLanguages('- code: en\n name: English\n url: https://x\n- code: fa\n name: فارسی'),
+ [],
+ );
+ assert.deepEqual(normaliseLanguages(undefined), []);
+ assert.deepEqual(normaliseLanguages('not a list'), []);
+});
+
+test('normalisePeople: mappings, plain names, a single entry; nameless dropped', () => {
+ assert.deepEqual(normalisePeople('- name: A\n url: https://a\n- name: B'), [
+ { name: 'A', url: 'https://a' },
+ { name: 'B' },
+ ]);
+ assert.deepEqual(normalisePeople(['A', { name: 'B' }]), [{ name: 'A' }, { name: 'B' }]);
+ assert.deepEqual(normalisePeople('Solo'), [{ name: 'Solo' }]);
+ assert.deepEqual(normalisePeople({ name: 'One', url: 'https://one' }), [{ name: 'One', url: 'https://one' }]);
+ assert.deepEqual(normalisePeople([{ url: 'https://nobody' }, { name: ' ' }]), []);
+ assert.deepEqual(normalisePeople(''), []);
+ assert.deepEqual(normalisePeople(undefined), []);
+});
+
+test('frontmatterPeople: strict list-of-mappings override, explicit empty suppresses', () => {
+ assert.deepEqual(frontmatterPeople(''), []);
+ assert.deepEqual(frontmatterPeople(null), []);
+ assert.deepEqual(frontmatterPeople([]), []);
+ assert.deepEqual(frontmatterPeople('- name: P'), [{ name: 'P' }]);
+ // Not a list of mappings: left alone (inherit).
+ assert.equal(frontmatterPeople('Jane Doe, John Roe'), undefined);
+ assert.equal(frontmatterPeople(['A', 'B']), undefined);
+ // A mapping without a usable name is left alone too.
+ assert.equal(frontmatterPeople([{ given: 'A', family: 'B' }]), undefined);
+});
+
+test('resolvePeople: page replaces, absent inherits, empty suppresses, unreadable inherits', () => {
+ const site = { translators: '- name: Site' };
+ assert.deepEqual(resolvePeople(undefined, site, 'translators'), {
+ people: [{ name: 'Site' }],
+ suppressed: false,
+ });
+ assert.deepEqual(resolvePeople({ translators: '- name: Page' }, site, 'translators'), {
+ people: [{ name: 'Page' }],
+ suppressed: false,
+ });
+ assert.deepEqual(resolvePeople({ translators: '' }, site, 'translators'), {
+ people: [],
+ suppressed: true,
+ });
+ assert.deepEqual(resolvePeople({ translators: 'Not, readable' }, site, 'translators'), {
+ people: [{ name: 'Site' }],
+ suppressed: false,
+ });
+});
+
+test('resolveLabel: page first, then site, then fallback; empty is empty', () => {
+ assert.equal(resolveLabel(undefined, undefined, 'translators_label', 'Translated by'), 'Translated by');
+ assert.equal(resolveLabel(undefined, { translators_label: '译者' }, 'translators_label', 'x'), '译者');
+ assert.equal(resolveLabel({ translators_label: 'مترجم' }, { translators_label: '译者' }, 'translators_label', 'x'), 'مترجم');
+ assert.equal(resolveLabel({ translators_label: '' }, { translators_label: '译者' }, 'translators_label', 'x'), '');
+ assert.equal(resolveLabel({ translators_label: null }, undefined, 'translators_label', 'x'), '');
+});
+
+test('htmlLang / htmlDir', () => {
+ assert.equal(htmlLang(undefined), 'en');
+ assert.equal(htmlLang({ current_language: ' fa ' }), 'fa');
+ assert.equal(htmlDir({ enable_rtl: true }), 'rtl');
+ assert.equal(htmlDir({}), undefined);
+});
+
+test('stripBaseurl / languageHref: static-build prefix comes off, root joins as a slash', () => {
+ assert.equal(stripBaseurl('/lecture-x/intro', '/lecture-x'), '/intro');
+ assert.equal(stripBaseurl('/lecture-x', '/lecture-x/'), '/');
+ assert.equal(stripBaseurl('/intro', undefined), '/intro');
+ assert.equal(stripBaseurl('', undefined), '/');
+ const fa = { code: 'fa', name: 'فارسی', url: 'https://example.org/fa' };
+ assert.equal(languageHref(fa, '/intro'), 'https://example.org/fa/intro');
+ assert.equal(languageHref(fa, '/'), 'https://example.org/fa/');
+ assert.equal(languageHref(fa, '/lecture-x/intro', '/lecture-x'), 'https://example.org/fa/intro');
+});
+
+test('hreflangLinks: one per edition plus x-default on the first; none below two', () => {
+ const links = hreflangLinks({ languages: LANGS_YAML }, '/intro');
+ assert.deepEqual(
+ links.map((l) => [l.hrefLang, l.href]),
+ [
+ ['en', 'https://python-programming.quantecon.org/intro'],
+ ['fa', 'https://quantecon.github.io/lecture-python-programming.fa/intro'],
+ ['x-default', 'https://python-programming.quantecon.org/intro'],
+ ],
+ );
+ assert.ok(links.every((l) => l.tagName === 'link' && l.rel === 'alternate'));
+ assert.deepEqual(hreflangLinks({}, '/intro'), []);
+});
diff --git a/tests/visual/README.md b/tests/visual/README.md
index 4757a0eca..6f756beb8 100644
--- a/tests/visual/README.md
+++ b/tests/visual/README.md
@@ -89,7 +89,13 @@ THEME_TEMPLATE="$PWD/.deploy/quantecon-theme" \
## Files
-- `fixture/` — minimal MyST project (`intro.md`, `features.md`, `notebook.ipynb`)
+- `fixture/` — minimal MyST project (`intro.md`, `features.md`, `notebook.ipynb`);
+ configures two editions and a translator so the language switcher, hreflang
+ alternates and translator credit render (#90, #143)
+- `fixture-no-thebe/` — the same without `project.thebe`, for the absent live-compute
+ toggle (second port)
+- `fixture-rtl/` — a Persian edition with `enable_rtl` (third port), for the `rtl`
+ snapshot and the right-to-left assertions (#91)
- `fixture/myst.yml.in` — template; `serve.sh` writes `myst.yml` from it
- `serve.sh` — `myst start` with the chosen `THEME_TEMPLATE`
- `theme.spec.ts` — one full-page snapshot per surface, plus a sidebar-open
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/features.png b/tests/visual/__snapshots__/desktop-chrome-darwin/features.png
index bf7a019e3..a6cc713d4 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/features.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/features.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png b/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png
index 426795730..a43359145 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/intro.png b/tests/visual/__snapshots__/desktop-chrome-darwin/intro.png
index ced017099..3ffe399a3 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/intro.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/intro.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/lists.png b/tests/visual/__snapshots__/desktop-chrome-darwin/lists.png
index 6f466d839..b25d51ca9 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/lists.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/lists.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png b/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png
index 960fe2642..1be33e7f7 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png b/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png
new file mode 100644
index 000000000..59999e59d
Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/sidebar-open.png b/tests/visual/__snapshots__/desktop-chrome-darwin/sidebar-open.png
index e2ef898c4..b125118cf 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/sidebar-open.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/sidebar-open.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/features.png b/tests/visual/__snapshots__/desktop-chrome-linux/features.png
index d46fe3a4d..bd4109149 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/features.png and b/tests/visual/__snapshots__/desktop-chrome-linux/features.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png
index 1297b7ed3..3f7fc21a7 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png and b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/intro.png b/tests/visual/__snapshots__/desktop-chrome-linux/intro.png
index a5e4cf9f5..57110a993 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/intro.png and b/tests/visual/__snapshots__/desktop-chrome-linux/intro.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/lists.png b/tests/visual/__snapshots__/desktop-chrome-linux/lists.png
index ca918eb18..69dd00421 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/lists.png and b/tests/visual/__snapshots__/desktop-chrome-linux/lists.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png
index 378f9fade..13d092900 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png and b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png
new file mode 100644
index 000000000..e5efd6e91
Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png b/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png
index 00706b840..4b74903d7 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png and b/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/features.png b/tests/visual/__snapshots__/mobile-chrome-darwin/features.png
index bfae13568..dbc7e8928 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/features.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/features.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png b/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png
index a0de5ce92..016826f52 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/intro.png b/tests/visual/__snapshots__/mobile-chrome-darwin/intro.png
index b7e1068d0..c713f0d68 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/intro.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/intro.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/lists.png b/tests/visual/__snapshots__/mobile-chrome-darwin/lists.png
index cd6ebba8d..6477bbb9e 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/lists.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/lists.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png b/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png
index 200192913..0c42f8e05 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/rtl.png b/tests/visual/__snapshots__/mobile-chrome-darwin/rtl.png
new file mode 100644
index 000000000..28a8c51e8
Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-darwin/rtl.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/sidebar-open.png b/tests/visual/__snapshots__/mobile-chrome-darwin/sidebar-open.png
index a74dcf2bb..9e4f125c8 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/sidebar-open.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/sidebar-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/features.png b/tests/visual/__snapshots__/mobile-chrome-linux/features.png
index 1e241c3e1..dd2a33e65 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/features.png and b/tests/visual/__snapshots__/mobile-chrome-linux/features.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png
index b7f9ef9d4..ced0cbe08 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png and b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/intro.png b/tests/visual/__snapshots__/mobile-chrome-linux/intro.png
index 823ec2054..868036a38 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/intro.png and b/tests/visual/__snapshots__/mobile-chrome-linux/intro.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/lists.png b/tests/visual/__snapshots__/mobile-chrome-linux/lists.png
index b042d5730..6c878cdd7 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/lists.png and b/tests/visual/__snapshots__/mobile-chrome-linux/lists.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png
index 6b301fa0c..0615cf580 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png and b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png b/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png
new file mode 100644
index 000000000..d4547d64d
Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/sidebar-open.png b/tests/visual/__snapshots__/mobile-chrome-linux/sidebar-open.png
index 93c6391ce..d3bdb7d41 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/sidebar-open.png and b/tests/visual/__snapshots__/mobile-chrome-linux/sidebar-open.png differ
diff --git a/tests/visual/fixture-rtl/intro.md b/tests/visual/fixture-rtl/intro.md
new file mode 100644
index 000000000..4f825d1e7
--- /dev/null
+++ b/tests/visual/fixture-rtl/intro.md
@@ -0,0 +1,66 @@
+---
+authors:
+ - name: Thomas J. Sargent
+ - name: John Stachurski
+---
+
+# برنامهنویسی با پایتون
+
+این صفحه ویرایش راستبهچپ قالب را میآزماید: متن *تأکیدی*، متن **پررنگ**، `کد درونخطی` و یک معادلهٔ درونخطی $e^{i\pi} + 1 = 0$ که باید چپبهراست بماند.
+
+## فهرستها
+
+- مورد نخست
+- مورد دوم با یک [پیوند](https://quantecon.org)
+- مورد سوم
+
+1. گام یک
+2. گام دو
+
+نشانگرهای پرانتزی باید در لبهٔ راست فهرست بنشینند:
+
+% fancy: lower-alpha parens
+1. گزینهٔ نخست
+2. گزینهٔ دوم، با متنی به اندازهٔ کافی بلند که سطر بشکند و ادامهٔ آن زیر متن گزینه قرار گیرد نه زیر نشانگر.
+3. گزینهٔ سوم
+
+## کد
+
+بلوک کد چپبهراست میماند و دکمهٔ کپی در لبهٔ راست آن:
+
+```python
+import numpy as np
+
+def f(x, scale=1.0):
+ # توضیح به فارسی درون کد
+ return scale * np.exp(-x ** 2)
+
+print(f(np.linspace(0, 1, 3)))
+```
+
+## ریاضیات
+
+$$
+\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
+$$
+
+## یادآوری
+
+:::{note}
+یک یادآوری با نوار رنگی در لبهٔ آغازین خط، که در این ویرایش سمت راست است.
+:::
+
+## جدول
+
+| نماد | معنا | مقدار |
+| ---- | ---- | ----: |
+| $r$ | نرخ بهره | 0.05 |
+| $\beta$ | عامل تنزیل | 0.96 |
+
+## فهرست تعریف
+
+اصطلاح
+: تعریف اصطلاح.
+
+اصطلاح دیگر
+: تعریف آن، با $x \in \mathbb{R}$.
diff --git a/tests/visual/fixture-rtl/myst.yml.in b/tests/visual/fixture-rtl/myst.yml.in
new file mode 100644
index 000000000..25f3bb6ef
--- /dev/null
+++ b/tests/visual/fixture-rtl/myst.yml.in
@@ -0,0 +1,33 @@
+# Template for the right-to-left visual-regression fixture (#91): a Persian
+# edition with `enable_rtl`, the language switcher marking `fa` as current,
+# and a translator credit with a Persian label. `serve.sh` copies this to
+# myst.yml, substituting __THEME__ (FIXTURE_DIR=fixture-rtl).
+version: 1
+project:
+ title: نظریهٔ نمونه — ویرایش فارسی
+ github: https://github.com/QuantEcon/quantecon-theme.mystmd
+ # Stamps `% fancy:` lists with style/delimiter, so intro.md can exercise the
+ # theme's parenthesised markers in RTL whichever mystmd parses it.
+ plugins:
+ - ../fixture/fancy-lists.mjs
+ toc:
+ - file: intro.md
+site:
+ title: QE Theme RTL Fixture
+ template: __THEME__
+ options:
+ logo_text: QE RTL Fixture
+ current_language: fa
+ enable_rtl: true
+ language_switcher_label: تغییر زبان
+ languages: |
+ - code: en
+ name: English
+ url: https://example.org/en
+ - code: fa
+ name: فارسی
+ url: https://example.org/fa
+ translators: |
+ - name: مترجم نمونه
+ url: https://example.org/translator
+ translators_label: ترجمهٔ
diff --git a/tests/visual/fixture/intro.md b/tests/visual/fixture/intro.md
index 560278e70..0b5888267 100644
--- a/tests/visual/fixture/intro.md
+++ b/tests/visual/fixture/intro.md
@@ -1,3 +1,10 @@
+---
+# Page-level translator override (#143): replaces the project's translator on
+# this page only. Written as a YAML block string, like the site option.
+site:
+ translators: |
+ - name: Page-level Translator
+---
# Introduction
This is a **visual-regression fixture** for the QuantEcon MyST theme. It exercises
diff --git a/tests/visual/fixture/lists.md b/tests/visual/fixture/lists.md
index 654478e22..35130a5f7 100644
--- a/tests/visual/fixture/lists.md
+++ b/tests/visual/fixture/lists.md
@@ -1,3 +1,9 @@
+---
+# An explicitly empty value suppresses the translator credit on this page
+# (#143), rather than falling back to the project-level one.
+site:
+ translators: ''
+---
# Fancy ordered lists
Ordered lists whose `list` nodes carry `style` / `delimiter` (QuantEcon/mystmd#50)
diff --git a/tests/visual/fixture/myst.yml.in b/tests/visual/fixture/myst.yml.in
index edd6b5225..b756e67f6 100644
--- a/tests/visual/fixture/myst.yml.in
+++ b/tests/visual/fixture/myst.yml.in
@@ -21,5 +21,20 @@ site:
template: __THEME__
options:
logo_text: QuantEcon Visual Fixture
+ # Multilingual editions (#90, #143). Two editions make the toolbar's
+ # language switcher and the hreflang alternates render on every page; the
+ # lists are YAML blocks because template options are scalar-only (see
+ # app/i18n.ts). The Persian edition lives in ../fixture-rtl.
+ current_language: en
+ languages: |
+ - code: en
+ name: English
+ url: https://example.org/en
+ - code: fa
+ name: فارسی
+ url: https://example.org/fa/
+ translators: |
+ - name: Jane Translator
+ url: https://example.org/jane
parts:
footer: footer.md
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index b7d2a27f1..693e86915 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -324,3 +324,119 @@ test.describe("QuantEcon theme — visual regression", () => {
await expect(page.getByRole("button", { name: /start compute/i })).toHaveCount(0);
});
});
+
+/**
+ * Multilingual editions: the language switcher and hreflang alternates
+ * (#90), right-to-left layout (#91) and translator credit (#143). The main
+ * fixture configures two editions (en current) and a project-level
+ * translator with a page-level override on `/` and a suppression on
+ * `/lists`; `fixture-rtl` is the Persian edition with `enable_rtl`.
+ */
+test.describe("Multilingual editions", () => {
+ const rtlBase = `http://localhost:${process.env.RTL_PORT || "3113"}`;
+
+ test("hreflang-alternates", async ({ page }) => {
+ await page.goto("/features", { waitUntil: "domcontentloaded" });
+ const alternate = (lang: string) =>
+ page.locator(`head link[rel="alternate"][hreflang="${lang}"]`);
+ await expect(alternate("en")).toHaveAttribute("href", "https://example.org/en/features");
+ // The trailing slash on the configured URL is dropped before the path is joined.
+ await expect(alternate("fa")).toHaveAttribute("href", "https://example.org/fa/features");
+ await expect(alternate("x-default")).toHaveAttribute("href", "https://example.org/en/features");
+ // The landing page maps to each edition's root.
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(alternate("fa")).toHaveAttribute("href", "https://example.org/fa/");
+ });
+
+ test("language-switcher", async ({ page }) => {
+ await page.goto("/features", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const trigger = page.getByRole("button", { name: "Switch language" });
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const items = page.getByRole("menuitem");
+ await expect(items).toHaveCount(2);
+ const english = items.filter({ hasText: "English" });
+ await expect(english).toHaveAttribute("aria-current", "true");
+ await expect(english).toHaveAttribute("href", "https://example.org/en/features");
+ const persian = items.filter({ hasText: "فارسی" });
+ await expect(persian).not.toHaveAttribute("aria-current", "true");
+ await expect(persian).toHaveAttribute("href", "https://example.org/fa/features");
+ await expect(persian).toHaveAttribute("hreflang", "fa");
+ // Escape closes the menu and returns focus to the trigger.
+ await page.keyboard.press("Escape");
+ await expect(items).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+ });
+
+ // A site with fewer than two editions (the no-thebe fixture configures
+ // none) renders no switcher, no hreflang tags, and -- the regression this
+ // guards -- no empty toolbar slot taking a gap. The
stays in the DOM;
+ // `empty:hidden` must take it out of the flow.
+ test("language-switcher-absent-with-one-edition", async ({ page }) => {
+ const noThebeBase = `http://localhost:${process.env.NO_THEBE_PORT}`;
+ await page.goto(`${noThebeBase}/notebook`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ await expect(page.getByRole("heading", { name: "Notebook outputs" })).toBeVisible();
+ await expect(page.locator(".qe-language-switcher")).toHaveCount(0);
+ await expect(page.locator('head link[rel="alternate"][hreflang]')).toHaveCount(0);
+ const slot = page.locator(".qe-language-slot");
+ await expect(slot).toHaveCount(1);
+ await expect(slot).toBeHidden();
+ });
+
+ test("translators", async ({ page }) => {
+ const block = page.locator(".qe-page__header-translators");
+ // Project-level credit, default English label.
+ await page.goto("/features", { waitUntil: "domcontentloaded" });
+ await expect(block).toHaveText(/Translated by\s+Jane Translator/);
+ await expect(block.locator("a")).toHaveAttribute("href", "https://example.org/jane");
+ await expect(block.locator("a[rel~='author']")).toHaveCount(0);
+ // Page-level override replaces it on that page only (the landing page here).
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(block).toHaveText(/Page-level Translator/);
+ await expect(block).not.toHaveText(/Jane/);
+ // An explicitly empty page value suppresses the block.
+ await page.goto("/lists", { waitUntil: "domcontentloaded" });
+ await expect(page.locator("h1")).toBeVisible();
+ await expect(block).toHaveCount(0);
+ });
+
+ test("rtl-document", async ({ page }) => {
+ await page.goto(`${rtlBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("dir", "rtl");
+ await expect(html).toHaveAttribute("lang", "fa");
+ // Persian label and the current edition marked in the switcher.
+ await expect(page.locator(".qe-page__header-translators")).toHaveText(/ترجمهٔ\s+مترجم نمونه/);
+ await page.getByRole("button", { name: "تغییر زبان" }).click();
+ await expect(page.getByRole("menuitem").filter({ hasText: "فارسی" })).toHaveAttribute(
+ "aria-current",
+ "true",
+ );
+ await page.keyboard.press("Escape");
+ // Code stays left-to-right inside the right-to-left document.
+ const code = page.locator(".myst-code").first();
+ await expect(code).toHaveCSS("direction", "ltr");
+ // The toolbar's spacing is direction-neutral (`gap-x`, not a physical
+ // `space-x` margin): the first two items -- now at the right edge -- keep
+ // their gap instead of touching.
+ const toolbar = page.locator("ul:has(> .qe-language-slot)");
+ const first = await toolbar.locator("> li").nth(0).boundingBox();
+ const second = await toolbar.locator("> li").nth(1).boundingBox();
+ expect(first && second).toBeTruthy();
+ // In RTL the first item is the rightmost, so the gap is first.x - second's right edge.
+ expect(first!.x - (second!.x + second!.width)).toBeGreaterThanOrEqual(10);
+ });
+
+ test("rtl", async ({ page }) => {
+ await page.goto(`${rtlBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ await expect(page).toHaveScreenshot("rtl.png", {
+ fullPage: true,
+ maxDiffPixelRatio: 0.01,
+ animations: "disabled",
+ });
+ });
+});