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
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -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`.
|
||||
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.
|
||||
14. Don't use emoji in code comments or commit messages. That's lame.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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
|
||||
@@ -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.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.2 h1:pKncsv6tUjPyAZa/Hec90SNxxgybB++Xc4o+soV29hg=
|
||||
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/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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
|
||||
@@ -6,3 +6,4 @@ out
|
||||
|
||||
lefthook.yml
|
||||
storybook-static
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+10
-2
@@ -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"
|
||||
}
|
||||
|
||||
+133
-114
@@ -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
+10
-2
@@ -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,7 +150,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -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