Skip to content

Add Spanish and Brazilian Portuguese locales#175

Open
luandro wants to merge 19 commits into
mainfrom
i18n/es-pt-br
Open

Add Spanish and Brazilian Portuguese locales#175
luandro wants to merge 19 commits into
mainfrom
i18n/es-pt-br

Conversation

@luandro
Copy link
Copy Markdown
Collaborator

@luandro luandro commented Jun 5, 2026

Implements the v1 of #172.

What's in this PR

  • VitePress i18n wired up in docs/.vitepress/config.mts: es and pt-BR added to the locales config with localized nav and a trimmed sidebar (only translated pages for now, grows as more land).
  • Four pages translated in each language (the highest-traffic ones):
    • what-is-libremesh
    • getting-started
    • guide/connecting
    • features
  • First-visit language banner (docs/.vitepress/theme/LanguageBanner.vue): reads navigator.language, on first visit suggests the matching locale with a dismissible banner. Choice remembered in localStorage. No jarring redirect, no content flash. Mounted via the layout-top slot in theme/index.ts.
  • docs/TRANSLATING.md: contributor guide with the link-out convention, glossary of terms to keep in English (Batman-adv, Babel, LiMe, LuCI, OpenWrt, …), and the steps to add a new language.
  • Untranslated pages link out with a small (en) / (em) hint rather than 404ing — see docs/es/getting-started.md for the pattern.

