Skip to content

i18n milestone 1: config-driven locale + localizable chrome strings (c.locale, c.ui_text) #59

Description

@mhenrixon

Problem / Goal

Every site built on docs-kit is hard-locked to English. DocsUI::Shell emits html(lang: "en") (app/components/docs_ui/shell.rb:41), ~17 user-facing chrome strings ("On this page", "Search…", "Theme", "N results for", …) are English literals inside the components, and two more live in the Stimulus controller ("Copied!", "No results"). c.seo.locale exists but only feeds og:locale and is disconnected from the document language.

A site that wants German/Swedish/French docs today cannot ship correct markup: a wrong <html lang> breaks screen-reader pronunciation, hyphenation, and browser translate prompts, and the chrome stays English no matter what the content says. Fixing it would require forking components — exactly what the kit forbids.

Done looks like: a consuming site sets c.locale = "de" and overrides chrome strings via c.ui_text, and gets a fully German site — correct <html lang>, correct og:locale, translated chrome — with zero component subclassing. A site that configures nothing renders byte-identical to today.

This is milestone 1 of multi-language support. Serving several locales side-by-side (/docs + /de/docs) builds on this foundation — see Future milestones at the bottom; it is NOT part of this issue.

Context (read these first)

  • lib/docs_kit/configuration.rb — the config surface; new knobs live here with backwards-safe defaults. DEFAULT_LANGUAGE_LABELS + #language_labels (merged-over-defaults map, symbol-keyed) is the exact pattern #ui_text must follow; code_lexer_aliases/api_clients are two more instances of it.
  • lib/docs_kit/seo_config.rb:45-46,74seo.locale, default "en_US", consumed only by og:locale.
  • app/components/docs_ui/meta_tags.rb:77meta(property: "og:locale", content: seo.locale) if seo.locale.
  • app/components/docs_ui/shell.rb:41 hardcoded html(lang: "en", …); :47-54 the <body> data-docs-nav-* Stimulus values (the existing channel for passing config to the one controller); :158 aria_label: "Open menu".
  • Components holding the other hardcoded strings — full inventory in step 4.
  • app/javascript/docs_kit/controllers/docs_nav_controller.js:353,524"Copied!" and "No results"; the controller already declares Stimulus values read from <body>.
  • lib/generators/docs_kit/install/templates/docs_kit.rb.erb — the site initializer template; new knobs get commented examples here (docs-kit new runs this same generator, so one template covers both install paths).
  • spec/docs_kit/configuration_spec.rb, spec/docs_ui/ — the unit and component spec layers (no Rails boot; components render via bare Phlex).
  • .claude/rules/seo.md — the <head> of an unconfigured site must stay a strict superset of today's markup; never raw/html_safe config free text.
  • .claude/rules/testing.md — TDD, semantics-over-snapshots, SimpleCov ≥ 80 (100% aspired for Configuration).

Decision

Extend the existing config pattern: a c.locale knob plus a c.ui_text merged-over-defaults string map. No Rails I18n dependency.

Why this wins:

  • It IS the gem's core invariant: what differs between two sites is configuration. A German site's chrome strings are site config, exactly like its brand and themes.
  • It works in every test layer as-is — unit and component specs boot no Rails, and a plain hash needs no load-path or backend management.
  • It is the fourth instance of an established shape (language_labels, lexer_aliases, api_clients), so maintenance cost is near zero.
  • Milestone 2 (multi-locale) extends the lookup with a locale key (c.ui_text = { de: {…} }); nothing here gets thrown away.

Alternatives rejected:

  1. Rails I18n end-to-end (gem-shipped locale YAMLs, I18n.t in components, I18n.locale drives lang): the Rails-standard idiom and would allow community-shared translations — but it introduces a second config channel beside DocsKit.configuration, needs I18n load-path plumbing for standalone Phlex renders (the whole component spec layer), and needs per-request I18n.locale wiring in the install path. For ~19 strings the shared-translation payoff doesn't cover that cost. Revisit only if milestone 2 proves config-keyed lookup insufficient.
  2. Hybrid (ui_text values resolved through I18n when present): two sources of truth for one string; the complexity of both approaches.
  3. Do nothing / document component subclassing: forks the chrome per site — the thing this gem exists to prevent.

