diff --git a/frontend/package.json b/frontend/package.json index 6f301b1..742db2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,9 @@ "preview": "vite preview", "lint": "eslint .", "test": "vitest run", - "seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs" + "seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs", + "lint:i18n": "node scripts/check-i18n-keys.mjs", + "lint:hardcoded": "node scripts/check-hardcoded-text.mjs" }, "dependencies": { "@microsoft/clarity": "^1.0.2", diff --git a/frontend/scripts/check-hardcoded-text.mjs b/frontend/scripts/check-hardcoded-text.mjs new file mode 100644 index 0000000..a494c6c --- /dev/null +++ b/frontend/scripts/check-hardcoded-text.mjs @@ -0,0 +1,127 @@ +/** + * check-hardcoded-text.mjs + * Scans shared/layout components for hardcoded English UI strings that should + * be replaced with t() calls. + * + * Heuristic: a JSX text node is flagged when it: + * - contains at least one space (multi-word) + * - is longer than 3 characters + * - starts with an uppercase letter or common English word + * - is NOT already wrapped in {t(...)} + * - is NOT a CSS class, URL, comment, code attribute, or aria-label value + * + * Usage: node scripts/check-hardcoded-text.mjs + * Exit code 1 if any potential hardcoded strings are found. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const ROOT = join(__dirname, '..'); + +/** Directories to scan */ +const SCAN_DIRS = [ + join(ROOT, 'src', 'components', 'shared'), + join(ROOT, 'src', 'components', 'layout'), +]; + +// Collect .tsx files +function* walkFiles(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + yield* walkFiles(full); + } else if (/\.tsx$/.test(entry)) { + yield full; + } + } +} + +/** + * Patterns that indicate the string is NOT a hardcoded UI label: + * - Only digits/punctuation + * - Looks like a URL, path, class name, CSS value + * - Already inside {t(…)} + * - Attribute values like className, href, src, id, type, etc. + */ +const SKIP_RE = [ + /^[\d\s.,/:%-]+$/, // pure numbers/punctuation + /^https?:\/\//, // URLs + /^\/[a-z]/, // paths + /^[a-z][-a-z0-9]*$/, // single lowercase word (CSS class, attr value) + /^[a-z][a-zA-Z0-9]*=[a-z]/, // key=value attrs +]; + +function shouldSkip(str) { + const trimmed = str.trim(); + if (trimmed.length <= 3) return true; + if (!/\s/.test(trimmed)) return true; // single word + if (!/[A-Z]/.test(trimmed[0])) return true; // doesn't start with uppercase + for (const re of SKIP_RE) if (re.test(trimmed)) return true; + return false; +} + +/** + * Find JSX text content that is hardcoded English. + * Strategy: look for lines where text appears between JSX tags but is NOT + * wrapped in a {…} expression. + * + * Pattern: > Some Text Here < (with optional leading whitespace) + */ +const JSX_TEXT_RE = />\s*([A-Z][^<{}\n]{3,}?)\s* = { PDF: [ - { slug: 'pdf-to-word', label: 'PDF to Word' }, - { slug: 'compress-pdf', label: 'Compress PDF' }, - { slug: 'merge-pdf', label: 'Merge PDF' }, - { slug: 'split-pdf', label: 'Split PDF' }, - { slug: 'pdf-to-images', label: 'PDF to Images' }, - { slug: 'protect-pdf', label: 'Protect PDF' }, - { slug: 'watermark-pdf', label: 'Watermark PDF' }, - { slug: 'pdf-editor', label: 'PDF Editor' }, + { slug: 'pdf-to-word', i18nKey: 'tools.pdfToWord.title' }, + { slug: 'compress-pdf', i18nKey: 'tools.compressPdf.title' }, + { slug: 'merge-pdf', i18nKey: 'tools.mergePdf.title' }, + { slug: 'split-pdf', i18nKey: 'tools.splitPdf.title' }, + { slug: 'pdf-to-images', i18nKey: 'tools.pdfToImages.title' }, + { slug: 'protect-pdf', i18nKey: 'tools.protectPdf.title' }, + { slug: 'watermark-pdf', i18nKey: 'tools.watermarkPdf.title' }, + { slug: 'pdf-editor', i18nKey: 'tools.pdfEditor.title' }, ], 'Image & Convert': [ - { slug: 'compress-image', label: 'Compress Image' }, - { slug: 'image-converter', label: 'Image Converter' }, - { slug: 'image-resize', label: 'Image Resize' }, - { slug: 'remove-background', label: 'Remove Background' }, - { slug: 'word-to-pdf', label: 'Word to PDF' }, - { slug: 'html-to-pdf', label: 'HTML to PDF' }, - { slug: 'pdf-to-excel', label: 'PDF to Excel' }, + { slug: 'compress-image', i18nKey: 'tools.compressImage.title' }, + { slug: 'image-converter', i18nKey: 'tools.imageConvert.title' }, + { slug: 'image-resize', i18nKey: 'tools.imageResize.title' }, + { slug: 'remove-background', i18nKey: 'tools.removeBg.title' }, + { slug: 'word-to-pdf', i18nKey: 'tools.wordToPdf.title' }, + { slug: 'html-to-pdf', i18nKey: 'tools.htmlToPdf.title' }, + { slug: 'pdf-to-excel', i18nKey: 'tools.pdfToExcel.title' }, ], 'AI & Utility': [ - { slug: 'chat-pdf', label: 'Chat with PDF' }, - { slug: 'summarize-pdf', label: 'Summarize PDF' }, - { slug: 'translate-pdf', label: 'Translate PDF' }, - { slug: 'ocr', label: 'OCR' }, - { slug: 'qr-code', label: 'QR Code Generator' }, - { slug: 'video-to-gif', label: 'Video to GIF' }, - { slug: 'word-counter', label: 'Word Counter' }, + { slug: 'chat-pdf', i18nKey: 'tools.chatPdf.title' }, + { slug: 'summarize-pdf', i18nKey: 'tools.summarizePdf.title' }, + { slug: 'translate-pdf', i18nKey: 'tools.translatePdf.title' }, + { slug: 'ocr', i18nKey: 'tools.ocr.title' }, + { slug: 'qr-code', i18nKey: 'tools.qrCode.title' }, + { slug: 'video-to-gif', i18nKey: 'tools.videoToGif.title' }, + { slug: 'word-counter', i18nKey: 'tools.wordCounter.title' }, ], Guides: [ - { slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true }, - { slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true }, - { slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true }, + { slug: 'best-pdf-tools', i18nKey: 'footer.guides.bestPdfTools', isLanding: true }, + { slug: 'free-pdf-tools-online', i18nKey: 'footer.guides.freePdfToolsOnline', isLanding: true }, + { slug: 'convert-files-online', i18nKey: 'footer.guides.convertFilesOnline', isLanding: true }, ], Comparisons: [ - { slug: 'compress-pdf-vs-ilovepdf', label: 'Dociva vs iLovePDF', isComparison: true }, - { slug: 'merge-pdf-vs-smallpdf', label: 'Dociva vs Smallpdf', isComparison: true }, - { slug: 'pdf-to-word-vs-adobe-acrobat', label: 'Dociva vs Adobe Acrobat', isComparison: true }, - { slug: 'compress-image-vs-tinypng', label: 'Dociva vs TinyPNG', isComparison: true }, - { slug: 'ocr-vs-adobe-scan', label: 'Dociva vs Adobe Scan', isComparison: true }, + { slug: 'compress-pdf-vs-ilovepdf', i18nKey: 'footer.comparisons.compressPdfVsIlovepdf', isComparison: true }, + { slug: 'merge-pdf-vs-smallpdf', i18nKey: 'footer.comparisons.mergePdfVsSmallpdf', isComparison: true }, + { slug: 'pdf-to-word-vs-adobe-acrobat', i18nKey: 'footer.comparisons.pdfToWordVsAdobeAcrobat', isComparison: true }, + { slug: 'compress-image-vs-tinypng', i18nKey: 'footer.comparisons.compressImageVsTinypng', isComparison: true }, + { slug: 'ocr-vs-adobe-scan', i18nKey: 'footer.comparisons.ocrVsAdobeScan', isComparison: true }, ], }; +const CATEGORY_KEYS: Record = { + 'PDF': 'footer.categories.pdf', + 'Image & Convert': 'footer.categories.imageConvert', + 'AI & Utility': 'footer.categories.aiUtility', + 'Guides': 'footer.categories.guides', + 'Comparisons': 'footer.categories.comparisons', +}; + export default function Footer() { const { t } = useTranslation(); @@ -63,16 +78,13 @@ export default function Footer() { {t('common.appName')}

- {t('common.siteTagline', 'Online PDF and file workflows')} + {t('common.siteTagline')}

- {t( - 'common.footerDescription', - 'Convert, compress, edit, and automate document work in one browser-based workspace built for speed, clarity, and secure processing.' - )} + {t('common.footerDescription')}

@@ -96,16 +108,16 @@ export default function Footer() { {Object.entries(FOOTER_TOOLS).map(([category, tools]) => (

- {category} + {t(CATEGORY_KEYS[category] ?? category)}

    {tools.map((tool) => (
  • - {tool.label} + {t(tool.i18nKey)}
  • ))} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 04c2fc0..5e867a9 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -110,7 +110,7 @@ export default function Header() { {t('common.appName')} - {t('common.siteTagline', 'Online PDF and file workflows')} + {t('common.siteTagline')}
@@ -145,7 +145,7 @@ export default function Header() { className="hidden items-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:-translate-y-0.5 hover:bg-primary-600 lg:inline-flex dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300" > - {t('home.startFree', 'Start Free')} + {t('home.startFree')} ) : null} diff --git a/frontend/src/components/shared/ErrorBoundary.tsx b/frontend/src/components/shared/ErrorBoundary.tsx index 73d88f1..3227a46 100644 --- a/frontend/src/components/shared/ErrorBoundary.tsx +++ b/frontend/src/components/shared/ErrorBoundary.tsx @@ -1,7 +1,8 @@ import { Component, type ReactNode } from 'react'; import { AlertTriangle } from 'lucide-react'; +import { withTranslation, type WithTranslation } from 'react-i18next'; -interface Props { +interface Props extends WithTranslation { children: ReactNode; fallbackMessage?: string; } @@ -10,7 +11,7 @@ interface State { hasError: boolean; } -export default class ErrorBoundary extends Component { +class ErrorBoundary extends Component { state: State = { hasError: false }; static getDerivedStateFromError(): State { @@ -22,6 +23,7 @@ export default class ErrorBoundary extends Component { }; render() { + const { t } = this.props; if (this.state.hasError) { return (
@@ -29,16 +31,16 @@ export default class ErrorBoundary extends Component {

- {this.props.fallbackMessage || 'Something went wrong'} + {this.props.fallbackMessage || t('common.errors.genericTitle')}

- An unexpected error occurred. Please try again. + {t('common.errors.genericDesc')}

); @@ -46,3 +48,5 @@ export default class ErrorBoundary extends Component { return this.props.children; } } + +export default withTranslation()(ErrorBoundary); diff --git a/frontend/src/components/shared/ToolTemplate.tsx b/frontend/src/components/shared/ToolTemplate.tsx index 270addf..6941f82 100644 --- a/frontend/src/components/shared/ToolTemplate.tsx +++ b/frontend/src/components/shared/ToolTemplate.tsx @@ -172,10 +172,10 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT {isUploading ? ( <> - {t('common.uploading', { defaultValue: 'Uploading...' })} + {t('common.uploading')} ) : ( - t('common.convert', { defaultValue: 'Convert' }) + t('common.convert') )} @@ -195,8 +195,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
-

Success!

-

Your file is ready

+

{t('result.success')}

+

{t('result.fileReady')}

@@ -208,15 +208,15 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
-

Error

-

{error || 'Processing failed'}

+

{t('common.error')}

+

{error || t('common.errors.processingFailed')}

)} )} diff --git a/frontend/src/components/tools/BarcodeGenerator.tsx b/frontend/src/components/tools/BarcodeGenerator.tsx index b34d2cc..37d3ca7 100644 --- a/frontend/src/components/tools/BarcodeGenerator.tsx +++ b/frontend/src/components/tools/BarcodeGenerator.tsx @@ -98,7 +98,7 @@ export default function BarcodeGenerator() { {phase === 'done' && downloadUrl && (
- Barcode + {t('tools.barcode.altText')}
{t('common.download')} diff --git a/frontend/src/components/tools/ImageResize.tsx b/frontend/src/components/tools/ImageResize.tsx index 0402b61..04e8c97 100644 --- a/frontend/src/components/tools/ImageResize.tsx +++ b/frontend/src/components/tools/ImageResize.tsx @@ -143,7 +143,7 @@ export default function ImageResize() { type="number" min="1" max="10000" - placeholder="e.g. 800" + placeholder={t('tools.imageResize.widthPlaceholder')} value={width} onChange={(e) => { setWidth(e.target.value); @@ -160,7 +160,7 @@ export default function ImageResize() { type="number" min="1" max="10000" - placeholder="e.g. 600" + placeholder={t('tools.imageResize.heightPlaceholder')} value={height} onChange={(e) => { setHeight(e.target.value); diff --git a/frontend/src/components/tools/QrCodeGenerator.tsx b/frontend/src/components/tools/QrCodeGenerator.tsx index 8336301..961b0c6 100644 --- a/frontend/src/components/tools/QrCodeGenerator.tsx +++ b/frontend/src/components/tools/QrCodeGenerator.tsx @@ -113,7 +113,7 @@ export default function QrCodeGenerator() { {phase === 'done' && result && result.status === 'completed' && downloadUrl && (
- QR Code + {t('tools.qrCode.altText')}
]*>/g, ' ').replace(/\s+/g, ' ').trim() : null) || - `Request failed (${error.response.status}).`; + i18n.t('common.errors.serverError'); return Promise.reject(new Error(message)); } if (error.request) { - return Promise.reject(new Error('Network error. Please check your connection.')); + return Promise.reject(new Error(i18n.t('common.errors.networkError'))); } return Promise.reject(error); } @@ -251,7 +258,57 @@ function isTaskErrorPayload(value: unknown): value is TaskErrorPayload { return Boolean(value) && typeof value === 'object'; } +/** + * Maps a backend error_code to a fully translated message via i18n. + * Returns null when no specific mapping exists (caller should fall back to user_message or generic). + */ +export function resolveErrorCode(errorCode: string): string | null { + const map: Record = { + TASK_FAILURE: i18n.t('common.errors.processingFailed'), + CELERY_NOT_REGISTERED: i18n.t('common.errors.taskUnavailable'), + OPENROUTER_UNAUTHORIZED: i18n.t('common.errors.aiUnavailable'), + OPENROUTER_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'), + OPENROUTER_INSUFFICIENT_CREDITS: i18n.t('common.errors.aiRateLimited'), + OPENROUTER_SERVER_ERROR: i18n.t('common.errors.serverError'), + OPENROUTER_CONNECTION_ERROR: i18n.t('common.errors.networkError'), + OPENROUTER_TIMEOUT: i18n.t('common.errors.serverError'), + OPENROUTER_MISSING_API_KEY: i18n.t('common.errors.aiUnavailable'), + OPENROUTER_EMPTY_RESPONSE: i18n.t('common.errors.aiUnavailable'), + OPENROUTER_ERROR_PAYLOAD: i18n.t('common.errors.aiUnavailable'), + OPENROUTER_REQUEST_ERROR: i18n.t('common.errors.serverError'), + DEEPL_NOT_CONFIGURED: i18n.t('common.errors.translationFailed'), + DEEPL_UNSUPPORTED_TARGET_LANGUAGE: i18n.t('common.errors.invalidInput'), + DEEPL_TIMEOUT: i18n.t('common.errors.translationFailed'), + DEEPL_CONNECTION_ERROR: i18n.t('common.errors.networkError'), + DEEPL_REQUEST_ERROR: i18n.t('common.errors.translationFailed'), + DEEPL_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'), + DEEPL_SERVER_ERROR: i18n.t('common.errors.serverError'), + DEEPL_CREDITS_OR_PERMISSIONS: i18n.t('common.errors.translationFailed'), + DEEPL_EMPTY_RESPONSE: i18n.t('common.errors.translationFailed'), + DEEPL_EMPTY_TEXT: i18n.t('common.errors.pdfTextEmpty'), + TRANSLATION_PROVIDER_FAILED: i18n.t('common.errors.translationFailed'), + AI_BUDGET_EXCEEDED: i18n.t('common.errors.aiBudgetExceeded'), + PDF_ENCRYPTED: i18n.t('common.errors.pdfEncrypted'), + PDF_TEXT_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'), + PDF_TEXT_EMPTY: i18n.t('common.errors.pdfTextEmpty'), + PDF_AI_INVALID_INPUT: i18n.t('common.errors.invalidInput'), + PDF_AI_ERROR: i18n.t('common.errors.processingFailed'), + PDF_TABLES_NOT_FOUND: i18n.t('common.errors.pdfNoTables'), + PDF_TABLE_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'), + TABULA_NOT_INSTALLED: i18n.t('common.errors.serverError'), + }; + return map[errorCode] ?? null; +} + export function getTaskErrorMessage(error: unknown, fallback: string): string { + if (isTaskErrorPayload(error)) { + // Prefer a translated message keyed by error_code + if (typeof error.error_code === 'string') { + const translated = resolveErrorCode(error.error_code); + if (translated) return translated; + } + } + if (typeof error === 'string' && error.trim()) { return error.trim(); }