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:
co-authored by
gabek
Copilot
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Owncast
Gabe Kangas
parent
1fd86fa190
commit
59b905649e
@@ -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: |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user