Skip to content

Commit 844ac96

Browse files
committed
feat: change i18n configuration
1 parent f3bf7be commit 844ac96

19 files changed

Lines changed: 1191 additions & 7 deletions

app/composables/i18n.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { UseTimeAgoOptions } from '@vueuse/core'
2+
3+
const formatter = Intl.NumberFormat()
4+
5+
export function formattedNumber(num: number, useFormatter: Intl.NumberFormat = formatter) {
6+
return useFormatter.format(num)
7+
}
8+
9+
export function useHumanReadableNumber() {
10+
const { n, locale } = useI18n()
11+
12+
const fn = (num: number) => {
13+
return n(
14+
num,
15+
num < 10000 ? 'smallCounting' : num < 1000000 ? 'kiloCounting' : 'millionCounting',
16+
locale.value,
17+
)
18+
}
19+
20+
return {
21+
formatHumanReadableNumber: (num: MaybeRef<number>) => fn(unref(num)),
22+
formatNumber: (num: MaybeRef<number>) => n(unref(num), 'smallCounting', locale.value),
23+
formatPercentage: (num: MaybeRef<number>) => n(unref(num), 'percentage', locale.value),
24+
forSR: (num: MaybeRef<number>) => unref(num) > 10000,
25+
}
26+
}
27+
28+
export function useFormattedDateTime(
29+
value: MaybeRefOrGetter<string | number | Date | undefined | null>,
30+
options: Intl.DateTimeFormatOptions = { dateStyle: 'long', timeStyle: 'medium' },
31+
) {
32+
const { locale } = useI18n()
33+
const formatter = computed(() => Intl.DateTimeFormat(locale.value, options))
34+
return computed(() => {
35+
const v = toValue(value)
36+
return v ? formatter.value.format(new Date(v)) : ''
37+
})
38+
}
39+
40+
export function useTimeAgoOptions(short = false): UseTimeAgoOptions<false> {
41+
const { d, t, n: fnf, locale } = useI18n()
42+
const prefix = short ? 'short_' : ''
43+
44+
const fn = (n: number, past: boolean, key: string) => {
45+
return t(`time_ago_options.${prefix}${key}_${past ? 'past' : 'future'}`, n, {
46+
named: {
47+
v: fnf(n, 'smallCounting', locale.value),
48+
},
49+
})
50+
}
51+
52+
return {
53+
rounding: 'floor',
54+
showSecond: !short,
55+
updateInterval: short ? 60000 : 1000,
56+
messages: {
57+
justNow: t('time_ago_options.just_now'),
58+
// just return the value
59+
past: n => n,
60+
// just return the value
61+
future: n => n,
62+
second: (n, p) => fn(n, p, 'second'),
63+
minute: (n, p) => fn(n, p, 'minute'),
64+
hour: (n, p) => fn(n, p, 'hour'),
65+
day: (n, p) => fn(n, p, 'day'),
66+
week: (n, p) => fn(n, p, 'week'),
67+
month: (n, p) => fn(n, p, 'month'),
68+
year: (n, p) => fn(n, p, 'year'),
69+
invalid: '',
70+
},
71+
fullDateFormatter(date) {
72+
return d(date, short ? 'short' : 'long')
73+
},
74+
}
75+
}
76+
77+
export function useFileSizeFormatter() {
78+
const { locale } = useI18n()
79+
80+
const formatters = computed(
81+
() =>
82+
[
83+
Intl.NumberFormat(locale.value, {
84+
style: 'unit',
85+
unit: 'megabyte',
86+
unitDisplay: 'narrow',
87+
maximumFractionDigits: 0,
88+
}),
89+
Intl.NumberFormat(locale.value, {
90+
style: 'unit',
91+
unit: 'kilobyte',
92+
unitDisplay: 'narrow',
93+
maximumFractionDigits: 0,
94+
}),
95+
] as const,
96+
)
97+
98+
const megaByte = 1024 * 1024
99+
100+
function formatFileSize(size: number) {
101+
return size >= megaByte
102+
? formatters.value[0].format(size / megaByte)
103+
: formatters.value[1].format(size / 1024)
104+
}
105+
106+
return { formatFileSize }
107+
}

