/ scripts / create-locale-templates.js
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  });