properly use i18n and enforce it

This commit is contained in:
Ulf Gebhardt 2023-11-24 00:40:25 +01:00
parent 5ab01326dc
commit 5985cd1fad
Signed by: ulfgebhardt
GPG Key ID: DA6B843E748679C9
19 changed files with 425 additions and 54 deletions

View File

@ -13,6 +13,7 @@
"plugin:promise/recommended",
"plugin:security/recommended",
"plugin:vue/vue3-recommended",
"plugin:@intlify/vue-i18n/recommended",
"plugin:storybook/recommended"
],
"parserOptions": {
@ -32,6 +33,9 @@
"import/resolver": {
"typescript": true,
"node": true
},
"vue-i18n": {
"localeDir": "./src/locales/*.json"
}
},
"rules": {

218
package-lock.json generated
View File

@ -35,6 +35,7 @@
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^2.0.0",
"@storybook/addon-essentials": "^7.5.3",
"@storybook/addon-interactions": "^7.5.3",
"@storybook/addon-links": "^7.5.3",
@ -3123,6 +3124,212 @@
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@intlify/eslint-plugin-vue-i18n/-/eslint-plugin-vue-i18n-2.0.0.tgz",
"integrity": "sha512-ECBD0TvQNa56XKyuM6FPIGAAl7MP6ODcgjBQJrzucNxcTb8fYTWmZ+xgBuvmvAtA0iE0D4Wp18UMild2N0bGyw==",
"dev": true,
"dependencies": {
"@eslint/eslintrc": "^1.2.0",
"@intlify/core-base": "^9.1.9",
"@intlify/message-compiler": "^9.1.9",
"debug": "^4.3.1",
"glob": "^8.0.0",
"ignore": "^5.0.5",
"is-language-code": "^3.1.0",
"js-yaml": "^4.0.0",
"json5": "^2.1.3",
"jsonc-eslint-parser": "^2.0.0",
"lodash": "^4.17.11",
"parse5": "^7.0.0",
"semver": "^7.3.4",
"vue-eslint-parser": "^9.0.0",
"yaml-eslint-parser": "^1.0.0"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"peerDependencies": {
"eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/@eslint/eslintrc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz",
"integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^9.4.0",
"globals": "^13.19.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/globals": {
"version": "13.23.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
"dev": true,
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/@intlify/message-compiler": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.7.1.tgz",
@ -16323,6 +16530,15 @@
"node": ">=8"
}
},
"node_modules/is-language-code": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-language-code/-/is-language-code-3.1.0.tgz",
"integrity": "sha512-zJdQ3QTeLye+iphMeK3wks+vXSRFKh68/Pnlw7aOfApFSEIOhYa8P9vwwa6QrImNNBMJTiL1PpYF0f4BxDuEgA==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.14.0"
}
},
"node_modules/is-map": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
@ -21872,8 +22088,6 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"entities": "^4.4.0"
},

View File

@ -84,6 +84,7 @@
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^2.0.0",
"@storybook/addon-essentials": "^7.5.3",
"@storybook/addon-interactions": "^7.5.3",
"@storybook/addon-links": "^7.5.3",

View File

