open-design/apps/web/src/i18n/index.tsx
lefarcen b2b94dbde7
feat(desktop): follow OS language in packaged builds (cherry-pick of #2544 into release/v0.8.0) (#2560)
* feat(desktop): follow OS language in packaged builds

Packaged Electron currently shows Open Design in en-US regardless of
the OS language setting, because the renderer's i18n picks its locale
from `navigator.language` and Chromium hard-codes that to en-US unless
the host process intervenes. Browser users and `tools-dev` users are
unaffected because their `navigator.language` already reflects the
OS / browser preference.

This change:

- Adds `applyOsLocaleSwitch(app)` in `@open-design/desktop/main`. It
  reads `app.getPreferredSystemLanguages()[0]` and (when called before
  Electron's `ready` event) points Chromium's `--lang` flag at it, so
  the renderer's `navigator.language` follows the OS. Safe to call
  more than once: `appendSwitch` is a no-op once `app.isReady()`.
- Calls the helper from both Electron entries: `apps/packaged` before
  its own `whenReady`, and `runDesktopMain` for tools-dev parity.
- Forwards the resolved locale through
  `BrowserWindow.webPreferences.additionalArguments` as
  `--od-os-locale=<bcp-47>`, parsed by the preload and exposed at
  `window.__od__.client.osLocale`. The host bridge type
  (`OpenDesignHostClient.osLocale`) is extended accordingly.
- Updates `detectInitialLocale` in `apps/web/src/i18n/index.tsx` to
  read that field as a new step between the existing localStorage and
  navigator fallbacks. Browser/web continues to fall through to
  `navigator.languages` unchanged.

The explicit `osLocale` channel exists in addition to `--lang` because
some `app.getPreferredSystemLanguages()` strings (e.g. `zh-Hant-TW`,
`pt-PT`) need to round-trip through `resolveSystemLocale` to land on
the right supported locale, which Chromium's `navigator.language`
cannot do on its own.

* fix(web): route OS locale read through getOpenDesignHost

The first cut of detectInitialLocale read `window.__od__.client.osLocale`
directly, which trips `tests/host-boundary.test.ts` — that guard test
keeps web source from referencing preload globals by name so the
boundary stays single-source. Switch to `getOpenDesignHost()` from
`@open-design/host`, and rewrite the i18n test to install the host via
`installMockOpenDesignHost` instead of poking the global directly.

* fix(tools-pack): unblock mac packaged build on pnpm workspaces

Two independent issues prevented `pnpm tools-pack mac build --to all`
from completing on a clean macOS workspace, both unrelated to the
desktop OS-locale change in this PR but bundled here because verifying
that change end-to-end required the packaged pipeline to actually
finish.

1. `apps/web/.next/standalone/node_modules/.pnpm/node_modules/<pkg>`
   contained dangling symlinks left by Next's nft trace (e.g. a
   `semver -> ../semver@5.7.2/node_modules/semver` link to a
   `.pnpm/<pkg>@<ver>` directory pnpm never created). The downstream
   `cp { dereference: true }` aborted the whole packaged pipeline
   with ENOENT. Walk every artifact tree before copy and unlink
   symlinks whose target doesn't resolve. Targets that *do* resolve
   stay untouched.

2. Next 16's standalone build under pnpm workspaces does not hoist
   peer-dep packages (react, react-dom, styled-jsx) into
   `<standalone>/apps/web/node_modules`. The downstream
   `web-standalone-after-pack.cjs` audit then does
   `createRequire(server.js).resolve('react/package.json')`, whose
   module walk falls out of the standalone tree and aborts the
   electron-builder phase. Add a `hoistStandaloneNextPeerDeps` step
   for the web standalone artifact only: it locates the
   `<pkg>@<version>` (not peer-resolved sibling) directory under
   `.pnpm` and symlinks it into `apps/web/node_modules/<pkg>`. The
   subsequent `cp { dereference: true }` then writes the real
   directory into the cache so the packaged tree stays self-contained.

Verified by `pnpm tools-pack mac build --to all` succeeding end-to-end
(zip + dmg + app), then `pnpm tools-pack mac install` and
`pnpm exec tools-pack mac inspect --expr` reading the desired
`__od__.client.osLocale` from the packaged renderer.

* feat(desktop): fold encodeURIComponent + manual locale source + pet window from #2554

Three defensive improvements lifted from @Eli-tangerine's parallel
implementation on #2554, kept consistent with the OS-locale chain
already on this branch:

- The argv value crossing main → preload is now wrapped with
  encodeURIComponent / decodeURIComponent so a locale string with `;`,
  `=`, or any other Chromium argv special char round-trips cleanly.
  BCP-47 region tags don't carry those today, but the renderer parser
  no longer has to assume it.
- `setLocale` now also writes `open-design:locale-source = "manual"`
  to localStorage, and `detectInitialLocale` only treats the stored
  locale as winning when that marker is present. An untagged value
  (left over from a future auto-write path, or a stale install) no
  longer pins the app to an old language once the host injects a
  fresh OS locale. Today `setLocale` is the only writer so the marker
  has no behaviour difference yet — this is a defensive net.
- `createDesktopPetWindow` now receives `osLocale` and forwards the
  same `additionalArguments` as the main `BrowserWindow`, so the
  pet renderer's `__od__.client.osLocale` is consistent with the main
  window's instead of being silently undefined.

Co-authored idea credit: changes mirror the locale-piece of
@Eli-tangerine on #2554 — that PR is closing in favour of this one.

Tests: detect-initial-locale gets a new "untagged localStorage value
loses to host locale" case. desktop 62/62, host 13/13, web i18n +
host-boundary 15/15 stay green.

* feat(web): fold onboarding view styles from #2554

Pulls the 747-line addition to `apps/web/src/styles/home/entry-layout.css`
from @Eli-tangerine's #2554 — the visual layer for the global onboarding
flow (`/onboarding` view, Connect / About-you / Design-system steps).
The view itself was already plumbed through `EntryShell.tsx`; this adds
the styling that makes it shippable on v0.8.0.

#2554 is closing in favour of this branch, so the CSS lands here so the
onboarding work doesn't get dropped on the floor.

Co-authored idea credit: @Eli-tangerine — original styling on #2554.

* fix(tools-pack): make hoistStandaloneNextPeerDeps idempotent across builds

Addresses non-blocking review by @PerishCode on #2560: the previous
`if (await pathExists(linkPath)) continue;` guard uses `access()`,
which follows symlinks. A stale symlink from a previous build whose
`.pnpm/<pkg>@<version>` target moved (e.g. after a react/react-dom
version bump that invalidates the workspace-build cache key and forces
a re-run) reports as missing through `pathExists`, then `symlink()`
rejects with EEXIST and the unhandled rejection aborts the packaged
build.

Switch to `lstat` (which does not follow the link) so we can tell
"genuinely empty slot", "real directory left by Next" and "stale
symlink" apart, then unlink stale entries before re-creating. Also
move `stripBrokenSymlinks` ahead of `hoistStandaloneNextPeerDeps` in
`copyWorkspaceBuildArtifactsToCache` so any leftover dangling links
that survived a previous run are cleared before hoist tries to write.
2026-05-21 18:23:20 +08:00

229 lines
7.2 KiB
TypeScript

'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { de } from './locales/de';
import { en } from './locales/en';
import { id } from './locales/id';
import { esES } from './locales/es-ES';
import { fa } from './locales/fa';
import { ar } from './locales/ar';
import { ja } from './locales/ja';
import { ko } from './locales/ko';
import { ptBR } from './locales/pt-BR';
import { ru } from './locales/ru';
import { zhCN } from './locales/zh-CN';
import { zhTW } from './locales/zh-TW';
import { pl } from './locales/pl';
import { hu } from './locales/hu';
import { fr } from './locales/fr';
import { uk } from './locales/uk';
import { tr } from './locales/tr';
import { th } from './locales/th';
import { it } from './locales/it';
import { getOpenDesignHost } from '@open-design/host';
import { LOCALES, type Dict, type Locale } from './types';
export { LOCALES, LOCALE_LABEL } from './types';
export type { Locale } from './types';
type DictKey = keyof Dict;
const DICTS: Record<Locale, Dict> = {
'en': en,
'id': id,
'de': de,
'zh-CN': zhCN,
'zh-TW': zhTW,
'pt-BR': ptBR,
'es-ES': esES,
'ru': ru,
'fa': fa,
'ar': ar,
'ja': ja,
'ko': ko,
'pl': pl,
'hu': hu,
'fr': fr,
'uk': uk,
'tr': tr,
'th': th,
'it': it,
};
const LS_KEY = 'open-design:locale';
// Marker that says "the value in LS_KEY came from a deliberate user
// action through setLocale, not from some auto-detection path". Only
// values tagged this way win over the desktop host's injected OS
// locale, so a stale auto-detected pick can't pin the app forever once
// the user changes their system language.
const LS_SOURCE_KEY = 'open-design:locale-source';
const MANUAL_LOCALE_SOURCE = 'manual';
export function resolveSystemLocale(languages: readonly string[]): Locale | null {
const supported = LOCALES as readonly string[];
for (const raw of languages) {
const normalized = raw.trim();
if (!normalized) continue;
const exact = LOCALES.find((locale) => locale.toLowerCase() === normalized.toLowerCase());
if (exact) return exact;
const [language, regionOrScript] = normalized.toLowerCase().split('-');
if (language === 'zh') {
if (regionOrScript === 'hant' || regionOrScript === 'tw' || regionOrScript === 'hk' || regionOrScript === 'mo') {
return 'zh-TW';
}
return 'zh-CN';
}
const baseMatch = LOCALES.find((locale) => locale.toLowerCase().split('-')[0] === language);
if (baseMatch && supported.includes(baseMatch)) return baseMatch;
}
return null;
}
// Read the OS locale the desktop host attached to its client descriptor.
// Packaged desktop builds need this because Chromium otherwise reports
// en-US through navigator.language regardless of the OS setting. We go
// through `getOpenDesignHost` rather than reading the bridge global by
// name so the web/preload boundary stays single-source (see the
// `host bridge boundary` guard test).
function readDesktopHostOsLocale(): string | undefined {
if (typeof window === 'undefined') return undefined;
const host = getOpenDesignHost();
const value = host?.client?.osLocale;
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
// First-run defaults to the user's OS / browser language when possible.
// Priority: explicit user pick saved to localStorage (only when tagged
// as manual) > OS locale that the desktop host injected (packaged
// Electron) > navigator.languages > 'en'. The source tag matters
// because untagged localStorage values are treated as legacy /
// auto-detected — they don't override a fresh OS locale read.
// Exported so tests can pin the priority chain without spinning up the
// full I18nProvider.
export function detectInitialLocale(): Locale {
if (typeof window === 'undefined') return 'en';
let storedLocale: string | null = null;
let storedSource: string | null = null;
try {
storedLocale = window.localStorage.getItem(LS_KEY);
storedSource = window.localStorage.getItem(LS_SOURCE_KEY);
} catch {
/* ignore */
}
if (
storedSource === MANUAL_LOCALE_SOURCE &&
storedLocale &&
(LOCALES as string[]).includes(storedLocale)
) {
return storedLocale as Locale;
}
const hostOsLocale = readDesktopHostOsLocale();
if (hostOsLocale) {
const fromHost = resolveSystemLocale([hostOsLocale]);
if (fromHost) return fromHost;
}
const detected = resolveSystemLocale(
navigator.languages?.length ? navigator.languages : [navigator.language],
);
return detected ?? 'en';
}
interface I18nContextValue {
locale: Locale;
setLocale: (next: Locale) => void;
t: (key: DictKey, vars?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
interface ProviderProps {
initial?: Locale;
children: ReactNode;
}
const RTL_LOCALES: Locale[] = ['ar', 'fa'];
export function I18nProvider({ initial, children }: ProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() => initial ?? detectInitialLocale());
// Keep <html lang="…" dir="…"> in sync so screen readers and CSS hooks
// pick the right language token and direction without each component
// having to set it itself.
useEffect(() => {
if (typeof document !== 'undefined') {
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
document.documentElement.setAttribute('lang', locale);
document.documentElement.setAttribute('dir', dir);
}
}, [locale]);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
try {
window.localStorage.setItem(LS_KEY, next);
// Marker so detectInitialLocale knows this came from a deliberate
// user action and should beat the desktop host's OS locale.
window.localStorage.setItem(LS_SOURCE_KEY, MANUAL_LOCALE_SOURCE);
} catch {
/* ignore */
}
}, []);
const t = useCallback(
(key: DictKey, vars?: Record<string, string | number>): string => {
const dict = DICTS[locale] ?? en;
const raw = dict[key] ?? en[key] ?? key;
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, name: string) => {
const v = vars[name];
return v == null ? `{${name}}` : String(v);
});
},
[locale],
);
const value = useMemo<I18nContextValue>(
() => ({ locale, setLocale, t }),
[locale, setLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
// Fall back to a stand-alone English translator when no provider is
// mounted (e.g. an isolated test). This keeps the API safe to call
// without requiring every callsite to wrap in a provider.
return {
locale: 'en',
setLocale: () => { },
t: (key, vars) => {
const raw = en[key] ?? key;
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, n: string) => {
const v = vars[n];
return v == null ? `{${n}}` : String(v);
});
},
};
}
return ctx;
}
// Convenience for components that only need the translator function.
export function useT(): I18nContextValue['t'] {
return useI18n().t;
}