app/composables/useSettings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,27 @@ export interface AppSettings {
1414
includeTypesInInstall: boolean
1515
/** Accent color theme */
1616
accentColorId: AccentColorId | null
17+
/** Language code (e.g., "en-US") */
18+
language: string
1719
}
1820

1921
const DEFAULT_SETTINGS: AppSettings = {
2022
relativeDates: false,
2123
includeTypesInInstall: true,
2224
accentColorId: null,
25+
language: 'en-US',
2326
}
2427

2528
const STORAGE_KEY = 'npmx-settings'
2629

2730
// Shared settings instance (singleton per app)
2831
let settingsRef: RemovableRef<AppSettings> | null = null
2932

33+
export function getDefaultLanguage(languages: string[]) {
34+
if (import.meta.server) return 'en-US'
35+
return matchLanguages(languages, navigator.languages) || 'en-US'
36+
}
37+
3038
/**
3139
* Composable for managing application settings with localStorage persistence.
3240
* Settings are shared across all components that use this composable.

app/composables/vue.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { SchemaAugmentations } from '@unhead/schema'
2+
import type { ActiveHeadEntry, UseHeadInput, UseHeadOptions } from '@unhead/vue'
3+
import type { ComponentInternalInstance } from 'vue'
4+
import { onActivated, onDeactivated, ref } from 'vue'
5+
6+
export const isHydrated = ref(false)
7+
8+
export function onHydrated(cb: () => unknown) {
9+
watch(isHydrated, () => cb(), { immediate: isHydrated.value, once: true })
10+
}
11+
12+
/**
13+
* ### Whether the current component is running in the background
14+
*
15+
* for handling problems caused by the keepalive function
16+
*/
17+
export function useDeactivated() {
18+
const deactivated = ref(false)
19+
onActivated(() => (deactivated.value = false))
20+
onDeactivated(() => (deactivated.value = true))
21+
22+
return deactivated
23+
}
24+
25+
/**
26+
* ### When the component is restored from the background
27+
*
28+
* for handling problems caused by the keepalive function
29+
*
30+
* @param hook
31+
* @param target
32+
*/
33+
export function onReactivated(hook: () => void, target?: ComponentInternalInstance | null): void {
34+
const initial = ref(true)
35+
onActivated(() => {
36+
if (initial.value) return
37+
hook()
38+
}, target)
39+
onDeactivated(() => (initial.value = false))
40+
}
41+
42+
export function useHydratedHead<T extends SchemaAugmentations>(
43+
input: UseHeadInput<T>,
44+
options?: UseHeadOptions,
45+
): ActiveHeadEntry<UseHeadInput<T>> | void {
46+
if (input && typeof input === 'object' && !('value' in input)) {
47+
const title = 'title' in input ? input.title : undefined
48+
if (import.meta.server && title) {
49+
input.meta = input.meta || []
50+
if (Array.isArray(input.meta)) {
51+
input.meta.push({
52+
property: 'og:title',
53+
content: (typeof input.title === 'function' ? input.title() : input.title) as string,
54+
})
55+
}
56+
} else if (title) {
57+
;(input as any).title = () =>
58+
isHydrated.value ? (typeof title === 'function' ? title() : title) : ''
59+
}
60+
}
61+
return useHead(
62+
(() => {
63+
if (!isHydrated.value) return {}
64+
return toValue(input)
65+
}) as UseHeadInput<T>,
66+
options,
67+
)
68+
}

