@refraction-ui/astro 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/astro-location-selector/LocationSelector.astro +56 -0
- package/dist/astro-location-selector/LocationSelector.stories.ts +15 -0
- package/dist/astro-location-selector/index.ts +1 -0
- package/dist/command-input/command-input.ts +5 -5
- package/dist/i18n/countries.ts +122 -0
- package/dist/i18n/detect-region.ts +243 -0
- package/dist/i18n/index.ts +47 -0
- package/dist/i18n/languages.ts +181 -0
- package/dist/i18n/locale-utils.ts +123 -0
- package/dist/i18n/voice-registry.ts +92 -0
- package/dist/index.ts +1 -0
- package/dist/location-selector/index.ts +1 -0
- package/dist/location-selector/location-selector.ts +60 -0
- package/package.json +2 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { createLocationSelector } from '../location-selector/index.ts'
|
|
3
|
+
import { cn } from '../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
defaultCountry?: string
|
|
7
|
+
defaultLanguage?: string
|
|
8
|
+
className?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
defaultCountry = 'US',
|
|
13
|
+
defaultLanguage = 'en',
|
|
14
|
+
className
|
|
15
|
+
} = Astro.props
|
|
16
|
+
|
|
17
|
+
const api = createLocationSelector({
|
|
18
|
+
defaultCountry,
|
|
19
|
+
defaultLanguage
|
|
20
|
+
})
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<div class={cn('flex flex-col gap-4 sm:flex-row', className)}>
|
|
24
|
+
<div class="flex flex-col gap-1.5 flex-1">
|
|
25
|
+
<label for={api.countryProps.id} class="text-sm font-medium">Country</label>
|
|
26
|
+
<select
|
|
27
|
+
id={api.countryProps.id}
|
|
28
|
+
name={api.countryProps.name}
|
|
29
|
+
class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
30
|
+
>
|
|
31
|
+
{api.countries.map((c: any) => {
|
|
32
|
+
const value = c.code || c.id || c;
|
|
33
|
+
const label = c.name || c.label || c;
|
|
34
|
+
return (
|
|
35
|
+
<option value={value} selected={value === defaultCountry}>{label}</option>
|
|
36
|
+
)
|
|
37
|
+
})}
|
|
38
|
+
</select>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="flex flex-col gap-1.5 flex-1">
|
|
41
|
+
<label for={api.languageProps.id} class="text-sm font-medium">Language</label>
|
|
42
|
+
<select
|
|
43
|
+
id={api.languageProps.id}
|
|
44
|
+
name={api.languageProps.name}
|
|
45
|
+
class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
46
|
+
>
|
|
47
|
+
{api.languages.map((l: any) => {
|
|
48
|
+
const value = l.code || l.id || l;
|
|
49
|
+
const label = l.name || l.label || l;
|
|
50
|
+
return (
|
|
51
|
+
<option value={value} selected={value === defaultLanguage}>{label}</option>
|
|
52
|
+
)
|
|
53
|
+
})}
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Component from './LocationSelector.astro'
|
|
2
|
+
|
|
3
|
+
const meta = {
|
|
4
|
+
title: 'Astro/LocationSelector',
|
|
5
|
+
component: Component,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default meta
|
|
9
|
+
|
|
10
|
+
export const Default = {
|
|
11
|
+
args: {
|
|
12
|
+
defaultCountry: 'Example defaultCountry',
|
|
13
|
+
defaultLanguage: 'Example defaultLanguage'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as LocationSelector } from './LocationSelector.astro'
|
|
@@ -97,10 +97,10 @@ export class CommandInput {
|
|
|
97
97
|
const matchedTrigger = this.options.triggers.find((t) => t.char === char);
|
|
98
98
|
|
|
99
99
|
if (matchedTrigger) {
|
|
100
|
-
const isStartOfWord = i === 0 ||
|
|
100
|
+
const isStartOfWord = i === 0 || /\s/.test(rawText[i - 1]);
|
|
101
101
|
if (isStartOfWord) {
|
|
102
102
|
const commandText = rawText.slice(i + 1, cursorPosition);
|
|
103
|
-
if (
|
|
103
|
+
if (!/\s/.test(commandText)) {
|
|
104
104
|
let isValid = true;
|
|
105
105
|
if (matchedTrigger.pattern) {
|
|
106
106
|
isValid = matchedTrigger.pattern.test(commandText);
|
|
@@ -118,7 +118,7 @@ export class CommandInput {
|
|
|
118
118
|
break;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
if (
|
|
121
|
+
if (/\s/.test(char)) {
|
|
122
122
|
break;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
@@ -130,8 +130,8 @@ export class CommandInput {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\\]
|
|
134
|
-
const patternStr = `(
|
|
133
|
+
const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
134
|
+
const patternStr = `(^|\\s)(${triggerChars.join('|')})([^\\s]*)`;
|
|
135
135
|
const regex = new RegExp(patternStr, 'g');
|
|
136
136
|
|
|
137
137
|
let currentIndex = 0;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country type and registry for i18n support.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Country {
|
|
6
|
+
code: string
|
|
7
|
+
name: string
|
|
8
|
+
flag: string
|
|
9
|
+
defaultLanguages: string[]
|
|
10
|
+
timezone?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const COUNTRIES: Record<string, Country> = {
|
|
14
|
+
// North America
|
|
15
|
+
'US': { code: 'US', name: 'United States', flag: '🇺🇸', defaultLanguages: ['en'], timezone: 'America/New_York' },
|
|
16
|
+
'CA': { code: 'CA', name: 'Canada', flag: '🇨🇦', defaultLanguages: ['en', 'fr'], timezone: 'America/Toronto' },
|
|
17
|
+
'MX': { code: 'MX', name: 'Mexico', flag: '🇲🇽', defaultLanguages: ['es'], timezone: 'America/Mexico_City' },
|
|
18
|
+
|
|
19
|
+
// UK & Ireland
|
|
20
|
+
'GB': { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', defaultLanguages: ['en'], timezone: 'Europe/London' },
|
|
21
|
+
'IE': { code: 'IE', name: 'Ireland', flag: '🇮🇪', defaultLanguages: ['en'], timezone: 'Europe/Dublin' },
|
|
22
|
+
|
|
23
|
+
// Oceania
|
|
24
|
+
'AU': { code: 'AU', name: 'Australia', flag: '🇦🇺', defaultLanguages: ['en'], timezone: 'Australia/Sydney' },
|
|
25
|
+
'NZ': { code: 'NZ', name: 'New Zealand', flag: '🇳🇿', defaultLanguages: ['en'], timezone: 'Pacific/Auckland' },
|
|
26
|
+
|
|
27
|
+
// South Asia
|
|
28
|
+
'IN': { code: 'IN', name: 'India', flag: '🇮🇳', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kolkata' },
|
|
29
|
+
'PK': { code: 'PK', name: 'Pakistan', flag: '🇵🇰', defaultLanguages: ['ur', 'en'], timezone: 'Asia/Karachi' },
|
|
30
|
+
'BD': { code: 'BD', name: 'Bangladesh', flag: '🇧🇩', defaultLanguages: ['bn'], timezone: 'Asia/Dhaka' },
|
|
31
|
+
'LK': { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰', defaultLanguages: ['ta', 'en'], timezone: 'Asia/Colombo' },
|
|
32
|
+
'NP': { code: 'NP', name: 'Nepal', flag: '🇳🇵', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kathmandu' },
|
|
33
|
+
|
|
34
|
+
// Western Europe
|
|
35
|
+
'FR': { code: 'FR', name: 'France', flag: '🇫🇷', defaultLanguages: ['fr'], timezone: 'Europe/Paris' },
|
|
36
|
+
'DE': { code: 'DE', name: 'Germany', flag: '🇩🇪', defaultLanguages: ['de'], timezone: 'Europe/Berlin' },
|
|
37
|
+
'ES': { code: 'ES', name: 'Spain', flag: '🇪🇸', defaultLanguages: ['es'], timezone: 'Europe/Madrid' },
|
|
38
|
+
'IT': { code: 'IT', name: 'Italy', flag: '🇮🇹', defaultLanguages: ['it'], timezone: 'Europe/Rome' },
|
|
39
|
+
'PT': { code: 'PT', name: 'Portugal', flag: '🇵🇹', defaultLanguages: ['pt'], timezone: 'Europe/Lisbon' },
|
|
40
|
+
'NL': { code: 'NL', name: 'Netherlands', flag: '🇳🇱', defaultLanguages: ['nl'], timezone: 'Europe/Amsterdam' },
|
|
41
|
+
'BE': { code: 'BE', name: 'Belgium', flag: '🇧🇪', defaultLanguages: ['nl', 'fr', 'de'], timezone: 'Europe/Brussels' },
|
|
42
|
+
'CH': { code: 'CH', name: 'Switzerland', flag: '🇨🇭', defaultLanguages: ['de', 'fr', 'it'], timezone: 'Europe/Zurich' },
|
|
43
|
+
'AT': { code: 'AT', name: 'Austria', flag: '🇦🇹', defaultLanguages: ['de'], timezone: 'Europe/Vienna' },
|
|
44
|
+
|
|
45
|
+
// Nordic
|
|
46
|
+
'SE': { code: 'SE', name: 'Sweden', flag: '🇸🇪', defaultLanguages: ['sv'], timezone: 'Europe/Stockholm' },
|
|
47
|
+
'NO': { code: 'NO', name: 'Norway', flag: '🇳🇴', defaultLanguages: ['no'], timezone: 'Europe/Oslo' },
|
|
48
|
+
'DK': { code: 'DK', name: 'Denmark', flag: '🇩🇰', defaultLanguages: ['da'], timezone: 'Europe/Copenhagen' },
|
|
49
|
+
'FI': { code: 'FI', name: 'Finland', flag: '🇫🇮', defaultLanguages: ['fi'], timezone: 'Europe/Helsinki' },
|
|
50
|
+
|
|
51
|
+
// Eastern Europe
|
|
52
|
+
'PL': { code: 'PL', name: 'Poland', flag: '🇵🇱', defaultLanguages: ['pl'], timezone: 'Europe/Warsaw' },
|
|
53
|
+
'CZ': { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿', defaultLanguages: ['cs'], timezone: 'Europe/Prague' },
|
|
54
|
+
'RO': { code: 'RO', name: 'Romania', flag: '🇷🇴', defaultLanguages: ['ro'], timezone: 'Europe/Bucharest' },
|
|
55
|
+
'HU': { code: 'HU', name: 'Hungary', flag: '🇭🇺', defaultLanguages: ['hu'], timezone: 'Europe/Budapest' },
|
|
56
|
+
'BG': { code: 'BG', name: 'Bulgaria', flag: '🇧🇬', defaultLanguages: ['bg'], timezone: 'Europe/Sofia' },
|
|
57
|
+
'HR': { code: 'HR', name: 'Croatia', flag: '🇭🇷', defaultLanguages: ['hr'], timezone: 'Europe/Zagreb' },
|
|
58
|
+
'SK': { code: 'SK', name: 'Slovakia', flag: '🇸🇰', defaultLanguages: ['sk'], timezone: 'Europe/Bratislava' },
|
|
59
|
+
'SI': { code: 'SI', name: 'Slovenia', flag: '🇸🇮', defaultLanguages: ['sl'], timezone: 'Europe/Ljubljana' },
|
|
60
|
+
'RS': { code: 'RS', name: 'Serbia', flag: '🇷🇸', defaultLanguages: ['sr'], timezone: 'Europe/Belgrade' },
|
|
61
|
+
'UA': { code: 'UA', name: 'Ukraine', flag: '🇺🇦', defaultLanguages: ['uk'], timezone: 'Europe/Kyiv' },
|
|
62
|
+
'RU': { code: 'RU', name: 'Russia', flag: '🇷🇺', defaultLanguages: ['ru'], timezone: 'Europe/Moscow' },
|
|
63
|
+
'TR': { code: 'TR', name: 'Turkey', flag: '🇹🇷', defaultLanguages: ['tr'], timezone: 'Europe/Istanbul' },
|
|
64
|
+
'GR': { code: 'GR', name: 'Greece', flag: '🇬🇷', defaultLanguages: ['el'], timezone: 'Europe/Athens' },
|
|
65
|
+
|
|
66
|
+
// East Asia
|
|
67
|
+
'JP': { code: 'JP', name: 'Japan', flag: '🇯🇵', defaultLanguages: ['ja'], timezone: 'Asia/Tokyo' },
|
|
68
|
+
'KR': { code: 'KR', name: 'South Korea', flag: '🇰🇷', defaultLanguages: ['ko'], timezone: 'Asia/Seoul' },
|
|
69
|
+
'CN': { code: 'CN', name: 'China', flag: '🇨🇳', defaultLanguages: ['zh'], timezone: 'Asia/Shanghai' },
|
|
70
|
+
'TW': { code: 'TW', name: 'Taiwan', flag: '🇹🇼', defaultLanguages: ['zh'], timezone: 'Asia/Taipei' },
|
|
71
|
+
|
|
72
|
+
// Southeast Asia
|
|
73
|
+
'TH': { code: 'TH', name: 'Thailand', flag: '🇹🇭', defaultLanguages: ['th'], timezone: 'Asia/Bangkok' },
|
|
74
|
+
'VN': { code: 'VN', name: 'Vietnam', flag: '🇻🇳', defaultLanguages: ['vi'], timezone: 'Asia/Ho_Chi_Minh' },
|
|
75
|
+
'ID': { code: 'ID', name: 'Indonesia', flag: '🇮🇩', defaultLanguages: ['id'], timezone: 'Asia/Jakarta' },
|
|
76
|
+
'MY': { code: 'MY', name: 'Malaysia', flag: '🇲🇾', defaultLanguages: ['ms', 'en'], timezone: 'Asia/Kuala_Lumpur' },
|
|
77
|
+
'PH': { code: 'PH', name: 'Philippines', flag: '🇵🇭', defaultLanguages: ['en'], timezone: 'Asia/Manila' },
|
|
78
|
+
'SG': { code: 'SG', name: 'Singapore', flag: '🇸🇬', defaultLanguages: ['en', 'ms', 'zh'], timezone: 'Asia/Singapore' },
|
|
79
|
+
|
|
80
|
+
// South America
|
|
81
|
+
'BR': { code: 'BR', name: 'Brazil', flag: '🇧🇷', defaultLanguages: ['pt'], timezone: 'America/Sao_Paulo' },
|
|
82
|
+
'AR': { code: 'AR', name: 'Argentina', flag: '🇦🇷', defaultLanguages: ['es'], timezone: 'America/Argentina/Buenos_Aires' },
|
|
83
|
+
'CO': { code: 'CO', name: 'Colombia', flag: '🇨🇴', defaultLanguages: ['es'], timezone: 'America/Bogota' },
|
|
84
|
+
'CL': { code: 'CL', name: 'Chile', flag: '🇨🇱', defaultLanguages: ['es'], timezone: 'America/Santiago' },
|
|
85
|
+
|
|
86
|
+
// Middle East & North Africa
|
|
87
|
+
'SA': { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦', defaultLanguages: ['ar'], timezone: 'Asia/Riyadh' },
|
|
88
|
+
'AE': { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪', defaultLanguages: ['ar', 'en'], timezone: 'Asia/Dubai' },
|
|
89
|
+
'IL': { code: 'IL', name: 'Israel', flag: '🇮🇱', defaultLanguages: ['he'], timezone: 'Asia/Jerusalem' },
|
|
90
|
+
'EG': { code: 'EG', name: 'Egypt', flag: '🇪🇬', defaultLanguages: ['ar'], timezone: 'Africa/Cairo' },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get a country by its code.
|
|
95
|
+
*/
|
|
96
|
+
export function getCountry(code: string): Country | undefined {
|
|
97
|
+
return COUNTRIES[code]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the flag emoji for a country code.
|
|
102
|
+
* Returns empty string if the code is not found.
|
|
103
|
+
*/
|
|
104
|
+
export function getCountryFlag(code: string): string {
|
|
105
|
+
return COUNTRIES[code]?.flag ?? ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the display name for a country code.
|
|
110
|
+
* Returns empty string if the code is not found.
|
|
111
|
+
*/
|
|
112
|
+
export function getCountryName(code: string): string {
|
|
113
|
+
return COUNTRIES[code]?.name ?? ''
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get default languages for a country.
|
|
118
|
+
* Returns empty array if the country is not found.
|
|
119
|
+
*/
|
|
120
|
+
export function getDefaultLanguagesForCountry(code: string): string[] {
|
|
121
|
+
return COUNTRIES[code]?.defaultLanguages ?? []
|
|
122
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Region detection utilities.
|
|
3
|
+
* Detects user country and language from browser/environment signals.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Mapping of IANA timezone identifiers to ISO country codes */
|
|
7
|
+
export const TIMEZONE_TO_COUNTRY: Record<string, string> = {
|
|
8
|
+
// United States
|
|
9
|
+
'America/New_York': 'US',
|
|
10
|
+
'America/Chicago': 'US',
|
|
11
|
+
'America/Denver': 'US',
|
|
12
|
+
'America/Los_Angeles': 'US',
|
|
13
|
+
'America/Phoenix': 'US',
|
|
14
|
+
'America/Anchorage': 'US',
|
|
15
|
+
'Pacific/Honolulu': 'US',
|
|
16
|
+
'America/Detroit': 'US',
|
|
17
|
+
'America/Indiana/Indianapolis': 'US',
|
|
18
|
+
'America/Boise': 'US',
|
|
19
|
+
|
|
20
|
+
// Canada
|
|
21
|
+
'America/Toronto': 'CA',
|
|
22
|
+
'America/Vancouver': 'CA',
|
|
23
|
+
'America/Edmonton': 'CA',
|
|
24
|
+
'America/Winnipeg': 'CA',
|
|
25
|
+
'America/Halifax': 'CA',
|
|
26
|
+
'America/St_Johns': 'CA',
|
|
27
|
+
|
|
28
|
+
// Mexico
|
|
29
|
+
'America/Mexico_City': 'MX',
|
|
30
|
+
'America/Cancun': 'MX',
|
|
31
|
+
'America/Tijuana': 'MX',
|
|
32
|
+
|
|
33
|
+
// UK & Ireland
|
|
34
|
+
'Europe/London': 'GB',
|
|
35
|
+
'Europe/Dublin': 'IE',
|
|
36
|
+
|
|
37
|
+
// Western Europe
|
|
38
|
+
'Europe/Paris': 'FR',
|
|
39
|
+
'Europe/Berlin': 'DE',
|
|
40
|
+
'Europe/Madrid': 'ES',
|
|
41
|
+
'Europe/Rome': 'IT',
|
|
42
|
+
'Europe/Lisbon': 'PT',
|
|
43
|
+
'Europe/Amsterdam': 'NL',
|
|
44
|
+
'Europe/Brussels': 'BE',
|
|
45
|
+
'Europe/Zurich': 'CH',
|
|
46
|
+
'Europe/Vienna': 'AT',
|
|
47
|
+
|
|
48
|
+
// Nordic
|
|
49
|
+
'Europe/Stockholm': 'SE',
|
|
50
|
+
'Europe/Oslo': 'NO',
|
|
51
|
+
'Europe/Copenhagen': 'DK',
|
|
52
|
+
'Europe/Helsinki': 'FI',
|
|
53
|
+
|
|
54
|
+
// Eastern Europe
|
|
55
|
+
'Europe/Warsaw': 'PL',
|
|
56
|
+
'Europe/Prague': 'CZ',
|
|
57
|
+
'Europe/Bucharest': 'RO',
|
|
58
|
+
'Europe/Budapest': 'HU',
|
|
59
|
+
'Europe/Sofia': 'BG',
|
|
60
|
+
'Europe/Zagreb': 'HR',
|
|
61
|
+
'Europe/Bratislava': 'SK',
|
|
62
|
+
'Europe/Ljubljana': 'SI',
|
|
63
|
+
'Europe/Belgrade': 'RS',
|
|
64
|
+
'Europe/Kyiv': 'UA',
|
|
65
|
+
'Europe/Moscow': 'RU',
|
|
66
|
+
'Europe/Istanbul': 'TR',
|
|
67
|
+
'Europe/Athens': 'GR',
|
|
68
|
+
|
|
69
|
+
// South Asia
|
|
70
|
+
'Asia/Kolkata': 'IN',
|
|
71
|
+
'Asia/Calcutta': 'IN',
|
|
72
|
+
'Asia/Karachi': 'PK',
|
|
73
|
+
'Asia/Dhaka': 'BD',
|
|
74
|
+
'Asia/Colombo': 'LK',
|
|
75
|
+
'Asia/Kathmandu': 'NP',
|
|
76
|
+
|
|
77
|
+
// East Asia
|
|
78
|
+
'Asia/Tokyo': 'JP',
|
|
79
|
+
'Asia/Seoul': 'KR',
|
|
80
|
+
'Asia/Shanghai': 'CN',
|
|
81
|
+
'Asia/Hong_Kong': 'CN',
|
|
82
|
+
'Asia/Taipei': 'TW',
|
|
83
|
+
|
|
84
|
+
// Southeast Asia
|
|
85
|
+
'Asia/Bangkok': 'TH',
|
|
86
|
+
'Asia/Ho_Chi_Minh': 'VN',
|
|
87
|
+
'Asia/Jakarta': 'ID',
|
|
88
|
+
'Asia/Kuala_Lumpur': 'MY',
|
|
89
|
+
'Asia/Manila': 'PH',
|
|
90
|
+
'Asia/Singapore': 'SG',
|
|
91
|
+
|
|
92
|
+
// Oceania
|
|
93
|
+
'Australia/Sydney': 'AU',
|
|
94
|
+
'Australia/Melbourne': 'AU',
|
|
95
|
+
'Australia/Brisbane': 'AU',
|
|
96
|
+
'Australia/Perth': 'AU',
|
|
97
|
+
'Australia/Adelaide': 'AU',
|
|
98
|
+
'Pacific/Auckland': 'NZ',
|
|
99
|
+
|
|
100
|
+
// South America
|
|
101
|
+
'America/Sao_Paulo': 'BR',
|
|
102
|
+
'America/Rio_Branco': 'BR',
|
|
103
|
+
'America/Argentina/Buenos_Aires': 'AR',
|
|
104
|
+
'America/Bogota': 'CO',
|
|
105
|
+
'America/Santiago': 'CL',
|
|
106
|
+
|
|
107
|
+
// Middle East
|
|
108
|
+
'Asia/Riyadh': 'SA',
|
|
109
|
+
'Asia/Dubai': 'AE',
|
|
110
|
+
'Asia/Jerusalem': 'IL',
|
|
111
|
+
'Africa/Cairo': 'EG',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Mapping of BCP-47 locale strings to ISO country codes */
|
|
115
|
+
export const LOCALE_TO_COUNTRY: Record<string, string> = {
|
|
116
|
+
'en-US': 'US',
|
|
117
|
+
'en-GB': 'GB',
|
|
118
|
+
'en-AU': 'AU',
|
|
119
|
+
'en-CA': 'CA',
|
|
120
|
+
'en-NZ': 'NZ',
|
|
121
|
+
'en-IE': 'IE',
|
|
122
|
+
'en-IN': 'IN',
|
|
123
|
+
'en-SG': 'SG',
|
|
124
|
+
'en-PH': 'PH',
|
|
125
|
+
'en-MY': 'MY',
|
|
126
|
+
'en-ZA': 'ZA',
|
|
127
|
+
'hi-IN': 'IN',
|
|
128
|
+
'te-IN': 'IN',
|
|
129
|
+
'ta-IN': 'IN',
|
|
130
|
+
'ur-PK': 'PK',
|
|
131
|
+
'pa-IN': 'IN',
|
|
132
|
+
'bn-IN': 'IN',
|
|
133
|
+
'bn-BD': 'BD',
|
|
134
|
+
'gu-IN': 'IN',
|
|
135
|
+
'mr-IN': 'IN',
|
|
136
|
+
'kn-IN': 'IN',
|
|
137
|
+
'ml-IN': 'IN',
|
|
138
|
+
'fr-FR': 'FR',
|
|
139
|
+
'fr-CA': 'CA',
|
|
140
|
+
'fr-BE': 'BE',
|
|
141
|
+
'fr-CH': 'CH',
|
|
142
|
+
'es-ES': 'ES',
|
|
143
|
+
'es-MX': 'MX',
|
|
144
|
+
'es-AR': 'AR',
|
|
145
|
+
'es-CO': 'CO',
|
|
146
|
+
'es-CL': 'CL',
|
|
147
|
+
'de-DE': 'DE',
|
|
148
|
+
'de-AT': 'AT',
|
|
149
|
+
'de-CH': 'CH',
|
|
150
|
+
'it-IT': 'IT',
|
|
151
|
+
'it-CH': 'CH',
|
|
152
|
+
'pt-PT': 'PT',
|
|
153
|
+
'pt-BR': 'BR',
|
|
154
|
+
'nl-NL': 'NL',
|
|
155
|
+
'nl-BE': 'BE',
|
|
156
|
+
'sv-SE': 'SE',
|
|
157
|
+
'da-DK': 'DK',
|
|
158
|
+
'nb-NO': 'NO',
|
|
159
|
+
'nn-NO': 'NO',
|
|
160
|
+
'fi-FI': 'FI',
|
|
161
|
+
'pl-PL': 'PL',
|
|
162
|
+
'cs-CZ': 'CZ',
|
|
163
|
+
'ro-RO': 'RO',
|
|
164
|
+
'hu-HU': 'HU',
|
|
165
|
+
'bg-BG': 'BG',
|
|
166
|
+
'hr-HR': 'HR',
|
|
167
|
+
'sk-SK': 'SK',
|
|
168
|
+
'sl-SI': 'SI',
|
|
169
|
+
'sr-RS': 'SR',
|
|
170
|
+
'uk-UA': 'UA',
|
|
171
|
+
'el-GR': 'GR',
|
|
172
|
+
'ru-RU': 'RU',
|
|
173
|
+
'ja-JP': 'JP',
|
|
174
|
+
'ko-KR': 'KR',
|
|
175
|
+
'zh-CN': 'CN',
|
|
176
|
+
'zh-TW': 'TW',
|
|
177
|
+
'zh-HK': 'CN',
|
|
178
|
+
'ar-SA': 'SA',
|
|
179
|
+
'ar-AE': 'AE',
|
|
180
|
+
'ar-EG': 'EG',
|
|
181
|
+
'he-IL': 'IL',
|
|
182
|
+
'tr-TR': 'TR',
|
|
183
|
+
'th-TH': 'TH',
|
|
184
|
+
'vi-VN': 'VN',
|
|
185
|
+
'id-ID': 'ID',
|
|
186
|
+
'ms-MY': 'MY',
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Detect the user's country from environment signals.
|
|
191
|
+
* Strategy: timezone mapping -> navigator.languages locale mapping -> fallback 'US'
|
|
192
|
+
*/
|
|
193
|
+
export function detectCountry(): string {
|
|
194
|
+
// 1. Try timezone mapping
|
|
195
|
+
try {
|
|
196
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
197
|
+
if (tz && TIMEZONE_TO_COUNTRY[tz]) {
|
|
198
|
+
return TIMEZONE_TO_COUNTRY[tz]
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Intl may not be available
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2. Try navigator.languages locale mapping
|
|
205
|
+
try {
|
|
206
|
+
if (typeof navigator !== 'undefined') {
|
|
207
|
+
const languages = navigator.languages
|
|
208
|
+
if (languages && languages.length > 0) {
|
|
209
|
+
for (const locale of languages) {
|
|
210
|
+
if (LOCALE_TO_COUNTRY[locale]) {
|
|
211
|
+
return LOCALE_TO_COUNTRY[locale]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// navigator may not be available
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Fallback
|
|
221
|
+
return 'US'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Detect the user's preferred language from navigator.languages.
|
|
226
|
+
* Returns the first language from navigator.languages, or 'en' as fallback.
|
|
227
|
+
*/
|
|
228
|
+
export function detectLanguage(): string {
|
|
229
|
+
try {
|
|
230
|
+
if (typeof navigator !== 'undefined') {
|
|
231
|
+
const languages = (navigator as any).languages
|
|
232
|
+
if (languages && languages.length > 0) {
|
|
233
|
+
return languages[0]
|
|
234
|
+
}
|
|
235
|
+
if ((navigator as any).language) {
|
|
236
|
+
return (navigator as any).language
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// navigator may not be available
|
|
241
|
+
}
|
|
242
|
+
return 'en'
|
|
243
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Languages
|
|
2
|
+
export type { Language } from './languages.js'
|
|
3
|
+
export {
|
|
4
|
+
LANGUAGES,
|
|
5
|
+
getLanguage,
|
|
6
|
+
getSupportedLanguages,
|
|
7
|
+
getLanguagesByGroup,
|
|
8
|
+
getLanguageName,
|
|
9
|
+
isRTL,
|
|
10
|
+
getTtsLang,
|
|
11
|
+
} from './languages.js'
|
|
12
|
+
|
|
13
|
+
// Countries
|
|
14
|
+
export type { Country } from './countries.js'
|
|
15
|
+
export {
|
|
16
|
+
COUNTRIES,
|
|
17
|
+
getCountry,
|
|
18
|
+
getCountryFlag,
|
|
19
|
+
getCountryName,
|
|
20
|
+
getDefaultLanguagesForCountry,
|
|
21
|
+
} from './countries.js'
|
|
22
|
+
|
|
23
|
+
// Region detection
|
|
24
|
+
export {
|
|
25
|
+
TIMEZONE_TO_COUNTRY,
|
|
26
|
+
LOCALE_TO_COUNTRY,
|
|
27
|
+
detectCountry,
|
|
28
|
+
detectLanguage,
|
|
29
|
+
} from './detect-region.js'
|
|
30
|
+
|
|
31
|
+
// Voice registry
|
|
32
|
+
export type { VoiceOption } from './voice-registry.js'
|
|
33
|
+
export {
|
|
34
|
+
VOICES,
|
|
35
|
+
getVoicesForLanguage,
|
|
36
|
+
getBestVoice,
|
|
37
|
+
getVoicesByProvider,
|
|
38
|
+
} from './voice-registry.js'
|
|
39
|
+
|
|
40
|
+
// Locale utilities
|
|
41
|
+
export {
|
|
42
|
+
LOCALE_DISPLAY_NAMES,
|
|
43
|
+
getLocaleDisplayName,
|
|
44
|
+
getStoredLocale,
|
|
45
|
+
setStoredLocale,
|
|
46
|
+
getLocalizedValue,
|
|
47
|
+
} from './locale-utils.js'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language type and registry for i18n support.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Language {
|
|
6
|
+
code: string
|
|
7
|
+
name: string
|
|
8
|
+
nativeName: string
|
|
9
|
+
direction: 'ltr' | 'rtl'
|
|
10
|
+
group?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const LANGUAGES: Record<string, Language> = {
|
|
14
|
+
// English variants
|
|
15
|
+
'en': { code: 'en', name: 'English', nativeName: 'English', direction: 'ltr', group: 'English' },
|
|
16
|
+
'en-US': { code: 'en-US', name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', group: 'English' },
|
|
17
|
+
'en-GB': { code: 'en-GB', name: 'English (UK)', nativeName: 'English (UK)', direction: 'ltr', group: 'English' },
|
|
18
|
+
'en-IN': { code: 'en-IN', name: 'English (India)', nativeName: 'English (India)',direction: 'ltr', group: 'English' },
|
|
19
|
+
|
|
20
|
+
// Indian languages
|
|
21
|
+
'hi': { code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', direction: 'ltr', group: 'Indian' },
|
|
22
|
+
'te': { code: 'te', name: 'Telugu', nativeName: 'తెలుగు', direction: 'ltr', group: 'Indian' },
|
|
23
|
+
'ta': { code: 'ta', name: 'Tamil', nativeName: 'தமிழ்', direction: 'ltr', group: 'Indian' },
|
|
24
|
+
'ur': { code: 'ur', name: 'Urdu', nativeName: 'اردو', direction: 'rtl', group: 'Indian' },
|
|
25
|
+
'pa': { code: 'pa', name: 'Punjabi', nativeName: 'ਪੰਜਾਬੀ', direction: 'ltr', group: 'Indian' },
|
|
26
|
+
'bn': { code: 'bn', name: 'Bengali', nativeName: 'বাংলা', direction: 'ltr', group: 'Indian' },
|
|
27
|
+
'gu': { code: 'gu', name: 'Gujarati', nativeName: 'ગુજરાતી', direction: 'ltr', group: 'Indian' },
|
|
28
|
+
'mr': { code: 'mr', name: 'Marathi', nativeName: 'मराठी', direction: 'ltr', group: 'Indian' },
|
|
29
|
+
'kn': { code: 'kn', name: 'Kannada', nativeName: 'ಕನ್ನಡ', direction: 'ltr', group: 'Indian' },
|
|
30
|
+
'ml': { code: 'ml', name: 'Malayalam', nativeName: 'മലയാളം', direction: 'ltr', group: 'Indian' },
|
|
31
|
+
|
|
32
|
+
// Western European
|
|
33
|
+
'fr': { code: 'fr', name: 'French', nativeName: 'Français', direction: 'ltr', group: 'European' },
|
|
34
|
+
'es': { code: 'es', name: 'Spanish', nativeName: 'Español', direction: 'ltr', group: 'European' },
|
|
35
|
+
'de': { code: 'de', name: 'German', nativeName: 'Deutsch', direction: 'ltr', group: 'European' },
|
|
36
|
+
'it': { code: 'it', name: 'Italian', nativeName: 'Italiano', direction: 'ltr', group: 'European' },
|
|
37
|
+
'pt': { code: 'pt', name: 'Portuguese', nativeName: 'Português', direction: 'ltr', group: 'European' },
|
|
38
|
+
'pt-BR': { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', direction: 'ltr', group: 'European' },
|
|
39
|
+
'nl': { code: 'nl', name: 'Dutch', nativeName: 'Nederlands', direction: 'ltr', group: 'European' },
|
|
40
|
+
|
|
41
|
+
// Nordic
|
|
42
|
+
'sv': { code: 'sv', name: 'Swedish', nativeName: 'Svenska', direction: 'ltr', group: 'European' },
|
|
43
|
+
'da': { code: 'da', name: 'Danish', nativeName: 'Dansk', direction: 'ltr', group: 'European' },
|
|
44
|
+
'no': { code: 'no', name: 'Norwegian', nativeName: 'Norsk', direction: 'ltr', group: 'European' },
|
|
45
|
+
'fi': { code: 'fi', name: 'Finnish', nativeName: 'Suomi', direction: 'ltr', group: 'European' },
|
|
46
|
+
|
|
47
|
+
// Eastern European
|
|
48
|
+
'pl': { code: 'pl', name: 'Polish', nativeName: 'Polski', direction: 'ltr', group: 'European' },
|
|
49
|
+
'cs': { code: 'cs', name: 'Czech', nativeName: 'Čeština', direction: 'ltr', group: 'European' },
|
|
50
|
+
'ro': { code: 'ro', name: 'Romanian', nativeName: 'Română', direction: 'ltr', group: 'European' },
|
|
51
|
+
'hu': { code: 'hu', name: 'Hungarian', nativeName: 'Magyar', direction: 'ltr', group: 'European' },
|
|
52
|
+
'bg': { code: 'bg', name: 'Bulgarian', nativeName: 'Български', direction: 'ltr', group: 'European' },
|
|
53
|
+
'hr': { code: 'hr', name: 'Croatian', nativeName: 'Hrvatski', direction: 'ltr', group: 'European' },
|
|
54
|
+
'sk': { code: 'sk', name: 'Slovak', nativeName: 'Slovenčina', direction: 'ltr', group: 'European' },
|
|
55
|
+
'sl': { code: 'sl', name: 'Slovenian', nativeName: 'Slovenščina', direction: 'ltr', group: 'European' },
|
|
56
|
+
'sr': { code: 'sr', name: 'Serbian', nativeName: 'Srpski', direction: 'ltr', group: 'European' },
|
|
57
|
+
'uk': { code: 'uk', name: 'Ukrainian', nativeName: 'Українська', direction: 'ltr', group: 'European' },
|
|
58
|
+
'el': { code: 'el', name: 'Greek', nativeName: 'Ελληνικά', direction: 'ltr', group: 'European' },
|
|
59
|
+
'ru': { code: 'ru', name: 'Russian', nativeName: 'Русский', direction: 'ltr', group: 'European' },
|
|
60
|
+
|
|
61
|
+
// East Asian
|
|
62
|
+
'ja': { code: 'ja', name: 'Japanese', nativeName: '日本語', direction: 'ltr', group: 'East Asian' },
|
|
63
|
+
'ko': { code: 'ko', name: 'Korean', nativeName: '한국어', direction: 'ltr', group: 'East Asian' },
|
|
64
|
+
'zh': { code: 'zh', name: 'Chinese (Simplified)', nativeName: '中文(简体)', direction: 'ltr', group: 'East Asian' },
|
|
65
|
+
'zh-TW': { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '中文(繁體)', direction: 'ltr', group: 'East Asian' },
|
|
66
|
+
|
|
67
|
+
// Middle Eastern
|
|
68
|
+
'ar': { code: 'ar', name: 'Arabic', nativeName: 'العربية', direction: 'rtl', group: 'Middle Eastern' },
|
|
69
|
+
'he': { code: 'he', name: 'Hebrew', nativeName: 'עברית', direction: 'rtl', group: 'Middle Eastern' },
|
|
70
|
+
'tr': { code: 'tr', name: 'Turkish', nativeName: 'Türkçe', direction: 'ltr', group: 'Middle Eastern' },
|
|
71
|
+
|
|
72
|
+
// Southeast Asian
|
|
73
|
+
'th': { code: 'th', name: 'Thai', nativeName: 'ไทย', direction: 'ltr', group: 'Southeast Asian' },
|
|
74
|
+
'vi': { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', direction: 'ltr', group: 'Southeast Asian' },
|
|
75
|
+
'id': { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', direction: 'ltr', group: 'Southeast Asian' },
|
|
76
|
+
'ms': { code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu', direction: 'ltr', group: 'Southeast Asian' },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** BCP-47 TTS language tag mappings for base language codes */
|
|
80
|
+
const TTS_LANG_MAP: Record<string, string> = {
|
|
81
|
+
'en': 'en-US',
|
|
82
|
+
'en-US': 'en-US',
|
|
83
|
+
'en-GB': 'en-GB',
|
|
84
|
+
'en-IN': 'en-IN',
|
|
85
|
+
'hi': 'hi-IN',
|
|
86
|
+
'te': 'te-IN',
|
|
87
|
+
'ta': 'ta-IN',
|
|
88
|
+
'ur': 'ur-PK',
|
|
89
|
+
'pa': 'pa-IN',
|
|
90
|
+
'bn': 'bn-IN',
|
|
91
|
+
'gu': 'gu-IN',
|
|
92
|
+
'mr': 'mr-IN',
|
|
93
|
+
'kn': 'kn-IN',
|
|
94
|
+
'ml': 'ml-IN',
|
|
95
|
+
'fr': 'fr-FR',
|
|
96
|
+
'es': 'es-ES',
|
|
97
|
+
'de': 'de-DE',
|
|
98
|
+
'it': 'it-IT',
|
|
99
|
+
'pt': 'pt-PT',
|
|
100
|
+
'pt-BR': 'pt-BR',
|
|
101
|
+
'nl': 'nl-NL',
|
|
102
|
+
'sv': 'sv-SE',
|
|
103
|
+
'da': 'da-DK',
|
|
104
|
+
'no': 'nb-NO',
|
|
105
|
+
'fi': 'fi-FI',
|
|
106
|
+
'pl': 'pl-PL',
|
|
107
|
+
'cs': 'cs-CZ',
|
|
108
|
+
'ro': 'ro-RO',
|
|
109
|
+
'hu': 'hu-HU',
|
|
110
|
+
'bg': 'bg-BG',
|
|
111
|
+
'hr': 'hr-HR',
|
|
112
|
+
'sk': 'sk-SK',
|
|
113
|
+
'sl': 'sl-SI',
|
|
114
|
+
'sr': 'sr-RS',
|
|
115
|
+
'uk': 'uk-UA',
|
|
116
|
+
'el': 'el-GR',
|
|
117
|
+
'ru': 'ru-RU',
|
|
118
|
+
'ja': 'ja-JP',
|
|
119
|
+
'ko': 'ko-KR',
|
|
120
|
+
'zh': 'zh-CN',
|
|
121
|
+
'zh-TW': 'zh-TW',
|
|
122
|
+
'ar': 'ar-SA',
|
|
123
|
+
'he': 'he-IL',
|
|
124
|
+
'tr': 'tr-TR',
|
|
125
|
+
'th': 'th-TH',
|
|
126
|
+
'vi': 'vi-VN',
|
|
127
|
+
'id': 'id-ID',
|
|
128
|
+
'ms': 'ms-MY',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get a language by its code.
|
|
133
|
+
*/
|
|
134
|
+
export function getLanguage(code: string): Language | undefined {
|
|
135
|
+
return LANGUAGES[code]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all supported languages as an array.
|
|
140
|
+
*/
|
|
141
|
+
export function getSupportedLanguages(): Language[] {
|
|
142
|
+
return Object.values(LANGUAGES)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get languages grouped by their language family/group.
|
|
147
|
+
*/
|
|
148
|
+
export function getLanguagesByGroup(): Record<string, Language[]> {
|
|
149
|
+
const groups: Record<string, Language[]> = {}
|
|
150
|
+
for (const lang of Object.values(LANGUAGES)) {
|
|
151
|
+
const group = lang.group ?? 'Other'
|
|
152
|
+
if (!groups[group]) {
|
|
153
|
+
groups[group] = []
|
|
154
|
+
}
|
|
155
|
+
groups[group].push(lang)
|
|
156
|
+
}
|
|
157
|
+
return groups
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the display name for a language code.
|
|
162
|
+
* Returns empty string if the code is not found.
|
|
163
|
+
*/
|
|
164
|
+
export function getLanguageName(code: string): string {
|
|
165
|
+
return LANGUAGES[code]?.name ?? ''
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if a language code represents a right-to-left language.
|
|
170
|
+
*/
|
|
171
|
+
export function isRTL(code: string): boolean {
|
|
172
|
+
return LANGUAGES[code]?.direction === 'rtl'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the BCP-47 language tag suitable for TTS engines.
|
|
177
|
+
* Returns the code itself if no mapping exists.
|
|
178
|
+
*/
|
|
179
|
+
export function getTtsLang(code: string): string {
|
|
180
|
+
return TTS_LANG_MAP[code] ?? code
|
|
181
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale utilities for display names, storage, and localized value resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Display names for common locale codes (in their native language) */
|
|
6
|
+
export const LOCALE_DISPLAY_NAMES: Record<string, string> = {
|
|
7
|
+
'en': 'English',
|
|
8
|
+
'en-US': 'English (US)',
|
|
9
|
+
'en-GB': 'English (UK)',
|
|
10
|
+
'en-IN': 'English (India)',
|
|
11
|
+
'hi': 'हिन्दी',
|
|
12
|
+
'te': 'తెలుగు',
|
|
13
|
+
'ta': 'தமிழ்',
|
|
14
|
+
'ur': 'اردو',
|
|
15
|
+
'pa': 'ਪੰਜਾਬੀ',
|
|
16
|
+
'bn': 'বাংলা',
|
|
17
|
+
'gu': 'ગુજરાતી',
|
|
18
|
+
'mr': 'मराठी',
|
|
19
|
+
'kn': 'ಕನ್ನಡ',
|
|
20
|
+
'ml': 'മലയാളം',
|
|
21
|
+
'fr': 'Français',
|
|
22
|
+
'es': 'Español',
|
|
23
|
+
'de': 'Deutsch',
|
|
24
|
+
'it': 'Italiano',
|
|
25
|
+
'pt': 'Português',
|
|
26
|
+
'pt-BR': 'Português (Brasil)',
|
|
27
|
+
'nl': 'Nederlands',
|
|
28
|
+
'sv': 'Svenska',
|
|
29
|
+
'da': 'Dansk',
|
|
30
|
+
'no': 'Norsk',
|
|
31
|
+
'fi': 'Suomi',
|
|
32
|
+
'pl': 'Polski',
|
|
33
|
+
'cs': 'Čeština',
|
|
34
|
+
'ro': 'Română',
|
|
35
|
+
'hu': 'Magyar',
|
|
36
|
+
'bg': 'Български',
|
|
37
|
+
'hr': 'Hrvatski',
|
|
38
|
+
'sk': 'Slovenčina',
|
|
39
|
+
'sl': 'Slovenščina',
|
|
40
|
+
'sr': 'Srpski',
|
|
41
|
+
'uk': 'Українська',
|
|
42
|
+
'el': 'Ελληνικά',
|
|
43
|
+
'ru': 'Русский',
|
|
44
|
+
'ja': '日本語',
|
|
45
|
+
'ko': '한국어',
|
|
46
|
+
'zh': '中文',
|
|
47
|
+
'zh-TW': '中文(繁體)',
|
|
48
|
+
'ar': 'العربية',
|
|
49
|
+
'he': 'עברית',
|
|
50
|
+
'tr': 'Türkçe',
|
|
51
|
+
'th': 'ไทย',
|
|
52
|
+
'vi': 'Tiếng Việt',
|
|
53
|
+
'id': 'Bahasa Indonesia',
|
|
54
|
+
'ms': 'Bahasa Melayu',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_STORAGE_KEY = 'refraction-ui-locale'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the display name for a locale code.
|
|
61
|
+
* Returns the code itself if not found in the display names map.
|
|
62
|
+
*/
|
|
63
|
+
export function getLocaleDisplayName(code: string): string {
|
|
64
|
+
return LOCALE_DISPLAY_NAMES[code] ?? code
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the stored locale from localStorage (or compatible storage).
|
|
69
|
+
* Returns null if not stored or localStorage is not available.
|
|
70
|
+
*/
|
|
71
|
+
export function getStoredLocale(storageKey?: string): string | null {
|
|
72
|
+
try {
|
|
73
|
+
if (typeof localStorage !== 'undefined') {
|
|
74
|
+
return localStorage.getItem(storageKey ?? DEFAULT_STORAGE_KEY)
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// localStorage may not be available
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Store a locale code in localStorage (or compatible storage).
|
|
84
|
+
* Does nothing if localStorage is not available.
|
|
85
|
+
*/
|
|
86
|
+
export function setStoredLocale(code: string, storageKey?: string): void {
|
|
87
|
+
try {
|
|
88
|
+
if (typeof localStorage !== 'undefined') {
|
|
89
|
+
localStorage.setItem(storageKey ?? DEFAULT_STORAGE_KEY, code)
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// localStorage may not be available
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a localized value from a record of locale -> value.
|
|
98
|
+
* Tries exact locale, then base language code (for regional variants like 'en-US' -> 'en'),
|
|
99
|
+
* then the fallback locale.
|
|
100
|
+
*/
|
|
101
|
+
export function getLocalizedValue<T>(
|
|
102
|
+
localized: Record<string, T>,
|
|
103
|
+
locale: string,
|
|
104
|
+
fallbackLocale?: string,
|
|
105
|
+
): T | undefined {
|
|
106
|
+
// 1. Exact match
|
|
107
|
+
if (locale in localized) {
|
|
108
|
+
return localized[locale]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Try base language code (e.g., 'en-US' -> 'en')
|
|
112
|
+
const baseLang = locale.split('-')[0]
|
|
113
|
+
if (baseLang !== locale && baseLang in localized) {
|
|
114
|
+
return localized[baseLang]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Fallback locale
|
|
118
|
+
if (fallbackLocale && fallbackLocale in localized) {
|
|
119
|
+
return localized[fallbackLocale]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice registry for TTS providers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface VoiceOption {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
language: string
|
|
9
|
+
provider: 'browser' | 'google' | 'elevenlabs' | 'openai'
|
|
10
|
+
gender?: 'male' | 'female'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const VOICES: VoiceOption[] = [
|
|
14
|
+
// Browser voices — English
|
|
15
|
+
{ id: 'browser-en-us-female', name: 'English (US) Female', language: 'en-US', provider: 'browser', gender: 'female' },
|
|
16
|
+
{ id: 'browser-en-us-male', name: 'English (US) Male', language: 'en-US', provider: 'browser', gender: 'male' },
|
|
17
|
+
{ id: 'browser-en-gb-female', name: 'English (UK) Female', language: 'en-GB', provider: 'browser', gender: 'female' },
|
|
18
|
+
{ id: 'browser-en-gb-male', name: 'English (UK) Male', language: 'en-GB', provider: 'browser', gender: 'male' },
|
|
19
|
+
{ id: 'browser-en-in-female', name: 'English (India) Female', language: 'en-IN', provider: 'browser', gender: 'female' },
|
|
20
|
+
|
|
21
|
+
// Browser voices — European
|
|
22
|
+
{ id: 'browser-fr-female', name: 'French Female', language: 'fr', provider: 'browser', gender: 'female' },
|
|
23
|
+
{ id: 'browser-de-female', name: 'German Female', language: 'de', provider: 'browser', gender: 'female' },
|
|
24
|
+
{ id: 'browser-es-female', name: 'Spanish Female', language: 'es', provider: 'browser', gender: 'female' },
|
|
25
|
+
{ id: 'browser-it-female', name: 'Italian Female', language: 'it', provider: 'browser', gender: 'female' },
|
|
26
|
+
{ id: 'browser-pt-female', name: 'Portuguese Female', language: 'pt', provider: 'browser', gender: 'female' },
|
|
27
|
+
|
|
28
|
+
// Browser voices — Asian
|
|
29
|
+
{ id: 'browser-ja-female', name: 'Japanese Female', language: 'ja', provider: 'browser', gender: 'female' },
|
|
30
|
+
{ id: 'browser-ko-female', name: 'Korean Female', language: 'ko', provider: 'browser', gender: 'female' },
|
|
31
|
+
{ id: 'browser-zh-female', name: 'Chinese Female', language: 'zh', provider: 'browser', gender: 'female' },
|
|
32
|
+
{ id: 'browser-hi-female', name: 'Hindi Female', language: 'hi', provider: 'browser', gender: 'female' },
|
|
33
|
+
{ id: 'browser-ar-male', name: 'Arabic Male', language: 'ar', provider: 'browser', gender: 'male' },
|
|
34
|
+
|
|
35
|
+
// Google TTS voices
|
|
36
|
+
{ id: 'google-en-us-wavenet-d', name: 'Google WaveNet English (US)', language: 'en-US', provider: 'google', gender: 'male' },
|
|
37
|
+
{ id: 'google-en-us-wavenet-f', name: 'Google WaveNet English (US) Female', language: 'en-US', provider: 'google', gender: 'female' },
|
|
38
|
+
{ id: 'google-en-gb-wavenet-a', name: 'Google WaveNet English (UK)', language: 'en-GB', provider: 'google', gender: 'female' },
|
|
39
|
+
{ id: 'google-fr-wavenet-a', name: 'Google WaveNet French', language: 'fr', provider: 'google', gender: 'female' },
|
|
40
|
+
{ id: 'google-de-wavenet-a', name: 'Google WaveNet German', language: 'de', provider: 'google', gender: 'female' },
|
|
41
|
+
{ id: 'google-es-wavenet-a', name: 'Google WaveNet Spanish', language: 'es', provider: 'google', gender: 'female' },
|
|
42
|
+
{ id: 'google-ja-wavenet-a', name: 'Google WaveNet Japanese', language: 'ja', provider: 'google', gender: 'female' },
|
|
43
|
+
{ id: 'google-hi-wavenet-a', name: 'Google WaveNet Hindi', language: 'hi', provider: 'google', gender: 'female' },
|
|
44
|
+
{ id: 'google-ar-wavenet-a', name: 'Google WaveNet Arabic', language: 'ar', provider: 'google', gender: 'female' },
|
|
45
|
+
|
|
46
|
+
// ElevenLabs voices
|
|
47
|
+
{ id: 'elevenlabs-rachel', name: 'Rachel', language: 'en', provider: 'elevenlabs', gender: 'female' },
|
|
48
|
+
{ id: 'elevenlabs-adam', name: 'Adam', language: 'en', provider: 'elevenlabs', gender: 'male' },
|
|
49
|
+
{ id: 'elevenlabs-antoni', name: 'Antoni', language: 'en', provider: 'elevenlabs', gender: 'male' },
|
|
50
|
+
{ id: 'elevenlabs-bella', name: 'Bella', language: 'es', provider: 'elevenlabs', gender: 'female' },
|
|
51
|
+
{ id: 'elevenlabs-elli', name: 'Elli', language: 'de', provider: 'elevenlabs', gender: 'female' },
|
|
52
|
+
|
|
53
|
+
// OpenAI TTS voices
|
|
54
|
+
{ id: 'openai-alloy', name: 'Alloy', language: 'en', provider: 'openai' },
|
|
55
|
+
{ id: 'openai-echo', name: 'Echo', language: 'en', provider: 'openai', gender: 'male' },
|
|
56
|
+
{ id: 'openai-fable', name: 'Fable', language: 'en', provider: 'openai', gender: 'male' },
|
|
57
|
+
{ id: 'openai-onyx', name: 'Onyx', language: 'en', provider: 'openai', gender: 'male' },
|
|
58
|
+
{ id: 'openai-nova', name: 'Nova', language: 'en', provider: 'openai', gender: 'female' },
|
|
59
|
+
{ id: 'openai-shimmer', name: 'Shimmer', language: 'en', provider: 'openai', gender: 'female' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get all voices that match a given language code.
|
|
64
|
+
* Matches both exact codes and prefix codes (e.g., 'en' matches 'en', 'en-US', 'en-GB').
|
|
65
|
+
*/
|
|
66
|
+
export function getVoicesForLanguage(langCode: string): VoiceOption[] {
|
|
67
|
+
return VOICES.filter((v) => v.language === langCode || v.language.startsWith(langCode + '-') || langCode.startsWith(v.language + '-') || v.language.split('-')[0] === langCode)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the best voice for a language, optionally preferring a specific provider.
|
|
72
|
+
* Returns undefined if no voice matches.
|
|
73
|
+
*/
|
|
74
|
+
export function getBestVoice(langCode: string, preferredProvider?: VoiceOption['provider']): VoiceOption | undefined {
|
|
75
|
+
const voices = getVoicesForLanguage(langCode)
|
|
76
|
+
if (voices.length === 0) return undefined
|
|
77
|
+
|
|
78
|
+
if (preferredProvider) {
|
|
79
|
+
const preferred = voices.find((v) => v.provider === preferredProvider)
|
|
80
|
+
if (preferred) return preferred
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Return first available voice
|
|
84
|
+
return voices[0]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get all voices from a specific provider.
|
|
89
|
+
*/
|
|
90
|
+
export function getVoicesByProvider(provider: VoiceOption['provider']): VoiceOption[] {
|
|
91
|
+
return VOICES.filter((v) => v.provider === provider)
|
|
92
|
+
}
|
package/dist/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export * from './astro-link-card/index.ts';
|
|
|
64
64
|
export * from './astro-live-captions/index.ts';
|
|
65
65
|
export * from './astro-live-cursors/index.ts';
|
|
66
66
|
export * from './astro-live-transcript/index.ts';
|
|
67
|
+
export * from './astro-location-selector/index.ts';
|
|
67
68
|
export * from './astro-logger/index.ts';
|
|
68
69
|
export * from './astro-markdown-renderer/index.ts';
|
|
69
70
|
export * from './astro-marquee-strip/index.ts';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './location-selector.ts'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { COUNTRIES, LANGUAGES, detectCountry, detectLanguage } from '../i18n/index.ts'
|
|
2
|
+
import { generateId } from '../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
export interface LocationSelectorState {
|
|
5
|
+
country: string
|
|
6
|
+
language: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LocationSelectorOptions {
|
|
10
|
+
defaultCountry?: string
|
|
11
|
+
defaultLanguage?: string
|
|
12
|
+
onCountryChange?: (country: string) => void
|
|
13
|
+
onLanguageChange?: (language: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createLocationSelector(options: LocationSelectorOptions = {}) {
|
|
17
|
+
const {
|
|
18
|
+
defaultCountry = 'US',
|
|
19
|
+
defaultLanguage = 'en',
|
|
20
|
+
onCountryChange,
|
|
21
|
+
onLanguageChange,
|
|
22
|
+
} = options
|
|
23
|
+
|
|
24
|
+
let state: LocationSelectorState = {
|
|
25
|
+
country: defaultCountry,
|
|
26
|
+
language: defaultLanguage,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const countryId = generateId('location-sel-country')
|
|
30
|
+
const languageId = generateId('location-sel-language')
|
|
31
|
+
|
|
32
|
+
function setCountry(country: string) {
|
|
33
|
+
state.country = country
|
|
34
|
+
onCountryChange?.(country)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setLanguage(language: string) {
|
|
38
|
+
state.language = language
|
|
39
|
+
onLanguageChange?.(language)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const countries = Array.isArray(COUNTRIES) ? COUNTRIES : Object.values(COUNTRIES)
|
|
43
|
+
const languages = Array.isArray(LANGUAGES) ? LANGUAGES : Object.values(LANGUAGES)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
state,
|
|
47
|
+
setCountry,
|
|
48
|
+
setLanguage,
|
|
49
|
+
countries,
|
|
50
|
+
languages,
|
|
51
|
+
countryProps: {
|
|
52
|
+
id: countryId,
|
|
53
|
+
name: 'country',
|
|
54
|
+
},
|
|
55
|
+
languageProps: {
|
|
56
|
+
id: languageId,
|
|
57
|
+
name: 'language',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@refraction-ui/astro",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "All Refraction UI Astro components in one package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"@refraction-ui/astro-live-captions": "workspace:*",
|
|
85
85
|
"@refraction-ui/astro-live-cursors": "workspace:*",
|
|
86
86
|
"@refraction-ui/astro-live-transcript": "workspace:*",
|
|
87
|
+
"@refraction-ui/astro-location-selector": "workspace:*",
|
|
87
88
|
"@refraction-ui/astro-logger": "workspace:*",
|
|
88
89
|
"@refraction-ui/astro-markdown-renderer": "workspace:*",
|
|
89
90
|
"@refraction-ui/astro-marquee-strip": "workspace:*",
|