Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add internationalization support #738

Merged
merged 5 commits into from
Jun 11, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions composables/compact-number.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFormatter, type Formatter } from '@vintl/compact-number'
import { IntlController } from '@vintl/vintl/controller'

const formatters = new WeakMap<IntlController<any>, Formatter>()

export function useCompactNumber(): Formatter {
const vintl = useVIntl()

let formatter = formatters.get(vintl)

if (formatter == null) {
const formatterRef = computed(() => createFormatter(vintl.intl))
formatter = (value, options) => formatterRef.value(value, options)
formatters.set(vintl, formatter)
}

return formatter
}
18 changes: 18 additions & 0 deletions composables/how-ago.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFormatter, type Formatter } from '@vintl/how-ago'
import { IntlController } from '@vintl/vintl/controller'

const formatters = new WeakMap<IntlController<any>, Formatter>()

export function useRelativeTime(): Formatter {
const vintl = useVIntl()

let formatter = formatters.get(vintl)

if (formatter == null) {
const formatterRef = computed(() => createFormatter(vintl.intl))
formatter = (value, options) => formatterRef.value(value, options)
formatters.set(vintl, formatter)
}

return formatter
}
7 changes: 7 additions & 0 deletions crowdin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
project_id: 518556
preserve_hierarchy: true

files:
- source: /locales/en-US/*
dest: /%original_file_name%
translation: /locales/%locale%/%original_file_name%
17 changes: 17 additions & 0 deletions locales/en-US/index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"frog": {
"message": "You've been frogged! 🐸"
},
"frog.altText": {
"message": "A photorealistic painting of a frog labyrinth"
},
"frog.froggedPeople": {
"message": "{count, plural, one {{count} more person} other {{count} more people}} were also frogged!"
},
"frog.sinceOpened": {
"message": "This page was opened {ago}"
},
"frog.title": {
"message": "Frog"
}
}
3 changes: 3 additions & 0 deletions locales/en-US/languages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"en-US": "American English"
}
6 changes: 6 additions & 0 deletions locales/en-US/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"displayName": {
"description": "The name of the language in dialect form (e.g. Français canadien for French spoken in Canada, not French (Canada))",
"message": "American English"
}
}
91 changes: 90 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { promises as fs } from 'fs'
import { pathToFileURL } from 'node:url'
import svgLoader from 'vite-svg-loader'
import { resolve } from 'pathe'
import { resolve, basename } from 'pathe'
import { defineNuxtConfig } from 'nuxt/config'
import { $fetch } from 'ofetch'
import { globIterate } from 'glob'
import { match as matchLocale } from '@formatjs/intl-localematcher'

const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/'
Expand Down Expand Up @@ -39,6 +42,14 @@ const meta = {
'twitter:site': '@modrinth',
}

/**
* Tags of locales that are auto-discovered besides the default locale.
*
* Preferably only the locales that reach a certain threshold of complete
* translations would be included in this array.
*/
const ENABLED_LOCALES: string[] = []

export default defineNuxtConfig({
app: {
head: {
Expand Down Expand Up @@ -176,6 +187,75 @@ export default defineNuxtConfig({
})
)
},
async 'vintl:extendOptions'(opts) {
opts.locales ??= []

const resolveCompactNumberDataImport = await (async () => {
const compactNumberLocales: string[] = []
const resolvedImports = new Map<string, string>()

for await (const localeFile of globIterate(
'node_modules/@vintl/compact-number/dist/locale-data/*.mjs',
{ ignore: '**/*.data.mjs' }
)) {
const tag = basename(localeFile, '.mjs')
compactNumberLocales.push(tag)
resolvedImports.set(tag, String(pathToFileURL(resolve(localeFile))))
}

function resolveImport(tag: string) {
const matchedTag = matchLocale([tag], compactNumberLocales, 'en-x-placeholder')
return matchedTag === 'en-x-placeholder'
? undefined
: `@vintl/compact-number/locale-data/${matchedTag}`
}

return resolveImport
})()

for await (const localeDir of globIterate('locales/*/', { posix: true })) {
const tag = basename(localeDir)
if (!ENABLED_LOCALES.includes(tag) && opts.defaultLocale !== tag) continue

const locale =
opts.locales.find((locale) => locale.tag === tag) ??
opts.locales[opts.locales.push({ tag }) - 1]

for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
const fileName = basename(localeFile)
if (fileName === 'index.json') {
if (locale.file == null) {
locale.file = {
from: `./${localeFile}`,
format: 'crowdin',
}
} else {
;(locale.files ??= []).push({
from: `./${localeFile}`,
format: 'crowdin',
})
}
} else if (fileName === 'meta.json') {
/** @type {Record<string, { message: string }>} */
const meta = await fs.readFile(localeFile, 'utf8').then((date) => JSON.parse(date))
locale.meta ??= {}
for (const key in meta) {
locale.meta[key] = meta[key].message
}
} else {
;(locale.resources ??= {})[fileName] = `./${localeFile}`
}
}

const cnDataImport = resolveCompactNumberDataImport(tag)
if (cnDataImport != null) {
;(locale.additionalImports ??= []).push({
from: cnDataImport,
resolve: false,
})
}
}
},
},
runtimeConfig: {
apiBaseUrl: process.env.BASE_URL ?? getApiUrl(),
Expand All @@ -202,6 +282,15 @@ export default defineNuxtConfig({
},
},
},
modules: ['@vintl/nuxt'],
vintl: {
defaultLocale: 'en-US',
storage: 'cookie',
parserless: 'only-prod',
},
nitro: {
moduleSideEffects: ['@vintl/compact-number/locale-data'],
},
})

