fix: escape @ in login email placeholder (vue-i18n linked syntax)

Production /login rendered a blank page (browser console: SyntaxError: 10
through the vue-i18n parser). Root cause proved with a RED regression
(RES: vue-i18n public API reproduces 'Invalid linked format' code 10) plus an
independent reviewer: auth.emailPlaceholder="name@company.com" is invalid
vue-i18n linked-message syntax, so createI18n() throws a message-compilation
SyntaxError while LoginView renders t('auth.emailPlaceholder').

Fix: escape the literal at-sign as name{'@'}company.com in th and en so the
message compiles and the visible label is unchanged (name@company.com). Add an
all-translations regression that translates every string in th/en (objects
and arrays) through vue-i18n's public createI18n/global.t API and asserts the
visible placeholder value.

Verification:
- RED test failed at th:auth.emailPlaceholder (code 10) before the fix.
- Independent reviewer verified reproduction + fix, finished PASS.
- Frontend tests 11 passed; production build passed (index-B4oVHpLg.js).
- Chrome headless rendered the login card, Thai heading, and name@company.com
  from the production dist. Artifact checksum hash 3621155075b3d9245d2d05511aaf39b1b0cbcaeea local vs server.
This commit is contained in:
Kunthawat Greethong
2026-09-01 12:11:40 +07:00
parent 16c0996eaf
commit 333f6ccc6e
6 changed files with 98 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { createI18n } from 'vue-i18n'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
@@ -20,6 +21,19 @@ const flattenKeys = (value, prefix = '') => {
return keys.sort()
}
const flattenStringKeys = (value, prefix = '') => {
const keys = []
for (const [key, child] of Object.entries(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object') {
keys.push(...flattenStringKeys(child, fullKey))
} else if (typeof child === 'string') {
keys.push(fullKey)
}
}
return keys.sort()
}
test('locale registry exposes only Thai and English', () => {
const languages = parseJson('locales/languages.json')
assert.deepEqual(Object.keys(languages).sort(), ['en', 'th'])
@@ -34,6 +48,26 @@ test('Thai and English translation dictionaries have the same keys', () => {
)
})
test('every translation message compiles through the vue-i18n public API', () => {
const messages = {
th: parseJson('locales/th.json'),
en: parseJson('locales/en.json'),
}
const i18n = createI18n({ legacy: false, locale: 'th', messages })
for (const locale of ['th', 'en']) {
i18n.global.locale.value = locale
for (const keyPath of flattenStringKeys(messages[locale])) {
assert.doesNotThrow(
() => i18n.global.t(keyPath),
`${locale}:${keyPath} is not valid vue-i18n syntax`,
)
}
}
assert.equal(i18n.global.t('auth.emailPlaceholder'), 'name@company.com')
})
test('frontend i18n uses Thai as the safe default and fallback', () => {
const source = read('frontend/src/i18n/index.js')
assert.match(source, /DEFAULT_LOCALE\s*=\s*['"]th['"]/)