Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# === 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=

# Translation cache directory (default: data/translations)
# TRANSLATE_CACHE_DIR=data/translations
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ next-env.d.ts

# AGENTS.md is the canonical agent instructions file.
# CLAUDE.md is a symlink → AGENTS.md (tracked by git, don't ignore).

# Translation cache (auto-generated)
data/translations/
60 changes: 60 additions & 0 deletions __tests__/lib/translate.shared.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
93 changes: 93 additions & 0 deletions __tests__/lib/translate.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
76 changes: 76 additions & 0 deletions app/api/translate/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<TranslateResponseBody>> {
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 },
)
}
}
41 changes: 27 additions & 14 deletions components/BlogCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link
href={`/blog/${encodeURIComponent(post.id)}`}
className="block p-6 h-full flex flex-col justify-between"
aria-label={post.title}
>
<div className="space-y-4">
<h3 className="font-semibold text-xl md:text-2xl text-gray-900 group-hover:text-blue-600 transition-colors duration-200 leading-tight">
{translatedTitle}
{isTranslating && <span className='ml-1 text-xs text-gray-400 animate-pulse'>translating...</span>}
</h3>
<p className="text-sm text-gray-500">
{formatDate(post.date)}
</p>
</div>
</Link>
);
};

export const BlogCard = ({ post }: { post: BlogPost }) => (
<div className="group bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-lg transition-all duration-200">
Expand All @@ -17,20 +43,7 @@ export const BlogCard = ({ post }: { post: BlogPost }) => (
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
</div>
)}
<Link
href={`/blog/${encodeURIComponent(post.id)}`}
className="block p-6 h-full flex flex-col justify-between"
aria-label={post.title}
>
<div className="space-y-4">
<h3 className="font-semibold text-xl md:text-2xl text-gray-900 group-hover:text-blue-600 transition-colors duration-200 leading-tight">
{post.title}
</h3>
<p className="text-sm text-gray-500">
{formatDate(post.date)}
</p>
</div>
</Link>
<BlogCardContent post={post} />
</div>
);

Expand Down
36 changes: 34 additions & 2 deletions components/BlogPostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { TranslationIndicator } from './TranslationIndicator'
import { useLocale } from 'next-intl'
interface BlogPostContentProps {
title: string
date: string
Expand All @@ -27,6 +30,22 @@ 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,
actuallyTranslated: contentActuallyTranslated,
} = useTranslation(content, true, `blog-content:${slug}`)

const {
translatedText: translatedTitle,
isTranslating: titleTranslating,
} = useTranslation(title, false, `blog-title:${slug}`)

return (
<div className='max-w-3xl mx-auto px-4 py-8'>
{headerContent && (
Expand All @@ -36,7 +55,10 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis
)}
<main className='bg-white rounded-lg border border-gray-200 p-8'>
<header className='mb-8'>
<h1 className='text-3xl font-bold leading-tight mb-3 text-gray-900'>{title}</h1>
<h1 className='text-3xl font-bold leading-tight mb-3 text-gray-900'>
{translatedTitle}
{titleTranslating && <span className='ml-2 text-xs text-gray-400 animate-pulse'>translating...</span>}
</h1>
<div className='text-sm text-gray-600 flex items-center gap-3'>
<time dateTime={date}>
{format(new Date(date), 'MMM d, yyyy')}
Expand All @@ -46,6 +68,16 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis
)}
</div>
</header>

<TranslationIndicator
variant='full'
locale={locale}
isTranslating={contentTranslating}
actuallyTranslated={contentActuallyTranslated}
showOriginal={contentShowOriginal}
onToggleOriginal={toggleContentOriginal}
/>

<div className='prose prose-lg max-w-none text-gray-900 leading-relaxed prose-p:my-3 prose-img:my-0'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
Expand Down Expand Up @@ -116,7 +148,7 @@ export function BlogPostContent({ title, date, content, slug, headerContent, dis
),
}}
>
{content}
{translatedContent}
</ReactMarkdown>
</div>

Expand Down
Loading
Loading