Sub-decision — og:locale derivation: seo.locale's default changes from "en_US" to nil; DocsUI::MetaTags emits seo.locale || config.og_locale, where Configuration#og_locale converts BCP-47 to OG form (tr("-", "_")) and special-cases the default "en""en_US" so an unconfigured site's head is byte-identical to today. An explicit c.seo.locale (e.g. "de_AT") always wins; a bare c.locale = "de" emits og:locale "de" — guessing a territory would be wrong.

Sub-decision — the two JS strings: threaded from config as Stimulus values on <body> (Shell already sets docs-nav values there), with English defaults declared in the controller's values so a stale cached page still works. Config stays the single source; no second controller; JS-off behavior untouched.

Implementation steps

TDD throughout: each step names its spec first (RED), then the change (GREEN).

1. Config knobsspec/docs_kit/configuration_spec.rb, then lib/docs_kit/configuration.rb

  • Specs: #locale defaults to "en" and is assignable; #og_locale maps "en""en_US", "de""de", "pt-BR""pt_BR"; #ui_text returns DEFAULT_UI_TEXT merged with site overrides, symbol-keyed (string keys transformed), unknown extra keys passing through; nil override behaves like {}.
  • Implement: attr_accessor :locale (init "en"); frozen DEFAULT_UI_TEXT (every key from the step-4 table + :copied/:no_results); attr_writer :ui_text + def ui_text merging like #language_labels; def og_locale.

2. og:locale derivationspec/docs_ui/meta_tags_spec.rb (+ seo_config spec), then lib/docs_kit/seo_config.rb, app/components/docs_ui/meta_tags.rb

  • Specs: unconfigured render emits og:locale "en_US" (regression pin — byte-identical head); c.locale = "de""de"; c.seo.locale = "de_AT" wins over c.locale.
  • Implement: seo.locale default nil; meta_tags.rb:77 → seo.locale || config.og_locale (always truthy now, so the tag stays always-present as today).

3. Shellspec/docs_ui/shell_spec.rb, then app/components/docs_ui/shell.rb

  • Specs: <html lang> reflects c.locale (default "en"); <body> carries data-docs-nav-copied-value and data-docs-nav-no-results-value from ui_text; hamburger aria-label reflects ui_text[:open_menu].
  • Implement: shell.rb:41 html(lang: config.locale, …); add the two values at shell.rb:47-54; shell.rb:158 reads config.

4. Components — one focused spec per component (override renders + default renders unconfigured), then replace each literal with DocsKit.configuration.ui_text.fetch(:key):

File:line Key Default
theme_switcher.rb:18 :theme "Theme"
on_this_page.rb:23 :on_this_page "On this page"
markdown_action.rb:15 :markdown_action "Markdown"
page.rb:68 :back_home "← Home"
search_box.rb:54 :search_placeholder "Search…"
search_box.rb:55 :search_docs "Search docs" (aria-label)
search_results.rb:37 :search_heading "Search"
search_results.rb:41,46-49 :search_results_one / :search_results_many "%{count} result for" / "%{count} results for"
search_results.rb:53 :search_prompt "Type a query above to search the docs."
search_results.rb:60 :search_no_results_hint "Try fewer or more general words."
search_results.rb:86 :overview "Overview"
landing.rb:163 :documentation "Documentation"
open_api_operation.rb:60 :parameters "Parameters"
open_api_operation.rb:61 :request_body "Request body"

Gotchas: markdown_action.rb:15 is a frozen LABEL constant — must become a render-time config read. on_this_page.rb:23 takes title: as a param — keep the per-instance override, defaulting to ui_text[:on_this_page] at render time (not in the signature, or it freezes config at class-load). The result-count line replaces the hand-rolled plural ("#{n} result#{'s' unless n == 1}") with format(ui_text.fetch(n == 1 ? :search_results_one : :search_results_many), count: n), keeping the styled query span after it (two-form pluralization covers the languages this targets; full CLDR plural rules are out of scope). All values render as ordinary Phlex text/attributes — Phlex escapes them; never raw/safe on ui_text.

5. Clientapp/javascript/docs_kit/controllers/docs_nav_controller.js

  • Declare copied: { type: String, default: "Copied!" } and noResults: { type: String, default: "No results" } in static values; use them at lines 353 and 524. No new controller (the ONE-controller rule).

6. Install pathspec/generators/install_generator_spec.rb, then lib/generators/docs_kit/install/templates/docs_kit.rb.erb

  • Spec: the generated initializer contains the commented # c.locale = "en" knob and a commented c.ui_text example.
  • Implement: add both near the seo block, with a one-line comment each ("BCP-47 document language → html[lang] and og:locale"; "override any chrome string — see README for all keys"). docs-kit new runs this same generator, so no second template to touch — verify lib/docs_kit/templates/new_site.rb needs nothing.

7. Docs — README: a "Localize the chrome" section — c.locale, the full ui_text key table, the seo.locale interplay (explicit override for territory forms), the two JS-side keys, and the caveat that sidebar collapse-state localStorage keys derive from rendered labels, so translating labels resets collapse state once (harmless).

8. Completeness sweep — before closing, grep for stragglers: grep -rnE '(plain |aria_label:|placeholder:|\{ ")' app/components/docs_ui/ and confirm every user-facing literal is either in DEFAULT_UI_TEXT or intentionally exempt (brand/config-driven values, code samples). The table above was verified against main on 2026-07-06.

Verification gates

  • bundle exec rspec — all green; SimpleCov ≥ 80; spec/docs_kit/configuration_spec.rb keeps Configuration at 100%.
  • bundle exec rubocop -A — no offenses.
  • Backwards compat: with zero config, the rendered Shell is unchanged vs main — lang="en", og:locale "en_US", every default English string (pinned by the step-2/3/4 default-path specs). Sanity-check the dogfood site in docs/ still renders (cd docs && bin/dev).
  • No new emitted CSS classes → no bun run build:css / @source change needed.

Out of scope

  • Multi-locale sites (milestone 2): c.locales map, locale-scoped registries/routes (/de/docs/…), a topbar locale switcher, hreflang alternates, locale-aware sidebar active-matching, locale-keyed ui_text. Do not touch DocsKit::Registry, the route templates, or DocsUI::Sidebar matching.
  • Locale-aware search/llms.txt/MCP (milestone 3): locale: params on SearchIndex/LlmsText/the MCP tools, CJK tokenization, non-ASCII anchor slugs. Do not touch lib/docs_kit/search_index.rb or lib/docs_kit/llms_text.rb.
  • No Rails I18n integration; no translation YAMLs shipped in the gem.
  • No localStorage key re-derivation in docs-nav (only matters once one site serves two locales).
  • Don't localize code-tab labels (code_language_labels is already config-driven) or the Ctrl kbd refinement.

Future milestones (design notes only — separate issues)

  • M2 — multi-locale content. The seams found: Registry.path_prefix is per-class (one registry class per locale, e.g. DocsDe with path_prefix "/de/docs", already works today for hrefs); the sidebar needs the current locale to render only that locale's groups — derivable from request.path against a c.locales map (no I18n global, JS-free); active-link matching is exact request.path == href (app/components/docs_ui/sidebar.rb:95-104) so prefixed hrefs match for free; DocsUI::MetaTags grows link rel="alternate" hreflang from the locales map; a locale switcher renders as plain topbar links (progressive enhancement, no new controller). Routes stay host-owned: the generator recipe documents a parallel get "de/docs/:doc" => "docs#show", defaults: { locale: "de" } or a scope.
  • M3 — locale-aware content surfaces. LlmsText.pages/SearchController/McpTools all flatten the single nav_registries set; each needs a locale parameter (MCP tools gain an optional locale: argument). SearchIndex#tokenize (whitespace split) is fine for European languages but not CJK; #slugify ASCII-transliterates non-Latin headings — both need attention before promising those languages.

Execution

Execute with /lfg <this issue number>.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions