Create custom Translation component for better i18n handling (#4431)

* Initial plan

* Implement Translation component with Storybook stories

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Add Jest test for Translation component and demonstrate ?lang=de functionality

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Javascript formatting autofixes

* Create centralized type-safe localization system

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Add @testing-library/react to Translation component tests

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Fix code formatting errors with prettier and eslint

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Revert "Fix code formatting errors with prettier and eslint"

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* fix(js): eslinter errors

* fix(js): unused code warnings

* fix(js): fix additional warnings

* Update Emoji admin page to use new Translation component

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Organize localization keys by logical sections (Frontend, Admin, Common, Testing)

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Organize localization keys by TypeScript namespaces

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Javascript formatting autofixes

* feat(js): add support for default translated text

* chore: add default lang translations on commit

* fix(js): unused code warnings

* Update OfflineBanner component to use new Translation component

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* fix(js): fix localization extraction job

* chore(js): remove ts-node cli

* fix(css): fix css warning

* feat(js): add some additional translation strings via component

* chore: update extracted translations

* test: add tests for Translation component defaultText and fallback behavior

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* chore: update extracted translations

* Javascript formatting autofixes

* chore: call out new Translation component

* fix: linter warning

* chore: updated instructions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gabek <414923+gabek@users.noreply.github.com>
Co-authored-by: Owncast <owncast@owncast.online>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
This commit is contained in:
Copilot
2025-07-13 02:21:46 -07:00
committed by GitHub
co-authored by gabek copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Owncast Gabe Kangas
parent 7b181aa0a6
commit 29b5100114
23 changed files with 2287 additions and 325 deletions
+119
View File
@@ -0,0 +1,119 @@
/* eslint-disable no-continue */
/* eslint-disable no-restricted-syntax */
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const TRANSLATIONS_PATH = path.join(process.cwd(), 'i18n/en/translation.json');
function getDotPath(node) {
if (node.type === 'MemberExpression') {
const objectPath = getDotPath(node.object);
const prop = node.property.name || node.property.value;
if (objectPath !== null && prop) {
return objectPath ? `${objectPath}.${prop}` : prop; // skip base if empty
}
} else if (node.type === 'Identifier' && node.name === 'Localization') {
return ''; // treat as base, skip
}
return null;
}
function scanTranslationKeys() {
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
ignore: ['node_modules/**', '.next/**', 'out/**'],
});
const results = {};
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
let ast;
try {
ast = parser.parse(source, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
} catch (e) {
console.warn(`[parse error] ${file}: ${e.message}`);
continue;
}
traverse(ast, {
JSXElement(p) {
const opening = p.node.openingElement;
const tagName = opening.name;
if (tagName.type !== 'JSXIdentifier' || tagName.name !== 'Translation') return;
let key = null;
let defaultText = null;
for (const attr of opening.attributes) {
if (attr.type !== 'JSXAttribute') continue;
const attrName = attr.name.name;
const { value } = attr;
if (!value) continue;
if (attrName === 'translationKey') {
if (value.expression) {
const dotPath = getDotPath(value.expression);
if (dotPath) {
key = dotPath;
}
} else if (value.type === 'StringLiteral') {
key = value.value;
}
}
if (attrName === 'defaultText' && value.type === 'StringLiteral') {
defaultText = value.value;
}
}
if (key && defaultText && !results[key]) {
results[key] = defaultText;
}
},
});
}
return results;
}
function updateTranslationFile(newTranslations) {
let existing = {};
if (fs.existsSync(TRANSLATIONS_PATH)) {
existing = JSON.parse(fs.readFileSync(TRANSLATIONS_PATH, 'utf8'));
}
let changed = false;
for (const [key, value] of Object.entries(newTranslations)) {
if (!(key in existing)) {
existing[key] = value;
changed = true;
console.log(`[i18n] Added: ${key}`);
}
}
if (changed) {
const sorted = Object.fromEntries(
Object.entries(existing).sort(([a], [b]) => a.localeCompare(b)),
);
fs.writeFileSync(TRANSLATIONS_PATH, JSON.stringify(sorted, null, 2));
console.log(`[i18n] Updated ${TRANSLATIONS_PATH}`);
} else {
console.log('[i18n] No new keys to add.');
}
}
const extracted = scanTranslationKeys();
updateTranslationFile(extracted);