|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Architecture Advisor — bilingual purity guard. |
| 4 | + * |
| 5 | + * Every user-facing string exists as an EN/ID pair. The failure this catches is a half-translated |
| 6 | + * sentence: an `id` string that still carries English prose (or the reverse), which reads as broken |
| 7 | + * to a monolingual user and is invisible to typechecking, linting and every existing test. |
| 8 | + * |
| 9 | + * What counts as evidence. Technical nouns are legitimately English inside Indonesian prose |
| 10 | + * ("microservices", "cache", "deploy", "event sourcing"), so their presence proves nothing and |
| 11 | + * flagging them would make this guard noise. FUNCTION words are the signal — "the", "with", "your", |
| 12 | + * "which" appear only in an untranslated English sentence, never as borrowed terminology. Hyphenated |
| 13 | + * compounds are stripped first, because "append-only" and "read-only" are single technical terms |
| 14 | + * whose tail would otherwise look like the English word "only". |
| 15 | + * |
| 16 | + * Covers both shapes the codebase uses: `{ en: '…', id: '…' }` objects (dict, config, chat topics) |
| 17 | + * and the Manual's inline `L('…','…')` / `p('…','…')` helpers. |
| 18 | + * |
| 19 | + * Run: node scripts/check-language-purity.mjs (exit 0 = no mixed-language strings) |
| 20 | + */ |
| 21 | +import { readFileSync } from 'node:fs'; |
| 22 | +import { execSync } from 'node:child_process'; |
| 23 | + |
| 24 | +const EN_FN = ['the', 'and', 'with', 'your', 'for', 'this', 'that', 'from', 'when', 'what', 'how', 'are', 'is', 'you', 'will', 'can', 'not', 'all', 'any', 'more', 'than', 'into', 'about', 'which', 'while', 'they', 'their', 'has', 'have', 'was', 'were', 'been', 'only', 'also', 'just', 'each', 'every', 'some', 'most', 'other', 'both', 'over', 'under', 'after', 'before', 'without', 'between', 'because', 'however', 'therefore', 'them', 'then', 'there', 'here', 'who', 'why', 'where', 'does', 'need', 'needs', 'want', 'uses', 'used', 'instead', 'rather', 'own']; |
| 25 | +const ID_FN = ['yang', 'dan', 'dengan', 'untuk', 'ini', 'itu', 'dari', 'pada', 'adalah', 'bisa', 'tidak', 'akan', 'atau', 'juga', 'tapi', 'tetapi', 'sudah', 'belum', 'masih', 'saja', 'agar', 'supaya', 'karena', 'sehingga', 'setiap', 'semua', 'lebih', 'paling', 'anda', 'kami', 'kita', 'mereka', 'ada', 'tak', 'bukan', 'saat', 'ketika', 'kalau', 'jika', 'maka', 'oleh', 'tanpa', 'antara', 'namun', 'sebuah', 'dalam', 'cuma', 'punya']; |
| 26 | + |
| 27 | +const Q = "(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")"; |
| 28 | +const RX_OBJ = new RegExp(`en:\\s*${Q}\\s*,\\s*id:\\s*${Q}`, 'g'); |
| 29 | +const RX_FN = new RegExp(`\\b[Lp]\\(\\s*${Q}\\s*,\\s*${Q}\\s*[,)]`, 'gs'); |
| 30 | + |
| 31 | +/** Strip hyphenated compounds first — they are single technical terms, not prose. */ |
| 32 | +const words = (s) => (s.toLowerCase().replace(/[a-z]+-[a-z-]+/g, ' ').match(/[a-z]+/g) || []); |
| 33 | + |
| 34 | +const files = execSync('grep -rl "en:\\|L(\'\\|p(\'" src/ content/ --include=*.ts --include=*.tsx --include=*.json 2>/dev/null || true', { encoding: 'utf8' }) |
| 35 | + .trim().split('\n').filter(Boolean).filter((f) => !f.includes('.test.')); |
| 36 | + |
| 37 | +const problems = []; |
| 38 | +let pairs = 0; |
| 39 | + |
| 40 | +for (const file of files) { |
| 41 | + const src = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); |
| 42 | + for (const rx of [RX_OBJ, RX_FN]) { |
| 43 | + rx.lastIndex = 0; |
| 44 | + let m; |
| 45 | + while ((m = rx.exec(src))) { |
| 46 | + const en = m[1] ?? m[2] ?? ''; |
| 47 | + const id = m[3] ?? m[4] ?? ''; |
| 48 | + if (!en || !id) continue; |
| 49 | + pairs++; |
| 50 | + const line = src.slice(0, m.index).split('\n').length; |
| 51 | + const enInId = [...new Set(words(id).filter((w) => EN_FN.includes(w)))]; |
| 52 | + const idInEn = [...new Set(words(en).filter((w) => ID_FN.includes(w)))]; |
| 53 | + if (enInId.length) problems.push(`${file}:${line} — ID string carries English prose (${enInId.join(', ')}): "${id.slice(0, 120)}"`); |
| 54 | + if (idInEn.length) problems.push(`${file}:${line} — EN string carries Indonesian prose (${idInEn.join(', ')}): "${en.slice(0, 120)}"`); |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +console.log(`Language purity — ${pairs} EN/ID pairs across ${files.length} files`); |
| 60 | +if (problems.length) { |
| 61 | + for (const p of problems) console.log(` ✗ ${p}`); |
| 62 | + console.error(`\n${problems.length} mixed-language string(s). Translate the sentence, or if the flagged word is genuinely a technical term, add it to this script's exclusions with a reason.`); |
| 63 | + process.exit(1); |
| 64 | +} |
| 65 | +console.log(' no mixed-language strings ✓'); |
0 commit comments