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 && ( +

+ {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..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={