Build a Chrome Side Panel That AI-Translates English Pages to Markdown
This article only covers "how to build it", not the code principles. You only need to know how to create folders, create files, type commands, and click buttons. Follow along and you'll build the complete skeleton of this extension. Each core logic file is labeled with "what it does and what to watch out for" — just code accordingly.
0. Before you start, prepare four things
| Item | Requirement | How to prepare |
|---|---|---|
| ① Node.js | 20.19 or higher (22 LTS recommended) | Go to nodejs.org download the LTS version, double-click to install, click "Next" all the way through |
| ② Editor | VS Code recommended | Go to code.visualstudio.com download and install |
| ③ Browser | Chrome or Edge | A relatively new version (Chrome 114+) |
| ④ An AI Key | DeepSeek, etc. | Go to platform.deepseek.com register → Console → "API Keys" → Create, copy that sk-... string and save it |
Confirm Node is installed: Press
Win + R, typecmdand hit Enter. In the black window, typenode -v— if it prints a version number (likev22.x.x), you're good. Then typenpm -vto confirm npm is also there.
1. Create the project folder
- Create a new folder
chrome-ai-translationanywhere on your computer (use English, no Chinese characters or spaces). - In VS Code, go to "File → Open Folder" and select it.
2. First, see which files you'll end up with
A total of 17 files: 4 config files + 13 source files. First, create the folders according to this structure:
chrome-ai-translation/
├── package.json # Config file (copy below)
├── tsconfig.json # Config file (copy below)
├── vite.config.ts # Config file (copy below)
├── manifest.json # Config file (copy below)
└── src/
├── shared/ # Cross-context reuse
│ ├── constants.ts
│ ├── types.ts
│ ├── messages.ts
│ └── storage.ts
├── content/ # Extract body text from web pages
│ ├── extractor.ts
│ └── index.ts
├── background/ # Orchestration + AI calls
│ ├── translator.ts
│ └── index.ts
├── components/ # Shared components
│ └── MarkdownView.tsx
└── panel/ # Side panel UI
├── index.html
├── main.tsx
├── App.tsx
└── style.css
How to create folders: In VS Code's left sidebar Explorer, right-click → "New Folder". First create
src, then insidesrccreate the five subfoldersshared,content,background,components,panel. Use "New File" to create files.
3. Four config files (copy exactly)
These 4 are "config files" — they must be exact. Just copy and paste directly.
File 1/17: package.json (project root)
{
"name": "chrome-ai-translation",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mozilla/readability": "^0.6.0",
"dompurify": "^3.4.14",
"md-wx": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"turndown": "^7.2.4"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.7.1",
"@types/chrome": "^0.2.7",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.9.3",
"vite": "^7.3.6"
}
}
File 2/17: tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["chrome"]
},
"include": ["src"]
}
File 3/17: vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json'
export default defineConfig({
plugins: [react(), crx({ manifest })],
build: {
emptyOutDir: true,
},
})
File 4/17: manifest.json
{
"manifest_version": 3,
"name": "英文网页 AI 翻译",
"version": "0.1.0",
"description": "一键提取英文网页正文,调用 AI 模型翻译并转为 Markdown",
"action": {
"default_title": "英文网页 AI 翻译"
},
"background": {
"service_worker": "src/background/index.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["src/content/index.ts"],
"run_at": "document_idle"
}
],
"side_panel": {
"default_path": "src/panel/index.html"
},
"permissions": ["activeTab", "scripting", "storage", "sidePanel"],
"host_permissions": ["https://api.deepseek.com/*", "http://*/*", "https://*/*"]
}
This one is the most critical: The
side_panelfield makes the extension open a side panel from the right (instead of a traditional popup), andactionalso has nodefault_popup. This is how you make it a side panel.
4. Thirteen source files (only list responsibilities, no code pasted)
These 13 are "logic source files" — implement them according to their responsibilities. Each file is labeled with "what it does + what to watch out for". Code accordingly.
4.1 src/shared/ — Cross-context reusable foundation (4 files)
| File | What it does |
|---|---|
constants.ts |
Constants: default API URL https://api.deepseek.com, default model deepseek-chat, storage keys, side panel communication port name panel |
types.ts |
Domain types: ExtractPayload (markdown/title/author/url), TranslationResult, Settings (apiKey/baseUrl/model), status enum (idle/extracting/translating/done/error) |
messages.ts |
Message protocol: strongly-typed definitions for all messages between background / content script / side panel (extraction request and result, translation request and streaming chunks, settings read/write) |
storage.ts |
Storage layer: wraps reads/writes to chrome.storage.local — "latest translation result" and "settings", settings read merges with defaults |
4.2 src/content/ — Extract body text from web pages (2 files)
| File | What it does |
|---|---|
extractor.ts |
Core extraction pipeline: clone DOM → normalize lazy-loaded image URLs (data-src/srcset → absolute src) → Readability identifies main content → length check (body < 50 chars falls back to selector-based extraction) → DOMPurify sanitize → Turndown convert to Markdown → get title / author |
index.ts |
Content script entry: listens for background "extract body text" message, calls extractor and returns result |
4.3 src/background/ — Orchestration + AI calls (2 files)
| File | What it does |
|---|---|
translator.ts |
Translation core: uses native fetch to call OpenAI-compatible streaming API (stream: true), parses SSE increments line by line (data: prefix, [DONE] ends); System Prompt constrains "only translate body text, preserve Markdown structure and images"; assembles # Title / > Author / > Link / Body |
index.ts |
Background entry: click icon opens side panel, manages Port connections, orchestrates "extract → translate → stream push → store" full flow; when content script is not injected, uses scripting.executeScript as fallback injection |
4.4 src/components/ + src/panel/ — UI (5 files)
| File | What it does |
|---|---|
components/MarkdownView.tsx |
Uses md-wx's MarkdownRenderer to render Markdown, disables extra UI like settings / copy / theme toggle |
panel/index.html |
Side panel entry HTML, mounts #root + imports main.tsx |
panel/main.tsx |
React mount entry, renders App |
panel/App.tsx |
Main interface: translate button + settings panel (Key / Base URL / Model) + streaming typewriter display (60ms throttle) + download .md / copy; connects Port to receive background pushes |
panel/style.css |
Side panel styles (header toolbar, buttons, status bar, error bar, body area) |
Implementation notes (keep these in mind when writing logic)
- Three contexts use one set of strongly-typed messages: All messages between background / content script / side panel are defined in
messages.ts. TypeScript will directly error on protocol changes, reducing hidden pitfalls. - Body text extraction first "clones the DOM" then processes: Don't pollute the original page the user is viewing; images must undergo lazy-load normalization, otherwise body images will all be lost.
- Translation API uses OpenAI-compatible protocol: Only change
base_url/api_key/modelin three places to freely switch between DeepSeek / Qwen / GLM, not locked to one provider. - API Key only lives in background: Content scripts and pages share a context and must never touch the Key; page HTML is always treated as untrusted data and must be DOMPurify-sanitized after extraction.
- Side panel height auto-fills, width must be manually dragged: Clicking the icon to directly open the side panel relies on
setPanelBehavior({ openPanelOnActionClick: true }).
5. Install dependencies
In VS Code, press Ctrl + ` (backtick) to open the terminal, paste and hit Enter:
npm install
Wait 1–3 minutes, seeing added N packages means success. If you get a network error, switch to a domestic mirror:
npm install --registry=https://registry.npmmirror.com
6. Build
npm run build
Seeing ✓ built in xx.xxs means success. Key point: The output is in the newly generated dist folder at the project root — what you'll load later is dist, not src, and not the project root.
7. Load into the browser
- Type
chrome://extensionsin the address bar (Edge:edge://extensions), hit Enter. - Turn on the "Developer mode" toggle in the top right.
- Click "Load unpacked" in the top left.
- Navigate into the project folder, select the
distfolder inside it, click "Select Folder".
When the "英文网页 AI 翻译" card appears, it's installed.
8. Configure API Key and first use
- Click the extension icon in the browser toolbar (it might be tucked inside the puzzle 🧩 icon; click that to "pin" it to the toolbar).
- The side panel slides out from the right.
- Expand "Settings (API Key / Model)" and fill in:
- API Key: The
sk-...string you created at DeepSeek in step 0; - Base URL: Keep the default
https://api.deepseek.com; - Model: Keep the default
deepseek-chat.
- API Key: The
- Click "Save Settings".
- Open any English article → click the extension icon → click "Translate Current Page".
- The translation will type out character by character. Once done, you can "Download .md" or "Copy".
Done 🎉
9. FAQ
| Problem | Cause & Solution |
|---|---|
No dist folder |
Run npm run build first; it only appears after a successful build |
| "Invalid manifest" error on load | You selected the wrong folder — select dist/, not src/ or the project root |
| Clicking the icon does nothing / side panel doesn't open | Browser version too old (needs Chrome 114+); or click "Reload" on the extensions page and try again |
| Translation returns HTTP 403 | API Key is wrong / expired, or model name is wrong (use a chat model like deepseek-chat, not an embedding type) |
| "Cannot read current page" prompt | Refresh the page and click translate again; confirm you're on an http/https page |
| "Please fill in API Key in extension settings first" prompt | You didn't click "Save Settings" in step 8, or didn't fill in the Key |
| Side panel is too narrow | Manually drag the left edge of the side panel to widen it (height auto-fills) |
10. Want to make it your own?
- Change the extension name: Open
manifest.json, replace both instances of"英文网页 AI 翻译"with your name, re-runnpm run buildand "Reload". - Switch AI provider: No code changes needed — change Base URL / Model / Key in the side panel "Settings" (as long as it's an OpenAI-compatible API, it'll work).
At this point you've built the complete skeleton of this extension from scratch. Implement the core logic files (body text extraction, streaming translation, side panel UI) according to the responsibility descriptions in section 4. If you want to understand the underlying implementation principles and development pitfalls, you can read the technical retrospective article.