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. # Set to empty in dev to make the cookie host-only.
NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life
# DeepL (used by the CMS "Translate from English" workflow on /admin/pages/:id) # OpenRouter (used by the CMS "Auto-translate" workflow on /admin/pages/:id
# Pro endpoint: https://api.deepl.com | Free-tier endpoint: https://api-free.deepl.com # and the translate_text / translate_page MCP tools)
DEEPL_API_KEY=your-deepl-api-key OPENROUTER_API_KEY=your-openrouter-api-key
DEEPL_API_URL=https://api.deepl.com # 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). # Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
# On container boot the edge cache is purged so stale prerendered HTML can't # 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. // replace editor content when the prop drifts from the editor state.
watch( watch(
() => props.modelValue, () => props.modelValue,

View file

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

View file

@ -6,6 +6,7 @@ definePageMeta({
interface SettingsResponse { interface SettingsResponse {
'auth.public_registration_enabled'?: boolean 'auth.public_registration_enabled'?: boolean
'translation.model'?: string
} }
const { hasPermission } = usePermissions() const { hasPermission } = usePermissions()
@ -31,6 +32,42 @@ const registrationEnabled = computed({
const saving = ref(false) 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) => { const handleToggleRegistration = async (next: boolean) => {
if (!canEdit.value || saving.value) return if (!canEdit.value || saving.value) return
const previous = registrationEnabled.value const previous = registrationEnabled.value
@ -102,6 +139,40 @@ const handleToggleRegistration = async (next: boolean) => {
</div> </div>
</UCard> </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)"> <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. You can view settings but not change them. Contact an administrator if you need to make changes.
</p> </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 nativeName: string // Name in the language itself
flag: string flag: string
dir?: 'ltr' | 'rtl' // Text direction (defaults to 'ltr') 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 bibleId?: string // Bolls.life translation ID for verse lookups
bibleLabel?: string // Display label for the Bible translation (defaults to bibleId) 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) 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 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) 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 // but hidden from the UI language selector, translation targets, and admin content
// find translations: https://bolls.life/static/bolls/app/views/languages.json // find translations: https://bolls.life/static/bolls/app/views/languages.json
export const LANGUAGES: Language[] = [ export const LANGUAGES: Language[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', deeplTarget: 'EN', deeplSource: 'EN', bibleId: 'NKJV' }, { code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', bibleId: 'NKJV' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', deeplTarget: 'ES', deeplSource: 'ES', bibleId: 'NVI', glossaryId: 'b867e526-2eb8-426f-a178-b8a6b9d4d6ce' }, //RV1960 { code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', bibleId: 'NVI' }, //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: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', bibleId: 'FRLSG', bibleLabel: 'LSG' }, //maybe BDS
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', deeplTarget: 'PT-BR', deeplSource: 'PT', bibleId: 'NAA', glossaryId: 'bbe0697f-429a-45f7-9c30-576c7f21c78d' }, { code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', bibleId: 'NAA', translationName: 'Brazilian Portuguese' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', deeplTarget: 'DE', deeplSource: 'DE', bibleId: 'S00', bibleLabel: 'SCH2000', glossaryId: '87ad31eb-1a58-4f91-b80e-943b3721065d', hideFromSwitcher: true }, { code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', bibleId: 'S00', bibleLabel: 'SCH2000', hideFromSwitcher: true },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', deeplTarget: 'IT', deeplSource: 'IT', bibleId: 'NR06', glossaryId: '98dca739-468a-4c29-a0ce-ba89f60da00b', enabled: false }, { code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', bibleId: 'NR06', 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: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', bibleId: 'CUNPS', translationName: 'Simplified Chinese', 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: '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: '🇷🇺', deeplTarget: 'RU', deeplSource: 'RU', bibleId: 'SYNOD', glossaryId: '7363b815-3cd1-4e1a-ae8b-d6695e6dbc4a', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative { code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', bibleId: 'SYNOD', 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: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', 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: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', bibleId: 'NTR', enabled: false },
] ]
// All language codes // All language codes
@ -57,18 +56,6 @@ export function getLanguage(code: string): Language | undefined {
return LANGUAGES.find(l => l.code === code) 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). // 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, // 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 // 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 // unset, the endpoint falls back to public.mapboxToken below so
// existing setups keep working with no config change. // existing setups keep working with no config change.
mapboxKey: process.env.NUXT_MAPBOX_KEY || '', mapboxKey: process.env.NUXT_MAPBOX_KEY || '',
deeplApiKey: process.env.DEEPL_API_KEY || '', // OpenRouter — LLM content translation. TRANSLATION_MODEL overrides the
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api.deepl.com', // 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). // Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
// Token needs only Zone → Cache Purge for the doxa.life zone. // Token needs only Zone → Cache Purge for the doxa.life zone.
cfApiToken: process.env.CF_API_TOKEN || '', 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 // Admin: one-click translate an EN (or other source) translation of a
// CMS page into one or more target locales. Uses DeepL for title, // CMS page into one or more target locales. Translates title, excerpt,
// excerpt, meta_title, meta_description, and body_json. Each target // meta_title, meta_description, and body_json. Each target translation is
// translation is upserted with the caller-chosen `status` (draft or // upserted with the caller-chosen `status` (draft or published, default
// published, default draft). `overwrite` controls whether to replace // draft). `overwrite` controls whether to replace translations that
// translations that already exist for a given locale. // 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. // validation, per-locale cache purge — lives in cmsTranslate.translatePage.
// This route stays thin. // This route stays thin.

View file

@ -87,7 +87,9 @@ export interface PageTranslationsTable {
updated: ColumnType<Date, Date | string | undefined, Date | string> 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 { export interface PageTranslationVersionsTable {
id: Generated<string> 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. // with upsert_page_translation when applying translations to pages.
// //
// The configured DeepL glossary for each target locale is always // The vendored glossary for each target locale is always injected into
// applied (the translateText service consults config/languages.ts), // the translation prompt, keeping MCP-driven translations terminologically
// keeping MCP-driven translations terminologically consistent with // consistent with admin auto-translates.
// admin auto-translates.
import { defineMcpTool, mcpLog } from '#mcp-layer' import { defineMcpTool, mcpLog } from '#mcp-layer'
import { translateTextInput, translatePageInput } from '../schemas' import { translateTextInput, translatePageInput } from '../schemas'
import { translateText, translatePage } from '../../services/cmsTranslate' 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." 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 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 // happened on Claude's behalf. Don't include the full text in
// metadata — keep the audit row small and non-PII-leaky. // 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, { 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 ──────────────────────────────────────────── // ── 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. 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: Inputs:
- \`page_id\`: the CMS page id. - \`page_id\`: the CMS page id.
- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row. - \`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. - \`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 DeepL call). Set true to replace. - \`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. - \`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. 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({ export const translatePageTool = defineMcpTool({
name: 'translate_page', name: 'translate_page',

View file

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