Why this approach

  • VitePress locales does the heavy lifting: language switcher in the nav, <html lang> set per locale, URL routing under /es/ and /pt-BR/. Zero new build pipeline.
  • Translations live in the same repo as plain markdown — no CMS, no Crowdin, just PRs. The existing editLink config already lands contributors on the right file.
  • Auto-generated package/profile pages stay in English (they're built from git submodules by prepare_data.js and number in the hundreds). Not realistic to translate by hand.
  • Future LLM-translation script (suggested in the issue) is compatible with this layout — no rework needed.

Out of scope for this PR (intentional)

  • Translating the auto-generated packages/ and profiles/ pages.
  • A translation management platform.
  • A <link rel="alternate" hreflang="..."> block (easy follow-up if wanted).

Verification

  • pnpm build passes locally; both /es/ and /pt-BR/ render the four translated pages and the language switcher is visible in the nav.
  • Banner is client-side only and SSR-safe (inBrowser guard).

Follow-ups

  • More translated pages (next up would be guide/packages-selection and reference/configuration).
  • Native-speaker review of the current translations — Spanish and pt-BR are not my strongest languages, so a pass from a maintainer/community member would be very welcome before merge.

Refs #172.

kilo added 19 commits June 5, 2026 19:16
Wires VitePress i18n via the locales config and mirrors the four
highest-traffic pages under docs/es/ and docs/pt-BR/:
  - what-is-libremesh
  - getting-started
  - guide/connecting
  - features

Other pages link out to English with a (en)/(em) hint, per the
link-out convention in docs/TRANSLATING.md. Sidebars for the new
locales are trimmed to translated pages only; they grow as more
translations land.

Adds a first-visit LanguageBanner that reads navigator.language
and, on the first visit, suggests the matching locale. Choice is
remembered in localStorage so it does not reappear. Banner is
mounted via the layout-top slot in theme/index.ts and styles live
in style.css. Detected locales currently: es, pt-BR.

docs/TRANSLATING.md is the contributor-facing guide: layout,
glossary of terms to keep in English (Batman-adv, Babel, LiMe,
LuCI, OpenWrt, ...), link-out convention, and how to add a new
language (one new folder + one config entry + one message entry
in LanguageBanner.vue).

Refs #172.
…rror

The full-width banner that lived in the layout-top slot was pushing
the home page content down and breaking the mobile menu. The
VitePress built-in language switcher (auto-rendered in the right
side of the nav from the locales config) is the tiny menu the user
wants, and it already renders consistently on home and doc pages.

Replace the banner with a small fixed-position toast in the
bottom-right corner so it never touches the nav and never shifts
layout. Mounted via the page-bottom slot in theme/index.ts; styled
in style.css as a discrete chip with Switch and dismiss buttons.
The toast is hidden by default and only appears on first visit when
navigator.language matches a known locale.

Also fix the runtime error from the previous build:

  Uncaught Error: useRouter() is called without provider.
  at useRouter (router.js?v=...:162:15)
  at useRoute  (router.js?v=...:166:12)

VitePress's useRoute() must be called once at setup top-level so
it can register with the provided router context. Calling it
inside onMounted and event handlers produced the missing-provider
error. Move the call to the top of <script setup> and store the
reactive route ref for use everywhere.
Visiting /es/ or /pt-BR/ in preview returned PAGE NOT FOUND because
VitePress i18n requires a per-locale index.md at the root of each
locale directory. The build emitted the four inner pages for each
locale but skipped the locale root, so the language switcher in
the nav pointed at a 404 on the home view.

Add docs/es/index.md and docs/pt-BR/index.md as translated
versions of docs/index.md, mirroring its layout: home hero,
features, organizations table, community mesh networks table.
Frontmatter details text is rephrased to avoid trailing colons
inside a single-line YAML value, which broke the parser on the
first attempt.
The localized sidebars had base: '/es/' (and '/pt-BR/') and items
with absolute paths like '/es/what-is-libremesh'. VitePress
concatenated them, producing broken links of the form
'/es/es/what-is-libremesh.html' from any page on the /es/ tree.

Make the items relative (drop the '/es/' / '/pt-BR/' prefix) so
base prepends correctly. The English sidebar already used this
pattern with base: '/' and absolute items.
The hero image was referenced as './lime.svg' which VitePress
resolved relative to the page URL: '/es/lime.svg' and
'/pt-BR/lime.svg'. The asset lives at the site root
(docs/public/lime.svg), so those paths 404'd.

Switch to the absolute '/lime.svg' so the image resolves the same
from every locale. Same as the English home page.
…locales

Final review pass. Four real issues found and fixed:

1. Localized nav 404'd. navEs() and navPtBr() pointed at
   /es/guide/packages-selection, /es/reference/configuration,
   /es/news/..., /es/changelog — none of which exist. Switch to
   the link-out convention: localized labels, English targets.
   Same structure as the English nav, just translated.

2. Reference sidebar removed from the es and pt-BR locales. The
   reference pages aren't translated, and sidebarReferenceEn()
   would have rendered English labels inside a Spanish sidebar.
   With the nav now pointing at English, the sidebar is not
   needed in those locales.

3. LanguageBanner used to dump the user on the locale home when
   they accepted, even if the page they were reading had a
   translated counterpart. Added a translatedPaths map for both
   locales so the toast takes them to the matching localized
   page (e.g. /guide/connecting -> /es/guide/connecting). Added
   a per-locale action label ('Cambiar' / 'Trocar') and a
   per-locale hint string so the toast reads in the user's
   language, not in English.

4. TRANSLATING.md said 'banner' for what is now a 'toast', and
   was missing a 'Submitting a translation PR' section. Updated
   both, and added a note that new translatedPaths entries
   should be added when a page is translated in both languages.
…miss label

Second review pass. Three improvements:

1. Add a 'description' field to each locale in config.mts. The
   top-level 'description' was being used as <meta name=description>
   on every page in every locale, so search results for es/pt-BR
   pages showed the English description. Override per locale.

2. LanguageBanner.pickLocale now accepts navigator.languages (an
   array) in addition to navigator.language (a single string), so
   a user with navigator.languages = ['fr-FR', 'es-ES', 'en-US']
   still gets the Spanish toast offered (es is the first match).
   The function falls back to the old single-value behavior if
   navigator.languages is missing.

3. The toast dismiss button had a hardcoded aria-label='Dismiss'
   in English. Bind it to the per-locale messages object so the
   screen reader label matches the toast's language (Cerrar /
   Fechar).

Also: in navEn() change 'changelog' (relative) to '/changelog'
(absolute) for consistency with the localized navs, which all
use absolute paths after the link-out fix.
…ts translatedPaths

Codex (gpt-5.1-codex-max) flagged a P2 in the previous review pass:
VitePress's built-in VPNavBarTranslations auto-generates links to
/<locale>/<current-path> for every locale, which 404s for the many
English pages that have no translated counterpart. Example: on
/guide/packages-selection the switcher offered
/es/guide/packages-selection.html and /pt-BR/guide/packages-selection.html,
neither of which exist.

