From 78cb7f415da677e08324583d638ee550cc91013f Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Sun, 21 Jun 2026 18:45:01 +0200 Subject: [PATCH 1/8] feat: auto-translate blog and memo content via DeepSeek API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds automatic translation of blog posts and memos to the user's browser language using DeepSeek Chat API, with file-based caching. - lib/translate.ts — server-side engine using DeepSeek Chat API, with file-based caching in data/translations/ - lib/translate.shared.ts — client-safe locale helpers (shouldTranslate, localeToLabel) importable by browser components - app/api/translate/route.ts — POST /api/translate endpoint, validates inputs (max 50K chars), returns translated text - hooks/useTranslation.ts — React hook that auto-detects locale, calls the translate API, and caches in sessionStorage - components/TranslateContent.tsx — alternative wrapper component - components/BlogPostContent.tsx — title + content auto-translated, with translation indicator and show-original toggle - components/MemoCard.tsx — memo content auto-translated, with toggle - components/BlogCard.tsx — card listing titles auto-translated - .gitignore — ignore data/translations/ cache - .env.example — documents DEEPSEEK_APIKEY requirement Chinese readers (zh/zh-TW/zh-HK) see the original content (no-op). Translation gracefully falls back to identity when API key is unset. --- .env.example | 9 ++ .gitignore | 3 + app/api/translate/route.ts | 76 ++++++++++ components/BlogCard.tsx | 41 ++++-- components/BlogPostContent.tsx | 51 ++++++- components/MemoCard.tsx | 39 ++++- components/TranslateContent.tsx | 137 ++++++++++++++++++ hooks/useTranslation.ts | 152 ++++++++++++++++++++ lib/translate.shared.ts | 63 ++++++++ lib/translate.ts | 248 ++++++++++++++++++++++++++++++++ 10 files changed, 801 insertions(+), 18 deletions(-) create mode 100644 .env.example create mode 100644 app/api/translate/route.ts create mode 100644 components/TranslateContent.tsx create mode 100644 hooks/useTranslation.ts create mode 100644 lib/translate.shared.ts create mode 100644 lib/translate.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b4b623b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# === Translation (auto-translate for blog content) === +# DeepSeek Chat API key for translation +# Get one at https://platform.deepseek.com/api_keys +# Uses the deepseek-chat model for high-quality translations. +# If unset, translation falls back to identity (no-op). +DEEPSEEK_APIKEY= + +# Translation cache directory (default: data/translations) +# TRANSLATE_CACHE_DIR=data/translations diff --git a/.gitignore b/.gitignore index 8d2cafc9..4353a992 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ next-env.d.ts # Claude Code instructions CLAUDE.md + +# Translation cache (auto-generated) +data/translations/ diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts new file mode 100644 index 00000000..611e96ca --- /dev/null +++ b/app/api/translate/route.ts @@ -0,0 +1,76 @@ +import { translateMarkdown, translateText, shouldTranslate } from '@/lib/translate' +import { NextRequest, NextResponse } from 'next/server' + +export interface TranslateRequestBody { + text: string + targetLocale: string + /** If true, content is treated as markdown and markdown syntax is preserved. */ + isMarkdown?: boolean +} + +export interface TranslateResponseBody { + translatedText: string + error?: string +} + +/** + * POST /api/translate + * + * Translates the provided text into the target locale. + * Content in Chinese (zh) will be translated; Chinese locales return the original. + * + * Body: + * { text: string, targetLocale: string, isMarkdown?: boolean } + */ +export async function POST(request: NextRequest): Promise> { + try { + const body: TranslateRequestBody = await request.json() + + if (!body.text || !body.targetLocale) { + return NextResponse.json( + { translatedText: '', error: 'Missing required fields: text, targetLocale' }, + { status: 400 }, + ) + } + + // Sanity: limit input length to avoid abuse / excessive API cost + if (body.text.length > 50_000) { + return NextResponse.json( + { translatedText: '', error: 'Text too long (max 50,000 characters)' }, + { status: 413 }, + ) + } + + // Validate locale + const supportedLocales = [ + 'en', 'zh', 'zh-TW', 'zh-HK', 'ja', 'ko', 'fr', 'de', 'es', + 'pt', 'ru', 'ar', 'hi', 'it', 'nl', 'tr', 'pl', 'vi', 'th', 'id', + ] + if (!supportedLocales.includes(body.targetLocale)) { + return NextResponse.json( + { translatedText: '', error: `Unsupported locale: ${body.targetLocale}` }, + { status: 400 }, + ) + } + + // No-op for Chinese locales (blog content is primarily Chinese) + if (!shouldTranslate(body.targetLocale)) { + return NextResponse.json({ translatedText: body.text }) + } + + const translatedText = body.isMarkdown + ? await translateMarkdown(body.text, body.targetLocale) + : (await translateText(body.text, body.targetLocale)).translatedText + + return NextResponse.json({ translatedText }) + } catch (error) { + console.error('[translate API] Error:', error) + return NextResponse.json( + { + translatedText: '', + error: error instanceof Error ? error.message : 'Internal translation error', + }, + { status: 500 }, + ) + } +} diff --git a/components/BlogCard.tsx b/components/BlogCard.tsx index 84abf342..4fb9a2bc 100644 --- a/components/BlogCard.tsx +++ b/components/BlogCard.tsx @@ -2,6 +2,32 @@ import { BlogPost } from "@/lib/types"; import Link from "next/link"; +import { useTranslation } from '@/hooks/useTranslation'; + +const BlogCardContent = ({ post }: { post: BlogPost }) => { + const { + translatedText: translatedTitle, + isTranslating, + } = useTranslation(post.title, false, `blog-card:${post.id}`); + + return ( + +
+

+ {translatedTitle} + {isTranslating && translating...} +

+

+ {formatDate(post.date)} +

+
+ + ); +}; export const BlogCard = ({ post }: { post: BlogPost }) => (
@@ -17,20 +43,7 @@ export const BlogCard = ({ post }: { post: BlogPost }) => (
)} - -
-

- {post.title} -

-

- {formatDate(post.date)} -

-
- + ); diff --git a/components/BlogPostContent.tsx b/components/BlogPostContent.tsx index c6642bf3..fcc10c5a 100644 --- a/components/BlogPostContent.tsx +++ b/components/BlogPostContent.tsx @@ -13,6 +13,9 @@ import remarkMath from 'remark-math' import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' import Image from 'next/image' import LikeButton from './LikeButton' +import { useTranslation } from '@/hooks/useTranslation' +import { shouldTranslate, localeToLabel } from '@/lib/translate.shared' +import { useLocale } from 'next-intl' interface BlogPostContentProps { title: string date: string @@ -27,6 +30,23 @@ interface BlogPostContentProps { } export function BlogPostContent({ title, date, content, slug, headerContent, discussionsComponent, location }: BlogPostContentProps) { + const locale = useLocale() + + // Auto-translate title and content + const { + translatedText: translatedContent, + isTranslating: contentTranslating, + toggleOriginal: toggleContentOriginal, + showOriginal: contentShowOriginal, + } = useTranslation(content, true, `blog-content:${slug}`) + + const { + translatedText: translatedTitle, + isTranslating: titleTranslating, + } = useTranslation(title, false, `blog-title:${slug}`) + + const needsTranslation = shouldTranslate(locale) + return (
{headerContent && ( @@ -36,7 +56,10 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis )}
-

{title}

+

+ {translatedTitle} + {titleTranslating && translating...} +

