Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions cloudflare_workers/translation/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { D1Database, ExecutionContext, MessageBatch, Queue } from '@cloudflare/workers-types'
import sourceMessageContexts from '../../messages/en.context.json' with { type: 'json' }
import sourceMessages from '../../messages/en.json' with { type: 'json' }

const CACHE_TTL_SECONDS = 5 * 60
Expand Down Expand Up @@ -115,9 +116,14 @@ interface TranslationQueuePayload {
targetLanguage?: string
}

type MessageEntry = [string, string]
type MessageEntry = [string, string, string]
type TranslationStoreEntryInput = Omit<TranslationStoreEntry, 'updatedAt'>

interface TranslationPromptMessage {
context?: string
text: string
}

interface ReadyTranslationWriteInput {
batchCount?: number
checksum: string
Expand Down Expand Up @@ -153,8 +159,20 @@ class PublicHttpError extends Error {
}
}

const sourceMessageCatalog = sourceMessages as Record<string, string>
const sourceCatalogChecksumPromise = sha256Hex(JSON.stringify(sourceMessageCatalog)) // NOSONAR: top-level await is disallowed by lint config.
function catalogWithoutSchema(messages: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(messages).filter((entry): entry is [string, string] => entry[0] !== '$schema' && typeof entry[1] === 'string'),
)
}

const sourceMessageCatalog = catalogWithoutSchema(sourceMessages as Record<string, unknown>)
const sourceMessageContextCatalog = catalogWithoutSchema(sourceMessageContexts as Record<string, unknown>)
// Context uses stable folder areas + UI role (not file names), so renames inside a
// folder do not invalidate translation caches. Area/role changes still should.
const sourceCatalogChecksumPromise = sha256Hex(JSON.stringify({
contexts: sourceMessageContextCatalog,
Comment thread
cursor[bot] marked this conversation as resolved.
messages: sourceMessageCatalog,
})) // NOSONAR: top-level await is disallowed by lint config.
let translationStoreInitialized = false
let lastTranslationStoreCleanupAt = 0

Expand Down Expand Up @@ -384,7 +402,12 @@ function shouldFlushBatch(current: MessageEntry[], currentCharacters: number, ne
return current.length > 0 && (current.length >= MAX_BATCH_ITEMS || currentCharacters + nextCharacters > MAX_BATCH_CHARACTERS)
}

function buildBatches(messages: Record<string, string>) {
function messageContextFor(key: string, contexts: Record<string, string> = sourceMessageContextCatalog) {
const context = contexts[key]
return typeof context === 'string' ? context.trim() : ''
}

function buildBatches(messages: Record<string, string>, contexts: Record<string, string> = sourceMessageContextCatalog) {
const batches: MessageEntry[][] = []
let current: MessageEntry[] = []
let currentCharacters = 0
Expand All @@ -398,8 +421,9 @@ function buildBatches(messages: Record<string, string>) {
}

for (const [key, message] of Object.entries(messages)) {
const entry: MessageEntry = [key, message]
const entryCharacters = key.length + message.length
const context = messageContextFor(key, contexts)
const entry: MessageEntry = [key, message, context]
const entryCharacters = key.length + message.length + context.length
if (shouldFlushBatch(current, currentCharacters, entryCharacters))
flush()
current.push(entry)
Expand Down Expand Up @@ -428,11 +452,24 @@ function translationPrompt(targetLanguage: string) {
return [
`Translate Capgo application UI messages from English to ${targetLanguageLabel(targetLanguage)}.`,
'Return JSON only, with a translations object keyed by the exact input keys.',
'Each input value is an object with text (translate this) and optional context (where/how the text is used in the Capgo console UI).',
'Use context to disambiguate meaning, tone, and part of speech (button label vs title vs status vs empty state).',
'Translate only the text field. Do not translate or copy context into the output.',
'Translate user-facing text naturally. Keep product names, code, URLs, commands, numbers, and placeholders unchanged.',
'Every placeholder like {count}, %name%, or $1 must be copied exactly.',
].join(' ')
}

function translationBatchPayload(batch: MessageEntry[]) {
const messages: Record<string, TranslationPromptMessage> = {}
for (const [key, text, context] of batch) {
messages[key] = context
? { text, context }
: { text }
}
return { messages }
}

function translationRequest(targetLanguage: string, batch: MessageEntry[]) {
return {
temperature: 0,
Expand All @@ -448,7 +485,7 @@ function translationRequest(targetLanguage: string, batch: MessageEntry[]) {
},
{
role: 'user',
content: JSON.stringify({ messages: Object.fromEntries(batch) }),
content: JSON.stringify(translationBatchPayload(batch)),
},
],
}
Expand Down Expand Up @@ -1351,7 +1388,9 @@ export const __translationWorkerTestUtils__ = {
keepTranslation,
normalizeBatchIndex,
parseTranslationObject,
translationBatchPayload,
translationBatchClaimMarker,
translationBatchIndexFromStore,
translationPrompt,
translationStoreTtlSeconds,
}
15 changes: 14 additions & 1 deletion messages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@

This directory keeps the English source catalog used by Vue i18n.

Non-English catalogs are generated on demand by the backend from `messages/en.json` and cached by source checksum for a short time, so only `en.json` is committed.
- `en.json` — English UI strings (source of truth for the app).
- `en.context.json` — translator context for each key (UI role + stable console area).

Non-English catalogs are generated on demand by the translation worker from `en.json`, using `en.context.json` to disambiguate meaning. Results are cached by a checksum of both files, so only English sources are committed.

Context areas are folder-based (not file names), so renames inside the same folder do not churn translation caches.

Regenerate contexts after adding or moving keys:

```bash
bun run i18n:contexts
```
Comment thread
cursor[bot] marked this conversation as resolved.

Do not put context inside `en.json`: the inlang message-format schema and Vue i18n expect string values only.
Loading
Loading