replace deepl with llm translation via openrouter

- translateText/translateTexts call OpenRouter chat-completions; glossary
  terms are prompt-injected from config/glossaries.ts, generated from the
  translation repo's TSVs by scripts/build-glossaries.ts
- model resolution: per-language override in config/languages.ts →
  translation.model site setting (new admin field) → TRANSLATION_MODEL env
  → anthropic/claude-opus-5, at low reasoning effort
- blank fragments are held back from the model, which drops them and breaks
  the one-translation-per-fragment count the caller maps results back on
- OpenRouter failures map to a typed error with a human-readable message;
  an unrecoverable one (no credits, bad key, rejected model) stops the run
  instead of repeating the same call for every remaining locale
- machine translations record source 'auto-translate'; 'deepl' stays
  readable for snapshots written before the switch
This commit is contained in:
corsacca 2026-09-07 16:30:48 +01:00
parent 050708084b
commit 7dfad0ee07
17 changed files with 1400 additions and 216 deletions

View file

@ -47,10 +47,12 @@ NUXT_PUBLIC_STATINATOR_ENABLED=true
# Set to empty in dev to make the cookie host-only.
NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life
# DeepL (used by the CMS "Translate from English" workflow on /admin/pages/:id)
# Pro endpoint: https://api.deepl.com | Free-tier endpoint: https://api-free.deepl.com
DEEPL_API_KEY=your-deepl-api-key
DEEPL_API_URL=https://api.deepl.com
# OpenRouter (used by the CMS "Auto-translate" workflow on /admin/pages/:id
# and the translate_text / translate_page MCP tools)
OPENROUTER_API_KEY=your-openrouter-api-key
# Optional model override. Admins can also set it at /admin/settings, which wins
# over this. Defaults to anthropic/claude-opus-5 when both are unset.
TRANSLATION_MODEL=
# Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
# On container boot the edge cache is purged so stale prerendered HTML can't

View file