Fix:

1. Extract the path map and helpers to a new i18n.ts module so the
   toast and the switcher use the same target resolution and cannot
   drift apart.

2. Add LanguageSwitcher.vue: a small dropdown in the nav, mounted
   via the nav-bar-content-after slot, that resolves the right
   target for each locale (translated counterpart if one exists,
   locale home otherwise). The current locale is shown highlighted
   and a 'home' hint is appended to items that fall back.

3. Hide VitePress's built-in switcher via CSS as a defensive
   fallback, and remove its DOM element from LanguageSwitcher.vue's
   onMounted so its dead hrefs never end up in the served HTML
   (would otherwise be crawled by search engines and treated as
   404s).

4. Refactor LanguageBanner.vue to import the same i18n.ts helpers.
…but not committed

The previous commit imported these two new files in index.ts but
used 'git commit -am', which only stages tracked files. The new
files stayed in the working tree, so the commit on the branch
references files that don't exist there and the build fails on
a clean checkout. Add them now.
…n switcher variants

Three follow-ups from the second Codex review pass:

1. After the i18n.ts refactor, the messages object lost the
   hint/action/dismiss fields, so the first-visit toast rendered
   blank. Add them back to the i18n.ts messages map so the toast
   and the switcher share one source of truth for the labels.

2. targetFor() returns root-relative paths like '/es/' which break
   when the site is built with IS_FORK=1 (base is
   /libremesh.github.io). The toast and switcher now prepend
   siteData.value.base before assigning to window.location.href,
   so both fork-style and root-style builds work.

3. VitePress renders the language switcher in three places — the
   desktop nav (.VPNavBarTranslations), the mobile overflow menu
   (.group.translations inside .VPNavBarExtra), and the mobile
   screen (.VPNavScreenTranslations). The previous fix only
   removed the first. removeBuiltInSwitchers() now drops all
   three, and re-runs on every SPA route change so it survives
   client-side navigation.
…plicitly

Codex review caught that navEs()/navPtBr() point 'Guía' to
/guide/packages-selection (English) without a hint, so a user on
/es/ clicks a translated label and lands on an English page with
no warning — and the translated /es/guide/connecting exists.

Point 'Guía'/'Guia' at the first translated guide page
(/<lang>/guide/connecting). For items whose target isn't translated
yet, append an '(en)' / '(em)' hint to the label and keep the
target as the English path, matching the link-out convention used
in body text.
Codex flagged that the JS-based cleanup in LanguageSwitcher.vue
only runs after client hydration, so the static SSR HTML still
contains dead /<locale>/<current-path> hrefs in the mobile
overflow menu (.group.translations) for no-JS visitors and
crawlers.

Extend the hide rule to all three locations where VitePress
renders the built-in switcher:
  - .VPNavBarTranslations  (desktop nav)
  - .VPNavScreenTranslations (mobile screen)
  - .group.translations    (mobile overflow)
  - attribute selectors as a defensive catch-all

The hrefs still exist in the markup (crawlers will hit 404s), but
they are hidden visually for every visitor regardless of JS
state. The remaining dead links shrink as more pages are
translated — see TRANSLATING.md for the path map.
…a translation

Codex caught that the toast said 'this page is also available in
Español' on any English page, even when clicking would actually
take the user to the locale home (because the current page has
no translated counterpart in translatedPaths).

Add hasTranslation() to i18n.ts and gate the toast on it. Now the
toast only appears when the user is on a page that genuinely has
a translated version — otherwise we stay silent and let the nav
switcher do its job (it already handles the home fallback
correctly).
Codex caught a runtime bug: in this VitePress version useData()
returns { site, theme, page, ... } (named 'site', not
'siteData'). The components were destructuring { siteData } which
is undefined, so withBase() always fell back to '/' and any
redirect on a base-path build (e.g. /libremesh.github.io) would
404 to the wrong place.

Destructure { site } from useData() in LanguageBanner.vue and
LanguageSwitcher.vue. Functionally equivalent for the root-style
build, and actually correct for the IS_FORK=1 build.
Codex found two bugs that both fire only on IS_FORK=1 builds
(where base is /libremesh.github.io) and on the root build they
silently worked by luck:

