Add pluralization support to Translation component (#4441)

* Initial plan

* Add pluralization support to Translation component with comprehensive tests

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Javascript formatting autofixes

* Update web/tests/translation.test.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update web/components/ui/Translation/Translation.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: linter warning

* Simplify pluralization logic: use original key for plural, only _one for singular

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* feat(i18n): fix support for nested namespace string and key extraction

* chore: update extracted translations

* fix(i18n): fix linter warnings in extraction script

* Update web/scripts/i18n-extract.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(i18n): sort translation keys

* fix(i18n): fix linter warnings

---------

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>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2025-07-13 17:34:06 -07:00
committed by GitHub
co-authored by gabek Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Owncast Gabe Kangas
parent 1fd86fa190
commit 59b905649e
9 changed files with 392 additions and 71 deletions
@@ -33,8 +33,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run i18n:extract
run: npm run i18n:extract
- name: Run translation extraction
run: npm run translate
- name: Commit and push changes
run: |
+25 -2
View File
@@ -8,6 +8,7 @@ export interface TranslationProps {
vars?: Record<string, any>;
className?: string;
defaultText?: string;
count?: number;
}
export const Translation: FC<TranslationProps> = ({
@@ -15,10 +16,32 @@ export const Translation: FC<TranslationProps> = ({
vars,
className,
defaultText,
count,
}) => {
const { t } = useTranslation();
let translatedText = t(translationKey, vars);
// Include count in vars for interpolation
const allVars = count !== undefined ? { ...vars, count } : vars;
let translatedText;
if (count !== undefined) {
if (count === 1) {
// For singular, try _one key first, fall back to original key
const singularKey = `${translationKey}_one`;
translatedText = t(singularKey, allVars);
// Fall back to original key if _one key doesn't exist
if (translatedText === singularKey) {
translatedText = t(translationKey, allVars);
}
} else {
// For plural, always use the original key (no _other suffix needed)
translatedText = t(translationKey, allVars);
}
} else {
translatedText = t(translationKey, allVars);
}
// Use fallback if translation is missing (returns the key itself)
if (translatedText === translationKey && defaultText) {
@@ -26,7 +49,7 @@ export const Translation: FC<TranslationProps> = ({
// Interpolate variables manually into defaultText
// eslint-disable-next-line no-restricted-syntax
for (const [k, v] of Object.entries(vars || {})) {
for (const [k, v] of Object.entries(allVars || {})) {
const regex = new RegExp(`{{\\s*${k}\\s*}}`, 'g');
translatedText = translatedText.replace(regex, String(v));
}
+50 -51
View File
@@ -1,52 +1,54 @@
{
"Add your Owncast instance to the Fediverse": "Add your Owncast instance to the Fediverse",
"Admin.directoryDescription": "Increase your audience by appearing in the <a href=\"https://directory.owncast.online\" target=\"_blank\" rel=\"noreferrer\"><strong>Owncast Directory</strong></a>. This is an external service run by the Owncast project. <a href=\"https://owncast.online/docs/directory/?source=admin\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more</a>.",
"Admin.EditInstanceDetails.directoryDescription": "Increase your audience by appearing in the <a href=\"https://directory.owncast.online\" target=\"_blank\" rel=\"noreferrer\"><strong>Owncast Directory</strong></a>. This is an external service run by the Owncast project. <a href=\"https://owncast.online/docs/directory/?source=admin\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more</a>.",
"Admin.EditInstanceDetails.offlineMessageDescription": "The offline message is displayed to your page visitors when you're not streaming. Markdown is supported.",
"Admin.EditInstanceDetails.serverUrlRequiredForDirectory": "You must set your <strong>Server URL</strong> above to enable the directory.",
"Admin.offlineMessageDescription": "The offline message is displayed to your page visitors when you're not streaming. Markdown is supported.",
"Admin.serverUrlRequiredForDirectory": "You must set your <strong>Server URL</strong> above to enable the directory.",
"Admin": {
"EditInstanceDetails": {
"directoryDescription": "Increase your audience by appearing in the <a href=\"https://directory.owncast.online\" target=\"_blank\" rel=\"noreferrer\"><strong>Owncast Directory</strong></a>. This is an external service run by the Owncast project. <a href=\"https://owncast.online/docs/directory/?source=admin\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more</a>.",
"offlineMessageDescription": "The offline message is displayed to your page visitors when you're not streaming. Markdown is supported.",
"serverUrlRequiredForDirectory": "You must set your <strong>Server URL</strong> above to enable the directory."
},
"emojiPageDescription": "<strong><em>Missing translation Admin.emojiPageDescription: Please report</em></strong>",
"emojiUploadBulkGuide": "<strong><em>Missing translation Admin.emojiUploadBulkGuide: Please report</em></strong>",
"emojis": "<strong><em>Missing translation Admin.emojis: Please report</em></strong>",
"uploadNewEmoji": "<strong><em>Missing translation Admin.uploadNewEmoji: Please report</em></strong>"
},
"Banned Users": "Banned Users",
"Bring in moderators to help keep your chat in order": "Bring in moderators to help keep your chat in order.",
"CPU": "CPU",
"Chat Messages": "Chat Messages",
"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": {
"poweredByOwncastVersion": "Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
},
"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.",
"Frontend": {
"chatOffline": "<strong><em>Missing translation Frontend.chatOffline: Please report</em></strong>",
"componentError": "Error: {{message}}",
"helloWorld": "<strong><em>Missing translation Frontend.helloWorld: Please report</em></strong>",
"notificationMessage": "<strong><em>Missing translation Frontend.notificationMessage: Please report</em></strong>",
"offlineBasic": "This stream is offline. Check back soon!",
"offlineFediverseOnly": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
"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.",
"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?",
@@ -58,13 +60,13 @@
"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",
"IP Bans": "IP Bans",
"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",
@@ -76,7 +78,6 @@
"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": {
@@ -84,7 +85,6 @@
},
"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",
@@ -97,18 +97,7 @@
"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."
},
"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",
@@ -117,9 +106,7 @@
"Peak viewer count": "Peak viewer count",
"Playback Health": "Playback Health",
"Please wait": "Please wait",
"Powered by Owncast": "Powered by Owncast",
"Read the Docs": "Read the Docs",
"settings": "settings.",
"Show": "Show",
"Skip to footer": "Skip to footer",
"Skip to offline message": "Skip to offline message",
@@ -129,29 +116,41 @@
"Stay updated!": "Stay updated!",
"Stream health represents": "Stream health represents",
"Stream started": "Stream started",
"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.",
"TROUBLESHOOT": "TROUBLESHOOT",
"Testing": {
"itemCount": "<strong><em>Missing translation Testing.itemCount: Please report</em></strong>",
"messageCount": "<strong><em>Missing translation Testing.messageCount: Please report</em></strong>",
"noPluralKey": "<strong><em>Missing translation Testing.noPluralKey: Please report</em></strong>",
"simpleKey": "<strong><em>Missing translation Testing.simpleKey: Please report</em></strong>"
},
"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",
"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."
"You should start one": "You should start one.",
"developer APIs": "developer APIs.",
"discussions": "discussions",
"documentation": "documentation",
"let us know": "let us know",
"max viewers": "max viewers",
"of all known players": {
" Other player status is unknown": "of all known players. Other player status is unknown."
},
"offline": "offline",
"offline_basic": "This stream is offline. Check back soon!",
"or exist in our": "or exist in our",
"settings": "settings.",
"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.",
"used": "used"
}
+32
View File
@@ -0,0 +1,32 @@
{
"Testing": {
"simpleKey": "<strong><em>Missing translation Testing.simpleKey: Please report</em></strong>",
"itemCount": "<strong><em>Missing translation Testing.itemCount: Please report</em></strong>",
"messageCount": "<strong><em>Missing translation Testing.messageCount: Please report</em></strong>",
"noPluralKey": "<strong><em>Missing translation Testing.noPluralKey: Please report</em></strong>"
},
"Frontend": {
"helloWorld": "<strong><em>Missing translation Frontend.helloWorld: Please report</em></strong>",
"notificationMessage": "<strong><em>Missing translation Frontend.notificationMessage: Please report</em></strong>",
"chatOffline": "<strong><em>Missing translation Frontend.chatOffline: Please report</em></strong>",
"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.",
"offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
"offlineFediverseOnly": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
"offlineBasic": "This stream is offline. Check back soon!",
"componentError": "Error: {{message}}"
},
"Admin": {
"emojis": "<strong><em>Missing translation Admin.emojis: Please report</em></strong>",
"emojiPageDescription": "<strong><em>Missing translation Admin.emojiPageDescription: Please report</em></strong>",
"emojiUploadBulkGuide": "<strong><em>Missing translation Admin.emojiUploadBulkGuide: Please report</em></strong>",
"uploadNewEmoji": "<strong><em>Missing translation Admin.uploadNewEmoji: Please report</em></strong>",
"EditInstanceDetails": {
"offlineMessageDescription": "The offline message is displayed to your page visitors when you're not streaming. Markdown is supported.",
"directoryDescription": "Increase your audience by appearing in the <a href=\"https://directory.owncast.online\" target=\"_blank\" rel=\"noreferrer\"><strong>Owncast Directory</strong></a>. This is an external service run by the Owncast project. <a href=\"https://owncast.online/docs/directory/?source=admin\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more</a>.",
"serverUrlRequiredForDirectory": "You must set your <strong>Server URL</strong> above to enable the directory."
}
},
"Common": {
"poweredByOwncastVersion": "Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
}
}
+2 -3
View File
@@ -16,8 +16,7 @@
"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",
"i18n:extract": "node scripts/i18n-extract.js"
"translate": "i18next -c i18next-parser.config.mjs && node scripts/i18n-extract.js"
},
"dependencies": {
"@ant-design/icons": "4.8.3",
@@ -157,4 +156,4 @@
"emoji-mart": "5.2.2"
}
}
}
}
+69 -13
View File
@@ -14,14 +14,28 @@ function getDotPath(node) {
const prop = node.property.name || node.property.value;
if (objectPath !== null && prop) {
return objectPath ? `${objectPath}.${prop}` : prop; // skip base if empty
return objectPath ? `${objectPath}.${prop}` : prop;
}
} else if (node.type === 'Identifier' && node.name === 'Localization') {
return ''; // treat as base, skip
return '';
}
return null;
}
function sortObjectKeys(obj) {
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj)
.sort()
.reduce((acc, key) => ({ ...acc, [key]: sortObjectKeys(obj[key]) }), {});
}
return obj;
}
function scanTranslationKeys() {
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
ignore: ['node_modules/**', '.next/**', 'out/**'],
@@ -77,8 +91,14 @@ function scanTranslationKeys() {
}
}
if (key && defaultText && !results[key]) {
results[key] = defaultText;
if (key) {
// Eventually enable this to not allow empty strings.
// Then remove the 'missing translation' fallback below.
// if (!defaultText || defaultText.trim() === '') {
// process.exit(1);
// }
results[key] =
defaultText || `<strong><em>Missing translation ${key}: Please report</em></strong>`;
}
},
});
@@ -87,7 +107,37 @@ function scanTranslationKeys() {
return results;
}
function updateTranslationFile(newTranslations) {
// Recursively sets a nested value using a dot-notated key
function setNestedKey(obj, keyPath, value) {
const keys = keyPath.split('.');
let current = obj;
keys.forEach((key, index) => {
if (index === keys.length - 1) {
current[key] = value;
} else {
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
});
}
// Deep merge of two objects
function mergeDeep(target, source) {
const output = { ...target };
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
output[key] = mergeDeep(output[key] || {}, source[key]);
} else {
output[key] = source[key];
}
}
return output;
}
function updateTranslationFile(flatTranslations) {
let existing = {};
if (fs.existsSync(TRANSLATIONS_PATH)) {
@@ -95,20 +145,26 @@ function updateTranslationFile(newTranslations) {
}
let changed = false;
let newNestedTranslations = {};
for (const [key, value] of Object.entries(newTranslations)) {
if (!(key in existing)) {
existing[key] = value;
for (const [flatKey, value] of Object.entries(flatTranslations)) {
const tempObj = {};
setNestedKey(tempObj, flatKey, value);
// Detect if this key is already present
const flatKeyParts = flatKey.split('.');
const alreadyExists = flatKeyParts.reduce((acc, part) => acc && acc[part], existing);
if (!alreadyExists) {
newNestedTranslations = mergeDeep(newNestedTranslations, tempObj);
changed = true;
console.log(`[i18n] Added: ${key}`);
console.log(`[i18n] Added: ${flatKey}`);
}
}
if (changed) {
const sorted = Object.fromEntries(
Object.entries(existing).sort(([a], [b]) => a.localeCompare(b)),
);
fs.writeFileSync(TRANSLATIONS_PATH, JSON.stringify(sorted, null, 2));
const merged = sortObjectKeys(mergeDeep(existing, newNestedTranslations));
fs.writeFileSync(TRANSLATIONS_PATH, JSON.stringify(merged, null, 2));
console.log(`[i18n] Updated ${TRANSLATIONS_PATH}`);
} else {
console.log('[i18n] No new keys to add.');
+88
View File
@@ -21,6 +21,15 @@ const meta: Meta<typeof Translation> = {
control: 'text',
description: 'CSS class name to apply to the component',
},
count: {
control: 'number',
description:
'Count for pluralization support (1 = singular with _one key, others = plural with original key)',
},
defaultText: {
control: 'text',
description: 'Default text to use when translation is missing',
},
},
};
@@ -95,3 +104,82 @@ export const TestDifferentLanguages: Story = {
},
},
};
export const PluralizationSingular: Story = {
args: {
translationKey: Localization.Testing.itemCount,
count: 1,
},
parameters: {
docs: {
description: {
story:
'Pluralization example with singular form (count = 1) - looks for _one key first, falls back to original key',
},
},
},
};
export const PluralizationPlural: Story = {
args: {
translationKey: Localization.Testing.itemCount,
count: 5,
},
parameters: {
docs: {
description: {
story: 'Pluralization example with plural form (count > 1) - uses original key directly',
},
},
},
};
export const PluralizationWithVariables: Story = {
args: {
translationKey: Localization.Testing.messageCount,
count: 3,
vars: {
sender: 'Alice',
},
},
parameters: {
docs: {
description: {
story: 'Pluralization with additional variables',
},
},
},
};
export const PluralizationFallback: Story = {
args: {
translationKey: Localization.Testing.noPluralKey,
count: 2,
},
parameters: {
docs: {
description: {
story:
'Pluralization fallback when no _one key exists (uses original key for both singular and plural)',
},
},
},
};
export const PluralizationWithDefaultText: Story = {
args: {
translationKey: 'non_existent_plural_key' as any,
count: 4,
defaultText: 'This is test number {{count}} for {{name}}',
vars: {
name: 'Bob',
},
},
parameters: {
docs: {
description: {
story: 'Pluralization with default text fallback',
},
},
},
};
+116
View File
@@ -14,6 +14,15 @@ jest.mock('next-export-i18n', () => ({
notification_message:
'You can <a href="#">click here</a> to receive notifications when {{streamer}} goes live.',
simple_key: 'Simple translation text',
// Pluralization test keys
item_count_one: 'You have {{count}} item',
item_count: 'You have {{count}} items', // Original key serves as plural form
message_count_one: 'You have {{count}} message from {{sender}}',
message_count: 'You have {{count}} messages from {{sender}}', // Original key serves as plural form
// Keys without pluralization variants
no_plural_key: 'This key has no plural variants - {{count}} things',
};
let result = translations[key] || key;
@@ -177,4 +186,111 @@ describe('Translation Component', () => {
// Should render the key itself as fallback
expect(screen.getByText('missing_key')).toBeInTheDocument();
});
// Pluralization tests
describe('Pluralization Support', () => {
test('should use singular form when count is 1', () => {
render(<Translation translationKey={Localization.Testing.itemCount} count={1} />);
expect(screen.getByText('You have 1 item')).toBeInTheDocument();
});
test('should use plural form when count is greater than 1', () => {
render(<Translation translationKey={Localization.Testing.itemCount} count={5} />);
expect(screen.getByText('You have 5 items')).toBeInTheDocument();
});
test('should use plural form when count is negative', () => {
render(<Translation translationKey={Localization.Testing.itemCount} count={-3} />);
expect(screen.getByText('You have -3 items')).toBeInTheDocument();
});
test('should interpolate count and other variables in pluralized text', () => {
render(
<Translation
translationKey={Localization.Testing.messageCount}
count={3}
vars={{ sender: 'Alice' }}
/>,
);
expect(screen.getByText('You have 3 messages from Alice')).toBeInTheDocument();
});
test('should interpolate count and other variables in singular text', () => {
render(
<Translation
translationKey={Localization.Testing.messageCount}
count={1}
vars={{ sender: 'Bob' }}
/>,
);
expect(screen.getByText('You have 1 message from Bob')).toBeInTheDocument();
});
test('should fallback to original key when pluralization keys do not exist', () => {
render(<Translation translationKey={Localization.Testing.noPluralKey} count={2} />);
expect(screen.getByText('This key has no plural variants - 2 things')).toBeInTheDocument();
});
test('should work without count prop (backward compatibility)', () => {
render(<Translation translationKey={Localization.Testing.simpleKey} />);
expect(screen.getByText('Simple translation text')).toBeInTheDocument();
});
test('should use defaultText with count interpolation when translation is missing', () => {
render(
<Translation
translationKey={'missing_plural_key' as any}
count={3}
defaultText="Default text with {{count}} items"
/>,
);
expect(screen.getByText('Default text with 3 items')).toBeInTheDocument();
});
test('should use defaultText with count and other vars when translation is missing', () => {
render(
<Translation
translationKey={'missing_plural_key' as any}
count={1}
vars={{ name: 'John' }}
defaultText="{{name}} has {{count}} item"
/>,
);
expect(screen.getByText('John has 1 item')).toBeInTheDocument();
});
test('should handle count of 0 as plural form', () => {
render(<Translation translationKey={Localization.Testing.itemCount} count={0} />);
expect(screen.getByText('You have 0 items')).toBeInTheDocument();
});
test('should handle fractional count as plural form', () => {
render(<Translation translationKey={Localization.Testing.itemCount} count={1.5} />);
expect(screen.getByText('You have 1.5 items')).toBeInTheDocument();
});
test('should work with className and pluralization', () => {
render(
<Translation
translationKey={Localization.Testing.itemCount}
count={2}
className="count-display"
/>,
);
const element = screen.getByText('You have 2 items');
expect(element).toHaveClass('count-display');
});
});
});
+8
View File
@@ -72,6 +72,11 @@ export const Localization = {
settings: 'settings',
overriddenViaCommandLine: 'Overridden via command line',
Chat: {
moderationMessagesSent: 'Admin.Chat.moderationMessagesSent',
moderationMessagesSent_one: 'Admin.Chat.moderationMessagesSent_one',
},
// EditInstanceDetails component specific keys
EditInstanceDetails: {
offlineMessageDescription: 'Admin.EditInstanceDetails.offlineMessageDescription',
@@ -114,6 +119,9 @@ export const Localization = {
testing: 'testing_string',
another: 'another_test',
simpleKey: 'simple_key',
itemCount: 'item_count',
messageCount: 'message_count',
noPluralKey: 'no_plural_key',
},
} as const;