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
+3 -1
View File
@@ -43,7 +43,7 @@ Do not access the web application via http://localhost:8080 or build the web pro
1. All APIs are to be documented using OpenAPI specifications and code is to be generated using `build/gen-api.sh`. Additional details can be found at https://docs.owncast.dev/api-web-routing. 1. All APIs are to be documented using OpenAPI specifications and code is to be generated using `build/gen-api.sh`. Additional details can be found at https://docs.owncast.dev/api-web-routing.
2. Write API tests for all new endpoints in the `test/automated/api` directory. 2. Write API tests for all new endpoints in the `test/automated/api` directory.
3. Use the `test/automated/browser` directory for browser-based tests for new functionality that simulate user interactions. 3. Use the `test/automated/browser` directory for browser-based tests for new functionality that simulate user interactions.
4. All user-facing frontend UI strings need to support localization. Use the `next-export-i18n` package for wrapping strings to enable this. Read https://docs.owncast.dev/web-translations for more details. Test localization by adding "?lang=XX" with XX being a country code, such as "de" for German. Strings that have not yet been translated will not show as changed, but it's good to test anyway to make sure that previously translated strings have not been broken or regressed in any way. Screenshots with some additional languages can be helpful in showing this. 4. All user-facing frontend UI strings need to support localization. Use the `Translation` component to show most displayable strings. To create dynamic translated strings use the `next-export-i18n` library and the `t()` function. But use the `Translation` component unless there is a reason not to as it allows you to set default text. Read https://docs.owncast.dev/web-translations for more details. Test localization by adding "?lang=XX" with XX being a country code, such as "de" for German. Strings that have not yet been translated will not show as changed, but it's good to test anyway to make sure that previously translated strings have not been broken or regressed in any way. Screenshots with some additional languages can be helpful in showing this.
5. For UI component changes, a before and after screenshot of the component should always be added to the pull request to help with review. Additionally a link to the PR's Storybook on Chromatic via the PR's Chromatic job should be included to help with review. 5. For UI component changes, a before and after screenshot of the component should always be added to the pull request to help with review. Additionally a link to the PR's Storybook on Chromatic via the PR's Chromatic job should be included to help with review.
6. For API changes a before and after example of the API response should be added to the pull request to help with review. 6. For API changes a before and after example of the API response should be added to the pull request to help with review.
7. For backend changes, a before and after example of logs to demonstrate the change should be added to the pull request to help with review. 7. For backend changes, a before and after example of logs to demonstrate the change should be added to the pull request to help with review.
@@ -53,6 +53,7 @@ Do not access the web application via http://localhost:8080 or build the web pro
11. The admin is found at `/admin`. 11. The admin is found at `/admin`.
12. If a live stream video is needed to run, you can run `./test/ocTestStream.sh` to start an actual stream that will begin streaming from the local development server. 12. If a live stream video is needed to run, you can run `./test/ocTestStream.sh` to start an actual stream that will begin streaming from the local development server.
13. You should never commit the `static/web` directory to the repository. It is generated from the `web` directory and should be ignored in your commits. 13. You should never commit the `static/web` directory to the repository. It is generated from the `web` directory and should be ignored in your commits.
14. Don't use emoji in code comments or commit messages. That's lame.
## Screenshots ## Screenshots
@@ -69,3 +70,4 @@ These screenshots should be displayed inline in the PR comments, and not as atta
- When taking screenshots for PR documentation, create temporary files in /tmp directory or use patterns like _screenshot_.js and _screenshot_.png that are excluded by .gitignore. - When taking screenshots for PR documentation, create temporary files in /tmp directory or use patterns like _screenshot_.js and _screenshot_.png that are excluded by .gitignore.
- Screnshots should be taken using the web dev server at `http://localhost:3000` and not the production build at `http://localhost:8080`. - Screnshots should be taken using the web dev server at `http://localhost:3000` and not the production build at `http://localhost:8080`.
- Never commit temporary screenshot scripts or image files to the repository - they should only be used locally and uploaded directly to GitHub for PR comments. - Never commit temporary screenshot scripts or image files to the repository - they should only be used locally and uploaded directly to GitHub for PR comments.
- Double check that the screenshots are attached to the PR comments. Copilot often forgets to do this or says it did it but doesn't actually do it. If it doesn't do it, it should continue to try until it succeeds.
@@ -0,0 +1,50 @@
name: Extract Default Translations
on:
push:
paths:
- 'web/**/*.ts'
- 'web/**/*.tsx'
- 'web/**/*.js'
- 'web/**/*.jsx'
jobs:
extract-and-commit:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: true
fetch-depth: 0 # Required to push back to same branch
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run i18n:extract
run: npm run i18n:extract
- name: Commit and push changes
run: |
git config --global user.name "Owncast default web localizations"
git config --global user.email "owncast@owncast.online"
if [ -n "$(git status --porcelain i18n/)" ]; then
git add i18n/
git commit -m "chore: update extracted translations"
git push origin HEAD
else
echo "No translation changes to commit."
fi
+2
View File
@@ -150,6 +150,7 @@ github.com/evilmartians/lefthook v1.11.16 h1:QJ3yTQN/X31+z1xL48NrwMzBpulkbicTE1q
github.com/evilmartians/lefthook v1.11.16/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok= github.com/evilmartians/lefthook v1.11.16/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok=
github.com/evilmartians/lefthook v1.12.0/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok= github.com/evilmartians/lefthook v1.12.0/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok=
github.com/evilmartians/lefthook v1.12.1/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok= github.com/evilmartians/lefthook v1.12.1/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok=
github.com/evilmartians/lefthook v1.12.2 h1:pKncsv6tUjPyAZa/Hec90SNxxgybB++Xc4o+soV29hg=
github.com/evilmartians/lefthook v1.12.2/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok= github.com/evilmartians/lefthook v1.12.2/go.mod h1:yQCQNPGxi0sn9+kH6O1T1VF45shbnvmt7F9xKCfTZok=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
@@ -902,6 +903,7 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+1
View File
@@ -6,3 +6,4 @@ out
lefthook.yml lefthook.yml
storybook-static storybook-static
tsconfig.tsbuildinfo
+10 -2
View File
@@ -11,7 +11,9 @@
"public/**", "public/**",
"tests/**", "tests/**",
"i18n/index.js", "i18n/index.js",
"i18next-parser.config.mjs" "i18next-parser.config.mjs",
"types/index.ts",
"scripts/i18n-extract.js"
], ],
"ignoreDependencies": [ "ignoreDependencies": [
"@fontsource/inter", "@fontsource/inter",
@@ -52,6 +54,12 @@
"i18next-scanner", "i18next-scanner",
"@types/chart.js", "@types/chart.js",
"@types/classnames", "@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 { Alert, Button } from 'antd';
import { FC } from 'react'; import { FC } from 'react';
import Translation from '../Translation/Translation';
import { Localization } from '../../../types/localization';
export type ComponentErrorProps = { export type ComponentErrorProps = {
message?: string; message?: string;
@@ -35,7 +37,15 @@ const ErrorContent = ({
<p>You may optionally retry, however functionality might not work as expected.</p> <p>You may optionally retry, however functionality might not work as expected.</p>
)} )}
<code> <code>
<div>{message && `Error: ${message}`}</div> <div>
{message && (
<Translation
translationKey={Localization.Frontend.componentError}
defaultText="Error: {{message}}"
vars={{ message }}
/>
)}
</div>
<div>Component: {componentName}</div> <div>Component: {componentName}</div>
<div>{details && details}</div> <div>{details && details}</div>
</code> </code>
+7 -2
View File
@@ -4,6 +4,8 @@ import { useTranslation } from 'next-export-i18n';
import styles from './Footer.module.scss'; import styles from './Footer.module.scss';
import { ServerStatus } from '../../../interfaces/server-status.model'; import { ServerStatus } from '../../../interfaces/server-status.model';
import { serverStatusState } from '../../stores/ClientConfigStore'; import { serverStatusState } from '../../stores/ClientConfigStore';
import { Localization } from '../../../types';
import Translation from '../Translation/Translation';
export const Footer: FC = () => { export const Footer: FC = () => {
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState); const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
@@ -12,8 +14,11 @@ export const Footer: FC = () => {
return ( return (
<footer className={styles.footer} id="footer"> <footer className={styles.footer} id="footer">
<span> <span>
{t('Powered by Owncast')} <Translation
<a href="https://owncast.online">&nbsp;v{versionNumber}</a> translationKey={Localization.Common.poweredByOwncastVersion}
vars={{ versionNumber }}
defaultText="Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
/>
</span> </span>
<span className={styles.links}> <span className={styles.links}>
<a href="https://owncast.online/docs" target="_blank" rel="noreferrer"> <a href="https://owncast.online/docs" target="_blank" rel="noreferrer">
@@ -20,11 +20,6 @@
} }
} }
.bodyText {
line-height: 2rem;
font-size: 1.2rem;
}
.separator { .separator {
margin-top: 15px; margin-top: 15px;
margin-bottom: 15px; margin-bottom: 15px;
@@ -61,3 +56,20 @@
color: var(--color-owncast-palette-7); 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 react/no-danger */
/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/click-events-have-key-events */
import { Divider } from 'antd'; import { Divider } from 'antd';
import { FC } from 'react'; import React, { FC } from 'react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'next-export-i18n'; import { useTranslation } from 'next-export-i18n';
import { Translation } from '../Translation/Translation';
import { Localization } from '../../../types/localization';
import styles from './OfflineBanner.module.scss'; import styles from './OfflineBanner.module.scss';
// Lazy loaded components // Lazy loaded components
@@ -39,49 +41,49 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
}) => { }) => {
const { t } = useTranslation(); 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; let text;
if (customText) { if (customText) {
text = customText; text = customText;
} else if (!customText && notificationsEnabled && fediverseAccount) { } else if (!customText && notificationsEnabled && fediverseAccount) {
text = ( text = (
<span> <Translation
{t('This stream is offline. You can')}{' '} translationKey={Localization.Frontend.offlineNotifyAndFediverse}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}> vars={{ streamer: streamName, fediverseAccount }}
be notified 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."
</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>
); );
} else if (!customText && notificationsEnabled) { } else if (!customText && notificationsEnabled) {
text = ( text = (
<span> <Translation
{t('This stream is offline')}.{' '} translationKey={Localization.Frontend.offlineNotifyOnly}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}> vars={{ streamer: streamName }}
Be notified defaultText="This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live."
</span>{' '} />
{t('the next time goes live', { streamer: streamName })}.
</span>
); );
} else if (!customText && fediverseAccount) { } else if (!customText && fediverseAccount) {
text = ( text = (
<span> <Translation
{t('This stream is offline.')}{' '} translationKey={Localization.Frontend.offlineFediverseOnly}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}> vars={{ fediverseAccount, streamer: streamName }}
{t('Follow')} defaultText="This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live."
</span>{' '} />
{t('on the Fediverse to see the next time goes live', {
fediverseAccount,
streamer: streamName,
})}
.
</span>
); );
} else { } else {
text = `This stream is offline. Check back soon!`; text = (
<Translation
translationKey={Localization.Frontend.offlineBasic}
defaultText="This stream is offline. Check back soon!"
/>
);
} }
return ( return (
@@ -96,7 +98,14 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
{customText ? ( {customText ? (
<div className={styles.bodyText} dangerouslySetInnerHTML={{ __html: text }} /> <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 && ( {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", "the next time goes live": "das nächste Mal geht live",
"Follow": "Folgen", "Follow": "Folgen",
"on the Fediverse to see the next time goes live": "auf dem Fediverse zu sehen, wann das nächste Mal live geht", "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", "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", "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", "Emoji guide": "Emoji-Guide",
@@ -128,5 +132,13 @@
"Max viewers this stream": "Maximale Anzahl von Zuschauern in diesem Stream", "Max viewers this stream": "Maximale Anzahl von Zuschauern in diesem Stream",
"Max viewers last stream": "Maximale Zuschauerzahl beim letzten Stream", "Max viewers last stream": "Maximale Zuschauerzahl beim letzten Stream",
"max viewers": "Max. Zuschauer", "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"
} }
+133 -114
View File
@@ -1,132 +1,151 @@
{ {
"Notify": "Notify", "Add your Owncast instance to the Fediverse": "Add your Owncast instance to the Fediverse",
"Level": "Level", "Banned Users": "Banned Users",
"Info": "Info", "Bring in moderators to help keep your chat in order": "Bring in moderators to help keep your chat in order.",
"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.",
"Chat is disabled": "Chat is disabled", "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.", "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", "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": { "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" " Enable it in": "List yourself in the Owncast Directory and show off your stream. Enable it in"
}, },
"settings": "settings.", "Logs": "Logs",
"Add your Owncast instance to the Fediverse": "Add your Owncast instance to the Fediverse", "Manage the messages from viewers that show up on your stream": "Manage the messages from viewers that show up on your stream.",
"Enable Owncast social features": "Enable Owncast social features", "max viewers": "max 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.", "Max viewers last stream": "Max viewers last stream",
"No stream is active": "No stream is active", "Max viewers this stream": "Max viewers this stream",
"You should start one": "You should start one.", "Memory": "Memory",
"Healthy Stream": "Healthy Stream", "Message": "Message",
"Yes": "Yes", "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", "No": "No",
"Playback Health": "Playback Health", "No hardware details have been collected yet": "No hardware details have been collected yet.",
"Stream health represents": "Stream health represents", "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": { "of all known players": {
" Other player status is unknown": "of all known players. Other player status is unknown." " 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", "Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation", "Read the Docs": "Read the Docs",
"Contribute": "Contribute", "settings": "settings.",
"Source": "Source", "Show": "Show",
"Skip to player": "Skip to player", "Skip to footer": "Skip to footer",
"Skip to offline message": "Skip to offline message", "Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content", "Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer", "Skip to player": "Skip to player",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.", "Source": "Source",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!", "Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!", "Stream health represents": "Stream health represents",
"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 started": "Stream started", "Stream started": "Stream started",
"Viewers": "Viewers", "Testing.simpleKey": "This default text should be ignored",
"Peak viewer count": "Peak viewer count", "the next time goes live": "the next time goes live",
"Outbound Stream Details": "Outbound Stream Details", "This stream is offline": "This stream is offline.",
"Inbound Stream Details": "Inbound Stream Details", "Time": "Time",
"Input": "Input", "Timestamp": "Timestamp",
"Inbound Video Stream": "Inbound Video Stream", "to configure additional details about your viewers": "to configure additional details about your viewers.",
"Inbound Audio Stream": "Inbound Audio Stream", "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.",
"Current stream": "Current stream", "TROUBLESHOOT": "TROUBLESHOOT",
"Last 12 hours": "Last 12 hours", "Troubleshooting": "Troubleshooting",
"Last 24 hours": "Last 24 hours", "Upload new emoji": "Upload new emoji",
"Last 7 days": "Last 7 days", "Use your broadcasting software": "Use your broadcasting software",
"Last 30 days": "Last 30 days", "used": "used",
"Last 3 months": "Last 3 months", "User": "User",
"Last 6 months": "Last 6 months", "View": "View",
"Viewer Info": "Viewer Info", "Viewer Info": "Viewer Info",
"Current viewers": "Current viewers", "Viewers": "Viewers",
"Max viewers this stream": "Max viewers this stream", "Visible messages": "Visible messages",
"Max viewers last stream": "Max viewers last stream", "Visit the": "Visit the",
"max viewers": "max viewers", "Want to upload custom emojis in bulk? Check out our": "Want to upload custom emojis in bulk? Check out our",
"No viewer data has been collected yet": "No viewer data has been collected yet." "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)$', testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 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
+10 -2
View File
@@ -16,10 +16,13 @@
"build-styles": "cd ./style-definitions && style-dictionary build && ./build.sh && cd -", "build-styles": "cd ./style-definitions && style-dictionary build && ./build.sh && cd -",
"test": "jest", "test": "jest",
"format": "prettier --write **/*.{js,ts,jsx,tsx,css,md,scss}", "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": { "dependencies": {
"@ant-design/icons": "4.8.3", "@ant-design/icons": "4.8.3",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@codemirror/lang-css": "6.3.1", "@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "^6.4.2", "@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2",
@@ -30,6 +33,8 @@
"@fontsource/inter": "^5.0.0", "@fontsource/inter": "^5.0.0",
"@fontsource/poppins": "5.2.5", "@fontsource/poppins": "5.2.5",
"@next/bundle-analyzer": "^14.0.0", "@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/codemirror-theme-bbedit": "4.23.7",
"@uiw/react-codemirror": "4.23.7", "@uiw/react-codemirror": "4.23.7",
"@xstate/react": "3.2.2", "@xstate/react": "3.2.2",
@@ -39,6 +44,7 @@
"chart.js": "^4.2.0", "chart.js": "^4.2.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"date-fns": "^4.0.0", "date-fns": "^4.0.0",
"glob": "^11.0.3",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"i18next-parser": "^9.1.0", "i18next-parser": "^9.1.0",
"i18next-scanner": "^4.6.0", "i18next-scanner": "^4.6.0",
@@ -92,6 +98,7 @@
"@storybook/react": "^8.3.6", "@storybook/react": "^8.3.6",
"@storybook/theming": "^8.3.6", "@storybook/theming": "^8.3.6",
"@svgr/webpack": "8.1.0", "@svgr/webpack": "8.1.0",
"@testing-library/react": "^16.3.0",
"@types/chart.js": "2.9.41", "@types/chart.js": "2.9.41",
"@types/classnames": "2.3.4", "@types/classnames": "2.3.4",
"@types/jest": "^29.5.0", "@types/jest": "^29.5.0",
@@ -122,6 +129,7 @@
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"html-webpack-plugin": "5.6.3", "html-webpack-plugin": "5.6.3",
"install": "^0.13.0", "install": "^0.13.0",
"jest-environment-jsdom": "^30.0.4",
"knip": "^5.0.0", "knip": "^5.0.0",
"less": "4.2.1", "less": "4.2.1",
"less-loader": "12.2.0", "less-loader": "12.2.0",
@@ -142,7 +150,7 @@
"stylelint-config-standard": "^36.0.0", "stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^14.0.0", "stylelint-config-standard-scss": "^14.0.0",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"typescript": "5.7.3" "typescript": "^5.7.3"
}, },
"overrides": { "overrides": {
"@emoji-mart/react": { "@emoji-mart/react": {
+9 -11
View File
@@ -14,6 +14,8 @@ import {
} from '../../../utils/input-statuses'; } from '../../../utils/input-statuses';
import { RESET_TIMEOUT } from '../../../utils/config-constants'; import { RESET_TIMEOUT } from '../../../utils/config-constants';
import { AdminLayout } from '../../../components/layouts/AdminLayout'; import { AdminLayout } from '../../../components/layouts/AdminLayout';
import { Translation } from '../../../components/ui/Translation/Translation';
import { Localization } from '../../../types/localization';
const URL_CUSTOM_EMOJIS = `/api/emoji`; const URL_CUSTOM_EMOJIS = `/api/emoji`;
@@ -135,18 +137,14 @@ const Emoji = () => {
return ( return (
<div> <div>
<Title>Emojis</Title> <Title>
<Translation translationKey={Localization.Admin.emojis} />
</Title>
<Paragraph> <Paragraph>
Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the <Translation translationKey={Localization.Admin.emojiPageDescription} />
filename without extension will be used as emoji name. Additionally, emoji names are
case-insensitive. For best results, ensure all emoji have unique names.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
{t('Want to upload custom emojis in bulk? Check out our')}{' '} <Translation translationKey={Localization.Admin.emojiUploadBulkGuide} />
<a href="https://owncast.online/docs/chat/emoji" rel="noopener noreferrer" target="_blank">
{t('Emoji guide')}
</a>
.
</Paragraph> </Paragraph>
<br /> <br />
<Upload <Upload
@@ -160,7 +158,7 @@ const Emoji = () => {
disabled={loading} disabled={loading}
> >
<Button type="primary" disabled={loading}> <Button type="primary" disabled={loading}>
Upload new emoji <Translation translationKey={Localization.Admin.uploadNewEmoji} />
</Button> </Button>
</Upload> </Upload>
<FormStatusIndicator status={submitStatus} /> <FormStatusIndicator status={submitStatus} />
@@ -186,7 +184,7 @@ const Emoji = () => {
<Button <Button
size="small" size="small"
type="ghost" type="ghost"
title="Delete emoji" title={t(Localization.Admin.deleteEmoji)}
style={{ style={{
position: 'absolute', position: 'absolute',
right: 0, 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, "noUnusedLocals": true,
"noUnusedParameters": 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"] "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;