Skip to content
Open
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
225 changes: 225 additions & 0 deletions docs/contribute/i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
---
navTitle: Internationalization (i18n)
meta:
description: Guide for implementing internationalization support in FlowFuse frontend and backend components.
tags:
- flowFuse
- i18n
- internationalization
- contributing
- translation
- localization
---

# Internationalization (i18n) Setup

This FlowFuse project now includes internationalization support using:

- **Backend**: `fastify-i18n` with `node-polyglot`
- **Frontend**: `vue-i18n` for Vue 3

## Current Implementation

### Backend Configuration

The backend i18n is configured in `forge/forge.js` and uses translation files in the `locales/` directory:

```
locales/
en/
common.json
```

### Frontend Configuration

The frontend i18n is configured in `frontend/src/i18n.js` and uses translation files in `frontend/src/locales/`:

```
frontend/src/locales/
en.json
```

### Updated Pages

The following pages have been prepared for translation:

- **Sign Up page** (`frontend/src/pages/account/Create.vue`)
- **Sign In page** (`frontend/src/pages/Login.vue`)

All hardcoded text strings have been replaced with translation keys using both `$t()` function and the newer `data-i18n` attribute approach.

## Usage Examples

### Method 1: data-i18n Attribute (Recommended)

The preferred approach for static text uses the `data-i18n` attribute with the `v-i18n` directive:

```js
<template>
<label v-i18n data-i18n="auth.username">Username / E-Mail</label>
<button v-i18n data-i18n="auth.login">Login</button>
<span v-i18n data-i18n="auth.errors.loginFailed">Login failed</span>
</template>
```

**Benefits:**
- Cleaner templates without `{{ }}` interpolation
- Better performance with directive-based updates
- SEO-friendly with fallback text visible in HTML
- Automatic updates when locale changes
- Falls back to original text if translation is missing

### Method 2: $t() Function (For Dynamic Content)

Use `$t()` for dynamic assignments, computed properties, and JavaScript code:

```js
<template>
<!-- Dynamic error messages -->
<span class="error">{{ errors.username }}</span>
</template>

<script>
export default {
methods: {
validateForm() {
// Dynamic assignment in JavaScript
this.errors.username = this.$t('auth.errors.usernameRequired')
}
},
computed: {
// Computed properties
submitButtonText() {
return this.isLoading ? this.$t('auth.loading') : this.$t('auth.submit')
}
}
}
</script>
```

### Method 3: Composition API Helper

For composition API contexts, use the provided helper:

```js
<script setup>
import { useTranslation } from '@/utils/i18n-helpers'

const { t } = useTranslation()

// Use in reactive data
const errorMessage = ref('')
errorMessage.value = t('auth.errors.loginFailed')
</script>
```

## Adding New Languages (Future)

To add a new language (e.g., Spanish):

### Backend

1. Create `locales/es/common.json` with Spanish translations
2. Update the backend configuration in `forge/forge.js`:
```js
await server.register(require('fastify-i18n'), {
locales: ['en', 'es'],
defaultLocale: 'en',
// ...
})
```

### Frontend

1. Create `frontend/src/locales/es.json` with Spanish translations
2. Update `frontend/src/i18n.js`:
```js
import en from './locales/en.json'
import es from './locales/es.json'

const messages = { en, es }
```

3. Add language switcher component to change locale:
```js
this.$i18n.locale = 'es'
```

## Translation Keys Structure

Current structure follows this pattern:

```json
{
"auth": {
"login": "Login",
"signup": "Sign Up",
"errors": {
"requiredField": "Required field",
"loginFailed": "Login failed"
}
}
}
```

This provides a logical grouping of related translations.

## Technical Implementation

### Vue Directive Implementation

The `data-i18n` functionality is implemented via a custom Vue directive located at `frontend/src/directives/i18n.js`:

```js
export default {
name: 'i18n',
mounted(el, binding, vnode) {
// Automatically translates elements with data-i18n attributes
const translationKey = el.getAttribute('data-i18n')
if (translationKey) {
const translatedText = $t(translationKey)
if (translatedText && translatedText !== translationKey) {
el.textContent = translatedText
}
}
},
updated(el, binding, vnode) {
// Re-applies translations when locale changes
}
}
```

### Registration

The directive is globally registered in `frontend/src/main.js`:

```js
import i18nDirective from './directives/i18n.js'

const app = createApp(App)
app.directive('i18n', i18nDirective)
```

### Helper Utilities

Additional utilities are provided in `frontend/src/utils/i18n-helpers.js`:

- `useTranslation()`: For composition API contexts
- `translateElements()`: For dynamic content translation

### Migration Guidelines

When adding i18n support to new components:

1. **For static text**: Use `v-i18n data-i18n="key">Fallback Text</element>`
2. **For dynamic text**: Use `$t('key')` in JavaScript/computed properties
3. **For form labels**: Consider both the label text and input placeholder
4. **For error messages**: Usually dynamic, so use `$t()` in JavaScript

### Best Practices

