pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1import countryLanguages, { LanguageObj } from "@ladjs/country-language";
2import { getTag } from "@sozialhelden/ietf-language-tags";
3import { iso6393To1 } from "iso-639-3";
4
5const languageOrder = ["en", "hi", "fr", "de", "nl", "pt"];
6
7// mapping of language code to country code.
8// multiple mappings can exist, since languages are spoken in multiple countries.
9// This mapping purely exists to prioritize a country over another in languages where the base language code does
10// not contain a region (i.e. if the language code is zh-Hant where Hant is a script) or if the region in the language code is incorrect
11// iso639_1 -> iso3166 Alpha-2
12const countryPriority: Record<string, string> = {
13 zh: "cn",
14 nv: "us",
15};
16
17// list of iso639_1 Alpha-2 codes used as default languages
18const defaultLanguageCodes: string[] = [
19 "ar-SA",
20 "bg-BG",
21 "bn-BD",
22 "cs-CZ",
23 "ca-AD",
24 "da-DK",
25 "de-DE",
26 "de-CH",
27 "el-GR",
28 "en-US",
29 "es-ES",
30 "et-EE",
31 "fa-IR",
32 "fr-FR",
33 "gl-ES",
34 "gu-IN",
35 "he-IL",
36 "id-ID",
37 "it-IT",
38 "ja-JP",
39 "ko-KR",
40 "lv-LV",
41 "ne-NP",
42 "nl-NL",
43 "pl-PL",
44 "pt-BR",
45 "ru-RU",
46 "sl-SI",
47 "sv-SE",
48 "ta-LK",
49 "th-TH",
50 "tr-TR",
51 "vi-VN",
52 "zh-CN",
53 "nv-US",
54];
55
56export interface LocaleInfo {
57 name: string;
58 nativeName?: string;
59 code: string;
60 isRtl?: boolean;
61}
62
63const extraLanguages: Record<string, LocaleInfo> = {
64 pirate: {
65 code: "pirate",
66 name: "Pirate",
67 nativeName: "Pirate Tongue",
68 },
69 kitty: {
70 code: "cat",
71 name: "Cat",
72 nativeName: "Kitty Speak",
73 },
74 uwu: {
75 code: "uwu",
76 name: "Cutsie OwO",
77 nativeName: "UwU",
78 },
79 minion: {
80 code: "minion",
81 name: "Minion",
82 nativeName: "Minionese",
83 },
84 tok: {
85 code: "tok",
86 name: "Toki pona",
87 nativeName: "Toki pona",
88 },
89 futhark: {
90 code: "futhark",
91 name: "Elder Futhark (EN)",
92 nativeName: "ᛖᛚᛞᛖᚱ ᚠᚢᚦᚨᚱᚲ",
93 },
94};
95
96function populateLanguageCode(language: string): string {
97 if (language.includes("-")) return language;
98 if (language.length !== 2) return language;
99 return (
100 defaultLanguageCodes.find((v) => v.startsWith(`${language}-`)) ?? language
101 );
102}
103
104/**
105 * @param locale idk what kinda code this takes, anything in ietf format I guess
106 * @returns pretty format for language, null if it no info can be found for language
107 */
108export function getPrettyLanguageNameFromLocale(locale: string): string | null {
109 const tag =
110 locale.length === 3
111 ? getTag(iso6393To1[locale] ?? locale, true)
112 : getTag(locale, true);
113 const lang = tag?.language?.Description?.[0] ?? null;
114 if (!lang) return null;
115
116 const region = tag?.region?.Description?.[0] ?? null;
117 let regionText = "";
118 if (region) regionText = ` (${region})`;
119
120 return `${lang}${regionText}`;
121}
122
123/**
124 * Sort locale codes by occurrence, rest on alphabetical order
125 * @param langCodes list language codes to sort
126 * @param appLanguage optional app language to prioritize
127 * @returns sorted version of inputted list
128 */
129export function sortLangCodes(langCodes: string[], appLanguage?: string) {
130 const languagesOrder = [...languageOrder];
131 if (appLanguage && !languagesOrder.includes(appLanguage)) {
132 languagesOrder.unshift(appLanguage);
133 }
134 const reversedOrder = [...languagesOrder].reverse(); // Reverse is necessary, not sure why
135
136 const results = langCodes.sort((a, b) => {
137 const langOrderA = reversedOrder.findIndex(
138 (v) => a.startsWith(`${v}-`) || a === v,
139 );
140 const langOrderB = reversedOrder.findIndex(
141 (v) => b.startsWith(`${v}-`) || b === v,
142 );
143 if (langOrderA !== -1 || langOrderB !== -1) return langOrderB - langOrderA;
144
145 return a.localeCompare(b);
146 });
147
148 return results;
149}
150
151/**
152 * Get country code for locale
153 * @param locale input locale
154 * @returns country code or null
155 */
156export function getCountryCodeForLocale(locale: string): string | null {
157 let output: LanguageObj | null = null as any as LanguageObj;
158 const tag = getTag(populateLanguageCode(locale), true);
159
160 if (!tag?.language?.Subtag) return null;
161 // this function isn't async, so its guaranteed to work like this
162 countryLanguages.getLanguage(tag.language.Subtag, (_err, lang) => {
163 if (lang) output = lang;
164 });
165
166 if (!output) return null;
167 const priority = countryPriority[output.iso639_1.toLowerCase()];
168 if (output.countries.length === 0) {
169 return priority ?? null;
170 }
171
172 if (priority) {
173 const prioritizedCountry = output.countries.find(
174 (v) => v.code_2.toLowerCase() === priority,
175 );
176 if (prioritizedCountry) return prioritizedCountry.code_2.toLowerCase();
177 }
178
179 // If the language contains a region, check that against the countries and
180 // return the region if it matches
181 const regionSubtag = tag?.region?.Subtag.toLowerCase();
182 if (regionSubtag) {
183 const regionCode = output.countries.find(
184 (c) =>
185 c.code_2.toLowerCase() === regionSubtag ||
186 c.code_3.toLowerCase() === regionSubtag,
187 );
188 if (regionCode) return regionCode.code_2.toLowerCase();
189 }
190 return output.countries[0].code_2.toLowerCase();
191}
192
193/**
194 * Get information for a specific local
195 * @param locale local code
196 * @returns locale object
197 */
198export function getLocaleInfo(locale: string): LocaleInfo | null {
199 const realLocale = populateLanguageCode(locale);
200
201 document.body.style.wordSpacing = "normal";
202
203 const extraLang = extraLanguages[realLocale];
204 if (extraLang) {
205 if (extraLang.code === "futhark") {
206 document.body.style.wordSpacing = "5px";
207 }
208 return extraLang;
209 }
210
211 const tag = getTag(realLocale, true);
212 if (!tag?.language?.Subtag) return null;
213
214 let output: LanguageObj | null = null as any as LanguageObj;
215 // this function isnt async, so its garuanteed to work like this
216 countryLanguages.getLanguage(tag.language.Subtag, (_err, lang) => {
217 if (lang) output = lang;
218 });
219 if (!output) return null;
220
221 const extras = [];
222 if (tag.region?.Description) extras.push(tag.region.Description[0]);
223 if (tag.script?.Description) extras.push(tag.script.Description[0]);
224 const extraStringified = extras.map((v) => `(${v})`).join(" ");
225
226 return {
227 code: tag.parts.langtag ?? realLocale,
228 isRtl: output.direction === "RTL",
229 name: output.name[0] + (extraStringified ? ` ${extraStringified}` : ""),
230 nativeName: output.nativeName[0] ?? undefined,
231 };
232}
233
234/**
235 * Converts a language code to a TMDB-compatible format (ISO 639-1 with region)
236 * @param language The language code to convert
237 * @returns A TMDB-compatible language code (e.g., "en-US", "el-GR")
238 */
239export function getTmdbLanguageCode(language: string): string {
240 // Handle empty or undefined
241 if (!language) return "en-US";
242
243 // If it already has a region code (e.g., "en-US"), use it directly
244 if (language.includes("-")) return language;
245
246 // Handle special/custom languages by defaulting to English
247 if (language.length > 2 || Object.keys(extraLanguages).includes(language))
248 return "en-US";
249
250 // For standard language codes, find the appropriate region from the existing defaultLanguageCodes array
251 const defaultCode = defaultLanguageCodes.find((code) =>
252 code.startsWith(`${language}-`),
253 );
254
255 if (defaultCode) return defaultCode;
256
257 // If we can't find a good match, create a standard format like "fr-FR" from "fr"
258 if (language.length === 2) {
259 return `${language}-${language.toUpperCase()}`;
260 }
261
262 // Last resort fallback
263 return "en-US";
264}