├── .gitignore
├── .DS_Store
├── public
├── icon.png
├── icon128.png
├── icon16.png
├── icon32.png
└── icon48.png
├── pnpm-workspace.yaml
├── postcss.config.js
├── src
├── utils
│ ├── cn.ts
│ ├── i18n.ts
│ ├── cn.test.ts
│ ├── rtl.ts
│ ├── useChromeLocalStorage.ts
│ └── epubParser.ts
├── shared
│ ├── settings.ts
│ ├── messages.ts
│ ├── languages.ts
│ └── streaming.ts
├── popup
│ ├── popup.html
│ └── popup.tsx
├── sidePanel
│ └── sidePanel.html
├── components
│ └── ui
│ │ ├── label.tsx
│ │ ├── slider.tsx
│ │ ├── progress.tsx
│ │ ├── textarea.tsx
│ │ ├── badge.tsx
│ │ ├── switch.tsx
│ │ ├── button.tsx
│ │ ├── tabs.tsx
│ │ ├── card.tsx
│ │ ├── alert.tsx
│ │ └── select.tsx
├── styles
│ └── tailwind.css
├── manifest.json
└── scripts
│ └── background.ts
├── test
└── setup.ts
├── vitest.config.ts
├── FUNDING.yml
├── biome.json
├── .cursor
└── rules
│ ├── 50-scripts-and-pnpm.mdc
│ ├── 60-coding-style.mdc
│ ├── 30-i18n-and-rtl.mdc
│ ├── 10-typescript-and-alias.mdc
│ ├── 40-extension-build-and-contracts.mdc
│ ├── 20-ui-and-tailwind.mdc
│ ├── 00-project-structure.mdc
│ └── 00-project-structure-and-entries.mdc
├── LICENSE
├── package.json
├── .github
└── workflows
│ └── release-on-tag.yml
├── AGENTS.md
├── README.zh-CN.md
├── develop.md
├── rspack.config.js
├── README.md
├── _locales
├── zh_CN
│ └── messages.json
├── ar
│ └── messages.json
├── hi
│ └── messages.json
├── vi
│ └── messages.json
├── th
│ └── messages.json
├── tr
│ └── messages.json
├── id
│ └── messages.json
├── nl
│ └── messages.json
├── pt
│ └── messages.json
├── es
│ └── messages.json
└── en
│ └── messages.json
├── QWEN.md
├── CLAUDE.md
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | *.zip
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/.DS_Store
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/public/icon.png
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | onlyBuiltDependencies:
2 | - '@tailwindcss/oxide'
3 | - esbuild
4 |
--------------------------------------------------------------------------------
/public/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/public/icon128.png
--------------------------------------------------------------------------------
/public/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/public/icon16.png
--------------------------------------------------------------------------------
/public/icon32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/public/icon32.png
--------------------------------------------------------------------------------
/public/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh30/native-translate/HEAD/public/icon48.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]): string {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import { cleanup } from '@testing-library/react';
3 | import { afterEach } from 'vitest';
4 |
5 | // Cleanup after each test case (e.g. clearing jsdom)
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
--------------------------------------------------------------------------------
/src/shared/settings.ts:
--------------------------------------------------------------------------------
1 | import type { LanguageCode } from '@/shared/languages';
2 |
3 | export const POPUP_SETTINGS_KEY = 'nativeTranslate.settings' as const;
4 |
5 | export interface PopupSettings {
6 | targetLanguage: LanguageCode;
7 | hotkeyModifier?: 'alt' | 'control' | 'shift';
8 | inputTargetLanguage?: LanguageCode;
9 | }
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/popup/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Popup
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/sidePanel/sidePanel.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Side Panel
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/utils/i18n.ts:
--------------------------------------------------------------------------------
1 | export function t(key: string, substitutions?: Array): string {
2 | try {
3 | // chrome.i18n.getMessage 第二个参数可为 string 或 string[]
4 | const value = chrome?.i18n?.getMessage?.(
5 | key,
6 | (substitutions ?? []) as unknown as string | string[]
7 | );
8 | return value || key;
9 | } catch (_e) {
10 | return key;
11 | }
12 | }
13 |
14 |
15 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 | import path from 'path';
3 |
4 | export default defineConfig({
5 | test: {
6 | environment: 'jsdom',
7 | globals: true,
8 | setupFiles: ['./test/setup.ts'],
9 | include: ['src/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'],
10 | alias: {
11 | '@': path.resolve(__dirname, './src'),
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as RadixLabel from '@radix-ui/react-label';
3 | import { cn } from '@/utils/cn';
4 |
5 | export const Label = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
14 | ));
15 | Label.displayName = RadixLabel.Root.displayName;
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface SliderProps {
4 | value: number;
5 | min?: number;
6 | max?: number;
7 | step?: number;
8 | onChange: (value: number) => void;
9 | className?: string;
10 | }
11 |
12 | export const Slider: React.FC = ({ value, min = 1, max = 12, step = 1, onChange, className }) => {
13 | return (
14 | ) => onChange(Number(e.target.value))}
21 | className={className}
22 | />
23 | );
24 | };
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/utils/cn.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import { cn } from './cn';
3 |
4 | describe('cn utility', () => {
5 | it('merges class names correctly', () => {
6 | expect(cn('c1', 'c2')).toBe('c1 c2');
7 | });
8 |
9 | it('handles conditional classes', () => {
10 | expect(cn('c1', true && 'c2', false && 'c3')).toBe('c1 c2');
11 | });
12 |
13 | it('merges tailwind classes using tailwind-merge', () => {
14 | // p-4 overrides p-2 in tailwind-merge
15 | expect(cn('p-2', 'p-4')).toBe('p-4');
16 | });
17 |
18 | it('handles arrays and objects', () => {
19 | expect(cn('c1', ['c2', 'c3'], { c4: true, c5: false })).toBe('c1 c2 c3 c4');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | /*
4 | The default border color has changed to `currentColor` in Tailwind CSS v4,
5 | so we've added these compatibility styles to make sure everything still
6 | looks the same as it did with Tailwind CSS v3.
7 |
8 | If we ever want to remove these styles, we need to add an explicit border
9 | color utility to any element that depends on these defaults.
10 | */
11 | @layer base {
12 | *,
13 | ::after,
14 | ::before,
15 | ::backdrop,
16 | ::file-selector-button {
17 | border-color: var(--color-gray-200, currentColor);
18 | }
19 | }
20 |
21 | @layer base {
22 | body {
23 | direction: __MSG_ @@bidi_dir__;
24 | color-scheme: light dark;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@/utils/cn';
3 |
4 | export interface ProgressProps extends React.HTMLAttributes {
5 | value?: number;
6 | }
7 |
8 | export function Progress({ value = 0, className, ...props }: ProgressProps) {
9 | const pct = Math.max(0, Math.min(100, value));
10 | return (
11 |
17 | );
18 | }
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/utils/cn"
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [zh30]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: zhanghe # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import { cn } from '@/utils/cn';
4 |
5 | const badgeVariants = cva(
6 | 'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
7 | {
8 | variants: {
9 | variant: {
10 | default: 'bg-gray-200 text-gray-700',
11 | success: 'bg-green-100 text-green-700',
12 | warning: 'bg-yellow-100 text-yellow-800',
13 | destructive: 'bg-red-100 text-red-700',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | }
20 | );
21 |
22 | export interface BadgeProps
23 | extends React.HTMLAttributes,
24 | VariantProps { }
25 |
26 | export const Badge = ({ className, variant, ...props }: BadgeProps) => (
27 |
28 | );
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/utils/rtl.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Determine if a BCP-47 language code should render Right-To-Left.
3 | */
4 | export function isRTLLanguage(languageCode: string | undefined | null): boolean {
5 | if (!languageCode) return false;
6 | const lc = languageCode.toLowerCase();
7 | // Common RTL languages
8 | const rtlPrefixes = ['ar', 'he', 'fa', 'ur', 'ps'];
9 | return rtlPrefixes.some((p) => lc === p || lc.startsWith(`${p}-`));
10 | }
11 |
12 | /**
13 | * Extract best-effort UI locale from Chrome i18n.
14 | */
15 | export function getUILocale(): string {
16 | try {
17 | // Prefer getUILanguage if available
18 | const lang = (chrome?.i18n?.getUILanguage?.() as string | undefined) || '';
19 | if (lang) return lang;
20 | } catch (_e) { }
21 | try {
22 | // Fallback special message
23 | const locale = chrome?.i18n?.getMessage?.('@@ui_locale');
24 | return locale || 'en';
25 | } catch (_e) {
26 | return 'en';
27 | }
28 | }
29 |
30 |
31 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git"
6 | },
7 | "files": {
8 | "ignore": [
9 | "dist",
10 | "node_modules",
11 | "**/*.png",
12 | "**/*.ico",
13 | "**/*.jpg",
14 | "**/*.jpeg",
15 | "**/*.svg",
16 | "**/*.webp",
17 | "**/*.avif",
18 | "pnpm-lock.yaml"
19 | ]
20 | },
21 | "formatter": {
22 | "enabled": true,
23 | "indentStyle": "space",
24 | "indentWidth": 2,
25 | "lineWidth": 100
26 | },
27 | "organizeImports": {
28 | "enabled": true
29 | },
30 | "linter": {
31 | "enabled": true,
32 | "rules": {
33 | "recommended": true
34 | }
35 | },
36 | "javascript": {
37 | "globals": [
38 | "chrome",
39 | "__DEV__"
40 | ],
41 | "jsxRuntime": "reactClassic",
42 | "formatter": {
43 | "quoteStyle": "single",
44 | "jsxQuoteStyle": "double",
45 | "semicolons": "asNeeded"
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/src/shared/messages.ts:
--------------------------------------------------------------------------------
1 | import type { LanguageCode } from './languages';
2 |
3 | // Runtime message types used across extension contexts
4 | export const MSG_TRANSLATE_PAGE = 'NATIVE_TRANSLATE_TRANSLATE_PAGE' as const;
5 | export const MSG_TRANSLATE_TEXT = 'NATIVE_TRANSLATE_TRANSLATE_TEXT' as const;
6 | export const MSG_UPDATE_HOTKEY = 'NATIVE_TRANSLATE_UPDATE_HOTKEY' as const;
7 | export const MSG_EASTER_CONFETTI = 'NATIVE_TRANSLATE_EASTER_EGG_CONFETTI' as const;
8 | export const MSG_WARM_TRANSLATOR = 'NATIVE_TRANSLATE_WARM_TRANSLATOR' as const;
9 |
10 | export type RuntimeMessage =
11 | | { type: typeof MSG_TRANSLATE_PAGE; payload: { targetLanguage: LanguageCode } }
12 | | { type: typeof MSG_TRANSLATE_TEXT; payload: { text: string; sourceLanguage: LanguageCode | 'auto'; targetLanguage: LanguageCode } }
13 | | { type: typeof MSG_UPDATE_HOTKEY; payload: { hotkeyModifier: 'alt' | 'control' | 'shift' } }
14 | | { type: typeof MSG_EASTER_CONFETTI }
15 | | { type: typeof MSG_WARM_TRANSLATOR; payload?: { sourceLanguage?: LanguageCode | 'auto'; targetLanguage?: LanguageCode } };
16 |
17 |
--------------------------------------------------------------------------------
/.cursor/rules/50-scripts-and-pnpm.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: 脚本命令与 pnpm 约定
3 | globs: "package.json,**/*.md,**/*.mdx"
4 | ---
5 | # 脚本命令与 pnpm 约定
6 |
7 | - **包管理器**:本项目使用 pnpm(见 `packageManager` 字段)。
8 | - **常用脚本**(来自 [package.json](mdc:package.json) 与 [README.md](mdc:README.md)):
9 | - `pnpm dev` — 监听构建(`rspack build --watch`),并启动开发时热重载服务。
10 | - `pnpm build` — 生产构建(`rspack build --mode production`)。
11 | - `pnpm tsc` — TypeScript 类型检查。
12 | - `pnpm lint` / `pnpm lint:fix` — 使用 Biome 进行检查与修复。
13 | - **开发产物**:本地开发不应生成 zip 包;发布或生产构建时产物在 `dist/` 目录。
14 | - **Node/Chrome 要求**:Chrome 138+,pnpm 9+;首次运行可能触发模型下载。
15 |
16 | ---
17 | alwaysApply: false
18 | description: 脚本命令与 pnpm 约定
19 | ---
20 | ## 命令
21 |
22 | - 安装依赖:`pnpm i`
23 | - 开发构建(监听):`pnpm dev`(Rspack watch 输出到 `dist/`)
24 | - 生产构建:`pnpm build`(生成 `dist/` 与 `Native-translate.zip`)
25 | - 类型检查:`pnpm tsc`
26 | - 代码质量:`pnpm lint` / `pnpm lint:fix`
27 |
28 | ## 说明
29 |
30 | - 使用 pnpm(见 `packageManager` 字段),不要混用 npm/yarn。
31 | - 若新增依赖:`pnpm add `;开发依赖:`pnpm add -D `。
32 | - Biome 负责格式化与 Lint,遵守 [biome.json](mdc:biome.json) 的规则(2 空格、单引号、JSX 双引号、最大行宽 100)。
33 |
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Henry Zhang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.cursor/rules/60-coding-style.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: 代码风格、命名与可维护性
3 | globs: "*.ts,*.tsx"
4 | ---
5 | # 代码风格与可维护性
6 |
7 | - **命名**:函数用动宾短语;变量用有意义的名词短语;避免缩写与 1-2 字母变量。
8 | - **类型**:公共 API 明确注解;避免 `any`,优先 `unknown`/范型;导出类型统一放在 `types` 或同文件顶部。
9 | - **控制流**:优先卫语句、处理边界;避免深层嵌套;仅在有意义时捕获异常。
10 | - **注释**:仅为复杂/决策性逻辑添加简洁注释(解释“为什么”而非“如何”)。
11 | - **格式**:遵循现有格式;长行换行;避免无关大规模改格式。
12 | - **UI/状态**:局部用 `useState`/`useReducer`,跨组件用 Context(轻量场景);必要时再引入更重状态库。
13 | - **性能**:有需要时使用 `useMemo`/`useCallback`/`React.memo`;避免过度优化。
14 |
15 | ---
16 | globs: *.ts,*.tsx
17 | description: 代码风格、命名与可维护性
18 | ---
19 | ## 代码风格
20 |
21 | - **命名**:避免 1-2 字母的短名。函数用动词短语,变量用名词短语。保持清晰可读。
22 | - **类型**:组件 Props、返回值与公共 API 必须显式类型。尽量避免 `any`。
23 | - **控制流**:使用早返回,捕获错误需有意义的处理;不要无意义吞错。
24 | - **注释**:仅在复杂处添加“为什么”,避免赘述“做什么”。
25 | - **格式化**:遵循 Biome;不要在无关行做大范围重排。
26 | - **文件组织**:
27 | - 组件:`src/components/`
28 | - 页面:`src/popup/`、`src/sidePanel/`
29 | - 工具:`src/utils/`
30 | - 类型:`src/types/`(如新增公共类型)
31 |
32 | ## React 组件
33 |
34 | - 使用函数式组件与自动 JSX 运行时;优先 `React.FC`。
35 | - 状态管理优先 `useState`/`useReducer`,跨组件简易场景可用 Context。
36 | - 需要 Portal/浮层的组件注意 `z-index` 与注入上下文(内容脚本环境)。
37 |
38 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "__MSG_extension_name__",
4 | "version": "2.2.2",
5 | "description": "__MSG_extension_description__",
6 | "minimum_chrome_version": "138",
7 | "default_locale": "en",
8 | "icons": {
9 | "16": "public/icon16.png",
10 | "32": "public/icon32.png",
11 | "48": "public/icon48.png",
12 | "128": "public/icon128.png"
13 | },
14 | "action": {
15 | "default_popup": "popup.html",
16 | "default_icon": {
17 | "16": "public/icon16.png",
18 | "32": "public/icon32.png",
19 | "48": "public/icon48.png",
20 | "128": "public/icon128.png"
21 | }
22 | },
23 | "side_panel": {
24 | "default_path": "sidePanel.html"
25 | },
26 | "background": {
27 | "service_worker": "background.js"
28 | },
29 | "content_scripts": [
30 | {
31 | "matches": [
32 | ""
33 | ],
34 | "all_frames": true,
35 | "match_about_blank": true,
36 | "js": [
37 | "contentScript.js"
38 | ]
39 | }
40 | ],
41 | "permissions": [
42 | "storage",
43 | "activeTab",
44 | "scripting",
45 | "tabs",
46 | "sidePanel"
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SwitchPrimitives from '@radix-ui/react-switch';
3 | import { cn } from '@/utils/cn';
4 |
5 | const Switch = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
17 |
22 |
23 | ));
24 | Switch.displayName = SwitchPrimitives.Root.displayName;
25 |
26 | export { Switch };
--------------------------------------------------------------------------------
/.cursor/rules/30-i18n-and-rtl.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: 国际化(Chrome i18n)与 RTL 方向设置规范
3 | globs: "src/**/*"
4 | ---
5 | # 国际化与 RTL 规范
6 |
7 | - **Chrome i18n**:文案应从 `_locales/*/messages.json` 读取,示例:
8 | - 英文:[ _locales/en/messages.json ](mdc:_locales/en/messages.json)
9 | - **工具函数**:使用 [src/utils/i18n.ts](mdc:src/utils/i18n.ts) 获取文案,使用 [src/utils/rtl.ts](mdc:src/utils/rtl.ts) 处理方向。
10 | - **方向与布局**:根据语言调用 `setDocumentDirection()` 设置 `dir="rtl|ltr"`,确保组件在 RTL 下布局正确。
11 | - **不要硬编码**:避免在 UI 中写死字符串;新增文案需同时补齐各语言或提供合理回退。
12 |
13 | ---
14 | globs: src/**/*.ts,src/**/*.tsx
15 | description: 国际化(Chrome i18n)与 RTL 方向设置规范
16 | ---
17 | ## i18n 与 RTL
18 |
19 | - **消息函数**:使用 [src/utils/i18n.ts](mdc:src/utils/i18n.ts) 的 `t(key, substitutions?)`,在缺失或调用异常时回退为 `key`。
20 | - **UI 方向**:在页面入口按 UI 语言设置 `dir` 与 `lang`:
21 |
22 | ```ts
23 | import { getUILocale, isRTLLanguage } from '@/utils/rtl';
24 | const ui = getUILocale();
25 | document.documentElement.setAttribute('dir', isRTLLanguage(ui) ? 'rtl' : 'ltr');
26 | document.documentElement.setAttribute('lang', ui);
27 | ```
28 |
29 | - **CSS 层**:`tailwind.css` 中 `body { direction: __MSG_@@bidi_dir__; }` 依赖 Chrome 本地化替换。不要移除该占位符。
30 | - **本地化目录**:在 [_locales/](mdc:_locales) 中新增/维护 `messages.json`,键名与 `t()` 调用保持一致。
31 | - **类型提示**:可在组件内维护受控的语言代码联合类型(参照 `popup.tsx` 的 `LanguageCode`)。
32 |
33 |
--------------------------------------------------------------------------------
/.cursor/rules/10-typescript-and-alias.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: TypeScript 严格模式、路径别名与 React 19 约定
3 | globs: "*.ts,*.tsx"
4 | ---
5 | # TypeScript 严格模式与路径别名
6 |
7 | - **严格模式**:默认在 `tsconfig.json` 启用 `strict`。新增类型需完整声明,避免使用 `any`,优先 `unknown` 或范型。
8 | - **显式导出类型**:公共 API 必须显式注解函数签名与返回类型;避免隐式导出推断。
9 | - **路径别名**:使用 `@/*` 指向 `src/*`。示例:
10 | - `import { cn } from '@/utils/cn'`
11 | - `import { Button } from '@/components/ui/button'`
12 | - **React 19 相关**:
13 | - 优先函数组件与 Hooks;组件 props、局部 state、上下文值均需类型。
14 | - 合理使用 Actions、`useOptimistic`、`useFormStatus`、`use` 等能力,注意定义请求与响应类型。
15 | - **导入顺序**:React → 第三方 → 本地(类型、组件、工具)。
16 |
17 | ---
18 | globs: *.ts,*.tsx
19 | description: TypeScript 严格模式、路径别名与 React 19 约定
20 | ---
21 | ## TypeScript 与路径别名
22 |
23 | - **严格模式**:`tsconfig.json` 已启用 `strict: true`。新增类型时避免使用 `any`,优先 `unknown`、字面量类型、联合类型、枚举或对象字面量。
24 | - **目标与库**:保持 `target` 为 `ES2020`,`lib` 包含 `DOM`, `DOM.Iterable`, `ES2020`,以兼容扩展运行时与异步迭代。
25 | - **路径别名**:使用 `@/*` 指向 `src/*`,在 TS 与 Rspack 均已配置。新增文件请使用绝对导入。
26 | - **React 19**:采用自动 JSX 运行时与函数式组件。Props 与返回值必须显式声明:
27 |
28 | ```ts
29 | interface MyComponentProps { title: string; count?: number }
30 | export const MyComponent: React.FC = ({ title, count }) => (
31 | {title} {count ?? 0}
32 | );
33 | ```
34 |
35 | - **工具函数**:遵循 `src/utils/` 下的命名与导出风格。例如 `cn` 返回 `string`,不可返回 `unknown`。
36 |
37 |
--------------------------------------------------------------------------------
/.cursor/rules/40-extension-build-and-contracts.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: 浏览器扩展构建(Rspack)与 Manifest 合约
3 | globs: "src/**/*,rspack.config.js,package.json"
4 | ---
5 | # 扩展构建与 Manifest 合约
6 |
7 | - 使用 Rspack 打包,入口与产物文件名必须与 [src/manifest.json](mdc:src/manifest.json) 中声明一致:
8 | - `background.service_worker` → `background.js`
9 | - `content_scripts[].js` → `contentScript.js`
10 | - `action.default_popup` → `popup.html`
11 | - `side_panel.default_path` → `sidePanel.html`
12 | - 开发模式不生成 zip 包;仅在 `pnpm run build` 时打包最终产物(遵循工作流与发布约定)。
13 | - 统一从 [rspack.config.js](mdc:rspack.config.js) 管理多入口与输出,改名时同步更新 `manifest.json`,保持一一对应。
14 | - 静态资源(图标等)放在 `public/`,参考 [public/icon.png](mdc:public/icon.png)。
15 |
16 | ---
17 | description: 浏览器扩展构建(Rspack)与 Manifest 合约
18 | ---
19 | ## 构建与清单合约
20 |
21 | - **入口声明**:构建入口在 [rspack.config.js](mdc:rspack.config.js) 中:`popup`, `sidePanel`, `background`, `contentScript`。产物文件名固定为 `[name].js` 与 `[name].css`,禁止 runtimeChunk 与 splitChunks,以便与清单严格对应。
22 | - **拷贝与产物**:通过 Copy 插件复制 `public/`、`src/manifest.json` 与 `_locales/`。保持路径一致。
23 | - **Zip 打包**:构建完成后自动压缩为 `Native-translate.zip`。如需关闭,临时移除 Zip 插件注册。
24 | - **Manifest 对齐**:
25 | - `background.service_worker`: `background.js`
26 | - `content_scripts[].js`: `contentScript.js`
27 | - `action.default_popup`: `popup.html`
28 | - `side_panel.default_path`: `sidePanel.html`
29 | - 如改名,需同步修改入口与清单。
30 | - **资源类型**:图片与字体使用 `asset/resource`,保持输出目录:`assets/` 与 `assets/fonts/`。
31 | - **最高层隐藏**:在覆盖页面 UI(尤其内容脚本)中,悬浮层使用 `z-[2147483647]` 避免被站点样式覆盖。
32 |
33 |
--------------------------------------------------------------------------------
/.cursor/rules/20-ui-and-tailwind.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: UI 组件与 Tailwind 使用规范
3 | globs: "*.tsx,*.ts"
4 | ---
5 | # UI 组件与 Tailwind 规范
6 |
7 | - **组件来源**:优先使用项目内 `src/components/ui/*` 组件:
8 | - [src/components/ui/button.tsx](mdc:src/components/ui/button.tsx)
9 | - [src/components/ui/select.tsx](mdc:src/components/ui/select.tsx)
10 | - [src/components/ui/label.tsx](mdc:src/components/ui/label.tsx)
11 | - **样式入口**:全局样式在 [src/styles/tailwind.css](mdc:src/styles/tailwind.css)。
12 | - **Tailwind 用法**:
13 | - 直接在 JSX 中组合 utility class,避免过度使用 `@apply`。
14 | - 使用 `cn()` 合并 className:[src/utils/cn.ts](mdc:src/utils/cn.ts)。
15 | - 响应式前缀:`sm:`、`md:`、`lg:`,尽量保持类名字符串可读。
16 | - 仅在必要时使用 JIT 任意值,例如 `w-[300px]`。
17 | - **无障碍与语义**:优先使用语义标签与可访问属性,表单元素配合 `Label`。
18 |
19 | ---
20 | globs: *.tsx
21 | description: UI 组件与 Tailwind 使用规范
22 | ---
23 | ## Tailwind 与 UI 规范
24 |
25 | - **Tailwind 入口**:通过 [src/styles/tailwind.css](mdc:src/styles/tailwind.css) 导入。页面入口需首先 `import '../styles/tailwind.css'`。
26 | - **类合并**:使用 `cn`(clsx + tailwind-merge)合并类名,避免冲突:`className={cn('p-2', conditional && 'text-blue-600')}`。
27 | - **组件变体**:优先使用 `class-variance-authority` 定义变体,如 [src/components/ui/button.tsx](mdc:src/components/ui/button.tsx)。新增组件参照此模式定义 `variants` 与 `defaultVariants`。
28 | - **选择器组件**:参照 [src/components/ui/select.tsx](mdc:src/components/ui/select.tsx) 的 Radix Select 封装。Layer 保持 `z-[2147483647]`,避免在内容脚本覆盖页面被遮挡。
29 | - **暗色模式**:使用 `dark:` 前缀支持深色主题,尽量避免自定义 CSS;若确需自定义,集中于 `tailwind.css`。
30 | - **排版密度**:默认 `text-sm` 与紧凑间距,延续现有设计语言。
31 |
32 |
--------------------------------------------------------------------------------
/src/scripts/background.ts:
--------------------------------------------------------------------------------
1 | const ZHANGHE_ORIGIN = 'https://zhanghe.dev';
2 |
3 | import { MSG_EASTER_CONFETTI } from '@/shared/messages';
4 |
5 | chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
6 | if (!tab.url) return;
7 | const url = new URL(tab.url);
8 | console.info("tabs.onUpdated", url.origin);
9 |
10 | if (url.origin === ZHANGHE_ORIGIN) {
11 | console.info("tabs.onUpdated", "enabling side panel");
12 | chrome.sidePanel.setOptions({
13 | tabId,
14 | path: 'sidePanel.html',
15 | enabled: true
16 | }).catch((error) => {
17 | console.error("Error enabling side panel:", error);
18 | });
19 | // 在目标站点自动打开侧边栏并触发一次撒花
20 | if (info.status === 'complete') {
21 | (async () => {
22 | try {
23 | await chrome.storage.local.set({ [MSG_EASTER_CONFETTI]: true });
24 | await chrome.sidePanel.open({ tabId });
25 | } catch (e) {
26 | console.error('auto-open side panel failed', e);
27 | }
28 | })();
29 | }
30 | } else {
31 | console.info("tabs.onUpdated", "disabling side panel");
32 | chrome.sidePanel.setOptions({
33 | tabId,
34 | enabled: false
35 | }).catch((error) => {
36 | console.error("Error disabling side panel:", error);
37 | });
38 | }
39 | });
40 |
41 | chrome.action.onClicked.addListener((tab) => {
42 | chrome.sidePanel.setPanelBehavior({
43 | openPanelOnActionClick: true,
44 | }).catch((error) => {
45 | console.error("action.onClicked", error);
46 | });
47 | });
48 |
49 | // Dev auto-reload removed
--------------------------------------------------------------------------------
/src/shared/languages.ts:
--------------------------------------------------------------------------------
1 | export type LanguageCode =
2 | | 'en'
3 | | 'zh-CN'
4 | | 'zh-TW'
5 | | 'ja'
6 | | 'ko'
7 | | 'fr'
8 | | 'de'
9 | | 'es'
10 | | 'it'
11 | | 'pt'
12 | | 'ru'
13 | | 'ar'
14 | | 'hi'
15 | | 'bn'
16 | | 'id'
17 | | 'tr'
18 | | 'vi'
19 | | 'th'
20 | | 'nl'
21 | | 'pl'
22 | | 'fa'
23 | | 'ur'
24 | | 'uk'
25 | | 'sv'
26 | | 'fil';
27 |
28 | export const SUPPORTED_LANGUAGES: ReadonlyArray<{ code: LanguageCode; label: string }> = [
29 | { code: 'en', label: 'English' },
30 | { code: 'zh-CN', label: '简体中文' },
31 | { code: 'zh-TW', label: '繁體中文' },
32 | { code: 'ja', label: '日本語' },
33 | { code: 'ko', label: '한국어' },
34 | { code: 'fr', label: 'Français' },
35 | { code: 'de', label: 'Deutsch' },
36 | { code: 'es', label: 'Español' },
37 | { code: 'it', label: 'Italiano' },
38 | { code: 'pt', label: 'Português' },
39 | { code: 'ru', label: 'Русский' },
40 | { code: 'ar', label: 'العربية' },
41 | { code: 'hi', label: 'हिन्दी' },
42 | { code: 'bn', label: 'বাংলা' },
43 | { code: 'id', label: 'Bahasa Indonesia' },
44 | { code: 'tr', label: 'Türkçe' },
45 | { code: 'vi', label: 'Tiếng Việt' },
46 | { code: 'th', label: 'ไทย' },
47 | { code: 'nl', label: 'Nederlands' },
48 | { code: 'pl', label: 'Polski' },
49 | { code: 'fa', label: 'فارسی' },
50 | { code: 'ur', label: 'اردو' },
51 | { code: 'uk', label: 'Українська' },
52 | { code: 'sv', label: 'Svenska' },
53 | { code: 'fil', label: 'Filipino' },
54 | ];
55 |
56 | export const DEFAULT_TARGET_LANGUAGE: LanguageCode = 'zh-CN';
57 | export const DEFAULT_INPUT_TARGET_LANGUAGE: LanguageCode = 'en';
58 |
59 |
60 |
--------------------------------------------------------------------------------
/.cursor/rules/00-project-structure.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | alwaysApply: true
3 | ---
4 | ## 项目结构与入口映射
5 |
6 | - **核心配置**
7 | - [rspack.config.js](mdc:rspack.config.js)
8 | - [tsconfig.json](mdc:tsconfig.json)
9 | - [biome.json](mdc:biome.json)
10 | - [package.json](mdc:package.json)
11 |
12 | - **浏览器扩展清单**
13 | - [src/manifest.json](mdc:src/manifest.json)
14 | - 保持输出文件名与清单一致:
15 | - `background.service_worker` → `background.js`
16 | - `content_scripts[].js` → `contentScript.js`
17 | - `action.default_popup` → `popup.html`
18 | - `side_panel.default_path` → `sidePanel.html`
19 |
20 | - **入口与产物(保持名称一一对应)**
21 | - Popup 页面: [src/popup/popup.tsx](mdc:src/popup/popup.tsx) → [src/popup/popup.html](mdc:src/popup/popup.html)
22 | - Side Panel: [src/sidePanel/sidePanel.tsx](mdc:src/sidePanel/sidePanel.tsx) → [src/sidePanel/sidePanel.html](mdc:src/sidePanel/sidePanel.html)
23 | - 后台 SW: [src/scripts/background.ts](mdc:src/scripts/background.ts) → `background.js`
24 | - 内容脚本: [src/scripts/contentScript.ts](mdc:src/scripts/contentScript.ts) → `contentScript.js`
25 |
26 | - **样式与静态资源**
27 | - Tailwind 入口: [src/styles/tailwind.css](mdc:src/styles/tailwind.css)
28 | - 公共图标示例: [public/icon.png](mdc:public/icon.png)
29 | - 多语言示例: [_locales/en/messages.json](mdc:_locales/en/messages.json)
30 |
31 | - **UI 与工具**
32 | - 公共组件: [src/components/ui/button.tsx](mdc:src/components/ui/button.tsx), [src/components/ui/select.tsx](mdc:src/components/ui/select.tsx), [src/components/ui/label.tsx](mdc:src/components/ui/label.tsx)
33 | - 工具函数: [src/utils/cn.ts](mdc:src/utils/cn.ts), [src/utils/i18n.ts](mdc:src/utils/i18n.ts), [src/utils/rtl.ts](mdc:src/utils/rtl.ts)
34 |
35 | - **路径别名**
36 | - 通过 bundler 与 TS 配置将 `@/*` 指向 `src/*`,优先使用绝对导入:例如 `import { cn } from '@/utils/cn'`。
37 |
38 |
--------------------------------------------------------------------------------
/.cursor/rules/00-project-structure-and-entries.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | alwaysApply: true
3 | ---
4 | # 项目结构与入口映射
5 |
6 | - **核心配置**
7 | - [rspack.config.js](mdc:rspack.config.js)
8 | - [tsconfig.json](mdc:tsconfig.json)
9 | - [biome.json](mdc:biome.json)
10 | - [package.json](mdc:package.json)
11 |
12 | - **浏览器扩展清单**
13 | - [src/manifest.json](mdc:src/manifest.json)
14 | - 保持输出文件名与清单一致:
15 | - `background.service_worker` → `background.js`
16 | - `content_scripts[].js` → `contentScript.js`
17 | - `action.default_popup` → `popup.html`
18 | - `side_panel.default_path` → `sidePanel.html`
19 |
20 | - **入口与产物(保持名称一一对应)**
21 | - Popup 页面: [src/popup/popup.tsx](mdc:src/popup/popup.tsx) → [src/popup/popup.html](mdc:src/popup/popup.html)
22 | - Side Panel: [src/sidePanel/sidePanel.tsx](mdc:src/sidePanel/sidePanel.tsx) → [src/sidePanel/sidePanel.html](mdc:src/sidePanel/sidePanel.html)
23 | - 后台 SW: [src/scripts/background.ts](mdc:src/scripts/background.ts) → `background.js`
24 | - 内容脚本: [src/scripts/contentScript.ts](mdc:src/scripts/contentScript.ts) → `contentScript.js`
25 |
26 | - **样式与静态资源**
27 | - Tailwind 入口: [src/styles/tailwind.css](mdc:src/styles/tailwind.css)
28 | - 公共图标示例: [public/icon.png](mdc:public/icon.png)
29 | - 多语言示例: [_locales/en/messages.json](mdc:_locales/en/messages.json)
30 |
31 | - **UI 与工具**
32 | - 公共组件: [src/components/ui/button.tsx](mdc:src/components/ui/button.tsx), [src/components/ui/select.tsx](mdc:src/components/ui/select.tsx), [src/components/ui/label.tsx](mdc:src/components/ui/label.tsx)
33 | - 工具函数: [src/utils/cn.ts](mdc:src/utils/cn.ts), [src/utils/i18n.ts](mdc:src/utils/i18n.ts), [src/utils/rtl.ts](mdc:src/utils/rtl.ts)
34 |
35 | - **路径别名**
36 | - 通过 bundler 与 TS 配置将 `@/*` 指向 `src/*`,优先使用绝对导入:例如 `import { cn } from '@/utils/cn'`。
37 |
38 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 | import { cn } from '@/utils/cn';
5 |
6 | const buttonVariants = cva(
7 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:pointer-events-none disabled:opacity-50',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400',
12 | secondary: 'bg-black text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-100',
13 | outline: 'border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-neutral-800',
14 | ghost: 'hover:bg-gray-50 dark:hover:bg-neutral-800',
15 | },
16 | size: {
17 | default: 'h-9 px-4 py-2',
18 | sm: 'h-8 rounded-md px-3',
19 | lg: 'h-10 rounded-md px-8',
20 | icon: 'h-9 w-9',
21 | },
22 | },
23 | defaultVariants: {
24 | variant: 'default',
25 | size: 'default',
26 | },
27 | }
28 | );
29 |
30 | export interface ButtonProps
31 | extends React.ButtonHTMLAttributes,
32 | VariantProps {
33 | asChild?: boolean;
34 | }
35 |
36 | export const Button = React.forwardRef(
37 | ({ className, variant, size, asChild = false, ...props }, ref) => {
38 | const Comp = asChild ? Slot : 'button';
39 | return (
40 |
41 | );
42 | }
43 | );
44 | Button.displayName = 'Button';
45 |
46 |
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "native-translate",
3 | "version": "2.2.2",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "vitest run",
8 | "test:watch": "vitest",
9 | "test:ui": "vitest --ui",
10 | "tsc": "tsc",
11 | "dev": "rspack build --watch",
12 | "build": "vitest run && rspack build --mode production",
13 | "lint": "biome check .",
14 | "lint:fix": "biome check . --write"
15 | },
16 | "keywords": [
17 | "browser",
18 | "extension",
19 | "chrome"
20 | ],
21 | "author": "zhanghe.dev",
22 | "license": "MIT",
23 | "packageManager": "pnpm@10.18.3",
24 | "devDependencies": {
25 | "@biomejs/biome": "^1.9.4",
26 | "@rspack/cli": "^1.6.7",
27 | "@rspack/core": "^1.6.7",
28 | "@tailwindcss/postcss": "^4.1.18",
29 | "@testing-library/dom": "^10.4.1",
30 | "@testing-library/jest-dom": "^6.9.1",
31 | "@testing-library/react": "^16.3.0",
32 | "@testing-library/user-event": "^14.6.1",
33 | "@types/jest": "^30.0.0",
34 | "@types/jszip": "^3.4.1",
35 | "@types/react": "^19.2.7",
36 | "@types/react-dom": "^19.2.3",
37 | "chrome-types": "^0.1.396",
38 | "css-loader": "^7.1.2",
39 | "jsdom": "^27.3.0",
40 | "postcss": "^8.5.6",
41 | "postcss-loader": "^8.2.0",
42 | "tailwindcss": "^4.1.18",
43 | "ts-loader": "^9.5.4",
44 | "typescript": "^5.9.3",
45 | "vitest": "^4.0.15"
46 | },
47 | "dependencies": {
48 | "@radix-ui/react-label": "^2.1.8",
49 | "@radix-ui/react-select": "^2.2.6",
50 | "@radix-ui/react-slot": "^1.2.4",
51 | "@radix-ui/react-switch": "^1.2.6",
52 | "@radix-ui/react-tabs": "^1.1.13",
53 | "class-variance-authority": "^0.7.1",
54 | "clsx": "^2.1.1",
55 | "jszip": "^3.10.1",
56 | "lucide-react": "^0.540.0",
57 | "radash": "^12.1.1",
58 | "react": "^19.2.3",
59 | "react-dom": "^19.2.3",
60 | "tailwind-merge": "^3.4.0"
61 | }
62 | }
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as TabsPrimitive from '@radix-ui/react-tabs';
3 | import { cn } from '@/utils/cn';
4 |
5 | const Tabs = TabsPrimitive.Root;
6 |
7 | const TabsList = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 | ));
20 | TabsList.displayName = TabsPrimitive.List.displayName;
21 |
22 | const TabsTrigger = React.forwardRef<
23 | React.ElementRef,
24 | React.ComponentPropsWithoutRef
25 | >(({ className, ...props }, ref) => (
26 |
34 | ));
35 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
36 |
37 | const TabsContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, ...props }, ref) => (
41 |
49 | ));
50 | TabsContent.displayName = TabsPrimitive.Content.displayName;
51 |
52 | export { Tabs, TabsList, TabsTrigger, TabsContent };
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cn } from '@/utils/cn';
3 |
4 | const Card = React.forwardRef<
5 | HTMLDivElement,
6 | React.HTMLAttributes
7 | >(({ className, ...props }, ref) => (
8 |
16 | ));
17 | Card.displayName = 'Card';
18 |
19 | const CardHeader = React.forwardRef<
20 | HTMLDivElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
28 | ));
29 | CardHeader.displayName = 'CardHeader';
30 |
31 | const CardTitle = React.forwardRef<
32 | HTMLParagraphElement,
33 | React.HTMLAttributes
34 | >(({ className, ...props }, ref) => (
35 |
40 | ));
41 | CardTitle.displayName = 'CardTitle';
42 |
43 | const CardDescription = React.forwardRef<
44 | HTMLParagraphElement,
45 | React.HTMLAttributes
46 | >(({ className, ...props }, ref) => (
47 |
52 | ));
53 | CardDescription.displayName = 'CardDescription';
54 |
55 | const CardContent = React.forwardRef<
56 | HTMLDivElement,
57 | React.HTMLAttributes
58 | >(({ className, ...props }, ref) => (
59 |
60 | ));
61 | CardContent.displayName = 'CardContent';
62 |
63 | const CardFooter = React.forwardRef<
64 | HTMLDivElement,
65 | React.HTMLAttributes
66 | >(({ className, ...props }, ref) => (
67 |
72 | ));
73 | CardFooter.displayName = 'CardFooter';
74 |
75 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import { cn } from '@/utils/cn';
4 |
5 | const alertVariants = cva(
6 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-gray-950 [&>svg~*]:pl-7 dark:[&>svg]:text-gray-50',
7 | {
8 | variants: {
9 | variant: {
10 | default: 'bg-white text-gray-950 border-gray-200 dark:bg-neutral-950 dark:text-gray-50 dark:border-neutral-800',
11 | destructive:
12 | 'border-red-500/50 text-red-900 dark:text-red-50 [&>svg]:text-red-900 dark:[&>svg]:text-red-50 bg-red-50 dark:bg-red-950/10',
13 | warning:
14 | 'border-yellow-500/50 text-yellow-900 dark:text-yellow-50 [&>svg]:text-yellow-900 dark:[&>svg]:text-yellow-50 bg-yellow-50 dark:bg-yellow-950/10',
15 | success:
16 | 'border-green-500/50 text-green-900 dark:text-green-50 [&>svg]:text-green-900 dark:[&>svg]:text-green-50 bg-green-50 dark:bg-green-950/10',
17 | },
18 | },
19 | defaultVariants: {
20 | variant: 'default',
21 | },
22 | }
23 | );
24 |
25 | const Alert = React.forwardRef<
26 | HTMLDivElement,
27 | React.HTMLAttributes & VariantProps
28 | >(({ className, variant, ...props }, ref) => (
29 |
35 | ));
36 | Alert.displayName = 'Alert';
37 |
38 | const AlertTitle = React.forwardRef<
39 | HTMLParagraphElement,
40 | React.HTMLAttributes
41 | >(({ className, ...props }, ref) => (
42 |
47 | ));
48 | AlertTitle.displayName = 'AlertTitle';
49 |
50 | const AlertDescription = React.forwardRef<
51 | HTMLParagraphElement,
52 | React.HTMLAttributes
53 | >(({ className, ...props }, ref) => (
54 |
59 | ));
60 | AlertDescription.displayName = 'AlertDescription';
61 |
62 | export { Alert, AlertTitle, AlertDescription };
--------------------------------------------------------------------------------
/src/utils/useChromeLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface UseChromeLocalStorageOptions {
4 | debounceMs?: number;
5 | serialize?: (value: T) => unknown;
6 | deserialize?: (stored: unknown) => T;
7 | }
8 |
9 | const DEFAULT_DEBOUNCE = 250;
10 |
11 | export function useChromeLocalStorage(
12 | key: string,
13 | defaultValue: T,
14 | options?: UseChromeLocalStorageOptions
15 | ): readonly [T, React.Dispatch>, boolean] {
16 | const { debounceMs = DEFAULT_DEBOUNCE, serialize, deserialize } = options ?? {};
17 | const [value, setValue] = React.useState(defaultValue);
18 | const [isHydrated, setIsHydrated] = React.useState(false);
19 | const timeoutRef = React.useRef(null);
20 |
21 | React.useEffect(() => {
22 | let active = true;
23 | (async () => {
24 | try {
25 | const stored = await chrome.storage.local.get(key);
26 | if (!active) return;
27 | const raw = stored?.[key as keyof typeof stored];
28 | if (raw !== undefined) {
29 | setValue(deserialize ? deserialize(raw) : (raw as T));
30 | } else {
31 | setValue(defaultValue);
32 | }
33 | } catch (error) {
34 | console.warn('Failed to read chrome.storage.local key', key, error);
35 | setValue(defaultValue);
36 | } finally {
37 | if (active) setIsHydrated(true);
38 | }
39 | })();
40 |
41 | return () => {
42 | active = false;
43 | };
44 | }, [key, defaultValue, deserialize]);
45 |
46 | React.useEffect(() => {
47 | if (!isHydrated) return undefined;
48 | if (timeoutRef.current) {
49 | window.clearTimeout(timeoutRef.current);
50 | }
51 | timeoutRef.current = window.setTimeout(() => {
52 | const payload = serialize ? serialize(value) : value;
53 | void chrome.storage.local.set({ [key]: payload }).catch((error: unknown) => {
54 | console.warn('Failed to write chrome.storage.local key', key, error);
55 | });
56 | }, debounceMs);
57 |
58 | return () => {
59 | if (timeoutRef.current) {
60 | window.clearTimeout(timeoutRef.current);
61 | timeoutRef.current = null;
62 | }
63 | };
64 | }, [value, key, isHydrated, debounceMs, serialize]);
65 |
66 | React.useDebugValue({ key, value, isHydrated });
67 |
68 | return [value, setValue, isHydrated] as const;
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SelectPrimitive from '@radix-ui/react-select';
3 | import { cn } from '@/utils/cn';
4 | import { ChevronDown, ChevronUp } from 'lucide-react';
5 |
6 | export interface Option {
7 | value: string;
8 | label: string;
9 | }
10 |
11 | export interface AppSelectProps {
12 | value: string;
13 | onValueChange: (value: string) => void;
14 | options: Option[];
15 | disabled?: boolean;
16 | }
17 |
18 | export function AppSelect({ value, onValueChange, options, disabled = false }: AppSelectProps) {
19 | return (
20 |
21 |
28 |
29 |
30 |
31 |
32 |
41 |
42 |
43 |
44 |
45 | {options.map((opt) => (
46 |
51 | {opt.label}
52 |
53 | ))}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/src/shared/streaming.ts:
--------------------------------------------------------------------------------
1 | // 共享:流式翻译相关的最小工具与类型
2 |
3 | export interface TranslatorInstance {
4 | ready?: Promise;
5 | translate: (text: string) => Promise;
6 | // 可选的流式翻译 API(不同实现形态不一致)
7 | translateStreaming?: (text: string) => unknown;
8 | }
9 |
10 | export const STREAMING_LENGTH_THRESHOLD = 500; // 文本较长时启用流式
11 |
12 | export function isReadableStreamLike(x: unknown): x is ReadableStream {
13 | return typeof x === 'object' && x !== null && typeof (x as any).getReader === 'function';
14 | }
15 |
16 | export function toStringChunk(chunk: unknown, decoder: TextDecoder): string {
17 | if (typeof chunk === 'string') return chunk;
18 | if (chunk instanceof Uint8Array) return decoder.decode(chunk, { stream: true });
19 | if (chunk && typeof (chunk as any).text === 'string') return (chunk as any).text;
20 | try {
21 | return String(chunk ?? '');
22 | } catch {
23 | return '';
24 | }
25 | }
26 |
27 | export async function* normalizeToAsyncStringIterable(
28 | source: unknown,
29 | registerReader?: (reader: ReadableStreamDefaultReader) => void,
30 | ): AsyncGenerator {
31 | const resolved = await Promise.resolve(source as any);
32 |
33 | if (resolved && typeof resolved[Symbol.asyncIterator] === 'function') {
34 | const decoder = new TextDecoder();
35 | for await (const chunk of resolved as AsyncIterable) {
36 | const text = toStringChunk(chunk, decoder);
37 | if (text) yield text;
38 | }
39 | const tail = new TextDecoder().decode();
40 | if (tail) yield tail;
41 | return;
42 | }
43 |
44 | const maybeReadable = isReadableStreamLike(resolved)
45 | ? (resolved as ReadableStream)
46 | : (resolved && typeof resolved === 'object' && isReadableStreamLike((resolved as any).readable))
47 | ? ((resolved as any).readable as ReadableStream)
48 | : null;
49 |
50 | if (maybeReadable) {
51 | const reader = maybeReadable.getReader();
52 | registerReader?.(reader as ReadableStreamDefaultReader);
53 | const decoder = new TextDecoder();
54 | try {
55 | while (true) {
56 | const { done, value } = await reader.read();
57 | if (done) break;
58 | const text = toStringChunk(value, decoder);
59 | if (text) yield text;
60 | }
61 | const tail = decoder.decode();
62 | if (tail) yield tail;
63 | } finally {
64 | try { await reader.cancel(); } catch { /* noop */ }
65 | }
66 | return;
67 | }
68 |
69 | if (Array.isArray(resolved)) {
70 | const decoder = new TextDecoder();
71 | for (const item of resolved) {
72 | const text = toStringChunk(item, decoder);
73 | if (text) yield text;
74 | }
75 | const tail = new TextDecoder().decode();
76 | if (tail) yield tail;
77 | return;
78 | }
79 |
80 | if (typeof resolved === 'string') {
81 | yield resolved;
82 | return;
83 | }
84 | }
85 |
86 |
87 |
--------------------------------------------------------------------------------
/.github/workflows/release-on-tag.yml:
--------------------------------------------------------------------------------
1 | name: Release on Tag
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build-and-release:
10 | name: Build and Release
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: write
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0
19 | fetch-tags: true
20 |
21 | - name: Setup pnpm
22 | uses: pnpm/action-setup@v4
23 | with:
24 | run_install: false
25 |
26 | - name: Setup Node.js
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: 20
30 | cache: 'pnpm'
31 |
32 | - name: Install dependencies
33 | run: pnpm install --frozen-lockfile
34 |
35 | - name: Build
36 | run: pnpm build
37 |
38 | - name: Verify zip exists
39 | run: test -f Native-translate.zip && ls -lh Native-translate.zip
40 |
41 | - name: Generate changelog from commits
42 | id: changelog
43 | shell: bash
44 | run: |
45 | set -euo pipefail
46 | git fetch --tags --force
47 | TAG="${GITHUB_REF_NAME}"
48 | # Find previous semver tag (descending order), excluding current
49 | PREV_TAG=$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -n 1 || true)
50 | if [ -n "${PREV_TAG}" ]; then
51 | RANGE="${PREV_TAG}..${TAG}"
52 | else
53 | # First tag — include all history up to current tag
54 | RANGE="${TAG}"
55 | fi
56 | echo "Using range: ${RANGE} (prev: ${PREV_TAG:-none})"
57 | COMMITS=$(git log --pretty=format:'- %s' ${RANGE} || true)
58 | if [ -z "${COMMITS}" ]; then
59 | COMMITS="- No changes."
60 | fi
61 | {
62 | echo "text<> "$GITHUB_OUTPUT"
68 |
69 | - name: Create GitHub Release
70 | uses: softprops/action-gh-release@v2
71 | with:
72 | name: ${{ github.ref_name }}
73 | tag_name: ${{ github.ref_name }}
74 | generate_release_notes: false
75 | body: ${{ steps.changelog.outputs.text }}
76 | draft: false
77 | prerelease: false
78 | files: |
79 | Native-translate.zip
80 | env:
81 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82 |
83 | - name: Upload to Chrome Web Store
84 | if: ${{ success() }}
85 | uses: mnao305/chrome-extension-upload@v5.0.0
86 | with:
87 | file-path: Native-translate.zip
88 | extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
89 | client-id: ${{ secrets.CHROME_CLIENT_ID }}
90 | client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
91 | refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
92 | publish: true
93 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines
2 |
3 | ## Project Structure & Module Organization
4 | Native Translate is a Chrome MV3 extension. Source lives in `src/`, with `components/` for shared UI primitives (Radix wrappers under `src/components/ui`), feature entry points in `popup/`, `sidePanel/`, `offscreen/`, and `scripts/`. Shared utilities sit in `src/utils`, cross-cutting logic in `src/shared`, and styles in `src/styles`. Packaged assets stay in `public/`, localized strings in `_locales/*`, and release artifacts in `dist/`; do not edit generated files.
5 |
6 | ## Build, Test, and Development Commands
7 | Use `pnpm dev` for watch builds during extension work. Run `pnpm build` to produce the distributable in `dist/` and the release zip. `pnpm tsc` performs strict type checking, while `pnpm lint` enforces Biome formatting and quality rules; `pnpm lint:fix` applies safe auto-fixes.
8 |
9 | ## Coding Style & Naming Conventions
10 | Follow the Biome config: 2-space indentation, 100-character line width, single quotes in TS/JS, double quotes in JSX, and semicolons as needed. Keep TypeScript strict by avoiding `any` and preferring explicit generics or `unknown`. Order imports React → third-party → local, and reference project modules with `@/*` aliases. Components must be function components with explicit prop interfaces, Radix UI wrappers from `src/components/ui`, and Tailwind powered via `cn()`; use `dark:` variants for themes and `z-[2147483647]` for overlays. Persist extension state with `chrome.storage.local`.
11 |
12 | - Prefer the shared `useChromeLocalStorage` hook for popup/side panel state so reads happen once and writes are debounced.
13 | - Keep overlays pointer-safe and accessible (`aria-live="polite"`, `role="status"`).
14 | - When touching content-script translations, use `buildCacheKey` helpers and batch DOM writes (see `translateBlocksSequentially`).
15 |
16 | ## Testing Guidelines
17 | Until automated tests exist, always run `pnpm lint` and `pnpm tsc` before opening a PR. Manually verify translation flows in Chrome: popup text translation, side panel session handling, file translation, and locale switching (confirm `dir` matches the active language). Name future specs `*.test.ts(x)` beside implementation files and document execution steps in the PR.
18 |
19 | ## Commit & Pull Request Guidelines
20 | Adopt Conventional Commits as in recent history (e.g. `feat(sidePanel): add PDF upload`). Keep commits focused, written in English, and scoped to a feature or module. PR descriptions must summarize behavior changes, list manual checks, link issues, and attach UI screenshots or recordings. Call out localization edits and manifest updates explicitly.
21 |
22 | ## Localization & Security Notes
23 | Pull user-facing strings from `_locales` via Chrome i18n APIs and update translated copies together. Sanitize external translation responses before rendering, and never commit API keys—store secrets through Chrome-managed configuration or `chrome.storage.local` at runtime.
24 |
25 | ## Messaging & Warm-up
26 | - Runtime message constants live in `src/shared/messages.ts`. Extend that file first when adding new cross-context communication.
27 | - Use `MSG_WARM_TRANSLATOR` to pre-warm language pairs instead of forcing dummy translations; the content script schedules work via `requestIdleCallback` and tracks warmed pairs.
28 | - Background → side panel automation (confetti, auto-enable) should stay idempotent; prefer `chrome.storage.local` flags over long-lived globals.
29 |
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 | # 原生翻译 (Native Translate)
2 |
3 | [](https://github.com/zh30/native-translate/actions/workflows/release-on-tag.yml)
4 | [](https://opensource.org/licenses/MIT)
5 | [](https://chrome.google.com/webstore/detail/native-translate/)
6 | [](https://peerlist.io/zhanghe)
7 |
8 | [English](./README.md) | 简体中文
9 |
10 | **原生翻译**是一款专注于隐私保护的 Chrome 扩展程序,使用 Chrome 内置的 AI 翻译和语言检测 API。所有翻译都在您的设备本地完成 - 无需外部 API 调用,无遥测数据,完全保护隐私。
11 |
12 | ## 功能特性
13 |
14 | ### 🌐 翻译模式
15 | - **整页翻译**:翻译整个网页,同时保持原始布局
16 | - **悬停翻译**:按住修饰键(Alt/Control/Shift)悬停文本即可即时翻译
17 | - **输入框翻译**:在任何输入框中输入三个空格即可翻译您的内容
18 | - **侧边栏翻译器**:自由文本翻译,实时显示结果
19 | - **EPUB 文件翻译**:上传并翻译 EPUB 电子书,支持进度跟踪
20 |
21 | ### 🚀 高级功能
22 | - **本地处理**:使用 Chrome 内置的 AI 模型(Chrome 138+)
23 | - **流式翻译**:长文本实时渐进式翻译
24 | - **智能内容检测**:智能跳过代码块、表格和导航元素
25 | - **预测预热**:切换目标语言时预先拉起模型,避免首次翻译卡顿
26 | - **多框架支持**:在所有框架中工作,包括 about:blank 页面
27 | - **输入法支持**:正确处理亚洲语言输入法
28 | - **离线功能**:模型下载后可离线工作
29 |
30 | ### 🛡️ 隐私与安全
31 | - **零数据收集**:无分析、无跟踪、无云端请求
32 | - **本地处理**:所有翻译都在您的设备上完成
33 | - **最小权限**:仅必要的 Chrome 扩展权限
34 | - **开源**:MIT 许可,代码完全透明
35 |
36 | ## 系统要求
37 |
38 | - **Chrome 138+**(内置 AI API 支持)
39 | - **pnpm 9.15.1+**(包管理器)
40 |
41 | ## 安装
42 |
43 | ### 从 Chrome 应用商店安装
44 | [从 Chrome 应用商店安装](https://chromewebstore.google.com/detail/native-translate-%E2%80%94-privat/npnbioleceelkeepkobjfagfchljkphb/)
45 |
46 | ### 从源码安装
47 |
48 | ```bash
49 | # 克隆仓库
50 | git clone https://github.com/zh30/native-translate.git
51 | cd native-translate
52 |
53 | # 安装依赖
54 | pnpm install
55 |
56 | # 开发构建(自动重载)
57 | pnpm dev
58 |
59 | # 在 Chrome 中加载扩展
60 | # 1. 打开 chrome://extensions
61 | # 2. 启用"开发者模式"
62 | # 3. 点击"加载已解压的扩展程序"
63 | # 4. 选择 `dist` 文件夹
64 | ```
65 |
66 | ## 使用说明
67 |
68 | ### 基本翻译
69 | 1. **从 Chrome 工具栏打开扩展弹出窗口**
70 | 2. **选择目标语言**
71 | 3. **选择悬停修饰键**(Alt/Control/Shift)
72 | 4. **点击"翻译当前网页"**进行整页翻译
73 |
74 | ### 翻译方式
75 | - **悬停翻译**:按住修饰键悬停任何文本
76 | - **输入翻译**:在任何文本框中输入三个空格
77 | - **侧边栏**:打开进行自由文本翻译
78 | - **EPUB 文件**:上传并翻译整本书籍
79 |
80 | ## 支持的语言
81 |
82 | 25+ 种语言,包括:
83 | - 英语、中文(简体/繁体)、日语、韩语
84 | - 法语、德语、西班牙语、意大利语、葡萄牙语
85 | - 俄语、阿拉伯语、印地语、孟加拉语、印尼语
86 | - 土耳其语、越南语、泰语、荷兰语、波兰语
87 | - 波斯语、乌尔都语、乌克兰语、瑞典语、菲律宾语
88 |
89 | ## 开发
90 |
91 | ```bash
92 | # 开发
93 | pnpm dev # 监听模式构建和自动重载
94 | pnpm build # 生产构建和 zip 打包
95 | pnpm tsc # 类型检查
96 | pnpm lint # 代码检查
97 | pnpm lint:fix # 修复代码问题
98 | ```
99 |
100 | ### 技术栈
101 | - **前端**:React 19 + TypeScript + Tailwind CSS v4
102 | - **构建**:Rspack + SWC
103 | - **UI 组件**:Radix UI 基础组件
104 | - **扩展 API**:Chrome Manifest V3
105 |
106 | ## 架构
107 |
108 | ```
109 | src/
110 | ├── scripts/
111 | │ ├── background.ts # Service Worker
112 | │ └── contentScript.ts # 主翻译引擎
113 | ├── popup/ # 扩展弹出界面
114 | ├── sidePanel/ # 侧边栏界面
115 | ├── components/ui/ # 可重用 UI 组件
116 | ├── shared/ # 共享类型和工具
117 | └── utils/ # 辅助函数
118 | ```
119 |
120 | ## 故障排除
121 |
122 | ### 常见问题
123 | - **"Translator API 不可用"**:确保 Chrome 138+ 且设备支持 AI 模型
124 | - **翻译不工作**:检查页面是否允许脚本注入(避免 chrome:// 页面)
125 | - **悬停翻译未触发**:在弹出窗口中验证修饰键设置
126 | - **首次翻译缓慢**:每个语言对首次使用时需要下载模型
127 |
128 | ### 性能
129 | - 每个语言对的模型在首次使用后会被缓存
130 | - 翻译结果会被缓存以加速后续访问
131 | - 弹出层和侧边栏会主动预热下一个语言对,减少冷启动等待
132 | - 使用 WeakSet 跟踪优化内存使用
133 |
134 | ## 贡献
135 |
136 | 欢迎贡献!请阅读我们的[贡献指南](CONTRIBUTING.md)了解详情。
137 |
138 | 1. Fork 仓库
139 | 2. 创建功能分支
140 | 3. 进行更改
141 | 4. 添加测试(如适用)
142 | 5. 提交拉取请求
143 |
144 | ### 开发标准
145 | - **TypeScript**:严格模式,公共 API 需要显式类型注解
146 | - **React 19**:使用 hooks 的函数组件
147 | - **代码风格**:Biome 检查,2 空格缩进
148 | - **测试**:提交前确保所有测试通过
149 |
150 | ## 许可证
151 |
152 | MIT © [zhanghe.dev](https://zhanghe.dev)
153 |
154 | ---
155 |
156 | **隐私声明**:此扩展在您的设备本地处理所有数据。不会将任何内容发送到外部服务器。
157 |
--------------------------------------------------------------------------------
/develop.md:
--------------------------------------------------------------------------------
1 | # Native Translate – Developer Notes
2 |
3 | This document captures the day-to-day workflow for working on the extension. Use it alongside `AGENTS.md` for conventions and the README for product messaging.
4 |
5 | ## Prerequisites
6 | - Chrome 138+ (required for the built-in Translator/Language Detector APIs).
7 | - `pnpm` ≥ 9.15.1.
8 | - Optional: enable the `chrome://flags/#enable-desktop-pwas-link-capturing` flag when testing side panel behaviour in PWAs.
9 |
10 | ## Local Setup
11 | ```bash
12 | pnpm install # install dependencies
13 | pnpm dev # rspack watch; outputs to dist/ with live rebuild
14 | pnpm build # production build + zip package
15 | pnpm tsc --noEmit # strict type checking
16 | pnpm lint # biome lint (see note below about existing formatting noise)
17 | ```
18 |
19 | > Biome currently reports formatting diffs in several config and locale files. Until that backlog is cleared, `pnpm tsc` acts as the primary gate for CI-quality validation.
20 |
21 | ## Manual QA Checklist
22 | 1. **Popup**
23 | - Switch target/input languages and confirm the spinner + disabled state display correctly.
24 | - Trigger “Translate current page”; verify warm-up has already happened (overlay should skip long downloads after the first run).
25 | 2. **Hover Translate**
26 | - Hold the configured modifier (Alt/Control/Shift) and hover paragraphs, headings, and inline text.
27 | - Confirm translated text is inserted as a sibling node without breaking layout.
28 | 3. **Side Panel**
29 | - Open via popup button and via the Chrome side panel icon.
30 | - Test free-form translation; observe placeholder skeleton while streaming.
31 | - Upload several EPUB files (valid + invalid) and ensure progress + error states render.
32 | 4. **Input Triple-Space**
33 | - Use both `` and `contenteditable` targets. Confirm IME composition does not trigger translation mid-flow.
34 | 5. **Background Rules**
35 | - Navigate to `https://zhanghe.dev/` to ensure the side panel auto-enables and confetti flag fires once.
36 |
37 | ## Messaging & Warm-up
38 | The extension communicates across contexts using messages defined in `src/shared/messages.ts`:
39 |
40 | - `MSG_TRANSLATE_PAGE`: popup → content script full-page translation.
41 | - `MSG_TRANSLATE_TEXT`: side panel → content script text translation.
42 | - `MSG_UPDATE_HOTKEY`: popup → content script modifier updates.
43 | - `MSG_WARM_TRANSLATOR`: popup/side panel → content script predictive warm-up. This is fired when a user changes target or input languages so the content script can pre-create the translator pair via `requestIdleCallback`.
44 |
45 | Warm-up state is tracked in `contentScript.ts` (`warmingPairs` + `READY_PAIRS_KEY`). When adding new features, prefer sending `MSG_WARM_TRANSLATOR` instead of forcing an immediate translation to keep the UI responsive.
46 |
47 | ## Caching & Performance Notes
48 | - `useChromeLocalStorage` (in `src/utils/`) hydrates once per key and debounces writes, keeping chrome.storage churn minimal.
49 | - Translation caching is keyed by language pairs. Use `buildCacheKey` helpers when adding new translation paths.
50 | - Overlays now set `aria-live="polite"` and avoid pointer events, so additional status banners should follow the same pattern.
51 | - Any heavy DOM mutations should be batched; see `translateBlocksSequentially` for an example using document fragments and idle yields.
52 |
53 | ## Release Process
54 | - Create a tag `vX.Y.Z` to trigger the `release-on-tag` GitHub Action (see badge in README).
55 | - The action runs `pnpm build`, attaches the packaged zip, and updates the Chrome Web Store listing if credentials are present.
56 | - Double-check `_locales/` before tagging—Chrome requires every string to have translations for all supported locales.
57 |
58 | ## Observability & Debugging
59 | - Enable “All levels” logging in DevTools for the extension background service worker to catch `console.info` messages from `background.ts`.
60 | - For content-script tracing, use the DevTools “Sources” panel on the target page and search for `native-translate` in the DOM tree to inspect inserted nodes.
61 | - To simulate cold start, clear `chrome.storage.local` and reload the extension; the warm-up hooks should restore ready pairs after the first interaction.
62 |
63 | Happy translating!
64 |
--------------------------------------------------------------------------------
/rspack.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | const path = require('path');
4 | const { defineConfig } = require('@rspack/cli');
5 | const rspack = require('@rspack/core');
6 | const fs = require('fs');
7 | const { execSync } = require('child_process');
8 |
9 | class ZipAfterBuildPlugin {
10 | /**
11 | * @param {{ outputName?: string }=} options
12 | */
13 | constructor(options = {}) {
14 | this.outputName = options.outputName || 'Native-translate.zip';
15 | }
16 |
17 | /**
18 | * @param {import('@rspack/core').Compiler} compiler
19 | */
20 | apply(compiler) {
21 | // eslint-disable-next-line no-console
22 | console.log('[zip] Plugin apply, hooks:', {
23 | hasAfterEmit: Boolean(compiler.hooks && compiler.hooks.afterEmit),
24 | hasDone: Boolean(compiler.hooks && compiler.hooks.done),
25 | });
26 | const runZip = () => {
27 | try {
28 | const outDir = compiler.options.output && compiler.options.output.path ? compiler.options.output.path : path.resolve(process.cwd(), 'dist');
29 | const zipPath = path.resolve(outDir, '..', this.outputName);
30 | // eslint-disable-next-line no-console
31 | console.log(`[zip] Start zipping from ${outDir} -> ${zipPath}`);
32 | try {
33 | fs.unlinkSync(zipPath);
34 | } catch { }
35 | execSync(`zip -r "${zipPath}" .`, { cwd: outDir, stdio: 'inherit' });
36 | // eslint-disable-next-line no-console
37 | console.log(`[zip] Created ${zipPath}`);
38 | } catch (err) {
39 | // eslint-disable-next-line no-console
40 | console.error('[zip] Failed to create zip:', err);
41 | }
42 | };
43 |
44 | if (compiler.hooks.afterEmit) {
45 | compiler.hooks.afterEmit.tap('ZipAfterBuildPlugin', () => runZip());
46 | } else if (compiler.hooks.done) {
47 | compiler.hooks.done.tap('ZipAfterBuildPlugin', () => runZip());
48 | }
49 | }
50 | }
51 |
52 | module.exports = (env, argv) => {
53 | const cliMode = argv?.mode;
54 | const mode = cliMode || process.env.NODE_ENV || 'development';
55 | const isProd = mode === 'production';
56 |
57 | return defineConfig({
58 | mode,
59 | entry: {
60 | popup: './src/popup/popup.tsx',
61 | sidePanel: './src/sidePanel/sidePanel.tsx',
62 | background: './src/scripts/background.ts',
63 | contentScript: './src/scripts/contentScript.ts',
64 | },
65 | output: {
66 | path: path.resolve(__dirname, 'dist'),
67 | filename: '[name].js',
68 | // 禁止额外 runtime 与分包后产生多余 chunk,确保与 manifest 一一对应
69 | chunkFilename: '[name].js',
70 | publicPath: '',
71 | // 在扩展页面与 Service Worker/Content Script 环境均可用
72 | globalObject: 'self',
73 | clean: true,
74 | },
75 | resolve: {
76 | extensions: ['.tsx', '.ts', '.jsx', '.js'],
77 | alias: {
78 | '@': path.resolve(__dirname, 'src'),
79 | },
80 | },
81 | module: {
82 | rules: [
83 | {
84 | test: /\.tsx?$/,
85 | exclude: /node_modules/,
86 | type: 'javascript/auto',
87 | use: {
88 | loader: 'builtin:swc-loader',
89 | options: {
90 | jsc: {
91 | target: 'es2022',
92 | parser: {
93 | syntax: 'typescript',
94 | tsx: true,
95 | },
96 | transform: {
97 | react: {
98 | runtime: 'automatic',
99 | development: !isProd,
100 | throwIfNamespace: true,
101 | },
102 | },
103 | },
104 | sourceMaps: !isProd,
105 | },
106 | },
107 | },
108 | {
109 | test: /\.css$/i,
110 | use: [rspack.CssExtractRspackPlugin.loader, 'css-loader', 'postcss-loader'],
111 | },
112 | {
113 | test: /\.(png|jpe?g|gif|svg|ico|webp|avif)$/i,
114 | type: 'asset/resource',
115 | generator: {
116 | filename: 'assets/[name][ext]'
117 | }
118 | },
119 | {
120 | test: /\.(woff2?|ttf|otf|eot)$/i,
121 | type: 'asset/resource',
122 | generator: {
123 | filename: 'assets/fonts/[name][ext]'
124 | }
125 | }
126 | ],
127 | },
128 | plugins: [
129 | new rspack.DefinePlugin({
130 | 'process.env.NODE_ENV': JSON.stringify(mode),
131 | __DEV__: JSON.stringify(!isProd),
132 | }),
133 | new rspack.CssExtractRspackPlugin({
134 | filename: '[name].css',
135 | }),
136 | new rspack.HtmlRspackPlugin({
137 | template: './src/popup/popup.html',
138 | filename: 'popup.html',
139 | chunks: ['popup'],
140 | minify: isProd,
141 | }),
142 | new rspack.HtmlRspackPlugin({
143 | template: './src/sidePanel/sidePanel.html',
144 | filename: 'sidePanel.html',
145 | chunks: ['sidePanel'],
146 | minify: isProd,
147 | }),
148 | new rspack.CopyRspackPlugin({
149 | patterns: [
150 | { from: 'public', to: 'public' },
151 | { from: 'src/manifest.json', to: 'manifest.json' },
152 | { from: '_locales', to: '_locales' },
153 | ],
154 | }),
155 | ...(isProd ? [new ZipAfterBuildPlugin({ outputName: 'Native-translate.zip' })] : []),
156 | ],
157 | devtool: isProd ? false : 'cheap-module-source-map',
158 | cache: true,
159 | optimization: {
160 | minimize: isProd,
161 | splitChunks: false,
162 | runtimeChunk: false,
163 | },
164 | performance: { hints: false },
165 | stats: 'errors-warnings',
166 | watchOptions: {
167 | ignored: ['**/dist/**', '**/node_modules/**'],
168 | },
169 | });
170 | };
171 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Native Translate
2 |
3 | [](https://github.com/zh30/native-translate/actions/workflows/release-on-tag.yml)
4 | [](https://opensource.org/licenses/MIT)
5 | [](https://chrome.google.com/webstore/detail/native-translate/)
6 | [](https://peerlist.io/zhanghe)
7 |
8 | English | [简体中文](./README.zh-CN.md)
9 |
10 | **Native Translate** is a privacy-focused Chrome extension that uses Chrome's built-in AI Translator and Language Detector APIs. All translation happens locally on your device - no external API calls, no telemetry, complete privacy.
11 |
12 | ## Features
13 |
14 | ### 🌐 Translation Modes
15 | - **Full-page translation**: Translates entire web pages while preserving original layout
16 | - **Hover-to-translate**: Hold modifier key (Alt/Control/Shift) and hover over text for instant translation
17 | - **Input field translation**: Type three spaces in any input field to translate your content
18 | - **Side panel translator**: Free-form text translation with real-time results
19 | - **EPUB file translation**: Upload and translate EPUB books with progress tracking
20 |
21 | ### 🚀 Advanced Capabilities
22 | - **On-device processing**: Uses Chrome's built-in AI models (Chrome 138+)
23 | - **Streaming translation**: Real-time progressive translation for longer texts
24 | - **Smart content detection**: Intelligently skips code blocks, tables, and navigation
25 | - **Predictive warm-up**: Pre-fetches language models when you switch targets to cut first-translation waits
26 | - **Multi-frame support**: Works across all frames including about:blank pages
27 | - **IME support**: Proper handling of Asian language input methods
28 | - **Offline capability**: Works offline after models are downloaded
29 |
30 | ### 🛡️ Privacy & Security
31 | - **Zero data collection**: No analytics, no tracking, no cloud requests
32 | - **Local processing**: All translation happens on your device
33 | - **Minimal permissions**: Only essential Chrome extension permissions
34 | - **Open source**: MIT licensed, fully transparent codebase
35 |
36 | ## Requirements
37 |
38 | - **Chrome 138+** (for built-in AI APIs)
39 | - **pnpm 9.15.1+** (package manager)
40 |
41 | ## Installation
42 |
43 | ### From Chrome Web Store
44 | [Install from Chrome Web Store](https://chromewebstore.google.com/detail/native-translate-%E2%80%94-privat/npnbioleceelkeepkobjfagfchljkphb/)
45 |
46 | ### From Source
47 |
48 | ```bash
49 | # Clone repository
50 | git clone https://github.com/zh30/native-translate.git
51 | cd native-translate
52 |
53 | # Install dependencies
54 | pnpm install
55 |
56 | # Development build with auto-reload
57 | pnpm dev
58 |
59 | # Load extension in Chrome
60 | # 1. Open chrome://extensions
61 | # 2. Enable "Developer mode"
62 | # 3. Click "Load unpacked"
63 | # 4. Select the `dist` folder
64 | ```
65 |
66 | ## Usage
67 |
68 | ### Basic Translation
69 | 1. **Open the extension popup** from the Chrome toolbar
70 | 2. **Select your target language**
71 | 3. **Choose a hover modifier key** (Alt/Control/Shift)
72 | 4. **Click "Translate current page"** for full-page translation
73 |
74 | ### Translation Methods
75 | - **Hover translation**: Hold modifier key and hover over any text
76 | - **Input translation**: Type three spaces in any text field
77 | - **Side panel**: Open for free-form text translation
78 | - **EPUB files**: Upload and translate entire books
79 |
80 | ## Supported Languages
81 |
82 | 25+ languages including:
83 | - English, Chinese (Simplified/Traditional), Japanese, Korean
84 | - French, German, Spanish, Italian, Portuguese
85 | - Russian, Arabic, Hindi, Bengali, Indonesian
86 | - Turkish, Vietnamese, Thai, Dutch, Polish
87 | - Persian, Urdu, Ukrainian, Swedish, Filipino
88 |
89 | ## Development
90 |
91 | ```bash
92 | # Development
93 | pnpm dev # Build with watch mode and auto-reload
94 | pnpm build # Production build with zip packaging
95 | pnpm tsc # Type checking
96 | pnpm lint # Code linting
97 | pnpm lint:fix # Fix linting issues
98 | ```
99 |
100 | ### Tech Stack
101 | - **Frontend**: React 19 + TypeScript + Tailwind CSS v4
102 | - **Build**: Rspack + SWC
103 | - **UI Components**: Radix UI primitives
104 | - **Extension APIs**: Chrome Manifest V3
105 |
106 | ## Architecture
107 |
108 | ```
109 | src/
110 | ├── scripts/
111 | │ ├── background.ts # Service worker
112 | │ └── contentScript.ts # Main translation engine
113 | ├── popup/ # Extension popup UI
114 | ├── sidePanel/ # Side panel interface
115 | ├── components/ui/ # Reusable UI components
116 | ├── shared/ # Shared types and utilities
117 | └── utils/ # Helper functions
118 | ```
119 |
120 | ## Troubleshooting
121 |
122 | ### Common Issues
123 | - **"Translator API unavailable"**: Ensure Chrome 138+ and device supports AI models
124 | - **Translation not working**: Check if page allows script injection (avoid chrome:// pages)
125 | - **Hover translation not triggering**: Verify modifier key settings in popup
126 | - **Slow first translation**: Initial model download occurs once per language pair
127 |
128 | ### Performance
129 | - Models are cached after first use per language pair
130 | - Translation results are cached for faster subsequent access
131 | - Popup and side panel proactively warm the next language pair to avoid cold-start stalls
132 | - Memory usage is optimized with WeakSet tracking
133 |
134 | ## Contributing
135 |
136 | Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details.
137 |
138 | 1. Fork the repository
139 | 2. Create a feature branch
140 | 3. Make your changes
141 | 4. Add tests if applicable
142 | 5. Submit a pull request
143 |
144 | ### Development Standards
145 | - **TypeScript**: Strict mode with explicit type annotations
146 | - **React 19**: Functional components with hooks
147 | - **Code Style**: Biome linting with 2-space indentation
148 | - **Testing**: Ensure all tests pass before submitting
149 |
150 | ## License
151 |
152 | MIT © [zhanghe.dev](https://zhanghe.dev)
153 |
154 | ---
155 |
156 | **Privacy Notice**: This extension processes all data locally on your device. No content is sent to external servers.
157 |
--------------------------------------------------------------------------------
/_locales/zh_CN/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "原生翻译 — 隐私优先的内置 AI 翻译",
4 | "description": "The name of the extension"
5 | },
6 | "extension_description": {
7 | "message": "隐私优先的 Chrome 扩展,内置 AI 翻译。无云端与遥测;内容不出浏览器。模型本地运行并缓存,支持离线。",
8 | "description": "The description of the extension"
9 | },
10 | "popup_title": {
11 | "message": "原生翻译",
12 | "description": "Popup 标题"
13 | },
14 | "global_availability": {
15 | "message": "全局可用性",
16 | "description": "可用性标签"
17 | },
18 | "pair_availability": {
19 | "message": "语言对可用性",
20 | "description": "语言对可用性标签"
21 | },
22 | "checking": {
23 | "message": "检测中…",
24 | "description": "检测中文案"
25 | },
26 | "recheck_availability": {
27 | "message": "重新检测可用性",
28 | "description": "重新检测按钮"
29 | },
30 | "source_language": {
31 | "message": "源语言",
32 | "description": "源语言标签"
33 | },
34 | "target_language": {
35 | "message": "目标语言",
36 | "description": "目标语言标签"
37 | },
38 | "preparing_translator": {
39 | "message": "准备中(可能在下载模型)…",
40 | "description": "准备翻译器中文案"
41 | },
42 | "create_prepare_translator": {
43 | "message": "创建/准备翻译器",
44 | "description": "创建翻译器按钮"
45 | },
46 | "download_progress": {
47 | "message": "下载进度",
48 | "description": "下载进度标签"
49 | },
50 | "translator_ready": {
51 | "message": "翻译器已就绪",
52 | "description": "翻译器就绪提示"
53 | },
54 | "translate_full_page": {
55 | "message": "翻译当前网页全文",
56 | "description": "翻译全文按钮"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "该操作会向当前标签页的内容脚本发送指令,后续会在页面内插入翻译结果。",
60 | "description": "翻译页面描述"
61 | },
62 | "hover_hotkey": {
63 | "message": "悬停翻译快捷键",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "按住所选修饰键并将鼠标悬停在文本上,即可翻译该段落。",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "使用内置 AI Translator API。模型下载需满足设备条件。— @Henry:https://zhanghe.dev/products/native-translate",
84 | "description": "页脚说明"
85 | },
86 | "sidepanel_title": {
87 | "message": "原生翻译侧边栏",
88 | "description": "Side Panel 标题"
89 | },
90 | "unknown_error": {
91 | "message": "未知错误",
92 | "description": "通用错误"
93 | },
94 | "translator_unavailable": {
95 | "message": "Translator API 不可用(需要 Chrome 138+ 且模型就绪)",
96 | "description": "翻译器不可用"
97 | },
98 | "create_translator_failed": {
99 | "message": "创建翻译器失败",
100 | "description": "创建失败"
101 | },
102 | "active_tab_not_found": {
103 | "message": "未找到活动标签页",
104 | "description": "找不到活动标签页"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "发送翻译指令失败",
108 | "description": "发送失败"
109 | },
110 | "availability_unknown": {
111 | "message": "未知",
112 | "description": "可用性徽标"
113 | },
114 | "availability_available": {
115 | "message": "可用",
116 | "description": "可用性徽标"
117 | },
118 | "availability_downloadable": {
119 | "message": "可下载",
120 | "description": "可用性徽标"
121 | },
122 | "availability_unavailable": {
123 | "message": "不可用",
124 | "description": "可用性徽标"
125 | },
126 | "overlay_preparing": {
127 | "message": "准备翻译器…",
128 | "description": "浮层:准备"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "Translator API 不可用(需要 Chrome 138+)",
132 | "description": "浮层:API 不可用"
133 | },
134 | "overlay_using_cached": {
135 | "message": "正在使用已缓存模型…",
136 | "description": "浮层:使用缓存"
137 | },
138 | "overlay_downloading": {
139 | "message": "正在下载模型… $1%",
140 | "description": "浮层:下载中"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "没有可翻译内容",
144 | "description": "浮层:无内容"
145 | },
146 | "overlay_translating": {
147 | "message": "正在翻译… $1%($2/$3)",
148 | "description": "浮层:翻译进度"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "翻译完成",
152 | "description": "浮层:完成"
153 | },
154 | "input_target_language": {
155 | "message": "输入框目标语言",
156 | "description": "Popup:输入框目标语言标签"
157 | },
158 | "input_target_language_desc": {
159 | "message": "输入内容将翻译为该语言。",
160 | "description": "Popup:输入目标语言说明"
161 | },
162 | "open_sidepanel": {
163 | "message": "侧边栏翻译",
164 | "description": "Popup:打开侧边栏按钮"
165 | },
166 | "auto_detect": {
167 | "message": "自动检测",
168 | "description": "侧边栏:自动检测选项"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "在这里输入要翻译的文本…",
172 | "description": "侧边栏:输入占位"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "翻译结果将显示在这里…",
176 | "description": "侧边栏:输出占位"
177 | },
178 | "text_translation_tab": {
179 | "message": "文本翻译",
180 | "description": "文本翻译标签页标题"
181 | },
182 | "file_translation_tab": {
183 | "message": "文件翻译",
184 | "description": "文件翻译标签页标题"
185 | },
186 | "file_upload_area": {
187 | "message": "拖拽 EPUB 文件到此处或点击选择文件",
188 | "description": "文件上传区域提示文字"
189 | },
190 | "file_supported_formats": {
191 | "message": "支持格式:EPUB 电子书",
192 | "description": "支持的文件格式说明"
193 | },
194 | "file_parsing": {
195 | "message": "正在解析文件…",
196 | "description": "文件解析状态文字"
197 | },
198 | "file_translating_progress": {
199 | "message": "正在翻译…请不要关闭侧边栏",
200 | "description": "翻译进度警告文字"
201 | },
202 | "translation_progress_detail": {
203 | "message": "已翻译 $1 段,共 $2 段",
204 | "description": "详细进度信息,$1 为已完成数量,$2 为总数量"
205 | },
206 | "translation_completed": {
207 | "message": "翻译完成!",
208 | "description": "翻译完成状态"
209 | },
210 | "download_translated_file": {
211 | "message": "下载翻译后的文件",
212 | "description": "下载按钮文字"
213 | },
214 | "auto_download": {
215 | "message": "下次完成后自动下载",
216 | "description": "自动下载开关文字"
217 | },
218 | "file_parse_error": {
219 | "message": "文件解析失败,请检查文件格式。",
220 | "description": "文件解析错误提示"
221 | },
222 | "unsupported_file_format": {
223 | "message": "不支持的文件格式,仅支持 EPUB 文件。",
224 | "description": "文件格式错误提示"
225 | },
226 | "file_too_large": {
227 | "message": "文件过大,请选择较小的文件。",
228 | "description": "文件大小错误提示"
229 | },
230 | "select_file_first": {
231 | "message": "请先选择一个文件。",
232 | "description": "未选择文件错误提示"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/ar/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "الترجمة الأصلية — ترجمة ذكاء اصطناعي مدمجة، خصوصية أولاً",
4 | "description": "اسم الإضافة"
5 | },
6 | "extension_description": {
7 | "message": "إضافة خاصة لكروم بمترجم ذكاء اصطناعي مدمج. بلا سحابة أو تتبّع؛ يبقى المحتوى داخل المتصفح. النماذج محلية وتعمل دون اتصال.",
8 | "description": "الوصف"
9 | },
10 | "popup_title": {
11 | "message": "الترجمة الأصلية",
12 | "description": "عنوان النافذة"
13 | },
14 | "global_availability": {
15 | "message": "التوفر العام",
16 | "description": "وسم"
17 | },
18 | "pair_availability": {
19 | "message": "توفر زوج اللغات",
20 | "description": "وسم"
21 | },
22 | "checking": {
23 | "message": "جارٍ التحقق…",
24 | "description": "حالة"
25 | },
26 | "recheck_availability": {
27 | "message": "إعادة التحقق من التوفر",
28 | "description": "زر"
29 | },
30 | "source_language": {
31 | "message": "لغة المصدر",
32 | "description": "وسم"
33 | },
34 | "target_language": {
35 | "message": "اللغة الهدف",
36 | "description": "وسم"
37 | },
38 | "preparing_translator": {
39 | "message": "جارٍ التحضير (قد يتم تنزيل النموذج)…",
40 | "description": "تحضير"
41 | },
42 | "create_prepare_translator": {
43 | "message": "إنشاء/تحضير المترجم",
44 | "description": "زر"
45 | },
46 | "download_progress": {
47 | "message": "تقدم التنزيل",
48 | "description": "وسم"
49 | },
50 | "translator_ready": {
51 | "message": "المترجم جاهز",
52 | "description": "إشعار"
53 | },
54 | "translate_full_page": {
55 | "message": "ترجمة الصفحة الحالية",
56 | "description": "زر"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "سيتم إرسال أمر إلى سكربت المحتوى لإدراج الترجمات في الصفحة.",
60 | "description": "وصف"
61 | },
62 | "hover_hotkey": {
63 | "message": "مفتاح اختصار الترجمة عند التحويم",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "اضغط مع الاستمرار على مفتاح التعديل المحدد أثناء تحويم المؤشر فوق النص لترجمة الفقرة.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "بدعم من واجهة برمجة تطبيقات الترجمة بالذكاء الاصطناعي المدمجة. يعتمد تنزيل النموذج على الجهاز. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "تذييل"
85 | },
86 | "sidepanel_title": {
87 | "message": "لوحة جانبية للترجمة الأصلية",
88 | "description": "عنوان"
89 | },
90 | "unknown_error": {
91 | "message": "خطأ غير معروف",
92 | "description": "خطأ"
93 | },
94 | "translator_unavailable": {
95 | "message": "واجهة الترجمة غير متاحة (تتطلب Chrome 138+ والنموذج جاهز)",
96 | "description": "غير متاح"
97 | },
98 | "create_translator_failed": {
99 | "message": "فشل إنشاء المترجم",
100 | "description": "فشل"
101 | },
102 | "active_tab_not_found": {
103 | "message": "لم يتم العثور على علامة التبويب النشطة",
104 | "description": "خطأ"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "فشل إرسال أمر الترجمة",
108 | "description": "خطأ"
109 | },
110 | "availability_unknown": {
111 | "message": "غير معروف",
112 | "description": "شارة"
113 | },
114 | "availability_available": {
115 | "message": "متاح",
116 | "description": "شارة"
117 | },
118 | "availability_downloadable": {
119 | "message": "قابل للتنزيل",
120 | "description": "شارة"
121 | },
122 | "availability_unavailable": {
123 | "message": "غير متاح",
124 | "description": "شارة"
125 | },
126 | "overlay_preparing": {
127 | "message": "جارٍ تحضير المترجم…",
128 | "description": "تراكب"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "واجهة الترجمة غير متاحة (تتطلب Chrome 138+)",
132 | "description": "تراكب"
133 | },
134 | "overlay_using_cached": {
135 | "message": "يتم استخدام نموذج مخزن مؤقتًا…",
136 | "description": "تراكب"
137 | },
138 | "overlay_downloading": {
139 | "message": "جارٍ تنزيل النموذج… $1%",
140 | "description": "تراكب"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "لا يوجد ما يترجم",
144 | "description": "تراكب"
145 | },
146 | "overlay_translating": {
147 | "message": "جارٍ الترجمة… $1% ($2/$3)",
148 | "description": "تراكب"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "اكتملت الترجمة",
152 | "description": "تراكب"
153 | },
154 | "input_target_language": {
155 | "message": "لغة الهدف للإدخال",
156 | "description": "Popup: تسمية لغة الهدف للإدخال"
157 | },
158 | "input_target_language_desc": {
159 | "message": "تُستخدم عند ترجمة النص الذي تكتبه في حقول الإدخال.",
160 | "description": "Popup: وصف لغة الهدف للإدخال"
161 | },
162 | "open_sidepanel": {
163 | "message": "ترجمة في اللوحة الجانبية",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "اكتشاف تلقائي",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "اكتب النص المراد ترجمته هنا…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "سيظهر النص المترجم هنا…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "ترجمة النص",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "ترجمة الملفات",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "اسحب ملف EPUB هنا أو انقر للاختيار",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "الصيغة المدعومة: كتب إلكترونية EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "جارٍ تحليل الملف…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "جارٍ الترجمة… الرجاء عدم إغلاق اللوحة الجانبية",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "تمت ترجمة $1 من أصل $2 مقطع",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "اكتملت الترجمة!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "تنزيل الملف المترجم",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "نزّل تلقائيًا في المرة القادمة",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "فشل تحليل الملف. يرجى التحقق من تنسيق الملف.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "تنسيق ملف غير مدعوم. يتم دعم ملفات EPUB فقط.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "الملف كبير جدًا. يرجى اختيار ملف أصغر.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "يرجى اختيار ملف أولاً.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/QWEN.md:
--------------------------------------------------------------------------------
1 | # Native Translate - Context for Qwen Code
2 |
3 | This document provides a high-level overview of the Native Translate project, intended for use by Qwen Code to understand the codebase and provide effective assistance.
4 |
5 | ## Project Overview
6 |
7 | Native Translate is a privacy-focused Chrome extension that leverages Chrome's built-in AI Translator and Language Detector APIs. It performs all translation and language detection tasks directly within the browser, ensuring user content never leaves the device. Translation models are downloaded and cached locally, enabling offline use after the initial download.
8 |
9 | ### Key Features
10 |
11 | - **Full-page Translation:** Appends translated text beneath the original content blocks.
12 | - **Hover-to-Translate:** Hold a selected modifier key (Alt/Ctrl/Shift) and hover over a paragraph to translate it inline.
13 | - **Input Field Translation:** In editable text fields (input/textarea/contentEditable), type three spaces to translate the current content to a preferred language.
14 | - **Offline Capability:** Models are cached for offline use after downloading.
15 | - **Privacy by Design:** No external network requests for translation by default. No telemetry or analytics.
16 | - **Localized UI:** Uses Chrome's i18n framework for multi-language support (English, Chinese, etc.).
17 |
18 | ### Technologies
19 |
20 | - **Language:** TypeScript
21 | - **Framework:** React 19
22 | - **Styling:** Tailwind CSS v4
23 | - **UI Components:** Radix UI primitives
24 | - **Build Tool:** Rspack (SWC) for a Manifest V3 Chrome Extension
25 | - **Package Manager:** pnpm
26 | - **Linting/Formatting:** Biome
27 |
28 | ## Codebase Structure
29 |
30 | The project follows a standard structure for a Chrome Extension MV3 project built with React and Rspack.
31 |
32 | ```
33 | src/
34 | manifest.json # Extension manifest
35 | popup/ # Popup UI (settings, translate button)
36 | popup.html
37 | popup.tsx
38 | sidePanel/ # Side panel UI (placeholder)
39 | sidePanel.html
40 | sidePanel.tsx
41 | scripts/ # Background and Content Scripts
42 | background.ts # Handles side panel toggle, action click
43 | contentScript.ts # Core translation engine and UI overlay
44 | components/ui/ # Reusable UI components (e.g., Button, Select)
45 | utils/ # Utility functions (e.g., i18n, RTL handling)
46 | styles/
47 | tailwind.css # Tailwind base styles
48 | shared/ # Shared types and constants
49 | public/ # Static assets (icons)
50 | _locales/ # i18n message files
51 | ```
52 |
53 | ### Core Components
54 |
55 | 1. **`src/scripts/contentScript.ts`**: The heart of the extension. It handles:
56 | - Detecting the source language of the page content or specific text.
57 | - Downloading and managing translation models via Chrome's `Translator` API.
58 | - Performing the actual translation of text blocks or input field content.
59 | - Injecting the translated text into the page DOM.
60 | - Managing the UI overlay for progress/status messages.
61 | - Implementing the hover-to-translate functionality by listening to mouse/keyboard events.
62 | - Implementing the input field translation feature by listening to key events.
63 | - Caching translations in memory to avoid redundant API calls.
64 | - Listening for messages from the popup (e.g., to trigger full-page translation or update settings).
65 | - Bridging API calls to the main world when the content script world lacks access.
66 | - Managing translation model pools and readiness states.
67 |
68 | 2. **`src/popup/popup.tsx`**: The UI that appears when the extension icon is clicked in the toolbar. It allows users to:
69 | - Select the target language for full-page translation.
70 | - Select the target language for input field translation.
71 | - Choose the modifier key for hover-to-translate.
72 | - Trigger full-page translation for the active tab.
73 | - Open the side panel.
74 | - It communicates with the content script via `chrome.tabs.sendMessage`.
75 |
76 | 3. **`src/scripts/background.ts`**: The background service worker. It handles:
77 | - Toggling the Side Panel for specific origins (e.g., zhanghe.dev).
78 | - Configuring the behavior of the extension's action (toolbar icon click) to open the side panel.
79 | - Development features like auto-reloading and injecting content scripts into open tabs.
80 | - Automatically enabling the side panel for specific websites.
81 |
82 | 4. **`src/sidePanel/sidePanel.tsx`**: A minimal placeholder for the extension's side panel.
83 |
84 | 5. **`src/shared/`**: Contains shared types, constants, and message definitions used across different parts of the extension.
85 |
86 | 6. **`_locales/`**: Contains message files for internationalization (English, Chinese, etc.).
87 |
88 | ## Development Workflow
89 |
90 | - **Install Dependencies:** `pnpm install`
91 | - **Development Build (Watch):** `pnpm dev` (Uses `rspack build --watch` and a development reload server)
92 | - **Production Build:** `pnpm build` (Uses `rspack build --mode production`)
93 | - **Type Checking:** `pnpm tsc`
94 | - **Linting:** `pnpm lint` or `pnpm lint:fix` (Uses Biome)
95 |
96 | ### Rspack Configuration (`rspack.config.js`)
97 |
98 | - Configured for a multi-entry MV3 extension.
99 | - Entries: `popup`, `sidePanel`, `background`, `contentScript`, `offscreen`.
100 | - Uses `swc-loader` for TypeScript/TSX.
101 | - Uses `postcss-loader` and `tailwindcss` for styling.
102 | - Copies static assets (`public/`, `manifest.json`, `_locales/`) to the `dist/` folder.
103 | - Automatically zips the `dist/` folder into `Native-translate.zip` after a production build.
104 | - Ensures clean output directory on each build.
105 | - Sets up an `offscreen` document for development auto-reload features.
106 |
107 | ## Deployment
108 |
109 | 1. Run `pnpm build`.
110 | 2. Load the generated `dist/` folder (or the `Native-translate.zip` file) into Chrome via `chrome://extensions` in Developer Mode.
111 |
112 | ## Important Considerations for Qwen Code
113 |
114 | - **Chrome APIs:** The code heavily relies on Chrome Extension APIs (e.g., `chrome.i18n`, `chrome.storage`, `chrome.tabs`, `chrome.scripting`, `chrome.runtime`) and the experimental Built-in AI APIs (`window.Translator`, `window.LanguageDetector`, `window.translation.createTranslator`). Understanding these APIs' behavior is crucial.
115 | - **Asynchronous Nature:** Translation and model downloading are asynchronous operations managed with Promises. State management (e.g., model readiness, download progress) is important.
116 | - **DOM Manipulation:** The content script dynamically injects translated text and UI elements into web pages. Selecting appropriate elements and avoiding conflicts with existing page structure/styles is handled by specific logic in `contentScript.ts`.
117 | - **Content Security Policy (CSP):** As an MV3 extension, the CSP is strict. All scripts must be bundled and included in the extension package. This is handled by Rspack.
118 | - **Permissions:** The extension requires specific permissions (`storage`, `activeTab`, `scripting`, `tabs`, `sidePanel`, `offscreen`) as declared in `manifest.json`. These are necessary for its functionality.
119 | - **API Availability and Fallbacks:** The extension checks for API availability and gracefully handles cases where APIs are not available in the content script world by using a bridge to the main world.
120 | - **IME Handling:** Special care is taken to avoid triggering translation during IME composition in input fields.
121 | - **Model Caching:** Translation models are cached for efficiency and offline use, with mechanisms to track readiness state.
122 | - **Memory Management:** Translations are cached in memory to avoid redundant API calls, and WeakSets are used to track processing state to prevent duplicate operations.
--------------------------------------------------------------------------------
/_locales/hi/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "नेटिव अनुवाद — अंतर्निहित, निजी AI अनुवाद",
4 | "description": "एक्सटेंशन का नाम"
5 | },
6 | "extension_description": {
7 | "message": "गोपनीयता‑प्रथम Chrome एक्सटेंशन, अंतर्निहित AI अनुवादक के साथ। क्लाउड/टेलीमेट्री नहीं; सामग्री ब्राउज़र से बाहर नहीं जाती। मॉडल स्थानीय, ऑफ़लाइन।",
8 | "description": "विवरण"
9 | },
10 | "popup_title": {
11 | "message": "नेटिव अनुवाद",
12 | "description": "पॉपअप शीर्षक"
13 | },
14 | "global_availability": {
15 | "message": "वैश्विक उपलब्धता",
16 | "description": "लेबल"
17 | },
18 | "pair_availability": {
19 | "message": "भाषा जोड़ी की उपलब्धता",
20 | "description": "लेबल"
21 | },
22 | "checking": {
23 | "message": "जांच हो रही है…",
24 | "description": "स्थिति"
25 | },
26 | "recheck_availability": {
27 | "message": "उपलब्धता पुनः जांचें",
28 | "description": "बटन"
29 | },
30 | "source_language": {
31 | "message": "स्रोत भाषा",
32 | "description": "लेबल"
33 | },
34 | "target_language": {
35 | "message": "लक्ष्य भाषा",
36 | "description": "लेबल"
37 | },
38 | "preparing_translator": {
39 | "message": "तैयार किया जा रहा है (मॉडल डाउनलोड हो सकता है)…",
40 | "description": "तैयारी"
41 | },
42 | "create_prepare_translator": {
43 | "message": "अनुवादक बनाएँ/तैयार करें",
44 | "description": "बटन"
45 | },
46 | "download_progress": {
47 | "message": "डाउनलोड प्रगति",
48 | "description": "लेबल"
49 | },
50 | "translator_ready": {
51 | "message": "अनुवादक तैयार है",
52 | "description": "सूचना"
53 | },
54 | "translate_full_page": {
55 | "message": "वर्तमान पृष्ठ का अनुवाद करें",
56 | "description": "बटन"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "यह सामग्री स्क्रिप्ट को पृष्ठ में अनुवाद जोड़ने का आदेश भेजेगा।",
60 | "description": "विवरण"
61 | },
62 | "hover_hotkey": {
63 | "message": "हॉवर अनुवाद शॉर्टकट",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "चुनी हुई मॉडिफ़ायर कुंजी दबाए रखें और पाठ पर हॉवर करें ताकि उस अनुच्छेद का अनुवाद हो।",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "अंतर्निहित AI अनुवाद API द्वारा संचालित। मॉडल डाउनलोड डिवाइस पर निर्भर करता है। — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "फुटर"
85 | },
86 | "sidepanel_title": {
87 | "message": "नेटिव अनुवाद साइड पैनल",
88 | "description": "शीर्षक"
89 | },
90 | "unknown_error": {
91 | "message": "अज्ञात त्रुटि",
92 | "description": "त्रुटि"
93 | },
94 | "translator_unavailable": {
95 | "message": "अनुवाद API उपलब्ध नहीं (Chrome 138+ और मॉडल आवश्यक)",
96 | "description": "उपलब्ध नहीं"
97 | },
98 | "create_translator_failed": {
99 | "message": "अनुवादक बनाना विफल",
100 | "description": "त्रुटि"
101 | },
102 | "active_tab_not_found": {
103 | "message": "सक्रिय टैब नहीं मिला",
104 | "description": "त्रुटि"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "अनुवाद आदेश भेजना विफल",
108 | "description": "त्रुटि"
109 | },
110 | "availability_unknown": {
111 | "message": "अज्ञात",
112 | "description": "बैज"
113 | },
114 | "availability_available": {
115 | "message": "उपलब्ध",
116 | "description": "बैज"
117 | },
118 | "availability_downloadable": {
119 | "message": "डाउनलोड करने योग्य",
120 | "description": "बैज"
121 | },
122 | "availability_unavailable": {
123 | "message": "उपलब्ध नहीं",
124 | "description": "बैज"
125 | },
126 | "overlay_preparing": {
127 | "message": "अनुवादक तैयार हो रहा है…",
128 | "description": "ओवरले"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "अनुवाद API उपलब्ध नहीं (Chrome 138+)",
132 | "description": "ओवरले"
133 | },
134 | "overlay_using_cached": {
135 | "message": "कैश्ड मॉडल का उपयोग हो रहा है…",
136 | "description": "ओवरले"
137 | },
138 | "overlay_downloading": {
139 | "message": "मॉडल डाउनलोड हो रहा है… $1%",
140 | "description": "ओवरले"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "अनुवाद करने के लिए कुछ नहीं",
144 | "description": "ओवरले"
145 | },
146 | "overlay_translating": {
147 | "message": "अनुवाद हो रहा है… $1% ($2/$3)",
148 | "description": "ओवरले"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "अनुवाद पूरा हुआ",
152 | "description": "ओवरले"
153 | },
154 | "input_target_language": {
155 | "message": "इनपुट का लक्ष्य भाषा",
156 | "description": "Popup: इनपुट लक्ष्य भाषा लेबल"
157 | },
158 | "input_target_language_desc": {
159 | "message": "इनपुट फ़ील्ड में आप जो लिखते हैं, उसे इसी भाषा में अनुवादित किया जाएगा।",
160 | "description": "Popup: इनपुट लक्ष्य भाषा विवरण"
161 | },
162 | "open_sidepanel": {
163 | "message": "साइड पैनल अनुवाद",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "स्वतः पहचान",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "यहाँ अनुवाद के लिए पाठ लिखें…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "अनुवादित पाठ यहाँ दिखाई देगा…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "पाठ अनुवाद",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "फ़ाइल अनुवाद",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "EPUB फ़ाइल यहाँ खींचें या चुनने के लिए क्लिक करें",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "समर्थित प्रारूप: EPUB ई‑बुक",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "फ़ाइल पार्स की जा रही है…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "अनुवाद जारी है… कृपया साइड पैनल बंद न करें",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "$2 में से $1 खंड अनुवादित",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "अनुवाद पूरा हुआ!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "अनुवादित फ़ाइल डाउनलोड करें",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "अगली बार स्वतः डाउनलोड करें",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "फ़ाइल पार्स करने में विफल। कृपया फ़ाइल प्रारूप जाँचें।",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "असमर्थित फ़ाइल प्रारूप। केवल EPUB फ़ाइलें समर्थित हैं।",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "फ़ाइल बहुत बड़ी है। कृपया छोटी फ़ाइल चुनें।",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "कृपया पहले एक फ़ाइल चुनें।",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/vi/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Dịch Bản Địa — Dịch AI tích hợp, ưu tiên quyền riêng tư",
4 | "description": "Tên tiện ích"
5 | },
6 | "extension_description": {
7 | "message": "Tiện ích Chrome riêng tư với trình dịch AI tích hợp. Không đám mây hay telemetry; nội dung ở lại trong trình duyệt. Mô hình chạy cục bộ, ngoại tuyến.",
8 | "description": "Mô tả"
9 | },
10 | "popup_title": {
11 | "message": "Dịch Bản Địa",
12 | "description": "Tiêu đề popup"
13 | },
14 | "global_availability": {
15 | "message": "Khả dụng toàn cục",
16 | "description": "Nhãn"
17 | },
18 | "pair_availability": {
19 | "message": "Khả dụng cặp ngôn ngữ",
20 | "description": "Nhãn"
21 | },
22 | "checking": {
23 | "message": "Đang kiểm tra…",
24 | "description": "Trạng thái"
25 | },
26 | "recheck_availability": {
27 | "message": "Kiểm tra lại khả dụng",
28 | "description": "Nút"
29 | },
30 | "source_language": {
31 | "message": "Ngôn ngữ nguồn",
32 | "description": "Nhãn"
33 | },
34 | "target_language": {
35 | "message": "Ngôn ngữ đích",
36 | "description": "Nhãn"
37 | },
38 | "preparing_translator": {
39 | "message": "Đang chuẩn bị (có thể tải mô hình)…",
40 | "description": "Chuẩn bị"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Tạo/Chuẩn bị bộ dịch",
44 | "description": "Nút"
45 | },
46 | "download_progress": {
47 | "message": "Tiến trình tải xuống",
48 | "description": "Nhãn"
49 | },
50 | "translator_ready": {
51 | "message": "Bộ dịch sẵn sàng",
52 | "description": "Thông báo"
53 | },
54 | "translate_full_page": {
55 | "message": "Dịch toàn bộ trang hiện tại",
56 | "description": "Nút"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Sẽ gửi lệnh tới content script để chèn bản dịch vào trang.",
60 | "description": "Mô tả"
61 | },
62 | "hover_hotkey": {
63 | "message": "Phím tắt dịch khi rê chuột",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Nhấn giữ phím bổ trợ đã chọn khi rê chuột qua văn bản để dịch đoạn đó.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Sử dụng API Dịch AI tích hợp. Việc tải mô hình phụ thuộc vào thiết bị. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Chú thích"
85 | },
86 | "sidepanel_title": {
87 | "message": "Bảng Bên Dịch Bản Địa",
88 | "description": "Tiêu đề panel"
89 | },
90 | "unknown_error": {
91 | "message": "Lỗi không xác định",
92 | "description": "Lỗi"
93 | },
94 | "translator_unavailable": {
95 | "message": "API dịch không khả dụng (cần Chrome 138+ và mô hình sẵn sàng)",
96 | "description": "Không khả dụng"
97 | },
98 | "create_translator_failed": {
99 | "message": "Tạo bộ dịch thất bại",
100 | "description": "Lỗi"
101 | },
102 | "active_tab_not_found": {
103 | "message": "Không tìm thấy thẻ đang hoạt động",
104 | "description": "Lỗi"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Gửi lệnh dịch thất bại",
108 | "description": "Lỗi"
109 | },
110 | "availability_unknown": {
111 | "message": "Không rõ",
112 | "description": "Huy hiệu"
113 | },
114 | "availability_available": {
115 | "message": "Khả dụng",
116 | "description": "Huy hiệu"
117 | },
118 | "availability_downloadable": {
119 | "message": "Có thể tải",
120 | "description": "Huy hiệu"
121 | },
122 | "availability_unavailable": {
123 | "message": "Không khả dụng",
124 | "description": "Huy hiệu"
125 | },
126 | "overlay_preparing": {
127 | "message": "Đang chuẩn bị bộ dịch…",
128 | "description": "Overlay"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "API dịch không khả dụng (cần Chrome 138+)",
132 | "description": "Overlay"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Đang dùng mô hình đã lưu…",
136 | "description": "Overlay"
137 | },
138 | "overlay_downloading": {
139 | "message": "Đang tải mô hình… $1%",
140 | "description": "Overlay"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Không có nội dung để dịch",
144 | "description": "Overlay"
145 | },
146 | "overlay_translating": {
147 | "message": "Đang dịch… $1% ($2/$3)",
148 | "description": "Overlay"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Hoàn tất dịch",
152 | "description": "Overlay"
153 | },
154 | "input_target_language": {
155 | "message": "Ngôn ngữ đích cho nội dung nhập",
156 | "description": "Popup: Nhãn ngôn ngữ đích cho nhập liệu"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Dùng khi dịch văn bản bạn gõ trong các ô nhập.",
160 | "description": "Popup: Mô tả ngôn ngữ đích cho nhập liệu"
161 | },
162 | "open_sidepanel": {
163 | "message": "Dịch ở bảng bên",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Tự động phát hiện",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Nhập văn bản cần dịch tại đây…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "Văn bản đã dịch sẽ xuất hiện ở đây…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Dịch Văn Bản",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Dịch Tệp",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Kéo tệp EPUB vào đây hoặc nhấp để chọn",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Định dạng hỗ trợ: Sách điện tử EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Đang phân tích tệp…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Đang dịch… Vui lòng không đóng bảng bên",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "Đã dịch $1 trên $2 đoạn",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "Hoàn tất dịch!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Tải Xuống Tệp Đã Dịch",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Tự động tải xuống lần sau",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Không thể phân tích tệp. Vui lòng kiểm tra định dạng tệp.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Định dạng tệp không được hỗ trợ. Chỉ hỗ trợ tệp EPUB.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Tệp quá lớn. Vui lòng chọn tệp nhỏ hơn.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Vui lòng chọn tệp trước.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/th/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "การแปลแบบเนทีฟ — การแปลด้วย AI แบบบูรณาการ เน้นความเป็นส่วนตัว",
4 | "description": "ชื่อตัวขยาย"
5 | },
6 | "extension_description": {
7 | "message": "ส่วนขยาย Chrome เน้นความเป็นส่วนตัว พร้อมตัวแปล AI ในตัว ไม่ใช้คลาวด์หรือเทเลเมตริ เนื้อหายังคงอยู่ในเบราว์เซอร์ โมเดลทำงานแบบออฟไลน์.",
8 | "description": "คำอธิบาย"
9 | },
10 | "popup_title": {
11 | "message": "การแปลแบบเนทีฟ",
12 | "description": "ชื่อหน้าต่างป๊อปอัป"
13 | },
14 | "global_availability": {
15 | "message": "ความพร้อมใช้งานทั่วโลก",
16 | "description": "ป้ายกำกับ"
17 | },
18 | "pair_availability": {
19 | "message": "ความพร้อมของคู่ภาษา",
20 | "description": "ป้ายกำกับ"
21 | },
22 | "checking": {
23 | "message": "กำลังตรวจสอบ…",
24 | "description": "สถานะ"
25 | },
26 | "recheck_availability": {
27 | "message": "ตรวจสอบความพร้อมอีกครั้ง",
28 | "description": "ปุ่ม"
29 | },
30 | "source_language": {
31 | "message": "ภาษาต้นฉบับ",
32 | "description": "ป้ายกำกับ"
33 | },
34 | "target_language": {
35 | "message": "ภาษาเป้าหมาย",
36 | "description": "ป้ายกำกับ"
37 | },
38 | "preparing_translator": {
39 | "message": "กำลังเตรียมการ (อาจดาวน์โหลดโมเดล)…",
40 | "description": "เตรียมการ"
41 | },
42 | "create_prepare_translator": {
43 | "message": "สร้าง/เตรียมนักแปล",
44 | "description": "ปุ่ม"
45 | },
46 | "download_progress": {
47 | "message": "ความคืบหน้าการดาวน์โหลด",
48 | "description": "ป้ายกำกับ"
49 | },
50 | "translator_ready": {
51 | "message": "นักแปลพร้อมแล้ว",
52 | "description": "ประกาศ"
53 | },
54 | "translate_full_page": {
55 | "message": "แปลทั้งหน้าเว็บปัจจุบัน",
56 | "description": "ปุ่ม"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "ระบบจะส่งคำสั่งไปยังสคริปต์เนื้อหาเพื่อฝังคำแปลลงในหน้าเว็บ.",
60 | "description": "คำอธิบาย"
61 | },
62 | "hover_hotkey": {
63 | "message": "ปุ่มลัดแปลเมื่อวางเมาส์",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "กดค้างปุ่มปรับแต่งที่เลือกไว้ขณะวางเมาส์เหนือข้อความเพื่อแปลย่อหน้านั้น.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "ขับเคลื่อนด้วย API การแปลด้วย AI ในตัว การดาวน์โหลดโมเดลขึ้นอยู่กับอุปกรณ์. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "ส่วนท้าย"
85 | },
86 | "sidepanel_title": {
87 | "message": "แผงด้านข้างการแปลแบบเนทีฟ",
88 | "description": "ชื่อแผง"
89 | },
90 | "unknown_error": {
91 | "message": "ข้อผิดพลาดที่ไม่รู้จัก",
92 | "description": "ข้อผิดพลาด"
93 | },
94 | "translator_unavailable": {
95 | "message": "API นักแปลไม่พร้อมใช้งาน (ต้องใช้ Chrome 138+ และโมเดลพร้อม)",
96 | "description": "ไม่พร้อมใช้งาน"
97 | },
98 | "create_translator_failed": {
99 | "message": "สร้างนักแปลไม่สำเร็จ",
100 | "description": "ข้อผิดพลาด"
101 | },
102 | "active_tab_not_found": {
103 | "message": "ไม่พบแท็บที่ใช้งานอยู่",
104 | "description": "ข้อผิดพลาด"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "ส่งคำสั่งแปลไม่สำเร็จ",
108 | "description": "ข้อผิดพลาด"
109 | },
110 | "availability_unknown": {
111 | "message": "ไม่ทราบ",
112 | "description": "ตรา"
113 | },
114 | "availability_available": {
115 | "message": "พร้อมใช้งาน",
116 | "description": "ตรา"
117 | },
118 | "availability_downloadable": {
119 | "message": "ดาวน์โหลดได้",
120 | "description": "ตรา"
121 | },
122 | "availability_unavailable": {
123 | "message": "ไม่พร้อมใช้งาน",
124 | "description": "ตรา"
125 | },
126 | "overlay_preparing": {
127 | "message": "กำลังเตรียมนักแปล…",
128 | "description": "โอเวอร์เลย์"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "API นักแปลไม่พร้อมใช้งาน (ต้องใช้ Chrome 138+)",
132 | "description": "โอเวอร์เลย์"
133 | },
134 | "overlay_using_cached": {
135 | "message": "กำลังใช้โมเดลที่แคชไว้…",
136 | "description": "โอเวอร์เลย์"
137 | },
138 | "overlay_downloading": {
139 | "message": "กำลังดาวน์โหลดโมเดล… $1%",
140 | "description": "โอเวอร์เลย์"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "ไม่มีสิ่งที่จะต้องแปล",
144 | "description": "โอเวอร์เลย์"
145 | },
146 | "overlay_translating": {
147 | "message": "กำลังแปล… $1% ($2/$3)",
148 | "description": "โอเวอร์เลย์"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "แปลเสร็จสิ้น",
152 | "description": "โอเวอร์เลย์"
153 | },
154 | "input_target_language": {
155 | "message": "ภาษาเป้าหมายสำหรับการป้อนข้อมูล",
156 | "description": "Popup: ป้ายกำกับภาษาเป้าหมายสำหรับการป้อนข้อมูล"
157 | },
158 | "input_target_language_desc": {
159 | "message": "ใช้เมื่อแปลข้อความที่คุณพิมพ์ในช่องป้อนข้อมูล.",
160 | "description": "Popup: คำอธิบายภาษาเป้าหมายสำหรับการป้อนข้อมูล"
161 | },
162 | "open_sidepanel": {
163 | "message": "แปลในแผงด้านข้าง",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "ตรวจจับอัตโนมัติ",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "พิมพ์ข้อความที่จะแปลได้ที่นี่…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "ข้อความที่แปลแล้วจะแสดงที่นี่…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "การแปลข้อความ",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "การแปลไฟล์",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "ลากไฟล์ EPUB มาที่นี่หรือคลิกเพื่อเลือก",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "รูปแบบที่รองรับ: หนังสืออิเล็กทรอนิกส์ EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "กำลังแยกวิเคราะห์ไฟล์…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "กำลังแปล… กรุณาอย่าปิดแผงด้านข้าง",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "แปลแล้ว $1 จาก $2 ส่วน",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "การแปลเสร็จสมบูรณ์!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "ดาวน์โหลดไฟล์ที่แปลแล้ว",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "ดาวน์โหลดอัตโนมัติในครั้งถัดไป",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "ไม่สามารถแยกวิเคราะห์ไฟล์ได้ โปรดตรวจสอบรูปแบบไฟล์",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "รูปแบบไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ EPUB เท่านั้น",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "ไฟล์มีขนาดใหญ่เกินไป โปรดเลือกไฟล์ที่มีขนาดเล็กกว่า",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "กรุณาเลือกไฟล์ก่อน",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/tr/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Yerel Çeviri — Entegre, gizlilik odaklı Yapay Zekâ çeviri",
4 | "description": "Eklenti adı"
5 | },
6 | "extension_description": {
7 | "message": "Gizlilik odaklı Chrome uzantısı, yerleşik Yapay Zekâ çevirmen ile. Bulut/telemetri yok; içerik tarayıcıyı terk etmez. Modeller yerel ve çevrimdışı.",
8 | "description": "Açıklama"
9 | },
10 | "popup_title": {
11 | "message": "Yerel Çeviri",
12 | "description": "Açılır başlık"
13 | },
14 | "global_availability": {
15 | "message": "Küresel kullanılabilirlik",
16 | "description": "Etiket"
17 | },
18 | "pair_availability": {
19 | "message": "Dil çifti kullanılabilirliği",
20 | "description": "Etiket"
21 | },
22 | "checking": {
23 | "message": "Kontrol ediliyor…",
24 | "description": "Durum"
25 | },
26 | "recheck_availability": {
27 | "message": "Kullanılabilirliği yeniden kontrol et",
28 | "description": "Düğme"
29 | },
30 | "source_language": {
31 | "message": "Kaynak dil",
32 | "description": "Etiket"
33 | },
34 | "target_language": {
35 | "message": "Hedef dil",
36 | "description": "Etiket"
37 | },
38 | "preparing_translator": {
39 | "message": "Hazırlanıyor (model indirilebilir)…",
40 | "description": "Hazırlık"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Çevirmen oluştur/hazırla",
44 | "description": "Düğme"
45 | },
46 | "download_progress": {
47 | "message": "İndirme ilerlemesi",
48 | "description": "Etiket"
49 | },
50 | "translator_ready": {
51 | "message": "Çevirmen hazır",
52 | "description": "Bildirim"
53 | },
54 | "translate_full_page": {
55 | "message": "Geçerli sayfayı çevir",
56 | "description": "Düğme"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Bu, sayfaya çeviriler eklemek için içerik komut dosyasına bir komut gönderir.",
60 | "description": "Açıklama"
61 | },
62 | "hover_hotkey": {
63 | "message": "Fareyle üzerine gelince çeviri kısayolu",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Metnin üzerine gelirken seçilen değiştirici tuşu basılı tutarak o paragrafı çevirin.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Yerleşik Yapay Zekâ Çeviri API'si ile desteklenir. Model indirme cihazınıza bağlıdır. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Alt bilgi"
85 | },
86 | "sidepanel_title": {
87 | "message": "Yerel Çeviri Yan Paneli",
88 | "description": "Panel başlığı"
89 | },
90 | "unknown_error": {
91 | "message": "Bilinmeyen hata",
92 | "description": "Hata"
93 | },
94 | "translator_unavailable": {
95 | "message": "Çeviri API'si kullanılamıyor (Chrome 138+ ve hazır model gerekir)",
96 | "description": "Kullanılamıyor"
97 | },
98 | "create_translator_failed": {
99 | "message": "Çevirmen oluşturma başarısız",
100 | "description": "Hata"
101 | },
102 | "active_tab_not_found": {
103 | "message": "Etkin sekme bulunamadı",
104 | "description": "Hata"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Çeviri komutu gönderilemedi",
108 | "description": "Hata"
109 | },
110 | "availability_unknown": {
111 | "message": "Bilinmiyor",
112 | "description": "Rozet"
113 | },
114 | "availability_available": {
115 | "message": "Kullanılabilir",
116 | "description": "Rozet"
117 | },
118 | "availability_downloadable": {
119 | "message": "İndirilebilir",
120 | "description": "Rozet"
121 | },
122 | "availability_unavailable": {
123 | "message": "Kullanılamıyor",
124 | "description": "Rozet"
125 | },
126 | "overlay_preparing": {
127 | "message": "Çevirmen hazırlanıyor…",
128 | "description": "Katman"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "Çeviri API'si kullanılamıyor (Chrome 138+)",
132 | "description": "Katman"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Önbellekteki model kullanılıyor…",
136 | "description": "Katman"
137 | },
138 | "overlay_downloading": {
139 | "message": "Model indiriliyor… $1%",
140 | "description": "Katman"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Çevrilecek bir şey yok",
144 | "description": "Katman"
145 | },
146 | "overlay_translating": {
147 | "message": "Çevriliyor… $1% ($2/$3)",
148 | "description": "Katman"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Çeviri tamamlandı",
152 | "description": "Katman"
153 | },
154 | "input_target_language": {
155 | "message": "Giriş için hedef dil",
156 | "description": "Popup: Giriş hedef dil etiketi"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Girdi alanlarına yazdığınız metin çevrilirken kullanılır.",
160 | "description": "Popup: Giriş hedef dil açıklaması"
161 | },
162 | "open_sidepanel": {
163 | "message": "Yan panelde çevir",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Otomatik algıla",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Çevrilecek metni buraya yazın…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "Çevrilen metin burada görünecek…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Metin Çevirisi",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Dosya Çevirisi",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "EPUB dosyasını buraya sürükleyin veya seçmek için tıklayın",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Desteklenen biçim: EPUB e‑kitaplar",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Dosya ayrıştırılıyor…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Çevriliyor… Lütfen yan paneli kapatmayın",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "$2 bölümün $1 bölümü çevrildi",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "Çeviri tamamlandı!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Çevrilen Dosyayı İndir",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Sonraki sefer otomatik indir",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Dosya ayrıştırılamadı. Lütfen dosya biçimini kontrol edin.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Desteklenmeyen dosya biçimi. Yalnızca EPUB dosyaları desteklenir.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Dosya çok büyük. Lütfen daha küçük bir dosya seçin.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Lütfen önce bir dosya seçin.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/id/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Terjemahan Native — AI bawaan, berfokus privasi",
4 | "description": "Nama ekstensi"
5 | },
6 | "extension_description": {
7 | "message": "Ekstensi Chrome privat dengan penerjemah AI bawaan. Tanpa cloud/telemetri; konten tetap di browser. Model lokal, bisa offline.",
8 | "description": "Deskripsi"
9 | },
10 | "popup_title": {
11 | "message": "Terjemahan Native",
12 | "description": "Judul popup"
13 | },
14 | "global_availability": {
15 | "message": "Ketersediaan global",
16 | "description": "Label"
17 | },
18 | "pair_availability": {
19 | "message": "Ketersediaan pasangan bahasa",
20 | "description": "Label"
21 | },
22 | "checking": {
23 | "message": "Memeriksa…",
24 | "description": "Status"
25 | },
26 | "recheck_availability": {
27 | "message": "Periksa ulang ketersediaan",
28 | "description": "Tombol"
29 | },
30 | "source_language": {
31 | "message": "Bahasa sumber",
32 | "description": "Label"
33 | },
34 | "target_language": {
35 | "message": "Bahasa target",
36 | "description": "Label"
37 | },
38 | "preparing_translator": {
39 | "message": "Menyiapkan (mungkin mengunduh model)…",
40 | "description": "Menyiapkan"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Buat/Siapkan penerjemah",
44 | "description": "Tombol"
45 | },
46 | "download_progress": {
47 | "message": "Progres unduhan",
48 | "description": "Label"
49 | },
50 | "translator_ready": {
51 | "message": "Penerjemah siap",
52 | "description": "Notifikasi"
53 | },
54 | "translate_full_page": {
55 | "message": "Terjemahkan halaman saat ini",
56 | "description": "Tombol"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Ini akan mengirim perintah ke skrip konten untuk menyisipkan terjemahan ke halaman.",
60 | "description": "Deskripsi"
61 | },
62 | "hover_hotkey": {
63 | "message": "Pintasan terjemahan saat mengarahkan kursor",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Tekan dan tahan tombol pengubah yang dipilih sambil mengarahkan kursor ke teks untuk menerjemahkan paragraf tersebut.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Didukung oleh API Penerjemah AI bawaan. Unduhan model bergantung pada perangkat. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Catatan kaki"
85 | },
86 | "sidepanel_title": {
87 | "message": "Panel Sisi Terjemahan Native",
88 | "description": "Judul panel"
89 | },
90 | "unknown_error": {
91 | "message": "Kesalahan tidak diketahui",
92 | "description": "Kesalahan"
93 | },
94 | "translator_unavailable": {
95 | "message": "API penerjemah tidak tersedia (memerlukan Chrome 138+ dan model siap)",
96 | "description": "Tidak tersedia"
97 | },
98 | "create_translator_failed": {
99 | "message": "Gagal membuat penerjemah",
100 | "description": "Kesalahan"
101 | },
102 | "active_tab_not_found": {
103 | "message": "Tab aktif tidak ditemukan",
104 | "description": "Kesalahan"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Gagal mengirim perintah terjemahan",
108 | "description": "Kesalahan"
109 | },
110 | "availability_unknown": {
111 | "message": "Tidak diketahui",
112 | "description": "Badge"
113 | },
114 | "availability_available": {
115 | "message": "Tersedia",
116 | "description": "Badge"
117 | },
118 | "availability_downloadable": {
119 | "message": "Dapat diunduh",
120 | "description": "Badge"
121 | },
122 | "availability_unavailable": {
123 | "message": "Tidak tersedia",
124 | "description": "Badge"
125 | },
126 | "overlay_preparing": {
127 | "message": "Menyiapkan penerjemah…",
128 | "description": "Overlay"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "API penerjemah tidak tersedia (memerlukan Chrome 138+)",
132 | "description": "Overlay"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Menggunakan model cache…",
136 | "description": "Overlay"
137 | },
138 | "overlay_downloading": {
139 | "message": "Mengunduh model… $1%",
140 | "description": "Overlay"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Tidak ada yang diterjemahkan",
144 | "description": "Overlay"
145 | },
146 | "overlay_translating": {
147 | "message": "Menerjemahkan… $1% ($2/$3)",
148 | "description": "Overlay"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Terjemahan selesai",
152 | "description": "Overlay"
153 | },
154 | "input_target_language": {
155 | "message": "Bahasa target untuk input",
156 | "description": "Popup: Label bahasa target input"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Digunakan saat menerjemahkan teks yang Anda ketik di kolom input.",
160 | "description": "Popup: Deskripsi bahasa target input"
161 | },
162 | "open_sidepanel": {
163 | "message": "Terjemah panel samping",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Deteksi otomatis",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Ketik teks untuk diterjemahkan di sini…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "Teks hasil terjemahan akan muncul di sini…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Terjemahan Teks",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Terjemahan Berkas",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Seret berkas EPUB ke sini atau klik untuk memilih",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Format yang didukung: e‑book EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Menganalisis berkas…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Menerjemahkan… Jangan tutup panel samping",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "Diterjemahkan $1 dari $2 segmen",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "Terjemahan selesai!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Unduh Berkas Terjemahan",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Unduh otomatis lain kali",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Gagal menganalisis berkas. Periksa format berkas.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Format berkas tidak didukung. Hanya berkas EPUB yang didukung.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Berkas terlalu besar. Harap pilih berkas yang lebih kecil.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Silakan pilih berkas terlebih dahulu.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/nl/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Native Vertalen — Ingebouwde, privacy‑eerst AI‑vertaling",
4 | "description": "Naam van de extensie"
5 | },
6 | "extension_description": {
7 | "message": "Privacy‑eerst Chrome‑extensie met ingebouwde AI‑vertaler. Geen cloud of telemetrie; je inhoud verlaat de browser niet. Modellen lokaal, offline.",
8 | "description": "Beschrijving"
9 | },
10 | "popup_title": {
11 | "message": "Native Vertalen",
12 | "description": "Titel van popup"
13 | },
14 | "global_availability": {
15 | "message": "Wereldwijde beschikbaarheid",
16 | "description": "Label"
17 | },
18 | "pair_availability": {
19 | "message": "Beschikbaarheid taalkoppel",
20 | "description": "Label"
21 | },
22 | "checking": {
23 | "message": "Controleren…",
24 | "description": "Status"
25 | },
26 | "recheck_availability": {
27 | "message": "Beschikbaarheid opnieuw controleren",
28 | "description": "Knop"
29 | },
30 | "source_language": {
31 | "message": "Brontaal",
32 | "description": "Label"
33 | },
34 | "target_language": {
35 | "message": "Doeltaal",
36 | "description": "Label"
37 | },
38 | "preparing_translator": {
39 | "message": "Voorbereiden (model kan worden gedownload)…",
40 | "description": "Voorbereiden"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Vertaler maken/voorbereiden",
44 | "description": "Knop"
45 | },
46 | "download_progress": {
47 | "message": "Downloadvoortgang",
48 | "description": "Label"
49 | },
50 | "translator_ready": {
51 | "message": "Vertaler gereed",
52 | "description": "Melding"
53 | },
54 | "translate_full_page": {
55 | "message": "Huidige pagina vertalen",
56 | "description": "Knop"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Stuurt een opdracht naar het inhoudsscript om vertalingen in de pagina in te voegen.",
60 | "description": "Beschrijving"
61 | },
62 | "hover_hotkey": {
63 | "message": "Sneltoets voor vertalen bij zweven",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Houd de geselecteerde modificatietoets ingedrukt terwijl je over tekst zweeft om die alinea te vertalen.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Aangedreven door de ingebouwde AI Translator API. Het downloaden van het model is afhankelijk van het apparaat. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Voettekst"
85 | },
86 | "sidepanel_title": {
87 | "message": "Native Vertalen Zijpaneel",
88 | "description": "Paneeltitel"
89 | },
90 | "unknown_error": {
91 | "message": "Onbekende fout",
92 | "description": "Fout"
93 | },
94 | "translator_unavailable": {
95 | "message": "Vertaal-API niet beschikbaar (vereist Chrome 138+ en gereed model)",
96 | "description": "Niet beschikbaar"
97 | },
98 | "create_translator_failed": {
99 | "message": "Vertaler maken mislukt",
100 | "description": "Fout"
101 | },
102 | "active_tab_not_found": {
103 | "message": "Actief tabblad niet gevonden",
104 | "description": "Fout"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Verzenden van vertaalopdracht mislukt",
108 | "description": "Fout"
109 | },
110 | "availability_unknown": {
111 | "message": "Onbekend",
112 | "description": "Badge"
113 | },
114 | "availability_available": {
115 | "message": "Beschikbaar",
116 | "description": "Badge"
117 | },
118 | "availability_downloadable": {
119 | "message": "Downloadbaar",
120 | "description": "Badge"
121 | },
122 | "availability_unavailable": {
123 | "message": "Niet beschikbaar",
124 | "description": "Badge"
125 | },
126 | "overlay_preparing": {
127 | "message": "Vertaler wordt voorbereid…",
128 | "description": "Overlay"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "Vertaal-API niet beschikbaar (vereist Chrome 138+)",
132 | "description": "Overlay"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Gecached model wordt gebruikt…",
136 | "description": "Overlay"
137 | },
138 | "overlay_downloading": {
139 | "message": "Model wordt gedownload… $1%",
140 | "description": "Overlay"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Niets om te vertalen",
144 | "description": "Overlay"
145 | },
146 | "overlay_translating": {
147 | "message": "Vertalen… $1% ($2/$3)",
148 | "description": "Overlay"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Vertaling voltooid",
152 | "description": "Overlay"
153 | },
154 | "input_target_language": {
155 | "message": "Doeltaal voor invoer",
156 | "description": "Popup: Label doeltaal voor invoer"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Gebruikt bij het vertalen van tekst die je in invoervelden typt.",
160 | "description": "Popup: Beschrijving doeltaal voor invoer"
161 | },
162 | "open_sidepanel": {
163 | "message": "Vertalen in zijpaneel",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Automatisch detecteren",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Typ hier tekst om te vertalen…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "De vertaalde tekst verschijnt hier…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Tekstvertaling",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Bestandsvertaling",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Sleep een EPUB‑bestand hierheen of klik om te selecteren",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Ondersteund formaat: EPUB‑e‑books",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Bestand wordt geanalyseerd…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Bezig met vertalen… Sluit het zijpaneel niet",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "$1 van $2 segmenten vertaald",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "Vertaling voltooid!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Vertaald bestand downloaden",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Volgende keer automatisch downloaden",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Bestand kan niet worden geanalyseerd. Controleer het bestandsformaat.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Bestandsformaat niet ondersteund. Alleen EPUB‑bestanden worden ondersteund.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Bestand is te groot. Kies een kleiner bestand.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Selecteer eerst een bestand.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/pt/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Tradução Nativa — Tradução de IA integrada, com privacidade",
4 | "description": "Nome da extensão"
5 | },
6 | "extension_description": {
7 | "message": "Extensão privada para o Chrome com tradutor de IA integrado. Sem nuvem nem telemetria; seu conteúdo não sai do navegador. Modelos locais, off‑line.",
8 | "description": "Descrição"
9 | },
10 | "popup_title": {
11 | "message": "Tradução Nativa",
12 | "description": "Título do popup"
13 | },
14 | "global_availability": {
15 | "message": "Disponibilidade global",
16 | "description": "Rótulo"
17 | },
18 | "pair_availability": {
19 | "message": "Disponibilidade do par de idiomas",
20 | "description": "Rótulo"
21 | },
22 | "checking": {
23 | "message": "Verificando…",
24 | "description": "Estado"
25 | },
26 | "recheck_availability": {
27 | "message": "Verificar disponibilidade novamente",
28 | "description": "Botão"
29 | },
30 | "source_language": {
31 | "message": "Idioma de origem",
32 | "description": "Rótulo"
33 | },
34 | "target_language": {
35 | "message": "Idioma de destino",
36 | "description": "Rótulo"
37 | },
38 | "preparing_translator": {
39 | "message": "Preparando (pode baixar o modelo)…",
40 | "description": "Preparando"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Criar/Preparar tradutor",
44 | "description": "Botão"
45 | },
46 | "download_progress": {
47 | "message": "Progresso do download",
48 | "description": "Rótulo"
49 | },
50 | "translator_ready": {
51 | "message": "Tradutor pronto",
52 | "description": "Aviso"
53 | },
54 | "translate_full_page": {
55 | "message": "Traduzir a página atual",
56 | "description": "Botão"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Isso enviará um comando ao script de conteúdo para inserir traduções na página.",
60 | "description": "Descrição"
61 | },
62 | "hover_hotkey": {
63 | "message": "Atalho para traduzir ao passar o mouse",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Pressione e mantenha a tecla modificadora selecionada enquanto passa o mouse sobre o texto para traduzir o parágrafo.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Tecnologia da API de tradução de IA integrada. O download do modelo depende do dispositivo. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Rodapé"
85 | },
86 | "sidepanel_title": {
87 | "message": "Painel lateral de Tradução Nativa",
88 | "description": "Título do painel"
89 | },
90 | "unknown_error": {
91 | "message": "Erro desconhecido",
92 | "description": "Erro"
93 | },
94 | "translator_unavailable": {
95 | "message": "API de tradução indisponível (requer Chrome 138+ e modelo pronto)",
96 | "description": "Indisponível"
97 | },
98 | "create_translator_failed": {
99 | "message": "Falha ao criar o tradutor",
100 | "description": "Erro"
101 | },
102 | "active_tab_not_found": {
103 | "message": "A guia ativa não foi encontrada",
104 | "description": "Erro"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Falha ao enviar o comando de tradução",
108 | "description": "Erro"
109 | },
110 | "availability_unknown": {
111 | "message": "Desconhecido",
112 | "description": "Selo"
113 | },
114 | "availability_available": {
115 | "message": "Disponível",
116 | "description": "Selo"
117 | },
118 | "availability_downloadable": {
119 | "message": "Baixável",
120 | "description": "Selo"
121 | },
122 | "availability_unavailable": {
123 | "message": "Indisponível",
124 | "description": "Selo"
125 | },
126 | "overlay_preparing": {
127 | "message": "Preparando tradutor…",
128 | "description": "Overlay"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "API de tradução indisponível (requer Chrome 138+)",
132 | "description": "Overlay"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Usando modelo em cache…",
136 | "description": "Overlay"
137 | },
138 | "overlay_downloading": {
139 | "message": "Baixando modelo… $1%",
140 | "description": "Overlay"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Nada para traduzir",
144 | "description": "Overlay"
145 | },
146 | "overlay_translating": {
147 | "message": "Traduzindo… $1% ($2/$3)",
148 | "description": "Overlay"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Tradução concluída",
152 | "description": "Overlay"
153 | },
154 | "input_target_language": {
155 | "message": "Idioma de destino para entradas",
156 | "description": "Popup: Rótulo do idioma de destino para entrada"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Usado ao traduzir o texto que você digita em campos de entrada.",
160 | "description": "Popup: Descrição do idioma de destino para entrada"
161 | },
162 | "open_sidepanel": {
163 | "message": "Traduzir no painel lateral",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Detecção automática",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Digite aqui o texto para traduzir…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "O texto traduzido aparecerá aqui…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Tradução de Texto",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Tradução de Arquivos",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Arraste um arquivo EPUB aqui ou clique para selecionar",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Formato suportado: e‑books EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Analisando arquivo…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Traduzindo… Por favor, não feche o painel lateral",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "$1 de $2 segmentos traduzidos",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "Tradução concluída!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Baixar Arquivo Traduzido",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Baixar automaticamente da próxima vez",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Falha ao analisar o arquivo. Verifique o formato do arquivo.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Formato de arquivo não suportado. Apenas arquivos EPUB são suportados.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Arquivo muito grande. Selecione um arquivo menor.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Selecione um arquivo primeiro, por favor.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/es/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Traducción Nativa — Traducción de IA integrada y privada",
4 | "description": "Nombre de la extensión"
5 | },
6 | "extension_description": {
7 | "message": "Extensión privada para Chrome con traductor de IA integrado. Sin nube ni telemetría; tu contenido no sale del navegador. Modelos locales.",
8 | "description": "Descripción"
9 | },
10 | "popup_title": {
11 | "message": "Traducción Nativa",
12 | "description": "Título del popup"
13 | },
14 | "global_availability": {
15 | "message": "Disponibilidad global",
16 | "description": "Etiqueta"
17 | },
18 | "pair_availability": {
19 | "message": "Disponibilidad del par de idiomas",
20 | "description": "Etiqueta"
21 | },
22 | "checking": {
23 | "message": "Comprobando…",
24 | "description": "Estado"
25 | },
26 | "recheck_availability": {
27 | "message": "Volver a comprobar disponibilidad",
28 | "description": "Botón"
29 | },
30 | "source_language": {
31 | "message": "Idioma de origen",
32 | "description": "Etiqueta"
33 | },
34 | "target_language": {
35 | "message": "Idioma de destino",
36 | "description": "Etiqueta"
37 | },
38 | "preparing_translator": {
39 | "message": "Preparando (puede descargar el modelo)…",
40 | "description": "Preparando"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Crear/Preparar traductor",
44 | "description": "Botón"
45 | },
46 | "download_progress": {
47 | "message": "Progreso de descarga",
48 | "description": "Etiqueta"
49 | },
50 | "translator_ready": {
51 | "message": "Traductor listo",
52 | "description": "Aviso"
53 | },
54 | "translate_full_page": {
55 | "message": "Traducir página actual",
56 | "description": "Botón"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "Esto enviará un comando al script de contenido para insertar traducciones en la página.",
60 | "description": "Descripción"
61 | },
62 | "hover_hotkey": {
63 | "message": "Atajo para traducir al pasar el ratón",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Mantén pulsado el modificador seleccionado mientras pasas el ratón sobre el texto para traducir ese párrafo.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Con tecnología de la API de traducción de IA integrada. La descarga del modelo depende del dispositivo. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Pie"
85 | },
86 | "sidepanel_title": {
87 | "message": "Panel lateral de Traducción Nativa",
88 | "description": "Título panel"
89 | },
90 | "unknown_error": {
91 | "message": "Error desconocido",
92 | "description": "Error"
93 | },
94 | "translator_unavailable": {
95 | "message": "API de traducción no disponible (requiere Chrome 138+ y modelo listo)",
96 | "description": "No disponible"
97 | },
98 | "create_translator_failed": {
99 | "message": "Error al crear el traductor",
100 | "description": "Error"
101 | },
102 | "active_tab_not_found": {
103 | "message": "No se encontró la pestaña activa",
104 | "description": "Error"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Error al enviar el comando de traducción",
108 | "description": "Error"
109 | },
110 | "availability_unknown": {
111 | "message": "Desconocido",
112 | "description": "Insignia"
113 | },
114 | "availability_available": {
115 | "message": "Disponible",
116 | "description": "Insignia"
117 | },
118 | "availability_downloadable": {
119 | "message": "Descargable",
120 | "description": "Insignia"
121 | },
122 | "availability_unavailable": {
123 | "message": "No disponible",
124 | "description": "Insignia"
125 | },
126 | "overlay_preparing": {
127 | "message": "Preparando traductor…",
128 | "description": "Overlay"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "API de traducción no disponible (requiere Chrome 138+)",
132 | "description": "Overlay"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Usando modelo en caché…",
136 | "description": "Overlay"
137 | },
138 | "overlay_downloading": {
139 | "message": "Descargando modelo… $1%",
140 | "description": "Overlay"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Nada que traducir",
144 | "description": "Overlay"
145 | },
146 | "overlay_translating": {
147 | "message": "Traduciendo… $1% ($2/$3)",
148 | "description": "Overlay"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Traducción completada",
152 | "description": "Overlay"
153 | },
154 | "input_target_language": {
155 | "message": "Idioma de destino para entradas",
156 | "description": "Popup: Etiqueta del idioma de destino para entrada"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Se usa al traducir el texto que escribes en campos de entrada.",
160 | "description": "Popup: Descripción del idioma de destino para entrada"
161 | },
162 | "open_sidepanel": {
163 | "message": "Traducir en panel lateral",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Detección automática",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Escribe aquí el texto para traducir…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "El texto traducido aparecerá aquí…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Traducción de Texto",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "Traducción de Archivos",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Arrastra archivo EPUB aquí o haz clic para seleccionar",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Formato soportado: libros electrónicos EPUB",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Analizando archivo…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Traduciendo… Por favor no cierres el panel lateral",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "Traducido $1 de $2 segmentos",
204 | "description": "Detailed progress information"
205 | },
206 | "translation_completed": {
207 | "message": "¡Traducción completada!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Descargar Archivo Traducido",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Descargar automáticamente la próxima vez",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Error al analizar el archivo. Verifica el formato.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Formato no soportado. Solo archivos EPUB.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "Archivo demasiado grande. Selecciona uno más pequeño.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Por favor selecciona un archivo primero.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension_name": {
3 | "message": "Native Translate — Private, Built-in AI Translation",
4 | "description": "The name of the extension"
5 | },
6 | "extension_description": {
7 | "message": "Private, offline translation with Chrome's built-in AI. No cloud or telemetry: your text stays in the browser; models run locally.",
8 | "description": "The description of the extension"
9 | },
10 | "popup_title": {
11 | "message": "Native Translate",
12 | "description": "Popup title"
13 | },
14 | "global_availability": {
15 | "message": "Global availability",
16 | "description": "Availability label"
17 | },
18 | "pair_availability": {
19 | "message": "Language pair availability",
20 | "description": "Pair availability label"
21 | },
22 | "checking": {
23 | "message": "Checking…",
24 | "description": "Checking state"
25 | },
26 | "recheck_availability": {
27 | "message": "Re-check availability",
28 | "description": "Recheck button"
29 | },
30 | "source_language": {
31 | "message": "Source language",
32 | "description": "Source language label"
33 | },
34 | "target_language": {
35 | "message": "Target language",
36 | "description": "Target language label"
37 | },
38 | "preparing_translator": {
39 | "message": "Preparing (may download model)…",
40 | "description": "Preparing translator"
41 | },
42 | "create_prepare_translator": {
43 | "message": "Create/Prepare translator",
44 | "description": "Create translator button"
45 | },
46 | "download_progress": {
47 | "message": "Download progress",
48 | "description": "Download progress label"
49 | },
50 | "translator_ready": {
51 | "message": "Translator ready",
52 | "description": "Ready notice"
53 | },
54 | "translate_full_page": {
55 | "message": "Translate current page",
56 | "description": "Translate full page button"
57 | },
58 | "translate_full_page_desc": {
59 | "message": "This will send a command to the content script to insert translations into the page.",
60 | "description": "Translate page description"
61 | },
62 | "hover_hotkey": {
63 | "message": "Hover translate hotkey",
64 | "description": "Label for hover modifier hotkey"
65 | },
66 | "hover_hotkey_desc": {
67 | "message": "Press and hold the selected modifier while hovering text to translate that paragraph.",
68 | "description": "Description for hover hotkey behavior"
69 | },
70 | "hotkey_alt": {
71 | "message": "Alt (Option)",
72 | "description": "Alt label"
73 | },
74 | "hotkey_control": {
75 | "message": "Control",
76 | "description": "Control label"
77 | },
78 | "hotkey_shift": {
79 | "message": "Shift",
80 | "description": "Shift label"
81 | },
82 | "footer_note": {
83 | "message": "Powered by built-in AI Translator API. Model download depends on device requirements. — @Henry: https://zhanghe.dev/products/native-translate",
84 | "description": "Footer note"
85 | },
86 | "sidepanel_title": {
87 | "message": "Native Translate Side Panel",
88 | "description": "Side panel title"
89 | },
90 | "unknown_error": {
91 | "message": "Unknown error",
92 | "description": "Generic error"
93 | },
94 | "translator_unavailable": {
95 | "message": "Translator API unavailable (requires Chrome 138+ and model readiness)",
96 | "description": "Translator unavailable"
97 | },
98 | "create_translator_failed": {
99 | "message": "Failed to create translator",
100 | "description": "Create translator failed"
101 | },
102 | "active_tab_not_found": {
103 | "message": "Active tab not found",
104 | "description": "Active tab missing"
105 | },
106 | "send_translate_command_failed": {
107 | "message": "Failed to send translate command",
108 | "description": "Send command failed"
109 | },
110 | "availability_unknown": {
111 | "message": "Unknown",
112 | "description": "Availability badge"
113 | },
114 | "availability_available": {
115 | "message": "Available",
116 | "description": "Availability badge"
117 | },
118 | "availability_downloadable": {
119 | "message": "Downloadable",
120 | "description": "Availability badge"
121 | },
122 | "availability_unavailable": {
123 | "message": "Unavailable",
124 | "description": "Availability badge"
125 | },
126 | "overlay_preparing": {
127 | "message": "Preparing translator…",
128 | "description": "Overlay preparing"
129 | },
130 | "overlay_api_unavailable": {
131 | "message": "Translator API unavailable (requires Chrome 138+)",
132 | "description": "Overlay API unavailable"
133 | },
134 | "overlay_using_cached": {
135 | "message": "Using cached model…",
136 | "description": "Overlay using cached"
137 | },
138 | "overlay_downloading": {
139 | "message": "Downloading model… $1%",
140 | "description": "Overlay downloading with percentage"
141 | },
142 | "overlay_nothing_to_translate": {
143 | "message": "Nothing to translate",
144 | "description": "Overlay nothing"
145 | },
146 | "overlay_translating": {
147 | "message": "Translating… $1% ($2/$3)",
148 | "description": "Overlay translating with progress"
149 | },
150 | "overlay_translation_complete": {
151 | "message": "Translation complete",
152 | "description": "Overlay complete"
153 | },
154 | "input_target_language": {
155 | "message": "Input target language",
156 | "description": "Popup: Input target language label"
157 | },
158 | "input_target_language_desc": {
159 | "message": "Used when translating text you type into fields.",
160 | "description": "Popup: Input target language description"
161 | },
162 | "open_sidepanel": {
163 | "message": "Side panel translate",
164 | "description": "Popup: open side panel button"
165 | },
166 | "auto_detect": {
167 | "message": "Auto detect",
168 | "description": "Side panel: auto detect option"
169 | },
170 | "sidepanel_input_placeholder": {
171 | "message": "Type text to translate here…",
172 | "description": "Side panel: input placeholder"
173 | },
174 | "sidepanel_output_placeholder": {
175 | "message": "Translated text will appear here…",
176 | "description": "Side panel: output placeholder"
177 | },
178 | "text_translation_tab": {
179 | "message": "Text Translation",
180 | "description": "Tab title for text translation"
181 | },
182 | "file_translation_tab": {
183 | "message": "File Translation",
184 | "description": "Tab title for file translation"
185 | },
186 | "file_upload_area": {
187 | "message": "Drag EPUB file here or click to select",
188 | "description": "File upload area prompt text"
189 | },
190 | "file_supported_formats": {
191 | "message": "Supported format: EPUB e-books",
192 | "description": "Supported file formats description"
193 | },
194 | "file_parsing": {
195 | "message": "Parsing file…",
196 | "description": "File parsing status text"
197 | },
198 | "file_translating_progress": {
199 | "message": "Translating… Please do not close the side panel",
200 | "description": "Translation progress warning text"
201 | },
202 | "translation_progress_detail": {
203 | "message": "Translated $1 of $2 segments",
204 | "description": "Detailed progress information, $1 for completed count, $2 for total count"
205 | },
206 | "translation_completed": {
207 | "message": "Translation completed!",
208 | "description": "Translation completed status"
209 | },
210 | "download_translated_file": {
211 | "message": "Download Translated File",
212 | "description": "Download button text"
213 | },
214 | "auto_download": {
215 | "message": "Auto-download next time",
216 | "description": "Auto-download switch text"
217 | },
218 | "file_parse_error": {
219 | "message": "Failed to parse file. Please check the file format.",
220 | "description": "File parsing error message"
221 | },
222 | "unsupported_file_format": {
223 | "message": "Unsupported file format. Only EPUB files are supported.",
224 | "description": "File format error message"
225 | },
226 | "file_too_large": {
227 | "message": "File is too large. Please select a smaller file.",
228 | "description": "File size error message"
229 | },
230 | "select_file_first": {
231 | "message": "Please select a file first.",
232 | "description": "No file selected error message"
233 | }
234 | }
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | **Native Translate** is a privacy-focused Chrome browser extension that uses Chrome's built-in AI Translator and Language Detector APIs. It provides on-device translation without external API calls, supporting full-page translation and hover-to-translate functionality with 26 languages. The extension follows Chrome Extension Manifest v3 architecture and includes comprehensive internationalization support.
8 |
9 | ## Development Commands
10 |
11 | ### Build & Development
12 | - `pnpm dev` - Start development build with file watching AND auto-reload server (listens on port 5173/5174)
13 | - `pnpm build` - Production build (creates dist/ and Native-translate.zip)
14 | - `pnpm tsc` - TypeScript type checking
15 | - `pnpm lint` - Run Biome linter
16 | - `pnpm lint:fix` - Run Biome linter with auto-fix
17 | - **Package Manager**: Uses `pnpm@9.15.1+` (specified in packageManager field)
18 |
19 | ### Development Auto-Reload
20 | - **Watch Mode**: `pnpm dev` runs Rspack in watch mode, rebuilding on file changes
21 | - **Extension Reload**: Manual reload required in chrome://extensions during development
22 | - **Environment**: Uses `__DEV__` flag to conditionally enable dev features
23 |
24 | ### Testing
25 | - No test framework configured yet (`npm test` returns error)
26 |
27 | ## Architecture
28 |
29 | ### Browser Extension Structure
30 | The extension follows Chrome Extension Manifest v3 architecture with these entry points:
31 |
32 | - **Background Script** (`src/scripts/background.ts`) - Service worker handling tab events and side panel management
33 | - **Content Script** (`src/scripts/contentScript.ts`) - Main translation engine with hover-to-translate functionality and page world bridge
34 | - **Side Panel** (`src/sidePanel/sidePanel.tsx`) - React component for the extension's side panel UI
35 | - **Popup** (`src/popup/popup.tsx`) - React component for extension popup with settings and translation controls
36 |
37 | ### Key Features
38 | 1. **On-Device Translation**: Uses Chrome's built-in AI Translator API (Chrome 138+)
39 | 2. **Full-Page Translation**: Appends translated text under original content blocks
40 | 3. **Hover-to-Translate**: Hold modifier key (Alt/Control/Shift) and hover over paragraphs
41 | 4. **Automatic Language Detection**: Uses Chrome's Language Detector API
42 | 5. **Model Caching**: Downloads and caches translation models per language pair
43 | 6. **Internationalization**: Support for 13 languages via `_locales/` directory
44 | 7. **RTL/LTR Support**: Automatic text direction handling for target languages
45 | 8. **Triple-Space Translation**: Type three spaces in input fields to translate content automatically
46 | 9. **Multi-Frame Support**: Content script runs in all frames including about:blank pages
47 | 10. **Input Field Translation**: Supports translation in contentEditable areas and text inputs
48 |
49 | ### Technology Stack
50 | - **Frontend**: React 19 + TypeScript
51 | - **Styling**: Tailwind CSS v4 with PostCSS
52 | - **Build Tool**: Rspack (webpack alternative) with SWC compiler
53 | - **UI Components**: Radix UI primitives (Select, Label, Button)
54 | - **Bundle Structure**: Separate chunks for popup, sidePanel, background, and contentScript
55 |
56 | ### Core Architecture Patterns
57 |
58 | #### Translation Engine (`src/scripts/contentScript.ts`)
59 | - **Block Collection**: Intelligently selects translatable content blocks while avoiding navigation elements
60 | - **Model Management**: Handles translator/detector API availability, downloads, and caching
61 | - **Progress Overlay**: Shows download and translation progress with user feedback
62 | - **Memory Caching**: Caches translations at both line and language-pair levels
63 | - **Hover System**: Event-driven hover translation with configurable modifier keys
64 | - **Translator API Adapter**: Supports both legacy (`window.Translator`) and modern (`window.translation.createTranslator`) Chrome APIs
65 | - **Page World Bridge**: Injects bridge script to access Translator API from isolated content script context
66 | - **Fallback Strategy**: Gracefully falls back to bridge translation when direct API access is unavailable
67 | - **Input Field Translation**: Triple-space trigger for translating content in input fields and contentEditable areas
68 | - **IME Awareness**: Handles composition events for Asian languages to prevent false triggers
69 | - **Smart Element Selection**: Avoids translating code blocks, tables, and navigation elements
70 |
71 | #### Settings Management
72 | - **Storage**: Uses chrome.storage.local for persistence
73 | - **Hotkey Configuration**: Configurable modifier keys (Alt/Control/Shift)
74 | - **Language Preferences**: Target language selection with 26 supported options
75 | - **Real-time Updates**: Settings changes propagate to content scripts immediately
76 |
77 | #### Chrome APIs Integration
78 | - **Translator API**: `window.Translator` (legacy) and `window.translation.createTranslator` (modern) for on-device translation
79 | - **Language Detector API**: `window.LanguageDetector` for source language detection
80 | - **Storage API**: For settings and model readiness caching
81 | - **Scripting API**: For content script injection when needed
82 | - **Tabs API**: For tab management and communication
83 | - **Runtime API**: For extension messaging and lifecycle management
84 |
85 | ### File Structure Patterns
86 | ```
87 | src/
88 | ├── manifest.json # Extension configuration
89 | ├── popup/ # Popup UI (HTML + React)
90 | ├── sidePanel/ # Side panel UI (HTML + React)
91 | ├── scripts/ # Background and content scripts
92 | ├── shared/ # Cross-context types and utilities
93 | ├── styles/ # Tailwind CSS configuration
94 | ├── components/ui/ # Reusable UI components (Radix-based)
95 | └── utils/ # Utility functions (i18n, RTL, EPUB parsing)
96 | ```
97 |
98 | ### Development Patterns
99 | - **React Components**: Functional components with hooks (React.useState)
100 | - **Chrome APIs**: Uses chrome.tabs, chrome.sidePanel, chrome.action, chrome.storage
101 | - **Error Handling**: Graceful fallbacks for API unavailability and script injection failures
102 | - **Styling**: Tailwind utility classes with dark mode support
103 | - **Type Safety**: Strict TypeScript with Chrome API types from chrome-types
104 |
105 | ### Rspack Configuration
106 | - **Entry Points**: Multi-entry setup for all extension components including offscreen document
107 | - **TypeScript**: SWC-based transpilation with JSX support and automatic runtime
108 | - **CSS**: PostCSS + Tailwind processing with extraction to separate files
109 | - **Output**: Clean builds to `dist/` directory with manifest and assets copying
110 | - **Zip Plugin**: Custom ZipAfterBuildPlugin creates distribution zip file (production only)
111 | - **Asset Handling**: Icons and fonts copied to appropriate locations
112 |
113 | ### Manifest Configuration
114 | - **Permissions**: storage, activeTab, scripting, tabs, sidePanel
115 | - **Content Scripts**: Runs on all URLs (``) with `all_frames: true` and `match_about_blank: true`
116 | - **Minimum Chrome**: v138+ (required for built-in AI APIs)
117 | - **Side Panel**: Default path set to `sidePanel.html`
118 | - **Action**: Popup enabled with full icon set
119 |
120 | ### Code Quality Tools
121 | - **Biome**: Linting and formatting (2-space indentation, 100-character line width)
122 | - **TypeScript**: Strict type checking with Chrome API definitions
123 | - **Chrome Types**: Type definitions for Chrome extension APIs
124 | - **Git Integration**: Biome VCS support enabled
125 |
126 | ### Internationalization
127 | - **Chrome i18n**: Uses chrome.i18n.getMessage for all UI text
128 | - **Locale Support**: 13 languages in `_locales/` directory
129 | - **RTL Handling**: Automatic direction detection and styling
130 | - **Fallback Strategy**: Returns key if translation not found
131 |
132 | ### Development Architecture
133 | - **Bridge Architecture**: Content script injects bridge into page world to access Translator API
134 | - **API Adapter Pattern**: Handles both legacy and modern Chrome Translator API implementations
135 | - **Streaming Translation**: Progressive translation with visual feedback for long texts
136 | - **Memory Management**: WeakSet tracking and translation caching for performance
137 |
138 | ## Cursor Rules Summary
139 |
140 | ### Project Structure & Entry Points
141 | - **Entry points must match manifest.json exactly**: background.js, contentScript.js, popup.html, sidePanel.html
142 | - **Use `@/*` path aliases** for absolute imports from `src/` (configured in both tsconfig.json and rspack.config.js)
143 | - **Maintain strict naming consistency** between build outputs and manifest references
144 | - **Core configuration files**: rspack.config.js, tsconfig.json, biome.json, package.json
145 | - **Browser extension manifest**: src/manifest.json with minimum Chrome version 138+
146 |
147 | ### TypeScript & React 19
148 | - **Strict TypeScript mode**: `strict: true` enabled, avoid `any` types, prefer `unknown` or explicit types
149 | - **Explicit type annotations**: Public APIs must have complete function signatures and return types
150 | - **React 19 patterns**: Function components with hooks, automatic JSX runtime, explicit prop typing
151 | - **Import order**: React → third-party → local (types, components, utils)
152 | - **Component props must be explicitly typed** with interfaces
153 | - **Target & libs**: ES2020 with DOM, DOM.Iterable, ES2020 for extension runtime compatibility
154 |
155 | ### UI & Styling
156 | - **Use Radix UI components** from `src/components/ui/` (Button, Select, Label, Progress, Textarea)
157 | - **Tailwind CSS with `cn()` utility** for class merging (clsx + tailwind-merge)
158 | - **Z-index for overlays**: `z-[2147483647]` to avoid being covered by page content
159 | - **Component variants** using `class-variance-authority` pattern
160 | - **Dark mode support**: Use `dark:` prefix, avoid custom CSS when possible
161 | - **Typography**: Default `text-sm` with compact spacing
162 | - **Import global styles**: Each entry point must import `../styles/tailwind.css`
163 |
164 | ### Extension Development
165 | - **Manifest v3** with service worker architecture
166 | - **Chrome 138+ required** for built-in AI APIs
167 | - **Development builds** use watch mode with manual extension reload
168 | - **Production builds** automatically create zip distribution package
169 | - **Multi-frame support**: Content scripts run in all frames including about:blank
170 | - **Path mapping**: Use `@/*` aliases consistently across TypeScript and build config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | /* Visit https://aka.ms/tsconfig to read more about this file */
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 | /* Language and Environment */
13 | "target": "ES2020", /* Modern target for extension runtime and AsyncIterable support */
14 | "lib": [
15 | "DOM",
16 | "DOM.Iterable",
17 | "ES2020"
18 | ], /* Include AsyncIterable and modern libs */
19 | // "jsx": "preserve", /* Specify what JSX code is generated. */
20 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
21 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
22 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
23 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
24 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
25 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
26 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
27 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
28 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
29 | /* Modules */
30 | "module": "ESNext", /* Specify what module code is generated. */
31 | // "rootDir": "./", /* Specify the root folder within your source files. */
32 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
33 | "baseUrl": ".", /* Enable absolute imports from project root */
34 | "paths": {
35 | "@/*": [
36 | "src/*"
37 | ]
38 | }, /* Match bundler alias for TS */
39 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
40 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
41 | "types": [
42 | "chrome-types",
43 | "vitest/globals",
44 | "@testing-library/jest-dom"
45 | ], /* Specify type package names to be included without being referenced in a source file. */
46 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
47 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
48 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
49 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
50 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
51 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
52 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
53 | // "resolveJsonModule": true, /* Enable importing .json files. */
54 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
55 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
56 | /* JavaScript Support */
57 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
58 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
59 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
60 | /* Emit */
61 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
62 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
63 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
64 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
65 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
66 | "noEmit": true, /* Disable emitting files from a compilation. */
67 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
68 | // "outDir": "./", /* Specify an output folder for all emitted files. */
69 | // "removeComments": true, /* Disable emitting comments. */
70 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
71 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
72 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
73 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
74 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
75 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
76 | // "newLine": "crlf", /* Set the newline character for emitting files. */
77 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
78 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
79 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
80 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
81 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
82 | /* Interop Constraints */
83 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
84 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
85 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
86 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
87 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
88 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
89 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
90 | /* Type Checking */
91 | "strict": true, /* Enable all strict type-checking options. */
92 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
93 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
94 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
95 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
96 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
97 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
98 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
99 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
100 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
101 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
102 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
103 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
104 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
105 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
106 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
107 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
108 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
109 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
110 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
111 | /* Completeness */
112 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
113 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
114 | },
115 | "include": [
116 | "src/**/*"
117 | ],
118 | "exclude": [
119 | "node_modules",
120 | "dist"
121 | ]
122 | }
--------------------------------------------------------------------------------
/src/popup/popup.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/tailwind.css';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom/client';
4 | import { t } from '@/utils/i18n';
5 | import { getUILocale, isRTLLanguage } from '@/utils/rtl';
6 | import { Button } from '@/components/ui/button';
7 | import { Label } from '@/components/ui/label';
8 | import { AppSelect } from '@/components/ui/select';
9 | import { Alert, AlertDescription } from '@/components/ui/alert';
10 | import { Card, CardContent } from '@/components/ui/card';
11 | import { useChromeLocalStorage } from '@/utils/useChromeLocalStorage';
12 | import { cn } from '@/utils/cn';
13 | import { Globe2, Keyboard, Languages, Loader2, PanelRightOpen, Wand2 } from 'lucide-react';
14 | import {
15 | LanguageCode,
16 | SUPPORTED_LANGUAGES,
17 | DEFAULT_TARGET_LANGUAGE,
18 | DEFAULT_INPUT_TARGET_LANGUAGE,
19 | } from '@/shared/languages';
20 | import { POPUP_SETTINGS_KEY } from '@/shared/settings';
21 | import { MSG_TRANSLATE_PAGE, MSG_UPDATE_HOTKEY, MSG_WARM_TRANSLATOR } from '@/shared/messages';
22 |
23 | interface PopupSettings {
24 | targetLanguage: LanguageCode;
25 | hotkeyModifier?: 'alt' | 'control' | 'shift';
26 | inputTargetLanguage?: LanguageCode;
27 | }
28 |
29 | const defaultSettings: PopupSettings = {
30 | targetLanguage: DEFAULT_TARGET_LANGUAGE,
31 | hotkeyModifier: 'alt',
32 | inputTargetLanguage: DEFAULT_INPUT_TARGET_LANGUAGE,
33 | };
34 |
35 | const LANGUAGE_OPTIONS = SUPPORTED_LANGUAGES.map((lang) => ({
36 | value: lang.code,
37 | label: lang.label,
38 | }));
39 |
40 | const HOTKEY_OPTIONS: ReadonlyArray<{
41 | value: NonNullable;
42 | labelKey: 'hotkey_alt' | 'hotkey_control' | 'hotkey_shift';
43 | }> = [
44 | { value: 'alt', labelKey: 'hotkey_alt' },
45 | { value: 'control', labelKey: 'hotkey_control' },
46 | { value: 'shift', labelKey: 'hotkey_shift' },
47 | ];
48 |
49 | const Popup: React.FC = () => {
50 | const [settings, setSettings, settingsReady] = useChromeLocalStorage(
51 | POPUP_SETTINGS_KEY,
52 | defaultSettings
53 | );
54 | const [error, setError] = React.useState(null);
55 | const [isTranslatingPage, setIsTranslatingPage] = React.useState(false);
56 | const [isOpeningSidePanel, setIsOpeningSidePanel] = React.useState(false);
57 | const translateBusyRef = React.useRef(false);
58 | const sidePanelBusyRef = React.useRef(false);
59 |
60 | const warmActiveTabTranslator = React.useCallback(
61 | async (payload: { targetLanguage?: LanguageCode; sourceLanguage?: LanguageCode | 'auto' }) => {
62 | try {
63 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
64 | if (!tab?.id) return;
65 | const sendWarm = async () => {
66 | await chrome.tabs.sendMessage(tab.id!, {
67 | type: MSG_WARM_TRANSLATOR,
68 | payload,
69 | });
70 | };
71 | try {
72 | await sendWarm();
73 | } catch (error) {
74 | const url = tab.url ?? '';
75 | if (!/^(chrome|edge|about|brave|opera|vivaldi):/i.test(url)) {
76 | try {
77 | await chrome.scripting.executeScript({
78 | target: { tabId: tab.id },
79 | files: ['contentScript.js'],
80 | });
81 | await sendWarm();
82 | } catch (_e) {
83 | // ignore warm failure
84 | }
85 | }
86 | }
87 | } catch (_err) {
88 | // ignore
89 | }
90 | },
91 | []
92 | );
93 |
94 | React.useEffect(() => {
95 | // 根据 UI 语言设置方向
96 | const ui = getUILocale();
97 | const dir = isRTLLanguage(ui) ? 'rtl' : 'ltr';
98 | document.documentElement.setAttribute('dir', dir);
99 | document.documentElement.setAttribute('lang', ui);
100 |
101 | }, []);
102 |
103 | // Removed global availability check logic
104 |
105 | React.useEffect(() => {
106 | if (!settingsReady) return;
107 | void warmActiveTabTranslator({ targetLanguage: settings.targetLanguage });
108 | }, [settings.targetLanguage, settingsReady, warmActiveTabTranslator]);
109 |
110 | React.useEffect(() => {
111 | if (!settingsReady) return;
112 | const inputTarget = settings.inputTargetLanguage ?? DEFAULT_INPUT_TARGET_LANGUAGE;
113 | void warmActiveTabTranslator({ targetLanguage: inputTarget });
114 | }, [settings.inputTargetLanguage, settingsReady, warmActiveTabTranslator]);
115 |
116 | const handleTranslatePage = React.useCallback(async () => {
117 | if (translateBusyRef.current) return;
118 | setError(null);
119 | translateBusyRef.current = true;
120 | setIsTranslatingPage(true);
121 | try {
122 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
123 | if (!tab?.id) throw new Error(t('active_tab_not_found'));
124 | const send = async () => {
125 | return chrome.tabs.sendMessage(tab.id!, {
126 | type: MSG_TRANSLATE_PAGE,
127 | payload: {
128 | targetLanguage: settings.targetLanguage,
129 | },
130 | });
131 | };
132 |
133 | try {
134 | await send();
135 | } catch (_err) {
136 | // 若内容脚本未就绪,则主动注入后重试
137 | try {
138 | const url = tab.url ?? '';
139 | if (/^(chrome|edge|about|brave|opera|vivaldi):/i.test(url)) {
140 | throw new Error('This page is not scriptable');
141 | }
142 | await chrome.scripting.executeScript({
143 | target: { tabId: tab.id },
144 | files: ['contentScript.js'],
145 | });
146 | await send();
147 | } catch (injectionErr) {
148 | throw injectionErr instanceof Error
149 | ? injectionErr
150 | : new Error('Failed to inject content script');
151 | }
152 | }
153 | window.close();
154 | } catch (e) {
155 | setError(e instanceof Error ? e.message : t('send_translate_command_failed'));
156 | } finally {
157 | translateBusyRef.current = false;
158 | setIsTranslatingPage(false);
159 | }
160 | }, [settings.targetLanguage]);
161 |
162 | const handleOpenSidePanel = React.useCallback(async () => {
163 | if (sidePanelBusyRef.current) return;
164 | setError(null);
165 | sidePanelBusyRef.current = true;
166 | setIsOpeningSidePanel(true);
167 | try {
168 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
169 | if (!tab?.id) throw new Error(t('active_tab_not_found'));
170 |
171 | try {
172 | await chrome.sidePanel.setOptions({
173 | tabId: tab.id,
174 | path: 'sidePanel.html',
175 | enabled: true,
176 | });
177 | } catch (_e) { /* noop */ }
178 |
179 | try {
180 | await chrome.sidePanel.open({ tabId: tab.id } as any);
181 | } catch (_e) {
182 | try {
183 | await chrome.sidePanel.setPanelBehavior?.({ openPanelOnActionClick: false } as any);
184 | await chrome.sidePanel.open({ tabId: tab.id } as any);
185 | } catch (err) {
186 | throw err instanceof Error
187 | ? err
188 | : new Error('Failed to open side panel');
189 | }
190 | }
191 | window.close();
192 | } catch (e) {
193 | setError(e instanceof Error ? e.message : t('unknown_error'));
194 | } finally {
195 | sidePanelBusyRef.current = false;
196 | setIsOpeningSidePanel(false);
197 | }
198 | }, []);
199 |
200 | return (
201 |
207 |
223 |
224 | {!settingsReady ? (
225 |
226 |
232 |
233 | {t('checking')}
234 |
235 |
236 | ) : (
237 | <>
238 |
244 |
245 |
246 |
255 |
{
259 | const next = v as LanguageCode;
260 | setSettings((s) => ({ ...s, targetLanguage: next }));
261 | void warmActiveTabTranslator({ targetLanguage: next });
262 | }}
263 | options={LANGUAGE_OPTIONS}
264 | />
265 |
266 |
267 |
268 |
277 |
{
281 | const next = v as LanguageCode;
282 | setSettings((s) => ({ ...s, inputTargetLanguage: next }));
283 | void warmActiveTabTranslator({ targetLanguage: next });
284 | }}
285 | options={LANGUAGE_OPTIONS}
286 | />
287 |
288 | {t('input_target_language_desc')}
289 |
290 |
291 |
292 |
293 |
294 |
300 |
301 |
302 |
311 |
{
315 | const next = v as 'alt' | 'control' | 'shift';
316 | setSettings((s) => ({ ...s, hotkeyModifier: next }));
317 | try {
318 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
319 | if (tab?.id) {
320 | try {
321 | await chrome.tabs.sendMessage(tab.id, {
322 | type: MSG_UPDATE_HOTKEY,
323 | payload: { hotkeyModifier: next },
324 | });
325 | } catch (_err) {
326 | const url = tab.url ?? '';
327 | if (!/^(chrome|edge|about|brave|opera|vivaldi):/i.test(url)) {
328 | try {
329 | await chrome.scripting.executeScript({
330 | target: { tabId: tab.id },
331 | files: ['contentScript.js'],
332 | });
333 | await chrome.tabs.sendMessage(tab.id, {
334 | type: MSG_UPDATE_HOTKEY,
335 | payload: { hotkeyModifier: next },
336 | });
337 | } catch (_e) {
338 | /* noop */
339 | }
340 | }
341 | }
342 | }
343 | } catch (_e) {
344 | /* noop */
345 | }
346 | }}
347 | options={HOTKEY_OPTIONS.map((option) => ({
348 | value: option.value,
349 | label: t(option.labelKey),
350 | }))}
351 | />
352 |
353 | {t('hover_hotkey_desc')}
354 |
355 |
356 |
357 |
358 |
370 |
371 | {t('translate_full_page_desc')}
372 |
373 |
374 |
375 |
388 |
389 |
390 | >
391 | )}
392 |
393 | {error && (
394 |
395 | {error}
396 |
397 | )}
398 |
399 |
407 |
408 | );
409 | };
410 |
411 | const container = document.getElementById('root');
412 | const root = ReactDOM.createRoot(container as HTMLElement);
413 | root.render();
414 |
--------------------------------------------------------------------------------
/src/utils/epubParser.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 |
3 | export interface EpubChapter {
4 | id: string;
5 | title: string;
6 | content: string;
7 | order: number;
8 | }
9 |
10 | export interface EpubMetadata {
11 | title: string;
12 | author: string;
13 | language: string;
14 | identifier: string;
15 | }
16 |
17 | export interface EpubBook {
18 | metadata: EpubMetadata;
19 | chapters: EpubChapter[];
20 | }
21 |
22 | export interface TextSegment {
23 | id: string;
24 | chapterId: string;
25 | originalText: string;
26 | translatedText?: string;
27 | elementType: string;
28 | order: number;
29 | // CSS path to the original element within the chapter document
30 | domPath: string;
31 | }
32 |
33 | class EpubParser {
34 | private zip: JSZip;
35 | private opfPath = '';
36 | private spine: Array<{ id: string; href: string }> = [];
37 | private manifest: Map = new Map();
38 |
39 | constructor(zipFile: JSZip) {
40 | this.zip = zipFile;
41 | }
42 |
43 | async parse(): Promise {
44 | // Find and parse container.xml to get OPF path
45 | await this.findOpfPath();
46 |
47 | // Parse OPF file to get metadata and spine
48 | const { metadata, spine, manifest } = await this.parseOpf();
49 | this.spine = spine;
50 | this.manifest = manifest;
51 |
52 | // Extract chapters content
53 | const chapters = await this.extractChapters();
54 |
55 | return {
56 | metadata,
57 | chapters
58 | };
59 | }
60 |
61 | private async findOpfPath(): Promise {
62 | const containerFile = this.zip.file('META-INF/container.xml');
63 | if (!containerFile) {
64 | throw new Error('Invalid EPUB: Missing container.xml');
65 | }
66 |
67 | const containerXml = await containerFile.async('text');
68 | const parser = new DOMParser();
69 | const doc = parser.parseFromString(containerXml, 'application/xml');
70 |
71 | const rootfile = doc.querySelector('rootfile');
72 | if (!rootfile) {
73 | throw new Error('Invalid EPUB: Missing rootfile in container.xml');
74 | }
75 |
76 | this.opfPath = rootfile.getAttribute('full-path') || '';
77 | if (!this.opfPath) {
78 | throw new Error('Invalid EPUB: Missing full-path in rootfile');
79 | }
80 | }
81 |
82 | private async parseOpf(): Promise<{
83 | metadata: EpubMetadata;
84 | spine: Array<{ id: string; href: string }>;
85 | manifest: Map;
86 | }> {
87 | const opfFile = this.zip.file(this.opfPath);
88 | if (!opfFile) {
89 | throw new Error(`Invalid EPUB: Missing OPF file at ${this.opfPath}`);
90 | }
91 |
92 | const opfXml = await opfFile.async('text');
93 | const parser = new DOMParser();
94 | const doc = parser.parseFromString(opfXml, 'application/xml');
95 |
96 | // Extract metadata
97 | const metadata = this.extractMetadata(doc);
98 |
99 | // Extract manifest
100 | const manifest = new Map();
101 | const manifestItems = doc.querySelectorAll('manifest item');
102 | for (const item of manifestItems) {
103 | const id = item.getAttribute('id');
104 | const href = item.getAttribute('href');
105 | if (id && href) {
106 | manifest.set(id, href);
107 | }
108 | }
109 |
110 | // Extract spine order
111 | const spine: Array<{ id: string; href: string }> = [];
112 | const spineItems = doc.querySelectorAll('spine itemref');
113 | for (const itemref of spineItems) {
114 | const idref = itemref.getAttribute('idref');
115 | if (!idref) continue;
116 | const href = manifest.get(idref);
117 | if (!href) continue;
118 | spine.push({ id: idref, href });
119 | }
120 |
121 | return { metadata, spine, manifest };
122 | }
123 |
124 | private extractMetadata(doc: Document): EpubMetadata {
125 | const getMetadata = (selector: string): string => {
126 | const element = doc.querySelector(selector);
127 | return element?.textContent?.trim() || '';
128 | };
129 |
130 | return {
131 | title: getMetadata('metadata title') || 'Unknown Title',
132 | author: getMetadata('metadata creator') || 'Unknown Author',
133 | language: getMetadata('metadata language') || 'en',
134 | identifier: getMetadata('metadata identifier') || ''
135 | };
136 | }
137 |
138 | private async extractChapters(): Promise {
139 | const chapters: EpubChapter[] = [];
140 | const opfDir = this.opfPath.substring(0, this.opfPath.lastIndexOf('/'));
141 | const basePath = opfDir ? `${opfDir}/` : '';
142 |
143 | for (let i = 0; i < this.spine.length; i++) {
144 | const spineItem = this.spine[i];
145 | const chapterPath = basePath + spineItem.href;
146 |
147 | try {
148 | const chapterFile = this.zip.file(chapterPath);
149 | if (!chapterFile) {
150 | console.warn(`Chapter file not found: ${chapterPath}`);
151 | continue;
152 | }
153 |
154 | const chapterHtml = await chapterFile.async('text');
155 | const parser = new DOMParser();
156 | const doc = parser.parseFromString(chapterHtml, 'text/html');
157 |
158 | // Extract title from h1, h2, or title element
159 | const titleElement = doc.querySelector('h1, h2, title');
160 | const title = titleElement?.textContent?.trim() || `Chapter ${i + 1}`;
161 |
162 | // Clean and extract text content
163 | const content = this.cleanHtmlContent(doc);
164 |
165 | chapters.push({
166 | id: spineItem.id,
167 | title,
168 | content,
169 | order: i
170 | });
171 | } catch (error) {
172 | console.error(`Error processing chapter ${chapterPath}:`, error);
173 | }
174 | }
175 |
176 | return chapters;
177 | }
178 |
179 | private cleanHtmlContent(doc: Document): string {
180 | // Remove script and style elements
181 | const scriptsAndStyles = doc.querySelectorAll('script, style');
182 | for (const element of scriptsAndStyles) {
183 | element.remove();
184 | }
185 |
186 | // Get text content from body, preserving paragraph structure
187 | const body = doc.querySelector('body');
188 | if (!body) return '';
189 |
190 | // Extract text while preserving paragraph breaks
191 | const paragraphs: string[] = [];
192 | const textElements = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, div');
193 |
194 | for (const element of textElements) {
195 | const text = element.textContent?.trim();
196 | if (text && text.length > 10) { // Filter out very short texts
197 | paragraphs.push(text);
198 | }
199 | }
200 |
201 | return paragraphs.join('\n\n');
202 | }
203 |
204 | public async extractTextSegments(_book: EpubBook): Promise {
205 | const segments: TextSegment[] = [];
206 | let segmentId = 0;
207 |
208 | const opfDir = this.opfPath.substring(0, this.opfPath.lastIndexOf('/'));
209 | const basePath = opfDir ? `${opfDir}/` : '';
210 |
211 | const computeCssPath = (el: Element): string => {
212 | const parts: string[] = [];
213 | let node: Element | null = el;
214 | while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== 'body') {
215 | const tag = node.tagName.toLowerCase();
216 | const parent: Element | null = node.parentElement;
217 | if (!parent) break;
218 | const siblings = Array.from(parent.children).filter((c: Element) => c.tagName.toLowerCase() === tag);
219 | const index = siblings.indexOf(node) + 1; // nth-of-type is 1-based
220 | parts.unshift(`${tag}:nth-of-type(${index})`);
221 | node = parent;
222 | }
223 | return parts.length > 0 ? `body > ${parts.join(' > ')}` : 'body';
224 | };
225 |
226 | for (let i = 0; i < this.spine.length; i++) {
227 | const spineItem = this.spine[i];
228 | const chapterPath = basePath + spineItem.href;
229 | const file = this.zip.file(chapterPath);
230 | if (!file) continue;
231 |
232 | try {
233 | const html = await file.async('text');
234 | const dom = new DOMParser();
235 | let doc = dom.parseFromString(html, 'application/xhtml+xml');
236 | if (doc.getElementsByTagName('parsererror').length > 0) {
237 | doc = dom.parseFromString(html, 'text/html');
238 | }
239 | const textElements = doc.querySelectorAll('p, li, blockquote, h1, h2, h3, h4, h5, h6');
240 | let order = 0;
241 | for (const el of Array.from(textElements)) {
242 | const text = el.textContent?.trim() || '';
243 | if (text.length <= 10) continue;
244 | const domPath = computeCssPath(el);
245 | segments.push({
246 | id: `segment-${segmentId++}`,
247 | chapterId: spineItem.id,
248 | originalText: text,
249 | elementType: el.tagName.toLowerCase(),
250 | order: order++,
251 | domPath,
252 | });
253 | }
254 | } catch (e) {
255 | console.error('extractTextSegments error for', chapterPath, e);
256 | }
257 | }
258 |
259 | return segments;
260 | }
261 |
262 | public async reconstructEpub(
263 | originalBook: EpubBook,
264 | translatedSegments: TextSegment[]
265 | ): Promise {
266 | // Create a new zip for the translated EPUB
267 | const newZip = new JSZip();
268 |
269 | // Copy all original files first (with correct EPUB constraints)
270 | await this.copyAllOriginalFiles(newZip);
271 |
272 | // Update chapters with translations (overwrite corresponding entries)
273 | await this.updateChaptersWithTranslations(newZip, originalBook, translatedSegments);
274 |
275 | // Generate the new EPUB blob
276 | return await newZip.generateAsync({
277 | type: 'blob',
278 | mimeType: 'application/epub+zip',
279 | compression: 'DEFLATE',
280 | });
281 | }
282 |
283 | private async copyAllOriginalFiles(newZip: JSZip): Promise {
284 | // 1) Write mimetype first and uncompressed (STORE)
285 | const origMime = this.zip.file('mimetype');
286 | let mimeText = 'application/epub+zip';
287 | if (origMime) {
288 | try {
289 | const txt = (await origMime.async('text')).trim();
290 | if (txt === 'application/epub+zip') mimeText = txt;
291 | } catch {
292 | // fallback to default
293 | }
294 | }
295 | newZip.file('mimetype', mimeText, { compression: 'STORE' });
296 |
297 | // 2) Copy all remaining files as-is (including HTML/XHTML, OPF, META-INF contents, images, CSS, etc.)
298 | const copyTasks: Array> = [];
299 | this.zip.forEach((relativePath, file) => {
300 | if (file.dir) return;
301 | if (relativePath === 'mimetype') return; // already added
302 | copyTasks.push((async () => {
303 | const content = await file.async('uint8array');
304 | newZip.file(relativePath, content);
305 | })());
306 | });
307 | await Promise.all(copyTasks);
308 | }
309 |
310 | private async updateChaptersWithTranslations(
311 | newZip: JSZip,
312 | originalBook: EpubBook,
313 | translatedSegments: TextSegment[]
314 | ): Promise {
315 | const opfDir = this.opfPath.substring(0, this.opfPath.lastIndexOf('/'));
316 | const basePath = opfDir ? `${opfDir}/` : '';
317 |
318 | // Group segments by chapter
319 | const segmentsByChapter = new Map();
320 | for (const segment of translatedSegments) {
321 | if (!segmentsByChapter.has(segment.chapterId)) {
322 | segmentsByChapter.set(segment.chapterId, []);
323 | }
324 | const arr = segmentsByChapter.get(segment.chapterId);
325 | if (arr) arr.push(segment);
326 | }
327 |
328 | for (const spineItem of this.spine) {
329 | const chapterPath = basePath + spineItem.href;
330 | const originalFile = this.zip.file(chapterPath);
331 |
332 | if (originalFile) {
333 | const originalHtml = await originalFile.async('text');
334 | const segments = segmentsByChapter.get(spineItem.id) || [];
335 |
336 | // Generate new HTML with translations
337 | const translatedHtml = this.injectTranslations(originalHtml, segments);
338 | newZip.file(chapterPath, translatedHtml);
339 | }
340 | }
341 | }
342 |
343 | private injectTranslations(originalHtml: string, segments: TextSegment[]): string {
344 | const dom = new DOMParser();
345 | let doc = dom.parseFromString(originalHtml, 'application/xhtml+xml');
346 | const hasParserError = doc.getElementsByTagName('parsererror').length > 0;
347 | let isXml = true;
348 | if (hasParserError) {
349 | // Fallback to HTML parsing if input is not strict XHTML
350 | doc = dom.parseFromString(originalHtml, 'text/html');
351 | isXml = false;
352 | }
353 |
354 | // Remove existing translated nodes to avoid duplication on re-run
355 | for (const el of Array.from(doc.querySelectorAll('.native-translate-translation'))) {
356 | el.remove();
357 | }
358 |
359 | // Strict 1-to-1 by domPath only; two-phase: resolve all targets first, then insert
360 | const resolvablePairs: Array<{ el: Element; seg: TextSegment }> = [];
361 | const missing: TextSegment[] = [];
362 | for (const seg of segments) {
363 | if (!seg.translatedText || !seg.domPath) continue;
364 | const el = doc.querySelector(seg.domPath);
365 | if (el) {
366 | resolvablePairs.push({ el, seg });
367 | } else {
368 | missing.push(seg);
369 | }
370 | }
371 |
372 | const ns = 'http://www.w3.org/1999/xhtml';
373 | for (const { el, seg } of resolvablePairs) {
374 | const tag = el.tagName.toLowerCase();
375 | const outTag = tag === 'li' ? 'li' : tag; // keep list structure for lists
376 | const translationElement = isXml
377 | ? doc.createElementNS(ns, outTag)
378 | : doc.createElement(outTag);
379 | translationElement.setAttribute('class', 'native-translate-translation');
380 | translationElement.textContent = seg.translatedText || '';
381 | el.parentNode?.insertBefore(translationElement, el.nextSibling);
382 | }
383 |
384 | // Debug logs (only in dev)
385 | // Dev-only debug logs (guarded by process.env)
386 | // eslint-disable-next-line no-console
387 | if (process?.env?.NODE_ENV !== 'production') {
388 | try {
389 | // eslint-disable-next-line no-console
390 | console.debug('[EPUB][inject] segments:', segments.length, 'inserted:', resolvablePairs.length, 'missing:', missing.length);
391 | if (missing.length > 0) {
392 | // eslint-disable-next-line no-console
393 | console.debug('[EPUB][inject] sample missing domPaths:', missing.slice(0, 5).map((m) => m.domPath));
394 | }
395 | } catch {
396 | // no-op
397 | }
398 | }
399 |
400 | if (isXml) {
401 | const serializer = new XMLSerializer();
402 | return serializer.serializeToString(doc);
403 | }
404 | return doc.documentElement.outerHTML;
405 | }
406 | }
407 |
408 | export async function parseEpubFile(file: File): Promise<{ book: EpubBook; segments: TextSegment[] }> {
409 | if (!file.name.toLowerCase().endsWith('.epub')) {
410 | throw new Error('Only EPUB files are supported');
411 | }
412 |
413 | if (file.size > 50 * 1024 * 1024) { // 50MB limit
414 | throw new Error('File is too large (max 50MB)');
415 | }
416 |
417 | try {
418 | const arrayBuffer = await file.arrayBuffer();
419 | const zip = await JSZip.loadAsync(arrayBuffer);
420 |
421 | const parser = new EpubParser(zip);
422 | const book = await parser.parse();
423 | const segments = await parser.extractTextSegments(book);
424 |
425 | return { book, segments };
426 | } catch (error) {
427 | console.error('EPUB parsing error:', error);
428 | throw new Error('Failed to parse EPUB file. Please check the file format.');
429 | }
430 | }
431 |
432 | export async function generateTranslatedEpub(
433 | originalFile: File,
434 | book: EpubBook,
435 | translatedSegments: TextSegment[]
436 | ): Promise {
437 | const arrayBuffer = await originalFile.arrayBuffer();
438 | const zip = await JSZip.loadAsync(arrayBuffer);
439 |
440 | const parser = new EpubParser(zip);
441 | await parser.parse(); // Initialize parser state
442 |
443 | return await parser.reconstructEpub(book, translatedSegments);
444 | }
--------------------------------------------------------------------------------