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
+1
View File
@@ -6,3 +6,4 @@ out
lefthook.yml
storybook-static
tsconfig.tsbuildinfo
+11 -3
View File
@@ -11,7 +11,9 @@
"public/**",
"tests/**",
"i18n/index.js",
"i18next-parser.config.mjs"
"i18next-parser.config.mjs",
"types/index.ts",
"scripts/i18n-extract.js"
],
"ignoreDependencies": [
"@fontsource/inter",
@@ -52,6 +54,12 @@
"i18next-scanner",
"@types/chart.js",
"@types/classnames",
"@types/video.js"
"@types/video.js",
"@testing-library/jest-dom",
"@testing-library/react",
"jest-environment-jsdom",
"@babel/parser",
"@babel/traverse",
"glob"
]
}
}
@@ -1,5 +1,7 @@
import { Alert, Button } from 'antd';
import { FC } from 'react';
import Translation from '../Translation/Translation';
import { Localization } from '../../../types/localization';
export type ComponentErrorProps = {
message?: string;
@@ -35,7 +37,15 @@ const ErrorContent = ({
<p>You may optionally retry, however functionality might not work as expected.</p>
)}
<code>
<div>{message && `Error: ${message}`}</div>
<div>
{message && (
<Translation
translationKey={Localization.Frontend.componentError}
defaultText="Error: {{message}}"
vars={{ message }}
/>
)}
</div>
<div>Component: {componentName}</div>
<div>{details && details}</div>
</code>
+7 -2
View File
@@ -4,6 +4,8 @@ import { useTranslation } from 'next-export-i18n';
import styles from './Footer.module.scss';
import { ServerStatus } from '../../../interfaces/server-status.model';
import { serverStatusState } from '../../stores/ClientConfigStore';
import { Localization } from '../../../types';
import Translation from '../Translation/Translation';
export const Footer: FC = () => {
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
@@ -12,8 +14,11 @@ export const Footer: FC = () => {
return (
<footer className={styles.footer} id="footer">
<span>
{t('Powered by Owncast')}
<a href="https://owncast.online">&nbsp;v{versionNumber}</a>
<Translation
translationKey={Localization.Common.poweredByOwncastVersion}
vars={{ versionNumber }}
defaultText="Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
/>
</span>
<span className={styles.links}>
<a href="https://owncast.online/docs" target="_blank" rel="noreferrer">
@@ -20,11 +20,6 @@
}
}
.bodyText {
line-height: 2rem;
font-size: 1.2rem;
}
.separator {
margin-top: 15px;
margin-bottom: 15px;
@@ -61,3 +56,20 @@
color: var(--color-owncast-palette-7);
}
}
// Styles for Translation component generated spans
.bodyText {
:global(.notify-link),
:global(.follow-link) {
color: var(--theme-color-action);
text-decoration: underline;
cursor: pointer;
&:hover {
color: var(--color-owncast-palette-7);
}
}
line-height: 2rem;
font-size: 1.2rem;
}
@@ -1,11 +1,13 @@
/* eslint-disable react/no-danger */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Divider } from 'antd';
import { FC } from 'react';
import React, { FC } from 'react';
import { formatDistanceToNow } from 'date-fns';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import { useTranslation } from 'next-export-i18n';
import { Translation } from '../Translation/Translation';
import { Localization } from '../../../types/localization';
import styles from './OfflineBanner.module.scss';
// Lazy loaded components
@@ -39,49 +41,49 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
}) => {
const { t } = useTranslation();
const handleSpanClick = (event: React.MouseEvent<HTMLSpanElement>) => {
const target = event.target as HTMLSpanElement;
if (target.classList.contains('notify-link')) {
onNotifyClick?.();
} else if (target.classList.contains('follow-link')) {
onFollowClick?.();
}
};
let text;
if (customText) {
text = customText;
} else if (!customText && notificationsEnabled && fediverseAccount) {
text = (
<span>
{t('This stream is offline. You can')}{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
be notified
</span>{' '}
the next time {streamName} goes live or{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}>
follow
</span>{' '}
{fediverseAccount} on the Fediverse.
</span>
<Translation
translationKey={Localization.Frontend.offlineNotifyAndFediverse}
vars={{ streamer: streamName, fediverseAccount }}
defaultText="This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse."
/>
);
} else if (!customText && notificationsEnabled) {
text = (
<span>
{t('This stream is offline')}.{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
Be notified
</span>{' '}
{t('the next time goes live', { streamer: streamName })}.
</span>
<Translation
translationKey={Localization.Frontend.offlineNotifyOnly}
vars={{ streamer: streamName }}
defaultText="This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live."
/>
);
} else if (!customText && fediverseAccount) {
text = (
<span>
{t('This stream is offline.')}{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}>
{t('Follow')}
</span>{' '}
{t('on the Fediverse to see the next time goes live', {
fediverseAccount,
streamer: streamName,
})}
.
</span>
<Translation
translationKey={Localization.Frontend.offlineFediverseOnly}
vars={{ fediverseAccount, streamer: streamName }}
defaultText="This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live."
/>
);
} else {
text = `This stream is offline. Check back soon!`;
text = (
<Translation
translationKey={Localization.Frontend.offlineBasic}
defaultText="This stream is offline. Check back soon!"
/>
);
}
return (
@@ -96,7 +98,14 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
{customText ? (
<div className={styles.bodyText} dangerouslySetInnerHTML={{ __html: text }} />
) : (
<div className={styles.bodyText}>{text}</div>
<div
className={styles.bodyText}
onClick={handleSpanClick}
role="presentation"
style={{ cursor: 'pointer' }}
>
{text}
</div>
)}
{lastLive && (
@@ -0,0 +1,38 @@
/* eslint-disable react/no-danger */
import React, { FC } from 'react';
import { useTranslation } from 'next-export-i18n';
import { LocalizationKey } from '../../../types/localization';
export interface TranslationProps {
translationKey: LocalizationKey;
vars?: Record<string, any>;
className?: string;
defaultText?: string;
}
export const Translation: FC<TranslationProps> = ({
translationKey,
vars,
className,
defaultText,
}) => {
const { t } = useTranslation();
let translatedText = t(translationKey, vars);
// Use fallback if translation is missing (returns the key itself)
if (translatedText === translationKey && defaultText) {
translatedText = defaultText;
// Interpolate variables manually into defaultText
// eslint-disable-next-line no-restricted-syntax
for (const [k, v] of Object.entries(vars || {})) {
const regex = new RegExp(`{{\\s*${k}\\s*}}`, 'g');
translatedText = translatedText.replace(regex, String(v));
}
}
return <span className={className} dangerouslySetInnerHTML={{ __html: translatedText }} />;
};
export default Translation;
+13 -1
View File
@@ -53,6 +53,10 @@
"the next time goes live": "das nächste Mal geht live",
"Follow": "Folgen",
"on the Fediverse to see the next time goes live": "auf dem Fediverse zu sehen, wann das nächste Mal live geht",
"offline_basic": "Dieser Stream ist offline. Schauen Sie bald wieder vorbei!",
"offline_notify_only": "Dieser Stream ist offline. <span class='notify-link'>Lassen Sie sich benachrichtigen</span>, wenn {{streamer}} das nächste Mal live geht.",
"offline_fediverse_only": "Dieser Stream ist offline. <span class='follow-link'>Folgen</span> Sie {{fediverseAccount}} auf dem Fediverse, um zu sehen, wann {{streamer}} das nächste Mal live geht.",
"offline_notify_and_fediverse": "Dieser Stream ist offline. Sie können <span class='notify-link'>sich benachrichtigen lassen</span>, wenn {{streamer}} das nächste Mal live geht oder <span class='follow-link'>folgen</span> Sie {{fediverseAccount}} auf dem Fediverse.",
"Last live ago": "Letzter Live- {{timeAgo}} vor",
"Want to upload custom emojis in bulk? Check out our": "Willst du benutzerdefinierte Emojis in großen Mengen hochladen? Schau in unseren",
"Emoji guide": "Emoji-Guide",
@@ -128,5 +132,13 @@
"Max viewers this stream": "Maximale Anzahl von Zuschauern in diesem Stream",
"Max viewers last stream": "Maximale Zuschauerzahl beim letzten Stream",
"max viewers": "Max. Zuschauer",
"No viewer data has been collected yet": "Bisher wurden noch keine Daten von Zuschauern gesammelt."
"No viewer data has been collected yet": "Bisher wurden noch keine Daten von Zuschauern gesammelt.",
"hello_world": "Hallo <strong>{{name}}</strong>, willkommen in der Welt!",
"notification_message": "Sie können <a href=\"#\">hier klicken</a>, um Benachrichtigungen zu erhalten, wenn {{streamer}} live geht.",
"complex_message": "Dies ist eine <em>komplexe</em> Nachricht mit <strong>{{count}}</strong> Zuschauern und <code>{{status}}</code> Status.",
"Emojis": "Emojis",
"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.": "Hier können Sie neue benutzerdefinierte Emojis für die Verwendung im Chat hochladen. Beim Hochladen eines neuen Emojis wird der Dateiname ohne Erweiterung als Emoji-Name verwendet. Zusätzlich sind Emoji-Namen nicht groß-/kleinschreibungsempfindlich. Für beste Ergebnisse sollten alle Emojis eindeutige Namen haben.",
"Want to upload custom emojis in bulk? Check out our <a href=\"https://owncast.online/docs/chat/emoji\" rel=\"noopener noreferrer\" target=\"_blank\">Emoji guide</a>.": "Möchten Sie benutzerdefinierte Emojis in großen Mengen hochladen? Schauen Sie sich unseren <a href=\"https://owncast.online/docs/chat/emoji\" rel=\"noopener noreferrer\" target=\"_blank\">Emoji-Guide</a> an.",
"Upload new emoji": "Neues Emoji hochladen",
"Delete emoji": "Emoji löschen"
}
+134 -115
View File
@@ -1,132 +1,151 @@
{
"Notify": "Notify",
"Level": "Level",
"Info": "Info",
"Warning": "Warning",
"Error": "Error",
"Timestamp": "Timestamp",
"Message": "Message",
"Logs": "Logs",
"Link": "Link",
"No news": "No news.",
"News & Updates from Owncast": "News & Updates from Owncast",
"Use your broadcasting software": "Use your broadcasting software",
"Learn how to point your existing software to your new server and start streaming your content": "Learn how to point your existing software to your new server and start streaming your content.",
"View": "View",
"Overridden via command line": "Overridden via command line.",
"Embed your video onto other sites": "Embed your video onto other sites",
"Learn how you can add your Owncast stream to other sites you control": "Learn how you can add your Owncast stream to other sites you control.",
"Add your Owncast instance to the Fediverse": "Add your Owncast instance to the Fediverse",
"Banned Users": "Banned Users",
"Bring in moderators to help keep your chat in order": "Bring in moderators to help keep your chat in order.",
"Chat is disabled": "Chat is disabled",
"Chat is offline": "Chat is offline",
"Chat Messages": "Chat Messages",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat will continue to be disabled until you begin a live stream": "Chat will continue to be disabled until you begin a live stream.",
"Click and never miss future streams!": "Click and never miss future streams!",
"Common tasks": "Common tasks",
"Common.poweredByOwncastVersion": "Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>",
"complex_message": "This is a <em>complex</em> message with <strong>{{count}}</strong> viewers and <code>{{status}}</code> status.",
"Connected": "Connected",
"Contribute": "Contribute",
"CPU": "CPU",
"Current stream": "Current stream",
"Current viewers": "Current viewers",
"Delete emoji": "Delete emoji",
"developer APIs": "developer APIs.",
"discussions": "discussions",
"Disk": "Disk",
"documentation": "documentation",
"Documentation": "Documentation",
"Embed your video onto other sites": "Embed your video onto other sites",
"Emoji guide": "Emoji guide",
"Emojis": "Emojis",
"Enable Owncast social features": "Enable Owncast social features",
"Error": "Error",
"FAQ": "FAQ",
"Find an audience on the Owncast Directory": "Find an audience on the Owncast Directory",
"Fix your problems": "Fix your problems",
"Follow": "Follow",
"Frontend.componentError": "Error: {{message}}",
"Frontend.offlineBasic": "This stream is offline. Check back soon!",
"Frontend.offlineFediverseOnly": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
"Frontend.offlineNotifyAndFediverse": "This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse.",
"Frontend.offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
"Hardware Info": "Hardware Info",
"Healthy Stream": "Healthy Stream",
"hello_world": "Hello <strong>{{name}}</strong>, welcome to the world!",
"Help configuring my broadcasting software": "Help configuring my broadcasting software",
"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.": "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.",
"Hidden messages": "Hidden messages",
"Hide": "Hide",
"How can we help you?": "How can we help you?",
"I found a bug": "I found a bug",
"I have a general question": "I have a general question",
"I want to build add-ons for Owncast": "I want to build add-ons for Owncast",
"I want to configure my owncast instance": "I want to configure my owncast instance",
"I want to customize my website": "I want to customize my website",
"I want to embed my stream into another site": "I want to embed my stream into another site",
"I want to tweak my video output": "I want to tweak my video output",
"I want to use an external storage provider": "I want to use an external storage provider",
"If you found a bug, then please": "If you found a bug, then please",
"Inbound Audio Stream": "Inbound Audio Stream",
"Inbound Stream Details": "Inbound Stream Details",
"Inbound Video Stream": "Inbound Video Stream",
"Info": "Info",
"Input": "Input",
"IP Bans": "IP Bans",
"Last 12 hours": "Last 12 hours",
"Last 24 hours": "Last 24 hours",
"Last 3 months": "Last 3 months",
"Last 30 days": "Last 30 days",
"Last 6 months": "Last 6 months",
"Last 7 days": "Last 7 days",
"Last live ago": "Last live {{timeAgo}} ago",
"Learn how to point your existing software to your new server and start streaming your content": "Learn how to point your existing software to your new server and start streaming your content.",
"Learn how you can add your Owncast stream to other sites you control": "Learn how you can add your Owncast stream to other sites you control.",
"Learn more": "Learn more",
"Learn more about chat moderation here": "Learn more about chat moderation here.",
"let us know": "let us know",
"Level": "Level",
"Link": "Link",
"List yourself in the Owncast Directory and show off your stream": {
" Enable it in": "List yourself in the Owncast Directory and show off your stream. Enable it in"
},
"settings": "settings.",
"Add your Owncast instance to the Fediverse": "Add your Owncast instance to the Fediverse",
"Enable Owncast social features": "Enable Owncast social features",
"to have your instance join the Fediverse, allowing people to follow, share and engage with your live stream": "to have your instance join the Fediverse, allowing people to follow, share and engage with your live stream.",
"No stream is active": "No stream is active",
"You should start one": "You should start one.",
"Healthy Stream": "Healthy Stream",
"Yes": "Yes",
"Logs": "Logs",
"Manage the messages from viewers that show up on your stream": "Manage the messages from viewers that show up on your stream.",
"max viewers": "max viewers",
"Max viewers last stream": "Max viewers last stream",
"Max viewers this stream": "Max viewers this stream",
"Memory": "Memory",
"Message": "Message",
"Moderators": "Moderators",
"Most general questions are answered in our": "Most general questions are answered in our",
"News & Updates from Owncast": "News & Updates from Owncast",
"No": "No",
"Playback Health": "Playback Health",
"Stream health represents": "Stream health represents",
"No hardware details have been collected yet": "No hardware details have been collected yet.",
"No news": "No news.",
"No stream is active": "No stream is active",
"No viewer data has been collected yet": "No viewer data has been collected yet.",
"notification_message": "You can <a href=\"#\">click here</a> to receive notifications when {{streamer}} goes live.",
"Notify": "Notify",
"of all known players": {
" Other player status is unknown": "of all known players. Other player status is unknown."
},
"TROUBLESHOOT": "TROUBLESHOOT",
"offline": "offline",
"offline_basic": "This stream is offline. Check back soon!",
"offline_fediverse_only": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
"offline_notify_and_fediverse": "This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse.",
"offline_notify_only": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"or exist in our": "or exist in our",
"Other": "Other",
"Outbound Audio Stream": "Outbound Audio Stream",
"Outbound Stream Details": "Outbound Stream Details",
"Outbound Video Stream": "Outbound Video Stream",
"Overridden via command line": "Overridden via command line.",
"Peak viewer count": "Peak viewer count",
"Playback Health": "Playback Health",
"Please wait": "Please wait",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Read the Docs": "Read the Docs",
"settings": "settings.",
"Show": "Show",
"Skip to footer": "Skip to footer",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Skip to player": "Skip to player",
"Source": "Source",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago",
"Want to upload custom emojis in bulk? Check out our": "Want to upload custom emojis in bulk? Check out our",
"Emoji guide": "Emoji guide",
"Time": "Time",
"User": "User",
"Visible messages": "Visible messages",
"Hidden messages": "Hidden messages",
"Chat Messages": "Chat Messages",
"Manage the messages from viewers that show up on your stream": "Manage the messages from viewers that show up on your stream.",
"Show": "Show",
"Hide": "Hide",
"Visit the": "Visit the",
"documentation": "documentation",
"to configure additional details about your viewers": "to configure additional details about your viewers.",
"When a stream is active and chat is enabled, connected chat clients will be displayed here": "When a stream is active and chat is enabled, connected chat clients will be displayed here.",
"Connected": "Connected",
"offline": "offline",
"Banned Users": "Banned Users",
"IP Bans": "IP Bans",
"Moderators": "Moderators",
"Bring in moderators to help keep your chat in order": "Bring in moderators to help keep your chat in order.",
"Learn more about chat moderation here": "Learn more about chat moderation here.",
"Hardware Info": "Hardware Info",
"Please wait": "Please wait",
"No hardware details have been collected yet": "No hardware details have been collected yet.",
"CPU": "CPU",
"Memory": "Memory",
"Disk": "Disk",
"used": "used",
"I want to configure my owncast instance": "I want to configure my owncast instance",
"Learn more": "Learn more",
"Help configuring my broadcasting software": "Help configuring my broadcasting software",
"I want to embed my stream into another site": "I want to embed my stream into another site",
"I want to customize my website": "I want to customize my website",
"I want to tweak my video output": "I want to tweak my video output",
"I want to use an external storage provider": "I want to use an external storage provider",
"I found a bug": "I found a bug",
"If you found a bug, then please": "If you found a bug, then please",
"let us know": "let us know",
"I have a general question": "I have a general question",
"Most general questions are answered in our": "Most general questions are answered in our",
"FAQ": "FAQ",
"or exist in our": "or exist in our",
"discussions": "discussions",
"I want to build add-ons for Owncast": "I want to build add-ons for Owncast",
"You can build your own bots, overlays, tools and add-ons with our": "You can build your own bots, overlays, tools and add-ons with our",
"developer APIs": "developer APIs.",
"How can we help you?": "How can we help you?",
"Troubleshooting": "Troubleshooting",
"Fix your problems": "Fix your problems",
"Read the Docs": "Read the Docs",
"Common tasks": "Common tasks",
"Other": "Other",
"Outbound Video Stream": "Outbound Video Stream",
"Outbound Audio Stream": "Outbound Audio Stream",
"Stream health represents": "Stream health represents",
"Stream started": "Stream started",
"Viewers": "Viewers",
"Peak viewer count": "Peak viewer count",
"Outbound Stream Details": "Outbound Stream Details",
"Inbound Stream Details": "Inbound Stream Details",
"Input": "Input",
"Inbound Video Stream": "Inbound Video Stream",
"Inbound Audio Stream": "Inbound Audio Stream",
"Current stream": "Current stream",
"Last 12 hours": "Last 12 hours",
"Last 24 hours": "Last 24 hours",
"Last 7 days": "Last 7 days",
"Last 30 days": "Last 30 days",
"Last 3 months": "Last 3 months",
"Last 6 months": "Last 6 months",
"Testing.simpleKey": "This default text should be ignored",
"the next time goes live": "the next time goes live",
"This stream is offline": "This stream is offline.",
"Time": "Time",
"Timestamp": "Timestamp",
"to configure additional details about your viewers": "to configure additional details about your viewers.",
"to have your instance join the Fediverse, allowing people to follow, share and engage with your live stream": "to have your instance join the Fediverse, allowing people to follow, share and engage with your live stream.",
"TROUBLESHOOT": "TROUBLESHOOT",
"Troubleshooting": "Troubleshooting",
"Upload new emoji": "Upload new emoji",
"Use your broadcasting software": "Use your broadcasting software",
"used": "used",
"User": "User",
"View": "View",
"Viewer Info": "Viewer Info",
"Current viewers": "Current viewers",
"Max viewers this stream": "Max viewers this stream",
"Max viewers last stream": "Max viewers last stream",
"max viewers": "max viewers",
"No viewer data has been collected yet": "No viewer data has been collected yet."
}
"Viewers": "Viewers",
"Visible messages": "Visible messages",
"Visit the": "Visit the",
"Want to upload custom emojis in bulk? Check out our": "Want to upload custom emojis in bulk? Check out our",
"Want to upload custom emojis in bulk? Check out our <a href=\"https://owncast.online/docs/chat/emoji\" rel=\"noopener noreferrer\" target=\"_blank\">Emoji guide</a>.": "Want to upload custom emojis in bulk? Check out our <a href=\"https://owncast.online/docs/chat/emoji\" rel=\"noopener noreferrer\" target=\"_blank\">Emoji guide</a>.",
"Warning": "Warning",
"When a stream is active and chat is enabled, connected chat clients will be displayed here": "When a stream is active and chat is enabled, connected chat clients will be displayed here.",
"Yes": "Yes",
"You can build your own bots, overlays, tools and add-ons with our": "You can build your own bots, overlays, tools and add-ons with our",
"You should start one": "You should start one."
}
+2 -1
View File
@@ -19,7 +19,8 @@ module.exports = {
},
],
},
testEnvironment: 'node',
testEnvironment: 'jsdom',
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
};
+1404 -149
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -16,10 +16,13 @@
"build-styles": "cd ./style-definitions && style-dictionary build && ./build.sh && cd -",
"test": "jest",
"format": "prettier --write **/*.{js,ts,jsx,tsx,css,md,scss}",
"translate": "i18next -c i18next-parser.config.mjs"
"translate": "i18next -c i18next-parser.config.mjs",
"i18n:extract": "node scripts/i18n-extract.js"
},
"dependencies": {
"@ant-design/icons": "4.8.3",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.2",
@@ -30,6 +33,8 @@
"@fontsource/inter": "^5.0.0",
"@fontsource/poppins": "5.2.5",
"@next/bundle-analyzer": "^14.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@uiw/codemirror-theme-bbedit": "4.23.7",
"@uiw/react-codemirror": "4.23.7",
"@xstate/react": "3.2.2",
@@ -39,6 +44,7 @@
"chart.js": "^4.2.0",
"classnames": "2.5.1",
"date-fns": "^4.0.0",
"glob": "^11.0.3",
"graphemer": "^1.4.0",
"i18next-parser": "^9.1.0",
"i18next-scanner": "^4.6.0",
@@ -92,6 +98,7 @@
"@storybook/react": "^8.3.6",
"@storybook/theming": "^8.3.6",
"@svgr/webpack": "8.1.0",
"@testing-library/react": "^16.3.0",
"@types/chart.js": "2.9.41",
"@types/classnames": "2.3.4",
"@types/jest": "^29.5.0",
@@ -122,6 +129,7 @@
"handlebars": "^4.7.7",
"html-webpack-plugin": "5.6.3",
"install": "^0.13.0",
"jest-environment-jsdom": "^30.0.4",
"knip": "^5.0.0",
"less": "4.2.1",
"less-loader": "12.2.0",
@@ -142,11 +150,11 @@
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^14.0.0",
"ts-jest": "^29.1.0",
"typescript": "5.7.3"
"typescript": "^5.7.3"
},
"overrides": {
"@emoji-mart/react": {
"emoji-mart": "5.2.2"
}
}
}
}
+9 -11
View File
@@ -14,6 +14,8 @@ import {
} from '../../../utils/input-statuses';
import { RESET_TIMEOUT } from '../../../utils/config-constants';
import { AdminLayout } from '../../../components/layouts/AdminLayout';
import { Translation } from '../../../components/ui/Translation/Translation';
import { Localization } from '../../../types/localization';
const URL_CUSTOM_EMOJIS = `/api/emoji`;
@@ -135,18 +137,14 @@ const Emoji = () => {
return (
<div>
<Title>Emojis</Title>
<Title>
<Translation translationKey={Localization.Admin.emojis} />
</Title>
<Paragraph>
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.
<Translation translationKey={Localization.Admin.emojiPageDescription} />
</Paragraph>
<Paragraph>
{t('Want to upload custom emojis in bulk? Check out our')}{' '}
<a href="https://owncast.online/docs/chat/emoji" rel="noopener noreferrer" target="_blank">
{t('Emoji guide')}
</a>
.
<Translation translationKey={Localization.Admin.emojiUploadBulkGuide} />
</Paragraph>
<br />
<Upload
@@ -160,7 +158,7 @@ const Emoji = () => {
disabled={loading}
>
<Button type="primary" disabled={loading}>
Upload new emoji
<Translation translationKey={Localization.Admin.uploadNewEmoji} />
</Button>
</Upload>
<FormStatusIndicator status={submitStatus} />
@@ -186,7 +184,7 @@ const Emoji = () => {
<Button
size="small"
type="ghost"
title="Delete emoji"
title={t(Localization.Admin.deleteEmoji)}
style={{
position: 'absolute',
right: 0,
+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);
+97
View File
@@ -0,0 +1,97 @@
import { Meta, StoryObj } from '@storybook/react';
import { Translation } from '../components/ui/Translation/Translation';
import { Localization } from '../types/localization';
const meta: Meta<typeof Translation> = {
title: 'owncast/Components/Translation',
component: Translation,
parameters: {
chromatic: { diffThreshold: 0.8 },
},
argTypes: {
translationKey: {
control: 'text',
description: 'The translation key to use for the text',
},
vars: {
control: 'object',
description: 'Variables to interpolate into the translation',
},
className: {
control: 'text',
description: 'CSS class name to apply to the component',
},
},
};
export default meta;
type Story = StoryObj<typeof Translation>;
export const SimpleTranslation: Story = {
args: {
translationKey: Localization.Frontend.chatOffline,
},
};
export const TranslationWithVariable: Story = {
args: {
translationKey: Localization.Frontend.lastLiveAgo,
vars: {
timeAgo: '2 hours',
},
},
};
export const ComplexHTMLTranslation: Story = {
args: {
translationKey: Localization.Frontend.helloWorld,
vars: {
name: 'Gabe',
},
},
};
export const NotificationMessage: Story = {
args: {
translationKey: Localization.Frontend.notificationMessage,
vars: {
streamer: 'MyAwesomeStream',
},
},
};
export const ComplexMessage: Story = {
args: {
translationKey: Localization.Frontend.complexMessage,
vars: {
count: 42,
status: 'live',
},
},
};
export const WithCustomClass: Story = {
args: {
translationKey: Localization.Frontend.helloWorld,
vars: {
name: 'Styled User',
},
className: 'custom-translation-style',
},
};
export const TestDifferentLanguages: Story = {
args: {
translationKey: Localization.Frontend.helloWorld,
vars: {
name: 'Test User',
},
},
parameters: {
docs: {
description: {
story: 'Test different languages by adding ?lang=de or ?lang=fr to the URL',
},
},
},
};
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+180
View File
@@ -0,0 +1,180 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Translation } from '../components/ui/Translation/Translation';
import { Localization } from '../types/localization';
// Mock the next-export-i18n hook
jest.mock('next-export-i18n', () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, any>) => {
// Mock translations for testing
const translations: Record<string, string> = {
hello_world: 'Hello <strong>{{name}}</strong>, welcome to the world!',
'Chat is offline': 'Chat is offline',
notification_message:
'You can <a href="#">click here</a> to receive notifications when {{streamer}} goes live.',
simple_key: 'Simple translation text',
};
let result = translations[key] || key;
// Simple variable replacement for testing
if (vars) {
Object.keys(vars).forEach(varKey => {
result = result.replace(`{{${varKey}}}`, vars[varKey]);
});
}
return result;
},
}),
}));
describe('Translation Component', () => {
test('should render simple translation text', () => {
render(<Translation translationKey={Localization.Testing.simpleKey} />);
expect(screen.getByText('Simple translation text')).toBeInTheDocument();
});
test('should render translation with variable interpolation', () => {
render(
<Translation translationKey={Localization.Frontend.helloWorld} vars={{ name: 'TestUser' }} />,
);
// Check that the text contains the interpolated variable
// Use a function matcher to handle text across multiple elements, targeting the span
const element = screen.getByText((_, e) => {
const hasText = e?.textContent === 'Hello TestUser, welcome to the world!';
const isSpan = e?.tagName === 'SPAN';
return hasText && isSpan;
});
expect(element).toBeInTheDocument();
});
test('should render HTML content correctly', () => {
render(
<Translation translationKey={Localization.Frontend.helloWorld} vars={{ name: 'TestUser' }} />,
);
// Check that HTML tags are rendered (strong tag in this case)
const strongElement = screen.getByText('TestUser');
expect(strongElement.tagName).toBe('STRONG');
});
test('should apply className prop', () => {
render(
<Translation translationKey={Localization.Testing.simpleKey} className="custom-class" />,
);
const element = screen.getByText('Simple translation text');
expect(element).toHaveClass('custom-class');
});
test('should render notification message with HTML link', () => {
render(
<Translation
translationKey={Localization.Frontend.notificationMessage}
vars={{ streamer: 'TestStreamer' }}
/>,
);
// Check that the link is rendered
const linkElement = screen.getByText('click here');
expect(linkElement.tagName).toBe('A');
expect(linkElement).toHaveAttribute('href', '#');
// Check that the variable is interpolated
expect(screen.getByText(/TestStreamer/)).toBeInTheDocument();
});
test('should render with all props combined', () => {
render(
<Translation
translationKey={Localization.Frontend.notificationMessage}
vars={{ streamer: 'TestStreamer' }}
className="notification-style"
/>,
);
// Check that the content is rendered correctly
const element = screen.getByText((_, e) => {
const hasText =
e?.textContent ===
'You can click here to receive notifications when TestStreamer goes live.';
const isSpan = e?.tagName === 'SPAN';
return hasText && isSpan;
});
expect(element).toBeInTheDocument();
expect(element).toHaveClass('notification-style');
});
test('should handle translation without variables', () => {
render(<Translation translationKey={Localization.Frontend.chatOffline} />);
expect(screen.getByText('Chat is offline')).toBeInTheDocument();
});
test('should render defaultText when translation key is missing', () => {
// Use a key that doesn't exist in our mock translations
render(
<Translation
translationKey={'non_existent_key' as any}
defaultText="This is the default text"
/>,
);
expect(screen.getByText('This is the default text')).toBeInTheDocument();
});
test('should render defaultText with variable interpolation when translation key is missing', () => {
// Use a key that doesn't exist in our mock translations
render(
<Translation
translationKey={'non_existent_key' as any}
defaultText="Hello {{name}}, this is default text with {{count}} items"
vars={{ name: 'John', count: 5 }}
/>,
);
expect(screen.getByText('Hello John, this is default text with 5 items')).toBeInTheDocument();
});
test('should render defaultText with HTML content when translation key is missing', () => {
// Use a key that doesn't exist in our mock translations
render(
<Translation
translationKey={'non_existent_key' as any}
defaultText="This is <strong>bold</strong> default text with <em>emphasis</em>"
/>,
);
// Check that HTML tags are rendered correctly
const strongElement = screen.getByText('bold');
expect(strongElement.tagName).toBe('STRONG');
const emElement = screen.getByText('emphasis');
expect(emElement.tagName).toBe('EM');
});
test('should use actual translation when key exists, ignoring defaultText', () => {
render(
<Translation
translationKey={Localization.Testing.simpleKey}
defaultText="This default text should be ignored"
/>,
);
// Should render the actual translation, not the default text
expect(screen.getByText('Simple translation text')).toBeInTheDocument();
expect(screen.queryByText('This default text should be ignored')).not.toBeInTheDocument();
});
test('should render translation key as fallback when no defaultText is provided and key is missing', () => {
// Use a key that doesn't exist in our mock translations
render(<Translation translationKey={'missing_key' as any} />);
// Should render the key itself as fallback
expect(screen.getByText('missing_key')).toBeInTheDocument();
});
});
+1 -1
View File
@@ -17,6 +17,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "utils/constants.js"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "utils/constants.js", "scripts/i18n-extract.js"],
"exclude": ["node_modules"]
}
+3
View File
@@ -0,0 +1,3 @@
// Index file to export localization types and constants
export { Localization } from './localization';
export type { LocalizationKey, LocalizationValue } from './localization';
+132
View File
@@ -0,0 +1,132 @@
/**
* Centralized localization keys for type-safe translation handling.
* This provides a single source of truth for all translation keys used in the application.
* Keys are organized by logical sections using TypeScript namespaces.
*/
export const Localization = {
/**
* Frontend keys used in the main user-facing web application
*/
Frontend: {
// Chat interface
chatOffline: 'Chat is offline',
chatDisabled: 'Chat is disabled',
chatWillBeAvailable: 'Chat will be available when the stream is live',
// Stream information and statistics
lastLiveAgo: 'Last live ago',
currentViewers: 'Current viewers',
maxViewers: 'Max viewers this stream',
noStreamActive: 'No stream is active',
healthyStream: 'Healthy Stream',
playbackHealth: 'Playback Health',
// User actions and interactions
notify: 'Notify',
follow: 'Follow',
connected: 'Connected',
// Navigation and accessibility
skipToPlayer: 'Skip to player',
skipToContent: 'Skip to page content',
skipToFooter: 'Skip to footer',
// Social and external services
stayUpdated: 'Stay updated!',
fediverse: 'Add your Owncast instance to the Fediverse',
owncastDirectory: 'Find an audience on the Owncast Directory',
// Streaming setup and integration
useBroadcastingSoftware: 'Use your broadcasting software',
embedVideo: 'Embed your video onto other sites',
// Complex HTML translations with variables
helloWorld: 'hello_world',
notificationMessage: 'notification_message',
complexMessage: 'complex_message',
// Errors
componentError: 'component_error',
// Offline banner messages
offlineBasic: 'offline_basic',
offlineNotifyOnly: 'offline_notify_only',
offlineFediverseOnly: 'offline_fediverse_only',
offlineNotifyAndFediverse: 'offline_notify_and_fediverse',
},
/**
* Admin keys used in the admin interface
*/
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 <a href="https://owncast.online/docs/chat/emoji" rel="noopener noreferrer" target="_blank">Emoji guide</a>.',
uploadNewEmoji: 'Upload new emoji',
deleteEmoji: 'Delete emoji',
// Settings and configuration
settings: 'settings',
overriddenViaCommandLine: 'Overridden via command line',
// Logging and monitoring
info: 'Info',
warning: 'Warning',
error: 'Error',
level: 'Level',
timestamp: 'Timestamp',
message: 'Message',
logs: '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',
},
/**
* Testing keys used for development and testing purposes
*/
Testing: {
testing: 'testing_string',
another: 'another_test',
simpleKey: 'simple_key',
},
} as const;
/**
* Helper type to extract all nested values from the Localization object
*/
type NestedValues<T> = T extends object
? {
[K in keyof T]: T[K] extends string ? T[K] : NestedValues<T[K]>;
}[keyof T]
: never;
/**
* Type representing all valid localization keys.
* This ensures type safety when using translation keys with nested structure.
*/
export type LocalizationKey = NestedValues<typeof Localization>;
/**
* Helper type to get the actual string value from a localization key.
* This can be useful for type inference in components.
*/
export type LocalizationValue<T extends LocalizationKey> = T;