Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-spies-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'generaltranslation': patch
---

fix: language direction browser compatibility
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment node
import { describe, it, expect, vi, afterEach } from 'vitest';
import { _getLocaleDirection } from '../getLocaleDirection';
import { intlCache } from '../../cache/IntlCache';

afterEach(() => {
vi.restoreAllMocks();
});

/**
* Mocks intlCache.get so that 'Locale' calls return a real Intl.Locale
* with textInfo stripped, simulating browser environments where the
* textInfo property is not supported. All other calls (DisplayNames, etc.)
* pass through to the real implementation.
*
* Note: jsdom/happy-dom inherit Node's Intl implementation, so textInfo
* must be manually stripped even when using a browser-like vitest environment.
*/
function mockLocaleWithoutTextInfo() {
const originalGet = intlCache.get.bind(intlCache);
vi.spyOn(intlCache, 'get').mockImplementation(
(...args: Parameters<typeof intlCache.get>) => {
if (args[0] === 'Locale') {
const locale = new Intl.Locale(args[1] as string);
Object.defineProperty(locale, 'textInfo', {
value: undefined,
configurable: true,
});
return locale as ReturnType<typeof intlCache.get>;
}
return originalGet(...args);
}
);
}

/**
* Browser environment tests for _getLocaleDirection.
*
* Simulates browsers where Intl.Locale does not expose the textInfo
* property, forcing the function to fall back to script and language
* heuristics via _getLocaleProperties.
*/
describe.sequential('_getLocaleDirection (browser)', () => {
it('should detect rtl script when Intl.Locale lacks textInfo support', () => {
mockLocaleWithoutTextInfo();
expect(_getLocaleDirection('az-Arab')).toBe('rtl');
});

it('should detect ltr script when Intl.Locale lacks textInfo support', () => {
mockLocaleWithoutTextInfo();
expect(_getLocaleDirection('az-Latn')).toBe('ltr');
});

it('should fall back to known rtl languages when Intl.Locale is not available', () => {
vi.spyOn(intlCache, 'get').mockImplementation(() => {
throw new Error('Intl.Locale not supported');
});

expect(_getLocaleDirection('ar')).toBe('rtl');
});

it('should fall back to ltr for unknown languages when Intl.Locale is not available', () => {
vi.spyOn(intlCache, 'get').mockImplementation(() => {
throw new Error('Intl.Locale not supported');
});

expect(_getLocaleDirection('en')).toBe('ltr');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @vitest-environment node
import { describe, it, expect, vi, afterEach } from 'vitest';
import { _getLocaleDirection } from '../getLocaleDirection';
import { intlCache } from '../../cache/IntlCache';

afterEach(() => {
vi.restoreAllMocks();
});

/**
* Node.js environment tests for _getLocaleDirection.
*
* In Node.js, Intl.Locale supports the textInfo property natively,
* so direction can be resolved directly without fallback heuristics.
*/
describe.sequential('_getLocaleDirection (node)', () => {
it('should use Intl.Locale.textInfo direction when available', () => {
const mockLocale = {
textInfo: { direction: 'rtl' as const },
language: 'en',
maximize: vi.fn(),
} as unknown as Intl.Locale;

vi.spyOn(intlCache, 'get').mockReturnValue(mockLocale);

expect(_getLocaleDirection('en-US')).toBe('rtl');
expect(mockLocale.maximize).not.toHaveBeenCalled();
});

it('should prioritize textInfo direction over script heuristics', () => {
const mockLocale = {
textInfo: { direction: 'ltr' as const },
language: 'ar',
maximize: vi.fn(() => ({ script: 'Arab' as const })),
} as unknown as Intl.Locale;

vi.spyOn(intlCache, 'get').mockReturnValue(mockLocale);

expect(_getLocaleDirection('ar-Arab')).toBe('ltr');
expect(mockLocale.maximize).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,8 @@ describe('_getLocaleDirection', () => {
});

it('should handle edge cases and special locales', () => {
// Test some edge cases
expect(_getLocaleDirection('root')).toBe('ltr');
expect(_getLocaleDirection('und')).toBe('ltr');

// Test mixed scripts - should default to ltr if parsing fails
expect(_getLocaleDirection('mixed-script-locale')).toBe('ltr');
});

Expand Down
90 changes: 86 additions & 4 deletions packages/core/src/locales/getLocaleDirection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { intlCache } from '../cache/IntlCache';
import _getLocaleProperties from './getLocaleProperties';

/**
* Get the text direction for a given locale code using the Intl.Locale API.
Expand All @@ -8,12 +9,93 @@ import { intlCache } from '../cache/IntlCache';
* @internal
*/
export function _getLocaleDirection(code: string): 'ltr' | 'rtl' {
// Extract via textInfo property
try {
const locale = intlCache.get('Locale', code);
// Return 'rtl' if the text direction of the language is right-to-left, otherwise 'ltr'
return (locale as any)?.textInfo?.direction === 'rtl' ? 'rtl' : 'ltr';
const textInfoDirection = extractDirectionWithTextInfo(locale);
if (textInfoDirection) {
return textInfoDirection;
}
} catch {
// If the code is invalid or causes an error, fallback to 'ltr'
return 'ltr';
// silent
}

// Fallback to simple heuristics
const { scriptCode, languageCode } = _getLocaleProperties(code);

// Handle RTL script or language
if (scriptCode) return isRtlScript(scriptCode) ? 'rtl' : 'ltr';
if (languageCode) return isRtlLanguage(languageCode) ? 'rtl' : 'ltr';

return 'ltr';
}

// ===== HELPER CONSTANTS ===== //

const RTL_SCRIPTS = new Set([
'arab',
'adlm',
'hebr',
'nkoo',
'rohg',
'samr',
'syrc',
'thaa',
'yezi',
]);

const RTL_LANGUAGES = new Set([
'ar',
'arc',
'ckb',
'dv',
'fa',
'he',
'iw',
'ku',
'lrc',
'nqo',
'ps',
'pnb',
'sd',
'syr',
'ug',
'ur',
'yi',
]);

// ===== HELPER FUNCTIONS ===== //

/**
* Handles extracting direction via textInfo property
* @param Locale - Intl.Locale object
* @returns {'ltr' | 'rtl'} - The direction of the locale
*
* Intl.Locale.prototype.getTextInfo() / textInfo property incorporated in ES2024 Specification.
* This is not supported by all browsers yet.
* See: {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getTextInfo#browser_compatibility}
*/
function extractDirectionWithTextInfo(
locale: Intl.Locale
): 'ltr' | 'rtl' | undefined {
if (
'textInfo' in locale &&
typeof locale.textInfo === 'object' &&
locale.textInfo !== null &&
'direction' in locale.textInfo &&
(locale.textInfo?.direction === 'rtl' ||
locale.textInfo?.direction === 'ltr')
) {
return locale.textInfo?.direction;
}

return undefined;
}

function isRtlScript(script: string | undefined): boolean {
return script ? RTL_SCRIPTS.has(script.toLowerCase()) : false;
}

function isRtlLanguage(language: string | undefined): boolean {
return language ? RTL_LANGUAGES.has(language.toLowerCase()) : false;
}
Loading