diff --git a/.eslintignore b/.eslintignore index 45ea17508b..ebf0e377bd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,4 +12,5 @@ static web-static public packages/frontend/i18n/src/i18n-generated.ts +packages/frontend/i18n/src/i18n-completenesses.json packages/frontend/templates/*.gen.ts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index ee12f0da9a..6758efdbe3 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -90,7 +90,7 @@ jobs: electron-install: false full-cache: true - name: Run i18n codegen - run: yarn i18n-codegen gen + run: yarn workspace @affine/i18n build - name: Run ESLint run: yarn lint:eslint --max-warnings=0 - name: Run Prettier diff --git a/.gitignore b/.gitignore index 719698ceea..a2a3f8492a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,6 @@ Thumbs.db .vercel out/ storybook-static -i18n-generated.ts test-results playwright-report diff --git a/.prettierignore b/.prettierignore index 5854025311..0c4099d55b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,7 @@ public packages/backend/server/src/schema.gql packages/backend/server/src/fundamentals/error/errors.gen.ts packages/frontend/i18n/src/i18n-generated.ts +packages/frontend/i18n/src/i18n-completenesses.json packages/frontend/graphql/src/graphql/index.ts tests/affine-legacy/**/static .yarnrc.yml diff --git a/packages/frontend/apps/electron/renderer/app.tsx b/packages/frontend/apps/electron/renderer/app.tsx index a78f8f67df..f8c0afc251 100644 --- a/packages/frontend/apps/electron/renderer/app.tsx +++ b/packages/frontend/apps/electron/renderer/app.tsx @@ -6,6 +6,7 @@ import { Telemetry } from '@affine/core/components/telemetry'; import { router } from '@affine/core/desktop/router'; import { configureCommonModules } from '@affine/core/modules'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; +import { I18nProvider } from '@affine/core/modules/i18n'; import { configureElectronStateStorageImpls } from '@affine/core/modules/storage'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; import { configureSqliteUserspaceStorageProvider } from '@affine/core/modules/userspace'; @@ -15,7 +16,6 @@ import { configureSqliteWorkspaceEngineStorageProvider, } from '@affine/core/modules/workspace-engine'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; -import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; import { Framework, @@ -50,15 +50,6 @@ const future = { v7_startTransition: true, } as const; -async function loadLanguage() { - const i18n = createI18n(); - document.documentElement.lang = i18n.language; - - await setUpLanguage(i18n); -} - -let languageLoadingPromise: Promise | null = null; - const framework = new Framework(); configureCommonModules(framework); configureElectronStateStorageImpls(framework); @@ -76,29 +67,27 @@ window.addEventListener('focus', () => { frameworkProvider.get(LifecycleService).applicationStart(); export function App() { - if (!languageLoadingPromise) { - languageLoadingPromise = loadLanguage().catch(console.error); - } - return ( - - - - - } - router={router} - future={future} - /> - {environment.isWindows && ( -
- -
- )} -
+ + + + + + } + router={router} + future={future} + /> + {environment.isWindows && ( +
+ +
+ )} +
+
diff --git a/packages/frontend/apps/electron/renderer/shell/index.tsx b/packages/frontend/apps/electron/renderer/shell/index.tsx index 3926f92339..00ae860c9c 100644 --- a/packages/frontend/apps/electron/renderer/shell/index.tsx +++ b/packages/frontend/apps/electron/renderer/shell/index.tsx @@ -1,19 +1,11 @@ import './setup'; import { apis, events } from '@affine/electron-api'; -import { createI18n, setUpLanguage } from '@affine/i18n'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app'; -function loadLanguage() { - const i18n = createI18n(); - document.documentElement.lang = i18n.language; - - setUpLanguage(i18n).catch(console.error); -} - async function main() { const handleMaximized = (maximized: boolean | undefined) => { document.documentElement.dataset.maximized = String(maximized); @@ -31,7 +23,6 @@ async function main() { events?.ui.onFullScreen(handleFullscreen); events?.ui.onTabShellViewActiveChange(handleActive); - loadLanguage(); mountApp(); } diff --git a/packages/frontend/apps/mobile/src/app.tsx b/packages/frontend/apps/mobile/src/app.tsx index 6c5b3a707d..4f88ff26cc 100644 --- a/packages/frontend/apps/mobile/src/app.tsx +++ b/packages/frontend/apps/mobile/src/app.tsx @@ -4,6 +4,7 @@ import { Telemetry } from '@affine/core/components/telemetry'; import { configureMobileModules } from '@affine/core/mobile/modules'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; +import { I18nProvider } from '@affine/core/modules/i18n'; import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage'; import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace'; import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench'; @@ -11,7 +12,6 @@ import { configureBrowserWorkspaceFlavours, configureIndexedDBWorkspaceEngineStorageProvider, } from '@affine/core/modules/workspace-engine'; -import { createI18n, setUpLanguage } from '@affine/i18n'; import { Framework, FrameworkRoot, @@ -25,15 +25,6 @@ const future = { v7_startTransition: true, } as const; -async function loadLanguage() { - const i18n = createI18n(); - document.documentElement.lang = i18n.language; - - await setUpLanguage(i18n); -} - -let languageLoadingPromise: Promise | null = null; - const framework = new Framework(); configureCommonModules(framework); configureBrowserWorkbenchModule(framework); @@ -51,21 +42,19 @@ window.addEventListener('focus', () => { frameworkProvider.get(LifecycleService).applicationStart(); export function App() { - if (!languageLoadingPromise) { - languageLoadingPromise = loadLanguage().catch(console.error); - } - return ( - - - } - router={router} - future={future} - /> - + + + + } + router={router} + future={future} + /> + + ); diff --git a/packages/frontend/apps/web/src/app.tsx b/packages/frontend/apps/web/src/app.tsx index 53d4c4fa75..daf97ccd3c 100644 --- a/packages/frontend/apps/web/src/app.tsx +++ b/packages/frontend/apps/web/src/app.tsx @@ -4,6 +4,7 @@ import { AppFallback } from '@affine/core/components/affine/app-container'; import { Telemetry } from '@affine/core/components/telemetry'; import { router } from '@affine/core/desktop/router'; import { configureCommonModules } from '@affine/core/modules'; +import { I18nProvider } from '@affine/core/modules/i18n'; import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace'; @@ -13,7 +14,6 @@ import { configureIndexedDBWorkspaceEngineStorageProvider, } from '@affine/core/modules/workspace-engine'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; -import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; import { Framework, @@ -30,15 +30,6 @@ const future = { v7_startTransition: true, } as const; -async function loadLanguage() { - const i18n = createI18n(); - document.documentElement.lang = i18n.language; - - await setUpLanguage(i18n); -} - -let languageLoadingPromise: Promise | null = null; - const framework = new Framework(); configureCommonModules(framework); configureBrowserWorkbenchModule(framework); @@ -55,24 +46,22 @@ window.addEventListener('focus', () => { frameworkProvider.get(LifecycleService).applicationStart(); export function App() { - if (!languageLoadingPromise) { - languageLoadingPromise = loadLanguage().catch(console.error); - } - return ( - - - - - } - router={router} - future={future} - /> - + + + + + + } + router={router} + future={future} + /> + + diff --git a/packages/frontend/core/src/commands/affine-i18n.tsx b/packages/frontend/core/src/commands/affine-i18n.tsx new file mode 100644 index 0000000000..4c0375ebff --- /dev/null +++ b/packages/frontend/core/src/commands/affine-i18n.tsx @@ -0,0 +1,40 @@ +import type { I18n } from '@affine/core/modules/i18n'; +import type { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { SettingsIcon } from '@blocksuite/icons/rc'; + +import { registerAffineCommand } from './registry'; + +export function registerAffineLanguageCommands({ + i18n, + t, +}: { + i18n: I18n; + t: ReturnType; +}) { + // Display Language + const disposables = i18n.languageList.map(language => { + return registerAffineCommand({ + id: `affine:change-display-language-to-${language.name}`, + label: `${t['com.affine.cmdk.affine.display-language.to']()} ${ + language.originalName + }`, + category: 'affine:settings', + icon: , + preconditionStrategy: () => + i18n.currentLanguage$.value.key !== language.key, + run() { + track.$.cmdk.settings.changeAppSetting({ + key: 'language', + value: language.name, + }); + + i18n.changeLanguage(language.key); + }, + }); + }); + + return () => { + disposables.forEach(dispose => dispose()); + }; +} diff --git a/packages/frontend/core/src/commands/affine-settings.tsx b/packages/frontend/core/src/commands/affine-settings.tsx index 193a80b8e8..1fd05bc785 100644 --- a/packages/frontend/core/src/commands/affine-settings.tsx +++ b/packages/frontend/core/src/commands/affine-settings.tsx @@ -5,7 +5,6 @@ import { appSettingAtom } from '@toeverything/infra'; import type { createStore } from 'jotai'; import type { useTheme } from 'next-themes'; -import type { useLanguageHelper } from '../components/hooks/affine/use-language-helper'; import type { EditorSettingService } from '../modules/editor-settting'; import { registerAffineCommand } from './registry'; @@ -13,17 +12,14 @@ export function registerAffineSettingsCommands({ t, store, theme, - languageHelper, editorSettingService, }: { t: ReturnType; store: ReturnType; theme: ReturnType; - languageHelper: ReturnType; editorSettingService: EditorSettingService; }) { const unsubs: Array<() => void> = []; - const { onLanguageChange, languagesList, currentLanguage } = languageHelper; const updateSettings = editorSettingService.editorSetting.set.bind( editorSettingService.editorSetting ); @@ -148,29 +144,6 @@ export function registerAffineSettingsCommands({ }) ); - // Display Language - languagesList.forEach(language => { - unsubs.push( - registerAffineCommand({ - id: `affine:change-display-language-to-${language.name}`, - label: `${t['com.affine.cmdk.affine.display-language.to']()} ${ - language.originalName - }`, - category: 'affine:settings', - icon: , - preconditionStrategy: () => currentLanguage?.tag !== language.tag, - run() { - track.$.cmdk.settings.changeAppSetting({ - key: 'language', - value: language.name, - }); - - onLanguageChange(language.tag); - }, - }) - ); - }); - // Layout Style unsubs.push( registerAffineCommand({ diff --git a/packages/frontend/core/src/commands/index.ts b/packages/frontend/core/src/commands/index.ts index 8a496f1453..8736466d11 100644 --- a/packages/frontend/core/src/commands/index.ts +++ b/packages/frontend/core/src/commands/index.ts @@ -1,5 +1,6 @@ export * from './affine-creation'; export * from './affine-help'; +export * from './affine-i18n'; export * from './affine-layout'; export * from './affine-navigation'; export * from './affine-settings'; diff --git a/packages/frontend/core/src/components/affine/language-menu/index.tsx b/packages/frontend/core/src/components/affine/language-menu/index.tsx index 8170c17113..4bddfc0045 100644 --- a/packages/frontend/core/src/components/affine/language-menu/index.tsx +++ b/packages/frontend/core/src/components/affine/language-menu/index.tsx @@ -1,34 +1,38 @@ import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu'; -import { calcLocaleCompleteness } from '@affine/i18n'; +import { type I18n, I18nService } from '@affine/core/modules/i18n'; import { DoneIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; import type { ReactElement } from 'react'; import { memo } from 'react'; -import { useLanguageHelper } from '../../../components/hooks/affine/use-language-helper'; import * as styles from './style.css'; // Fixme: keyboard focus should be supported by Menu component -const LanguageMenuContent = memo(function LanguageMenuContent() { - const { currentLanguage, languagesList, onLanguageChange } = - useLanguageHelper(); - +const LanguageMenuContent = memo(function LanguageMenuContent({ + current, + onChange, + i18n, +}: { + i18n: I18n; + current: string; + onChange: (value: string) => void; +}) { return ( <> - {languagesList.map(option => { - const selected = currentLanguage?.originalName === option.originalName; - const completeness = calcLocaleCompleteness(option.tag); + {i18n.languageList.map(lang => { + const selected = current === lang.key; return ( onLanguageChange(option.tag)} - suffix={(completeness * 100).toFixed(0) + '%'} + key={lang.name} + title={lang.name} + lang={lang.key} + onSelect={() => onChange(lang.key)} + suffix={lang.completeness + '%'} data-selected={selected} className={styles.menuItem} >
-
{option.originalName}
+
{lang.originalName}
{selected && }
@@ -39,10 +43,20 @@ const LanguageMenuContent = memo(function LanguageMenuContent() { }); export const LanguageMenu = () => { - const { currentLanguage } = useLanguageHelper(); + const i18n = useService(I18nService).i18n; + const currentLanguage = useLiveData(i18n.currentLanguage$); + return ( ) as ReactElement} + items={ + ( + + ) as ReactElement + } contentOptions={{ className: styles.menu, align: 'end', @@ -53,7 +67,7 @@ export const LanguageMenu = () => { style={{ textTransform: 'capitalize', fontWeight: 600, width: '250px' }} block={true} > - {currentLanguage?.originalName || ''} + {currentLanguage.originalName} ); diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index eb083cd430..138d3dbeb2 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -33,7 +33,7 @@ export function AffinePageReference({ }) { const docDisplayMetaService = useService(DocDisplayMetaService); const journalHelper = useJournalInfoHelper(); - const t = useI18n(); + const i18n = useI18n(); let linkWithMode: DocMode | null = null; let linkToNode = false; @@ -59,9 +59,7 @@ export function AffinePageReference({ const el = ( <> - - {typeof title === 'string' ? title : t[title.key]()} - + {i18n.t(title)} ); @@ -132,7 +130,7 @@ export function AffineSharedPageReference({ }) { const docDisplayMetaService = useService(DocDisplayMetaService); const journalHelper = useJournalInfoHelper(); - const t = useI18n(); + const i18n = useI18n(); let linkWithMode: DocMode | null = null; let linkToNode = false; @@ -155,9 +153,7 @@ export function AffineSharedPageReference({ const el = ( <> - - {typeof title === 'string' ? title : t[title.key]()} - + {i18n.t(title)} ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index ed253c8de0..a9d406fe0c 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -23,7 +23,7 @@ import { SubscriptionStatus, UserFriendlyError, } from '@affine/graphql'; -import { i18nTime, Trans, useI18n } from '@affine/i18n'; +import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { ArrowRightSmallIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; @@ -43,17 +43,19 @@ import { BelieverCard } from '../plans/lifetime/believer-card'; import { BelieverBenefits } from '../plans/lifetime/benefits'; import * as styles from './style.css'; -enum DescriptionI18NKey { - Basic = 'com.affine.payment.billing-setting.current-plan.description', - Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly', - Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly', - Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime', -} +const DescriptionI18NKey = { + Basic: 'com.affine.payment.billing-setting.current-plan.description', + Monthly: + 'com.affine.payment.billing-setting.current-plan.description.monthly', + Yearly: 'com.affine.payment.billing-setting.current-plan.description.yearly', + Lifetime: + 'com.affine.payment.billing-setting.current-plan.description.lifetime', +} as const satisfies { [key: string]: I18nString }; const getMessageKey = ( plan: SubscriptionPlan, recurring: SubscriptionRecurring -): DescriptionI18NKey => { +) => { if (plan !== SubscriptionPlan.Pro) { return DescriptionI18NKey.Basic; } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx index 5728825224..3c2e5d381a 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx @@ -370,10 +370,10 @@ export function patchQuickSearchService(framework: FrameworkProvider) { }, { label: { - key: 'com.affine.cmdk.insert-links', + i18nKey: 'com.affine.cmdk.insert-links', }, placeholder: { - key: 'com.affine.cmdk.docs.placeholder', + i18nKey: 'com.affine.cmdk.docs.placeholder', }, } ) diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts index 124569e3d4..a1f1adf1e1 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts @@ -39,7 +39,7 @@ export function createLinkedWidgetConfig( }).value; return { ...meta, - title: typeof title === 'string' ? title : I18n[title.key](), + title: I18n.t(title), }; }) .filter(({ title }) => isFuzzyMatch(title, query)); diff --git a/packages/frontend/core/src/components/hooks/affine/use-language-helper.ts b/packages/frontend/core/src/components/hooks/affine/use-language-helper.ts deleted file mode 100644 index bd0c5bfb72..0000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-language-helper.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { LOCALES, useI18n } from '@affine/i18n'; -import { useEffect, useMemo } from 'react'; - -export function useLanguageHelper() { - const i18n = useI18n(); - const currentLanguage = useMemo( - () => LOCALES.find(item => item.tag === i18n.language), - [i18n.language] - ); - const languagesList = useMemo( - () => - LOCALES.map(item => ({ - tag: item.tag, - originalName: item.originalName, - name: item.name, - })), - [] - ); - const onLanguageChange = useAsyncCallback( - async (event: string) => { - await i18n.changeLanguage(event); - }, - [i18n] - ); - - useEffect(() => { - if (currentLanguage) { - document.documentElement.lang = currentLanguage.tag; - } - }, [currentLanguage]); - - return useMemo( - () => ({ - currentLanguage, - languagesList, - onLanguageChange, - }), - [currentLanguage, languagesList, onLanguageChange] - ); -} diff --git a/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts index f80f383f4b..bc411187ba 100644 --- a/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/components/hooks/use-register-workspace-commands.ts @@ -1,4 +1,5 @@ import { AppSidebarService } from '@affine/core/modules/app-sidebar'; +import { I18nService } from '@affine/core/modules/i18n'; import { useI18n } from '@affine/i18n'; import type { AffineEditorContainer } from '@blocksuite/affine/presets'; import { useService, WorkspaceService } from '@toeverything/infra'; @@ -11,6 +12,7 @@ import { registerAffineCommand, registerAffineCreationCommands, registerAffineHelpCommands, + registerAffineLanguageCommands, registerAffineLayoutCommands, registerAffineNavigationCommands, registerAffineSettingsCommands, @@ -20,7 +22,6 @@ import { usePageHelper } from '../../components/blocksuite/block-suite-page-list import { CreateWorkspaceDialogService } from '../../modules/create-workspace'; import { EditorSettingService } from '../../modules/editor-settting'; import { CMDKQuickSearchService } from '../../modules/quicksearch/services/cmdk'; -import { useLanguageHelper } from './affine/use-language-helper'; import { useActiveBlocksuiteEditor } from './use-block-suite-editor'; import { useNavigateHelper } from './use-navigate-helper'; @@ -65,7 +66,6 @@ export function useRegisterWorkspaceCommands() { const t = useI18n(); const theme = useTheme(); const currentWorkspace = useService(WorkspaceService).workspace; - const languageHelper = useLanguageHelper(); const pageHelper = usePageHelper(currentWorkspace.docCollection); const navigationHelper = useNavigateHelper(); const [editor] = useActiveBlocksuiteEditor(); @@ -73,6 +73,7 @@ export function useRegisterWorkspaceCommands() { const editorSettingService = useService(EditorSettingService); const createWorkspaceDialogService = useService(CreateWorkspaceDialogService); const appSidebarService = useService(AppSidebarService); + const i18n = useService(I18nService).i18n; useEffect(() => { const unsub = registerCMDKCommand(cmdkQuickSearchService, editor); @@ -114,14 +115,25 @@ export function useRegisterWorkspaceCommands() { store, t, theme, - languageHelper, editorSettingService, }); return () => { unsub(); }; - }, [editorSettingService, languageHelper, store, t, theme]); + }, [editorSettingService, store, t, theme]); + + // register AffineLanguageCommands + useEffect(() => { + const unsub = registerAffineLanguageCommands({ + i18n, + t, + }); + + return () => { + unsub(); + }; + }, [i18n, t]); // register AffineLayoutCommands useEffect(() => { diff --git a/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx b/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx index 7ee1666b63..9644a1df71 100644 --- a/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx +++ b/packages/frontend/core/src/components/page-list/__tests__/filter.spec.tsx @@ -10,7 +10,7 @@ import type { Ref, VariableMap, } from '@affine/env/filter'; -import { createI18n, I18nextProvider } from '@affine/i18n'; +import { getOrCreateI18n, I18nextProvider } from '@affine/i18n'; import { assertExists } from '@blocksuite/affine/global/utils'; import { render } from '@testing-library/react'; import type { ReactElement } from 'react'; @@ -128,8 +128,8 @@ describe('eval filter', () => { describe('render filter', () => { test('boolean condition value change', async () => { - const i18n = createI18n(); const is = filterMatcher.match(tBoolean.create()); + const i18n = getOrCreateI18n(); assertExists(is); const Wrapper = () => { const [value, onChange] = useState( diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 399bce1b49..195be47009 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -278,10 +278,10 @@ function tagIdToTagOption( } const PageTitle = ({ id }: { id: string }) => { - const t = useI18n(); + const i18n = useI18n(); const docDisplayMetaService = useService(DocDisplayMetaService); const title = useLiveData(docDisplayMetaService.title$(id)); - return typeof title === 'string' ? title : t[title.key](); + return i18n.t(title); }; const UnifiedPageIcon = ({ id }: { id: string }) => { diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx index ff836cd940..df892b0e6a 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal.tsx @@ -43,13 +43,13 @@ interface PageItemProps right?: ReactNode; } const PageItem = ({ docId, right, className, ...attrs }: PageItemProps) => { - const t = useI18n(); + const i18n = useI18n(); const docDisplayMetaService = useService(DocDisplayMetaService); const Icon = useLiveData( docDisplayMetaService.icon$(docId, { compareDate: new Date() }) ); const titleMeta = useLiveData(docDisplayMetaService.title$(docId)); - const title = typeof titleMeta === 'string' ? titleMeta : t[titleMeta.key](); + const title = i18n.t(titleMeta); return ( { const i18n = useI18n(); - const text = !isI18nString(item.label) - ? i18n.t(item.label.title) - : i18n.t(item.label); - return ; + return ( + + ); }; diff --git a/packages/frontend/core/src/mobile/views/settings/appearance/language.tsx b/packages/frontend/core/src/mobile/views/settings/appearance/language.tsx index 01e4a7735b..ff075bfb86 100644 --- a/packages/frontend/core/src/mobile/views/settings/appearance/language.tsx +++ b/packages/frontend/core/src/mobile/views/settings/appearance/language.tsx @@ -1,5 +1,6 @@ -import { useLanguageHelper } from '@affine/core/components/hooks/affine/use-language-helper'; +import { I18nService } from '@affine/core/modules/i18n'; import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; import { useMemo } from 'react'; import { SettingDropdownSelect } from '../dropdown-select'; @@ -7,24 +8,24 @@ import { RowLayout } from '../row.layout'; export const LanguageSetting = () => { const t = useI18n(); - const { currentLanguage, languagesList, onLanguageChange } = - useLanguageHelper(); + const i18n = useService(I18nService).i18n; + const currentLanguage = useLiveData(i18n.currentLanguage$); const languageOptions = useMemo( () => - languagesList.map(language => ({ + i18n.languageList.map(language => ({ label: language.originalName, - value: language.tag, + value: language.key, })), - [languagesList] + [i18n] ); return ( { + i18n.init(); + }, [i18n]); + + return {children}; +} diff --git a/packages/frontend/core/src/modules/i18n/entities/i18n.ts b/packages/frontend/core/src/modules/i18n/entities/i18n.ts new file mode 100644 index 0000000000..83991f3519 --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/entities/i18n.ts @@ -0,0 +1,83 @@ +import { notify } from '@affine/component'; +import { DebugLogger } from '@affine/debug'; +import { + getOrCreateI18n, + i18nCompletenesses, + type Language, + SUPPORTED_LANGUAGES, +} from '@affine/i18n'; +import type { GlobalCache } from '@toeverything/infra'; +import { effect, Entity, fromPromise, LiveData } from '@toeverything/infra'; +import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs'; + +export type LanguageInfo = { + key: Language; + name: string; + originalName: string; + completeness: number; +}; + +const logger = new DebugLogger('i18n'); + +function mapLanguageInfo(language: Language = 'en'): LanguageInfo { + const languageInfo = SUPPORTED_LANGUAGES[language]; + + return { + key: language, + name: languageInfo.name, + originalName: languageInfo.originalName, + completeness: i18nCompletenesses[language], + }; +} + +export class I18n extends Entity { + private readonly i18n = getOrCreateI18n(); + + get i18next() { + return this.i18n; + } + + readonly currentLanguageKey$ = LiveData.from( + this.cache.watch('i18n_lng'), + undefined + ); + + readonly currentLanguage$ = this.currentLanguageKey$ + .distinctUntilChanged() + .map(mapLanguageInfo); + + readonly languageList: Array = + // @ts-expect-error same key indexing + Object.keys(SUPPORTED_LANGUAGES).map(mapLanguageInfo); + + constructor(private readonly cache: GlobalCache) { + super(); + this.i18n.on('languageChanged', (language: Language) => { + document.documentElement.lang = language; + this.cache.set('i18n_lng', language); + }); + } + + init() { + this.changeLanguage(this.currentLanguageKey$.value ?? 'en'); + } + + changeLanguage = effect( + exhaustMap((language: string) => + fromPromise(() => this.i18n.changeLanguage(language)).pipe( + catchError(error => { + notify({ + theme: 'error', + title: 'Failed to change language', + message: 'Error occurs when loading language files', + }); + + logger.error('Failed to change language', error); + + return EMPTY; + }), + mergeMap(() => EMPTY) + ) + ) + ); +} diff --git a/packages/frontend/core/src/modules/i18n/index.ts b/packages/frontend/core/src/modules/i18n/index.ts new file mode 100644 index 0000000000..9f031446e4 --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/index.ts @@ -0,0 +1,11 @@ +import { type Framework, GlobalCache } from '@toeverything/infra'; + +import { I18nProvider } from './context'; +import { I18n, type LanguageInfo } from './entities/i18n'; +import { I18nService } from './services/i18n'; + +export function configureI18nModule(framework: Framework) { + framework.service(I18nService).entity(I18n, [GlobalCache]); +} + +export { I18n, I18nProvider, I18nService, type LanguageInfo }; diff --git a/packages/frontend/core/src/modules/i18n/services/i18n.ts b/packages/frontend/core/src/modules/i18n/services/i18n.ts new file mode 100644 index 0000000000..d36e392b07 --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/services/i18n.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { I18n } from '../entities/i18n'; + +export class I18nService extends Service { + public readonly i18n = this.framework.createEntity(I18n); +} diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index c52c397747..90297ef27a 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -14,6 +14,7 @@ import { configureEditorSettingModule } from './editor-settting'; import { configureExplorerModule } from './explorer'; import { configureFavoriteModule } from './favorite'; import { configureFindInPageModule } from './find-in-page'; +import { configureI18nModule } from './i18n'; import { configureImportTemplateModule } from './import-template'; import { configureNavigationModule } from './navigation'; import { configureOrganizeModule } from './organize'; @@ -30,6 +31,7 @@ import { configureThemeEditorModule } from './theme-editor'; import { configureUserspaceModule } from './userspace'; export function configureCommonModules(framework: Framework) { + configureI18nModule(framework); configureInfraModules(framework); configureCollectionModule(framework); configureNavigationModule(framework); diff --git a/packages/frontend/core/src/modules/quicksearch/impls/collections.ts b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts index 11fdb21c51..6e7a03c8c4 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/collections.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts @@ -11,7 +11,7 @@ import { highlighter } from '../utils/highlighter'; const group = { id: 'collections', label: { - key: 'com.affine.cmdk.affine.category.affine.collections', + i18nKey: 'com.affine.cmdk.affine.category.affine.collections', }, score: 10, } as QuickSearchGroup; @@ -60,7 +60,7 @@ export class CollectionsQuickSearchSession label: { title: (highlighter(item.name, '', '', titleMatches ?? []) ?? item.name) || { - key: 'Untitled', + i18nKey: 'Untitled', }, }, group, diff --git a/packages/frontend/core/src/modules/quicksearch/impls/commands.ts b/packages/frontend/core/src/modules/quicksearch/impls/commands.ts index 4d215e662c..e275a5a73a 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/commands.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/commands.ts @@ -17,81 +17,81 @@ import { highlighter } from '../utils/highlighter'; const categories = { 'affine:recent': { id: 'command:affine:recent', - label: { key: 'com.affine.cmdk.affine.category.affine.recent' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.recent' }, score: 10, }, 'affine:navigation': { id: 'command:affine:navigation', label: { - key: 'com.affine.cmdk.affine.category.affine.navigation', + i18nKey: 'com.affine.cmdk.affine.category.affine.navigation', }, score: 10, }, 'affine:creation': { id: 'command:affine:creation', - label: { key: 'com.affine.cmdk.affine.category.affine.creation' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.creation' }, score: 10, }, 'affine:general': { id: 'command:affine:general', - label: { key: 'com.affine.cmdk.affine.category.affine.general' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.general' }, score: 10, }, 'affine:layout': { id: 'command:affine:layout', - label: { key: 'com.affine.cmdk.affine.category.affine.layout' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.layout' }, score: 10, }, 'affine:pages': { id: 'command:affine:pages', - label: { key: 'com.affine.cmdk.affine.category.affine.pages' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.pages' }, score: 10, }, 'affine:edgeless': { id: 'command:affine:edgeless', - label: { key: 'com.affine.cmdk.affine.category.affine.edgeless' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.edgeless' }, score: 10, }, 'affine:collections': { id: 'command:affine:collections', label: { - key: 'com.affine.cmdk.affine.category.affine.collections', + i18nKey: 'com.affine.cmdk.affine.category.affine.collections', }, score: 10, }, 'affine:settings': { id: 'command:affine:settings', - label: { key: 'com.affine.cmdk.affine.category.affine.settings' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.settings' }, score: 10, }, 'affine:updates': { id: 'command:affine:updates', - label: { key: 'com.affine.cmdk.affine.category.affine.updates' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.updates' }, score: 10, }, 'affine:help': { id: 'command:affine:help', - label: { key: 'com.affine.cmdk.affine.category.affine.help' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.affine.help' }, score: 10, }, 'editor:edgeless': { id: 'command:editor:edgeless', - label: { key: 'com.affine.cmdk.affine.category.editor.edgeless' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.editor.edgeless' }, score: 10, }, 'editor:insert-object': { id: 'command:editor:insert-object', - label: { key: 'com.affine.cmdk.affine.category.editor.insert-object' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.editor.insert-object' }, score: 10, }, 'editor:page': { id: 'command:editor:page', - label: { key: 'com.affine.cmdk.affine.category.editor.page' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.editor.page' }, score: 10, }, 'affine:results': { id: 'command:affine:results', - label: { key: 'com.affine.cmdk.affine.category.results' }, + label: { i18nKey: 'com.affine.cmdk.affine.category.results' }, score: 10, }, } satisfies Required<{ diff --git a/packages/frontend/core/src/modules/quicksearch/impls/creation.ts b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts index 47ebc09358..0eaf2b5e4e 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/creation.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts @@ -8,7 +8,7 @@ import type { QuickSearchItem } from '../types/item'; const group = { id: 'creation', - label: { key: 'com.affine.quicksearch.group.creation' }, + label: { i18nKey: 'com.affine.quicksearch.group.creation' }, score: 0, } as QuickSearchGroup; @@ -30,7 +30,7 @@ export class CreationQuickSearchSession id: 'creation:create-page', source: 'creation', label: { - key: 'com.affine.cmdk.affine.create-new-page-as', + i18nKey: 'com.affine.cmdk.affine.create-new-page-as', options: { keyWord: query }, }, group, @@ -41,7 +41,7 @@ export class CreationQuickSearchSession id: 'creation:create-edgeless', source: 'creation', label: { - key: 'com.affine.cmdk.affine.create-new-edgeless-as', + i18nKey: 'com.affine.cmdk.affine.create-new-edgeless-as', options: { keyWord: query }, }, group, diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts index cb1cdb413f..64115daf5a 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -76,7 +76,7 @@ export class DocsQuickSearchSession group: { id: 'docs', label: { - key: 'com.affine.quicksearch.group.searchfor', + i18nKey: 'com.affine.quicksearch.group.searchfor', options: { query: truncate(query) }, }, score: 5, diff --git a/packages/frontend/core/src/modules/quicksearch/impls/external-links.ts b/packages/frontend/core/src/modules/quicksearch/impls/external-links.ts index e55fbc12e3..d65bc016ec 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/external-links.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/external-links.ts @@ -42,7 +42,7 @@ export class ExternalLinksQuickSearchSession source: 'external-link', icon: LinkIcon, label: { - key: 'com.affine.cmdk.affine.insert-link', + i18nKey: 'com.affine.cmdk.affine.insert-link', }, payload: { url: query }, } as QuickSearchItem<'external-link', ExternalLinkPayload>, diff --git a/packages/frontend/core/src/modules/quicksearch/impls/links.ts b/packages/frontend/core/src/modules/quicksearch/impls/links.ts index 50bfa12c74..756c152e1c 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/links.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/links.ts @@ -63,7 +63,7 @@ export class LinksQuickSearchSession group: { id: 'docs', label: { - key: 'com.affine.quicksearch.group.searchfor', + i18nKey: 'com.affine.quicksearch.group.searchfor', options: { query: truncate(query) }, }, score: 5, diff --git a/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts index cbc62bd6ec..02baae271f 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts @@ -9,7 +9,7 @@ import type { QuickSearchItem } from '../types/item'; const group = { id: 'recent-docs', label: { - key: 'com.affine.cmdk.affine.category.affine.recent', + i18nKey: 'com.affine.cmdk.affine.category.affine.recent', }, score: 15, } as QuickSearchGroup; diff --git a/packages/frontend/core/src/modules/quicksearch/impls/tags.ts b/packages/frontend/core/src/modules/quicksearch/impls/tags.ts index ce5610bddb..14ab98c4e4 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/tags.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/tags.ts @@ -11,7 +11,7 @@ import { QuickSearchTagIcon } from '../views/tag-icon'; const group: QuickSearchGroup = { id: 'tags', label: { - key: 'com.affine.cmdk.affine.category.affine.tags', + i18nKey: 'com.affine.cmdk.affine.category.affine.tags', }, score: 10, }; @@ -72,7 +72,7 @@ export class TagsQuickSearchSession titleMatches ?? [] ) ?? item.title) || { - key: 'Untitled', + i18nKey: 'Untitled', }, }, group, diff --git a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts index f2384fb794..c4bf6db966 100644 --- a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts +++ b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts @@ -118,7 +118,7 @@ export class CMDKQuickSearchService extends Service { }, { placeholder: { - key: 'com.affine.cmdk.docs.placeholder', + i18nKey: 'com.affine.cmdk.docs.placeholder', }, } ); diff --git a/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx index 2e58b4eda6..c215cae503 100644 --- a/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx +++ b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx @@ -232,12 +232,13 @@ export const CMDKGroup = ({ style={{ overflowAnchor: 'none' }} > {items.map(item => { - const title = !isI18nString(item.label) - ? i18n.t(item.label.title) - : i18n.t(item.label); - const subTitle = !isI18nString(item.label) - ? item.label.subTitle && i18n.t(item.label.subTitle) - : null; + const [title, subTitle] = isI18nString(item.label) + ? [i18n.t(item.label), null] + : [ + i18n.t(item.label.title), + item.label.subTitle ? i18n.t(item.label.subTitle) : null, + ]; + return ( file.endsWith('.json')) + .reduce((langs, file) => { + const filePath = `${resourcesDir}/${file}`; + const fileContent = JSON.parse(readFileSync(filePath, 'utf-8')); + langs[parse(file).name] = fileContent; + return langs; + }, {}); + + const base = Object.keys(langs.en).length; + + const completenesses = {}; + + for (const key in langs) { + const [langPart, variantPart] = key.split('-'); + + const completeness = Object.keys( + variantPart ? { ...langs[langPart], ...langs[key] } : langs[key] + ).length; + + completenesses[key] = Math.min( + Math.ceil(/* avoid 0% */ (completeness / base) * 100), + 100 + ); + } + + writeFileSync( + join(pkgRoot, 'src', 'i18n-completenesses.json'), + JSON.stringify(completenesses, null, 2) + ); +} + runCli( { - config: fileURLToPath( - new URL('../../../.i18n-codegen.json', import.meta.url) - ), - watch: false, + config: fileURLToPath(new URL('./.i18n-codegen.json', import.meta.url)), + watch: isDev, }, error => { console.error(error); - process.exit(1); + if (!isDev) { + process.exit(1); + } } ); + +calcCompletenesses(); diff --git a/packages/frontend/i18n/dev.mjs b/packages/frontend/i18n/dev.mjs deleted file mode 100644 index 8282704458..0000000000 --- a/packages/frontend/i18n/dev.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { fileURLToPath } from 'node:url'; - -import { runCli } from '@magic-works/i18n-codegen'; - -runCli( - { - config: fileURLToPath( - new URL('../../../.i18n-codegen.json', import.meta.url) - ), - watch: true, - }, - error => { - console.error(error); - } -); diff --git a/packages/frontend/i18n/package.json b/packages/frontend/i18n/package.json index 473691c523..0b7f27a539 100644 --- a/packages/frontend/i18n/package.json +++ b/packages/frontend/i18n/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build": "node build.mjs", - "dev": "node dev.mjs" + "dev": "node build.mjs --dev" }, "keywords": [], "repository": { @@ -16,6 +16,7 @@ "url": "git+https://github.com/toeverything/AFFiNE.git" }, "dependencies": { + "@affine/debug": "workspace:*", "@magic-works/i18n-codegen": "^0.6.0", "dayjs": "^1.11.11", "i18next": "^23.11.1", diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json new file mode 100644 index 0000000000..15d69be757 --- /dev/null +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -0,0 +1,22 @@ +{ + "ar": 87, + "ca": 6, + "da": 7, + "de": 33, + "en": 100, + "es-AR": 14, + "es-CL": 16, + "es": 14, + "fr": 78, + "hi": 2, + "it": 1, + "ja": 28, + "ko": 92, + "pl": 0, + "pt-BR": 100, + "ru": 85, + "sv-SE": 5, + "ur": 3, + "zh-Hans": 99, + "zh-Hant": 21 +} \ No newline at end of file diff --git a/packages/frontend/i18n/src/i18n.ts b/packages/frontend/i18n/src/i18n.ts deleted file mode 100644 index 453973d528..0000000000 --- a/packages/frontend/i18n/src/i18n.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useMemo } from 'react'; -import { getI18n, useTranslation } from 'react-i18next'; - -import type { useAFFiNEI18N } from './i18n-generated'; - -export type I18nFuncs = ReturnType; - -export type I18nInfos = { - [K in keyof I18nFuncs]: I18nFuncs[K] extends (...a: infer Opt) => any - ? Opt[0] - : never; -}; - -export type I18nKeys = keyof I18nInfos; - -export type I18nString = - | { - [K in I18nKeys]: { - key: K; - } & (I18nInfos[K] extends undefined - ? unknown - : { options: I18nInfos[K] }); - }[I18nKeys] - | string; - -export const isI18nString = (value: any): value is I18nString => { - return ( - typeof value === 'string' || (typeof value === 'object' && 'key' in value) - ); -}; - -function createI18nWrapper( - getI18nFn: () => ReturnType, - getI18nT: () => ReturnType['t'] -) { - const I18nMethod = { - t(i18nStr: I18nString) { - const i18n = getI18nFn(); - if (typeof i18nStr === 'object') { - return i18n.t(i18nStr.key, 'options' in i18nStr ? i18nStr.options : {}); - } - return i18nStr; - }, - get language() { - const i18n = getI18nFn(); - return i18n.language; - }, - changeLanguage(lng?: string | undefined) { - const i18n = getI18nFn(); - return i18n.changeLanguage(lng); - }, - get on() { - const i18n = getI18nFn(); - return i18n.on.bind(i18n); - }, - }; - - return new Proxy(I18nMethod, { - get(self, key) { - const i18n = getI18nFn(); - if (typeof key === 'string' && i18n.exists(key)) { - return getI18nT().bind(null, key as string); - } else { - return (self as any)[key as string] as any; - } - }, - }) as I18nFuncs & typeof I18nMethod; -} - -export const useI18n = () => { - const { i18n, t } = useTranslation(); - return useMemo( - () => - createI18nWrapper( - () => i18n, - () => t - ), - [i18n, t] - ); -}; - -/** - * I18n['com.affine.xxx']({ arg1: 'hello' }) -> '中文 hello' - */ -export const I18n = createI18nWrapper(getI18n, () => getI18n().t); -export type I18n = typeof I18n; diff --git a/packages/frontend/i18n/src/i18next.ts b/packages/frontend/i18n/src/i18next.ts new file mode 100644 index 0000000000..c5d5a35a3d --- /dev/null +++ b/packages/frontend/i18n/src/i18next.ts @@ -0,0 +1,161 @@ +import { DebugLogger } from '@affine/debug'; +import type { BackendModule, i18n } from 'i18next'; +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import type { useAFFiNEI18N } from './i18n-generated'; +import type { Language } from './resources'; +import { SUPPORTED_LANGUAGES } from './resources'; + +const logger = new DebugLogger('i18n'); + +const defaultLng: Language = 'en'; + +let _instance: i18n | null = null; +export const getOrCreateI18n = (): i18n => { + if (!_instance) { + _instance = i18next.createInstance(); + _instance + .use(initReactI18next) + .use({ + type: 'backend', + init: () => {}, + read: (lng: Language, _ns: string, callback) => { + const resource = SUPPORTED_LANGUAGES[lng].resource; + if (typeof resource === 'function') { + resource() + .then(data => { + logger.info(`Loaded i18n ${lng} resource`); + callback(null, data.default); + }) + .catch(err => { + logger.error(`Failed to load i18n ${lng} resource`, err); + callback(null, null); + }); + } else { + callback(null, resource); + } + }, + } as BackendModule) + .init({ + lng: defaultLng, + fallbackLng: code => { + // always fallback to english + const fallbacks: string[] = [defaultLng]; + const langPart = code.split('-')[0]; + + // fallback xx-YY to xx, e.g. es-AR to es + // fallback zh-Hant to zh-Hans + if (langPart === 'cn') { + fallbacks.push('zh-Hans'); + } else if ( + langPart !== code && + SUPPORTED_LANGUAGES[code as Language] + ) { + fallbacks.unshift(langPart); + } + + return fallbacks; + }, + supportedLngs: Object.keys(SUPPORTED_LANGUAGES), + debug: false, + partialBundledLanguages: true, + resources: { + [defaultLng]: { + translation: SUPPORTED_LANGUAGES[defaultLng].resource, + }, + }, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + }) + .then(() => { + logger.info('i18n initialized'); + }) + .catch(() => {}); + } + + return _instance; +}; + +declare module 'i18next' { + interface CustomTypeOptions { + // NOTE(@forehalo): + // DO NOT ENABLE THIS + // This could bring typecheck for component, + // but it will make typecheck of whole codebase so laggy! + // check [./react.ts] + // resources: { + // translation: LanguageResource; + // }; + } +} + +export type I18nFuncs = ReturnType; +type KnownI18nKey = keyof I18nFuncs; + +export type I18nString = + | KnownI18nKey + | string + | { i18nKey: string; options?: Record }; + +export function isI18nString(value: unknown): value is I18nString { + if (typeof value === 'string') { + return true; + } + + if (typeof value === 'object' && value !== null) { + return 'i18nKey' in value; + } + + return false; +} + +export function createI18nWrapper(getI18nFn: () => i18n) { + const I18nMethod = { + t(key: I18nString, options?: Record) { + if (typeof key === 'object' && 'i18nKey' in key) { + options = key.options; + key = key.i18nKey as string; + } + + const i18n = getI18nFn(); + if (i18n.exists(key)) { + return i18n.t(key, options); + } else { + // unknown translate key 'xxx.xxx' returns itself + return key; + } + }, + get language() { + const i18n = getI18nFn(); + return i18n.language; + }, + changeLanguage(lng?: string | undefined) { + const i18n = getI18nFn(); + return i18n.changeLanguage(lng); + }, + get on() { + const i18n = getI18nFn(); + return i18n.on.bind(i18n); + }, + }; + + return new Proxy(I18nMethod, { + get(self, key: string) { + if (key in self) { + // @ts-expect-error allow + return self[key]; + } + + return I18nMethod.t.bind(null, key); + }, + }) as typeof I18nMethod & + ReturnType & { [unknownKey: string]: () => string }; +} + +/** + * I18n['com.affine.xxx']({ arg1: 'hello' }) -> '中文 hello' + */ +export const I18n = createI18nWrapper(getOrCreateI18n); +export type I18n = typeof I18n; diff --git a/packages/frontend/i18n/src/index.ts b/packages/frontend/i18n/src/index.ts index c591ffdce4..e5086cca60 100644 --- a/packages/frontend/i18n/src/index.ts +++ b/packages/frontend/i18n/src/index.ts @@ -1,122 +1,7 @@ -import type { i18n, Resource } from 'i18next'; -import i18next from 'i18next'; -import type { I18nextProviderProps } from 'react-i18next'; -import { I18nextProvider, initReactI18next, Trans } from 'react-i18next'; - -import { LOCALES } from './resources'; -import type en_US from './resources/en.json'; - -export * from './i18n'; +export * from './i18next'; +export * from './react'; +export * from './resources'; export * from './utils'; +import completenesses from './i18n-completenesses.json'; -declare module 'i18next' { - // Refs: https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz - interface CustomTypeOptions { - returnNull: false; - } -} - -// const localStorage = { -// getItem() { -// return undefined; -// }, -// setItem() {}, -// }; -// See https://react.i18next.com/latest/typescript -declare module 'react-i18next' { - interface CustomTypeOptions { - // custom namespace type if you changed it - // defaultNS: 'ns1'; - // custom resources type - allowObjectInHTMLChildren: true; - resources: { - en: typeof en_US; - }; - } -} - -const STORAGE_KEY = 'i18n_lng'; - -export { I18nextProvider, LOCALES, Trans }; - -const resources = LOCALES.reduce((acc, { tag, res }) => { - return Object.assign(acc, { [tag]: { translation: res } }); -}, {}); - -const fallbackLng = 'en'; -const standardizeLocale = (language: string) => { - if (language === 'zh-CN' || language === 'zh' || language === 'zh-Hans') { - language = 'zh-Hans'; - } else if (language.slice(0, 2).toLowerCase() === 'zh') { - language = 'zh-Hant'; - } - if (LOCALES.some(locale => locale.tag === language)) return language; - if ( - LOCALES.some(locale => locale.tag === language.slice(0, 2).toLowerCase()) - ) { - return language.slice(0, 2).toLowerCase(); - } - - return fallbackLng; -}; - -export const createI18n = (): I18nextProviderProps['i18n'] => { - const i18n: I18nextProviderProps['i18n'] = i18next.createInstance(); - i18n - .use(initReactI18next) - .init({ - lng: 'en', - fallbackLng, - debug: false, - resources, - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - }, - }) - .then(() => { - console.info('i18n init success'); - }) - .catch(() => { - console.error('i18n init failed'); - }); - - if (globalThis.localStorage) { - i18n.on('languageChanged', lng => { - localStorage.setItem(STORAGE_KEY, lng); - }); - } - return i18n; -}; - -export function setUpLanguage(i: i18n) { - let language; - const localStorageLanguage = localStorage.getItem(STORAGE_KEY); - if (localStorageLanguage) { - language = standardizeLocale(localStorageLanguage); - } else { - language = standardizeLocale(navigator.language); - } - return i.changeLanguage(language); -} - -const cachedCompleteness: Record = {}; -export const calcLocaleCompleteness = ( - locale: (typeof LOCALES)[number]['tag'] -) => { - if (cachedCompleteness[locale]) { - return cachedCompleteness[locale]; - } - const base = LOCALES.find(item => item.base); - if (!base) { - throw new Error('Base language not found'); - } - const target = LOCALES.find(item => item.tag === locale); - if (!target) { - throw new Error('Locale not found'); - } - const baseKeyCount = Object.keys(base.res).length; - const translatedKeyCount = Object.keys(target.res).length; - const completeness = translatedKeyCount / baseKeyCount; - cachedCompleteness[target.tag] = completeness; - return completeness; -}; +export const i18nCompletenesses = completenesses; diff --git a/packages/frontend/i18n/src/react.ts b/packages/frontend/i18n/src/react.ts new file mode 100644 index 0000000000..3cfe628f31 --- /dev/null +++ b/packages/frontend/i18n/src/react.ts @@ -0,0 +1,12 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { createI18nWrapper } from './i18next'; + +export const useI18n = () => { + const { i18n } = useTranslation('translation'); + + return useMemo(() => createI18nWrapper(() => i18n), [i18n]); +}; + +export { I18nextProvider, Trans } from 'react-i18next'; diff --git a/packages/frontend/i18n/src/resources/index.ts b/packages/frontend/i18n/src/resources/index.ts index a80764ddc1..65d217ecab 100644 --- a/packages/frontend/i18n/src/resources/index.ts +++ b/packages/frontend/i18n/src/resources/index.ts @@ -1,174 +1,153 @@ -import ar from './ar.json'; -import ca from './ca.json'; -import da from './da.json'; -import de from './de.json'; import en from './en.json'; -import es from './es.json'; -import es_AR from './es-AR.json'; -import es_CL from './es-CL.json'; -import fr from './fr.json'; -import hi from './hi.json'; -import it from './it.json'; -import ja from './ja.json'; -import ko from './ko.json'; -import pt_BR from './pt-BR.json'; -import ru from './ru.json'; -import sv_SE from './sv-SE.json'; -import ur from './ur.json'; -import zh_Hans from './zh-Hans.json'; -import zh_Hant from './zh-Hant.json'; -export const LOCALES = [ - { - name: 'Korean (South Korea)', - tag: 'ko', - originalName: '한국어(대한민국)', - flagEmoji: '🇰🇷', - base: false, - res: ko, - }, - { - name: 'Portuguese (Brazil)', - tag: 'pt-BR', - originalName: 'português (Brasil)', - flagEmoji: '🇧🇷', - base: false, - res: pt_BR, - }, +export type Language = + | 'en' + | 'zh-Hans' + | 'zh-Hant' + | 'fr' + | 'es' + | 'es-AR' + | 'es-CL' + | 'de' + | 'ru' + | 'ja' + | 'it' + | 'ca' + | 'da' + | 'hi' + | 'sv-SE' + | 'ur' + | 'ar' + | 'ko' + | 'pt-BR'; + +export type LanguageResource = typeof en; +export const SUPPORTED_LANGUAGES: Record< + Language, { + name: string; + originalName: string; + flagEmoji: string; + resource: + | LanguageResource + | (() => Promise<{ default: Partial }>); + } +> = { + en: { name: 'English', - tag: 'en', originalName: 'English', flagEmoji: '🇬🇧', - base: true, - res: en, + resource: en, }, - { - name: 'Traditional Chinese', - tag: 'zh-Hant', - originalName: '繁體中文', - flagEmoji: '🇭🇰', - base: false, - res: zh_Hant, + ko: { + name: 'Korean (South Korea)', + originalName: '한국어(대한민국)', + flagEmoji: '🇰🇷', + resource: () => /* webpackChunkName "i18n-ko" */ import('./ko.json'), }, - { + 'pt-BR': { + name: 'Portuguese (Brazil)', + originalName: 'português (Brasil)', + flagEmoji: '🇧🇷', + resource: () => /* webpackChunkName "i18n-pt_BR" */ import('./pt-BR.json'), + }, + 'zh-Hans': { name: 'Simplified Chinese', - tag: 'zh-Hans', originalName: '简体中文', flagEmoji: '🇨🇳', - base: false, - res: zh_Hans, + resource: () => + /* webpackChunkName "i18n-zh_Hans" */ import('./zh-Hans.json'), }, - { + 'zh-Hant': { + name: 'Traditional Chinese', + originalName: '繁體中文', + flagEmoji: '🇭🇰', + resource: () => + /* webpackChunkName "i18n-zh_Hant" */ import('./zh-Hant.json'), + }, + fr: { name: 'French', - tag: 'fr', originalName: 'français', flagEmoji: '🇫🇷', - base: false, - res: fr, + resource: () => /* webpackChunkName "i18n-fr" */ import('./fr.json'), }, - { + es: { name: 'Spanish', - tag: 'es', originalName: 'español', flagEmoji: '🇪🇸', - base: false, - res: es, + resource: () => /* webpackChunkName "i18n-es" */ import('./es.json'), }, - { - name: 'German', - tag: 'de', - originalName: 'Deutsch', - flagEmoji: '🇩🇪', - base: false, - res: de, - }, - { - name: 'Russian', - tag: 'ru', - originalName: 'русский', - flagEmoji: '🇷🇺', - base: false, - res: ru, - }, - { - name: 'Japanese', - tag: 'ja', - originalName: '日本語', - flagEmoji: '🇯🇵', - base: false, - res: ja, - }, - { - name: 'Italian', - tag: 'it', - originalName: 'italiano', - flagEmoji: '🇮🇹', - base: false, - res: it, - }, - { - name: 'Catalan', - tag: 'ca', - originalName: 'català', - flagEmoji: '🇦🇩', - base: false, - res: ca, - }, - { - name: 'Danish', - tag: 'da', - originalName: 'dansk', - flagEmoji: '🇩🇰', - base: false, - res: da, - }, - { - name: 'Spanish (Chile)', - tag: 'es-CL', - originalName: 'español (Chile)', - flagEmoji: '🇨🇱', - base: false, - res: es_CL, - }, - { - name: 'Hindi', - tag: 'hi', - originalName: 'हिन्दी', - flagEmoji: '🇮🇳', - base: false, - res: hi, - }, - { - name: 'Swedish (Sweden)', - tag: 'sv-SE', - originalName: 'svenska (Sverige)', - flagEmoji: '🇸🇪', - base: false, - res: sv_SE, - }, - { + 'es-AR': { name: 'Spanish (Argentina)', - tag: 'es-AR', originalName: 'español (Argentina)', flagEmoji: '🇦🇷', - base: false, - res: es_AR, + resource: () => /* webpackChunkName "i18n-es_AR" */ import('./es-AR.json'), }, - { + 'es-CL': { + name: 'Spanish (Chile)', + originalName: 'español (Chile)', + flagEmoji: '🇨🇱', + resource: () => /* webpackChunkName "i18n-es_CL" */ import('./es-CL.json'), + }, + de: { + name: 'German', + originalName: 'Deutsch', + flagEmoji: '🇩🇪', + resource: () => /* webpackChunkName "i18n-de" */ import('./de.json'), + }, + ru: { + name: 'Russian', + originalName: 'русский', + flagEmoji: '🇷🇺', + resource: () => /* webpackChunkName "i18n-ru" */ import('./ru.json'), + }, + ja: { + name: 'Japanese', + originalName: '日本語', + flagEmoji: '🇯🇵', + resource: () => /* webpackChunkName "i18n-ja" */ import('./ja.json'), + }, + it: { + name: 'Italian', + originalName: 'italiano', + flagEmoji: '🇮🇹', + resource: () => /* webpackChunkName "i18n-it" */ import('./it.json'), + }, + ca: { + name: 'Catalan', + originalName: 'català', + flagEmoji: '🇦🇩', + resource: () => /* webpackChunkName "i18n-ca" */ import('./ca.json'), + }, + da: { + name: 'Danish', + originalName: 'dansk', + flagEmoji: '🇩🇰', + resource: () => /* webpackChunkName "i18n-da" */ import('./da.json'), + }, + hi: { + name: 'Hindi', + originalName: 'हिन्दी', + flagEmoji: '🇮🇳', + resource: () => /* webpackChunkName "i18n-hi" */ import('./hi.json'), + }, + 'sv-SE': { + name: 'Swedish (Sweden)', + originalName: 'svenska (Sverige)', + flagEmoji: '🇸🇪', + resource: () => /* webpackChunkName "i18n-sv_SE" */ import('./sv-SE.json'), + }, + + ur: { name: 'Urdu', - tag: 'ur', originalName: 'اردو', flagEmoji: '🇵🇰', - base: false, - res: ur, + resource: () => /* webpackChunkName "i18n-ur" */ import('./ur.json'), }, - { + ar: { name: 'Arabic', - tag: 'ar', originalName: 'العربية', flagEmoji: '🇸🇦', - base: false, - res: ar, + resource: () => /* webpackChunkName "i18n-ar" */ import('./ar.json'), }, -] as const; +}; diff --git a/packages/frontend/i18n/src/utils/__tests__/time.spec.ts b/packages/frontend/i18n/src/utils/__tests__/time.spec.ts index 44321a4b18..afa6427837 100644 --- a/packages/frontend/i18n/src/utils/__tests__/time.spec.ts +++ b/packages/frontend/i18n/src/utils/__tests__/time.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from 'vitest'; -import { createI18n, I18n } from '../../'; +import { getOrCreateI18n, I18n } from '../../'; import { i18nTime } from '../time'; // Intl api is not available in github action, skip the test describe('humanTime', () => { test('absolute', async () => { - createI18n(); + getOrCreateI18n(); expect(i18nTime('2024-10-10 13:30:28')).toBe('Oct 10, 2024, 1:30:28 PM'); expect( i18nTime('2024-10-10 13:30:28', { @@ -48,7 +48,7 @@ describe('humanTime', () => { }); test('relative', async () => { - createI18n(); + getOrCreateI18n(); expect( i18nTime('2024-10-10 13:30:28.005', { now: '2024-10-10 13:30:30', @@ -148,7 +148,7 @@ describe('humanTime', () => { }); test('relative - accuracy', async () => { - createI18n(); + getOrCreateI18n(); expect( i18nTime('2024-10-10 13:30:28.005', { now: '2024-10-10 13:30:30', @@ -224,7 +224,7 @@ describe('humanTime', () => { }); test('relative - disable yesterdayAndTomorrow', async () => { - createI18n(); + getOrCreateI18n(); expect( i18nTime('2024-10-9 13:30:30', { now: '2024-10-10 13:30:30', @@ -244,7 +244,7 @@ describe('humanTime', () => { }); test('relative - weekday', async () => { - createI18n(); + getOrCreateI18n(); expect( i18nTime('2024-10-9 13:30:30', { now: '2024-10-10 13:30:30', @@ -302,7 +302,7 @@ describe('humanTime', () => { }); test('mix relative and absolute', async () => { - createI18n(); + getOrCreateI18n(); expect( i18nTime('2024-10-9 14:30:30', { now: '2024-10-10 13:30:30', @@ -348,9 +348,9 @@ describe('humanTime', () => { ).toBe('Oct 8, 2024'); }); - test('chinese', () => { - createI18n(); - I18n.changeLanguage('zh-Hans'); + test('chinese', async () => { + getOrCreateI18n(); + await I18n.changeLanguage('zh-Hans'); expect(i18nTime('2024-10-10 13:30:28.005')).toBe('2024年10月10日 13:30:28'); expect( i18nTime('2024-10-10 13:30:28.005', { @@ -398,7 +398,7 @@ describe('humanTime', () => { }); test('invalid time', () => { - createI18n(); + getOrCreateI18n(); expect(i18nTime('foobar')).toBe(''); }); }); diff --git a/packages/frontend/i18n/src/utils/time.ts b/packages/frontend/i18n/src/utils/time.ts index a1e3bae92e..7d10bda3ed 100644 --- a/packages/frontend/i18n/src/utils/time.ts +++ b/packages/frontend/i18n/src/utils/time.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; -import { I18n } from '../i18n'; +import { I18n } from '../i18next'; export type TimeUnit = | 'second' diff --git a/packages/frontend/i18n/tsconfig.json b/packages/frontend/i18n/tsconfig.json index 66e008d309..7292756207 100644 --- a/packages/frontend/i18n/tsconfig.json +++ b/packages/frontend/i18n/tsconfig.json @@ -10,6 +10,9 @@ "references": [ { "path": "./tsconfig.resources.json" + }, + { + "path": "../../common/debug/tsconfig.json" } ] } diff --git a/packages/frontend/i18n/tsconfig.resources.json b/packages/frontend/i18n/tsconfig.resources.json index a2a1fb3c9f..f59226b68c 100644 --- a/packages/frontend/i18n/tsconfig.resources.json +++ b/packages/frontend/i18n/tsconfig.resources.json @@ -8,6 +8,6 @@ "outDir": "lib/resources", "noEmit": false }, - "include": ["./src/resources/**/*.ts", "./src/resources/**/*.json"], + "include": ["./src/resources/**/*.ts", "./src/**/*.json"], "exclude": ["dist", "lib"] } diff --git a/tests/affine-local/e2e/settings.spec.ts b/tests/affine-local/e2e/settings.spec.ts index 6abe5eb73c..008d99d68c 100644 --- a/tests/affine-local/e2e/settings.spec.ts +++ b/tests/affine-local/e2e/settings.spec.ts @@ -34,6 +34,10 @@ test('change language using keyboard', async ({ page }) => { await page.keyboard.press('ArrowDown', { delay: 50, }); + // incase the current language is the top one + await page.keyboard.press('ArrowDown', { + delay: 50, + }); await page.keyboard.press('Enter', { delay: 50, }); diff --git a/tools/cli/src/bin/build.ts b/tools/cli/src/bin/build.ts index d59b2c1097..64113974a6 100644 --- a/tools/cli/src/bin/build.ts +++ b/tools/cli/src/bin/build.ts @@ -1,8 +1,9 @@ +import { spawn } from 'node:child_process'; + import webpack from 'webpack'; import { getCwdFromDistribution } from '../config/cwd.cjs'; import type { BuildFlags } from '../config/index.js'; -import { buildI18N } from '../util/i18n.js'; import { createWebpackConfig } from '../webpack/webpack.config.js'; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -48,7 +49,9 @@ const flags = { static: false, } satisfies BuildFlags; -buildI18N(); +spawn('yarn', ['workspace', '@affine/i18n', 'build'], { + stdio: 'inherit', +}); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion webpack(createWebpackConfig(cwd!, flags), (err, stats) => { diff --git a/tools/cli/src/bin/dev.ts b/tools/cli/src/bin/dev.ts index 6365701f54..625095752a 100644 --- a/tools/cli/src/bin/dev.ts +++ b/tools/cli/src/bin/dev.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -8,7 +9,6 @@ import WebpackDevServer from 'webpack-dev-server'; import { getCwdFromDistribution, projectRoot } from '../config/cwd.cjs'; import type { BuildFlags } from '../config/index.js'; -import { watchI18N } from '../util/i18n.js'; import { createWebpackConfig } from '../webpack/webpack.config.js'; const flags: BuildFlags = { @@ -119,7 +119,9 @@ if (flags.distribution === 'desktop') { console.info(flags); if (!flags.static) { - watchI18N(); + spawn('yarn', ['workspace', '@affine/i18n', 'dev'], { + stdio: 'inherit', + }); } try { diff --git a/tools/cli/src/util/i18n.ts b/tools/cli/src/util/i18n.ts deleted file mode 100644 index a0af11044c..0000000000 --- a/tools/cli/src/util/i18n.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { resolve } from 'node:path'; - -import { runCli } from '@magic-works/i18n-codegen'; - -import { projectRoot } from '../config/cwd.cjs'; - -const configPath = resolve(projectRoot, '.i18n-codegen.json'); - -export const watchI18N = () => { - runCli( - { - config: configPath, - watch: true, - }, - error => { - console.error(error); - } - ); -}; - -export const buildI18N = () => { - runCli( - { - config: configPath, - watch: false, - }, - error => { - console.error(error); - process.exit(1); - } - ); -}; diff --git a/yarn.lock b/yarn.lock index 0d3c70985f..b02957af4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -552,6 +552,7 @@ __metadata: version: 0.0.0-use.local resolution: "@affine/i18n@workspace:packages/frontend/i18n" dependencies: + "@affine/debug": "workspace:*" "@magic-works/i18n-codegen": "npm:^0.6.0" dayjs: "npm:^1.11.11" glob: "npm:^11.0.0"