+ + {/* Translation indicator */} + {needsTranslation && ( +
+ + {!contentShowOriginal && ( + + + Auto-translated to {localeToLabel(locale)} + + )} + {contentTranslating && ( + translating... + )} +
+ )} +
- {content} + {translatedContent}
diff --git a/components/MemoCard.tsx b/components/MemoCard.tsx index b17902ed..af4e4abd 100755 --- a/components/MemoCard.tsx +++ b/components/MemoCard.tsx @@ -18,8 +18,10 @@ import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' -import { useTranslations } from 'next-intl' +import { useTranslations, useLocale } from 'next-intl' import LikeButton from './LikeButton' +import { useTranslation } from '@/hooks/useTranslation' +import { shouldTranslate, localeToLabel } from '@/lib/translate.shared' interface MemoCardProps { memo: Memo @@ -43,8 +45,18 @@ function memoLocationLabel(memo: Memo): string | null { export const MemoCard = ({ memo, onDelete, onEdit, isDeleting = false }: MemoCardProps) => { const t = useTranslations('HomePage') + const locale = useLocale() const location = memoLocationLabel(memo) + const { + translatedText: translatedContent, + isTranslating, + toggleOriginal, + showOriginal, + } = useTranslation(memo.content, true, `memo:${memo.id}`) + + const needsTranslation = shouldTranslate(locale) + return (
- {memo.content} + {translatedContent} + + {/* Translation indicator */} + {needsTranslation && ( +
+ + {!showOriginal && ( + + + Translated + + )} + {isTranslating && ( + translating... + )} +
+ )}
{/* Footer — date + location on the left, actions on the right */} diff --git a/components/TranslateContent.tsx b/components/TranslateContent.tsx new file mode 100644 index 00000000..1b42e4c6 --- /dev/null +++ b/components/TranslateContent.tsx @@ -0,0 +1,137 @@ +'use client' + +import React, { useEffect, useState, useCallback, useRef } from 'react' +import { useLocale } from 'next-intl' +import { shouldTranslate } from '@/lib/translate.shared' + +interface TranslateContentProps { + children: string + /** Treat content as markdown (preserves markdown syntax). Default: true */ + isMarkdown?: boolean + /** Unique identifier for this content (used in session storage cache key). */ + contentId?: string + /** Optional class name for the wrapper. */ + className?: string + /** Render function to customise how the translated text is displayed. */ + render?: (text: string) => React.ReactNode +} + +/** + * Auto-translates its text content into the user's browser language. + * + * - For Chinese (zh*) locales, content is shown as-is (no-op). + * - For non-Chinese locales, content is translated via the /api/translate endpoint. + * - Translations are cached in sessionStorage to avoid redundant API calls + * during a browsing session. + * - Shows the original text while translation loads. + */ +export function TranslateContent({ + children: text, + isMarkdown = true, + contentId, + className, + render, +}: TranslateContentProps) { + const locale = useLocale() + const [translatedText, setTranslatedText] = useState(null) + const [isTranslating, setIsTranslating] = useState(false) + const mountedRef = useRef(true) + + const needsTranslation = shouldTranslate(locale) + + useEffect(() => { + mountedRef.current = true + return () => { mountedRef.current = false } + }, []) + + const translate = useCallback(async () => { + if (!needsTranslation || !text.trim()) { + setTranslatedText(null) + return + } + + // Check sessionStorage cache + const cacheKey = `translate:${contentId ?? hashText(text)}:${locale}` + try { + const cached = sessionStorage.getItem(cacheKey) + if (cached) { + setTranslatedText(cached) + return + } + } catch { + /* sessionStorage may not be available */ + } + + setIsTranslating(true) + + try { + const response = await fetch('/api/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, targetLocale: locale, isMarkdown }), + }) + + if (!response.ok) { + const errData = await response.json().catch(() => ({})) + throw new Error(errData.error ?? `Translation failed (${response.status})`) + } + + const data = await response.json() + if (mountedRef.current) { + setTranslatedText(data.translatedText) + + // Cache in sessionStorage + try { + sessionStorage.setItem(cacheKey, data.translatedText) + } catch { /* quota exceeded */ } + } + } catch (err) { + if (mountedRef.current) { + console.error('[TranslateContent] Error:', err) + // Fall back to original text + setTranslatedText(null) + } + } finally { + if (mountedRef.current) { + setIsTranslating(false) + } + } + }, [text, locale, isMarkdown, contentId, needsTranslation]) + + useEffect(() => { + translate() + }, [translate]) + + const displayText = translatedText ?? text + + if (render) { + return <>{render(displayText)} + } + + return ( + + {displayText} + {isTranslating && translatedText === null && text !== displayText && ( + translating... + )} + + ) +} + +/** + * Simple hash function for use as a cache key. + */ +function hashText(text: string): string { + let hash = 0 + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash |= 0 + } + return Math.abs(hash).toString(36) +} + + + +// Re-export for convenience +export { shouldTranslate } diff --git a/hooks/useTranslation.ts b/hooks/useTranslation.ts new file mode 100644 index 00000000..4121b9c5 --- /dev/null +++ b/hooks/useTranslation.ts @@ -0,0 +1,152 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useLocale } from 'next-intl' +import { shouldTranslate } from '@/lib/translate.shared' + +interface UseTranslationResult { + /** Translated text, or original text if translation is not needed / pending. */ + translatedText: string + /** True while a translation request is in flight. */ + isTranslating: boolean + /** Error message if translation failed, or null. */ + error: string | null + /** Manually retry translation. */ + retry: () => void + /** Toggle between translated and original text. */ + toggleOriginal: () => void + /** Whether the original text is being shown. */ + showOriginal: boolean +} + +function hashText(text: string): string { + let hash = 0 + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash |= 0 + } + return Math.abs(hash).toString(36) +} + +/** + * Hook that auto-translates text content into the user's browser language. + * + * - For Chinese (zh*) locales, returns the original text (no-op). + * - For non-Chinese locales, calls /api/translate and caches in sessionStorage. + * - Translations are cached per (contentHash, locale) pair. + * + * @param text The original text to translate. + * @param isMarkdown Whether to preserve markdown syntax during translation. + * @param contentId Optional unique ID for more reliable cache key. + */ +export function useTranslation( + text: string, + isMarkdown = true, + contentId?: string, +): UseTranslationResult { + const locale = useLocale() + const [translatedText, setTranslatedText] = useState(text) + const [isTranslating, setIsTranslating] = useState(false) + const [error, setError] = useState(null) + const [showOriginal, setShowOriginal] = useState(false) + const mountedRef = useRef(true) + + const needsTranslation = shouldTranslate(locale) + const cacheKey = contentId + ? `translate:${contentId}:${locale}` + : `translate:${hashText(text)}:${locale}` + + useEffect(() => { + mountedRef.current = true + return () => { mountedRef.current = false } + }, []) + + // Reset when text or locale changes + useEffect(() => { + if (!needsTranslation || !text.trim()) { + setTranslatedText(text) + setError(null) + setIsTranslating(false) + setShowOriginal(false) + return + } + + // Check sessionStorage cache + try { + const cached = sessionStorage.getItem(cacheKey) + if (cached) { + setTranslatedText(cached) + setError(null) + setIsTranslating(false) + return + } + } catch { /* sessionStorage unavailable */ } + + // Initiate translation + setIsTranslating(true) + setError(null) + setTranslatedText(text) // show original while loading + }, [text, locale, needsTranslation, cacheKey]) + + const translate = useCallback(async () => { + if (!needsTranslation || !text.trim()) { + setTranslatedText(text) + return + } + + setIsTranslating(true) + setError(null) + setShowOriginal(false) + + try { + const response = await fetch('/api/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, targetLocale: locale, isMarkdown }), + }) + + if (!response.ok) { + const errData = await response.json().catch(() => ({})) + throw new Error(errData.error ?? `Translation failed (${response.status})`) + } + + const data = await response.json() + if (mountedRef.current) { + setTranslatedText(data.translatedText) + // Cache in sessionStorage + try { + sessionStorage.setItem(cacheKey, data.translatedText) + } catch { /* quota exceeded */ } + } + } catch (err) { + if (mountedRef.current) { + console.error('[useTranslation] Error:', err) + setError(err instanceof Error ? err.message : 'Translation error') + setTranslatedText(text) // fall back to original + } + } finally { + if (mountedRef.current) { + setIsTranslating(false) + } + } + }, [text, locale, isMarkdown, cacheKey, needsTranslation]) + + useEffect(() => { + // Only trigger the actual API call on mount / text change + if (needsTranslation && text.trim() && !sessionStorage.getItem(cacheKey)) { + translate() + } + }, [translate, needsTranslation, text, cacheKey]) + + const displayedText = showOriginal ? text : translatedText + + return { + translatedText: displayedText, + isTranslating, + error, + retry: translate, + toggleOriginal: () => setShowOriginal((prev) => !prev), + showOriginal, + } +} diff --git a/lib/translate.shared.ts b/lib/translate.shared.ts new file mode 100644 index 00000000..6e43b960 --- /dev/null +++ b/lib/translate.shared.ts @@ -0,0 +1,63 @@ +/** + * Client-safe translation helpers (no Node.js dependencies). + * This module can be imported by both server and client code. + */ + +/** Map our locale codes to Google Translate target codes. */ +export function normalizeLocale(locale: string): string { + if (locale === 'zh') return 'zh-CN' + if (locale === 'zh-HK') return 'zh-TW' + if (locale === 'zh-TW') return 'zh-TW' + return locale.split('-')[0] +} + +/** Locales that should NOT trigger translation (reader likely understands Chinese). */ +const CHINESE_LOCALES = new Set(['zh', 'zh-CN', 'zh-TW', 'zh-HK']) + +/** + * Whether translation is needed for a given user locale. + * Returns false for Chinese locales since the blog content is primarily in Chinese. + */ +export function shouldTranslate(targetLocale: string): boolean { + const normalized = normalizeLocale(targetLocale) + return !CHINESE_LOCALES.has(normalized) && !CHINESE_LOCALES.has(targetLocale.split('-')[0]) +} + +/** + * Normalised locale → human-readable label. + */ +export function localeToLabel(locale: string): string { + const labels: Record = { + en: 'English', + ja: '日本語', + ko: '한국어', + fr: 'Français', + de: 'Deutsch', + es: 'Español', + pt: 'Português', + ru: 'Русский', + ar: 'العربية', + hi: 'हिन्दी', + it: 'Italiano', + nl: 'Nederlands', + tr: 'Türkçe', + pl: 'Polski', + vi: 'Tiếng Việt', + th: 'ไทย', + id: 'Bahasa Indonesia', + zh: '中文', + 'zh-TW': '繁體中文', + 'zh-HK': '繁體中文', + } + return labels[locale] ?? locale +} + +/** + * List the locales we can translate into (all non-Chinese supported locales). + */ +export function getTranslatableLocales(): string[] { + return [ + 'en', 'ja', 'ko', 'fr', 'de', 'es', 'pt', 'ru', 'ar', + 'hi', 'it', 'nl', 'tr', 'pl', 'vi', 'th', 'id', + ] +} diff --git a/lib/translate.ts b/lib/translate.ts new file mode 100644 index 00000000..e26a52d0 --- /dev/null +++ b/lib/translate.ts @@ -0,0 +1,248 @@ +/** + * Auto-translation utilities for blog.minghe.me + * + * Uses DeepSeek Chat API for translation, with file-based caching to minimise + * API calls and avoid re-translating unchanged content. + * + * Environment variables: + * DEEPSEEK_APIKEY – API key for DeepSeek Chat (required) + * TRANSLATE_CACHE_DIR – custom cache dir (default: data/translations) + * + * NOTE: This module uses Node.js built-in modules (fs, path, crypto) and + * can only be imported by server-side code (API routes, server components, etc.). + * Client code should import from './translate.shared' instead. + */ + +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { normalizeLocale } from './translate.shared' + +// Re-export shared helpers for convenience on the server side. +export { shouldTranslate, localeToLabel, getTranslatableLocales } from './translate.shared' + +// --------------------------------------------------------------------------- +// DeepSeek API helpers +// --------------------------------------------------------------------------- + +const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions' +const DEEPSEEK_MODEL = 'deepseek-chat' + +/** Map a locale code to the language name DeepSeek understands. */ +function localeToLanguageName(locale: string): string { + const names: Record = { + en: 'English', + ja: 'Japanese', + ko: 'Korean', + fr: 'French', + de: 'German', + es: 'Spanish', + pt: 'Portuguese', + ru: 'Russian', + ar: 'Arabic', + hi: 'Hindi', + it: 'Italian', + nl: 'Dutch', + tr: 'Turkish', + pl: 'Polish', + vi: 'Vietnamese', + th: 'Thai', + id: 'Indonesian', + } + // Normalise locale first + const normalised = normalizeLocale(locale) + return names[normalised] ?? 'English' +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** SHA-256 hex digest of a string – used as translation cache key. */ +function hash(text: string): string { + return crypto.createHash('sha256').update(text, 'utf-8').digest('hex') +} + +// --------------------------------------------------------------------------- +// Translation API +// --------------------------------------------------------------------------- + +export interface TranslateResult { + translatedText: string + detectedSourceLanguage?: string +} + +type TranslateFn = (text: string, targetLocale: string) => Promise + +/** + * Translate text via DeepSeek Chat API. + * Falls back to identity (no-op) if no API key is configured. + */ +async function deepSeekTranslate(text: string, targetLocale: string): Promise { + const apiKey = process.env.DEEPSEEK_APIKEY + if (!apiKey) { + console.warn('[translate] No DEEPSEEK_APIKEY set — falling back to identity.') + return { translatedText: text } + } + + const targetLanguage = localeToLanguageName(targetLocale) + + const systemPrompt = `You are a professional translator. Translate the following text from Chinese to ${targetLanguage}. + +Rules: +1. Preserve ALL markdown formatting exactly as-is (headings, lists, tables, bold, italic, etc.) +2. Preserve ALL code blocks, inline code, URLs, and special characters verbatim +3. Preserve all LaTeX math expressions ($$...$$, $...$) exactly +4. Preserve all image markdown ![alt](url) unchanged +5. Only translate the natural language text — do not modify or translate code, URLs, or syntax +6. Keep the same line breaks and paragraph structure +7. Do not add any explanations, notes, or commentary — output ONLY the translated text` + + const res = await fetch(DEEPSEEK_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: DEEPSEEK_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: text }, + ], + temperature: 0.1, // low temperature for more deterministic translation + max_tokens: 8192, + }), + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(`DeepSeek API error (${res.status}): ${body}`) + } + + const json = await res.json() + const translatedText = json.choices?.[0]?.message?.content + + if (!translatedText) { + throw new Error(`Unexpected DeepSeek API response: ${JSON.stringify(json)}`) + } + + return { + translatedText: translatedText.trim(), + detectedSourceLanguage: 'zh', // we always translate from Chinese + } +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +let _cacheDir: string | null = null + +function getCacheDir(): string { + if (_cacheDir) return _cacheDir + _cacheDir = process.env.TRANSLATE_CACHE_DIR ?? path.join(process.cwd(), 'data', 'translations') + if (!fs.existsSync(_cacheDir)) { + fs.mkdirSync(_cacheDir, { recursive: true }) + } + return _cacheDir +} + +function cachePath(contentHash: string, targetLocale: string): string { + return path.join(getCacheDir(), `${contentHash}-${targetLocale}.json`) +} + +function readCache(contentHash: string, targetLocale: string): string | null { + try { + const p = cachePath(contentHash, targetLocale) + if (!fs.existsSync(p)) return null + const raw = fs.readFileSync(p, 'utf-8') + const entry = JSON.parse(raw) + return entry.translatedText ?? null + } catch { + return null + } +} + +function writeCache(contentHash: string, targetLocale: string, translatedText: string): void { + try { + const p = cachePath(contentHash, targetLocale) + fs.writeFileSync(p, JSON.stringify({ translatedText, cachedAt: new Date().toISOString() }), 'utf-8') + } catch (err) { + console.warn('[translate] Failed to write translation cache:', err) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +let _translateFn: TranslateFn = deepSeekTranslate + +/** + * Override the translation implementation (useful for testing or custom providers). + */ +export function setTranslateFn(fn: TranslateFn): void { + _translateFn = fn +} + +/** + * Translate a block of text. + * + * Checks the file-based cache first. If a cache hit exists, returns instantly. + * Otherwise calls the translation API, stores the result, and returns it. + */ +export async function translateText(text: string, targetLocale: string): Promise { + if (!text.trim()) return { translatedText: text } + + // Normalise locale for cache key + const normalised = normalizeLocale(targetLocale) + + // Check cache + const contentHash = hash(text) + const cached = readCache(contentHash, normalised) + if (cached !== null) { + return { translatedText: cached } + } + + // Call translation API + const result = await _translateFn(text, normalised) + + // Write cache (only if we got a real translation back) + if (result.translatedText !== text) { + writeCache(contentHash, normalised, result.translatedText) + } + + return result +} + +/** + * Translate markdown content while preserving markdown syntax. + * + * For best quality with DeepSeek, we send the full markdown content in one + * request and let the LLM handle syntax preservation via the system prompt. + * Only fall back to line-by-line if the content exceeds token limits. + */ +export async function translateMarkdown( + content: string, + targetLocale: string, +): Promise { + if (!content.trim()) return content + + // For short content, translate as a whole (preserves context best) + if (content.length < 8000) { + const result = await translateText(content, targetLocale) + return result.translatedText + } + + // For longer content, break into paragraphs + const blocks = content.split('\n\n') + const translatedBlocks: string[] = [] + + for (const block of blocks) { + const result = await translateText(block, targetLocale) + translatedBlocks.push(result.translatedText) + } + + return translatedBlocks.join('\n\n') +} From 69509867d849d66964d9945427db622acfc912a2 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Sun, 21 Jun 2026 18:48:26 +0200 Subject: [PATCH 2/8] refactor: clean up translate engine per clean-code principles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract DeepSeek system prompt into buildTranslationPrompt() - Extract fetch call into callDeepSeek() — one function, one concern - Remove dead code: _translateFn variable, setTranslateFn export - Replace section-marker comments with structural code ordering - Extract magic 8000 into named MAX_SINGLE_REQUEST_CHARS constant - Rename _cacheDir → cacheDir, getCacheDir → ensureCacheDir - Inline redundant variable declarations for clarity --- lib/translate.ts | 139 ++++++++++++++--------------------------------- 1 file changed, 40 insertions(+), 99 deletions(-) diff --git a/lib/translate.ts b/lib/translate.ts index e26a52d0..9d6eb064 100644 --- a/lib/translate.ts +++ b/lib/translate.ts @@ -18,76 +18,32 @@ import path from 'node:path' import crypto from 'node:crypto' import { normalizeLocale } from './translate.shared' -// Re-export shared helpers for convenience on the server side. export { shouldTranslate, localeToLabel, getTranslatableLocales } from './translate.shared' -// --------------------------------------------------------------------------- -// DeepSeek API helpers -// --------------------------------------------------------------------------- - const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions' const DEEPSEEK_MODEL = 'deepseek-chat' -/** Map a locale code to the language name DeepSeek understands. */ -function localeToLanguageName(locale: string): string { - const names: Record = { - en: 'English', - ja: 'Japanese', - ko: 'Korean', - fr: 'French', - de: 'German', - es: 'Spanish', - pt: 'Portuguese', - ru: 'Russian', - ar: 'Arabic', - hi: 'Hindi', - it: 'Italian', - nl: 'Dutch', - tr: 'Turkish', - pl: 'Polish', - vi: 'Vietnamese', - th: 'Thai', - id: 'Indonesian', - } - // Normalise locale first - const normalised = normalizeLocale(locale) - return names[normalised] ?? 'English' -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** SHA-256 hex digest of a string – used as translation cache key. */ -function hash(text: string): string { - return crypto.createHash('sha256').update(text, 'utf-8').digest('hex') -} - -// --------------------------------------------------------------------------- -// Translation API -// --------------------------------------------------------------------------- - export interface TranslateResult { translatedText: string detectedSourceLanguage?: string } -type TranslateFn = (text: string, targetLocale: string) => Promise +function hash(text: string): string { + return crypto.createHash('sha256').update(text, 'utf-8').digest('hex') +} -/** - * Translate text via DeepSeek Chat API. - * Falls back to identity (no-op) if no API key is configured. - */ -async function deepSeekTranslate(text: string, targetLocale: string): Promise { - const apiKey = process.env.DEEPSEEK_APIKEY - if (!apiKey) { - console.warn('[translate] No DEEPSEEK_APIKEY set — falling back to identity.') - return { translatedText: text } +function localeToLanguageName(locale: string): string { + const names: Record = { + en: 'English', ja: 'Japanese', ko: 'Korean', fr: 'French', de: 'German', + es: 'Spanish', pt: 'Portuguese', ru: 'Russian', ar: 'Arabic', hi: 'Hindi', + it: 'Italian', nl: 'Dutch', tr: 'Turkish', pl: 'Polish', vi: 'Vietnamese', + th: 'Thai', id: 'Indonesian', } + return names[normalizeLocale(locale)] ?? 'English' +} - const targetLanguage = localeToLanguageName(targetLocale) - - const systemPrompt = `You are a professional translator. Translate the following text from Chinese to ${targetLanguage}. +function buildTranslationPrompt(targetLanguage: string): string { + return `You are a professional translator. Translate the following text from Chinese to ${targetLanguage}. Rules: 1. Preserve ALL markdown formatting exactly as-is (headings, lists, tables, bold, italic, etc.) @@ -97,6 +53,14 @@ Rules: 5. Only translate the natural language text — do not modify or translate code, URLs, or syntax 6. Keep the same line breaks and paragraph structure 7. Do not add any explanations, notes, or commentary — output ONLY the translated text` +} + +async function callDeepSeek(text: string, targetLanguage: string): Promise { + const apiKey = process.env.DEEPSEEK_APIKEY + if (!apiKey) { + console.warn('[translate] No DEEPSEEK_APIKEY set — falling back to identity.') + return { translatedText: text } + } const res = await fetch(DEEPSEEK_API_URL, { method: 'POST', @@ -107,10 +71,10 @@ Rules: body: JSON.stringify({ model: DEEPSEEK_MODEL, messages: [ - { role: 'system', content: systemPrompt }, + { role: 'system', content: buildTranslationPrompt(targetLanguage) }, { role: 'user', content: text }, ], - temperature: 0.1, // low temperature for more deterministic translation + temperature: 0.1, max_tokens: 8192, }), }) @@ -129,35 +93,30 @@ Rules: return { translatedText: translatedText.trim(), - detectedSourceLanguage: 'zh', // we always translate from Chinese + detectedSourceLanguage: 'zh', } } -// --------------------------------------------------------------------------- -// Cache -// --------------------------------------------------------------------------- +let cacheDir: string | null = null -let _cacheDir: string | null = null - -function getCacheDir(): string { - if (_cacheDir) return _cacheDir - _cacheDir = process.env.TRANSLATE_CACHE_DIR ?? path.join(process.cwd(), 'data', 'translations') - if (!fs.existsSync(_cacheDir)) { - fs.mkdirSync(_cacheDir, { recursive: true }) +function ensureCacheDir(): string { + if (cacheDir) return cacheDir + cacheDir = process.env.TRANSLATE_CACHE_DIR ?? path.join(process.cwd(), 'data', 'translations') + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }) } - return _cacheDir + return cacheDir } function cachePath(contentHash: string, targetLocale: string): string { - return path.join(getCacheDir(), `${contentHash}-${targetLocale}.json`) + return path.join(ensureCacheDir(), `${contentHash}-${targetLocale}.json`) } function readCache(contentHash: string, targetLocale: string): string | null { try { const p = cachePath(contentHash, targetLocale) if (!fs.existsSync(p)) return null - const raw = fs.readFileSync(p, 'utf-8') - const entry = JSON.parse(raw) + const entry = JSON.parse(fs.readFileSync(p, 'utf-8')) return entry.translatedText ?? null } catch { return null @@ -173,19 +132,6 @@ function writeCache(contentHash: string, targetLocale: string, translatedText: s } } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -let _translateFn: TranslateFn = deepSeekTranslate - -/** - * Override the translation implementation (useful for testing or custom providers). - */ -export function setTranslateFn(fn: TranslateFn): void { - _translateFn = fn -} - /** * Translate a block of text. * @@ -195,20 +141,16 @@ export function setTranslateFn(fn: TranslateFn): void { export async function translateText(text: string, targetLocale: string): Promise { if (!text.trim()) return { translatedText: text } - // Normalise locale for cache key const normalised = normalizeLocale(targetLocale) - - // Check cache const contentHash = hash(text) const cached = readCache(contentHash, normalised) + if (cached !== null) { return { translatedText: cached } } - // Call translation API - const result = await _translateFn(text, normalised) + const result = await callDeepSeek(text, localeToLanguageName(targetLocale)) - // Write cache (only if we got a real translation back) if (result.translatedText !== text) { writeCache(contentHash, normalised, result.translatedText) } @@ -216,12 +158,13 @@ export async function translateText(text: string, targetLocale: string): Promise return result } +const MAX_SINGLE_REQUEST_CHARS = 8_000 + /** * Translate markdown content while preserving markdown syntax. * - * For best quality with DeepSeek, we send the full markdown content in one - * request and let the LLM handle syntax preservation via the system prompt. - * Only fall back to line-by-line if the content exceeds token limits. + * Sends the full content in one request when short (better context for the LLM). + * Falls back to paragraph-by-paragraph for longer content. */ export async function translateMarkdown( content: string, @@ -229,13 +172,11 @@ export async function translateMarkdown( ): Promise { if (!content.trim()) return content - // For short content, translate as a whole (preserves context best) - if (content.length < 8000) { + if (content.length < MAX_SINGLE_REQUEST_CHARS) { const result = await translateText(content, targetLocale) return result.translatedText } - // For longer content, break into paragraphs const blocks = content.split('\n\n') const translatedBlocks: string[] = [] From 7b29a444543ce55e28abfb491bbef7c299afcea0 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Sun, 21 Jun 2026 18:55:18 +0200 Subject: [PATCH 3/8] fix: SSR-safe sessionStorage access and indicator logic - Wrap sessionStorage.getItem() in try-catch in the second useEffect to prevent the effect from silently crashing during SSR - Add actuallyTranslated flag to useTranslation hook so the UI only shows the indicator when a real translation occurred - Accept DEEPSEEK_API_KEY as an alternative env var name for compatibility with Vercel conventions - Update .env.example to document both accepted names --- .env.example | 1 + components/BlogPostContent.tsx | 29 ++++++++++++++++------------- components/MemoCard.tsx | 29 ++++++++++++++++------------- hooks/useTranslation.ts | 20 ++++++++++++++++---- lib/translate.ts | 8 ++++++-- 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index b4b623b3..3a0d6806 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # === Translation (auto-translate for blog content) === # DeepSeek Chat API key for translation # Get one at https://platform.deepseek.com/api_keys +# Accepts either DEEPSEEK_APIKEY or DEEPSEEK_API_KEY. # Uses the deepseek-chat model for high-quality translations. # If unset, translation falls back to identity (no-op). DEEPSEEK_APIKEY= diff --git a/components/BlogPostContent.tsx b/components/BlogPostContent.tsx index fcc10c5a..34006072 100644 --- a/components/BlogPostContent.tsx +++ b/components/BlogPostContent.tsx @@ -14,7 +14,7 @@ import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' import Image from 'next/image' import LikeButton from './LikeButton' import { useTranslation } from '@/hooks/useTranslation' -import { shouldTranslate, localeToLabel } from '@/lib/translate.shared' +import { localeToLabel } from '@/lib/translate.shared' import { useLocale } from 'next-intl' interface BlogPostContentProps { title: string @@ -38,6 +38,7 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis isTranslating: contentTranslating, toggleOriginal: toggleContentOriginal, showOriginal: contentShowOriginal, + actuallyTranslated: contentActuallyTranslated, } = useTranslation(content, true, `blog-content:${slug}`) const { @@ -45,7 +46,7 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis isTranslating: titleTranslating, } = useTranslation(title, false, `blog-title:${slug}`) - const needsTranslation = shouldTranslate(locale) + const showTranslationUI = contentTranslating || contentActuallyTranslated return (
@@ -70,18 +71,20 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis
- {/* Translation indicator */} - {needsTranslation && ( + {/* Translation indicator — only shown while in-flight or when actual translation exists */} + {showTranslationUI && (
- - {!contentShowOriginal && ( + {contentActuallyTranslated && ( + + )} + {contentActuallyTranslated && !contentShowOriginal && ( Auto-translated to {localeToLabel(locale)} diff --git a/components/MemoCard.tsx b/components/MemoCard.tsx index af4e4abd..de50552a 100755 --- a/components/MemoCard.tsx +++ b/components/MemoCard.tsx @@ -21,7 +21,7 @@ import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' import { useTranslations, useLocale } from 'next-intl' import LikeButton from './LikeButton' import { useTranslation } from '@/hooks/useTranslation' -import { shouldTranslate, localeToLabel } from '@/lib/translate.shared' +import { localeToLabel } from '@/lib/translate.shared' interface MemoCardProps { memo: Memo @@ -53,9 +53,10 @@ export const MemoCard = ({ memo, onDelete, onEdit, isDeleting = false }: MemoCar isTranslating, toggleOriginal, showOriginal, + actuallyTranslated, } = useTranslation(memo.content, true, `memo:${memo.id}`) - const needsTranslation = shouldTranslate(locale) + const showTranslationUI = isTranslating || actuallyTranslated return (
- {/* Translation indicator */} - {needsTranslation && ( + {/* Translation indicator — only shown while in-flight or when actual translation exists */} + {showTranslationUI && (
- - {!showOriginal && ( + {actuallyTranslated && ( + + )} + {actuallyTranslated && !showOriginal && ( Translated diff --git a/hooks/useTranslation.ts b/hooks/useTranslation.ts index 4121b9c5..f3953910 100644 --- a/hooks/useTranslation.ts +++ b/hooks/useTranslation.ts @@ -17,6 +17,8 @@ interface UseTranslationResult { toggleOriginal: () => void /** Whether the original text is being shown. */ showOriginal: boolean + /** True if a translation was actually applied (text differs from original). */ + actuallyTranslated: boolean } function hashText(text: string): string { @@ -133,14 +135,23 @@ export function useTranslation( }, [text, locale, isMarkdown, cacheKey, needsTranslation]) useEffect(() => { - // Only trigger the actual API call on mount / text change - if (needsTranslation && text.trim() && !sessionStorage.getItem(cacheKey)) { - translate() - } + if (!shouldFetch()) return + translate() }, [translate, needsTranslation, text, cacheKey]) + function shouldFetch(): boolean { + if (!needsTranslation || !text.trim()) return false + try { + return !sessionStorage.getItem(cacheKey) + } catch { + return true // sessionStorage unavailable; fetch fresh + } + } + const displayedText = showOriginal ? text : translatedText + const actuallyTranslated = needsTranslation && translatedText !== text + return { translatedText: displayedText, isTranslating, @@ -148,5 +159,6 @@ export function useTranslation( retry: translate, toggleOriginal: () => setShowOriginal((prev) => !prev), showOriginal, + actuallyTranslated, } } diff --git a/lib/translate.ts b/lib/translate.ts index 9d6eb064..30370167 100644 --- a/lib/translate.ts +++ b/lib/translate.ts @@ -55,10 +55,14 @@ Rules: 7. Do not add any explanations, notes, or commentary — output ONLY the translated text` } +function deepSeekApiKey(): string | undefined { + return process.env.DEEPSEEK_APIKEY ?? process.env.DEEPSEEK_API_KEY +} + async function callDeepSeek(text: string, targetLanguage: string): Promise { - const apiKey = process.env.DEEPSEEK_APIKEY + const apiKey = deepSeekApiKey() if (!apiKey) { - console.warn('[translate] No DEEPSEEK_APIKEY set — falling back to identity.') + console.warn('[translate] No DEEPSEEK_APIKEY / DEEPSEEK_API_KEY set — falling back to identity.') return { translatedText: text } } From 69811e0fe830339b755a6cf8644403f56a665db5 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Mon, 22 Jun 2026 07:39:11 +0200 Subject: [PATCH 4/8] refactor(translate): remove unused TranslateContent component TranslateContent duplicated the entire useTranslation hook (fetch, sessionStorage caching, hashText) and was imported nowhere. The hook is the single source of truth used by BlogPostContent, MemoCard, and BlogCard. --- components/TranslateContent.tsx | 137 -------------------------------- 1 file changed, 137 deletions(-) delete mode 100644 components/TranslateContent.tsx diff --git a/components/TranslateContent.tsx b/components/TranslateContent.tsx deleted file mode 100644 index 1b42e4c6..00000000 --- a/components/TranslateContent.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -import React, { useEffect, useState, useCallback, useRef } from 'react' -import { useLocale } from 'next-intl' -import { shouldTranslate } from '@/lib/translate.shared' - -interface TranslateContentProps { - children: string - /** Treat content as markdown (preserves markdown syntax). Default: true */ - isMarkdown?: boolean - /** Unique identifier for this content (used in session storage cache key). */ - contentId?: string - /** Optional class name for the wrapper. */ - className?: string - /** Render function to customise how the translated text is displayed. */ - render?: (text: string) => React.ReactNode -} - -/** - * Auto-translates its text content into the user's browser language. - * - * - For Chinese (zh*) locales, content is shown as-is (no-op). - * - For non-Chinese locales, content is translated via the /api/translate endpoint. - * - Translations are cached in sessionStorage to avoid redundant API calls - * during a browsing session. - * - Shows the original text while translation loads. - */ -export function TranslateContent({ - children: text, - isMarkdown = true, - contentId, - className, - render, -}: TranslateContentProps) { - const locale = useLocale() - const [translatedText, setTranslatedText] = useState(null) - const [isTranslating, setIsTranslating] = useState(false) - const mountedRef = useRef(true) - - const needsTranslation = shouldTranslate(locale) - - useEffect(() => { - mountedRef.current = true - return () => { mountedRef.current = false } - }, []) - - const translate = useCallback(async () => { - if (!needsTranslation || !text.trim()) { - setTranslatedText(null) - return - } - - // Check sessionStorage cache - const cacheKey = `translate:${contentId ?? hashText(text)}:${locale}` - try { - const cached = sessionStorage.getItem(cacheKey) - if (cached) { - setTranslatedText(cached) - return - } - } catch { - /* sessionStorage may not be available */ - } - - setIsTranslating(true) - - try { - const response = await fetch('/api/translate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, targetLocale: locale, isMarkdown }), - }) - - if (!response.ok) { - const errData = await response.json().catch(() => ({})) - throw new Error(errData.error ?? `Translation failed (${response.status})`) - } - - const data = await response.json() - if (mountedRef.current) { - setTranslatedText(data.translatedText) - - // Cache in sessionStorage - try { - sessionStorage.setItem(cacheKey, data.translatedText) - } catch { /* quota exceeded */ } - } - } catch (err) { - if (mountedRef.current) { - console.error('[TranslateContent] Error:', err) - // Fall back to original text - setTranslatedText(null) - } - } finally { - if (mountedRef.current) { - setIsTranslating(false) - } - } - }, [text, locale, isMarkdown, contentId, needsTranslation]) - - useEffect(() => { - translate() - }, [translate]) - - const displayText = translatedText ?? text - - if (render) { - return <>{render(displayText)} - } - - return ( - - {displayText} - {isTranslating && translatedText === null && text !== displayText && ( - translating... - )} - - ) -} - -/** - * Simple hash function for use as a cache key. - */ -function hashText(text: string): string { - let hash = 0 - for (let i = 0; i < text.length; i++) { - const char = text.charCodeAt(i) - hash = ((hash << 5) - hash) + char - hash |= 0 - } - return Math.abs(hash).toString(36) -} - - - -// Re-export for convenience -export { shouldTranslate } From bf50c56bc5b5ad2b893b71993c7117af4b79c5be Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Mon, 22 Jun 2026 07:40:01 +0200 Subject: [PATCH 5/8] refactor(translate): simplify locale helpers, fix stale comment - Correct the normalizeLocale doc comment (engine is DeepSeek, not Google Translate) and describe what it actually does. - Merge the duplicate zh-HK/zh-TW branches in normalizeLocale. - Drop the redundant second Chinese-locale check in shouldTranslate; normalizeLocale already collapses every variant into CHINESE_LOCALES. Verified behaviourally identical across 15 locale inputs. --- lib/translate.shared.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/translate.shared.ts b/lib/translate.shared.ts index 6e43b960..85df4518 100644 --- a/lib/translate.shared.ts +++ b/lib/translate.shared.ts @@ -3,11 +3,14 @@ * This module can be imported by both server and client code. */ -/** Map our locale codes to Google Translate target codes. */ +/** + * Canonicalise a locale code for use as a stable cache key and for Chinese + * detection: collapse regional Chinese variants (zh-HK → zh-TW) and strip the + * region from everything else (en-US → en). + */ export function normalizeLocale(locale: string): string { if (locale === 'zh') return 'zh-CN' - if (locale === 'zh-HK') return 'zh-TW' - if (locale === 'zh-TW') return 'zh-TW' + if (locale === 'zh-HK' || locale === 'zh-TW') return 'zh-TW' return locale.split('-')[0] } @@ -19,8 +22,7 @@ const CHINESE_LOCALES = new Set(['zh', 'zh-CN', 'zh-TW', 'zh-HK']) * Returns false for Chinese locales since the blog content is primarily in Chinese. */ export function shouldTranslate(targetLocale: string): boolean { - const normalized = normalizeLocale(targetLocale) - return !CHINESE_LOCALES.has(normalized) && !CHINESE_LOCALES.has(targetLocale.split('-')[0]) + return !CHINESE_LOCALES.has(normalizeLocale(targetLocale)) } /** From 766905784002535d9eb580cd4b2bbf950fe24ea2 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Mon, 22 Jun 2026 07:41:03 +0200 Subject: [PATCH 6/8] refactor(translate): consolidate useTranslation effects Collapse the separate reset effect, translate-trigger effect, and shouldFetch helper into a single resolution effect plus a stable translate callback. Behaviour is unchanged: Chinese/empty content shows the original, a cached translation is served immediately, and a cache miss fetches while showing the original meanwhile. Extract readSessionCache/writeSessionCache so every sessionStorage access stays wrapped in try-catch (preserving the SSR-safety fix) instead of being re-implemented inline in three places. --- hooks/useTranslation.ts | 113 ++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/hooks/useTranslation.ts b/hooks/useTranslation.ts index f3953910..15145cae 100644 --- a/hooks/useTranslation.ts +++ b/hooks/useTranslation.ts @@ -31,6 +31,24 @@ function hashText(text: string): string { return Math.abs(hash).toString(36) } +/** Read a cached translation. Returns null if absent or sessionStorage is unavailable (e.g. SSR). */ +function readSessionCache(key: string): string | null { + try { + return sessionStorage.getItem(key) + } catch { + return null + } +} + +/** Persist a translation. Silently no-ops if sessionStorage is unavailable or over quota. */ +function writeSessionCache(key: string, value: string): void { + try { + sessionStorage.setItem(key, value) + } catch { + /* unavailable or quota exceeded */ + } +} + /** * Hook that auto-translates text content into the user's browser language. * @@ -55,48 +73,14 @@ export function useTranslation( const mountedRef = useRef(true) const needsTranslation = shouldTranslate(locale) - const cacheKey = contentId - ? `translate:${contentId}:${locale}` - : `translate:${hashText(text)}:${locale}` + const cacheKey = `translate:${contentId ?? hashText(text)}:${locale}` useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } }, []) - // Reset when text or locale changes - useEffect(() => { - if (!needsTranslation || !text.trim()) { - setTranslatedText(text) - setError(null) - setIsTranslating(false) - setShowOriginal(false) - return - } - - // Check sessionStorage cache - try { - const cached = sessionStorage.getItem(cacheKey) - if (cached) { - setTranslatedText(cached) - setError(null) - setIsTranslating(false) - return - } - } catch { /* sessionStorage unavailable */ } - - // Initiate translation - setIsTranslating(true) - setError(null) - setTranslatedText(text) // show original while loading - }, [text, locale, needsTranslation, cacheKey]) - const translate = useCallback(async () => { - if (!needsTranslation || !text.trim()) { - setTranslatedText(text) - return - } - setIsTranslating(true) setError(null) setShowOriginal(false) @@ -113,43 +97,46 @@ export function useTranslation( throw new Error(errData.error ?? `Translation failed (${response.status})`) } - const data = await response.json() - if (mountedRef.current) { - setTranslatedText(data.translatedText) - // Cache in sessionStorage - try { - sessionStorage.setItem(cacheKey, data.translatedText) - } catch { /* quota exceeded */ } - } + const { translatedText } = await response.json() + if (!mountedRef.current) return + + setTranslatedText(translatedText) + writeSessionCache(cacheKey, translatedText) } catch (err) { - if (mountedRef.current) { - console.error('[useTranslation] Error:', err) - setError(err instanceof Error ? err.message : 'Translation error') - setTranslatedText(text) // fall back to original - } + if (!mountedRef.current) return + console.error('[useTranslation] Error:', err) + setError(err instanceof Error ? err.message : 'Translation error') + setTranslatedText(text) // fall back to original } finally { - if (mountedRef.current) { - setIsTranslating(false) - } + if (mountedRef.current) setIsTranslating(false) } - }, [text, locale, isMarkdown, cacheKey, needsTranslation]) + }, [text, locale, isMarkdown, cacheKey]) + // Resolve the displayed text whenever the inputs change: show the original + // for Chinese/empty content, serve a cached translation when present, or + // kick off a fresh translation while showing the original in the meantime. useEffect(() => { - if (!shouldFetch()) return - translate() - }, [translate, needsTranslation, text, cacheKey]) + if (!needsTranslation || !text.trim()) { + setTranslatedText(text) + setError(null) + setIsTranslating(false) + setShowOriginal(false) + return + } - function shouldFetch(): boolean { - if (!needsTranslation || !text.trim()) return false - try { - return !sessionStorage.getItem(cacheKey) - } catch { - return true // sessionStorage unavailable; fetch fresh + const cached = readSessionCache(cacheKey) + if (cached !== null) { + setTranslatedText(cached) + setError(null) + setIsTranslating(false) + return } - } - const displayedText = showOriginal ? text : translatedText + setTranslatedText(text) // show original while the request is in flight + translate() + }, [text, locale, needsTranslation, cacheKey, translate]) + const displayedText = showOriginal ? text : translatedText const actuallyTranslated = needsTranslation && translatedText !== text return { From c56527fc19f44d4c68aed7205fe14e7bc73c226d Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Mon, 22 Jun 2026 07:42:39 +0200 Subject: [PATCH 7/8] refactor(translate): extract shared TranslationIndicator component BlogPostContent and MemoCard had near-identical copies of the translation status row (toggle button + auto-translated badge + spinner). Extract a single TranslationIndicator that reproduces both the verbose article ('full') and compact card ('compact') variants, and owns the 'show only when in-flight or translated' guard. --- components/BlogPostContent.tsx | 36 +++++------------ components/MemoCard.tsx | 36 +++++------------ components/TranslationIndicator.tsx | 60 +++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 54 deletions(-) create mode 100644 components/TranslationIndicator.tsx diff --git a/components/BlogPostContent.tsx b/components/BlogPostContent.tsx index 34006072..f97bf569 100644 --- a/components/BlogPostContent.tsx +++ b/components/BlogPostContent.tsx @@ -14,7 +14,7 @@ import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' import Image from 'next/image' import LikeButton from './LikeButton' import { useTranslation } from '@/hooks/useTranslation' -import { localeToLabel } from '@/lib/translate.shared' +import { TranslationIndicator } from './TranslationIndicator' import { useLocale } from 'next-intl' interface BlogPostContentProps { title: string @@ -46,8 +46,6 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis isTranslating: titleTranslating, } = useTranslation(title, false, `blog-title:${slug}`) - const showTranslationUI = contentTranslating || contentActuallyTranslated - return (
{headerContent && ( @@ -71,30 +69,14 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis
- {/* Translation indicator — only shown while in-flight or when actual translation exists */} - {showTranslationUI && ( -
- {contentActuallyTranslated && ( - - )} - {contentActuallyTranslated && !contentShowOriginal && ( - - - Auto-translated to {localeToLabel(locale)} - - )} - {contentTranslating && ( - translating... - )} -
- )} +
- {/* Translation indicator — only shown while in-flight or when actual translation exists */} - {showTranslationUI && ( -
- {actuallyTranslated && ( - - )} - {actuallyTranslated && !showOriginal && ( - - - Translated - - )} - {isTranslating && ( - translating... - )} -
- )} +
{/* Footer — date + location on the left, actions on the right */} diff --git a/components/TranslationIndicator.tsx b/components/TranslationIndicator.tsx new file mode 100644 index 00000000..4a98c02e --- /dev/null +++ b/components/TranslationIndicator.tsx @@ -0,0 +1,60 @@ +'use client' + +import { localeToLabel } from '@/lib/translate.shared' + +interface TranslationIndicatorProps { + /** The user's locale (used to label the target language). */ + locale: string + /** True while a translation request is in flight. */ + isTranslating: boolean + /** True once a translation actually differs from the original. */ + actuallyTranslated: boolean + /** Whether the original (untranslated) text is currently shown. */ + showOriginal: boolean + /** Toggle between the translated and original text. */ + onToggleOriginal: () => void + /** 'full' for article pages (verbose), 'compact' for cards. */ + variant?: 'full' | 'compact' +} + +/** + * Auto-translation status row: a "show original / show translation" toggle, + * an "auto-translated" badge, and an in-flight spinner. Renders nothing when + * there is neither an in-flight request nor an applied translation. + */ +export function TranslationIndicator({ + locale, + isTranslating, + actuallyTranslated, + showOriginal, + onToggleOriginal, + variant = 'full', +}: TranslationIndicatorProps) { + if (!isTranslating && !actuallyTranslated) return null + + const full = variant === 'full' + + return ( +
+ {actuallyTranslated && ( + + )} + {actuallyTranslated && !showOriginal && ( + + + {full ? `Auto-translated to ${localeToLabel(locale)}` : 'Translated'} + + )} + {isTranslating && ( + translating... + )} +
+ ) +} From 2c2ee82eb8faa0215db49d4d6a408fbaa7031b39 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Mon, 22 Jun 2026 07:44:03 +0200 Subject: [PATCH 8/8] test(translate): cover locale helpers and engine cache/fallback - translate.shared: normalizeLocale, shouldTranslate, localeToLabel, getTranslatableLocales across Chinese variants and unknown codes. - translate engine: blank no-op, cache miss calls DeepSeek and caches, cache hit skips the API, and missing API key falls back to the original text. Uses an isolated temp cache dir and a mocked fetch. --- __tests__/lib/translate.shared.test.ts | 60 +++++++++++++++++ __tests__/lib/translate.test.ts | 93 ++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 __tests__/lib/translate.shared.test.ts create mode 100644 __tests__/lib/translate.test.ts diff --git a/__tests__/lib/translate.shared.test.ts b/__tests__/lib/translate.shared.test.ts new file mode 100644 index 00000000..6a9d87b7 --- /dev/null +++ b/__tests__/lib/translate.shared.test.ts @@ -0,0 +1,60 @@ +import { + normalizeLocale, + shouldTranslate, + localeToLabel, + getTranslatableLocales, +} from '../../lib/translate.shared' + +describe('translate.shared', () => { + describe('normalizeLocale', () => { + it('maps zh to zh-CN', () => { + expect(normalizeLocale('zh')).toBe('zh-CN') + }) + + it('collapses zh-HK and zh-TW to zh-TW', () => { + expect(normalizeLocale('zh-HK')).toBe('zh-TW') + expect(normalizeLocale('zh-TW')).toBe('zh-TW') + }) + + it('strips the region from non-Chinese locales', () => { + expect(normalizeLocale('en-US')).toBe('en') + expect(normalizeLocale('de-DE')).toBe('de') + expect(normalizeLocale('fr')).toBe('fr') + }) + }) + + describe('shouldTranslate', () => { + it('returns false for every Chinese variant', () => { + for (const locale of ['zh', 'zh-CN', 'zh-TW', 'zh-HK', 'zh-Hans', 'zh-Hans-CN']) { + expect(shouldTranslate(locale)).toBe(false) + } + }) + + it('returns true for non-Chinese locales', () => { + for (const locale of ['en', 'en-US', 'ja', 'ko', 'fr', 'de-DE']) { + expect(shouldTranslate(locale)).toBe(true) + } + }) + }) + + describe('localeToLabel', () => { + it('returns the native label for known locales', () => { + expect(localeToLabel('en')).toBe('English') + expect(localeToLabel('ja')).toBe('日本語') + expect(localeToLabel('zh-TW')).toBe('繁體中文') + }) + + it('falls back to the raw code for unknown locales', () => { + expect(localeToLabel('xx')).toBe('xx') + }) + }) + + describe('getTranslatableLocales', () => { + it('lists only locales that should be translated', () => { + const locales = getTranslatableLocales() + expect(locales).toContain('en') + expect(locales).not.toContain('zh') + expect(locales.every((locale) => shouldTranslate(locale))).toBe(true) + }) + }) +}) diff --git a/__tests__/lib/translate.test.ts b/__tests__/lib/translate.test.ts new file mode 100644 index 00000000..273839c5 --- /dev/null +++ b/__tests__/lib/translate.test.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +// Point the engine at an isolated temp cache dir before importing it, so the +// module's lazily-memoised cacheDir never touches the real data/translations. +const CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'translate-test-')) +process.env.TRANSLATE_CACHE_DIR = CACHE_DIR + +import { translateText } from '../../lib/translate' + +function mockDeepSeek(content: string): jest.Mock { + return jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content } }] }), + }) +} + +describe('translateText', () => { + const originalFetch = global.fetch + const originalKey = process.env.DEEPSEEK_APIKEY + const originalKeyAlt = process.env.DEEPSEEK_API_KEY + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + global.fetch = originalFetch + jest.restoreAllMocks() + }) + + afterAll(() => { + fs.rmSync(CACHE_DIR, { recursive: true, force: true }) + restoreEnv('DEEPSEEK_APIKEY', originalKey) + restoreEnv('DEEPSEEK_API_KEY', originalKeyAlt) + }) + + it('returns blank text immediately without calling the API', async () => { + const fetchMock = jest.fn() + global.fetch = fetchMock as unknown as typeof fetch + + const result = await translateText(' ', 'en') + + expect(result.translatedText).toBe(' ') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls DeepSeek and caches the result on a cache miss', async () => { + process.env.DEEPSEEK_APIKEY = 'test-key' + const fetchMock = mockDeepSeek('Hello world') + global.fetch = fetchMock as unknown as typeof fetch + + const result = await translateText('你好世界-miss', 'en') + + expect(result.translatedText).toBe('Hello world') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('serves a cached translation without calling the API again', async () => { + process.env.DEEPSEEK_APIKEY = 'test-key' + + global.fetch = mockDeepSeek('Cached translation') as unknown as typeof fetch + await translateText('你好世界-hit', 'en') // miss → writes cache + + const secondCall = jest.fn() + global.fetch = secondCall as unknown as typeof fetch + const result = await translateText('你好世界-hit', 'en') // hit → no fetch + + expect(result.translatedText).toBe('Cached translation') + expect(secondCall).not.toHaveBeenCalled() + }) + + it('falls back to the original text when no API key is configured', async () => { + delete process.env.DEEPSEEK_APIKEY + delete process.env.DEEPSEEK_API_KEY + const fetchMock = jest.fn() + global.fetch = fetchMock as unknown as typeof fetch + + const result = await translateText('未翻译-nokey', 'en') + + expect(result.translatedText).toBe('未翻译-nokey') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +}