Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
58 changes: 57 additions & 1 deletion packages/core/src/locales/__tests__/getLocaleDirection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { _getLocaleDirection } from '../getLocaleDirection';
import { intlCache } from '../../cache/IntlCache';

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

describe('_getLocaleDirection', () => {
it('should return ltr for left-to-right languages', () => {
Expand Down Expand Up @@ -116,4 +121,55 @@ describe('_getLocaleDirection', () => {
expect(['ltr', 'rtl']).toContain(result);
}
});

describe('browser environments', () => {
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();
});
});

describe('node environments', () => {
it('should use script detection when Intl.Locale lacks textInfo', () => {
const maximize = vi.fn(() => ({ script: 'Arab' as const }));
const fakeLocale = {
language: 'az',
maximize,
} as unknown as Intl.Locale;

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

expect(_getLocaleDirection('az-Arab')).toBe('rtl');
expect(maximize).toHaveBeenCalled();
});

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');
});
});
});
138 changes: 134 additions & 4 deletions packages/core/src/locales/getLocaleDirection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,142 @@ import { intlCache } from '../cache/IntlCache';
* @internal
*/
export function _getLocaleDirection(code: string): 'ltr' | 'rtl' {
console.log('--------------------------------');
console.log('getLocaleDirection', code);
let script: string | undefined, language: string | undefined;
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';

// Extract via textInfo property
const textInfoDirection = extractDirectionWithTextInfo(locale);
if (textInfoDirection) {
return textInfoDirection;
}

// Extract via script and language properties
script = getLikelyScript(locale);
language = locale.language;
} catch {
// If the code is invalid or causes an error, fallback to 'ltr'
return 'ltr';
// silent
}

// Fallback to simple heuristics
script ||= extractScript(code);
language ||= extractLanguage(code);

// Handle RTL script or language
if (script) return isRtlScript(script) ? 'rtl' : 'ltr';
if (language) return isRtlLanguage(language) ? '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 extractLanguage(code: string): string | undefined {
return code?.split(/[-_]/)[0]?.toLowerCase();
}

/**
* Handles extracting direction via script property
* @param code - The locale code to extract the script from
* @returns {string | undefined} - The script of the locale
*
* Script segment guaranteed to be 4 characters long.
* Filter by letters to avoid variant: https://datatracker.ietf.org/doc/html/rfc5646#section-2.2.5
*/
function extractScript(code: string): string | undefined {
return code
?.split(/[-_]/)
.find((segment) => segment.length === 4 && /^[a-zA-Z]+$/.test(segment))
?.toLowerCase();
}

function getLikelyScript(locale: Intl.Locale): string | undefined {
// Check for script property directly
if (locale?.script) {
return locale.script.toLowerCase();
}

// Check for script property via maximize()
if (typeof locale?.maximize === 'function') {
const maximized = locale.maximize();
if (maximized?.script) {
return maximized.script.toLowerCase();
}
}

return undefined;
}

function isRtlScript(script: string | undefined): boolean {
const result = script ? RTL_SCRIPTS.has(script.toLowerCase()) : false;
console.log('isRtlScript', script, result);
return result;
}

function isRtlLanguage(language: string | undefined): boolean {
const result = language ? RTL_LANGUAGES.has(language.toLowerCase()) : false;
console.log('isRtlLanguage', language, result);
return result;
}
Loading