app/pages/settings.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,8 @@ useSeoMeta({
100100
<div class="px-2 py-1">
101101
<select
102102
id="language-select"
103-
:value="locale"
103+
v-model="settings.language"
104104
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus:outline-none focus:ring-2 focus:ring-fg/50 cursor-pointer"
105-
@change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)"
106105
>
107106
<option v-for="loc in availableLocales" :key="loc.code" :value="loc.code">
108107
{{ loc.name }}

app/plugins/hydration.client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default defineNuxtPlugin(nuxtApp => {
2+
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
3+
isHydrated.value = true
4+
})
5+
})

app/plugins/setup-i18n.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Locale } from '#i18n'
2+
3+
export default defineNuxtPlugin(async nuxt => {
4+
const t = nuxt.vueApp.config.globalProperties.$t
5+
const d = nuxt.vueApp.config.globalProperties.$d
6+
const n = nuxt.vueApp.config.globalProperties.$n
7+
8+
nuxt.vueApp.config.globalProperties.$t = wrapI18n(t)
9+
nuxt.vueApp.config.globalProperties.$d = wrapI18n(d)
10+
nuxt.vueApp.config.globalProperties.$n = wrapI18n(n)
11+
12+
if (import.meta.client) {
13+
const i18n = useNuxtApp().$i18n
14+
const { setLocale, locales } = i18n
15+
const { settings } = useSettings()
16+
const lang = computed(() => settings.value.language as Locale)
17+
18+
const supportLanguages = unref(locales).map(locale => locale.code)
19+
if (!supportLanguages.includes(lang.value))
20+
settings.value.language = getDefaultLanguage(supportLanguages)
21+
22+
if (lang.value !== i18n.locale.value) await setLocale(settings.value.language as Locale)
23+
24+
watch(
25+
[lang, isHydrated],
26+
() => {
27+
if (isHydrated.value && lang.value !== i18n.locale.value) setLocale(lang.value)
28+
},
29+
{ immediate: true },
30+
)
31+
}
32+
})

app/utils/i18n.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useI18n as useOriginalI18n } from 'vue-i18n'
2+
3+
export function useI18n() {
4+
const { t, d, n, ...rest } = useOriginalI18n()
5+
6+
return {
7+
...rest,
8+
t: wrapI18n(t),
9+
d: wrapI18n(d),
10+
n: wrapI18n(n),
11+
} satisfies ReturnType<typeof useOriginalI18n>
12+
}
13+
14+
export function wrapI18n<T extends (...args: any[]) => any>(t: T): T {
15+
return <T>((...args: any[]) => {
16+
return isHydrated.value ? t(...args) : ''
17+
})
18+
}

app/utils/language.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export function matchLanguages(
2+
languages: string[],
3+
acceptLanguages: readonly string[],
4+
): string | null {
5+
{
6+
// const lang = acceptLanguages.map(userLang => languages.find(lang => lang.startsWith(userLang))).filter(v => !!v)[0]
7+
// TODO: Support es-419, remove this code if we include spanish country variants
8+
const lang = acceptLanguages
9+
.map(userLang =>
10+
languages.find(currentLang => {
11+
if (currentLang === userLang) return currentLang
12+
13+
// Edge browser: case for ca-valencia
14+
if (currentLang === 'ca-valencia' && userLang === 'ca-Es-VALENCIA') return currentLang
15+
16+
if (userLang.startsWith('es-') && userLang !== 'es-ES' && currentLang === 'es-419')
17+
return currentLang
18+
19+
return currentLang.startsWith(userLang) ? currentLang : undefined
20+
}),
21+
)
22+
.find(v => !!v)
23+
if (lang) return lang
24+
}
25+
26+
const lang = acceptLanguages
27+
.map(userLang => {
28+
userLang = userLang.split('-')[0]!
29+
return languages.find(lang => lang.startsWith(userLang))
30+
})
31+
.find(v => !!v)
32+
if (lang) return lang
33+
34+
return null
35+
}

0 commit comments

Comments
 (0)