create-locale-templates.js
1 #!/usr/bin/env node 2 /** 3 * Create Locale Templates 4 * Generates country/language template variants from AU master templates. 5 * 6 * AU templates are the canonical source of truth. All other templates are derived from AU. 7 * 8 * Usage: 9 * node scripts/create-locale-templates.js --target US # English variant for US 10 * node scripts/create-locale-templates.js --target NZ # English variant for NZ 11 * node scripts/create-locale-templates.js --translate de # German translation (DE/de) 12 * node scripts/create-locale-templates.js --translate hi # Hindi translation (IN/hi) 13 * node scripts/create-locale-templates.js --all # All countries and translations 14 * 15 * Transformations applied per country: 16 * - Replace G'day with country-specific greeting (or remove for IN/en) 17 * - Collapse {s|z} spelling variant to z (US/CA) or s (all others) 18 * - Update country, language, id fields 19 * - For translations: translate via Claude API 20 */ 21 22 import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 23 import { join, dirname } from 'path'; 24 import { fileURLToPath } from 'url'; 25 import dotenv from 'dotenv'; 26 27 // Load compliance requirements for opt-out spintax appending 28 let complianceRequirements = {}; 29 try { 30 complianceRequirements = JSON.parse( 31 readFileSync( 32 join(dirname(fileURLToPath(import.meta.url)), '../data/compliance/requirements.json'), 33 'utf-8' 34 ) 35 ); 36 } catch { 37 // requirements.json not found — opt-out appending disabled 38 } 39 40 dotenv.config(); 41 42 const __filename = fileURLToPath(import.meta.url); 43 const __dirname = dirname(__filename); 44 const projectRoot = join(__dirname, '..'); 45 46 // ─── Greeting Map ───────────────────────────────────────────────────────────── 47 // Replace G'day in AU spintax with country-appropriate greeting. 48 // null = remove G'day entirely (fall back to Hi/Hello). 49 const GREETING_MAP = { 50 AU: "G'day", // source — keep as-is 51 NZ: 'Kia ora', 52 US: 'Howdy', 53 CA: 'Hey', 54 GB: 'Hiya', 55 IE: 'Howya', 56 ZA: 'Howzit', 57 IN: null, // remove G'day; use {Hi|Hello} only 58 }; 59 60 // ─── Spelling variant ───────────────────────────────────────────────────────── 61 // {s|z} in AU templates (e.g. optimi{s|z}e) → collapse to regional spelling 62 const Z_SPELLING_COUNTRIES = new Set(['US', 'CA']); // American English 63 64 // ─── Ring/Call ──────────────────────────────────────────────────────────────── 65 // AU/NZ/GB/IE use {ring|call}; US/CA use just "call" 66 const CALL_ONLY_COUNTRIES = new Set(['US', 'CA']); 67 68 // ─── English country targets ────────────────────────────────────────────────── 69 const ENGLISH_TARGETS = ['US', 'CA', 'GB', 'IE', 'NZ', 'ZA', 'IN']; 70 71 // ─── Translation targets ────────────────────────────────────────────────────── 72 // Format: { lang, country, targetPath } 73 const TRANSLATION_TARGETS = [ 74 { lang: 'hi', country: 'IN', targetPath: 'IN/hi' }, 75 { lang: 'de', country: 'DE', targetPath: 'DE/de' }, 76 { lang: 'fr', country: 'FR', targetPath: 'FR/fr' }, 77 { lang: 'it', country: 'IT', targetPath: 'IT/it' }, 78 { lang: 'ja', country: 'JP', targetPath: 'JP/ja' }, 79 { lang: 'ko', country: 'KR', targetPath: 'KR/ko' }, 80 { lang: 'es', country: 'MX', targetPath: 'MX/es' }, 81 { lang: 'nl', country: 'NL', targetPath: 'NL/nl' }, 82 { lang: 'pl', country: 'PL', targetPath: 'PL/pl' }, 83 { lang: 'sv', country: 'SE', targetPath: 'SE/sv' }, 84 { lang: 'da', country: 'DK', targetPath: 'DK/da' }, 85 { lang: 'id', country: 'ID', targetPath: 'ID/id' }, 86 ]; 87 88 // ─── Local greetings for translations ──────────────────────────────────────── 89 const TRANSLATION_GREETINGS = { 90 hi: 'नमस्ते', 91 de: '{Hallo|Guten Tag}', 92 fr: '{Bonjour|Salut}', 93 it: '{Ciao|Buongiorno}', 94 ja: 'こんにちは', 95 ko: '안녕하세요', 96 es: 'Hola', 97 nl: '{Hallo|Goedendag}', 98 pl: '{Cześć|Dzień dobry}', 99 sv: 'Hej', 100 da: 'Hej', 101 id: 'Halo', 102 }; 103 104 // ─── OpenRouter API ─────────────────────────────────────────────────────────── 105 const { OPENROUTER_API_KEY } = process.env; 106 const MODEL = 'anthropic/claude-sonnet-4-6'; 107 108 async function callClaude(prompt) { 109 if (!OPENROUTER_API_KEY) throw new Error('OPENROUTER_API_KEY not set'); 110 const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { 111 method: 'POST', 112 headers: { 113 Authorization: `Bearer ${OPENROUTER_API_KEY}`, 114 'Content-Type': 'application/json', 115 'HTTP-Referer': 'https://github.com/333method', 116 }, 117 body: JSON.stringify({ 118 model: MODEL, 119 messages: [{ role: 'user', content: prompt }], 120 temperature: 0.2, 121 max_tokens: 4000, 122 }), 123 }); 124 if (!response.ok) { 125 throw new Error(`OpenRouter API error: ${response.status} ${await response.text()}`); 126 } 127 const data = await response.json(); 128 return data.choices[0].message.content.trim(); 129 } 130 131 // ─── Spintax transformations ────────────────────────────────────────────────── 132 133 function replaceGreeting(spintax, targetCC) { 134 const replacement = GREETING_MAP[targetCC]; 135 136 if (replacement === undefined) { 137 // Non-English country — greeting will be translated anyway, skip 138 return spintax; 139 } 140 141 if (replacement === null) { 142 // Remove G'day from spintax options (IN/en) 143 // Handles: {Hi|G'day}, {G'day|Hi}, {Hey|G'day}, {{Hi|G'day}|...} 144 return spintax 145 .replace(/\|G'day/g, '') 146 .replace(/G'day\|/g, '') 147 .replace(/G'day/g, 'Hi'); // fallback if standalone 148 } 149 150 // If replacement is already present as a spintax option, just remove G'day 151 // e.g. CA: {Hi|Hey|G'day} + "Hey" → {Hi|Hey} (avoids {Hi|Hey|Hey}) 152 if (spintax.includes(replacement)) { 153 return spintax 154 .replace(/\|G'day/g, '') 155 .replace(/G'day\|/g, '') 156 .replace(/G'day/g, replacement); // fallback if standalone 157 } 158 159 // Replace G'day string with target greeting 160 return spintax.replace(/G'day/g, replacement); 161 } 162 163 function collapseSpelling(spintax, targetCC) { 164 // {s|z} → z for US/CA, s for all others 165 const useZ = Z_SPELLING_COUNTRIES.has(targetCC); 166 return spintax.replace(/\{s\|z\}/g, useZ ? 'z' : 's').replace(/\{z\|s\}/g, useZ ? 'z' : 's'); 167 } 168 169 function normalizeRingCall(spintax, targetCC) { 170 // US/CA: "ring" isn't used — collapse {ring|call} to "call" 171 if (!CALL_ONLY_COUNTRIES.has(targetCC)) return spintax; 172 return spintax.replace(/\{ring\|call\}/g, 'call').replace(/\{call\|ring\}/g, 'call'); 173 } 174 175 function appendOptOutSpintax(bodySpintax, targetCC, channel) { 176 if (channel !== 'sms') return bodySpintax; 177 // Only append opt-out spintax when legally required (US/CA = TCPA, KR = KISA) 178 const reqs = complianceRequirements[targetCC]?.sms; 179 if (!reqs?.requiresOptOutInBody) return bodySpintax; 180 const optOut = reqs.optOutSpintax; 181 if (!optOut) return bodySpintax; 182 return `${bodySpintax}\n\n${optOut}`; 183 } 184 185 function transformTemplateForCountry(template, targetCC) { 186 const oldSuffix = '_au'; 187 const newSuffix = `_${targetCC.toLowerCase()}`; 188 189 const newTemplate = { ...template }; 190 191 // Update ID 192 newTemplate.id = template.id.replace(new RegExp(`${oldSuffix}$`), newSuffix); 193 194 // Update country and language 195 newTemplate.country = targetCC; 196 newTemplate.language = 'en'; 197 198 // Reset performance metrics (new template variant) 199 newTemplate.sends = 0; 200 newTemplate.conversions = 0; 201 newTemplate.tested = false; 202 203 // Transform spintax fields 204 for (const field of ['subject_spintax', 'body_spintax']) { 205 if (newTemplate[field]) { 206 let text = newTemplate[field]; 207 text = replaceGreeting(text, targetCC); 208 text = collapseSpelling(text, targetCC); 209 text = normalizeRingCall(text, targetCC); 210 // Append country opt-out spintax to SMS body at generation time 211 if (field === 'body_spintax') { 212 text = appendOptOutSpintax(text, targetCC, newTemplate.channel); 213 } 214 newTemplate[field] = text; 215 } 216 } 217 218 return newTemplate; 219 } 220 221 // ─── English variant generation ─────────────────────────────────────────────── 222 223 function generateEnglishVariant(targetCC) { 224 const channels = ['email', 'sms']; 225 226 for (const channel of channels) { 227 const sourcePath = join(projectRoot, `data/templates/AU/${channel}.json`); 228 const targetDir = join(projectRoot, `data/templates/${targetCC}`); 229 const targetPath = join(targetDir, `${channel}.json`); 230 231 let sourceData; 232 try { 233 sourceData = JSON.parse(readFileSync(sourcePath, 'utf-8')); 234 } catch (_) { 235 console.log(` Skipping ${channel}: AU source not found`); 236 continue; 237 } 238 239 mkdirSync(targetDir, { recursive: true }); 240 241 const newTemplates = sourceData.templates.map(t => transformTemplateForCountry(t, targetCC)); 242 243 const output = { templates: newTemplates }; 244 writeFileSync(targetPath, `${JSON.stringify(output, null, 2)}\n`); 245 console.log(` ✓ Wrote ${targetPath} (${newTemplates.length} templates)`); 246 } 247 } 248 249 // ─── Translation ────────────────────────────────────────────────────────────── 250 251 async function translateTemplate(template, lang, targetCC, localGreeting) { 252 const langNames = { 253 hi: 'Hindi', 254 de: 'German', 255 fr: 'French', 256 it: 'Italian', 257 ja: 'Japanese', 258 ko: 'Korean', 259 es: 'Spanish (Latin American)', 260 nl: 'Dutch', 261 pl: 'Polish', 262 sv: 'Swedish', 263 da: 'Danish', 264 id: 'Indonesian', 265 }; 266 const langName = langNames[lang] || lang; 267 268 const fields = []; 269 if (template.subject_spintax) 270 fields.push({ name: 'subject_spintax', value: template.subject_spintax }); 271 if (template.body_spintax) fields.push({ name: 'body_spintax', value: template.body_spintax }); 272 273 const newTemplate = { ...template }; 274 newTemplate.id = template.id.replace(/_au$/, `_${targetCC.toLowerCase()}`); 275 newTemplate.country = targetCC; 276 newTemplate.language = lang; 277 newTemplate.sends = 0; 278 newTemplate.conversions = 0; 279 newTemplate.tested = false; 280 281 for (const field of fields) { 282 const prompt = `Translate this marketing email/SMS spintax template from English to ${langName}. 283 284 CRITICAL RULES: 285 1. Preserve the {option1|option2} spintax structure EXACTLY — same number of options, same positions 286 2. Keep ALL [placeholder] tokens unchanged: [firstname], [business_name], [domain], [grade], [score], [primary_weakness], [secondary_weakness], [impact], [keyword], [industry] 287 3. Keep \\n\\n paragraph breaks unchanged 288 4. The word STOP must remain in Latin characters (Twilio keyword) — translate surrounding text only 289 5. Use ${lang === 'hi' ? 'formal आप register' : lang === 'ja' ? 'polite です/ます form' : lang === 'ko' ? '합쇼체 (formal polite)' : lang === 'de' || lang === 'nl' ? 'informal du/jij register (NOT formal Sie/u) — casual, modern marketing tone' : 'polite but informal register'} 290 6. Greeting should use "${localGreeting}" (replace G'day and Hi/Hello/Hey greetings with this; if localGreeting itself contains {|} spintax, keep it as-is) 291 7. Adapt marketing idioms naturally — don't translate literally if a natural ${langName} equivalent exists 292 8. Return ONLY the translated spintax, no explanation, no self-corrections, no "let me redo" statements 293 9. NEVER add opt-out, unsubscribe, or STOP instructions to EMAIL templates — email unsubscribe is handled separately 294 10. BRACE BALANCE: Count { and } in the input — your output must have the exact same count. If the input starts with { your output MUST start with {. The pattern {{greeting} [firstname|there]{,|!}\\n\\n|} is an outer optional-greeting wrapper — preserve both {{ at the start exactly. IMPORTANT: In your output change [firstname|there] to [firstname|] (empty fallback — "there" is English and must not appear in non-English templates). 295 296 English ${field.name}: 297 ${field.value} 298 299 ${langName} translation:`; 300 301 console.log(` Translating ${field.name}...`); 302 const translated = await callClaude(prompt); 303 newTemplate[field.name] = translated; 304 } 305 306 // Append country-specific opt-out spintax to SMS body after translation. 307 // The optOutSpintax in requirements.json is already in the target language. 308 if (newTemplate.channel === 'sms' && newTemplate.body_spintax) { 309 newTemplate.body_spintax = appendOptOutSpintax(newTemplate.body_spintax, targetCC, 'sms'); 310 } 311 312 return newTemplate; 313 } 314 315 async function generateTranslation({ lang, country, targetPath }) { 316 const langDir = join(projectRoot, 'data/templates', targetPath); 317 mkdirSync(langDir, { recursive: true }); 318 319 const localGreeting = TRANSLATION_GREETINGS[lang] || ''; 320 const channels = ['email', 'sms']; 321 322 for (const channel of channels) { 323 const sourcePath = join(projectRoot, `data/templates/AU/${channel}.json`); 324 let sourceData; 325 try { 326 sourceData = JSON.parse(readFileSync(sourcePath, 'utf-8')); 327 } catch (_) { 328 console.log(` Skipping ${channel}: AU source not found`); 329 continue; 330 } 331 332 const translatedTemplates = []; 333 for (let i = 0; i < sourceData.templates.length; i++) { 334 const t = sourceData.templates[i]; 335 console.log(` [${i + 1}/${sourceData.templates.length}] ${t.id}`); 336 try { 337 const translated = await translateTemplate(t, lang, country, localGreeting); 338 // Safety-net: [firstname|there] must become [firstname|] in non-English templates 339 if (translated.body_spintax) 340 translated.body_spintax = translated.body_spintax.replace( 341 /\[firstname\|there\]/g, 342 '[firstname|]' 343 ); 344 if (translated.subject_spintax) 345 translated.subject_spintax = translated.subject_spintax.replace( 346 /\[firstname\|there\]/g, 347 '[firstname|]' 348 ); 349 translatedTemplates.push(translated); 350 } catch (err) { 351 console.error(` ✗ Failed to translate ${t.id}: ${err.message}`); 352 // Include original with error flag 353 translatedTemplates.push({ ...t, translation_error: err.message }); 354 } 355 } 356 357 const outputPath = join(langDir, `${channel}.json`); 358 writeFileSync(outputPath, `${JSON.stringify({ templates: translatedTemplates }, null, 2)}\n`); 359 console.log(` ✓ Wrote ${outputPath} (${translatedTemplates.length} templates)`); 360 } 361 } 362 363 // ─── Grammar check after generation ────────────────────────────────────────── 364 365 async function runGrammarCheck(templatePath, lang, autoFix = true) { 366 const { execSync } = await import('child_process'); 367 const relPath = templatePath.replace(`${projectRoot}/`, ''); 368 const args = `--file ${relPath} --lang ${lang}${autoFix ? ' --auto-fix' : ''}`; 369 try { 370 const output = execSync(`node scripts/spintax-grammar-check.js ${args}`, { 371 cwd: projectRoot, 372 encoding: 'utf-8', 373 timeout: 300000, 374 }); 375 const summaryLine = output.match(/Total issues found: (\d+)/); 376 const fixedLine = output.match(/Total fixes applied: (\d+)/); 377 if (summaryLine) { 378 console.log(` Grammar: ${summaryLine[1]} issues, ${fixedLine?.[1] || 0} fixed`); 379 } 380 } catch (err) { 381 console.error(` Grammar check failed: ${err.message.slice(0, 100)}`); 382 } 383 } 384 385 // ─── Main ───────────────────────────────────────────────────────────────────── 386 387 const args = process.argv.slice(2); 388 389 async function main() { 390 const targetIdx = args.indexOf('--target'); 391 const translateIdx = args.indexOf('--translate'); 392 const isAll = args.includes('--all'); 393 const skipGrammar = args.includes('--skip-grammar'); 394 395 if (!targetIdx && targetIdx !== 0 && !translateIdx && translateIdx !== 0 && !isAll) { 396 console.log(`Usage: 397 node scripts/create-locale-templates.js --target <CC> # English variant (US, NZ, GB, CA, IE, ZA, IN) 398 node scripts/create-locale-templates.js --translate <lang> # Translation (de, fr, it, ja, ko, es, nl, pl, sv, da, hi, id) 399 node scripts/create-locale-templates.js --all # All countries and translations 400 node scripts/create-locale-templates.js --target US --skip-grammar # Skip grammar check`); 401 process.exit(0); 402 } 403 404 if (isAll) { 405 // Part 3: All English country variants 406 console.log('\n=== Generating English country variants ==='); 407 for (const cc of ENGLISH_TARGETS) { 408 console.log(`\n→ ${cc}`); 409 generateEnglishVariant(cc); 410 411 if (!skipGrammar) { 412 for (const channel of ['email', 'sms']) { 413 const path = join(projectRoot, `data/templates/${cc}/${channel}.json`); 414 await runGrammarCheck(path, 'en'); 415 } 416 } 417 } 418 419 // Part 4-6: All translations 420 console.log('\n=== Generating translations ==='); 421 for (const target of TRANSLATION_TARGETS) { 422 console.log(`\n→ ${target.lang} (${target.country}) → ${target.targetPath}`); 423 await generateTranslation(target); 424 425 if (!skipGrammar) { 426 for (const channel of ['email', 'sms']) { 427 const path = join(projectRoot, `data/templates/${target.targetPath}/${channel}.json`); 428 await runGrammarCheck(path, target.lang); 429 } 430 } 431 } 432 433 console.log('\n✓ All templates generated.'); 434 return; 435 } 436 437 if (targetIdx !== -1) { 438 const cc = args[targetIdx + 1]?.toUpperCase(); 439 if (!cc) { 440 console.error('--target requires a country code'); 441 process.exit(1); 442 } 443 console.log(`\n→ Generating English variant for ${cc}`); 444 generateEnglishVariant(cc); 445 446 if (!skipGrammar) { 447 for (const channel of ['email', 'sms']) { 448 const path = join(projectRoot, `data/templates/${cc}/${channel}.json`); 449 await runGrammarCheck(path, 'en'); 450 } 451 } 452 return; 453 } 454 455 if (translateIdx !== -1) { 456 const lang = args[translateIdx + 1]?.toLowerCase(); 457 if (!lang) { 458 console.error('--translate requires a language code'); 459 process.exit(1); 460 } 461 const target = TRANSLATION_TARGETS.find(t => t.lang === lang); 462 if (!target) { 463 console.error( 464 `Unknown language: ${lang}. Supported: ${TRANSLATION_TARGETS.map(t => t.lang).join(', ')}` 465 ); 466 process.exit(1); 467 } 468 console.log(`\n→ Translating to ${lang} (${target.country}) → ${target.targetPath}`); 469 await generateTranslation(target); 470 471 if (!skipGrammar) { 472 for (const channel of ['email', 'sms']) { 473 const path = join(projectRoot, `data/templates/${target.targetPath}/${channel}.json`); 474 await runGrammarCheck(path, lang); 475 } 476 } 477 return; 478 } 479 } 480 481 main().catch(err => { 482 console.error('Error:', err.message); 483 process.exit(1); 484 });