language.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. export const LOCALES = [
  2. "en",
  3. "zh",
  4. "zht",
  5. "ko",
  6. "de",
  7. "es",
  8. "fr",
  9. "it",
  10. "da",
  11. "ja",
  12. "pl",
  13. "ru",
  14. "uk",
  15. "ar",
  16. "no",
  17. "br",
  18. "th",
  19. "tr",
  20. ] as const
  21. export type Locale = (typeof LOCALES)[number]
  22. export const LOCALE_COOKIE = "oc_locale" as const
  23. export const LOCALE_HEADER = "x-opencode-locale" as const
  24. function fix(pathname: string) {
  25. if (pathname.startsWith("/")) return pathname
  26. return `/${pathname}`
  27. }
  28. const LABEL = {
  29. en: "English",
  30. zh: "简体中文",
  31. zht: "繁體中文",
  32. ko: "한국어",
  33. de: "Deutsch",
  34. es: "Español",
  35. fr: "Français",
  36. it: "Italiano",
  37. da: "Dansk",
  38. ja: "日本語",
  39. pl: "Polski",
  40. ru: "Русский",
  41. uk: "Українська",
  42. ar: "العربية",
  43. no: "Norsk",
  44. br: "Português (Brasil)",
  45. th: "ไทย",
  46. tr: "Türkçe",
  47. } satisfies Record<Locale, string>
  48. const TAG = {
  49. en: "en",
  50. zh: "zh-Hans",
  51. zht: "zh-Hant",
  52. ko: "ko",
  53. de: "de",
  54. es: "es",
  55. fr: "fr",
  56. it: "it",
  57. da: "da",
  58. ja: "ja",
  59. pl: "pl",
  60. ru: "ru",
  61. uk: "uk",
  62. ar: "ar",
  63. no: "no",
  64. br: "pt-BR",
  65. th: "th",
  66. tr: "tr",
  67. } satisfies Record<Locale, string>
  68. const DOCS = {
  69. en: "root",
  70. zh: "zh-cn",
  71. zht: "zh-tw",
  72. ko: "ko",
  73. de: "de",
  74. es: "es",
  75. fr: "fr",
  76. it: "it",
  77. da: "da",
  78. ja: "ja",
  79. pl: "pl",
  80. ru: "ru",
  81. uk: "uk",
  82. ar: "ar",
  83. no: "nb",
  84. br: "pt-br",
  85. th: "th",
  86. tr: "tr",
  87. } satisfies Record<Locale, string>
  88. const DOCS_SEGMENT = new Set([
  89. "ar",
  90. "bs",
  91. "da",
  92. "de",
  93. "es",
  94. "fr",
  95. "it",
  96. "ja",
  97. "ko",
  98. "nb",
  99. "pl",
  100. "pt-br",
  101. "ru",
  102. "th",
  103. "tr",
  104. "uk",
  105. "zh-cn",
  106. "zh-tw",
  107. ])
  108. const DOCS_LOCALE = {
  109. ar: "ar",
  110. da: "da",
  111. de: "de",
  112. en: "en",
  113. es: "es",
  114. fr: "fr",
  115. it: "it",
  116. ja: "ja",
  117. ko: "ko",
  118. nb: "no",
  119. "pt-br": "br",
  120. root: "en",
  121. ru: "ru",
  122. th: "th",
  123. tr: "tr",
  124. uk: "uk",
  125. "zh-cn": "zh",
  126. "zh-tw": "zht",
  127. } as const satisfies Record<string, Locale>
  128. function suffix(pathname: string) {
  129. const index = pathname.search(/[?#]/)
  130. if (index === -1) {
  131. return {
  132. path: fix(pathname),
  133. suffix: "",
  134. }
  135. }
  136. return {
  137. path: fix(pathname.slice(0, index)),
  138. suffix: pathname.slice(index),
  139. }
  140. }
  141. export function docs(locale: Locale, pathname: string) {
  142. const value = DOCS[locale]
  143. const next = suffix(pathname)
  144. if (next.path !== "/docs" && next.path !== "/docs/" && !next.path.startsWith("/docs/")) {
  145. return `${next.path}${next.suffix}`
  146. }
  147. if (value === "root") {
  148. if (next.path === "/docs/en") return `/docs${next.suffix}`
  149. if (next.path === "/docs/en/") return `/docs/${next.suffix}`
  150. if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
  151. return `${next.path}${next.suffix}`
  152. }
  153. if (next.path === "/docs") return `/docs/${value}${next.suffix}`
  154. if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
  155. const head = next.path.slice("/docs/".length).split("/")[0] ?? ""
  156. if (!head) return `/docs/${value}/${next.suffix}`
  157. if (DOCS_SEGMENT.has(head)) return `${next.path}${next.suffix}`
  158. if (head.startsWith("_")) return `${next.path}${next.suffix}`
  159. if (head.includes(".")) return `${next.path}${next.suffix}`
  160. return `/docs/${value}${next.path.slice("/docs".length)}${next.suffix}`
  161. }
  162. export function parseLocale(value: unknown): Locale | null {
  163. if (typeof value !== "string") return null
  164. if ((LOCALES as readonly string[]).includes(value)) return value as Locale
  165. return null
  166. }
  167. export function fromPathname(pathname: string) {
  168. return parseLocale(fix(pathname).split("/")[1])
  169. }
  170. export function fromDocsPathname(pathname: string) {
  171. const next = fix(pathname)
  172. const value = next.split("/")[2]?.toLowerCase()
  173. if (!value) return null
  174. if (!next.startsWith("/docs/")) return null
  175. if (!(value in DOCS_LOCALE)) return null
  176. return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
  177. }
  178. export function strip(pathname: string) {
  179. const locale = fromPathname(pathname)
  180. if (!locale) return fix(pathname)
  181. const next = fix(pathname).slice(locale.length + 1)
  182. if (!next) return "/"
  183. if (next.startsWith("/")) return next
  184. return `/${next}`
  185. }
  186. export function route(locale: Locale, pathname: string) {
  187. const next = strip(pathname)
  188. if (next.startsWith("/docs")) return docs(locale, next)
  189. if (next.startsWith("/auth")) return next
  190. if (next.startsWith("/workspace")) return next
  191. if (locale === "en") return next
  192. if (next === "/") return `/${locale}`
  193. return `/${locale}${next}`
  194. }
  195. export function label(locale: Locale) {
  196. return LABEL[locale]
  197. }
  198. export function tag(locale: Locale) {
  199. return TAG[locale]
  200. }
  201. export function dir(locale: Locale) {
  202. if (locale === "ar") return "rtl"
  203. return "ltr"
  204. }
  205. function match(input: string): Locale | null {
  206. const value = input.trim().toLowerCase()
  207. if (!value) return null
  208. if (value.startsWith("zh")) {
  209. if (value.includes("hant") || value.includes("-tw") || value.includes("-hk") || value.includes("-mo")) return "zht"
  210. return "zh"
  211. }
  212. if (value.startsWith("ko")) return "ko"
  213. if (value.startsWith("de")) return "de"
  214. if (value.startsWith("es")) return "es"
  215. if (value.startsWith("fr")) return "fr"
  216. if (value.startsWith("it")) return "it"
  217. if (value.startsWith("da")) return "da"
  218. if (value.startsWith("ja")) return "ja"
  219. if (value.startsWith("pl")) return "pl"
  220. if (value.startsWith("ru")) return "ru"
  221. if (value.startsWith("uk")) return "uk"
  222. if (value.startsWith("ar")) return "ar"
  223. if (value.startsWith("tr")) return "tr"
  224. if (value.startsWith("th")) return "th"
  225. if (value.startsWith("pt")) return "br"
  226. if (value.startsWith("no") || value.startsWith("nb") || value.startsWith("nn")) return "no"
  227. if (value.startsWith("en")) return "en"
  228. return null
  229. }
  230. export function detectFromLanguages(languages: readonly string[]) {
  231. for (const language of languages) {
  232. const locale = match(language)
  233. if (locale) return locale
  234. }
  235. return "en" satisfies Locale
  236. }
  237. export function detectFromAcceptLanguage(header: string | null) {
  238. if (!header) return "en" satisfies Locale
  239. const items = header
  240. .split(",")
  241. .map((raw) => raw.trim())
  242. .filter(Boolean)
  243. .map((raw) => {
  244. const parts = raw.split(";").map((x) => x.trim())
  245. const lang = parts[0] ?? ""
  246. const q = parts
  247. .slice(1)
  248. .find((x) => x.startsWith("q="))
  249. ?.slice(2)
  250. return {
  251. lang,
  252. q: q ? Number.parseFloat(q) : 1,
  253. }
  254. })
  255. .sort((a, b) => b.q - a.q)
  256. for (const item of items) {
  257. if (!item.lang || item.lang === "*") continue
  258. const locale = match(item.lang)
  259. if (locale) return locale
  260. }
  261. return "en" satisfies Locale
  262. }
  263. export function localeFromCookieHeader(header: string | null) {
  264. if (!header) return null
  265. const raw = header
  266. .split(";")
  267. .map((x) => x.trim())
  268. .find((x) => x.startsWith(`${LOCALE_COOKIE}=`))
  269. ?.slice(`${LOCALE_COOKIE}=`.length)
  270. if (!raw) return null
  271. return parseLocale(decodeURIComponent(raw))
  272. }
  273. export function localeFromRequest(request: Request) {
  274. const fromHeader = parseLocale(request.headers.get(LOCALE_HEADER))
  275. if (fromHeader) return fromHeader
  276. const fromPath = fromPathname(new URL(request.url).pathname)
  277. if (fromPath) return fromPath
  278. const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
  279. if (fromDocsPath) return fromDocsPath
  280. return (
  281. localeFromCookieHeader(request.headers.get("cookie")) ??
  282. detectFromAcceptLanguage(request.headers.get("accept-language"))
  283. )
  284. }
  285. export function cookie(locale: Locale) {
  286. return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
  287. }
  288. export function clearCookie() {
  289. return `${LOCALE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
  290. }