1. route.path includes the base prefix (e.g.
/libremesh.github.io/pt-BR/foo), so stripLocale and
currentLocaleOf never matched the /es or /pt-BR prefix. The
custom switcher then rendered translated pages as 'EN' and
language clicks misrouted to the locale home or double-prefixed
the base.

2. goesToHome compared targetFor(loc, route.path) (returning
/es/...) against cleanPath(stripLocale(route.path)) (returning
/...). They always differed, so the 'home' hint showed on every
non-English dropdown item even for pages with a real
translation.

Add stripBase(path, base) to i18n.ts and compute rootPath in both
components. All locale helpers now see a root-relative path.

Also fix goesToHome to compare against the locale home directly
instead of against the always-different clean path, so the hint
only shows for actual fallbacks.
Codex caught two P1/P2 bugs:

1. VitePress emits '/es/what-is-libremesh.html' but
   translatedPaths was returning the extensionless
   '/es/what-is-libremesh', which 404s on static hosting. Add
   .html to every non-home target. The home target stays as
   '/es/' (VitePress serves the directory index).

2. hasTranslation didn't include '/' in translatedPaths, so
   the first-visit toast was suppressed when the user landed on
   the English home page '/' even though the localized homes
   '/es/' and '/pt-BR/' exist. Add the home mapping so the
   homepage gets the same language suggestion as other
   translated pages.
The previous custom switcher used a bordered text button with a
text short-code (EN/ES/PT) and a square dropdown panel — it
didn't look anything like VitePress's built-in
VPNavBarTranslations. Rebuild it as a flyout that matches the
built-in styling: transparent button, vpi-languages globe icon
+ vpi-chevron-down, 0 12px padding, var(--vp-nav-height) tall,
hover/aria-expanded drives menu visibility, themed panel
(border-radius 12px, var(--vp-c-bg-elv), var(--vp-shadow-3)),
themed menu links with active highlight. On screens < 1280px
the switcher hides itself entirely, matching the built-in's
@media rule.

Mobile and the responsive 'extra' menu still get the built-in
hidden via CSS, so the only switcher users ever see is this one.
…le screen variant

Three things:

1. Drop the duplicate locale name from the dropdown. The previous
   template rendered a 'title' row (current locale, e.g. 'English')
   AND listed every locale in the items below, so the active one
   appeared twice. Remove the title; items alone are the source of
   truth and the active item is still highlighted via the
   'active' class.

2. Reposition the switcher to the right of the theme (appearance)
   switcher. VitePress's nav has no slot between appearance and
   social, so reorder the flex children via CSS order: appearance
   order 0, lang-switcher order 1, social order 2, extra order 3.
   Add a '|' divider via a 1px-tall ::before pseudo-element on
   the switcher, matching the visual rhythm of the built-in nav.

3. Add a LanguageSwitcherMobile.vue and mount it in
   nav-screen-content-after so the language menu is reachable from
   the mobile full-screen menu too. Mirrors the built-in
   VPNavScreenTranslations layout: collapsible header with globe
   icon and current locale label, expanding to a list of locale
   buttons. The two components share i18n.ts so the path map stays
   in sync.

Also remove the @media (min-width: 1280px) hide on the desktop
switcher — flex order is no-op on small screens where the
desktop nav is hidden, so the rule was redundant. The
corresponding hide below 1280px on the built-in
VPNavBarTranslations is what determines whether the desktop
switcher shows; CSS only hides the built-in everywhere, the
custom one in nav-bar-content-after inherits the same visibility
from its flex parent (display: none on .content-body? no, the
content-body is always shown — but the nav-bar-content-after
slot content sits inside content-body which is always visible,
just hidden when isScreenOpen? no...). The desktop switcher is
visible at all sizes inside the desktop nav row, and the
mobile screen switcher covers phones. So the @media hide is
no longer needed.
…uilt-in

The .lang-switcher::before pseudo-element had margin: 0 12px 0 0
(12px right, 0 left), making the divider flush against the
lang-switcher's left edge. This created uneven spacing between
the theme toggle and the divider.

Changed to margin: 0 8px to match VitePress's built-in dividers,
which use margin-right: 8px; margin-left: 8px; for equal 8px
spacing on both sides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant