Skip to content

feat(web): add Ukrainian localization and extract hardcoded copy#6312

Merged
houko merged 5 commits into
librefang:mainfrom
pavver:feat/web-ukrainian-localization
Jun 26, 2026
Merged

feat(web): add Ukrainian localization and extract hardcoded copy#6312
houko merged 5 commits into
librefang:mainfrom
pavver:feat/web-ukrainian-localization

Conversation

@pavver

@pavver pavver commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds Ukrainian localization support for the public web UI and moves the hardcoded web copy covered in this pass into the shared i18n system.

Changes

  • Added uk as a selectable web locale with /uk route support.
  • Added Ukrainian translations for the existing English web i18n keys.
  • Added English fallback resolution through getTranslation(lang) so every locale falls back to en when a key is missing.
  • Moved the audited hardcoded web UI text into i18n keys for shared page chrome and subpages.
  • Updated web pages and components to use resolved translations instead of reading raw locale objects directly.
  • Added i18n tests for Ukrainian locale registration, full Ukrainian key coverage, unknown-locale fallback, and resolved-locale completeness.

Commit split

  1. feat(web): add Ukrainian localization

    • Adds the Ukrainian locale.
    • Translates the already existing web i18n keys.
    • Adds /uk detection and URL prefix handling.
    • Adds the English fallback helper and fallback coverage tests.
  2. refactor(web): move hardcoded text into i18n

    • Adds new i18n keys for hardcoded web text found during this pass.
    • Updates components and pages to consume those keys.
    • Adds Ukrainian values for the new keys.

Scope note

This is not a claim that every hardcoded string in web/ has been exhaustively audited.
The PR covers the web pages and components touched in this localization pass.
A follow-up should add automated hardcoded-string, missing-key, and dead-key checks so future cleanup can be exhaustive and repeatable.

Verification

Ran:

pnpm lint
pnpm test
pnpm build
pnpm test:e2e

Results:

  • TypeScript check passed.
  • Vitest passed: 5 files, 40 tests.
  • Production build passed.
  • Playwright e2e passed: 12 tests.

Notes:

  • pnpm build and pnpm test:e2e were run outside the sandbox because tsx needs an IPC pipe that the sandbox blocks.
  • Playwright Chromium was installed locally before rerunning e2e.
  • Generated verification artifacts (web/public/feed.xml, web/public/registry.json, web/test-results/) were not included in the commits.

@github-actions github-actions Bot added no-rust-required This task does not require Rust knowledge size/XL 1000+ lines changed labels Jun 24, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One prose-wrapping nit in the new test code; otherwise no dashboard data-layer, query-key, or mutation-invalidation violations found. The i18n architecture (fallback resolution, key coverage tests, locale registration) looks correct.


Generated by Claude Code

Comment thread web/src/i18n.test.ts Outdated
Comment on lines +55 to +56
// Every locale must resolve to the full EN shape after fallback. Raw locale
// objects may stay partial; getTranslation is the runtime contract.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md prose-wrapping rule: "Break only at sentence boundaries. One sentence = one line, regardless of length."

The line break here falls mid-sentence — "Raw locale" ends one line, "objects may stay partial…" continues on the next. Should be two separate lines, one sentence each:

  // Every locale must resolve to the full EN shape after fallback.
  // Raw locale objects may stay partial; getTranslation is the runtime contract.

Generated by Claude Code

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three issues found in this pass.

Missing CHANGELOG entry. The other PRs landing in [Unreleased] this cycle (e.g. #6315, #6316) each add a bullet under the relevant section. This PR adds no entry. Please add one under ### Added or ### Changed in ## [Unreleased] (one sentence per line, with (@pavver) attribution per the pre-commit hook requirement).

DeployPage.tsx — troubleshooting answer formatting regression (see inline comment). The answers previously contained <code>/<em>/<br /> nodes for readable code snippets; converting them to plain strings drops that formatting for all locales including English.

MetricsPage.tsx — fragile split on a literal URL substring (see inline comment). copy.desc.split('/api/registry/click')[1] silently truncates if any future locale omits the exact substring.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The troubleshootingItems answers were converted from JSX (which contained <code>, <em>, and <br /> nodes) to plain strings in the i18n table.
This is a regression for English users as well: the formatted code snippets (e.g. flyctl tokens org <your-org-name>) are now rendered as unstyled monospace-less text.
Before merging, the render site needs to either:
(a) parse the answer strings as Markdown/HTML so the formatting survives, or
(b) keep the JSX structure for the answers and only translate the surrounding text strings, or
(c) document that the formatting loss is intentional and acceptable.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metrics description is rendered by splitting on the literal substring '/api/registry/click':

{copy.desc.replace('/api/registry/click', '')}
<code ...>/api/registry/click</code>
{copy.desc.split('/api/registry/click')[1]}

split(...)[1] is undefined (silently renders as nothing) if any locale's metrics.desc omits or abbreviates that exact substring.
The current Ukrainian value includes it verbatim, so it passes today, but this is a silent breakage waiting for the next translator.
A more robust approach is to store the URL separately (e.g. a {endpoint} interpolation slot) and assemble the JSX in the component, so translators never need to replicate the raw URL.


Generated by Claude Code

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the localization pass — the getTranslation English-fallback design is sound and the key extraction is clean. One user-visible rendering regression needs to be fixed before merge, plus two incomplete locale lists and an efficiency issue. Details below, most important first.

Blocking

1. web/src/pages/MetricsPage.tsx:62.replace() renders the whole description plus a duplicated tail

{copy.desc.replace('/api/registry/click', '')}
<code>/api/registry/click</code>
{copy.desc.split('/api/registry/click')[1]}

copy.desc.replace('/api/registry/click', '') returns the entire sentence with the path removed, not the prefix before it.
With the EN string "Aggregate counts … recorded via the /api/registry/click worker endpoint." the paragraph renders as:

Aggregate counts … recorded via the worker endpoint. + /api/registry/click + worker endpoint.

So the full sentence shows once, the <code> lands at the end, and worker endpoint. is duplicated (uk has the same problem — the path sits at the end of its sentence).
The first segment should be copy.desc.split('/api/registry/click')[0].

Should fix in this PR

2. web/src/components/SearchDialog.tsx:223 — paste-prefix locale set is missing uk

const langs = new Set(['zh', 'zh-TW', 'ja', 'ko', 'de', 'es', 'pl'])

This set already lists pl but not the new uk.
Pasting a https://librefang.ai/uk/skills/<id> registry URL into the search box no longer strips the uk prefix, so CATEGORIES.includes('uk') is false and the direct-navigate shortcut silently fails for Ukrainian URLs.

3. web/src/i18n.ts:2923getTranslation deep-merges the whole tree on every render with no cache

For any non-English locale, getTranslation rebuilds the entire merged translation object on every call via mergeObject(english, selected).
It is called directly from hot-path components — BackToTop re-renders on every scroll, SiteHeader on every menu/lang/feature toggle — so each re-render clones the full tree.
It also defeats SearchDialog's useMemo(..., [t]): because t is a fresh object each render for non-EN users, anchorHits/itemHits recompute every frame.
A module-level per-locale cache fixes both (EN already early-returns, so it is unaffected).

Optional / note

4. web/index.html:45 — pre-hydration <html lang> and localized meta do not cover uk (or pl)

The inline detection script only handles zh-TW/zh/de/ja/ko/es, and the metaDesc table the same.
Non-JS crawlers and link unfurls on /uk therefore see <html lang="en"> and the English description; App.tsx corrects documentElement.lang after hydration, so this is SEO/preview-only.
pl already has the same gap so it predates this PR, but since this change adds a new selectable locale and /uk routing it would be good to wire uk (and pl) into the SEO path here too.

@github-actions github-actions Bot added the needs-changes Changes requested by reviewer label Jun 25, 2026
@github-actions github-actions Bot added area/docs Documentation and guides and removed no-rust-required This task does not require Rust knowledge labels Jun 25, 2026
@pavver
pavver requested a review from houko June 25, 2026 09:36

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues worth resolving before merge — see inline notes. The Ukrainian translation work itself looks thorough; these are architectural questions about ErrorBoundary and the partial-locale contract, not translation quality issues.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrorBoundary is a class component, so it cannot call hooks and has no access to the active locale. The file hardcodes translations.en!.errorBoundary!, which means non-English users always see English error text — the new errorBoundary i18n keys are dead for every locale other than English.

Two ways to fix:

Option A (minimal): Accept English-only error boundaries and add a comment so the next reader understands why: // Class component cannot read hooks; always falls back to English. Convert to a functional wrapper to localize.

Option B (correct): Wrap with a thin functional ErrorBoundaryWrapper that reads the locale via useStore (or however the rest of the app resolves it), resolves the errorBoundary strings, and passes them as props into the class component's render(). This is the standard pattern for class components that need hook-provided values.


Generated by Claude Code

Comment thread web/src/i18n.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test 'Ukrainian has no missing raw keys vs en' asserts that translations.uk (the raw partial object) has zero missing keys relative to English. But the comment immediately below it says: "Raw locale objects may stay partial; getTranslation is the runtime contract."

These two contradict each other. Either:

  • The design allows partial locales — in which case the test should be deleted or rewritten to assert that getTranslation('uk') (the merged result) has full EN coverage, not the raw object. Otherwise this test will fail for any future language that legitimately omits keys it inherits from English.

  • Every locale must be 100% complete — in which case remove the "may stay partial" comment, and the test is correct.

Pick one contract and make the test and the comment agree. The current state encodes contradictory promises for the next contributor adding a new locale.


Generated by Claude Code

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One reachability issue in the new code: web/src/components/ErrorBoundary.tsx imports translations directly and always reads translations.en!.errorBoundary!, bypassing getTranslation entirely. The Ukrainian translations you added for errorBoundary (lines +1199–1204 in uk.json) are unreachable through this class component — it will always render English error text regardless of the active locale.

Since ErrorBoundary is a class component it cannot use hooks, so there is no lang state available in the usual way. The simplest fix is to read the locale from document.documentElement.lang (or a module-level export of the resolved locale) in getDerivedStateFromError / render, and pass it to getTranslation. Alternatively, convert to a functional wrapper that wraps the class with a locale-aware shell.

Everything else in the PR looks good — locale parity across en/uk for all new keys is complete, and the getTranslation fallback logic is correct.


Generated by Claude Code

@pavver

pavver commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Web i18n Review Notes

Summary

This follow-up addresses the review feedback around the website localization layer.
The main goal was to keep English fallback behavior reliable at runtime while still giving translators a way to verify that a locale is fully translated.

What Changed

The website now separates translator-authored locale data from runtime translation lookup.
The exported locale table was renamed from translations to rawTranslations to make that contract explicit.

rawTranslations represents only the strings that were actually authored for each locale.
It should not hide missing locale keys by spreading English into non-English locale objects.

getTranslation(lang) remains the runtime API for UI code.
It deep-merges the selected raw locale over English and caches the merged result per locale.
That means users still see English for any key missing from their selected locale.

ErrorBoundary now follows the same runtime path as the rest of the web UI.
Because React error boundaries still need a class component, a small functional wrapper reads lang from useAppStore, resolves copy with getTranslation(lang), and passes the localized strings into the class implementation.
This avoids reading document.documentElement.lang as state and keeps useAppStore as the single source of truth for the active locale.

The strict Ukrainian raw-key completeness check was removed from the regular unit test suite.
CI should verify the runtime contract: every locale resolves to the full English shape after fallback.
Raw completeness is a translator-maintenance concern, so it now lives in a manual audit command.

Translator Audit

A new command was added:

cd web
pnpm i18n:audit uk

The audit compares rawTranslations[locale] against rawTranslations.en.
It reports keys that exist in English but are missing from the selected raw locale.

There is also an aggregate mode:

pnpm i18n:audit --all

That mode lists every non-English locale that is not raw-key complete.
It is expected to fail while some locales intentionally rely on English fallback.

Why This Shape

The review surfaced two valid but different requirements:

  1. Runtime UI should always be complete and fall back to English when a selected locale is missing a key.
  2. Ukrainian is intended to be fully translated, so future English keys should not silently remain untranslated in Ukrainian.

Keeping those checks in one automatic test made the contract confusing.
It implied that all raw locale objects must always be complete, while the runtime design explicitly supports partial locales.

The new split keeps both requirements:

  • getTranslation(lang) defines and tests the runtime fallback contract.
  • pnpm i18n:audit <locale> gives translators a strict completeness tool for locales that are meant to be complete.

This also prevents English spread from masking missing Ukrainian keys.
If a new English key is added and no Ukrainian value is authored, the app still renders English at runtime, but pnpm i18n:audit uk reports the missing key.

Verification

The follow-up was verified with:

cd web
pnpm lint
pnpm test
pnpm i18n:audit uk

The Ukrainian audit currently reports:

uk: complete (318 keys)

@pavver
pavver requested a review from houko June 25, 2026 18:14

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five blocking items from the previous review are resolved as of 96290b26, and a couple of the architectural notes too. Verified against the current branch:

  • MetricsPage .replace() duplication → fixed. Now renders {copy.descPrefix}<code>{copy.endpoint}</code>{copy.descSuffix} with the three slots supplied per-locale. No duplicated tail.
  • ErrorBoundary locale bypass → fixed. A functional wrapper now reads useAppStore(s => s.lang) and passes getTranslation(lang).errorBoundary into the class component as a prop, so Ukrainian error text is reachable. Cleaner than the document.documentElement.lang approach I suggested.
  • CHANGELOG entry → added under ## [Unreleased] → ### Changed with (@pavver) attribution.
  • DeployPage formatting regression → fixed. Replaced the plain strings with a structured RichTextPart[][] model that restores <code>/<em>/<br> for all locales (EN included) without dangerouslySetInnerHTML.
  • Incomplete locale lists + efficiency → fixed. uk added to the SearchDialog paste-prefix set and to the index.html lang/meta SEO paths (pl too); getTranslation now memoizes merged locales in a module-level cache, which also fixes the useMemo([t]) reference thrash.

The i18n test contract (assert on the merged getTranslation result, raw locale may stay partial) is now self-consistent. No new issues introduced.

Approving.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Jun 26, 2026
@houko
houko merged commit 2f27879 into librefang:main Jun 26, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants