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
- {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')}
- An unexpected error occurred. Please try again. + {t('common.errors.genericDesc')}
Your file is ready
+{t('result.fileReady')}
{error || 'Processing failed'}
+{error || t('common.errors.processingFailed')}