diff --git a/web/.knip.json b/web/.knip.json index 55e61bcd3c..4983ec5cc8 100644 --- a/web/.knip.json +++ b/web/.knip.json @@ -52,7 +52,6 @@ "@babel/preset-react", "@babel/core", "i18next-scanner", - "@types/chart.js", "@types/video.js", "@testing-library/jest-dom", "@testing-library/react", diff --git a/web/components/admin/LogTable.tsx b/web/components/admin/LogTable.tsx index b484d416d8..49f4ca1fa6 100644 --- a/web/components/admin/LogTable.tsx +++ b/web/components/admin/LogTable.tsx @@ -4,6 +4,7 @@ import Linkify from 'react-linkify'; import { SortOrder, TablePaginationConfig } from 'antd/lib/table/interface'; import { format } from 'date-fns'; import { useTranslation } from 'next-export-i18n'; +import { Localization } from '../../types/localization'; const { Title } = Typography; @@ -19,10 +20,6 @@ function renderColumnLevel(text, entry) { return {text}; } -function renderMessage(text) { - return {text}; -} - export type LogTableProps = { logs: object[]; initialPageSize: number; @@ -42,49 +39,50 @@ export const LogTable: FC = ({ logs, initialPageSize }) => { const columns = [ { - title: t('Level'), + title: t(Localization.Admin.LogTable.level), dataIndex: 'level', key: 'level', filters: [ { - text: t('Info'), + text: t(Localization.Admin.LogTable.info), value: 'info', }, { - text: t('Warning'), + text: t(Localization.Admin.LogTable.warning), value: 'warning', }, { - text: t('Error'), - value: 'Error', + text: t(Localization.Admin.LogTable.error), + value: 'error', }, ], - onFilter: (level, row) => row.level.indexOf(level) === 0, + onFilter: (level, row) => row.level === level, render: renderColumnLevel, }, { - title: t('Timestamp'), + title: t(Localization.Admin.LogTable.timestamp), dataIndex: 'time', key: 'time', - render: timestamp => { + render: (timestamp: Date) => { const dateObject = new Date(timestamp); - return format(dateObject, 'pp P'); + return format(dateObject, 'p P'); }, - sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), + sorter: (a: any, b: any) => new Date(a.time).getTime() - new Date(b.time).getTime(), sortDirections: ['descend', 'ascend'] as SortOrder[], defaultSortOrder: 'descend' as SortOrder, }, + { - title: t('Message'), + title: t(Localization.Admin.LogTable.message), dataIndex: 'message', key: 'message', - render: renderMessage, + render: (message: string) => {message}, }, ]; return (
- {t('Logs')} + {t(Localization.Admin.LogTable.logs)} = ({

{dateString} ( - {t('Link')} + {t(Localization.Admin.NewsFeed.link)} )

@@ -72,11 +73,12 @@ export const NewsFeed = () => { }, []); const loadingSpinner = loading ? : null; - const noNews = !loading && feed.length === 0 ?
{t('No news.')}
: null; + const noNews = + !loading && feed.length === 0 ?
{t(Localization.Admin.NewsFeed.noNews)}
: null; return (
- {t('News & Updates from Owncast')} + {t(Localization.Admin.NewsFeed.title)} {loadingSpinner} {feed.map(item => ( diff --git a/web/components/modals/NameChangeModal/NameChangeModal.tsx b/web/components/modals/NameChangeModal/NameChangeModal.tsx index 971238e46b..768d455e12 100644 --- a/web/components/modals/NameChangeModal/NameChangeModal.tsx +++ b/web/components/modals/NameChangeModal/NameChangeModal.tsx @@ -1,10 +1,13 @@ import React, { CSSProperties, FC, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { Input, Button, Select, Form } from 'antd'; +import { useTranslation } from 'next-export-i18n'; import { MessageType } from '../../../interfaces/socket-events'; import WebsocketService from '../../../services/websocket-service'; import { websocketServiceAtom, currentUserAtom } from '../../stores/ClientConfigStore'; import { validateDisplayName } from '../../../utils/displayNameValidation'; +import { Translation } from '../../ui/Translation/Translation'; +import { Localization } from '../../../types/localization'; import styles from './NameChangeModal.module.scss'; const { Option } = Select; @@ -28,6 +31,7 @@ type NameChangeModalProps = { }; export const NameChangeModal: FC = ({ closeModal }) => { + const { t } = useTranslation(); const currentUser = useRecoilValue(currentUserAtom); const websocketService = useRecoilValue(websocketServiceAtom); const [newName, setNewName] = useState(currentUser?.displayName || ''); @@ -70,11 +74,22 @@ export const NameChangeModal: FC = ({ closeModal }) => { websocketService.send(colorChange); }; - const showCount = info => (info.count > characterLimit ? 'Over limit' : ''); + const showCount = info => + info.count > characterLimit ? ( + + ) : ( + '' + ); const maxColor = 8; // 0...n const colorOptions = [...Array(maxColor)].map((_, i) => i); + const placeholderText = + t(Localization.Frontend.NameChangeModal.placeholder) || 'Your chat display name'; + const validation = validateDisplayName(newName, displayName, characterLimit); const saveButton = ( @@ -84,14 +99,20 @@ export const NameChangeModal: FC = ({ closeModal }) => { onClick={handleNameChange} disabled={!saveEnabled()} > - Change name + ); return (
- Your chat display name is what people see when you send chat messages. +
= ({ closeModal }) => { id="name-change-field" value={newName} onChange={e => setNewName(e.target.value)} - placeholder="Your chat display name" - aria-label="Your chat display name" + placeholder={placeholderText} + aria-label={placeholderText} showCount={{ formatter: showCount }} defaultValue={displayName} className={styles.inputGroup} @@ -109,7 +130,15 @@ export const NameChangeModal: FC = ({ closeModal }) => {
{validation.errorMessage}
)} - + + } + className={styles.colorChange} + >
- You can also authenticate an IndieAuth or Fediverse account via the "Authenticate" - menu. +
); diff --git a/web/components/ui/Footer/Footer.tsx b/web/components/ui/Footer/Footer.tsx index b2e810ab3c..9172cf1411 100644 --- a/web/components/ui/Footer/Footer.tsx +++ b/web/components/ui/Footer/Footer.tsx @@ -22,13 +22,13 @@ export const Footer: FC = () => { - {t('Documentation')} + {t(Localization.Frontend.Footer.documentation)} - {t('Contribute')} + {t(Localization.Frontend.Footer.contribute)} - {t('Source')} + {t(Localization.Frontend.Footer.source)} diff --git a/web/components/ui/Header/Header.tsx b/web/components/ui/Header/Header.tsx index 2557d2de17..00c4e1c582 100644 --- a/web/components/ui/Header/Header.tsx +++ b/web/components/ui/Header/Header.tsx @@ -4,6 +4,7 @@ import cn from 'classnames'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useTranslation } from 'next-export-i18n'; +import { Localization } from '../../../types/localization'; import styles from './Header.module.scss'; // Lazy loaded components @@ -34,18 +35,18 @@ export const Header: FC = ({ name, chatAvailable, chatDisa
{online ? ( - {t('Skip to player')} + {t(Localization.Frontend.Header.skipToPlayer)} ) : ( - {t('Skip to offline message')} + {t(Localization.Frontend.Header.skipToOfflineMessage)} )} - {t('Skip to page content')} + {t(Localization.Frontend.Header.skipToContent)} - {t('Skip to footer')} + {t(Localization.Frontend.Header.skipToFooter)}
- {t('Troubleshooting')} + {t(Localization.Admin.Help.troubleshooting)} - {t('Documentation')} + {t(Localization.Admin.Help.documentation)} - {t('Common tasks')} + {t(Localization.Admin.Help.commonTasks)} {questions.map(question => ( @@ -250,7 +251,7 @@ export default function Help() { ))} - {t('Other')} + {t(Localization.Admin.Help.other)} {otherResources.map(question => ( diff --git a/web/pages/admin/viewer-info.tsx b/web/pages/admin/viewer-info.tsx index 795a3cdab4..0631d5d64d 100644 --- a/web/pages/admin/viewer-info.tsx +++ b/web/pages/admin/viewer-info.tsx @@ -12,6 +12,7 @@ import { ServerStatusContext } from '../../utils/server-status-context'; import { VIEWERS_OVER_TIME, ACTIVE_VIEWER_DETAILS, fetchData } from '../../utils/apis'; import { AdminLayout } from '../../components/layouts/AdminLayout'; +import { Localization } from '../../types/localization'; // Lazy loaded components @@ -36,13 +37,13 @@ export default function ViewersOverTime() { } const times = [ - { title: t('Current stream'), start: streamStart }, - { title: t('Last 12 hours'), start: sub(new Date(), { hours: 12 }) }, - { title: t('Last 24 hours'), start: sub(new Date(), { hours: 24 }) }, - { title: t('Last 7 days'), start: sub(new Date(), { days: 7 }) }, - { title: t('Last 30 days'), start: sub(new Date(), { days: 30 }) }, - { title: t('Last 3 months'), start: sub(new Date(), { months: 3 }) }, - { title: t('Last 6 months'), start: sub(new Date(), { months: 6 }) }, + { title: t(Localization.Admin.ViewerInfo.currentStream), start: streamStart }, + { title: t(Localization.Admin.ViewerInfo.last12Hours), start: sub(new Date(), { hours: 12 }) }, + { title: t(Localization.Admin.ViewerInfo.last24Hours), start: sub(new Date(), { hours: 24 }) }, + { title: t(Localization.Admin.ViewerInfo.last7Days), start: sub(new Date(), { days: 7 }) }, + { title: t(Localization.Admin.ViewerInfo.last30Days), start: sub(new Date(), { days: 30 }) }, + { title: t(Localization.Admin.ViewerInfo.last3Months), start: sub(new Date(), { months: 3 }) }, + { title: t(Localization.Admin.ViewerInfo.last6Months), start: sub(new Date(), { months: 6 }) }, ]; const [loadingChart, setLoadingChart] = useState(true); @@ -96,13 +97,13 @@ export default function ViewersOverTime() { return ( <> - {t('Viewer Info')} + {t(Localization.Admin.ViewerInfo.title)}
{online && ( } /> @@ -110,14 +111,18 @@ export default function ViewersOverTime() { )} } /> } /> @@ -127,8 +132,8 @@ export default function ViewersOverTime() { )} @@ -136,7 +141,7 @@ export default function ViewersOverTime() { {viewerInfo.length > 0 && ( Missing translation ${key}: Please report`; } }, + + CallExpression(p) { + const { node } = p; + + // Check if this is a call to t() function + if (node.callee.type !== 'Identifier' || node.callee.name !== 't') return; + + // Check if the first argument is a Localization key + if (node.arguments.length === 0) return; + + const firstArg = node.arguments[0]; + let key = null; + + // Handle t(Localization.Frontend.NameChangeModal.placeholder) + if (firstArg.type === 'MemberExpression') { + const dotPath = getDotPath(firstArg); + if (dotPath) { + key = dotPath; + } + } + // Handle t("some.string.key") - but only if it looks like a translation key + else if (firstArg.type === 'StringLiteral') { + const { value } = firstArg; + // Only include string literals that follow our translation key pattern: + // - Must have dots for hierarchy (e.g., Frontend.Component.key) + // - Must start with a capital letter (namespace convention) + // - Must not contain spaces (translation keys shouldn't have spaces) + // - Must not be common JS patterns like prototype methods + if ( + value.includes('.') && + /^[A-Z][a-zA-Z0-9]*\./.test(value) && + !value.includes(' ') && + !value.includes('prototype') && + !value.includes('()') && + value.split('.').length >= 2 && + value.split('.').length <= 5 + ) { + // Reasonable depth for translation keys + key = value; + } + } + + if (key) { + // For t() calls, we don't have defaultText, so use the fallback + if (!results[key]) { + console.log(`[i18n] Found t() call with key: ${key} in ${file}`); + } + results[key] = + results[key] || `Missing translation ${key}: Please report`; + } + }, }); } @@ -164,7 +222,7 @@ function updateTranslationFile(flatTranslations) { if (changed) { const merged = sortObjectKeys(mergeDeep(existing, newNestedTranslations)); - fs.writeFileSync(TRANSLATIONS_PATH, JSON.stringify(merged, null, 2)); + fs.writeFileSync(TRANSLATIONS_PATH, JSON.stringify(merged, null, '\t')); console.log(`[i18n] Updated ${TRANSLATIONS_PATH}`); } else { console.log('[i18n] No new keys to add.'); diff --git a/web/stories/Translation.stories.tsx b/web/stories/Translation.stories.tsx index a938a84d7f..1b34e69fa8 100644 --- a/web/stories/Translation.stories.tsx +++ b/web/stories/Translation.stories.tsx @@ -60,9 +60,9 @@ export const ComplexHTMLTranslation: Story = { }, }; -export const NotificationMessage: Story = { +export const ComplexHTMLMessage: Story = { args: { - translationKey: Localization.Frontend.notificationMessage, + translationKey: Localization.Frontend.offlineNotifyOnly, vars: { streamer: 'MyAwesomeStream', }, diff --git a/web/styles/globals.scss b/web/styles/globals.scss index bdfdab30fd..98d9877d1f 100644 --- a/web/styles/globals.scss +++ b/web/styles/globals.scss @@ -110,7 +110,7 @@ body { a { color: var(--theme-color-action); - word-break: break-word; + overflow-wrap: break-word; &:hover { color: var(--theme-color-action-hover); diff --git a/web/tests/localization-keys.test.ts b/web/tests/localization-keys.test.ts new file mode 100644 index 0000000000..8f88293c98 --- /dev/null +++ b/web/tests/localization-keys.test.ts @@ -0,0 +1,660 @@ +import fs from 'fs'; +import path from 'path'; +import { Localization } from '../types/localization'; + +/** + * Comprehensive localization test suite to verify that translation keys exist + * across multiple languages and that the localization system is working correctly. + */ + +describe('Localization Keys Cross-Language Validation', () => { + const i18nDir = path.join(__dirname, '../i18n'); + + // Get all available language directories + const getAvailableLanguages = (): string[] => + fs.readdirSync(i18nDir).filter(item => { + const itemPath = path.join(i18nDir, item); + return fs.statSync(itemPath).isDirectory() && item !== 'en'; // Exclude English as it's our reference + }); + + // Load translation file for a specific language + const loadTranslationFile = (language: string): Record => { + try { + const translationPath = path.join(i18nDir, language, 'translation.json'); + const content = fs.readFileSync(translationPath, 'utf-8'); + return JSON.parse(content); + } catch { + return {}; + } + }; + + // Helper function to get nested value from object using dot notation + const getNestedValue = (obj: Record, key: string): any => + key + .split('.') + .reduce( + (current, prop) => (current && current[prop] !== undefined ? current[prop] : undefined), + obj, + ); + + // Helper function to check if a key exists in a translation object + const keyExists = (translations: Record, key: string): boolean => + getNestedValue(translations, key) !== undefined; + + // Load English translations as reference + const englishTranslations = loadTranslationFile('en'); + const availableLanguages = getAvailableLanguages(); + + describe('Core Frontend Component Keys', () => { + const testKeys = [ + // NameChangeModal keys + { + key: Localization.Frontend.NameChangeModal.description, + name: 'NameChangeModal.description', + }, + { + key: Localization.Frontend.NameChangeModal.placeholder, + name: 'NameChangeModal.placeholder', + }, + { key: Localization.Frontend.NameChangeModal.buttonText, name: 'NameChangeModal.buttonText' }, + { key: Localization.Frontend.NameChangeModal.colorLabel, name: 'NameChangeModal.colorLabel' }, + { key: Localization.Frontend.NameChangeModal.authInfo, name: 'NameChangeModal.authInfo' }, + { key: Localization.Frontend.NameChangeModal.overLimit, name: 'NameChangeModal.overLimit' }, + + // Header component keys + { + key: Localization.Frontend.Header.skipToPlayer, + name: 'Header.skipToPlayer', + }, + { + key: Localization.Frontend.Header.skipToOfflineMessage, + name: 'Header.skipToOfflineMessage', + }, + { + key: Localization.Frontend.Header.skipToContent, + name: 'Header.skipToContent', + }, + { + key: Localization.Frontend.Header.skipToFooter, + name: 'Header.skipToFooter', + }, + { + key: Localization.Frontend.Header.chatWillBeAvailable, + name: 'Header.chatWillBeAvailable', + }, + { + key: Localization.Frontend.Header.chatOffline, + name: 'Header.chatOffline', + }, + + // Footer component keys + { + key: Localization.Frontend.Footer.documentation, + name: 'Footer.documentation', + }, + { + key: Localization.Frontend.Footer.contribute, + name: 'Footer.contribute', + }, + { + key: Localization.Frontend.Footer.source, + name: 'Footer.source', + }, + + // BrowserNotifyModal keys (sample) + { + key: Localization.Frontend.BrowserNotifyModal.unsupported, + name: 'BrowserNotifyModal.unsupported', + }, + { + key: Localization.Frontend.BrowserNotifyModal.allowButton, + name: 'BrowserNotifyModal.allowButton', + }, + { + key: Localization.Frontend.BrowserNotifyModal.enabledTitle, + name: 'BrowserNotifyModal.enabledTitle', + }, + { + key: Localization.Frontend.BrowserNotifyModal.mainDescription, + name: 'BrowserNotifyModal.mainDescription', + }, + + // Offline messages + { key: Localization.Frontend.offlineBasic, name: 'Frontend.offlineBasic' }, + { key: Localization.Frontend.offlineNotifyOnly, name: 'Frontend.offlineNotifyOnly' }, + + // Error handling + { key: Localization.Frontend.componentError, name: 'Frontend.componentError' }, + ]; + + test('should verify all test keys exist in English translation file', () => { + testKeys.forEach(({ key }) => { + const value = getNestedValue(englishTranslations, key); + expect(keyExists(englishTranslations, key)).toBe(true); + expect(value).toBeDefined(); + expect(typeof value).toBe('string'); + }); + }); + + testKeys.forEach(({ key, name }) => { + test(`should verify "${name}" exists across all available languages`, () => { + const missingLanguages: string[] = []; + const emptyTranslationLanguages: string[] = []; + + availableLanguages.forEach(language => { + const translations = loadTranslationFile(language); + + if (!keyExists(translations, key)) { + missingLanguages.push(language); + } else { + const value = getNestedValue(translations, key); + if (!value || value.trim() === '') { + emptyTranslationLanguages.push(language); + } + } + }); + + // Log warnings for missing translations but don't fail the test + if (missingLanguages.length > 0) { + console.warn(`⚠️ Key "${key}" is missing in languages: ${missingLanguages.join(', ')}`); + } + + if (emptyTranslationLanguages.length > 0) { + console.warn( + `⚠️ Key "${key}" has empty translations in languages: ${emptyTranslationLanguages.join(', ')}`, + ); + } + + // At minimum, ensure the key exists in English + expect(keyExists(englishTranslations, key)).toBe(true); + }); + }); + }); + + describe('Admin Component Keys', () => { + const adminTestKeys = [ + // EditInstanceDetails + { + key: Localization.Admin.EditInstanceDetails.offlineMessageDescription, + name: 'Admin.EditInstanceDetails.offlineMessageDescription', + }, + { + key: Localization.Admin.EditInstanceDetails.directoryDescription, + name: 'Admin.EditInstanceDetails.directoryDescription', + }, + { + key: Localization.Admin.EditInstanceDetails.serverUrlRequiredForDirectory, + name: 'Admin.EditInstanceDetails.serverUrlRequiredForDirectory', + }, + + // HardwareInfo + { + key: Localization.Admin.HardwareInfo.title, + name: 'Admin.HardwareInfo.title', + }, + { + key: Localization.Admin.HardwareInfo.pleaseWait, + name: 'Admin.HardwareInfo.pleaseWait', + }, + { + key: Localization.Admin.HardwareInfo.noDetails, + name: 'Admin.HardwareInfo.noDetails', + }, + { + key: Localization.Admin.HardwareInfo.cpu, + name: 'Admin.HardwareInfo.cpu', + }, + { + key: Localization.Admin.HardwareInfo.memory, + name: 'Admin.HardwareInfo.memory', + }, + { + key: Localization.Admin.HardwareInfo.disk, + name: 'Admin.HardwareInfo.disk', + }, + { + key: Localization.Admin.HardwareInfo.used, + name: 'Admin.HardwareInfo.used', + }, + + // Help page keys + { + key: Localization.Admin.Help.title, + name: 'Admin.Help.title', + }, + { + key: Localization.Admin.Help.configureInstance, + name: 'Admin.Help.configureInstance', + }, + { + key: Localization.Admin.Help.learnMore, + name: 'Admin.Help.learnMore', + }, + { + key: Localization.Admin.Help.configureBroadcasting, + name: 'Admin.Help.configureBroadcasting', + }, + { + key: Localization.Admin.Help.troubleshooting, + name: 'Admin.Help.troubleshooting', + }, + { + key: Localization.Admin.Help.documentation, + name: 'Admin.Help.documentation', + }, + { + key: Localization.Admin.Help.commonTasks, + name: 'Admin.Help.commonTasks', + }, + + // LogTable keys + { + key: Localization.Admin.LogTable.level, + name: 'Admin.LogTable.level', + }, + { + key: Localization.Admin.LogTable.info, + name: 'Admin.LogTable.info', + }, + { + key: Localization.Admin.LogTable.warning, + name: 'Admin.LogTable.warning', + }, + { + key: Localization.Admin.LogTable.error, + name: 'Admin.LogTable.error', + }, + { + key: Localization.Admin.LogTable.timestamp, + name: 'Admin.LogTable.timestamp', + }, + { + key: Localization.Admin.LogTable.message, + name: 'Admin.LogTable.message', + }, + { + key: Localization.Admin.LogTable.logs, + name: 'Admin.LogTable.logs', + }, + + // NewsFeed keys + { + key: Localization.Admin.NewsFeed.link, + name: 'Admin.NewsFeed.link', + }, + { + key: Localization.Admin.NewsFeed.noNews, + name: 'Admin.NewsFeed.noNews', + }, + { + key: Localization.Admin.NewsFeed.title, + name: 'Admin.NewsFeed.title', + }, + + // ViewerInfo keys + { + key: Localization.Admin.ViewerInfo.title, + name: 'Admin.ViewerInfo.title', + }, + { + key: Localization.Admin.ViewerInfo.currentStream, + name: 'Admin.ViewerInfo.currentStream', + }, + { + key: Localization.Admin.ViewerInfo.last12Hours, + name: 'Admin.ViewerInfo.last12Hours', + }, + { + key: Localization.Admin.ViewerInfo.last24Hours, + name: 'Admin.ViewerInfo.last24Hours', + }, + { + key: Localization.Admin.ViewerInfo.currentViewers, + name: 'Admin.ViewerInfo.currentViewers', + }, + { + key: Localization.Admin.ViewerInfo.maxViewersThisStream, + name: 'Admin.ViewerInfo.maxViewersThisStream', + }, + { + key: Localization.Admin.ViewerInfo.viewers, + name: 'Admin.ViewerInfo.viewers', + }, + ]; + + adminTestKeys.forEach(({ key, name }) => { + test(`should verify admin key "${name}" has appropriate translation structure`, () => { + const englishValue = getNestedValue(englishTranslations, key); + + // Admin keys might have missing translation indicators + expect(englishValue).toBeDefined(); + expect(typeof englishValue).toBe('string'); + + // Check if it's a missing translation placeholder + if (englishValue.includes('Missing translation')) { + // console.warn( + // `⚠️ Admin key "${key}" appears to have missing translation in English: ${englishValue}`, + // ); + } + }); + }); + + test('should identify missing admin keys in localization.ts vs translation files', () => { + const missingAdminKeys = [ + { key: Localization.Admin.emojis, name: 'Admin.emojis' }, + { key: Localization.Admin.settings, name: 'Admin.settings' }, + { + key: Localization.Admin.Chat.moderationMessagesSent, + name: 'Admin.Chat.moderationMessagesSent', + }, + ]; + + missingAdminKeys.forEach(({ key, name }) => { + const englishValue = getNestedValue(englishTranslations, key); + + if (!englishValue) { + console.warn( + `⚠️ Admin key "${name}" (${key}) is not present in translation files - consider adding it or removing from localization.ts`, + ); + } else if (englishValue.includes('Missing translation')) { + console.warn( + `⚠️ Admin key "${name}" (${key}) has placeholder translation: ${englishValue}`, + ); + } + }); + + // This test always passes but generates useful warnings + expect(true).toBe(true); + }); + }); + + describe('Common Keys', () => { + const commonTestKeys = [ + { key: Localization.Common.poweredByOwncastVersion, name: 'Common.poweredByOwncastVersion' }, + ]; + + commonTestKeys.forEach(({ key, name }) => { + test(`should verify common key "${name}" exists in English`, () => { + expect(keyExists(englishTranslations, key)).toBe(true); + const value = getNestedValue(englishTranslations, key); + expect(value).toBeDefined(); + expect(typeof value).toBe('string'); + }); + }); + + test('should identify missing common keys in localization.ts vs translation files', () => { + // All Common keys are now properly used and extracted automatically + // Only poweredByOwncastVersion remains as it's actually used in Footer.tsx + const englishValue = getNestedValue( + englishTranslations, + Localization.Common.poweredByOwncastVersion, + ); + expect(englishValue).toBeDefined(); + expect(typeof englishValue).toBe('string'); + // console.log(`✓ Common key "poweredByOwncastVersion" found: "${englishValue}"`); + }); + }); + + describe('Legacy Frontend Keys (Direct String Values)', () => { + // These are keys that still use the old direct translation string approach + const legacyKeys = [ + { + key: Localization.Frontend.chatDisabled, + name: 'chatDisabled', + expectedValue: 'Chat is disabled', + }, + { + key: Localization.Frontend.currentViewers, + name: 'currentViewers', + expectedValue: 'Current viewers', + }, + { key: Localization.Frontend.connected, name: 'connected', expectedValue: 'Connected' }, + { + key: Localization.Frontend.healthyStream, + name: 'healthyStream', + expectedValue: 'Healthy Stream', + }, + { + key: Localization.Frontend.lastLiveAgo, + name: 'lastLiveAgo', + expectedValue: 'Last live {{timeAgo}} ago', + }, + { + key: Localization.Frontend.maxViewers, + name: 'maxViewers', + expectedValue: 'Max viewers this stream', + }, + ]; + + legacyKeys.forEach(({ key, name, expectedValue }) => { + test(`should verify legacy frontend key "${name}" uses direct string value`, () => { + // These keys use direct string values instead of namespace keys + expect(key).toBe(expectedValue); + + // But we should also verify they exist in the translation file for some languages + const value = getNestedValue(englishTranslations, key); + if (value) { + expect(typeof value).toBe('string'); + } + }); + }); + }); + + describe('Localization Summary Report', () => { + test('should provide a concise summary of localization status', () => { + const criticalIssues: string[] = []; + const warnings: string[] = []; + + // Check NameChangeModal keys (new feature) + const nameChangeKeys = [ + Localization.Frontend.NameChangeModal.description, + Localization.Frontend.NameChangeModal.placeholder, + Localization.Frontend.NameChangeModal.buttonText, + ]; + + const criticalLanguages = ['de', 'es', 'fr', 'it', 'ja', 'ru', 'zh']; + let missingCriticalTranslations = 0; + + nameChangeKeys.forEach(key => { + criticalLanguages.forEach(lang => { + const langTranslations = loadTranslationFile(lang); + if (!keyExists(langTranslations, key)) { + missingCriticalTranslations++; + } + }); + }); + + if (missingCriticalTranslations > 0) { + warnings.push( + `NameChangeModal needs translations in ${Math.floor(missingCriticalTranslations / nameChangeKeys.length)} major languages`, + ); + } + + // Check for keys that shouldn't be in localization.ts + const problematicKeys = [Localization.Admin.settings]; + + problematicKeys.forEach(key => { + if (!getNestedValue(englishTranslations, key)) { + criticalIssues.push( + `Key "${key}" exists in localization.ts but not in translation files`, + ); + } + }); + + // Print summary + // console.log('\n🔍 Localization Status Summary:'); + if (criticalIssues.length === 0 && warnings.length === 0) { + console.log('✅ All critical localization keys are properly configured'); + } else { + if (criticalIssues.length > 0) { + console.log('❌ Critical Issues:'); + criticalIssues.forEach(issue => console.log(` - ${issue}`)); + } + if (warnings.length > 0) { + console.log('⚠️ Warnings:'); + warnings.forEach(warning => console.log(` - ${warning}`)); + } + } + + // console.log(`📊 Languages supported: ${availableLanguages.length + 1} (including English)`); + // console.log('💡 Run with LOCALIZATION_VERBOSE=true for detailed warnings\n'); + + // Test always passes - this is just informational + expect(true).toBe(true); + }); + }); + + describe('Language Coverage Report', () => { + test('should generate language coverage report for key components', () => { + const reportKeys = [ + Localization.Frontend.NameChangeModal.placeholder, + Localization.Frontend.BrowserNotifyModal.allowButton, + Localization.Frontend.offlineBasic, + Localization.Common.poweredByOwncastVersion, + ]; + + const report: Record = {}; + + availableLanguages.forEach(language => { + const translations = loadTranslationFile(language); + const missing = reportKeys.filter(key => !keyExists(translations, key)).length; + const total = reportKeys.length; + const coverage = (((total - missing) / total) * 100).toFixed(1); + + report[language] = { + total, + missing, + coverage: `${coverage}%`, + }; + }); + + // console.log('\n📊 Translation Coverage Report for Key Components:'); + // console.log('Language\tCoverage\tMissing Keys'); + // console.log('--------\t--------\t------------'); + + // Object.entries(report) + // .sort((a, b) => parseFloat(b[1].coverage) - parseFloat(a[1].coverage)) + // .forEach(([lang, stats]) => { + // console.log(`${lang}\t\t${stats.coverage}\t\t${stats.missing}/${stats.total}`); + // }); + + // Test passes if we have the report data + expect(Object.keys(report).length).toBeGreaterThan(0); + }); + }); + + describe('Translation File Structure Validation', () => { + test('should verify all language directories have translation.json files', () => { + availableLanguages.forEach(language => { + const translationPath = path.join(i18nDir, language, 'translation.json'); + expect(fs.existsSync(translationPath)).toBe(true); + + // Verify the file can be parsed as JSON + expect(() => { + const content = fs.readFileSync(translationPath, 'utf-8'); + JSON.parse(content); + }).not.toThrow(); + }); + }); + + test('should verify English translation file has expected structure', () => { + expect(englishTranslations).toBeDefined(); + expect(typeof englishTranslations).toBe('object'); + + // Check for expected top-level sections + expect(englishTranslations.Frontend).toBeDefined(); + expect(englishTranslations.Common).toBeDefined(); + + // Check for specific component sections + expect(englishTranslations.Frontend.NameChangeModal).toBeDefined(); + expect(englishTranslations.Frontend.BrowserNotifyModal).toBeDefined(); + }); + }); + + describe('Localization System Integration Test', () => { + test('should verify the translation hook works with our localization keys', () => { + // This test verifies that the actual translation system can resolve our keys + // We'll use a sample of our keys to test the integration + + const testKeys = [ + Localization.Frontend.NameChangeModal.placeholder, + Localization.Frontend.BrowserNotifyModal.allowButton, + Localization.Common.poweredByOwncastVersion, + ]; + + testKeys.forEach(key => { + const value = getNestedValue(englishTranslations, key); + expect(value).toBeDefined(); + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThan(0); + }); + }); + + test('should verify interpolation variables are correctly structured', () => { + // Test keys that should have interpolation variables + const interpolationTests = [ + { + key: Localization.Frontend.componentError, + expectedVars: ['message'], + description: 'Component error message should interpolate {{message}}', + }, + { + key: Localization.Common.poweredByOwncastVersion, + expectedVars: ['versionNumber'], + description: 'Powered by Owncast should interpolate {{versionNumber}}', + }, + { + key: Localization.Frontend.offlineNotifyOnly, + expectedVars: ['streamer'], + description: 'Offline notify message should interpolate {{streamer}}', + }, + ]; + + interpolationTests.forEach(({ key, expectedVars }) => { + const value = getNestedValue(englishTranslations, key); + expect(value).toBeDefined(); + + expectedVars.forEach(varName => { + const hasVariable = value.includes(`{{${varName}}}`); + expect(hasVariable).toBe(true); + }); + }); + }); + }); + + describe('Localization.ts Type Safety', () => { + test('should verify localization keys match expected patterns', () => { + // Test that nested component keys follow the namespace pattern + expect(Localization.Frontend.NameChangeModal.placeholder).toMatch( + /^Frontend\.NameChangeModal\./, + ); + expect(Localization.Frontend.BrowserNotifyModal.allowButton).toMatch( + /^Frontend\.BrowserNotifyModal\./, + ); + expect(Localization.Admin.Chat.moderationMessagesSent).toMatch(/^Admin\.Chat\./); + expect(Localization.Common.poweredByOwncastVersion).toMatch(/^Common\./); + + // Test that basic frontend keys are direct translation strings + expect(typeof Localization.Frontend.chatOffline).toBe('string'); + expect(typeof Localization.Frontend.currentViewers).toBe('string'); + expect(typeof Localization.Frontend.connected).toBe('string'); + }); + + test('should verify all localization keys are strings', () => { + const validateKeys = (obj: any, keyPath = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const currentPath = keyPath ? `${keyPath}.${key}` : key; + + if (typeof value === 'object' && value !== null) { + validateKeys(value, currentPath); + } else { + expect(typeof value).toBe('string'); + expect(value).toBeTruthy(); // Ensure no empty strings + } + }); + }; + + validateKeys(Localization); + }); + }); +}); diff --git a/web/tests/translation.test.tsx b/web/tests/translation.test.tsx index 4d3be68b72..c991ef4d42 100644 --- a/web/tests/translation.test.tsx +++ b/web/tests/translation.test.tsx @@ -7,43 +7,41 @@ import { Localization } from '../types/localization'; jest.mock('next-export-i18n', () => ({ useTranslation: () => ({ t: (key: string, vars?: Record) => { - // Use the actual keys as in localization.ts and translation.json - const translations: Record = { + // Simulate the actual translation structure from the JSON files + const translations: Record = { + // Frontend translations + 'Frontend.helloWorld': 'Hello {{name}}, welcome to the world!', + 'Frontend.componentError': 'Error: {{message}}', + 'Frontend.offlineBasic': 'This stream is offline. Check back soon!', + 'Frontend.offlineNotifyOnly': + "This stream is offline. Be notified the next time {{streamer}} goes live.", + + // Testing translations + 'Testing.simpleKey': 'Simple translation text', + 'Testing.itemCount': 'You have {{count}} items', + 'Testing.itemCount_one': 'You have {{count}} item', + 'Testing.messageCount': 'You have {{count}} messages from {{sender}}', + 'Testing.messageCount_one': 'You have {{count}} message from {{sender}}', + 'Testing.noPluralKey': 'This key has no plural variants - {{count}} things', + + // Legacy flat keys for backwards compatibility hello_world: 'Hello {{name}}, welcome to the world!', chat_offline: 'Chat is offline', notification_message: 'You can click here to receive notifications when {{streamer}} goes live.', component_error: 'Error: {{message}}', offline_basic: 'This stream is offline. Check back soon!', - // Testing keys - 'Testing.simpleKey': 'Simple translation text', - 'Testing.itemCount_one': 'You have {{count}} item', - 'Testing.itemCount': 'You have {{count}} items', - 'Testing.messageCount_one': 'You have {{count}} message from {{sender}}', - 'Testing.messageCount': 'You have {{count}} messages from {{sender}}', - 'Testing.noPluralKey': 'This key has no plural variants - {{count}} things', }; let result = translations[key]; - // If not found, try to fallback to a snake_case version (for legacy or fallback) - if (!result && key.includes('.')) { - const [ns, k] = key.split('.'); - // Try snake_case - const snakeKey = `${ns}.${k - .replace(/([A-Z])/g, '_$1') - .toLowerCase() - .replace(/^_/, '')}`; - result = translations[snakeKey]; - } - - // If still not found, return the key itself + // If not found, return the key itself (as real i18n would do) if (!result) { result = key; } // Simple variable replacement for testing - if (vars) { + if (vars && typeof result === 'string') { Object.keys(vars).forEach(varKey => { result = result.replace(new RegExp(`{{${varKey}}}`, 'g'), vars[varKey]); }); @@ -95,18 +93,18 @@ describe('Translation Component', () => { expect(element).toHaveClass('custom-class'); }); - test('should render notification message with HTML link', () => { + test('should render notification message with HTML content', () => { render( , ); - // Check that the link is rendered - const linkElement = screen.getByText('click here'); - expect(linkElement.tagName).toBe('A'); - expect(linkElement).toHaveAttribute('href', '#'); + // Check that the HTML content is rendered + const linkElement = screen.getByText('Be notified'); + expect(linkElement.tagName).toBe('SPAN'); + expect(linkElement).toHaveClass('notify-link'); // Check that the variable is interpolated expect(screen.getByText(/TestStreamer/)).toBeInTheDocument(); @@ -115,7 +113,7 @@ describe('Translation Component', () => { test('should render with all props combined', () => { render( , @@ -125,7 +123,7 @@ describe('Translation Component', () => { const element = screen.getByText((_, e) => { const hasText = e?.textContent === - 'You can click here to receive notifications when TestStreamer goes live.'; + 'This stream is offline. Be notified the next time TestStreamer goes live.'; const isSpan = e?.tagName === 'SPAN'; return hasText && isSpan; }); diff --git a/web/types/localization.ts b/web/types/localization.ts index 75acc6abca..9297a983ae 100644 --- a/web/types/localization.ts +++ b/web/types/localization.ts @@ -14,7 +14,7 @@ export const Localization = { chatWillBeAvailable: 'Chat will be available when the stream is live', // Stream information and statistics - lastLiveAgo: 'Last live ago', + lastLiveAgo: 'Last live {{timeAgo}} ago', currentViewers: 'Current viewers', maxViewers: 'Max viewers this stream', noStreamActive: 'No stream is active', @@ -41,44 +41,70 @@ export const Localization = { embedVideo: 'Embed your video onto other sites', // Complex HTML translations with variables - helloWorld: 'hello_world', - notificationMessage: 'notification_message', - complexMessage: 'complex_message', + helloWorld: 'Frontend.helloWorld', + complexMessage: 'Frontend.complexMessage', // Errors - componentError: 'component_error', + componentError: 'Frontend.componentError', // Browser notifications - organized by component BrowserNotifyModal: { - unsupported: 'browser_notify_unsupported', - unsupportedLocal: 'browser_notify_unsupported_local', - iosTitle: 'browser_notify_ios_title', - iosDescription: 'browser_notify_ios_description', - iosShareButton: 'browser_notify_ios_share_button', - iosAddToHomeScreen: 'browser_notify_ios_add_to_home_screen', - iosAddButton: 'browser_notify_ios_add_button', - iosNameAndTap: 'browser_notify_ios_name_and_tap', - iosComeBack: 'browser_notify_ios_come_back', - iosAllowPrompt: 'browser_notify_ios_allow_prompt', - permissionWantsTo: 'browser_notify_permission_wants_to', - showNotifications: 'browser_notify_show_notifications', - allowButton: 'browser_notify_allow_button', - blockButton: 'browser_notify_block_button', - enabledTitle: 'browser_notify_enabled_title', - enabledDescription: 'browser_notify_enabled_description', - deniedTitle: 'browser_notify_denied_title', - deniedDescription: 'browser_notify_denied_description', - mainDescription: 'browser_notify_main_description', - learnMore: 'browser_notify_learn_more', - errorTitle: 'browser_notify_error_title', - errorMessage: 'browser_notify_error_message', + unsupported: 'Frontend.BrowserNotifyModal.unsupported', + unsupportedLocal: 'Frontend.BrowserNotifyModal.unsupportedLocal', + iosTitle: 'Frontend.BrowserNotifyModal.iosTitle', + iosDescription: 'Frontend.BrowserNotifyModal.iosDescription', + iosShareButton: 'Frontend.BrowserNotifyModal.iosShareButton', + iosAddToHomeScreen: 'Frontend.BrowserNotifyModal.iosAddToHomeScreen', + iosAddButton: 'Frontend.BrowserNotifyModal.iosAddButton', + iosNameAndTap: 'Frontend.BrowserNotifyModal.iosNameAndTap', + iosComeBack: 'Frontend.BrowserNotifyModal.iosComeBack', + iosAllowPrompt: 'Frontend.BrowserNotifyModal.iosAllowPrompt', + permissionWantsTo: 'Frontend.BrowserNotifyModal.permissionWantsTo', + showNotifications: 'Frontend.BrowserNotifyModal.showNotifications', + allowButton: 'Frontend.BrowserNotifyModal.allowButton', + blockButton: 'Frontend.BrowserNotifyModal.blockButton', + enabledTitle: 'Frontend.BrowserNotifyModal.enabledTitle', + enabledDescription: 'Frontend.BrowserNotifyModal.enabledDescription', + deniedTitle: 'Frontend.BrowserNotifyModal.deniedTitle', + deniedDescription: 'Frontend.BrowserNotifyModal.deniedDescription', + mainDescription: 'Frontend.BrowserNotifyModal.mainDescription', + learnMore: 'Frontend.BrowserNotifyModal.learnMore', + errorTitle: 'Frontend.BrowserNotifyModal.errorTitle', + errorMessage: 'Frontend.BrowserNotifyModal.errorMessage', + }, + + // Name change modal - organized by component + NameChangeModal: { + description: 'Frontend.NameChangeModal.description', + placeholder: 'Frontend.NameChangeModal.placeholder', + buttonText: 'Frontend.NameChangeModal.buttonText', + colorLabel: 'Frontend.NameChangeModal.colorLabel', + authInfo: 'Frontend.NameChangeModal.authInfo', + overLimit: 'Frontend.NameChangeModal.overLimit', + }, + + // Header component + Header: { + skipToPlayer: 'Frontend.Header.skipToPlayer', + skipToOfflineMessage: 'Frontend.Header.skipToOfflineMessage', + skipToContent: 'Frontend.Header.skipToContent', + skipToFooter: 'Frontend.Header.skipToFooter', + chatWillBeAvailable: 'Frontend.Header.chatWillBeAvailable', + chatOffline: 'Frontend.Header.chatOffline', + }, + + // Footer component + Footer: { + documentation: 'Frontend.Footer.documentation', + contribute: 'Frontend.Footer.contribute', + source: 'Frontend.Footer.source', }, // Offline banner messages - offlineBasic: 'offline_basic', - offlineNotifyOnly: 'offline_notify_only', - offlineFediverseOnly: 'offline_fediverse_only', - offlineNotifyAndFediverse: 'offline_notify_and_fediverse', + offlineBasic: 'Frontend.offlineBasic', + offlineNotifyOnly: 'Frontend.offlineNotifyOnly', + offlineFediverseOnly: 'Frontend.offlineFediverseOnly', + offlineNotifyAndFediverse: 'Frontend.offlineNotifyAndFediverse', }, /** @@ -86,17 +112,15 @@ export const Localization = { */ Admin: { // Emoji management - emojis: 'Emojis', - emojiPageDescription: - 'Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the filename without extension will be used as emoji name. Additionally, emoji names are case-insensitive. For best results, ensure all emoji have unique names.', - emojiUploadBulkGuide: - 'Want to upload custom emojis in bulk? Check out our Emoji guide.', - uploadNewEmoji: 'Upload new emoji', - deleteEmoji: 'Delete emoji', + emojis: 'Admin.emojis', + emojiPageDescription: 'Admin.emojiPageDescription', + emojiUploadBulkGuide: 'Admin.emojiUploadBulkGuide', + uploadNewEmoji: 'Admin.uploadNewEmoji', + deleteEmoji: 'Admin.deleteEmoji', // Settings and configuration - settings: 'settings', - overriddenViaCommandLine: 'Overridden via command line', + settings: 'Admin.settings', + overriddenViaCommandLine: 'Admin.overriddenViaCommandLine', Chat: { moderationMessagesSent: 'Admin.Chat.moderationMessagesSent', @@ -119,32 +143,99 @@ export const Localization = { bitrateGoodForHigh: 'Admin.VideoVariantForm.bitrateGoodForHigh', }, + // Hardware monitoring page + HardwareInfo: { + title: 'Admin.HardwareInfo.title', + pleaseWait: 'Admin.HardwareInfo.pleaseWait', + noDetails: 'Admin.HardwareInfo.noDetails', + cpu: 'Admin.HardwareInfo.cpu', + memory: 'Admin.HardwareInfo.memory', + disk: 'Admin.HardwareInfo.disk', + used: 'Admin.HardwareInfo.used', + }, + + // Help page + Help: { + title: 'Admin.Help.title', + configureInstance: 'Admin.Help.configureInstance', + learnMore: 'Admin.Help.learnMore', + configureBroadcasting: 'Admin.Help.configureBroadcasting', + embedStream: 'Admin.Help.embedStream', + customizeWebsite: 'Admin.Help.customizeWebsite', + tweakVideo: 'Admin.Help.tweakVideo', + useStorage: 'Admin.Help.useStorage', + foundBug: 'Admin.Help.foundBug', + bugPlease: 'Admin.Help.bugPlease', + letUsKnow: 'Admin.Help.letUsKnow', + generalQuestion: 'Admin.Help.generalQuestion', + generalAnswered: 'Admin.Help.generalAnswered', + faq: 'Admin.Help.faq', + orExist: 'Admin.Help.orExist', + discussions: 'Admin.Help.discussions', + buildAddons: 'Admin.Help.buildAddons', + buildTools: 'Admin.Help.buildTools', + developerApis: 'Admin.Help.developerApis', + troubleshooting: 'Admin.Help.troubleshooting', + fixProblems: 'Admin.Help.fixProblems', + documentation: 'Admin.Help.documentation', + readDocs: 'Admin.Help.readDocs', + commonTasks: 'Admin.Help.commonTasks', + other: 'Admin.Help.other', + }, + + // Log table component + LogTable: { + level: 'Admin.LogTable.level', + info: 'Admin.LogTable.info', + warning: 'Admin.LogTable.warning', + error: 'Admin.LogTable.error', + timestamp: 'Admin.LogTable.timestamp', + message: 'Admin.LogTable.message', + logs: 'Admin.LogTable.logs', + }, + + // News feed component + NewsFeed: { + link: 'Admin.NewsFeed.link', + noNews: 'Admin.NewsFeed.noNews', + title: 'Admin.NewsFeed.title', + }, + + // Viewer info page + ViewerInfo: { + title: 'Admin.ViewerInfo.title', + currentStream: 'Admin.ViewerInfo.currentStream', + last12Hours: 'Admin.ViewerInfo.last12Hours', + last24Hours: 'Admin.ViewerInfo.last24Hours', + last7Days: 'Admin.ViewerInfo.last7Days', + last30Days: 'Admin.ViewerInfo.last30Days', + last3Months: 'Admin.ViewerInfo.last3Months', + last6Months: 'Admin.ViewerInfo.last6Months', + currentViewers: 'Admin.ViewerInfo.currentViewers', + maxViewersThisStream: 'Admin.ViewerInfo.maxViewersThisStream', + maxViewersLastStream: 'Admin.ViewerInfo.maxViewersLastStream', + maxViewers: 'Admin.ViewerInfo.maxViewers', + pleaseWait: 'Admin.ViewerInfo.pleaseWait', + noData: 'Admin.ViewerInfo.noData', + viewers: 'Admin.ViewerInfo.viewers', + }, + // Logging and monitoring - info: 'Info', - warning: 'Warning', - error: 'Error', - level: 'Level', - timestamp: 'Timestamp', - message: 'Message', - logs: 'Logs', + info: 'Admin.info', + warning: 'Admin.warning', + error: 'Admin.error', + level: 'Admin.level', + timestamp: 'Admin.timestamp', + message: 'Admin.message', + logs: 'Admin.logs', }, /** * Common keys shared across both frontend and admin interfaces */ Common: { - // Basic UI elements - yes: 'Yes', - no: 'No', - - // Documentation and help - documentation: 'Documentation', - contribute: 'Contribute', - source: 'Source', - // Branding - poweredByOwncast: 'Powered by Owncast', - poweredByOwncastVersion: 'powered_by_owncast_version', + poweredByOwncastVersion: 'Common.poweredByOwncastVersion', }, /**