- **Always provide fallback text** in the element content
- **Use descriptive translation keys** following the namespace pattern
- **Group related translations** under logical parent keys
- **Test with missing translations** to ensure fallbacks work
- **Consider pluralization** for count-dependent text (future enhancement)
21 changes: 21 additions & 0 deletions forge/forge.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,27 @@ module.exports = async (options = {}) => {
}
})

// I18n : internationalization
const fs = require('fs')
const path = require('path')
const localesDir = path.join(__dirname, '..', 'locales')
const messages = {}

// Load locale files
const locales = ['en']
for (const locale of locales) {
const localePath = path.join(localesDir, locale, 'common.json')
if (fs.existsSync(localePath)) {
messages[locale] = JSON.parse(fs.readFileSync(localePath, 'utf8'))
}
}

await server.register(require('fastify-i18n'), {
messages,
fallbackLocale: 'en',
objectNotation: true
})

// Routes : the HTTP routes
await server.register(routes, { logLevel: server.config.logging.http })
// Post Office : handles email
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/directives/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getCurrentInstance } from 'vue'

export default {
name: 'i18n',
mounted (el, binding, vnode) {
const instance = getCurrentInstance()
const { $t } = instance.appContext.app.config.globalProperties

// Get translation key from data-i18n attribute
const translationKey = el.getAttribute('data-i18n')

if (translationKey) {
// Store original content as fallback
if (!el.dataset.originalContent) {
el.dataset.originalContent = el.textContent
}

// Apply translation
const translatedText = $t(translationKey)

// Only update if translation exists and is different from key
if (translatedText && translatedText !== translationKey) {
el.textContent = translatedText
}
}
},

updated (el, binding, vnode) {
// Re-apply translation on updates (e.g., locale changes)
const instance = getCurrentInstance()
const { $t } = instance.appContext.app.config.globalProperties

const translationKey = el.getAttribute('data-i18n')

if (translationKey) {
const translatedText = $t(translationKey)

if (translatedText && translatedText !== translationKey) {
el.textContent = translatedText
} else if (el.dataset.originalContent) {
// Fallback to original content if translation not found
el.textContent = el.dataset.originalContent
}
}
}
}
17 changes: 17 additions & 0 deletions frontend/src/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'

import en from './locales/en.json'

const messages = {
en
}

const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages,
legacy: false,
globalInjection: true
})

export default i18n
49 changes: 49 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"auth": {
"login": "Login",
"signup": "Sign Up",
"username": "Username / E-Mail",
"password": "Password",
"confirmPassword": "Confirm Password",
"fullName": "Full Name",
"emailAddress": "E-Mail Address",
"forgotPassword": "Forgot your password?",
"alreadyRegistered": "Already registered?",
"loginHere": "Log in here",
"signInWithGoogle": "Sign In with Google",
"loggingIn": "Logging in...",
"continue": "Continue",
"securityCodePrompt": "Enter the 6-digit security code from your authenticator app",
"errors": {
"loginFailed": "Login failed",
"tooManyAttempts": "Too many login attempts. Try again later.",
"tooManyRegistrationAttempts": "Too many attempts. Try again later.",
"requiredField": "Required field",
"tooLong": "Too long",
"usernameRequired": "Username is required",
"emailRequired": "Email is required",
"passwordRequired": "Password is required",
"invalidEmail": "Enter a valid email address",
"passwordTooShort": "Password must be 8 characters or more",
"passwordTooLong": "Password too long",
"passwordMatchesUsername": "Password must not match username",
"passwordMatchesEmail": "Password must not match email",
"passwordMatchesName": "Password must not match name",
"passwordTooWeak": "Password needs to be more complex",
"passwordsDoNotMatch": "Passwords do not match",
"userRegistrationDisabled": "User registration is not enabled",
"unexpectedError": "An unexpected error occurred. Please try again later or contact support.",
"usernameInvalidChars": "Must only contain a-z A-Z 0-9 - _",
"nameCannotBeUrl": "Names can not be URLs",
"checkAllFields": "Please check all fields are valid"
},
"joinReason": {
"label": "What brings you to FlowFuse?",
"education": "Educational Use",
"business": "Business Needs",
"personal": "Personal Use"
},
"termsAndConditions": "I accept the FlowFuse Terms & Conditions.",
"ssoCreated": "You can now login using your SSO Provider."
}
}
6 changes: 6 additions & 0 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import App from './App.vue'
import Loading from './components/Loading.vue'
import SectionNavigationHeader from './components/SectionNavigationHeader.vue'
import TeamLink from './components/router-links/TeamLink.vue'
import i18nDirective from './directives/i18n.js'
import i18n from './i18n.js'
import PageLayout from './layouts/Page.vue'
import router from './routes.js'
import Alerts from './services/alerts.js'
Expand All @@ -29,8 +31,12 @@ const app = createApp(App)
.use(ForgeUIComponents)
.use(store)
.use(router)
.use(i18n)
.use(VueShepherdPlugin)

// Register i18n directive
app.directive('i18n', i18nDirective)

// Error tracking
setupSentry(app, router)

Expand Down
Loading
Loading