mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Fix i18n package locale files hot reloading (no-changelog) (#19385)
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
@@ -1,6 +1,32 @@
|
|||||||
import { I18nClass } from './index';
|
/* eslint-disable id-denylist */
|
||||||
|
import { I18nClass, loadLanguage, i18nInstance } from './index';
|
||||||
|
|
||||||
|
// Store original state for cleanup
|
||||||
|
let originalLocale: string;
|
||||||
|
let originalHtmlLang: string;
|
||||||
|
const testLocales = new Set<string>();
|
||||||
|
|
||||||
describe(I18nClass, () => {
|
describe(I18nClass, () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
// Set up basic i18n messages for displayTimer tests
|
||||||
|
i18nInstance.global.setLocaleMessage('en', {
|
||||||
|
genericHelpers: {
|
||||||
|
millis: 'ms',
|
||||||
|
secShort: 's',
|
||||||
|
minShort: 'm',
|
||||||
|
hrsShort: 'h',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
originalLocale = i18nInstance.global.locale.value;
|
||||||
|
originalHtmlLang = document.querySelector('html')?.getAttribute('lang') ?? 'en';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore original locale and html lang
|
||||||
|
i18nInstance.global.locale.value = originalLocale as 'en';
|
||||||
|
document.querySelector('html')?.setAttribute('lang', originalHtmlLang);
|
||||||
|
});
|
||||||
|
|
||||||
describe('displayTimer', () => {
|
describe('displayTimer', () => {
|
||||||
it('should format duration with hours, minutes and seconds', () => {
|
it('should format duration with hours, minutes and seconds', () => {
|
||||||
expect(new I18nClass().displayTimer(-1)).toBe('-1s');
|
expect(new I18nClass().displayTimer(-1)).toBe('-1s');
|
||||||
@@ -33,3 +59,325 @@ describe(I18nClass, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadLanguage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset to English before each test
|
||||||
|
i18nInstance.global.locale.value = 'en';
|
||||||
|
document.querySelector('html')?.setAttribute('lang', 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test locales (we can't easily remove them, but we can reset to English)
|
||||||
|
i18nInstance.global.locale.value = 'en';
|
||||||
|
document.querySelector('html')?.setAttribute('lang', 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load a new language and set it as current locale', () => {
|
||||||
|
const locale = 'de';
|
||||||
|
const messages = {
|
||||||
|
hello: 'Hallo',
|
||||||
|
world: 'Welt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(result).toBe(locale);
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||||
|
expect(i18nInstance.global.t('world')).toBe('Welt');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the HTML lang attribute when loading a language', () => {
|
||||||
|
const locale = 'fr';
|
||||||
|
const messages = { greeting: 'Bonjour' };
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(document.querySelector('html')?.getAttribute('lang')).toBe(locale);
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle number formats when provided in messages', () => {
|
||||||
|
const locale = 'de-num';
|
||||||
|
const messages = {
|
||||||
|
hello: 'Hallo',
|
||||||
|
numberFormats: {
|
||||||
|
currency: {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||||
|
// Verify that numberFormats were set
|
||||||
|
expect(i18nInstance.global.getNumberFormat(locale)).toBeDefined();
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reload a language if it has already been loaded', () => {
|
||||||
|
const locale = 'es';
|
||||||
|
const originalMessages = { hello: 'Hola' };
|
||||||
|
const newMessages = { hello: 'Buenos días' };
|
||||||
|
|
||||||
|
// Load the language for the first time
|
||||||
|
loadLanguage(locale, originalMessages);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Hola');
|
||||||
|
|
||||||
|
// Switch to English temporarily
|
||||||
|
i18nInstance.global.locale.value = 'en';
|
||||||
|
|
||||||
|
// Try to load the same language again with different messages
|
||||||
|
const result = loadLanguage(locale, newMessages);
|
||||||
|
|
||||||
|
// Should return the locale but not update the messages
|
||||||
|
expect(result).toBe(locale);
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Hola'); // Should still be the original message
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty messages object', () => {
|
||||||
|
const locale = 'it';
|
||||||
|
const messages = {};
|
||||||
|
|
||||||
|
const result = loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(result).toBe(locale);
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with nested objects', () => {
|
||||||
|
const locale = 'pt';
|
||||||
|
const messages = {
|
||||||
|
nested: {
|
||||||
|
deep: {
|
||||||
|
message: 'Mensagem aninhada',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
simple: 'Simples',
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('nested.deep.message')).toBe('Mensagem aninhada');
|
||||||
|
expect(i18nInstance.global.t('simple')).toBe('Simples');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly separate numberFormats from other messages', () => {
|
||||||
|
const locale = 'ja';
|
||||||
|
const messages = {
|
||||||
|
greeting: 'こんにちは',
|
||||||
|
farewell: 'さようなら',
|
||||||
|
numberFormats: {
|
||||||
|
currency: {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'JPY',
|
||||||
|
},
|
||||||
|
decimal: {
|
||||||
|
style: 'decimal',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('greeting')).toBe('こんにちは');
|
||||||
|
expect(i18nInstance.global.t('farewell')).toBe('さようなら');
|
||||||
|
// numberFormats should not be accessible as regular translation messages
|
||||||
|
expect(i18nInstance.global.te('numberFormats')).toBe(false);
|
||||||
|
expect(i18nInstance.global.te('numberFormats.currency')).toBe(false);
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch between already loaded languages', () => {
|
||||||
|
const locale1 = 'fr-switch';
|
||||||
|
const locale2 = 'de-switch';
|
||||||
|
|
||||||
|
// Load first language
|
||||||
|
loadLanguage(locale1, { hello: 'Bonjour' });
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale1);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Bonjour');
|
||||||
|
|
||||||
|
// Load second language
|
||||||
|
loadLanguage(locale2, { hello: 'Hallo' });
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale2);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Hallo');
|
||||||
|
|
||||||
|
// Switch back to first language (should not reload messages)
|
||||||
|
loadLanguage(locale1, { hello: 'Salut' }); // Different message
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale1);
|
||||||
|
expect(i18nInstance.global.t('hello')).toBe('Bonjour'); // Should be original message
|
||||||
|
testLocales.add(locale1);
|
||||||
|
testLocales.add(locale2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the locale that was set', () => {
|
||||||
|
const locale = 'nl';
|
||||||
|
const messages = { test: 'test' };
|
||||||
|
|
||||||
|
const result = loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(result).toBe(locale);
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with special characters', () => {
|
||||||
|
const locale = 'zh';
|
||||||
|
const messages = {
|
||||||
|
special: '特殊字符测试',
|
||||||
|
emoji: '🚀 测试 🎉',
|
||||||
|
mixed: 'Mixed 混合 content',
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('special')).toBe('特殊字符测试');
|
||||||
|
expect(i18nInstance.global.t('emoji')).toBe('🚀 测试 🎉');
|
||||||
|
expect(i18nInstance.global.t('mixed')).toBe('Mixed 混合 content');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with undefined and null values', () => {
|
||||||
|
const locale = 'test-null';
|
||||||
|
const messages = {
|
||||||
|
defined: 'Valid message',
|
||||||
|
undefined,
|
||||||
|
null: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('defined')).toBe('Valid message');
|
||||||
|
// Undefined and null values should fallback to the key
|
||||||
|
expect(i18nInstance.global.t('undefined')).toBe('undefined');
|
||||||
|
expect(i18nInstance.global.t('null')).toBe('null');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with array values', () => {
|
||||||
|
const locale = 'test-array';
|
||||||
|
const messages = {
|
||||||
|
items: ['item1', 'item2', 'item3'],
|
||||||
|
nested: {
|
||||||
|
list: ['a', 'b', 'c'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
// Vue i18n handles arrays, but accessing them directly might not work as expected
|
||||||
|
// This tests that the function doesn't crash with array values
|
||||||
|
expect(() => i18nInstance.global.t('items')).not.toThrow();
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle loading the same locale as current locale', () => {
|
||||||
|
const currentLocale = 'en';
|
||||||
|
const messages = {
|
||||||
|
newMessage: 'This is a new message',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure we're starting with English
|
||||||
|
i18nInstance.global.locale.value = currentLocale;
|
||||||
|
|
||||||
|
const result = loadLanguage(currentLocale, messages);
|
||||||
|
|
||||||
|
expect(result).toBe(currentLocale);
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(currentLocale);
|
||||||
|
expect(i18nInstance.global.t('newMessage')).toBe('This is a new message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numberFormats with complex structure', () => {
|
||||||
|
const locale = 'complex-number';
|
||||||
|
const messages = {
|
||||||
|
greeting: 'Hello',
|
||||||
|
numberFormats: {
|
||||||
|
currency: {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
currencyDisplay: 'symbol',
|
||||||
|
},
|
||||||
|
percent: {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
},
|
||||||
|
decimal: {
|
||||||
|
style: 'decimal',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('greeting')).toBe('Hello');
|
||||||
|
|
||||||
|
const numberFormats = i18nInstance.global.getNumberFormat(
|
||||||
|
locale,
|
||||||
|
) as typeof messages.numberFormats;
|
||||||
|
expect(numberFormats).toBeDefined();
|
||||||
|
expect(numberFormats.currency).toBeDefined();
|
||||||
|
expect(numberFormats.percent).toBeDefined();
|
||||||
|
expect(numberFormats.decimal).toBeDefined();
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with boolean and numeric values', () => {
|
||||||
|
const locale = 'test-types';
|
||||||
|
const messages = {
|
||||||
|
boolTrue: true,
|
||||||
|
boolFalse: false,
|
||||||
|
number: 42,
|
||||||
|
zero: 0,
|
||||||
|
string: 'actual string',
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLanguage(locale, messages);
|
||||||
|
|
||||||
|
expect(i18nInstance.global.locale.value).toBe(locale);
|
||||||
|
expect(i18nInstance.global.t('string')).toBe('actual string');
|
||||||
|
// Boolean and numeric values that are not strings will fallback to the key name
|
||||||
|
// This is the expected behavior of vue-i18n when values are not strings
|
||||||
|
expect(i18nInstance.global.t('boolTrue')).toBe('boolTrue');
|
||||||
|
expect(i18nInstance.global.t('boolFalse')).toBe('boolFalse');
|
||||||
|
expect(i18nInstance.global.t('number')).toBe('number');
|
||||||
|
expect(i18nInstance.global.t('zero')).toBe('zero');
|
||||||
|
testLocales.add(locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve HTML document language attribute correctly', () => {
|
||||||
|
const html = document.querySelector('html');
|
||||||
|
const originalLang = html?.getAttribute('lang') ?? 'en';
|
||||||
|
|
||||||
|
const locale1 = 'preserve-1';
|
||||||
|
const locale2 = 'preserve-2';
|
||||||
|
|
||||||
|
loadLanguage(locale1, { test: 'test1' });
|
||||||
|
expect(html?.getAttribute('lang')).toBe(locale1);
|
||||||
|
|
||||||
|
loadLanguage(locale2, { test: 'test2' });
|
||||||
|
expect(html?.getAttribute('lang')).toBe(locale2);
|
||||||
|
|
||||||
|
// Restore original
|
||||||
|
html?.setAttribute('lang', originalLang);
|
||||||
|
testLocales.add(locale1);
|
||||||
|
testLocales.add(locale2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-this-alias */
|
/* eslint-disable @typescript-eslint/no-this-alias */
|
||||||
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
|
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
|
||||||
|
import { ref } from 'vue';
|
||||||
import { createI18n } from 'vue-i18n';
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import englishBaseText from './locales/en.json';
|
|
||||||
import type { BaseTextKey, INodeTranslationHeaders } from './types';
|
import type { BaseTextKey, INodeTranslationHeaders } from './types';
|
||||||
import {
|
import {
|
||||||
deriveMiddleKey,
|
deriveMiddleKey,
|
||||||
@@ -17,10 +17,13 @@ export const i18nInstance = createI18n({
|
|||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: 'en',
|
||||||
messages: { en: englishBaseText },
|
messages: { en: {} },
|
||||||
warnHtmlMessage: false,
|
warnHtmlMessage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reactive version to signal i18n message updates to Vue computations
|
||||||
|
export const i18nVersion = ref(0);
|
||||||
|
|
||||||
type BaseTextOptions = {
|
type BaseTextOptions = {
|
||||||
adjustToNumber?: number;
|
adjustToNumber?: number;
|
||||||
interpolate?: Record<string, string | number>;
|
interpolate?: Record<string, string | number>;
|
||||||
@@ -57,8 +60,12 @@ export class I18nClass {
|
|||||||
* Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
|
* Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
|
||||||
*/
|
*/
|
||||||
baseText(key: BaseTextKey, options?: BaseTextOptions): string {
|
baseText(key: BaseTextKey, options?: BaseTextOptions): string {
|
||||||
// Create a unique cache key
|
// Track reactive version so computed properties re-evaluate when messages change
|
||||||
const cacheKey = `${key}-${JSON.stringify(options)}`;
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
i18nVersion.value;
|
||||||
|
|
||||||
|
// Create a unique cache key, scoped by version
|
||||||
|
const cacheKey = `${i18nVersion.value}|${key}-${JSON.stringify(options)}`;
|
||||||
|
|
||||||
// Check if the result is already cached
|
// Check if the result is already cached
|
||||||
if (this.baseTextCache.has(cacheKey)) {
|
if (this.baseTextCache.has(cacheKey)) {
|
||||||
@@ -79,6 +86,14 @@ export class I18nClass {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached baseText results. Useful when locale messages are updated at runtime (e.g. HMR) or locale changes.
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.baseTextCache.clear();
|
||||||
|
i18nVersion.value++;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value.
|
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value.
|
||||||
*/
|
*/
|
||||||
@@ -375,35 +390,34 @@ export class I18nClass {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedLanguages = ['en'];
|
const loadedLanguages: string[] = [];
|
||||||
|
|
||||||
async function setLanguage(language: string) {
|
export function setLanguage(locale: string) {
|
||||||
i18nInstance.global.locale.value = language as 'en';
|
i18nInstance.global.locale.value = locale as 'en';
|
||||||
document.querySelector('html')!.setAttribute('lang', language);
|
document.querySelector('html')!.setAttribute('lang', locale);
|
||||||
|
|
||||||
return language;
|
// Invalidate cached baseText results on locale change
|
||||||
|
i18n.clearCache();
|
||||||
|
|
||||||
|
return locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLanguage(language: string) {
|
export function loadLanguage(locale: string, messages: Record<string, unknown>) {
|
||||||
if (i18nInstance.global.locale.value === language) {
|
if (loadedLanguages.includes(locale)) {
|
||||||
return await setLanguage(language);
|
return setLanguage(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadedLanguages.includes(language)) {
|
const { numberFormats, ...rest } = messages;
|
||||||
return await setLanguage(language);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { numberFormats, ...rest } = (await import(`@n8n/i18n/locales/${language}.json`)).default;
|
i18nInstance.global.setLocaleMessage(locale, rest);
|
||||||
|
|
||||||
i18nInstance.global.setLocaleMessage(language, rest);
|
|
||||||
|
|
||||||
if (numberFormats) {
|
if (numberFormats) {
|
||||||
i18nInstance.global.setNumberFormat(language, numberFormats);
|
i18nInstance.global.setNumberFormat(locale, numberFormats);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedLanguages.push(language);
|
loadedLanguages.push(locale);
|
||||||
|
|
||||||
return await setLanguage(language);
|
return setLanguage(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -422,6 +436,22 @@ export function addNodeTranslation(
|
|||||||
i18nInstance.global.mergeLocaleMessage(language, newMessages);
|
i18nInstance.global.mergeLocaleMessage(language, newMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev/runtime helper to replace messages for a locale without import side-effects.
|
||||||
|
* Used by editor UI HMR to apply updated translation JSON.
|
||||||
|
*/
|
||||||
|
export function updateLocaleMessages(locale: string, messages: Record<string, unknown>) {
|
||||||
|
const { numberFormats, ...rest } = messages as Record<string, unknown> & {
|
||||||
|
numberFormats?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
i18nInstance.global.setLocaleMessage(locale, rest);
|
||||||
|
if (numberFormats) i18nInstance.global.setNumberFormat(locale, numberFormats);
|
||||||
|
|
||||||
|
// Ensure subsequent reads recompute
|
||||||
|
i18n.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a credential translation to the i18n instance's `messages` object.
|
* Add a credential translation to the i18n instance's `messages` object.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type englishBaseText from './locales/en.json';
|
|||||||
export type GetBaseTextKey<T> = T extends `_${string}` ? never : T;
|
export type GetBaseTextKey<T> = T extends `_${string}` ? never : T;
|
||||||
|
|
||||||
export type BaseTextKey = GetBaseTextKey<keyof typeof englishBaseText>;
|
export type BaseTextKey = GetBaseTextKey<keyof typeof englishBaseText>;
|
||||||
|
export type LocaleMessages = typeof englishBaseText;
|
||||||
|
|
||||||
export interface INodeTranslationHeaders {
|
export interface INodeTranslationHeaders {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@/polyfills';
|
import '@/polyfills';
|
||||||
|
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import AssistantsHub from '@/components/AskAssistant/AssistantsHub.vue';
|
||||||
import { useRoute } from 'vue-router';
|
import AskAssistantFloatingButton from '@/components/AskAssistant/Chat/AskAssistantFloatingButton.vue';
|
||||||
import LoadingView from '@/views/LoadingView.vue';
|
|
||||||
import BannerStack from '@/components/banners/BannerStack.vue';
|
import BannerStack from '@/components/banners/BannerStack.vue';
|
||||||
import Modals from '@/components/Modals.vue';
|
import Modals from '@/components/Modals.vue';
|
||||||
import Telemetry from '@/components/Telemetry.vue';
|
import Telemetry from '@/components/Telemetry.vue';
|
||||||
import AskAssistantFloatingButton from '@/components/AskAssistant/Chat/AskAssistantFloatingButton.vue';
|
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||||
import AssistantsHub from '@/components/AskAssistant/AssistantsHub.vue';
|
import { useTelemetryContext } from '@/composables/useTelemetryContext';
|
||||||
import { loadLanguage } from '@n8n/i18n';
|
import { useWorkflowDiffRouting } from '@/composables/useWorkflowDiffRouting';
|
||||||
import {
|
import {
|
||||||
APP_MODALS_ELEMENT_ID,
|
APP_MODALS_ELEMENT_ID,
|
||||||
CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID,
|
CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID,
|
||||||
HIRING_BANNER,
|
HIRING_BANNER,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
import { useBuilderStore } from '@/stores/builder.store';
|
import { useBuilderStore } from '@/stores/builder.store';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import LoadingView from '@/views/LoadingView.vue';
|
||||||
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
|
||||||
import { useWorkflowDiffRouting } from '@/composables/useWorkflowDiffRouting';
|
|
||||||
import { useStyles } from './composables/useStyles';
|
|
||||||
import { locale } from '@n8n/design-system';
|
import { locale } from '@n8n/design-system';
|
||||||
|
import { setLanguage } from '@n8n/i18n';
|
||||||
|
// Note: no need to import en.json here; default 'en' is handled via setLanguage
|
||||||
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTelemetryContext } from '@/composables/useTelemetryContext';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useStyles } from './composables/useStyles';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
@@ -100,13 +101,16 @@ watch(route, (r) => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
(newLocale) => {
|
async (newLocale) => {
|
||||||
void loadLanguage(newLocale);
|
setLanguage(newLocale);
|
||||||
void locale.use(newLocale);
|
|
||||||
axios.defaults.headers.common['Accept-Language'] = newLocale;
|
axios.defaults.headers.common['Accept-Language'] = newLocale;
|
||||||
|
void locale.use(newLocale);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dev HMR for i18n is imported in main.ts before app mount
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import '@testing-library/jest-dom';
|
|||||||
import 'fake-indexeddb/auto';
|
import 'fake-indexeddb/auto';
|
||||||
import { configure } from '@testing-library/vue';
|
import { configure } from '@testing-library/vue';
|
||||||
import 'core-js/proposals/set-methods-v2';
|
import 'core-js/proposals/set-methods-v2';
|
||||||
|
import englishBaseText from '@n8n/i18n/locales/en.json';
|
||||||
|
import { loadLanguage } from '@n8n/i18n';
|
||||||
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
||||||
|
|
||||||
// Avoid tests failing because of difference between local and GitHub actions timezone
|
// Avoid tests failing because of difference between local and GitHub actions timezone
|
||||||
@@ -144,3 +146,5 @@ Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
|||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadLanguage('en', englishBaseText);
|
||||||
|
|||||||
69
packages/frontend/editor-ui/src/dev/i18nHmr.ts
Normal file
69
packages/frontend/editor-ui/src/dev/i18nHmr.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { i18n, i18nInstance, setLanguage, updateLocaleMessages } from '@n8n/i18n';
|
||||||
|
import type { LocaleMessages } from '@n8n/i18n/types';
|
||||||
|
import { locale as designLocale } from '@n8n/design-system';
|
||||||
|
|
||||||
|
const hot = import.meta.hot;
|
||||||
|
const DEFAULT_LOCALE = 'en';
|
||||||
|
|
||||||
|
if (hot) {
|
||||||
|
// Eagerly import locale JSONs so this module becomes their HMR owner
|
||||||
|
const localeModules = import.meta.glob('@n8n/i18n/locales/*.json', { eager: true }) as Record<
|
||||||
|
string,
|
||||||
|
{ default?: LocaleMessages }
|
||||||
|
>;
|
||||||
|
const localePaths = Object.keys(localeModules);
|
||||||
|
|
||||||
|
const lcOf = (p: string) => p.match(/\/locales\/([^/]+)\.json$/)?.[1] ?? DEFAULT_LOCALE;
|
||||||
|
const apply = (lc: string, msgs: LocaleMessages) => updateLocaleMessages(lc, msgs);
|
||||||
|
|
||||||
|
// Seed all locales on initial load in dev so switching locales
|
||||||
|
// does not require component-level dynamic imports (avoids hard reload chains)
|
||||||
|
for (const p of localePaths) {
|
||||||
|
const lc = lcOf(p);
|
||||||
|
const msgs = (localeModules[p] as { default?: LocaleMessages })?.default;
|
||||||
|
if (msgs && lc) apply(lc, msgs);
|
||||||
|
}
|
||||||
|
const refresh = () => {
|
||||||
|
const current = (i18nInstance.global.locale.value as string) || DEFAULT_LOCALE;
|
||||||
|
i18n.clearCache();
|
||||||
|
setLanguage(current);
|
||||||
|
void designLocale.use(current);
|
||||||
|
};
|
||||||
|
// Fetch with cache-buster to avoid stale content (one-update-behind);
|
||||||
|
// falls back to the eager map if network fetch is unavailable.
|
||||||
|
const fetchAndApply = async (file: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/@fs${file}?t=${Date.now()}`);
|
||||||
|
apply(lcOf(file), (await res.json()) as LocaleMessages);
|
||||||
|
} catch {
|
||||||
|
const msgs = localeModules[file]?.default;
|
||||||
|
if (msgs) apply(lcOf(file), msgs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Apply fresh modules provided by Vite HMR
|
||||||
|
hot.accept(localePaths, (mods) => {
|
||||||
|
mods.forEach((mod, i) => apply(lcOf(localePaths[i] ?? DEFAULT_LOCALE), mod?.default ?? {}));
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Handle explicit locale update events (fetch ensures latest content)
|
||||||
|
hot.on('n8n:locale-update', async (payload: { locales?: string[]; file?: string }) => {
|
||||||
|
if (payload.file) await fetchAndApply(payload.file);
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Last resort for cases where accept doesn’t trigger
|
||||||
|
hot.on(
|
||||||
|
'vite:afterUpdate',
|
||||||
|
async (payload: { updates?: Array<{ path?: string; acceptedPath?: string }> }) => {
|
||||||
|
const updates = payload?.updates ?? [];
|
||||||
|
const files = updates
|
||||||
|
.map((u) => (u.path ?? u.acceptedPath ?? '') as string)
|
||||||
|
.filter((p) => p.includes('/locales/') && p.endsWith('.json'));
|
||||||
|
if (files.length === 0) return;
|
||||||
|
for (const file of files) await fetchAndApply(file);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { useInsightsStore } from '@/features/insights/insights.store';
|
|||||||
import type { InsightsDateRange } from '@n8n/api-types';
|
import type { InsightsDateRange } from '@n8n/api-types';
|
||||||
import { N8nOption, N8nSelect } from '@n8n/design-system';
|
import { N8nOption, N8nSelect } from '@n8n/design-system';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { TIME_RANGE_LABELS, UNLICENSED_TIME_RANGE } from '../insights.constants';
|
import { UNLICENSED_TIME_RANGE } from '../insights.constants';
|
||||||
|
import { getTimeRangeLabels } from '../insights.utils';
|
||||||
|
|
||||||
const model = defineModel<InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE>({
|
const model = defineModel<InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE>({
|
||||||
required: true,
|
required: true,
|
||||||
@@ -11,11 +12,13 @@ const model = defineModel<InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANG
|
|||||||
|
|
||||||
const insightsStore = useInsightsStore();
|
const insightsStore = useInsightsStore();
|
||||||
|
|
||||||
|
const timeRangeLabels = getTimeRangeLabels();
|
||||||
|
|
||||||
const timeOptions = ref(
|
const timeOptions = ref(
|
||||||
insightsStore.dateRanges.map((option) => {
|
insightsStore.dateRanges.map((option) => {
|
||||||
return {
|
return {
|
||||||
key: option.key,
|
key: option.key,
|
||||||
label: TIME_RANGE_LABELS[option.key],
|
label: timeRangeLabels[option.key],
|
||||||
value: option.licensed ? option.key : UNLICENSED_TIME_RANGE,
|
value: option.licensed ? option.key : UNLICENSED_TIME_RANGE,
|
||||||
licensed: option.licensed,
|
licensed: option.licensed,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@n8n/i18n';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import {
|
import {
|
||||||
INSIGHT_IMPACT_TYPES,
|
INSIGHT_IMPACT_TYPES,
|
||||||
INSIGHTS_UNIT_IMPACT_MAPPING,
|
INSIGHTS_UNIT_IMPACT_MAPPING,
|
||||||
TIME_RANGE_LABELS,
|
|
||||||
} from '@/features/insights/insights.constants';
|
} from '@/features/insights/insights.constants';
|
||||||
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||||
import type { InsightsDateRange, InsightsSummary } from '@n8n/api-types';
|
import type { InsightsDateRange, InsightsSummary } from '@n8n/api-types';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||||
import { computed, useCssModule } from 'vue';
|
import { computed, useCssModule } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { I18nT } from 'vue-i18n';
|
import { I18nT } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { getTimeRangeLabels } from '../insights.utils';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
summary: InsightsSummaryDisplay;
|
summary: InsightsSummaryDisplay;
|
||||||
@@ -25,6 +25,8 @@ const route = useRoute();
|
|||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const timeRangeLabels = getTimeRangeLabels();
|
||||||
|
|
||||||
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
|
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
|
||||||
total: i18n.baseText('insights.banner.title.total'),
|
total: i18n.baseText('insights.banner.title.total'),
|
||||||
failed: i18n.baseText('insights.banner.title.failed'),
|
failed: i18n.baseText('insights.banner.title.failed'),
|
||||||
@@ -94,7 +96,7 @@ const trackTabClick = (insightType: keyof InsightsSummary) => {
|
|||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
</strong>
|
</strong>
|
||||||
<small :class="$style.days">
|
<small :class="$style.days">
|
||||||
{{ TIME_RANGE_LABELS[timeRange] }}
|
{{ timeRangeLabels[timeRange] }}
|
||||||
</small>
|
</small>
|
||||||
<span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
|
<span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
|
||||||
<em>--</em>
|
<em>--</em>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { InsightsSummaryType } from '@n8n/api-types';
|
import type { InsightsSummaryType } from '@n8n/api-types';
|
||||||
import { useI18n } from '@n8n/i18n';
|
|
||||||
import dateformat from 'dateformat';
|
import dateformat from 'dateformat';
|
||||||
|
|
||||||
export const INSIGHT_TYPES = {
|
export const INSIGHT_TYPES = {
|
||||||
@@ -79,14 +78,4 @@ export const TELEMETRY_TIME_RANGE = {
|
|||||||
year: 365,
|
year: 365,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TIME_RANGE_LABELS = {
|
|
||||||
day: useI18n().baseText('insights.lastNHours', { interpolate: { count: 24 } }),
|
|
||||||
week: useI18n().baseText('insights.lastNDays', { interpolate: { count: 7 } }),
|
|
||||||
'2weeks': useI18n().baseText('insights.lastNDays', { interpolate: { count: 14 } }),
|
|
||||||
month: useI18n().baseText('insights.lastNDays', { interpolate: { count: 30 } }),
|
|
||||||
quarter: useI18n().baseText('insights.lastNDays', { interpolate: { count: 90 } }),
|
|
||||||
'6months': useI18n().baseText('insights.months', { interpolate: { count: 6 } }),
|
|
||||||
year: useI18n().baseText('insights.oneYear'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UNLICENSED_TIME_RANGE = 'UNLICENSED_TIME_RANGE' as const;
|
export const UNLICENSED_TIME_RANGE = 'UNLICENSED_TIME_RANGE' as const;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
import type { InsightsSummary, InsightsSummaryType } from '@n8n/api-types';
|
import type { InsightsSummary, InsightsSummaryType } from '@n8n/api-types';
|
||||||
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||||
import {
|
import {
|
||||||
@@ -57,3 +58,17 @@ export const transformInsightsSummary = (data: InsightsSummary | null): Insights
|
|||||||
unit: INSIGHTS_UNIT_MAPPING[key](data[key].value),
|
unit: INSIGHTS_UNIT_MAPPING[key](data[key].value),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
export const getTimeRangeLabels = () => {
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: i18n.baseText('insights.lastNHours', { interpolate: { count: 24 } }),
|
||||||
|
week: i18n.baseText('insights.lastNDays', { interpolate: { count: 7 } }),
|
||||||
|
'2weeks': i18n.baseText('insights.lastNDays', { interpolate: { count: 14 } }),
|
||||||
|
month: i18n.baseText('insights.lastNDays', { interpolate: { count: 30 } }),
|
||||||
|
quarter: i18n.baseText('insights.lastNDays', { interpolate: { count: 90 } }),
|
||||||
|
'6months': i18n.baseText('insights.months', { interpolate: { count: 6 } }),
|
||||||
|
year: i18n.baseText('insights.oneYear'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
7
packages/frontend/editor-ui/src/i18n/loadDefaultEn.ts
Normal file
7
packages/frontend/editor-ui/src/i18n/loadDefaultEn.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { loadLanguage } from '@n8n/i18n';
|
||||||
|
import type { LocaleMessages } from '@n8n/i18n/types';
|
||||||
|
|
||||||
|
export async function loadDefaultEn() {
|
||||||
|
const mod = (await import('@n8n/i18n/locales/en.json')) as { default: LocaleMessages };
|
||||||
|
loadLanguage('en', mod.default);
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import './n8n-theme.scss';
|
|||||||
import App from '@/App.vue';
|
import App from '@/App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
|
|
||||||
import { i18nInstance } from '@n8n/i18n';
|
import { i18nInstance, setLanguage } from '@n8n/i18n';
|
||||||
|
|
||||||
import { TelemetryPlugin } from './plugins/telemetry';
|
import { TelemetryPlugin } from './plugins/telemetry';
|
||||||
import { GlobalComponentsPlugin } from './plugins/components';
|
import { GlobalComponentsPlugin } from './plugins/components';
|
||||||
import { GlobalDirectivesPlugin } from './plugins/directives';
|
import { GlobalDirectivesPlugin } from './plugins/directives';
|
||||||
@@ -34,6 +35,17 @@ const app = createApp(App);
|
|||||||
|
|
||||||
app.use(SentryPlugin);
|
app.use(SentryPlugin);
|
||||||
|
|
||||||
|
// Initialize i18n
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// Import HMR owner early so messages are seeded before app mount
|
||||||
|
await import('@/dev/i18nHmr');
|
||||||
|
setLanguage('en');
|
||||||
|
} else {
|
||||||
|
// Production: load English messages explicitly via isolated module
|
||||||
|
const { loadDefaultEn } = await import('@/i18n/loadDefaultEn');
|
||||||
|
await loadDefaultEn();
|
||||||
|
}
|
||||||
|
|
||||||
// Register module routes
|
// Register module routes
|
||||||
// We do this here so landing straight on a module page works
|
// We do this here so landing straight on a module page works
|
||||||
registerModuleRoutes(router);
|
registerModuleRoutes(router);
|
||||||
|
|||||||
1
packages/frontend/editor-ui/src/shims.d.ts
vendored
1
packages/frontend/editor-ui/src/shims.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="vite-plugin-comlink/client" />
|
/// <reference types="vite-plugin-comlink/client" />
|
||||||
|
|
||||||
import type { VNode, ComponentPublicInstance } from 'vue';
|
import type { VNode, ComponentPublicInstance } from 'vue';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { posix as pathPosix, resolve } from 'path';
|
import { posix as pathPosix, resolve, sep as pathSep } from 'path';
|
||||||
import { defineConfig, mergeConfig, type UserConfig } from 'vite';
|
import { defineConfig, mergeConfig, type UserConfig } from 'vite';
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||||
@@ -12,6 +12,7 @@ import components from 'unplugin-vue-components/vite';
|
|||||||
import browserslistToEsbuild from 'browserslist-to-esbuild';
|
import browserslistToEsbuild from 'browserslist-to-esbuild';
|
||||||
import legacy from '@vitejs/plugin-legacy';
|
import legacy from '@vitejs/plugin-legacy';
|
||||||
import browserslist from 'browserslist';
|
import browserslist from 'browserslist';
|
||||||
|
import { isLocaleFile, sendLocaleUpdate } from './vite/i18n-locales-hmr-helpers';
|
||||||
|
|
||||||
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/';
|
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/';
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ const packagesDir = resolve(__dirname, '..', '..');
|
|||||||
const alias = [
|
const alias = [
|
||||||
{ find: '@', replacement: resolve(__dirname, 'src') },
|
{ find: '@', replacement: resolve(__dirname, 'src') },
|
||||||
{ find: 'stream', replacement: 'stream-browserify' },
|
{ find: 'stream', replacement: 'stream-browserify' },
|
||||||
|
// Ensure bare imports resolve to sources (not dist)
|
||||||
|
{ find: '@n8n/i18n', replacement: resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src') },
|
||||||
{
|
{
|
||||||
find: /^@n8n\/chat(.+)$/,
|
find: /^@n8n\/chat(.+)$/,
|
||||||
replacement: resolve(packagesDir, 'frontend', '@n8n', 'chat', 'src$1'),
|
replacement: resolve(packagesDir, 'frontend', '@n8n', 'chat', 'src$1'),
|
||||||
@@ -140,6 +143,27 @@ const plugins: UserConfig['plugins'] = [
|
|||||||
nodePolyfills({
|
nodePolyfills({
|
||||||
include: ['fs', 'path', 'url', 'util', 'timers'],
|
include: ['fs', 'path', 'url', 'util', 'timers'],
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
name: 'i18n-locales-hmr',
|
||||||
|
configureServer(server) {
|
||||||
|
const localesDir = resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src', 'locales');
|
||||||
|
server.watcher.add(localesDir);
|
||||||
|
|
||||||
|
// Only emit for add/unlink; change events are handled in handleHotUpdate
|
||||||
|
server.watcher.on('all', (event, file) => {
|
||||||
|
if ((event === 'add' || event === 'unlink') && isLocaleFile(file)) {
|
||||||
|
sendLocaleUpdate(server, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleHotUpdate(ctx) {
|
||||||
|
const { file, server } = ctx;
|
||||||
|
if (!isLocaleFile(file)) return;
|
||||||
|
sendLocaleUpdate(server, file);
|
||||||
|
// Swallow default HMR for this file to prevent full page reloads
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { RELEASE: release } = process.env;
|
const { RELEASE: release } = process.env;
|
||||||
|
|||||||
19
packages/frontend/editor-ui/vite/i18n-locales-hmr-helpers.ts
Normal file
19
packages/frontend/editor-ui/vite/i18n-locales-hmr-helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { sep as pathSep } from 'path';
|
||||||
|
|
||||||
|
export const isLocaleFile = (file: string): boolean =>
|
||||||
|
file.endsWith('.json') && file.includes(`${pathSep}locales${pathSep}`);
|
||||||
|
|
||||||
|
export const extractLocale = (file: string): string | null => {
|
||||||
|
const match = file.match(new RegExp(`${pathSep}locales${pathSep}([^${pathSep}]+)\\.json$`));
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendLocaleUpdate = (server: any, file: string): void => {
|
||||||
|
if (!isLocaleFile(file)) return;
|
||||||
|
const locale = extractLocale(file);
|
||||||
|
server.ws.send({
|
||||||
|
type: 'custom',
|
||||||
|
event: 'n8n:locale-update',
|
||||||
|
data: { locales: locale ? [locale] : [], file },
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user