Add Scandinavian i18n (da/sv/nb) via DeepL pipeline.

Integrate new locale bundles, language cycling in the UI, SEO hreflang tags, and localized beta flyer HTML variants with scripts for batch translation and key validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 15:53:43 +02:00
parent 2e656dc6b2
commit 3749f87c1d
30 changed files with 3975 additions and 42 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* Shared DeepL API helpers for batch translation scripts.
* @see https://developers.deepl.com/docs/getting-started/quickstart
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../..')
/** Terms that must not be translated (product, tech, brands). */
export const NO_TRANSLATE_TERMS = [
'Kapteins Daagbok',
'Kiel.Sailing.City.',
'KnorrLabs',
'Markus F.J. Busche',
'kapteins-daagbok.eu',
'elpatron+kd@mailbox.org',
'GPX/KML',
'GPX',
'KML',
'PDF',
'CSV',
'PWA',
'E2E',
'Passkey',
'OpenWeatherMap',
'Safari',
'iPad',
'iPhone',
'Android',
'Knorrstr. 16 · 24106 Kiel'
]
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
export function loadEnvKey() {
const fromEnv = process.env.DEEPL_API_KEY ?? process.env.DeepLAPIKey
if (fromEnv) return fromEnv.trim()
try {
const envPath = resolve(repoRoot, '.env')
const content = readFileSync(envPath, 'utf8')
for (const line of content.split('\n')) {
const match = line.match(/^(?:DEEPL_API_KEY|DeepLAPIKey)\s*=\s*(.+)$/)
if (match) return match[1].trim()
}
} catch {
// .env optional when key is exported in shell
}
throw new Error('DeepL API key missing. Set DEEPL_API_KEY or DeepLAPIKey in .env')
}
export function resolveApiUrl(apiKey) {
if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL
return apiKey.endsWith(':fx')
? 'https://api-free.deepl.com/v2/translate'
: 'https://api.deepl.com/v2/translate'
}
export function protectText(text) {
const segments = []
let protectedText = text.replace(PLACEHOLDER_RE, (match) => {
const id = segments.length
segments.push(match)
return `__X${id}__`
})
for (const term of NO_TRANSLATE_TERMS) {
if (!protectedText.includes(term)) continue
const id = segments.length
segments.push(term)
protectedText = protectedText.split(term).join(`__X${id}__`)
}
return { text: protectedText, segments }
}
export function restoreText(text, segments) {
let restored = text
segments.forEach((value, id) => {
restored = restored.replaceAll(`__X${id}__`, value)
restored = restored.replaceAll(`__ X ${id} __`, value)
restored = restored.replaceAll(`__X ${id}__`, value)
})
return restored
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
}
export async function translateBatch(texts, targetLang, { sourceLang = 'DE', apiKey, retries = 3 } = {}) {
if (texts.length === 0) return []
const key = apiKey ?? loadEnvKey()
const url = resolveApiUrl(key)
const protectedEntries = texts.map((text) => protectText(text))
const body = {
text: protectedEntries.map((entry) => entry.text),
source_lang: sourceLang,
target_lang: targetLang
}
for (let attempt = 0; attempt < retries; attempt++) {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${key}`,
'Content-Type': 'application/json',
'User-Agent': 'kapteins-daagbok-translate/1.0'
},
body: JSON.stringify(body)
})
if (response.status === 429 && attempt < retries - 1) {
const waitMs = 1500 * (attempt + 1)
console.warn(`Rate limited — waiting ${waitMs}ms…`)
await sleep(waitMs)
continue
}
if (!response.ok) {
const detail = await response.text()
throw new Error(`DeepL error ${response.status}: ${detail}`)
}
const payload = await response.json()
return payload.translations.map((item, index) =>
restoreText(item.text, protectedEntries[index].segments)
)
}
throw new Error('DeepL translation failed after retries')
}
export async function translateTexts(texts, targetLang, options = {}) {
const batchSize = options.batchSize ?? 40
const results = []
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize)
const translated = await translateBatch(batch, targetLang, options)
results.push(...translated)
if (i + batchSize < texts.length) {
process.stdout.write(` ${Math.min(i + batchSize, texts.length)}/${texts.length}\r`)
await sleep(300)
}
}
process.stdout.write(` ${texts.length}/${texts.length}\n`)
return results
}
export function flattenTranslation(obj, prefix = '') {
const entries = []
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
entries.push(...flattenTranslation(value, path))
} else if (typeof value === 'string') {
entries.push([path, value])
}
}
return entries
}
export function unflattenTranslation(entries) {
const root = {}
for (const [path, value] of entries) {
const parts = path.split('.')
let node = root
for (let i = 0; i < parts.length - 1; i++) {
node[parts[i]] ??= {}
node = node[parts[i]]
}
node[parts[parts.length - 1]] = value
}
return root
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Generate localized beta flyer HTML files from the German master via DeepL.
*
* Usage: node scripts/translate-flyer.mjs [--lang da,sv,nb]
*/
import { readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadEnvKey, translateTexts } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const sourcePath = resolve(repoRoot, 'docs/marketing/beta-flyer.html')
const TARGETS = {
da: { code: 'DA', htmlLang: 'da', file: 'beta-flyer.da.html' },
sv: { code: 'SV', htmlLang: 'sv', file: 'beta-flyer.sv.html' },
nb: { code: 'NB', htmlLang: 'nb', file: 'beta-flyer.nb.html' }
}
/** Extract translatable text segments from HTML (text nodes only). */
function extractSegments(html) {
const segments = []
const re = />([^<]+)</g
let match
while ((match = re.exec(html)) !== null) {
const text = match[1]
if (!text.trim()) continue
if (/^\s*$/.test(text)) continue
segments.push({ text, index: segments.length })
}
return segments
}
function replaceSegments(html, originals, translated) {
let result = html
for (let i = 0; i < originals.length; i++) {
const from = originals[i].text
const to = translated[i]
if (from === to) continue
result = result.replace(`>${from}<`, `>${to}<`)
}
return result
}
function patchLanguageFeature(html, lang) {
const langBlocks = {
da: `<span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span>`,
sv: `<span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span>`,
nb: `<span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span>`
}
const deEnBlock =
/<span class="lang-list">[\s\S]*?<\/span><\/span><\/div>/
const replacement = `<span class="lang-list">${langBlocks[lang]}<span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Engelsk</span></span></div>`
return html.replace(deEnBlock, replacement)
}
function parseArgs(argv) {
let langs = Object.keys(TARGETS)
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--lang' && argv[i + 1]) {
langs = argv[++i].split(',').map((l) => l.trim())
}
}
return langs
}
async function main() {
const langs = parseArgs(process.argv)
const apiKey = loadEnvKey()
const sourceHtml = await readFile(sourcePath, 'utf8')
const segments = extractSegments(sourceHtml)
const texts = segments.map((s) => s.text)
for (const lang of langs) {
const target = TARGETS[lang]
if (!target) {
console.error(`Unknown language: ${lang}`)
process.exit(1)
}
console.log(`\n${target.file}`)
const translated = await translateTexts(texts, target.code, {
sourceLang: 'DE',
apiKey,
batchSize: 20
})
let html = replaceSegments(sourceHtml, segments, translated)
html = html.replace(/<html lang="de">/, `<html lang="${target.htmlLang}">`)
html = html.replace(
/<title>Kapteins Daagbok — Beta-Flyer<\/title>/,
`<title>Kapteins Daagbok — Beta-Flyer (${target.htmlLang.toUpperCase()})</title>`
)
html = patchLanguageFeature(html, lang)
const outPath = resolve(repoRoot, 'docs/marketing', target.file)
await writeFile(outPath, html, 'utf8')
console.log(`Wrote ${outPath}`)
}
}
main().catch((err) => {
console.error(err.message ?? err)
process.exit(1)
})
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Translate i18n locale JSON from German master via DeepL.
*
* Usage:
* node scripts/translate-locales.mjs [--lang da,sv,nb] [--source client/src/i18n/locales/de.json]
*/
import { readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
flattenTranslation,
loadEnvKey,
translateTexts,
unflattenTranslation
} from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
const TARGETS = {
da: 'DA',
sv: 'SV',
nb: 'NB'
}
/** Keys whose values stay identical to source (language names, brand). */
const COPY_AS_IS_PREFIXES = ['languages.', 'app.name']
function parseArgs(argv) {
let langs = Object.keys(TARGETS)
let sourcePath = defaultSource
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--lang' && argv[i + 1]) {
langs = argv[++i].split(',').map((l) => l.trim())
} else if (argv[i] === '--source' && argv[i + 1]) {
sourcePath = resolve(repoRoot, argv[++i])
}
}
return { langs, sourcePath }
}
function shouldCopyAsIs(path) {
return COPY_AS_IS_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix))
}
async function translateLocale(sourceJson, langCode, apiKey) {
const entries = flattenTranslation(sourceJson.translation)
const toTranslate = []
const indices = []
entries.forEach(([path, value], index) => {
if (shouldCopyAsIs(path)) return
toTranslate.push(value)
indices.push(index)
})
console.log(`Translating ${toTranslate.length} strings to ${langCode.toUpperCase()}`)
const translated = await translateTexts(toTranslate, TARGETS[langCode], {
sourceLang: 'DE',
apiKey
})
const resultEntries = [...entries]
indices.forEach((entryIndex, i) => {
resultEntries[entryIndex][1] = translated[i]
})
return { translation: unflattenTranslation(resultEntries) }
}
async function main() {
const { langs, sourcePath } = parseArgs(process.argv)
const apiKey = loadEnvKey()
const sourceRaw = await readFile(sourcePath, 'utf8')
const sourceJson = JSON.parse(sourceRaw)
for (const lang of langs) {
if (!TARGETS[lang]) {
console.error(`Unknown language: ${lang}`)
process.exit(1)
}
const outPath = resolve(repoRoot, `client/src/i18n/locales/${lang}.json`)
console.log(`\n${lang}.json`)
const translated = await translateLocale(sourceJson, lang, apiKey)
await writeFile(outPath, `${JSON.stringify(translated, null, 2)}\n`, 'utf8')
console.log(`Wrote ${outPath}`)
}
}
main().catch((err) => {
console.error(err.message ?? err)
process.exit(1)
})
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Verify all locale JSON files have identical key sets.
* Usage: node scripts/validate-i18n-keys.mjs
*/
import { readFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { flattenTranslation } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
async function loadKeys(filename) {
const raw = await readFile(resolve(localesDir, filename), 'utf8')
const json = JSON.parse(raw)
return flattenTranslation(json.translation).map(([path]) => path).sort()
}
async function main() {
const keySets = {}
for (const file of localeFiles) {
keySets[file] = await loadKeys(file)
}
const master = keySets['de.json']
let failed = false
for (const file of localeFiles) {
if (file === 'de.json') continue
const keys = keySets[file]
const missing = master.filter((k) => !keys.includes(k))
const extra = keys.filter((k) => !master.includes(k))
if (missing.length || extra.length) {
failed = true
console.error(`\n${file}:`)
if (missing.length) console.error(` missing (${missing.length}):`, missing.slice(0, 10).join(', '))
if (extra.length) console.error(` extra (${extra.length}):`, extra.slice(0, 10).join(', '))
} else {
console.log(`${file}: OK (${keys.length} keys)`)
}
}
if (failed) process.exit(1)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})