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:
co-authored by
gabek
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Owncast
Gabe Kangas
parent
7b181aa0a6
commit
29b5100114
@@ -6,3 +6,4 @@ out
|
||||
|
||||
lefthook.yml
|
||||
storybook-static
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+11
-3
@@ -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>
|
||||
|
||||
@@ -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"> 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;
|
||||
@@ -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
@@ -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
@@ -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'],
|
||||
};
|
||||
|
||||
Generated
+1404
-149
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Index file to export localization types and constants
|
||||
export { Localization } from './localization';
|
||||
export type { LocalizationKey, LocalizationValue } from './localization';
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user