diff --git a/frontend/scripts/check-i18n.js b/frontend/scripts/check-i18n.js index d255eafcd..5ecb21309 100644 --- a/frontend/scripts/check-i18n.js +++ b/frontend/scripts/check-i18n.js @@ -19,6 +19,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { ENGLISH_FALLBACK_KEYS } from '../src/lib/locales/adminOperationsFallback.js'; import { mergeInto } from '../src/lib/locales/createLocale.js'; import { DATE_FORMAT_KEYS, DATE_FORMATS } from '../src/lib/locales/dateFormats.js'; +import { findMixedKeyQuoting } from './locale-key-quoting.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const LOCALES_DIR = join(__dirname, '..', 'src', 'lib', 'locales'); @@ -515,6 +516,36 @@ async function main() { } console.log(''); + // --- Check 6: Locale key quoting --- + console.log(' --- Locale key quoting ---'); + const localeSourceFiles = []; + for (const locale of localeDirs) { + const localeDir = join(LOCALES_DIR, locale); + const files = (await readdir(localeDir)).filter((f) => f.endsWith('.js') && f !== 'index.js'); + for (const file of files) { + localeSourceFiles.push({ + file: `src/lib/locales/${locale}/${file}`, + content: await readFile(join(localeDir, file), 'utf-8'), + }); + } + } + + const quotingProblems = findMixedKeyQuoting(localeSourceFiles); + let quotingErrors = 0; + if (quotingProblems.length > 0) { + for (const { file, unquoted } of quotingProblems) { + console.log(` ✗ ${file}: unquoted key(s) in a quoted-key file`); + for (const { line, key } of unquoted) { + console.log(` line ${line}: ${key}`); + } + quotingErrors += unquoted.length; + } + exitCode = 1; + } else { + console.log(' ✓ No mixed key quoting in locale files'); + } + console.log(''); + // --- Summary --- if (requiredMissing > 0 || totalExtra > 0) { exitCode = 1; @@ -532,13 +563,17 @@ async function main() { if (placeholderErrors > 0) issues.push(`${placeholderErrors} placeholder mismatch(es)`); if (carryoverTotal > 0) issues.push(`${carryoverTotal} suspected carryover(s)`); if (dateFormatErrors > 0) issues.push(`${dateFormatErrors} date format coverage error(s)`); + if (quotingErrors > 0) + issues.push(`${quotingErrors} unquoted key(s) in quoted-key locale file(s)`); console.log(` ${issues.join(', ')}`); console.log(''); process.exit(1); } } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/frontend/scripts/locale-key-quoting.js b/frontend/scripts/locale-key-quoting.js new file mode 100644 index 000000000..affdfa752 --- /dev/null +++ b/frontend/scripts/locale-key-quoting.js @@ -0,0 +1,40 @@ +// The machine-translated catalogs quote every object key. Quoting an +// identifier key is never required in JavaScript, so a file that quotes some +// identifier keys and leaves others bare is a hand-edit or regeneration +// artifact. Keys that are not valid identifiers (e.g. "field-mappings") must +// stay quoted and do not signal the file's convention. +const QUOTED_IDENTIFIER_KEY_PATTERN = /^\s*"([A-Za-z_$][A-Za-z0-9_$]*)":\s*(['"`])/; +const UNQUOTED_IDENTIFIER_KEY_PATTERN = /^\s*([A-Za-z_$][A-Za-z0-9_$]*):\s*(['"`])/; + +/** + * Finds locale files that mix quoted and unquoted object keys. Files that + * quote their keys are expected to quote all of them. + * + * @param {Array<{file: string, content: string}>} files + * @returns {Array<{file: string, unquoted: Array<{line: number, key: string}>}>} + */ +export function findMixedKeyQuoting(files) { + const problems = []; + + for (const { file, content } of files) { + const unquoted = []; + let quoted = 0; + + content.split('\n').forEach((line, index) => { + if (QUOTED_IDENTIFIER_KEY_PATTERN.test(line)) { + quoted += 1; + return; + } + const match = line.match(UNQUOTED_IDENTIFIER_KEY_PATTERN); + if (match) { + unquoted.push({ line: index + 1, key: match[1] }); + } + }); + + if (quoted > 0 && unquoted.length > 0) { + problems.push({ file, unquoted }); + } + } + + return problems; +} diff --git a/frontend/src/lib/locales/ar/supplemental.js b/frontend/src/lib/locales/ar/supplemental.js index 8b67f71e1..59fa5cb3e 100644 --- a/frontend/src/lib/locales/ar/supplemental.js +++ b/frontend/src/lib/locales/ar/supplemental.js @@ -209,10 +209,10 @@ export default { "orSignInWithEmail": "أو قم بتسجيل الدخول باستخدام البريد الإلكتروني", "profileAndSecurity": "الملف الشخصي والأمان", "profileTitle": "الملف الشخصي", - languageTitle: "اللغة", - languageSubtitle: "اختر لغة هذه البوابة والرسائل التي تصلك.", - languageLabel: "اللغة", - languageSaved: "تم تحديث اللغة", + "languageTitle": "اللغة", + "languageSubtitle": "اختر لغة هذه البوابة والرسائل التي تصلك.", + "languageLabel": "اللغة", + "languageSaved": "تم تحديث اللغة", "profileSubtitle": "إدارة كيفية تسجيل الدخول إلى هذه البوابة.", "profileSection": "الحساب", "passkeysTitle": "مفاتيح المرور", diff --git a/frontend/src/lib/locales/de/supplemental.js b/frontend/src/lib/locales/de/supplemental.js index 3355e8180..522750d21 100644 --- a/frontend/src/lib/locales/de/supplemental.js +++ b/frontend/src/lib/locales/de/supplemental.js @@ -170,10 +170,10 @@ export default { "orSignInWithEmail": "oder melden Sie sich mit E-Mail an", "profileAndSecurity": "Profil und Sicherheit", "profileTitle": "Profil", - languageTitle: "Sprache", - languageSubtitle: "Wählen Sie die Sprache für dieses Portal und die E-Mails, die Sie erhalten.", - languageLabel: "Sprache", - languageSaved: "Sprache aktualisiert", + "languageTitle": "Sprache", + "languageSubtitle": "Wählen Sie die Sprache für dieses Portal und die E-Mails, die Sie erhalten.", + "languageLabel": "Sprache", + "languageSaved": "Sprache aktualisiert", "profileSubtitle": "Verwalten Sie, wie Sie sich bei diesem Portal anmelden.", "profileSection": "Konto", "passkeysTitle": "Passkeys", diff --git a/frontend/src/lib/locales/es/supplemental.js b/frontend/src/lib/locales/es/supplemental.js index 28f164ce9..5fdf86556 100644 --- a/frontend/src/lib/locales/es/supplemental.js +++ b/frontend/src/lib/locales/es/supplemental.js @@ -209,10 +209,10 @@ export default { "orSignInWithEmail": "o inicie sesión con el correo electrónico", "profileAndSecurity": "Perfil y seguridad", "profileTitle": "Perfil", - languageTitle: "Idioma", - languageSubtitle: "Elige el idioma de este portal y de los correos que recibes.", - languageLabel: "Idioma", - languageSaved: "Idioma actualizado", + "languageTitle": "Idioma", + "languageSubtitle": "Elige el idioma de este portal y de los correos que recibes.", + "languageLabel": "Idioma", + "languageSaved": "Idioma actualizado", "profileSubtitle": "Administre cómo inicia sesión en este portal.", "profileSection": "Cuenta", "passkeysTitle": "Claves de acceso", diff --git a/frontend/src/lib/locales/ko/admin.js b/frontend/src/lib/locales/ko/admin.js index febd54e7b..524f510aa 100644 --- a/frontend/src/lib/locales/ko/admin.js +++ b/frontend/src/lib/locales/ko/admin.js @@ -658,14 +658,14 @@ export default { "textBody": "일반 텍스트 본문", "adminNotes": "설명(관리자 메모)", "activeHint": "활성(해제하면 기본 내장 템플릿 사용)", - localeLabel: "언어", - defaultLocale: "기본(모든 언어)", - translationHint: "이 언어의 버전을 편집하고 있습니다. 다른 언어는 기본 템플릿을 사용합니다.", - translationSaved: "번역이 저장되었습니다", - translationDelete: "번역 삭제", - translationDeleted: "번역이 삭제되었습니다", - translationSaveFailed: "번역을 저장하지 못했습니다: {error}", - translationDeleteFailed: "번역을 삭제하지 못했습니다: {error}", + "localeLabel": "언어", + "defaultLocale": "기본(모든 언어)", + "translationHint": "이 언어의 버전을 편집하고 있습니다. 다른 언어는 기본 템플릿을 사용합니다.", + "translationSaved": "번역이 저장되었습니다", + "translationDelete": "번역 삭제", + "translationDeleted": "번역이 삭제되었습니다", + "translationSaveFailed": "번역을 저장하지 못했습니다: {error}", + "translationDeleteFailed": "번역을 삭제하지 못했습니다: {error}", "availableVariables": "사용 가능한 변수", "syntaxHint": "템플릿은 Go의 html/template 문법을 사용합니다. 값에는 {open} .Field {close}를, 반복에는 {open} range .Items {close}…{open} end {close}를 사용하세요.", "preview": "미리보기", diff --git a/frontend/src/lib/locales/ko/channels.js b/frontend/src/lib/locales/ko/channels.js index 85699eee1..042491718 100644 --- a/frontend/src/lib/locales/ko/channels.js +++ b/frontend/src/lib/locales/ko/channels.js @@ -434,10 +434,10 @@ export default { "orSignInWithEmail": "또는 이메일로 로그인", "profileAndSecurity": "프로필 및 보안", "profileTitle": "프로필", - languageTitle: "언어", - languageSubtitle: "이 포털과 받은 이메일에 사용할 언어를 선택하세요.", - languageLabel: "언어", - languageSaved: "언어가 업데이트되었습니다", + "languageTitle": "언어", + "languageSubtitle": "이 포털과 받은 이메일에 사용할 언어를 선택하세요.", + "languageLabel": "언어", + "languageSaved": "언어가 업데이트되었습니다", "profileSubtitle": "이 포털의 로그인 방식을 관리합니다.", "profileSection": "계정", "passkeysTitle": "패스키", diff --git a/frontend/src/lib/locales/pt-BR/supplemental.js b/frontend/src/lib/locales/pt-BR/supplemental.js index 6786e80ee..817cacfdd 100644 --- a/frontend/src/lib/locales/pt-BR/supplemental.js +++ b/frontend/src/lib/locales/pt-BR/supplemental.js @@ -209,10 +209,10 @@ export default { "orSignInWithEmail": "ou faça login com o e-mail", "profileAndSecurity": "Perfil e segurança", "profileTitle": "Perfil", - languageTitle: "Idioma", - languageSubtitle: "Escolha o idioma deste portal e dos e-mails que você recebe.", - languageLabel: "Idioma", - languageSaved: "Idioma atualizado", + "languageTitle": "Idioma", + "languageSubtitle": "Escolha o idioma deste portal e dos e-mails que você recebe.", + "languageLabel": "Idioma", + "languageSaved": "Idioma atualizado", "profileSubtitle": "Gerencie como você entra neste portal.", "profileSection": "Conta", "passkeysTitle": "Chaves de acesso", diff --git a/frontend/src/lib/locales/zh-CN/supplemental.js b/frontend/src/lib/locales/zh-CN/supplemental.js index 90cefb090..54ce4ccc3 100644 --- a/frontend/src/lib/locales/zh-CN/supplemental.js +++ b/frontend/src/lib/locales/zh-CN/supplemental.js @@ -210,10 +210,10 @@ export default { "orSignInWithEmail": "或使用电子邮件登录", "profileAndSecurity": "个人资料和安全", "profileTitle": "个人资料", - languageTitle: "语言", - languageSubtitle: "选择此门户和您收到的邮件的语言。", - languageLabel: "语言", - languageSaved: "语言已更新", + "languageTitle": "语言", + "languageSubtitle": "选择此门户和您收到的邮件的语言。", + "languageLabel": "语言", + "languageSaved": "语言已更新", "profileSubtitle": "管理您登录此门户的方式。", "profileSection": "帐户", "passkeysTitle": "通行密钥",