@ -89,7 +89,7 @@ const editor = useEditor({
}
})
// External updates (DeepL translation refresh, locale tab switch) —
// External updates (auto-translation refresh, locale tab switch) —
// replace editor content when the prop drifts from the editor state.
watch(
() => props.modelValue,

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
// Admin: CMS page editor. Per-locale tabs, Tiptap editor, featured
// image + OG image uploaders, meta_title / meta_description, and
// Save / Publish / Unpublish actions. A DeepL button fan-outs English
// into the other enabled locales.
// Save / Publish / Unpublish actions. An auto-translate button fan-outs
// English into the other enabled locales.
definePageMeta({
layout: 'admin',
@ -325,7 +325,7 @@ async function deletePage() {
}
}
// DeepL translate modal
// Auto-translate modal
const translateModalOpen = ref(false)
const translateSource = ref('en')
const translateTargets = ref<string[]>([])
@ -397,7 +397,7 @@ interface VersionSummary {
id: string
created: string
status: 'draft' | 'published'
source: 'admin-ui' | 'mcp' | 'deepl'
source: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
title: string
created_by: { id: string; name: string } | null
}
@ -495,6 +495,7 @@ function loadVersionIntoEditor() {
const SOURCE_LABELS: Record<string, string> = {
'admin-ui': 'Admin',
mcp: 'MCP',
'auto-translate': 'Auto-translate',
deepl: 'DeepL'
}
@ -879,7 +880,7 @@ function formatVersionTime(iso: string): string {
</template>
</USlideover>
<UModal v-model:open="translateModalOpen" title="Translate with DeepL">
<UModal v-model:open="translateModalOpen" title="Auto-translate">
<template #body>
<div class="space-y-3">
<UFormField label="Source locale">

View file

@ -6,6 +6,7 @@ definePageMeta({
interface SettingsResponse {
'auth.public_registration_enabled'?: boolean
'translation.model'?: string
}
const { hasPermission } = usePermissions()
@ -31,6 +32,42 @@ const registrationEnabled = computed({
const saving = ref(false)
// Empty means "no override" — the server falls back to TRANSLATION_MODEL and
// then the code default, so the placeholder shows what an empty box resolves to.
const DEFAULT_TRANSLATION_MODEL = 'anthropic/claude-opus-5'
const translationModel = ref('')
const savingModel = ref(false)
watch(data, (val) => {
translationModel.value = val?.['translation.model'] ?? ''
}, { immediate: true })
const handleSaveModel = async () => {
if (!canEdit.value || savingModel.value) return
const next = translationModel.value.trim()
savingModel.value = true
try {
await $fetch('/api/admin/settings/translation.model', {
method: 'PATCH',
body: { value: next }
})
translationModel.value = next
data.value = { ...(data.value ?? {}), 'translation.model': next }
toast.add({
title: next ? `Translation model set to ${next}` : 'Translation model reset to the default',
color: 'success'
})
} catch (err: any) {
toast.add({
title: 'Update failed',
description: err?.data?.statusMessage || err?.message || 'Failed to update setting',
color: 'error'
})
} finally {
savingModel.value = false
}
}
const handleToggleRegistration = async (next: boolean) => {
if (!canEdit.value || saving.value) return
const previous = registrationEnabled.value
@ -102,6 +139,40 @@ const handleToggleRegistration = async (next: boolean) => {
</div>
</UCard>
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-languages" class="size-4 text-(--ui-text-muted)" />
<h2 class="text-sm font-semibold uppercase tracking-wide text-(--ui-text-muted)">Translation</h2>
</div>
</template>
<div>
<h3 class="font-medium">OpenRouter model</h3>
<p class="text-sm text-(--ui-text-muted) mt-1 mb-3">
Model used to auto-translate CMS pages. Any
<ULink to="https://openrouter.ai/models" target="_blank" class="underline">OpenRouter model id</ULink>
works — leave it blank to use the default.
</p>
<div class="flex items-center gap-2">
<UInput
v-model="translationModel"
:placeholder="DEFAULT_TRANSLATION_MODEL"
:disabled="!canEdit || savingModel"
class="flex-1 font-mono text-xs"
@keyup.enter="handleSaveModel"
/>
<UButton
color="primary"
variant="outline"
:disabled="!canEdit || translationModel.trim() === (data?.['translation.model'] ?? '')"
:loading="savingModel"
@click="handleSaveModel"
>Save</UButton>
</div>
</div>
</UCard>
<p v-if="canView && !canEdit" class="text-xs text-(--ui-text-muted)">
You can view settings but not change them. Contact an administrator if you need to make changes.
</p>

882
config/glossaries.ts Normal file
View file

@ -0,0 +1,882 @@
// Generated by scripts/build-glossaries.ts from ../translation/deepl-glossaries/*.tsv
// Do not edit by hand — update the TSVs in the translation repo and re-run:
// bun scripts/build-glossaries.ts
/** English → target-language term pairs, keyed by language code. */
export const GLOSSARIES: Record<string, ReadonlyArray<readonly [string, string]>> = {
ar: [
["people group", "جماعة عرقية"],
["people groups", "جماعات عرقية"],
["unreached people group", "جماعة عرقية غير مُبلَّغة"],
["unreached people groups", "جماعات عرقية غير مُبلَّغة"],
["unengaged people group", "جماعة عرقية غير مُنخرَطة"],
["unengaged people groups", "جماعات عرقية غير مُنخرَطة"],
["unengaged unreached people group", "جماعة عرقية غير مُبلَّغة وغير مُنخرَطة"],
["under-engaged", "ناقصة الانخراط"],
["frontier people group", "جماعة عرقية حدودية"],
["frontier people groups", "جماعات عرقية حدودية"],
["ethnolinguistic group", "جماعة إثنولغوية"],
["unreached", "غير مُبلَّغ"],
["unengaged", "غير مُنخرَط"],
["engaged", "مُنخرَط"],
["engagement", "انخراط"],
["fruitful engagement", "انخراط مثمر"],
["gospel access", "الوصول إلى الإنجيل"],
["gospel engagement", "الانخراط بالإنجيل"],
["the gospel", "الإنجيل"],
["church-planting movement", "حركة تأسيس كنائس"],
["church planting", "تأسيس كنائس"],
["indigenous church", "كنيسة محلّية"],
["indigenous churches", "كنائس محلّية"],
["self-propagating", "ذاتي الانتشار"],
["self-governing", "ذاتي الحكم"],
["self-supporting", "ذاتي التمويل"],
["self-theologizing", "ذاتي اللاهوت"],
["self-missionizing", "ذاتي الإرسال"],
["residential engagement", "انخراط إقامي"],
["workers in residence", "عاملون مقيمون"],
["cross-cultural workers", "عاملون عابرون للثقافات"],
["apostolic effort", "جهد رسولي"],
["pioneer work", "عمل ريادي"],
["heart language", "اللغة الأم"],
["movement to Jesus", "حركة نحو يسوع"],
["nonresident partners", "شركاء غير مقيمين"],
["broader engagement", "انخراط أوسع"],
["diaspora", "شتات"],
["daily prayer", "صلاة يومية"],
["24-hour prayer coverage", "تغطية صلاة على مدار 24 ساعة"],
["intercessor", "شفيع"],
["intercessors", "شفعاء"],
["intercessory prayer", "صلاة شفاعية"],
["prayer points", "نقاط صلاة"],
["daily prayer guide", "دليل الصلاة اليومي"],
["prayer prepares the way", "الصلاة تُمهِّد الطريق"],
["Lord of the harvest", "ربّ الحصاد"],
["adopt", "تبنّي"],
["pray, give, and send", "صلّوا وقدِّموا وأرسِلوا"],
["mobilize", "تعبئة"],
["mobilization", "تعبئة"],
["sending agency", "وكالة إرسال"],
["sending agencies", "وكالات إرسال"],
["our gift to Jesus", "تقدمتنا ليسوع"],
["DOXA partnership missionaries", "مُرسَلون شركاء في DOXA"],
["every tribe, tongue, people, and nation", "كلّ قبيلة ولسان وشعب وأمّة"],
["make disciples of all the nations", "تلمِذوا جميع الأمم"],
["disciple", "تلميذ"],
["disciples", "تلاميذ"],
["baptized disciples", "تلاميذ مُعمَّدون"],
["culturally appropriate", "ملائم ثقافيًّا"],
["self-sustaining churches", "كنائس مكتفية ذاتيًّا"],
["spiritual generations", "أجيال روحية"],
["abide in Jesus", "اثبُتوا في يسوع"],
["pray without ceasing", "صلّوا بلا انقطاع"],
["deny yourself", "أنكِروا أنفسكم"],
["engage the regions beyond", "ابلُغوا المناطق الأبعد"],
["Pentecostal", "خمسيني"],
["Statement of Faith", "بيان الإيمان"],
["new birth", "الولادة الجديدة"],
["regeneration", "التجديد"],
["justification", "التبرير"],
["atonement", "الكفّارة"],
["sanctification", "التقديس"],
["baptism in water", "المعمودية بالماء"],
["Lord's Supper", "العشاء الربّاني"],
["baptism in the Holy Spirit", "المعمودية بالروح القدس"],
["speaking in tongues", "التكلّم بألسنة"],
["gifts of the Holy Spirit", "مواهب الروح القدس"],
["pre-millennial return", "الرجوع قبل الألفية"],
["bodily resurrection", "القيامة الجسدية"],
["pray", "صلّوا"],
["engage", "انخرِطوا"],
["choose a people group", "اختاروا جماعة عرقية"],
["sign up", "التسجيل"]
],
de: [
["people group", "Volksgruppe"],
["people groups", "Volksgruppen"],
["unreached people group", "unerreichte Volksgruppe"],
["unreached people groups", "unerreichte Volksgruppen"],
["unengaged people group", "nicht-engagierte Volksgruppe"],
["unengaged people groups", "nicht-engagierte Volksgruppen"],
["unengaged unreached people group", "unerreichte und nicht-engagierte Volksgruppe"],
["under-engaged", "unterengagiert"],
["frontier people group", "Frontier-Volksgruppe"],
["frontier people groups", "Frontier-Volksgruppen"],
["ethnolinguistic group", "ethnolinguistische Gruppe"],
["unreached", "unerreicht"],
["unengaged", "nicht-engagiert"],
["engaged", "engagiert"],
["engagement", "Engagement"],
["fruitful engagement", "fruchtbares Engagement"],
["gospel access", "Zugang zum Evangelium"],
["gospel engagement", "Engagement für das Evangelium"],
["the gospel", "das Evangelium"],
["church-planting movement", "Gemeindegründungsbewegung"],
["church planting", "Gemeindegründung"],
["indigenous church", "einheimische Gemeinde"],
["indigenous churches", "einheimische Gemeinden"],
["self-propagating", "sich selbst ausbreitend"],
["self-governing", "selbstverwaltend"],
["self-supporting", "sich selbst finanzierend"],
["self-theologizing", "selbsttheologisierend"],
["self-missionizing", "selbstmissionierend"],
["residential engagement", "Engagement vor Ort"],
["workers in residence", "Mitarbeiter vor Ort"],
["cross-cultural workers", "interkulturelle Mitarbeiter"],
["apostolic effort", "apostolischer Einsatz"],
["pioneer work", "Pionierarbeit"],
["heart language", "Muttersprache"],
["movement to Jesus", "Bewegung zu Jesus"],
["nonresident partners", "nicht-residente Partner"],
["broader engagement", "erweitertes Engagement"],
["diaspora", "Diaspora"],
["daily prayer", "tägliches Gebet"],
["24-hour prayer coverage", "24-Stunden-Gebetsabdeckung"],
["intercessor", "Fürbitter"],
["intercessors", "Fürbitter"],
["intercessory prayer", "Fürbitte"],
["prayer points", "Gebetsanliegen"],
["daily prayer guide", "täglicher Gebetsleitfaden"],
["prayer prepares the way", "Gebet bereitet den Weg"],
["Lord of the harvest", "Herr der Ernte"],
["adopt", "adoptieren"],
["pray, give, and send", "beten, geben und senden"],
["mobilize", "mobilisieren"],
["mobilization", "Mobilisierung"],
["sending agency", "Entsendeorganisation"],
["sending agencies", "Entsendeorganisationen"],
["our gift to Jesus", "unsere Gabe an Jesus"],
["DOXA partnership missionaries", "DOXA-Partnerschaftsmissionare"],
["every tribe, tongue, people, and nation", "jeder Stamm, jede Sprache, jedes Volk und jede Nation"],
["make disciples of all the nations", "macht zu Jüngern alle Völker"],
["disciple", "Jünger"],
["disciples", "Jünger"],
["baptized disciples", "getaufte Jünger"],
["culturally appropriate", "kulturell angemessen"],
["self-sustaining churches", "eigenständige Gemeinden"],
["spiritual generations", "geistliche Generationen"],
["abide in Jesus", "bleibt in Jesus"],
["pray without ceasing", "betet ohne Unterlass"],
["deny yourself", "verleugnet euch selbst"],
["engage the regions beyond", "erreicht die Gebiete darüber hinaus"],
["Pentecostal", "pfingstlich"],
["Statement of Faith", "Glaubensbekenntnis"],
["new birth", "Wiedergeburt"],
["regeneration", "Erneuerung"],
["justification", "Rechtfertigung"],
["atonement", "Sühne"],
["sanctification", "Heiligung"],
["baptism in water", "Wassertaufe"],
["Lord's Supper", "Abendmahl"],
["baptism in the Holy Spirit", "Taufe im Heiligen Geist"],
["speaking in tongues", "Zungenreden"],
["gifts of the Holy Spirit", "Gaben des Heiligen Geistes"],
["pre-millennial return", "prämillenniale Wiederkunft"],
["bodily resurrection", "leibliche Auferstehung"],
["pray", "beten"],
["engage", "engagieren"],
["choose a people group", "eine Volksgruppe wählen"],
["sign up", "anmelden"]
],
es: [
["people group", "grupo étnico"],
["people groups", "grupos étnicos"],
["unreached people group", "grupo étnico no alcanzado"],
["unreached people groups", "grupos étnicos no alcanzados"],
["unengaged people group", "grupo étnico no comprometido"],
["unengaged people groups", "grupos étnicos no comprometidos"],
["unengaged unreached people group", "grupo étnico no alcanzado y no comprometido"],
["under-engaged", "subcomprometido"],
["frontier people group", "grupo étnico frontera"],
["frontier people groups", "grupos étnicos frontera"],
["ethnolinguistic group", "grupo etnolingüístico"],
["unreached", "no alcanzado"],
["unengaged", "no comprometido"],
["engaged", "comprometido"],
["engagement", "compromiso"],
["fruitful engagement", "compromiso fructífero"],
["gospel access", "acceso al Evangelio"],
["gospel engagement", "compromiso con el Evangelio"],
["the gospel", "el Evangelio"],
["church-planting movement", "movimiento de plantación de iglesias"],
["church planting", "plantación de iglesias"],
["indigenous church", "iglesia autóctona"],
["indigenous churches", "iglesias autóctonas"],
["self-propagating", "autopropagador"],
["self-governing", "autogobernado"],
["self-supporting", "autofinanciado"],
["self-theologizing", "autoteologizante"],
["self-missionizing", "automisionero"],
["residential engagement", "compromiso residencial"],
["workers in residence", "obreros residentes"],
["cross-cultural workers", "obreros transculturales"],
["apostolic effort", "esfuerzo apostólico"],
["pioneer work", "trabajo pionero"],
["heart language", "lengua materna"],
["movement to Jesus", "movimiento hacia Jesús"],
["nonresident partners", "socios no residentes"],
["broader engagement", "compromiso ampliado"],
["diaspora", "diáspora"],
["daily prayer", "oración diaria"],
["24-hour prayer coverage", "cobertura de oración de 24 horas"],
["intercessor", "intercesor"],
["intercessors", "intercesores"],
["intercessory prayer", "oración de intercesión"],
["prayer points", "motivos de oración"],
["daily prayer guide", "guía de oración diaria"],
["prayer prepares the way", "la oración prepara el camino"],
["Lord of the harvest", "Señor de la cosecha"],
["adopt", "adoptar"],
["pray, give, and send", "orar, dar y enviar"],
["mobilize", "movilizar"],
["mobilization", "movilización"],
["sending agency", "agencia de envío"],
["sending agencies", "agencias de envío"],
["our gift to Jesus", "nuestra ofrenda a Jesús"],
["DOXA partnership missionaries", "misioneros asociados de DOXA"],
["every tribe, tongue, people, and nation", "toda tribu, lengua, pueblo y nación"],
["make disciples of all the nations", "hagan discípulos de todas las naciones"],
["disciple", "discípulo"],
["disciples", "discípulos"],
["baptized disciples", "discípulos bautizados"],
["culturally appropriate", "culturalmente apropiado"],
["self-sustaining churches", "iglesias autosostenibles"],
["spiritual generations", "generaciones espirituales"],
["abide in Jesus", "permanezcan en Jesús"],
["pray without ceasing", "oren sin cesar"],
["deny yourself", "niéguense a sí mismos"],
["engage the regions beyond", "alcanzar las regiones de más allá"],
["Pentecostal", "pentecostal"],
["Statement of Faith", "Declaración de fe"],
["new birth", "nuevo nacimiento"],
["regeneration", "regeneración"],
["justification", "justificación"],
["atonement", "expiación"],
["sanctification", "santificación"],
["baptism in water", "bautismo en agua"],
["Lord's Supper", "Santa Cena"],
["baptism in the Holy Spirit", "bautismo en el Espíritu Santo"],
["speaking in tongues", "hablar en lenguas"],
["gifts of the Holy Spirit", "dones del Espíritu Santo"],
["pre-millennial return", "retorno premilenial"],
["bodily resurrection", "resurrección corporal"],
["pray", "orar"],
["engage", "comprometer"],
["UUPG", "PNANC"],
["World Assemblies of God Fellowship", "Fraternidad Mundial de las Asambleas de Dios"],
["choose a people group", "elegir un grupo étnico"],
["sign up", "registrarse"]
],
fr: [
["people group", "peuple"],
["people groups", "peuples"],
["unreached people group", "peuple non atteint"],
["unreached people groups", "peuples non atteints"],
["unengaged people group", "peuple non engagé"],
["unengaged people groups", "peuples non engagés"],
["unengaged unreached people group", "peuple non atteint et non engagé"],
["under-engaged", "sous-engagé"],
["frontier people group", "peuple frontière"],
["frontier people groups", "peuples frontières"],
["frontier peoples", "peuples frontières"],
["ethnolinguistic group", "groupe ethnolinguistique"],
["unreached", "non atteint"],
["unengaged", "non engagé"],
["engaged", "engagé"],
["engagement", "engagement"],
["fruitful engagement", "engagement fructueux"],
["gospel access", "accès à l'Évangile"],
["gospel engagement", "engagement pour l'Évangile"],
["the gospel", "l'Évangile"],
["church-planting movement", "mouvement d'implantation d'églises"],
["church planting", "implantation d'églises"],
["indigenous church", "église autochtone"],
["indigenous churches", "églises autochtones"],
["self-propagating", "auto-propagateur"],
["self-governing", "auto-gouverné"],
["self-supporting", "auto-financé"],
["self-theologizing", "auto-théologisant"],
["self-missionizing", "auto-missionnaire"],
["residential engagement", "engagement sur le terrain"],
["workers in residence", "ouvriers résidents"],
["cross-cultural workers", "ouvriers transculturels"],
["apostolic effort", "effort pionnier"],
["pioneer work", "travail de pionnier"],
["heart language", "langue du cœur"],
["movement to Jesus", "mouvement vers Jésus"],
["nonresident partners", "partenaires extérieurs"],
["broader engagement", "engagement élargi"],
["diaspora", "diaspora"],
["daily prayer", "prière quotidienne"],
["24-hour prayer coverage", "couverture de prière 24h/24"],
["intercessor", "intercesseur"],
["intercessors", "intercesseurs"],
["intercessory prayer", "prière d'intercession"],
["prayer points", "sujets de prière"],
["daily prayer guide", "guide de prière quotidien"],
["prayer prepares the way", "la prière prépare le chemin"],
["Lord of the harvest", "Maître de la moisson"],
["adopt", "adopter"],
["pray, give, and send", "prier, donner et envoyer"],
["mobilize", "mobiliser"],
["mobilization", "mobilisation"],
["sending agency", "agence missionnaire"],
["sending agencies", "agences missionnaires"],
["our gift to Jesus", "notre offrande à Jésus"],
["DOXA partnership missionaries", "missionnaires en partenariat DOXA"],
["every tribe, tongue, people, and nation", "toute tribu, toute langue, tout peuple et toute nation"],
["make disciples of all the nations", "faites de toutes les nations des disciples"],
["disciple", "disciple"],
["disciples", "disciples"],
["baptized disciples", "disciples baptisés"],
["culturally appropriate", "culturellement approprié"],
["self-sustaining churches", "églises autonomes"],
["spiritual generations", "générations spirituelles"],
["abide in Jesus", "demeurez en Jésus"],
["pray without ceasing", "priez sans cesse"],
["deny yourself", "renoncez à vous-même"],
["engage the regions beyond", "portez l'Évangile au-delà"],
["Pentecostal", "pentecôtiste"],
["Statement of Faith", "Déclaration de foi"],
["new birth", "nouvelle naissance"],
["regeneration", "régénération"],
["justification", "justification"],
["atonement", "expiation"],
["sanctification", "sanctification"],
["baptism in water", "baptême d'eau"],
["Lord's Supper", "Sainte Cène"],
["baptism in the Holy Spirit", "baptême du Saint-Esprit"],
["speaking in tongues", "parler en langues"],
["gifts of the Holy Spirit", "dons du Saint-Esprit"],
["pre-millennial return", "retour prémillénaire"],
["bodily resurrection", "résurrection corporelle"],
["pray", "prier"],
["engage", "engager"],
["World Assemblies of God Fellowship", "Association mondiale des Assemblées de Dieu"],
["choose a people group", "choisir un peuple"],
["sign up", "s'inscrire"]
],
hi: [
["people group", "जातीय समूह"],
["people groups", "जातीय समूह"],
["unreached people group", "अगम्य जातीय समूह"],
["unreached people groups", "अगम्य जातीय समूह"],
["unengaged people group", "अप्रवेशित जातीय समूह"],
["unengaged people groups", "अप्रवेशित जातीय समूह"],
["unengaged unreached people group", "अगम्य और अप्रवेशित जातीय समूह"],
["under-engaged", "अल्प-प्रवेशित"],
["frontier people group", "सीमांत जातीय समूह"],
["frontier people groups", "सीमांत जातीय समूह"],
["ethnolinguistic group", "भाषाई-जातीय समूह"],
["unreached", "अगम्य"],
["unengaged", "अप्रवेशित"],
["engaged", "प्रवेशित"],
["engagement", "प्रवेश"],
["fruitful engagement", "फलदायक प्रवेश"],
["gospel access", "सुसमाचार तक पहुँच"],
["gospel engagement", "सुसमाचार प्रवेश"],
["the gospel", "सुसमाचार"],
["church-planting movement", "कलीसिया रोपण आंदोलन"],
["church planting", "कलीसिया रोपण"],
["indigenous church", "देशज कलीसिया"],
["indigenous churches", "देशज कलीसियाएँ"],
["self-propagating", "स्व-प्रचारक"],
["self-governing", "स्व-शासित"],
["self-supporting", "स्व-पोषित"],
["self-theologizing", "स्व-धर्मशास्त्रीय"],
["self-missionizing", "स्व-मिशनकारी"],
["residential engagement", "आवासीय प्रवेश"],
["workers in residence", "निवासी कार्यकर्ता"],
["cross-cultural workers", "अंतर-सांस्कृतिक कार्यकर्ता"],
["apostolic effort", "प्रेरितिक प्रयास"],
["pioneer work", "अग्रणी कार्य"],
["heart language", "मातृभाषा"],
["movement to Jesus", "यीशु की ओर आंदोलन"],
["nonresident partners", "अनिवासी सहभागी"],
["broader engagement", "व्यापक प्रवेश"],
["diaspora", "प्रवासी समुदाय"],
["daily prayer", "दैनिक प्रार्थना"],
["24-hour prayer coverage", "24 घंटे की प्रार्थना कवरेज"],
["intercessor", "मध्यस्थ प्रार्थनाकर्ता"],
["intercessors", "मध्यस्थ प्रार्थनाकर्ता"],
["intercessory prayer", "मध्यस्थ प्रार्थना"],
["prayer points", "प्रार्थना विषय"],
["daily prayer guide", "दैनिक प्रार्थना मार्गदर्शिका"],
["prayer prepares the way", "प्रार्थना मार्ग तैयार करती है"],
["Lord of the harvest", "कटनी का स्वामी"],
["adopt", "अपनाना"],
["pray, give, and send", "प्रार्थना करो, दो और भेजो"],
["mobilize", "गतिशील करना"],
["mobilization", "गतिशीलता"],
["sending agency", "भेजने वाली संस्था"],
["sending agencies", "भेजने वाली संस्थाएँ"],
["our gift to Jesus", "यीशु को हमारी भेंट"],
["DOXA partnership missionaries", "DOXA साझेदारी के मिशनरी"],
["every tribe, tongue, people, and nation", "हर एक कुल, भाषा, लोग और जाति"],
["make disciples of all the nations", "सब जातियों को चेला बनाओ"],
["disciple", "चेला"],
["disciples", "चेले"],
["baptized disciples", "बपतिस्मा प्राप्त चेले"],
["culturally appropriate", "सांस्कृतिक रूप से उपयुक्त"],
["self-sustaining churches", "स्वावलंबी कलीसियाएँ"],
["spiritual generations", "आत्मिक पीढ़ियाँ"],
["abide in Jesus", "यीशु में बने रहो"],
["pray without ceasing", "निरन्तर प्रार्थना करो"],
["deny yourself", "अपने आप का इन्कार करो"],
["engage the regions beyond", "परे के क्षेत्रों में सुसमाचार ले जाओ"],
["Pentecostal", "पेन्तेकोस्तल"],
["Statement of Faith", "विश्वास का वक्तव्य"],
["new birth", "नया जन्म"],
["regeneration", "नवीनीकरण"],
["justification", "धर्मीकरण"],
["atonement", "प्रायश्चित्त"],
["sanctification", "पवित्रीकरण"],
["baptism in water", "जल बपतिस्मा"],
["Lord's Supper", "प्रभु भोज"],
["baptism in the Holy Spirit", "पवित्र आत्मा का बपतिस्मा"],
["speaking in tongues", "अन्य भाषाओं में बोलना"],
["gifts of the Holy Spirit", "पवित्र आत्मा के वरदान"],
["pre-millennial return", "सहस्राब्दी-पूर्व पुनरागमन"],
["bodily resurrection", "शारीरिक पुनरुत्थान"],
["pray", "प्रार्थना करना"],
["engage", "प्रवेश करना"],
["choose a people group", "एक जातीय समूह चुनना"],
["sign up", "पंजीकरण करना"]
],
it: [
["people group", "gruppo etnico"],
["people groups", "gruppi etnici"],
["unreached people group", "gruppo etnico non raggiunto"],
["unreached people groups", "gruppi etnici non raggiunti"],
["unengaged people group", "gruppo etnico non coinvolto"],
["unengaged people groups", "gruppi etnici non coinvolti"],
["unengaged unreached people group", "gruppo etnico non raggiunto e non coinvolto"],
["under-engaged", "sottocoinvolto"],
["frontier people group", "gruppo etnico di frontiera"],
["frontier people groups", "gruppi etnici di frontiera"],
["ethnolinguistic group", "gruppo etnolinguistico"],
["unreached", "non raggiunto"],
["unengaged", "non coinvolto"],
["engaged", "coinvolto"],
["engagement", "coinvolgimento"],
["fruitful engagement", "coinvolgimento fruttuoso"],
["gospel access", "accesso al Vangelo"],
["gospel engagement", "coinvolgimento per il Vangelo"],
["the gospel", "il Vangelo"],
["church-planting movement", "movimento di fondazione di chiese"],
["church planting", "fondazione di chiese"],
["indigenous church", "chiesa autoctona"],
["indigenous churches", "chiese autoctone"],
["self-propagating", "autopropagatrice"],
["self-governing", "autogovernata"],
["self-supporting", "autofinanziata"],
["self-theologizing", "autoteologizzante"],
["self-missionizing", "automissionaria"],
["residential engagement", "coinvolgimento residenziale"],
["workers in residence", "operatori residenti"],
["cross-cultural workers", "operatori interculturali"],
["apostolic effort", "impegno apostolico"],
["pioneer work", "lavoro pionieristico"],
["heart language", "lingua madre"],
["movement to Jesus", "movimento verso Gesù"],
["nonresident partners", "partner non residenti"],
["broader engagement", "coinvolgimento più ampio"],
["diaspora", "diaspora"],
["daily prayer", "preghiera quotidiana"],
["24-hour prayer coverage", "copertura di preghiera di 24 ore"],
["intercessor", "intercessore"],
["intercessors", "intercessori"],
["intercessory prayer", "preghiera di intercessione"],
["prayer points", "spunti di preghiera"],
["daily prayer guide", "guida di preghiera quotidiana"],
["prayer prepares the way", "la preghiera prepara la via"],
["Lord of the harvest", "Signore della messe"],
["adopt", "adottare"],
["pray, give, and send", "pregare, dare e inviare"],
["mobilize", "mobilitare"],
["mobilization", "mobilitazione"],
["sending agency", "agenzia di invio"],
["sending agencies", "agenzie di invio"],
["our gift to Jesus", "la nostra offerta a Gesù"],
["DOXA partnership missionaries", "missionari partner di DOXA"],
["every tribe, tongue, people, and nation", "ogni tribù, lingua, popolo e nazione"],
["make disciples of all the nations", "fate discepoli di tutti i popoli"],
["disciple", "discepolo"],
["disciples", "discepoli"],
["baptized disciples", "discepoli battezzati"],
["culturally appropriate", "culturalmente adeguato"],
["self-sustaining churches", "chiese autonome"],
["spiritual generations", "generazioni spirituali"],
["abide in Jesus", "dimorate in Gesù"],
["pray without ceasing", "pregate senza cessare"],
["deny yourself", "rinnegate voi stessi"],
["engage the regions beyond", "portate il Vangelo oltre"],
["Pentecostal", "pentecostale"],
["Statement of Faith", "Dichiarazione di fede"],
["new birth", "nuova nascita"],
["regeneration", "rigenerazione"],
["justification", "giustificazione"],
["atonement", "espiazione"],
["sanctification", "santificazione"],
["baptism in water", "battesimo in acqua"],
["Lord's Supper", "Santa Cena"],
["baptism in the Holy Spirit", "battesimo nello Spirito Santo"],
["speaking in tongues", "parlare in lingue"],
["gifts of the Holy Spirit", "doni dello Spirito Santo"],
["pre-millennial return", "ritorno premillenario"],
["bodily resurrection", "risurrezione corporea"],
["pray", "pregare"],
["engage", "coinvolgere"],
["choose a people group", "scegliere un gruppo etnico"],
["sign up", "iscriversi"]
],
pt: [
["people group", "grupo étnico"],
["people groups", "grupos étnicos"],
["unreached people group", "grupo étnico não alcançado"],
["unreached people groups", "grupos étnicos não alcançados"],
["unengaged people group", "grupo étnico não engajado"],
["unengaged people groups", "grupos étnicos não engajados"],
["unengaged unreached people group", "grupo étnico não alcançado e não engajado"],
["under-engaged", "subengajado"],
["frontier people group", "grupo étnico de fronteira"],
["frontier people groups", "grupos étnicos de fronteira"],
["ethnolinguistic group", "grupo etnolinguístico"],
["unreached", "não alcançado"],
["unengaged", "não engajado"],
["engaged", "engajado"],
["engagement", "engajamento"],
["fruitful engagement", "engajamento frutífero"],
["gospel access", "acesso ao Evangelho"],
["gospel engagement", "engajamento com o Evangelho"],
["the gospel", "o Evangelho"],
["church-planting movement", "movimento de plantação de igrejas"],
["church planting", "plantação de igrejas"],
["indigenous church", "igreja autóctona"],
["indigenous churches", "igrejas autóctones"],
["self-propagating", "autopropagador"],
["self-governing", "autogovernado"],
["self-supporting", "autofinanciado"],
["self-theologizing", "autoteologizante"],
["self-missionizing", "automissionário"],
["residential engagement", "engajamento residencial"],
["workers in residence", "obreiros residentes"],
["cross-cultural workers", "obreiros transculturais"],
["apostolic effort", "esforço apostólico"],
["pioneer work", "trabalho pioneiro"],
["heart language", "língua materna"],
["movement to Jesus", "movimento em direção a Jesus"],
["nonresident partners", "parceiros não residentes"],
["broader engagement", "engajamento ampliado"],
["diaspora", "diáspora"],
["daily prayer", "oração diária"],
["24-hour prayer coverage", "cobertura de oração de 24 horas"],
["intercessor", "intercessor"],
["intercessors", "intercessores"],
["intercessory prayer", "oração intercessória"],
["prayer points", "motivos de oração"],
["daily prayer guide", "guia de oração diário"],
["prayer prepares the way", "a oração prepara o caminho"],
["Lord of the harvest", "Senhor da seara"],
["adopt", "adotar"],
["pray, give, and send", "orar, contribuir e enviar"],
["mobilize", "mobilizar"],
["mobilization", "mobilização"],
["sending agency", "agência de envio"],
["sending agencies", "agências de envio"],
["our gift to Jesus", "nossa oferta a Jesus"],
["DOXA partnership missionaries", "missionários parceiros da DOXA"],
["every tribe, tongue, people, and nation", "toda tribo, língua, povo e nação"],
["make disciples of all the nations", "façam discípulos de todas as nações"],
["disciple", "discípulo"],
["disciples", "discípulos"],
["baptized disciples", "discípulos batizados"],
["culturally appropriate", "culturalmente apropriado"],
["self-sustaining churches", "igrejas autossustentáveis"],
["spiritual generations", "gerações espirituais"],
["abide in Jesus", "permaneçam em Jesus"],
["pray without ceasing", "orem sem cessar"],
["deny yourself", "neguem a si mesmos"],
["engage the regions beyond", "alcancem as regiões mais além"],
["Pentecostal", "pentecostal"],
["Statement of Faith", "Declaração de fé"],
["new birth", "novo nascimento"],
["regeneration", "regeneração"],
["justification", "justificação"],
["atonement", "expiação"],
["sanctification", "santificação"],
["baptism in water", "batismo nas águas"],
["Lord's Supper", "Santa Ceia"],
["baptism in the Holy Spirit", "batismo no Espírito Santo"],
["speaking in tongues", "falar em línguas"],
["gifts of the Holy Spirit", "dons do Espírito Santo"],
["pre-millennial return", "retorno pré-milenial"],
["bodily resurrection", "ressurreição corporal"],
["pray", "orar"],
["engage", "engajar"],
["World Assemblies of God Fellowship", "Comunhão das Assembléias de Deus Mundial"],
["choose a people group", "escolher um grupo étnico"],
["sign up", "inscrever-se"]
],
ro: [
["people group", "grup etnic"],
["people groups", "grupuri etnice"],
["unreached people group", "grup etnic neatins"],
["unreached people groups", "grupuri etnice neatinse"],
["unengaged people group", "grup etnic neangajat"],
["unengaged people groups", "grupuri etnice neangajate"],
["unengaged unreached people group", "grup etnic neatins și neangajat"],
["under-engaged", "insuficient angajat"],
["frontier people group", "grup etnic de frontieră"],
["frontier people groups", "grupuri etnice de frontieră"],
["ethnolinguistic group", "grup etnolingvistic"],
["unreached", "neatins"],
["unengaged", "neangajat"],
["engaged", "angajat"],
["engagement", "angajare"],
["fruitful engagement", "angajare rodnică"],
["gospel access", "acces la Evanghelie"],
["gospel engagement", "angajare pentru Evanghelie"],
["the gospel", "Evanghelia"],
["church-planting movement", "mișcare de plantare de biserici"],
["church planting", "plantare de biserici"],
["indigenous church", "biserică autohtonă"],
["indigenous churches", "biserici autohtone"],
["self-propagating", "autopropagator"],
["self-governing", "autoguvernat"],
["self-supporting", "autofinanțat"],
["self-theologizing", "autoteologizant"],
["self-missionizing", "automisionar"],
["residential engagement", "angajare rezidențială"],
["workers in residence", "lucrători rezidenți"],
["cross-cultural workers", "lucrători interculturali"],
["apostolic effort", "efort apostolic"],
["pioneer work", "lucrare pionieră"],
["heart language", "limba maternă"],
["movement to Jesus", "mișcare spre Isus"],
["nonresident partners", "parteneri nerezidenți"],
["broader engagement", "angajare extinsă"],
["diaspora", "diaspora"],
["daily prayer", "rugăciune zilnică"],
["24-hour prayer coverage", "acoperire de rugăciune de 24 de ore"],
["intercessor", "mijlocitor"],
["intercessors", "mijlocitori"],
["intercessory prayer", "rugăciune de mijlocire"],
["prayer points", "subiecte de rugăciune"],
["daily prayer guide", "ghid zilnic de rugăciune"],
["prayer prepares the way", "rugăciunea pregătește calea"],
["Lord of the harvest", "Stăpânul secerișului"],
["adopt", "a adopta"],
["pray, give, and send", "rugați-vă, dăruiți și trimiteți"],
["mobilize", "a mobiliza"],
["mobilization", "mobilizare"],
["sending agency", "agenție de trimitere"],
["sending agencies", "agenții de trimitere"],
["our gift to Jesus", "darul nostru pentru Isus"],
["DOXA partnership missionaries", "misionari parteneri DOXA"],
["every tribe, tongue, people, and nation", "orice seminție, orice limbă, orice popor și orice națiune"],
["make disciples of all the nations", "faceți ucenici din toate neamurile"],
["disciple", "ucenic"],
["disciples", "ucenici"],
["baptized disciples", "ucenici botezați"],
["culturally appropriate", "adaptat cultural"],
["self-sustaining churches", "biserici autonome"],
["spiritual generations", "generații spirituale"],
["abide in Jesus", "rămâneți în Isus"],
["pray without ceasing", "rugați-vă neîncetat"],
["deny yourself", "lepădați-vă de voi înșivă"],
["engage the regions beyond", "duceți Evanghelia dincolo de hotare"],
["Pentecostal", "penticostal"],
["Statement of Faith", "Declarație de credință"],
["new birth", "nașterea din nou"],
["regeneration", "regenerare"],
["justification", "justificare"],
["atonement", "ispășire"],
["sanctification", "sfințire"],
["baptism in water", "botezul în apă"],
["Lord's Supper", "Cina Domnului"],
["baptism in the Holy Spirit", "botezul în Duhul Sfânt"],
["speaking in tongues", "vorbirea în limbi"],
["gifts of the Holy Spirit", "darurile Duhului Sfânt"],
["pre-millennial return", "revenirea premilenială"],
["bodily resurrection", "învierea trupească"],
["pray", "a se ruga"],
["engage", "a angaja"],
["choose a people group", "a alege un grup etnic"],
["sign up", "a se înregistra"]
],
ru: [
["people group", "этническая группа"],
["people groups", "этнические группы"],
["unreached people group", "недостигнутая этническая группа"],
["unreached people groups", "недостигнутые этнические группы"],
["unengaged people group", "невовлечённая этническая группа"],
["unengaged people groups", "невовлечённые этнические группы"],
["unengaged unreached people group", "недостигнутая и невовлечённая этническая группа"],
["under-engaged", "недостаточно вовлечённая"],
["frontier people group", "этническая группа на передовой"],
["frontier people groups", "этнические группы на передовой"],
["ethnolinguistic group", "этнолингвистическая группа"],
["unreached", "недостигнутый"],
["unengaged", "невовлечённый"],
["engaged", "вовлечённый"],
["engagement", "вовлечение"],
["fruitful engagement", "плодотворное вовлечение"],
["gospel access", "доступ к Евангелию"],
["gospel engagement", "евангельское вовлечение"],
["the gospel", "Евангелие"],
["church-planting movement", "движение насаждения церквей"],
["church planting", "насаждение церквей"],
["indigenous church", "местная церковь"],
["indigenous churches", "местные церкви"],
["self-propagating", "самовоспроизводящийся"],
["self-governing", "самоуправляемый"],
["self-supporting", "самофинансируемый"],
["self-theologizing", "самобогословствующий"],
["self-missionizing", "самомиссионерский"],
["residential engagement", "вовлечение на месте"],
["workers in residence", "резидентные работники"],
["cross-cultural workers", "межкультурные работники"],
["apostolic effort", "апостольское служение"],
["pioneer work", "пионерское служение"],
["heart language", "родной язык"],
["movement to Jesus", "движение к Иисусу"],
["nonresident partners", "нерезидентные партнёры"],
["broader engagement", "расширенное вовлечение"],
["diaspora", "диаспора"],
["daily prayer", "ежедневная молитва"],
["24-hour prayer coverage", "круглосуточное молитвенное покрытие"],
["intercessor", "ходатай"],
["intercessors", "ходатаи"],
["intercessory prayer", "ходатайственная молитва"],
["prayer points", "молитвенные нужды"],
["daily prayer guide", "ежедневный молитвенный путеводитель"],
["prayer prepares the way", "молитва готовит путь"],
["Lord of the harvest", "Господин жатвы"],
["adopt", "взять попечение"],
["pray, give, and send", "молитесь, жертвуйте и посылайте"],
["mobilize", "мобилизовать"],
["mobilization", "мобилизация"],
["sending agency", "посылающая организация"],
["sending agencies", "посылающие организации"],
["our gift to Jesus", "наш дар Иисусу"],
["DOXA partnership missionaries", "миссионеры-партнёры DOXA"],
["every tribe, tongue, people, and nation", "всякое колено, и язык, и народ, и племя"],
["make disciples of all the nations", "научите все народы"],
["disciple", "ученик"],
["disciples", "ученики"],
["baptized disciples", "крещёные ученики"],
["culturally appropriate", "культурно адаптированный"],
["self-sustaining churches", "самостоятельные церкви"],
["spiritual generations", "духовные поколения"],
["abide in Jesus", "пребывайте в Иисусе"],
["pray without ceasing", "непрестанно молитесь"],
["deny yourself", "отвергните себя"],
["engage the regions beyond", "несите Евангелие за пределы"],
["Pentecostal", "пятидесятнический"],
["Statement of Faith", "Исповедание веры"],
["new birth", "рождение свыше"],
["regeneration", "возрождение"],
["justification", "оправдание"],
["atonement", "искупление"],
["sanctification", "освящение"],
["baptism in water", "водное крещение"],
["Lord's Supper", "Вечеря Господня"],
["baptism in the Holy Spirit", "крещение Духом Святым"],
["speaking in tongues", "говорение на иных языках"],
["gifts of the Holy Spirit", "дары Святого Духа"],
["pre-millennial return", "домилленниальное возвращение"],
["bodily resurrection", "телесное воскресение"],
["pray", "молиться"],
["engage", "вовлекать"],
["choose a people group", "выбрать этническую группу"],
["sign up", "зарегистрироваться"]
],
zh: [
["people group", "族群"],
["people groups", "族群"],
["unreached people group", "未得之民"],
["unreached people groups", "未得之民"],
["unengaged people group", "未接触族群"],
["unengaged people groups", "未接触族群"],
["unengaged unreached people group", "未接触未得之民"],
["under-engaged", "接触不足的"],
["frontier people group", "前沿族群"],
["frontier people groups", "前沿族群"],
["ethnolinguistic group", "民族语言群体"],
["unreached", "未得的"],
["unengaged", "未接触的"],
["engaged", "已接触的"],
["engagement", "接触"],
["fruitful engagement", "有果效的接触"],
["gospel access", "福音可及性"],
["gospel engagement", "福音接触"],
["the gospel", "福音"],
["church-planting movement", "植堂运动"],
["church planting", "植堂"],
["indigenous church", "本土教会"],
["indigenous churches", "本土教会"],
["self-propagating", "自传的"],
["self-governing", "自治的"],
["self-supporting", "自养的"],
["self-theologizing", "自我神学建构的"],
["self-missionizing", "自我差传的"],
["residential engagement", "驻地接触"],
["workers in residence", "驻地工人"],
["cross-cultural workers", "跨文化工人"],
["apostolic effort", "使徒性开拓"],
["pioneer work", "先锋事工"],
["heart language", "母语"],
["movement to Jesus", "归主运动"],
["nonresident partners", "非驻地伙伴"],
["broader engagement", "广义接触"],
["diaspora", "散居群体"],
["daily prayer", "每日祷告"],
["24-hour prayer coverage", "全天候祷告覆盖"],
["intercessor", "代祷者"],
["intercessors", "代祷者"],
["intercessory prayer", "代祷"],
["prayer points", "祷告事项"],
["daily prayer guide", "每日祷告指南"],
["prayer prepares the way", "祷告预备道路"],
["Lord of the harvest", "庄稼的主"],
["adopt", "认领"],
["pray, give, and send", "祷告、奉献、差派"],
["mobilize", "动员"],
["mobilization", "动员"],
["sending agency", "差传机构"],
["sending agencies", "差传机构"],
["our gift to Jesus", "我们献给耶稣的礼物"],
["DOXA partnership missionaries", "DOXA伙伴宣教士"],
["every tribe, tongue, people, and nation", "各族、各方、各民、各国"],
["make disciples of all the nations", "使万民作主的门徒"],
["disciple", "门徒"],
["disciples", "门徒"],
["baptized disciples", "受洗门徒"],
["culturally appropriate", "合乎文化的"],
["self-sustaining churches", "自立教会"],
["spiritual generations", "属灵世代"],
["abide in Jesus", "常在主里面"],
["pray without ceasing", "不住地祷告"],
["deny yourself", "舍己"],
["engage the regions beyond", "进入未及之地"],
["Pentecostal", "五旬节派"],
["Statement of Faith", "信仰宣言"],
["new birth", "重生"],
["regeneration", "更新"],
["justification", "称义"],
["atonement", "赎罪"],
["sanctification", "成圣"],
["baptism in water", "水的洗礼"],
["Lord's Supper", "圣餐"],
["baptism in the Holy Spirit", "圣灵的洗"],
["speaking in tongues", "说方言"],
["gifts of the Holy Spirit", "圣灵的恩赐"],
["pre-millennial return", "千禧年前再来"],
["bodily resurrection", "身体复活"],
["pray", "祷告"],
["engage", "接触"],
["choose a people group", "选择一个族群"],
["sign up", "注册"]
]
}

View file

@ -7,11 +7,10 @@ export interface Language {
nativeName: string // Name in the language itself
flag: string
dir?: 'ltr' | 'rtl' // Text direction (defaults to 'ltr')
deeplTarget: string // DeepL target language code
deeplSource: string // DeepL source language code (sometimes different)
bibleId?: string // Bolls.life translation ID for verse lookups
bibleLabel?: string // Display label for the Bible translation (defaults to bibleId)
glossaryId?: string // DeepL glossary ID for this language pair (EN → target)
translationName?: string // Language name used in LLM translation prompts when the plain name is ambiguous
translationModel?: string // OpenRouter model override for this language (falls back to the site-wide setting)
enabled?: boolean // Whether the language is active in the UI (default: true)
hideFromSwitcher?: boolean // If true, the language is routable (/de, /hi, …) but omitted from the public LanguageSwitcher dropdown
latinHeadings?: boolean // Headings render in the Latin display font (Bebas Neue); set false for non-Latin scripts (default: true)
@ -21,17 +20,17 @@ export interface Language {
// but hidden from the UI language selector, translation targets, and admin content
// find translations: https://bolls.life/static/bolls/app/views/languages.json
export const LANGUAGES: Language[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', deeplTarget: 'EN', deeplSource: 'EN', bibleId: 'NKJV' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', deeplTarget: 'ES', deeplSource: 'ES', bibleId: 'NVI', glossaryId: 'b867e526-2eb8-426f-a178-b8a6b9d4d6ce' }, //RV1960
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', deeplTarget: 'FR', deeplSource: 'FR', bibleId: 'FRLSG', bibleLabel: 'LSG', glossaryId: 'b6dd3213-1ddb-42ab-a2dc-91ff3a772fa3' }, //maybe BDS
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', deeplTarget: 'PT-BR', deeplSource: 'PT', bibleId: 'NAA', glossaryId: 'bbe0697f-429a-45f7-9c30-576c7f21c78d' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', deeplTarget: 'DE', deeplSource: 'DE', bibleId: 'S00', bibleLabel: 'SCH2000', glossaryId: '87ad31eb-1a58-4f91-b80e-943b3721065d', hideFromSwitcher: true },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', deeplTarget: 'IT', deeplSource: 'IT', bibleId: 'NR06', glossaryId: '98dca739-468a-4c29-a0ce-ba89f60da00b', enabled: false },
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', deeplTarget: 'ZH-HANS', deeplSource: 'ZH', bibleId: 'CUNPS', glossaryId: '532adee9-5117-4a9e-a6cb-a8c4b670232d', enabled: false, latinHeadings: false },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl', deeplTarget: 'AR', deeplSource: 'AR', bibleId: 'SVD', glossaryId: '294b6001-2831-45d4-bab5-3fd1af92dc7a', latinHeadings: false }, // NAV (New Arabic Version) would be better but not on Bolls
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', deeplTarget: 'RU', deeplSource: 'RU', bibleId: 'SYNOD', glossaryId: '7363b815-3cd1-4e1a-ae8b-d6695e6dbc4a', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', deeplTarget: 'HI', deeplSource: 'HI', bibleId: 'HIOV', bibleLabel: 'OV', hideFromSwitcher: true, latinHeadings: false },
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', deeplTarget: 'RO', deeplSource: 'RO', bibleId: 'NTR', glossaryId: 'e50d3fed-6d0e-4f2c-a3ac-96112829055e', enabled: false },
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', bibleId: 'NKJV' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', bibleId: 'NVI' }, //RV1960
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', bibleId: 'FRLSG', bibleLabel: 'LSG' }, //maybe BDS
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', bibleId: 'NAA', translationName: 'Brazilian Portuguese' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', bibleId: 'S00', bibleLabel: 'SCH2000', hideFromSwitcher: true },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', bibleId: 'NR06', enabled: false },
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', bibleId: 'CUNPS', translationName: 'Simplified Chinese', enabled: false, latinHeadings: false },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl', bibleId: 'SVD', translationName: 'Modern Standard Arabic', latinHeadings: false }, // NAV (New Arabic Version) would be better but not on Bolls
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', bibleId: 'SYNOD', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', bibleId: 'HIOV', bibleLabel: 'OV', hideFromSwitcher: true, latinHeadings: false },
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', bibleId: 'NTR', enabled: false },
]
// All language codes
@ -57,18 +56,6 @@ export function getLanguage(code: string): Language | undefined {
return LANGUAGES.find(l => l.code === code)
}
export function getDeeplTargetCode(code: string): string {
return getLanguage(code)?.deeplTarget ?? code.toUpperCase()
}
export function getDeeplSourceCode(code: string): string {
return getLanguage(code)?.deeplSource ?? code.toUpperCase()
}
export function getGlossaryId(code: string): string | undefined {
return getLanguage(code)?.glossaryId
}
// Whether this language's headings render in the Latin display font (Bebas Neue).
// Non-Latin scripts (Cyrillic, Arabic, CJK, Devanagari) fall back to other fonts,
// so callers can skip Bebas-specific work (e.g. preloading it). Unknown codes

View file

@ -264,8 +264,10 @@ export default defineNuxtConfig({
// unset, the endpoint falls back to public.mapboxToken below so
// existing setups keep working with no config change.
mapboxKey: process.env.NUXT_MAPBOX_KEY || '',
deeplApiKey: process.env.DEEPL_API_KEY || '',
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api.deepl.com',
// OpenRouter — LLM content translation. TRANSLATION_MODEL overrides the
// code default; the `translation.model` site setting overrides both.
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
translationModel: process.env.TRANSLATION_MODEL || '',
// Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
// Token needs only Zone → Cache Purge for the doxa.life zone.
cfApiToken: process.env.CF_API_TOKEN || '',

View file

@ -0,0 +1,47 @@
/**
* Generates config/glossaries.ts from the translation repo's glossary TSVs
* (../translation/deepl-glossaries/*.tsv). The generated module is committed
* so production builds don't need the translation repo checked out.
*
* Run after updating the TSVs: bun scripts/build-glossaries.ts
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const sourceDir = join(import.meta.dir, '../../translation/deepl-glossaries')
const outFile = join(import.meta.dir, '../config/glossaries.ts')
const glossaries: Record<string, Array<[string, string]>> = {}
for (const file of readdirSync(sourceDir).sort()) {
if (!file.endsWith('.tsv')) continue
const code = file.replace(/\.tsv$/, '')
const pairs: Array<[string, string]> = []
for (const line of readFileSync(join(sourceDir, file), 'utf8').split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const [source, target] = trimmed.split('\t')
if (source && target) pairs.push([source.trim(), target.trim()])
}
glossaries[code] = pairs
}
const entries = Object.entries(glossaries)
.map(([code, pairs]) => {
const rows = pairs.map(([s, t]) => ` [${JSON.stringify(s)}, ${JSON.stringify(t)}]`).join(',\n')
return ` ${code}: [\n${rows}\n ]`
})
.join(',\n')
const output = `// Generated by scripts/build-glossaries.ts from ../translation/deepl-glossaries/*.tsv
// Do not edit by hand — update the TSVs in the translation repo and re-run:
// bun scripts/build-glossaries.ts
/** English → target-language term pairs, keyed by language code. */
export const GLOSSARIES: Record<string, ReadonlyArray<readonly [string, string]>> = {
${entries}
}
`
writeFileSync(outFile, output)
console.log(`Wrote ${Object.keys(glossaries).length} glossaries (${Object.values(glossaries).reduce((n, p) => n + p.length, 0)} terms) to config/glossaries.ts`)

View file

@ -1,11 +1,11 @@
// Admin: one-click translate an EN (or other source) translation of a
// CMS page into one or more target locales. Uses DeepL for title,
// excerpt, meta_title, meta_description, and body_json. Each target
// translation is upserted with the caller-chosen `status` (draft or
// published, default draft). `overwrite` controls whether to replace
// translations that already exist for a given locale.
// CMS page into one or more target locales. Translates title, excerpt,
// meta_title, meta_description, and body_json. Each target translation is
// upserted with the caller-chosen `status` (draft or published, default
// draft). `overwrite` controls whether to replace translations that
// already exist for a given locale.
//
// All workflow logic — DeepL calls, body walking, upsert with
// All workflow logic — translation calls, body walking, upsert with
// validation, per-locale cache purge — lives in cmsTranslate.translatePage.
// This route stays thin.

View file

@ -87,7 +87,9 @@ export interface PageTranslationsTable {
updated: ColumnType<Date, Date | string | undefined, Date | string>
}
export type PageTranslationVersionSource = 'admin-ui' | 'mcp' | 'deepl'
// 'deepl' is retained for snapshots written by the previous translation
// provider; new machine translations are recorded as 'auto-translate'.
export type PageTranslationVersionSource = 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
export interface PageTranslationVersionsTable {
id: Generated<string>

View file

@ -1,16 +1,15 @@
// DeepL text-translation tool. Generic primitive — Claude composes
// LLM text-translation tool. Generic primitive — Claude composes
// with upsert_page_translation when applying translations to pages.
//
// The configured DeepL glossary for each target locale is always
// applied (the translateText service consults config/languages.ts),
// keeping MCP-driven translations terminologically consistent with
// admin auto-translates.
// The vendored glossary for each target locale is always injected into
// the translation prompt, keeping MCP-driven translations terminologically
// consistent with admin auto-translates.
import { defineMcpTool, mcpLog } from '#mcp-layer'
import { translateTextInput, translatePageInput } from '../schemas'
import { translateText, translatePage } from '../../services/cmsTranslate'
const DESCRIPTION = `Translate a piece of text into one or more locales using DeepL.
const DESCRIPTION = `Translate a piece of text into one or more locales.
Use this when you've authored a section in one language (typically English) and need versions in the site's other locales — for example, "translate this paragraph then add it to each page in every locale."
@ -35,7 +34,7 @@ export const translateTextTool = defineMcpTool({
target_locales: input.target_locales
})
// Audit the call so the activity log records when DeepL spend
// Audit the call so the activity log records when translation spend
// happened on Claude's behalf. Don't include the full text in
// metadata — keep the audit row small and non-PII-leaky.
await mcpLog('CREATE', 'translations.cache', `${result.source_locale}->${result.translations.map(t => t.locale).join(',')}`, ctx, {
@ -59,20 +58,20 @@ export const translateTextTool = defineMcpTool({
// ── Page-level translate ────────────────────────────────────────────
const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales using DeepL.
const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales.
Use this for the "make this page exist in all locales" workflow. For surgical edits — translating one new section and inserting it into existing translations — prefer translate_text composed with upsert_page_translation.
Inputs:
- \`page_id\`: the CMS page id.
- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row.
- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — DeepL flaking on one locale lets the rest proceed.
- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no DeepL call). Set true to replace.
- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — the model flaking on one locale lets the rest proceed. A failure no retry can fix (no credits, bad key, rejected model) does stop the run, reporting the same reason for the untried locales.
- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no model call). Set true to replace.
- \`status\`: \`draft\` (default) or \`published\` for the new translation rows.
Output: \`{source_locale, results: [{locale, ok? | skipped? | error?}]}\`. Each result entry reports one of three outcomes; \`ok\` means a row was written and the public cache was purged for that locale.
Cost note: this triggers up to 5 DeepL calls per target locale (4 metadata strings batched into one call + 1 body call). A "translate to all 9 locales" run on a typical page is ~18 DeepL requests.`
Cost note: this triggers one model call for the metadata plus one per 40 body fragments, per target locale. A "translate to all 9 locales" run on a typical page is ~20-30 model requests.`
export const translatePageTool = defineMcpTool({
name: 'translate_page',

View file

@ -1,15 +1,14 @@
// DeepL text-translation service — the canonical primitive both the
// MCP translate_text tool and any future admin batch-translate flow
// share.
// LLM text-translation service — the canonical primitive both the
// MCP translate_text tool and the admin batch-translate flow share.
//
// Always uses the per-locale DeepL glossary configured in
// config/languages.ts (via translateTexts()'s built-in lookup), so
// the terminology stays consistent with admin auto-translates and
// with the translation/ glossaries repo.
// The per-locale glossary vendored in config/glossaries.ts is injected
// into every translation prompt, so the terminology stays consistent
// with admin auto-translates and with the translation/ glossaries repo.
import type { H3Error } from 'h3'
import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../utils/deepl'
import { ENABLED_LANGUAGE_CODES, getLanguage } from '~~/config/languages'
import { translateTexts, translateTiptapContent, isTranslationConfigured } from '../utils/translate'
import { OpenRouterError, hasGlossary } from '../utils/openrouter'
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
import { db } from '../utils/database'
import {
upsertCmsPageTranslation,
@ -41,12 +40,12 @@ export interface TranslateTextResult {
translations: TranslateTextResultEntry[]
}
const MAX_TEXT_BYTES = 100 * 1024 // DeepL's per-request soft limit; large bodies
// should be split client-side and translated piece by piece
const MAX_TEXT_BYTES = 100 * 1024 // Per-request ceiling; large bodies should be
// split client-side and translated piece by piece
export async function translateText(input: TranslateTextInput): Promise<TranslateTextResult> {
if (!isDeepLConfigured()) {
throw err(503, 'DeepL is not configured on this server')
if (!isTranslationConfigured()) {
throw err(503, 'Translation is not configured on this server')
}
const text = input.text ?? ''
@ -77,17 +76,15 @@ export async function translateText(input: TranslateTextInput): Promise<Translat
// Dedupe — Claude might list the same locale twice
const targets = Array.from(new Set(input.target_locales))
// One DeepL call per target locale (DeepL only translates to a single
// target per request). The /translate endpoint applies the configured
// glossary automatically based on the target locale.
// One model call per target locale — each prompt is built around a single
// target language and carries that locale's glossary.
const results = await Promise.all(
targets.map(async (locale) => {
const out = await translateTexts([text], locale, source)
const lang = getLanguage(locale)
return {
locale,
text: out[0] ?? '',
glossary_used: Boolean(lang?.glossaryId)
glossary_used: hasGlossary(locale)
}
})
)
@ -116,7 +113,7 @@ export interface TranslatePageInput {
export interface TranslatePageResultEntry {
locale: string
// Mutually exclusive: either ok=true (translation written), skipped=true
// (existing row preserved, no DeepL call), or error set (translation
// (existing row preserved, no model call), or error set (translation
// failed for this locale only — other locales still proceed).
ok?: true
skipped?: true
@ -130,15 +127,17 @@ export interface TranslatePageResult {
// Translate a whole CMS page (title, excerpt, meta_title,
// meta_description, body_json) from a source locale into one or more
// targets. Per-locale failures don't abort the batch — DeepL flaking
// on one locale shouldn't block the others.
// targets. Per-locale failures don't abort the batch — the model flaking
// on one locale shouldn't block the others. The exception is a failure no
// retry can fix (no credits, bad key, rejected model): the remaining
// locales are marked with the same reason instead of repeating the call.
//
// Each successful translation is written via upsertCmsPageTranslation,
// so the standard validator + cache-invalidation path runs on every
// translated row (consistent with hand-edited translations).
export async function translatePage(input: TranslatePageInput): Promise<TranslatePageResult> {
if (!isDeepLConfigured()) {
throw err(503, 'DeepL is not configured on this server')
if (!isTranslationConfigured()) {
throw err(503, 'Translation is not configured on this server')
}
const sourceLocale = input.source_locale ?? 'en'
@ -170,7 +169,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
const results: TranslatePageResultEntry[] = []
for (const target of targets) {
for (const [i, target] of targets.entries()) {
if (!input.overwrite) {
const existing = await db
.selectFrom('page_translations')
@ -207,7 +206,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
og_image: source.og_image,
status,
actor_user_id: input.actor_user_id ?? null,
source: 'deepl'
source: 'auto-translate'
})
// Per-locale cache purge after each successful upsert. This
@ -221,6 +220,15 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
const msg = e instanceof Error ? e.message : 'translation failed'
console.error(`[cmsTranslate] page ${input.page_id} → ${target} failed:`, msg)
results.push({ locale: target, error: msg })
if (e instanceof OpenRouterError && !e.retryable) {
// Every remaining locale would hit the same wall, so report one
// reason rather than burning a call per locale to rediscover it.
for (const remaining of targets.slice(i + 1)) {
results.push({ locale: remaining, error: msg })
}
break
}
}
}

View file

@ -39,7 +39,7 @@ export interface UpsertTranslationInput {
// optional because some background paths may not have a user; the
// snapshot still gets written, just with NULLs.
actor_user_id?: string | null
source?: 'admin-ui' | 'mcp' | 'deepl'
source?: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
user_agent?: string | null
}

View file

@ -1,134 +0,0 @@
// DeepL translation utility. Slim port of
// campaigns-sever/server/utils/deepl.ts — drops the Bible / verse-node
// handling since marketing-rebuild has no verse content. Supports text,
// batched text, and Tiptap JSON documents.
import { getDeeplTargetCode, getDeeplSourceCode, getGlossaryId } from '~~/config/languages'
interface DeepLTranslation {
detected_source_language: string
text: string
}
interface DeepLResponse {
translations: DeepLTranslation[]
}
export interface TiptapNode {
type: string
content?: TiptapNode[]
text?: string
marks?: any[]
attrs?: Record<string, any>
}
export function isDeepLConfigured(): boolean {
const config = useRuntimeConfig()
return Boolean(config.deeplApiKey)
}
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string
): Promise<string> {
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
return translated[0] ?? ''
}
export async function translateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage?: string
): Promise<string[]> {
if (texts.length === 0) return []
const config = useRuntimeConfig()
const apiKey = config.deeplApiKey
if (!apiKey) throw new Error('DEEPL_API_KEY is not configured')
const targetLang = getDeeplTargetCode(targetLanguage)
const sourceLang = sourceLanguage ? getDeeplSourceCode(sourceLanguage) : undefined
const params = new URLSearchParams({ target_lang: targetLang })
for (const text of texts) params.append('text', text)
if (sourceLang) params.append('source_lang', sourceLang)
const glossaryId = getGlossaryId(targetLanguage)
if (glossaryId) params.append('glossary_id', glossaryId)
params.append('model_type', 'quality_optimized')
const apiUrl = config.deeplApiUrl || 'https://api.deepl.com'
console.log(`[DeepL] Translating ${texts.length} text(s) → ${targetLang}${sourceLang ? ` from ${sourceLang}` : ''}${glossaryId ? ` (glossary: ${glossaryId})` : ''}`)
const response = await fetch(`${apiUrl}/v2/translate`, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
})
if (!response.ok) {
const body = await response.text()
throw new Error(`DeepL API error: ${response.status} - ${body}`)
}
const data = await response.json() as DeepLResponse
if (!data.translations) throw new Error('No translations returned from DeepL')
return data.translations.map(t => t.text)
}
// Extract plain text nodes from a Tiptap JSON tree so we can send them
// to DeepL as a batch, then write the translations back at the same
// positions. Mirrors campaigns-sever's implementation minus the
// verse-node skip (no verses in marketing content).
export function extractTexts(
node: TiptapNode,
path: number[] = []
): Array<{ path: number[]; text: string }> {
const results: Array<{ path: number[]; text: string }> = []
if (node.type === 'text' && node.text) {
results.push({ path: [...path], text: node.text })
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach((child, index) => {
results.push(...extractTexts(child, [...path, index]))
})
}
return results
}
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
if (path.length === 0) {
node.text = text
return
}
const [index, ...rest] = path as [number, ...number[]]
if (node.content && node.content[index]) {
setTextAtPath(node.content[index], rest, text)
}
}
export async function translateTiptapContent(
contentJson: TiptapNode,
targetLanguage: string,
sourceLanguage?: string
): Promise<TiptapNode> {
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
const entries = extractTexts(cloned)
if (entries.length === 0) return cloned
const CHUNK_SIZE = 100
const allTranslated: string[] = []
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
if (i > 0) await new Promise(r => setTimeout(r, 200))
const chunk = entries.slice(i, i + CHUNK_SIZE)
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
allTranslated.push(...translated)
}
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
return cloned
}

199
server/utils/openrouter.ts Normal file
View file

@ -0,0 +1,199 @@
/**
* OpenRouter Translation Client
*
* Translates text fragments with an LLM via the OpenRouter chat-completions
* API. Each request carries the target language's glossary and every fragment
* of the batch, and the model must return exactly one translation per fragment
* so the caller can map results back into structured content.
*/
import { getLanguage } from '~~/config/languages'
import { GLOSSARIES } from '~~/config/glossaries'
import { getSetting } from './site-settings'
const DEFAULT_TRANSLATION_MODEL = 'anthropic/claude-opus-5'
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
export function isOpenRouterConfigured(): boolean {
return Boolean(useRuntimeConfig().openrouterApiKey)
}
/**
* The OpenRouter model used to translate into a language. A per-language
* override in config/languages.ts wins; then the admin-managed
* `translation.model` site setting, the TRANSLATION_MODEL env var, and a code
* default — so adopting a newly released model is a setting, not a code edit.
*/
export async function getTranslationModel(targetLanguage?: string): Promise<string> {
if (targetLanguage) {
const override = getLanguage(targetLanguage)?.translationModel
if (override) return override
}
const stored = await getSetting('translation.model')
if (stored) return stored
return useRuntimeConfig().translationModel || DEFAULT_TRANSLATION_MODEL
}
/** Language name as used in prompts — the precise variant when the plain name is ambiguous. */
function promptLanguageName(code: string): string {
const lang = getLanguage(code)
return lang?.translationName || lang?.name || code
}
/** Whether a curated terminology glossary is injected for this language. */
export function hasGlossary(code: string): boolean {
return Boolean(GLOSSARIES[code]?.length)
}
function buildSystemPrompt(targetLanguage: string, sourceLanguage: string, fragmentCount: number): string {
const source = promptLanguageName(sourceLanguage)
const target = promptLanguageName(targetLanguage)
const glossary = GLOSSARIES[targetLanguage]
const glossaryBlock = glossary?.length
? `\nGlossary — always use these translations, inflected correctly for the surrounding grammar:\n${glossary.map(([s, t]) => `${s} → ${t}`).join('\n')}\n`
: ''
return `You are a professional translator for DOXA, a Christian mission organization mobilizing prayer for unreached people groups. Translate marketing and informational website copy from ${source} into ${target}.
Rules:
- Use natural, idiomatic ${target} in a warm but professional register suited to a public website.
- The numbered fragments are consecutive pieces of one document; formatting may split a sentence across fragments. Translate each fragment so that the concatenated result reads naturally.
- Return exactly one translation per fragment, in the same order. Never merge, split, reorder, or skip fragments, and add no commentary.
- If a fragment begins or ends with whitespace, preserve that whitespace.
- Preserve any markup, placeholders, or URLs exactly as they appear.
- Render names of people, places, and Scripture references using standard ${target} conventions.
${glossaryBlock}
Respond with a JSON object of the form {"translations": ["...", "..."]} containing exactly ${fragmentCount} strings.`
}
interface ChatCompletionResponse {
choices?: Array<{ message?: { content?: string } }>
error?: { message?: string }
}
/**
* Failure of an OpenRouter request. The message is written for the admin UI,
* and `retryable` says whether a later attempt could plausibly succeed —
* false for anything that needs a human (no credits, bad key, rejected model).
*/
export class OpenRouterError extends Error {
constructor(message: string, readonly status: number | null, readonly retryable: boolean) {
super(message)
this.name = 'OpenRouterError'
}
}
function providerMessage(body: string): string {
try {
const message = JSON.parse(body)?.error?.message
if (typeof message === 'string') return message
} catch {
// error bodies are not always JSON
}
return body.slice(0, 200)
}
function toOpenRouterError(status: number, body: string, model: string): OpenRouterError {
console.error(`[Translate] OpenRouter ${status} for ${model}: ${body.slice(0, 500)}`)
if (status === 402) {
return new OpenRouterError('OpenRouter account has no credits — add credits at https://openrouter.ai/settings/credits', status, false)
}
if (status === 401 || status === 403) {
return new OpenRouterError('OpenRouter rejected the API key — check OPENROUTER_API_KEY', status, false)
}
if (status === 400 || status === 404 || status === 422) {
return new OpenRouterError(`OpenRouter did not accept the request for model "${model}": ${providerMessage(body)}`, status, false)
}
return new OpenRouterError(`OpenRouter is temporarily unavailable (HTTP ${status}) — try again in a moment`, status, true)
}
function parseTranslations(content: string, expectedCount: number): string[] {
// Some models wrap JSON output in a markdown code fence despite json_object mode
const raw = content.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '')
const parsed = JSON.parse(raw)
const translations = parsed?.translations
if (!Array.isArray(translations) || translations.length !== expectedCount || !translations.every((t: unknown) => typeof t === 'string')) {
throw new Error(`Expected ${expectedCount} translated fragments, got ${Array.isArray(translations) ? translations.length : 'invalid output'}`)
}
return translations
}
/**
* Translate an array of text fragments in a single model request.
* Returns translations in the same order as the input.
*/
export async function openrouterTranslateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage: string = 'en'
): Promise<string[]> {
if (texts.length === 0) return []
const config = useRuntimeConfig()
const apiKey = config.openrouterApiKey
if (!apiKey) {
throw new OpenRouterError('OpenRouter is not configured — set OPENROUTER_API_KEY', null, false)
}
const model = await getTranslationModel(targetLanguage)
const body = JSON.stringify({
model,
messages: [
{ role: 'system', content: buildSystemPrompt(targetLanguage, sourceLanguage, texts.length) },
{ role: 'user', content: JSON.stringify({ fragments: texts }) }
],
response_format: { type: 'json_object' },
// Translation is mechanical — the glossary and the output contract are both
// in the system prompt, so deep reasoning adds cost without adding accuracy.
// Low rather than off: with thinking disabled, Claude models can leak
// <thinking> tags into the response, which here would corrupt a fragment.
reasoning: { effort: 'low' }
})
console.log(`[Translate] ${model}: ${texts.length} fragments → ${targetLanguage}`)
// The fragment-count contract occasionally fails on a first attempt; one
// retry recovers those cases before surfacing an error to the caller.
let lastError: Error | undefined
for (let attempt = 1; attempt <= 2; attempt++) {
let response: Response
try {
response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Title': 'DOXA Marketing'
},
body
})
} catch (e: any) {
console.error(`[Translate] could not reach OpenRouter: ${e?.message}`)
throw new OpenRouterError('Could not reach OpenRouter — try again in a moment', null, true)
}
if (!response.ok) {
throw toOpenRouterError(response.status, await response.text(), model)
}
const data = await response.json() as ChatCompletionResponse
const content = data.choices?.[0]?.message?.content
if (!content) {
throw new OpenRouterError(`OpenRouter returned no translation${data.error?.message ? `: ${data.error.message}` : ''}`, response.status, true)
}
try {
return parseTranslations(content, texts.length)
} catch (e: any) {
lastError = e
console.warn(`[Translate] attempt ${attempt} failed for ${targetLanguage}: ${e?.message}`)
}
}
throw new OpenRouterError(`Translation into "${targetLanguage}" failed: ${lastError?.message}`, null, true)
}

View file

@ -10,6 +10,16 @@ const settingDefs = {
default: true,
public: true,
},
// OpenRouter model id used for CMS auto-translation. Empty means "no
// override" — resolution then falls through to TRANSLATION_MODEL and the
// code default in server/utils/openrouter.ts. Free-text on purpose: the
// value is passed straight to the OpenRouter API, so a newly released model
// can be adopted by typing its id.
'translation.model': {
schema: z.string(),
default: '',
public: false,
},
} as const
export type SettingKey = keyof typeof settingDefs

108
server/utils/translate.ts Normal file
View file

@ -0,0 +1,108 @@
// Translation utility — translates plain text, batched text, and Tiptap JSON
// documents with an LLM via OpenRouter. Slim counterpart of
// campaigns-sever/server/utils/translate.ts, without the Bible / verse-node
// handling since marketing-rebuild has no verse content.
import { openrouterTranslateTexts, isOpenRouterConfigured } from './openrouter'
export interface TiptapNode {
type: string
content?: TiptapNode[]
text?: string
marks?: any[]
attrs?: Record<string, any>
}
export function isTranslationConfigured(): boolean {
return isOpenRouterConfigured()
}
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string
): Promise<string> {
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
return translated[0] ?? ''
}
export async function translateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage?: string
): Promise<string[]> {
if (texts.length === 0) return []
// Blank fragments never reach the model: it has nothing to translate and
// tends to drop them, which would break the one-translation-per-fragment
// count the caller relies on to map results back.
const indexes: number[] = []
const translatable: string[] = []
texts.forEach((text, i) => {
if (text.trim().length > 0) {
indexes.push(i)
translatable.push(text)
}
})
if (translatable.length === 0) return [...texts]
const translated = await openrouterTranslateTexts(translatable, targetLanguage, sourceLanguage)
const out = [...texts]
indexes.forEach((target, i) => { out[target] = translated[i]! })
return out
}
// Extract plain text nodes from a Tiptap JSON tree so we can send them
// to the translator as a batch, then write the translations back at the
// same positions.
export function extractTexts(
node: TiptapNode,
path: number[] = []
): Array<{ path: number[]; text: string }> {
const results: Array<{ path: number[]; text: string }> = []
if (node.type === 'text' && node.text) {
results.push({ path: [...path], text: node.text })
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach((child, index) => {
results.push(...extractTexts(child, [...path, index]))
})
}
return results
}
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
if (path.length === 0) {
node.text = text
return
}
const [index, ...rest] = path as [number, ...number[]]
if (node.content && node.content[index]) {
setTextAtPath(node.content[index], rest, text)
}
}
export async function translateTiptapContent(
contentJson: TiptapNode,
targetLanguage: string,
sourceLanguage?: string
): Promise<TiptapNode> {
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
const entries = extractTexts(cloned)
if (entries.length === 0) return cloned
// Chunk size balances request count against the fragment-alignment
// contract, which gets harder for the model to honor on long batches
const CHUNK_SIZE = 40
const allTranslated: string[] = []
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
if (i > 0) await new Promise(r => setTimeout(r, 200))
const chunk = entries.slice(i, i + CHUNK_SIZE)
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
allTranslated.push(...translated)
}
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
return cloned
}