function getApiUrl() {
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,25 @@
"postinstall": "nuxi prepare",
"lint:js": "eslint . --ext .js,.vue,.ts",
"lint": "npm run lint:js && prettier --check .",
"fix": "eslint . --fix --ext .js,.vue,.ts && prettier --write ."
"fix": "eslint . --fix --ext .js,.vue,.ts && prettier --write .",
"intl:extract": "formatjs extract \"{,components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file locales/en-US/index.json --format crowdin"
},
"devDependencies": {
"@formatjs/cli": "^6.1.2",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@types/node": "^20.1.0",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"@vintl/compact-number": "^2.0.4",
"@vintl/how-ago": "^2.0.1",
"@vintl/nuxt": "^1.2.2",
"eslint": "^8.41.0",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.14.1",
"glob": "^10.2.7",
"nuxt": "^3.5.3",
"prettier": "^2.8.8",
"sass": "^1.58.0",
Expand Down
4 changes: 2 additions & 2 deletions pages/[type]/[id]/gallery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop=""
@click.stop
/>

<div class="floating" @click.stop="">
<div class="floating" @click.stop>
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
Expand Down
60 changes: 54 additions & 6 deletions pages/frog.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
<script setup lang="ts">
const vintl = useVIntl()
const { formatMessage } = vintl
const messages = defineMessages({
frogTitle: {
id: 'frog.title',
defaultMessage: 'Frog',
},
frogDescription: {
id: 'frog',
defaultMessage: "You've been frogged! 🐸",
},
frogAltText: {
id: 'frog.altText',
defaultMessage: 'A photorealistic painting of a frog labyrinth',
},
frogSinceOpened: {
id: 'frog.sinceOpened',
defaultMessage: 'This page was opened {ago}',
},
frogFroggedPeople: {
id: 'frog.froggedPeople',
defaultMessage:
'{count, plural, one {{count} more person} other {{count} more people}} were also frogged!',
},
})
const formatCompactNumber = useCompactNumber()
const formatRelativeTime = useRelativeTime()
const pageOpen = useState('frogPageOpen', () => Date.now())
const peopleFrogged = useState('frogPeopleFrogged', () => Math.round(Math.random() * 100_000_000))
const peopleFroggedCount = computed(() => formatCompactNumber(peopleFrogged.value))
let interval: ReturnType<typeof setTimeout>
const formattedOpenedCounter = ref(formatRelativeTime(Date.now()))
onMounted(() => {
interval = setInterval(() => {
formattedOpenedCounter.value = formatRelativeTime(pageOpen.value)
}, 1000)
})
onUnmounted(() => clearInterval(interval))
</script>

<template>
<div class="card">
<h1>Frog</h1>
<p>You've been frogged! 🐸</p>
<img
src="https://cdn.modrinth.com/frog.png"
alt="a photorealistic painting of a frog labyrinth"
/>
<h1>{{ formatMessage(messages.frogTitle) }}</h1>
<p>{{ formatMessage(messages.frogDescription) }}</p>
<img src="https://cdn.modrinth.com/frog.png" :alt="formatMessage(messages.frogAltText)" />
<p>{{ formatMessage(messages.frogSinceOpened, { ago: formattedOpenedCounter }) }}</p>
<p>{{ formatMessage(messages.frogFroggedPeople, { count: peopleFroggedCount }) }}</p>
</div>
</template>

Expand Down
Loading