From 3df07dc5bef22ddcf92d51a68bdaa497a5cd071b Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 7 Sep 2026 13:43:34 +1000 Subject: [PATCH 1/7] feat: language switcher, hreflang, RTL and translator credit (Phases 4-5) Brings the theme to parity with quantecon-book-theme for translated editions: Phase 4 (#90), Phase 5 (#91) and the translator credit (#143) of the parity plan, on the shape settled across the themes on QuantEcon/workspace-themes#3. Language switcher and hreflang (#90). `languages` (list of code, name, url) and `current_language` site options; with two or more editions the toolbar gains a globe-icon Radix dropdown at every width linking to the same page in each edition, the current one marked with aria-current, and the two page routes emit tags with the first edition as x-default. Right-to-left (#91). `enable_rtl` sets dir="rtl" on , and `current_language` now sets its lang in place of the hard-coded "en". Upstream's Document offers neither attribute, so the theme carries a local copy (app/components/Document.tsx) adding the two props and Radix's DirectionProvider. The theme's own components move to logical utilities; styles/rtl.css mirrors the physical utilities upstream content markup uses, flips the drawer's slide and keeps code and maths left-to-right. Translator credit (#143). `translators` and `translators_label` site options with a per-page `site:` override (replace, never merge; an explicit empty value suppresses), rendered at the end of the "Last changed" row as the book theme does since v0.22.0, with a distinct class and no rel="author". Labels are the localisation mechanism; the list connector stays English, the same gap the book theme records. Configuration surface. The CLI drops every site.options key the template does not declare and can declare only scalar types (see #173), so the options are declared in template.yml and the two lists are YAML written inside a block string, parsed by app/i18n.ts; a real list is accepted too. No template defaults: a declared default is written into every page's validated `site:` block, which then replaces the raw block and both overrides the site-wide value and discards git_metadata. Defaults live in the code. Tests: unit tests for the parsing and resolution rules; the visual fixture configures two editions and a translator with a page-level override and a suppression; a third fixture is a Persian edition with enable_rtl for the rtl snapshot and the direction assertions. Darwin baselines refreshed. Local Document and the RTL audit are recorded on the upstream-candidates ledger. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 + CHANGELOG.md | 21 ++ PLAN.md | 58 +++-- README.md | 46 ++++ UPSTREAM-CANDIDATES.yml | 48 +++- app/components/Document.tsx | 164 ++++++++++++ app/components/PageContent.tsx | 17 ++ app/components/PageHeaderHistory.tsx | 12 +- app/components/ProjectFrontmatter.tsx | 58 ++++- app/components/toolbar/DownloadButton.tsx | 2 +- app/components/toolbar/GitHubButton.tsx | 2 +- app/components/toolbar/LanguageSwitcher.tsx | 87 +++++++ app/components/toolbar/LaunchButton.tsx | 2 +- app/components/toolbar/Toolbar.tsx | 10 +- app/i18n.ts | 246 ++++++++++++++++++ app/root.tsx | 9 +- app/routes/$.tsx | 37 ++- app/routes/_index.tsx | 32 ++- app/types.ts | 15 ++ package-lock.json | 64 ++++- package.json | 4 +- playwright.config.ts | 16 ++ styles/app.css | 3 + styles/rtl.css | 95 +++++++ template.yml | 43 +++ tests/unit/i18n.test.mjs | 146 +++++++++++ tests/visual/README.md | 8 +- .../desktop-chrome-darwin/features.png | Bin 187578 -> 190306 bytes .../desktop-chrome-darwin/history-open.png | Bin 208290 -> 211383 bytes .../desktop-chrome-darwin/intro.png | Bin 100608 -> 103998 bytes .../desktop-chrome-darwin/lists.png | Bin 200111 -> 200564 bytes .../desktop-chrome-darwin/notebook.png | Bin 90036 -> 93882 bytes .../desktop-chrome-darwin/rtl.png | Bin 0 -> 127939 bytes .../desktop-chrome-darwin/sidebar-open.png | Bin 87899 -> 90046 bytes .../mobile-chrome-darwin/features.png | Bin 153782 -> 156705 bytes .../mobile-chrome-darwin/history-open.png | Bin 170432 -> 173502 bytes .../mobile-chrome-darwin/intro.png | Bin 82203 -> 85416 bytes .../mobile-chrome-darwin/lists.png | Bin 185317 -> 185888 bytes .../mobile-chrome-darwin/notebook.png | Bin 71454 -> 74627 bytes .../mobile-chrome-darwin/rtl.png | Bin 0 -> 100686 bytes .../mobile-chrome-darwin/sidebar-open.png | Bin 14372 -> 15616 bytes tests/visual/fixture-rtl/intro.md | 59 +++++ tests/visual/fixture-rtl/myst.yml.in | 29 +++ tests/visual/fixture/intro.md | 7 + tests/visual/fixture/lists.md | 6 + tests/visual/fixture/myst.yml.in | 15 ++ tests/visual/theme.spec.ts | 91 +++++++ 47 files changed, 1387 insertions(+), 67 deletions(-) create mode 100644 app/components/Document.tsx create mode 100644 app/components/toolbar/LanguageSwitcher.tsx create mode 100644 app/i18n.ts create mode 100644 styles/rtl.css create mode 100644 tests/unit/i18n.test.mjs create mode 100644 tests/visual/__snapshots__/desktop-chrome-darwin/rtl.png create mode 100644 tests/visual/__snapshots__/mobile-chrome-darwin/rtl.png create mode 100644 tests/visual/fixture-rtl/intro.md create mode 100644 tests/visual/fixture-rtl/myst.yml.in 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..cb2c32e2d 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)). +- 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)). +- 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)). +- 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". ## [2.5.0] - 2026-09-05 diff --git a/PLAN.md b/PLAN.md index b28434287..1e93a677e 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-07 (#89), unreleased; Phases 4–5 and the translator credit (#143) followed the same +day. **Phase 6 is next.** --- @@ -410,6 +412,14 @@ between custom QuantEcon token colours and any built-in Pygments style. ## Phase 4 — Internationalisation (language switcher + hreflang) +**Status: complete** *(2026-09-07, #90; 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** *(2026-09-07, #91)*. + **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 and + wraps the tree in Radix's `DirectionProvider`; an upstream prop is the candidate in + `UPSTREAM-CANDIDATES.yml`. +- [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). @@ -472,12 +498,12 @@ 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 4 i18n (language switcher) ──▶ Phase 5 RTL ✅ on main 2026-09-07 (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..e2c7d993a 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: PR_PHASE_4_5 + 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: PR_PHASE_4_5 + 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 and wraps the tree in Radix's DirectionProvider. + status: pending target: jupyter-book/myst-theme - provenance: [] + provenance: + - local_pr: PR_PHASE_4_5 + 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..522344b03 --- /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 { DirectionProvider } from '@radix-ui/react-direction'; +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. + * + * Radix's `DirectionProvider` follows `dir`, so dropdown menus and tooltips + * mirror their alignment in a right-to-left edition without per-component + * work. + * + * 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 && ( +

+ {translatorsLabel && ( + {translatorsLabel} + )} + +
+ )} +
); 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..9f0e5a0aa 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() { @@ -62,7 +63,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 +78,13 @@ 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. */} +
  • + +
  • 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={