Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 browserLocale = {
textInfo: { direction: 'rtl' as const },
language: 'en',
maximize: vi.fn(),
} as unknown as Intl.Locale;

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

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

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

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

expect(_getLocaleDirection('ar-Arab')).toBe('ltr');
expect(browserLocale.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',
]);

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 &&
(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