@ -5,7 +5,7 @@ import type { PageContext, VikePageContext } from '#types/PageContext'
let app: ReturnType<typeof createApp>
async function render(pageContext: VikePageContext & PageContext) {
if (!app) {
app = createApp(pageContext)
app = createApp(pageContext).app
app.mount('#app')
} else {
app.changePage(pageContext)
@ -17,9 +17,11 @@ function onHydrationEnd() {
}
function onPageTransitionStart() {
// console.log('Page transition start')
// document.body.classList.add('page-transition')
}
function onPageTransitionEnd() {
// console.log('Page transition end')
// document.body.classList.remove('page-transition')
}
export const clientRouting = true

View File

@ -13,7 +13,9 @@ import type { App } from 'vue'
export const passToClient = ['pageProps', /* 'urlPathname', */ 'routeParams']
async function render(pageContext: PageContextServer & PageContext) {
const app = createApp(pageContext, false)
const { app, i18n } = createApp(pageContext, false)
const locale = i18n.global.locale.value
const appHtml = await renderToString(app)
@ -23,7 +25,7 @@ async function render(pageContext: PageContextServer & PageContext) {
const desc = (documentProps && documentProps.description) || META.DEFAULT_DESCRIPTION
const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="en">
<html lang="${locale}">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="${logoUrl}" />

View File

@ -61,7 +61,7 @@ function createApp(pageContext: VikePageContext & PageContext, isClient = true)
// Make pageContext available from any Vue component
setPageContext(app, pageContextReactive)
return app
return { app, i18n }
}
// Same as `Object.assign()` but with type inference

View File

@ -7,6 +7,7 @@ import en from '#src/locales/en.json'
export default createI18n({
legacy: false, // Vuetify does not support the legacy mode of vue-i18n
globalInjection: true,
locale: 'de',
fallbackLocale: 'en',
messages: { de, en },

View File

@ -1,5 +1,7 @@
<template>
<v-btn elevation="2" @click="state.count++">{{ $t('counter') }} {{ state.count }}</v-btn>
<v-btn elevation="2" @click="state.count++">{{
$t('app.inc.text', { count: state.count })
}}</v-btn>
</template>
<script lang="ts" setup>

View File

@ -1,16 +1,38 @@
<template>
<v-app-bar flat>
<LogoAvatar />
<v-container class="mx-auto d-flex align-center justify-center">
<VikeBtn href="/">Home</VikeBtn>
<VikeBtn href="/app">App</VikeBtn>
<VikeBtn href="/about">About</VikeBtn>
</v-container>
<v-row>
<v-col>
<LogoAvatar />
</v-col>
<v-col class="d-flex align-center justify-center grow">
<VikeBtn href="/">{{ $t('menu.home') }}</VikeBtn>
<VikeBtn href="/app">{{ $t('menu.app') }}</VikeBtn>
<VikeBtn href="/about">{{ $t('menu.about') }}</VikeBtn>
</v-col>
<v-col>
<v-switch
v-model="isEnabled"
class="d-flex justify-end mr-5"
:label="$t('language.german')"
color="success"
@update:model-value="onChange"
></v-switch>
</v-col>
</v-row>
</v-app-bar>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useLocale } from 'vuetify'
import VikeBtn from '#components/VikeBtn.vue'
import LogoAvatar from './LogoAvatar.vue'
const { current: locale } = useLocale()
const isEnabled = ref(locale.value === 'de')
const onChange = () => {
locale.value = isEnabled.value ? 'de' : 'en'
}
</script>

View File

@ -1,3 +1,53 @@
{
"counter": "Zähler"
"about": {
"h1": "Über",
"link1": "github.com",
"link2": "Webseite",
"text1": "Um mehr über diese Kochplatte zu erfahren kannst du dir den Quellcode auf {link} anschauen.",
"text2": "Du willst mehr erfahren? Besuche uns auf {link}."
},
"app": {
"inc": {
"h1": "Erhöhe den Zähler",
"menu": "Erhöhe",
"text": "Erhöhe: {count}"
},
"reset": {
"h1": "Den Zähler zurücksetzen",
"menu": "Zurücksetzen",
"text": "Zurücksetzent: {count}"
},
"value": {
"h1": "Der Zähler",
"menu": "Wert",
"text": "Der aktuelle Wert des Zählers lautet: {count}"
}
},
"error": {
"404": {
"h1": "404 Seite nicht gefunden",
"text": "Diese Seite konnte nicht gefunden werden."
},
"500": {
"h1": "500 Interner Fehler",
"text": "Irgendetwas ist schief gegangen."
}
},
"home": {
"greet1": "Hochachtungsvoll",
"greet2": "Dein IT Team For Change",
"h1": "IT4C Frontend Kochplatte",
"text1": "Willkommen zu diesem minimalen Frontendstarter.",
"text2": "Es handelt sich um ein einfaches Beispiel um zu zeigen was so geht - nichts besonderes.",
"text3": "In dem Anwendungsbereich wirst du ein Zählerbeispiel finden, welches den Browserspeicher verwendet.",
"text4": "Fröhliches Programmieren"
},
"language": {
"german": "Deutsch"
},
"menu": {
"about": "Über",
"app": "Anwendung",
"home": "Daheim"
}
}

View File

@ -1,3 +1,53 @@
{
"counter": "Counter"
"about": {
"h1": "About",
"link1": "github.com",
"link2": "website",
"text1": "To find out more about this boilerplate you can look at the sources on {link}.",
"text2": "Want to get in touch? Find out how on our {link}."
},
"app": {
"inc": {
"h1": "Increase the Counter",
"menu": "Increase",
"text": "Increase: {count}"
},
"reset": {
"h1": "Reset the Counter",
"menu": "Reset",
"text": "Reset: {count}"
},
"value": {
"h1": "The Counter",
"menu": "Value",
"text": "The current value of the counter is: {count}"
}
},
"error": {
"404": {
"h1": "404 Page Not Found",
"text": "This page could not be found."
},
"500": {
"h1": "500 Internal Error",
"text": "Something went wrong."
}
},
"home": {
"greet1": "Sincerly",
"greet2": "Your IT Team For Change",
"h1": "IT4C Frontend Boilerplate",
"text1": "Welcome to this minimal starter for frontends.",
"text2": "This is just a basic example to demonstrate things - nothing fancy.",
"text3": "In the App Section you will find a counter example utilizing the local storage.",
"text4": "Happy Coding"
},
"language": {
"german": "German"
},
"menu": {
"about": "About",
"app": "App",
"home": "Home"
}
}

View File

@ -1,11 +1,11 @@
<template>
<div v-if="is404">
<h1>404 Page Not Found</h1>
<p>This page could not be found.</p>
<h1>{{ $t('error.404.h1') }}</h1>
<p>{{ $t('error.404.text') }}</p>
</div>
<div v-else>
<h1>500 Internal Error</h1>
<p>Something went wrong.</p>
<h1>{{ $t('error.500.h1') }}</h1>
<p>{{ $t('error.500.text') }}</p>
</div>
</template>

View File

@ -1,17 +1,19 @@
<template>
<DefaultLayout>
<h1>About</h1>
<p>
To find out more about this boilerplate you can look at the sources:
<a href="https://github.com/IT4Change/boilerplate-frontend/" target="_blank"
>github.com/IT4Change/boilerplate-frontend</a
>.
</p>
<h1>{{ $t('about.h1') }}</h1>
<i18n-t scope="global" keypath="about.text1" tag="p">
<template #link>
<a href="https://github.com/IT4Change/boilerplate-frontend/" target="_blank">
{{ $t('about.link1') }}
</a>
</template>
</i18n-t>
<br />
<p>
Want to get in touch? Find out how on our
<a href="https://it4c.dev" target="_blank">website</a>
</p>
<i18n-t scope="global" keypath="about.text2" tag="p">
<template #link>
<a href="https://it4c.dev" target="_blank">{{ $t('about.link2') }}</a>
</template>
</i18n-t>
</DefaultLayout>
</template>

View File

@ -3,11 +3,9 @@ import type { PageContextBuiltInServer } from 'vike/types'
export { onBeforeRender }
async function onBeforeRender(pageContext: PageContextBuiltInServer) {
const { page } = pageContext.routeParams
const pageProps = { page }
return {
pageContext: {
pageProps,
pageProps: pageContext.routeParams,
},
}
}

View File

@ -2,34 +2,54 @@
<DefaultLayout>
<template #sidemenu>
<v-list rounded>
<v-list-item link title="Value" :active="page === null" href="/app"></v-list-item>
<v-list-item link title="Increase" :active="page === 'inc'" href="/app/inc"></v-list-item>
<v-list-item
link
:title="$t('app.value.menu')"
:active="page === null"
href="/app"
></v-list-item>
<v-list-item
link
:title="$t('app.inc.menu')"
:active="page === 'inc'"
href="/app/inc"
></v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item link title="Reset" :active="page === 'reset'" href="/app/reset"></v-list-item>
<v-list-item
link
:title="$t('app.reset.menu')"
:active="page === 'reset'"
href="/app/reset"
></v-list-item>
</v-list>
</template>
<template #default>
<div v-if="page === null">
<h1>The Counter</h1>
<p>
The current value of the counter is:
<ClientOnly
><b>{{ counter.count }}</b></ClientOnly
>
</p>
<h1>{{ $t('app.value.h1') }}</h1>
<i18n-t scope="global" keypath="app.value.text" tag="p">
<template #count>
<ClientOnly>
<b>{{ counter.count }}</b>
</ClientOnly>
</template>
</i18n-t>
</div>
<div v-else-if="page === 'inc'">
<h1>Increase the Counter</h1>
<h1>{{ $t('app.inc.h1') }}</h1>
<ClientOnly>
<v-btn elevation="2" @click="counter.increment()">{{ counter.count }}</v-btn>
<v-btn elevation="2" @click="counter.increment()">{{
$t('app.inc.text', { count: counter.count })
}}</v-btn>
</ClientOnly>
</div>
<div v-else-if="page === 'reset'">
<h1>Reset the Counter</h1>
<h1>{{ $t('app.reset.h1') }}</h1>
<ClientOnly>
<v-btn elevation="2" @click="counter.reset()">{{ counter.count }}</v-btn>
<v-btn elevation="2" @click="counter.reset()">{{
$t('app.reset.text', { count: counter.count })
}}</v-btn>
</ClientOnly>
</div>
</template>

View File

@ -1,16 +1,16 @@
<template>
<DefaultLayout>
<h1>IT4C Frontend Boilerplate</h1>
<p>Welcome to this minimal starter for frontends.</p>
<h1>{{ $t('home.h1') }}</h1>
<p>{{ $t('home.text1') }}</p>
<br />
<p>This is just a basic example to demonstrate things - nothing fancy.</p>
<p>{{ $t('home.text2') }}</p>
<br />
<p>In the App Section you will find a counter example utilizing the local storage.</p>
<p>{{ $t('home.text3') }}</p>
<br />
<p>Happy Coding <v-icon icon="mdi-heart" color="red" /></p>
<p>{{ $t('home.text4') }} <v-icon icon="mdi-heart" color="red" /></p>
<br />
<p>Sincerly</p>
<p>Your IT Team For Change</p>
<p>{{ $t('home.greet1') }}</p>
<p>{{ $t('home.greet2') }}</p>
</DefaultLayout>
</template>

View File

@ -1,3 +1,4 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<header>
<div class="storybook-header">

View File

@ -1,3 +1,4 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<article>
<example-header

View File

@ -14,6 +14,7 @@ const config: UserConfig = {
!isStorybook() && vike(), // SSR only when storybook is not running
vueI18n({
ssr: true,
include: path.resolve(__dirname, './src/locales/**'),
}),
],
build: {