/* ====================================================== i18n — live whole-site translation (PT · EN · ES) ------------------------------------------------------ PT = original authored content, always restored verbatim. EN = full translation to English. ES = translation to Spanish, but ANY content already authored in English (section titles, brand terms, etc.) stays English. Translation runs through window.claude.complete and is cached in localStorage so a language only needs to be translated once. ====================================================== */ (function () { let current = 'pt'; let busy = false; let snapshot = null; // { nodes:[{node,pt}], attrs:[{el,attr,pt}] } const listeners = new Set(); const notify = () => listeners.forEach(fn => fn({ lang: current, busy })); const hasLetters = (s) => /[A-Za-zÀ-ÖØ-öø-ÿ]/.test(s); function skip(node) { const el = node.parentElement; if (!el) return true; if (el.closest('[data-no-i18n]')) return true; const tag = el.tagName; return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT'; } function buildSnapshot() { const root = document.getElementById('root') || document.body; const nodes = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n) { const v = n.nodeValue; if (!v || !v.trim() || !hasLetters(v)) return NodeFilter.FILTER_REJECT; if (skip(n)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); let n; while ((n = walker.nextNode())) nodes.push({ node: n, pt: n.nodeValue }); const attrs = []; root.querySelectorAll('[placeholder]').forEach(el => { if (el.closest('[data-no-i18n]')) return; const v = el.getAttribute('placeholder'); if (v && v.trim() && hasLetters(v)) attrs.push({ el, attr: 'placeholder', pt: v }); }); return { nodes, attrs }; } function restore() { if (!snapshot) return; snapshot.nodes.forEach(({ node, pt }) => { node.nodeValue = pt; }); snapshot.attrs.forEach(({ el, attr, pt }) => { el.setAttribute(attr, pt); }); } const cacheKey = (lang) => 'morse_i18n_' + lang; const loadCache = (lang) => { try { return JSON.parse(localStorage.getItem(cacheKey(lang)) || '{}'); } catch (e) { return {}; } }; const saveCache = (lang, obj) => { try { localStorage.setItem(cacheKey(lang), JSON.stringify(obj)); } catch (e) {} }; async function translateBatch(strings, lang) { if (!window.claude || !window.claude.complete) throw new Error('no-claude'); const rules = lang === 'es' ? 'Translate from Brazilian Portuguese into neutral Latin-American Spanish. CRITICAL: if an item is ALREADY in English (brand names, proper nouns, or title/section terms such as "Strategy", "Brand Design", "Digital Experience", "Content & Social Systems", "Coming soon", "Accepting Projects", "Studio", "Capabilities", "Work", "Founder", "Contact"), keep it EXACTLY as-is in English — never translate English items into Spanish.' : 'Translate from Brazilian Portuguese into natural, idiomatic English. If an item is already in English, return it unchanged.'; const prompt = 'You are a professional website localizer for an independent design studio. ' + rules + '\n' + 'Preserve capitalization style, punctuation, line breaks, symbols and emojis exactly. ' + 'Keep proper nouns / brand names unchanged: Morse, Rancho Thands, Empório da Terra, Black Fish Brasil, Engenho de Ideias, Brazil, Brasil, Substack, LinkedIn, Instagram, WhatsApp.\n' + 'Return ONLY a JSON array of strings — the translations, in the SAME ORDER and SAME COUNT as the input array. No commentary, no keys.\n' + 'Input: ' + JSON.stringify(strings); const raw = await window.claude.complete(prompt); let txt = String(raw).trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim(); const a = txt.indexOf('['), b = txt.lastIndexOf(']'); if (a >= 0 && b >= 0) txt = txt.slice(a, b + 1); const arr = JSON.parse(txt); if (!Array.isArray(arr) || arr.length !== strings.length) throw new Error('bad-shape'); return arr; } async function ensureTranslations(lang) { const cache = loadCache(lang); const uniq = [], seen = new Set(); const collect = (s) => { const k = s.trim(); if (k && !seen.has(k) && !(k in cache)) { seen.add(k); uniq.push(k); } }; snapshot.nodes.forEach(({ pt }) => collect(pt)); snapshot.attrs.forEach(({ pt }) => collect(pt)); const CHUNK = 24; for (let i = 0; i < uniq.length; i += CHUNK) { const batch = uniq.slice(i, i + CHUNK); const out = await translateBatch(batch, lang); batch.forEach((s, idx) => { cache[s] = out[idx]; }); saveCache(lang, cache); } return cache; } function applyLang(cache) { const tr = (pt) => { const k = pt.trim(); const t = cache[k]; if (t == null) return pt; const lead = (pt.match(/^\s*/) || [''])[0]; const trail = (pt.match(/\s*$/) || [''])[0]; return lead + t + trail; }; snapshot.nodes.forEach(({ node, pt }) => { node.nodeValue = tr(pt); }); snapshot.attrs.forEach(({ el, attr, pt }) => { el.setAttribute(attr, cache[pt.trim()] || pt); }); } async function setLang(lang) { if (busy || lang === current) return; if (!snapshot) snapshot = buildSnapshot(); if (lang === 'pt') { restore(); current = 'pt'; document.documentElement.lang = 'pt-BR'; try { localStorage.setItem('morse_lang', 'pt'); } catch (e) {} notify(); return; } busy = true; notify(); try { restore(); // always translate from the PT baseline const cache = await ensureTranslations(lang); applyLang(cache); current = lang; document.documentElement.lang = lang === 'en' ? 'en' : 'es'; try { localStorage.setItem('morse_lang', lang); } catch (e) {} } catch (e) { console.warn('[i18n] translation failed:', e && e.message); restore(); current = 'pt'; } finally { busy = false; notify(); } } window.__i18n = { setLang, get lang() { return current; }, get busy() { return busy; }, subscribe(fn) { listeners.add(fn); fn({ lang: current, busy }); return () => listeners.delete(fn); }, rebuild() { snapshot = null; } }; // Restore the visitor's last language after the app has mounted. function boot() { let saved = 'pt'; try { saved = localStorage.getItem('morse_lang') || 'pt'; } catch (e) {} if (saved && saved !== 'pt') setTimeout(() => setLang(saved), 500); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 700)); else setTimeout(boot, 700); })();