ozner-omali/laravel-to-i18next

Parse Laravel language files into i18next-compatible JSON

v0.1.0 2025-08-03 22:34 UTC

This package is auto-updated.

Last update: 2025-08-03 22:43:23 UTC


README

This package parses your Laravel translation files into i18next-compatible JSON, converting plural forms and placeholders into a format ready for dynamic translation usage in the frontend.

🎬 Features

- Reads every `lang/{locale}/*.php` file
- Parses Laravel’s `trans_choice` plural syntax (`{0}|{1}|[2,*]`)
- Converts `:placeholder`, `:Placeholder`, `:PLACEHOLDER` into i18next interpolations:
    - `:foo` → `{{foo}}`
    - `:Foo` → `{{foo, capitalize}}`
    - `:FOO` → `{{foo, uppercase}}`
- Strips attributes from HTML tags
- Writes to `public/locales/{locale}/*.json`
- Generates a `public/locales/versions.json` file with:
    - A unique hash for each locale’s translation files
    - A `last_updated` timestamp
    - Useful for cache invalidation and detecting translation updates
- Artisan command: `lang:to-i18next {locale?}`

📦 Installation

composer require ozner-omali/laravel-to-i18next

🔧 Usage

1. Export all locales
    php artisan lang:to-i18next
2. Export a specific locale
    php artisan lang:to-i18next es

This will generate:
• Translation files under: /public/locales/{locale}/*.json
• A version tracking file under: /public/locales/versions.json

Example versions.json file

{
    "en": {
        "hash": "a1b2c3d4e5f6g7h8i9j0",
        "last_updated": "2025-06-01T12:00:00Z"
    },
    "es": {
        "hash": "0j9i8h7g6f5e4d3c2b1a",
        "last_updated": "2025-06-01T12:30:00Z"
    }
}

Example Laravel Translations:

// resources/lang/en/messages.php

return [
    'success' => [
        'created' => '{0} No :resource created.|{1} :Resource created successfully.|[2,*] Many :resource created successfully.'
    ],
];

// resources/lang/en/models.php

return [
    'user' => 'user|users',
    'address' => 'address|addresses',
];

Generated i18next JSON:

// resources/locales/en/messages.json

{
    "success": {
        "created_zero": "No {{resource}} created.",
        "created_one": "{{resource, capitalize}} created successfully.",
        "created_other": "Many {{resource}} created successfully."
    }
}

// resources/lang/en/models.json

{
    "user_one": "user",
    "user_other": "users",
    "address_one": "address",
    "address_other": "addresses"
}

⚙️ Front-end Integration

1. Installation

npm install i18next react-i18next i18next-http-backend react-native-localize

2. useLangStore.ts (Zustand store)

Define a store with:

  • hydrated: boolean — whether storage has finished hydration
  • lang: string — current language
  • versions: Record<string, { hash: string; last_updated: string }> — the translation version hashes
  • getVersion(lang) to fetch hash or fallback to "latest"
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

type VersionsMap = Record<string, { hash: string; last_updated: string }>;

type LangStore = {
  hydrated: boolean;

  lang: string;
  setLang: (lang: string) => void;

  versions: VersionsMap;
  getVersion: (lang: string) => string;
  setVersions: (versions: VersionsMap) => void;
};

export const useLangStore = create<LangStore>()(
  persist(
    immer((set, get) => ({
      // Hydration
      hydrated: false,

      // Language
      lang: 'en',
      setLang: lang =>
        set(state => {
          state.lang = lang;
        }),

      // Versions
      versions: {},
      getVersion: lang => {
        return get().versions[lang]?.hash ?? 'latest';
      },
      setVersions: versions =>
        set(state => {
          state.versions = { ...versions };
        }),
    })),
    {
      name: 'lang-storage',
      storage: createJSONStorage(() => AsyncStorage),
      onRehydrateStorage: () => {
        return (_state, error) => {
          if (!error) {
            useLangStore.setState({ hydrated: true });
          }
        };
      },
      partialize: (state): Pick<LangStore, 'lang' | 'versions'> => ({
        lang: state.lang,
        versions: state.versions,
      }),
    },
  ),
);

3. useFetchVersions.ts (Hook to sync version.json)

import { useEffect } from 'react';
import { BACKEND_URL } from '@env';
import { useLangStore } from '@/stores/useLangStore';
import i18next from '@/config/i18next';

export const useFetchVersions = (hydrated: Boolean) => {
  const setVersions = useLangStore.getState().setVersions;
  const getVersions = useLangStore.getState().versions;

  useEffect(() => {
    if (!hydrated) return;
    const fetchVersions = async () => {
      try {
        // Append timestamp to bust all caches
        const timestamp = Date.now();
        const url = `${BACKEND_URL}/locales/versions.json?ts=${timestamp}`;

        const res = await fetch(url);

        if (!res.ok)
          throw new Error(`Failed to fetch versions (${res.status})`);

        const data = await res.json();

        const previous = getVersions;
        const changed = JSON.stringify(previous) !== JSON.stringify(data);

        if (changed) {
          console.log(
            '[i18n - useFetchVersions] VERSIONS UPDATED, reloading translations...',
          );
          setVersions(data);

          const currentLng = i18next.language || 'en';
          const namespaces = i18next.options.ns as string[];

          await i18next.reloadResources([currentLng], namespaces);
        } else {
          console.log(
            '[i18n - useFetchVersions] VERSIONS UNCHANGED, using cached translations.',
          );
        }
      } catch (e) {
        console.warn(
          '[i18n - useFetchVersions] Failed to fetch versions.json:',
          e,
        );
      }
    };

    fetchVersions();
  }, [hydrated]);
};

4. config/i18next.ts (Initialization)

import i18next from 'i18next';
import { BACKEND_URL } from '@env';
import { initReactI18next } from 'react-i18next';
import { useLangStore } from '@/stores/useLangStore';
import HttpApi, { HttpBackendOptions } from 'i18next-http-backend';

const fallbackLng = 'en';
const namespaces = [
    'addresses',
    'auth',
    'cache',
    'common',
    'http',
    'models',
    'pagination',
    'passwords',
    'resource',
    'validation',
];

export const initI18n = async () => {
    const store = useLangStore.getState();
    const initialLang = store.lang;

    return i18next
        .use(HttpApi)
        .use(initReactI18next)
        .init<HttpBackendOptions>({
            lng: initialLang,
            fallbackLng,
            ns: namespaces,
            defaultNS: 'common',
            backend: {
                loadPath: (lngs: string[], namespaces: string[]): string => {
                    const lng = lngs[0];
                    const ns = namespaces[0];
                    const version = useLangStore.getState().getVersion(lng);
                    console.log(
                        `[i18n] Loading translations for ${lng}/${ns} (version: ${version})`,
                    );
                    return `${BACKEND_URL}/locales/${lng}/${ns}.json?v=${version}`;
                },
            },
            interpolation: {
                escapeValue: false,
                format: (value, format) => {
                    if (typeof value !== 'string') return value;
                    if (format === 'capitalize') {
                        return value.charAt(0).toUpperCase() + value.slice(1);
                    }
                    if (format === 'uppercase') {
                        return value.toUpperCase();
                    }
                    return value;
                },
            },
            react: {
                useSuspense: false,
            },
        });
};

export default i18next;

5. App.tsx (Entry point)

import i18next from './config/i18next';
import { initI18n } from './config/i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { useLangStore } from './stores/useLangStore';
import { ThemeProvider } from './theme/ThemeProvider';
import { View, ActivityIndicator } from 'react-native';
import AppNavigation from './navigations/AppNavigation';
import { useFetchVersions } from './hooks/useFetchVersions';
import { SafeAreaProvider } from 'react-native-safe-area-context';

export default function App() {
  const langStoreHydrated = useLangStore(state => state.hydrated);
  const [i18nReady, setI18nReady] = useState(false);

  // Fetch versions only after hydration
  useFetchVersions(langStoreHydrated);

  useEffect(() => {
    if (langStoreHydrated) {
      initI18n().then(() => setI18nReady(true));
    }
  }, [langStoreHydrated]);

  if (!langStoreHydrated || !i18nReady) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <I18nextProvider i18n={i18next}>
      <ThemeProvider>
        <SafeAreaProvider>
          <AppNavigation />
        </SafeAreaProvider>
      </ThemeProvider>
    </I18nextProvider>
  );
}

6. Basic Example Usage

// passing 2 when count is 0 or less on the model 
// so that the pluralization in this case is correct
// Note: could have also set up the pluralization for the resource
// on the laravel end with {0}, {1}, [2,*]
// then you could use the same count in the model translation

let count = null;
let resource = null;

// 0 items 
count = 0;
resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count});
i18next.t('success.created', {
    count: count,
    resource
});
// → "No users created."


// 1 item
count = 1
resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count});
i18next.t('success.created', {
    count,
    resource
});
// → "User created successfully."

// Multiple items
count = 5;
resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count});
i18next.t('success.created', {
    count,
    resource
});
// → "Many users created successfully."

7. Trans Component Example

A. Laravel translation: resources/lang/en/{file}.php

'apples' => '{0} Hello <b style="background-color: #0ea5e9">:name</b>, <br/>there are none
            |{1} Hello <b class="highlight">:name</b>, there is one apple
            |[2,*] Hello <b id="special-name">:name</b>, there are :count apples',

B. Generated i18next JSON: public/locales/en/{file}.json

{
  "apples_zero": "Hello <b>{{name}}</b>, <br/>there are none",
  "apples_one": "Hello <b>{{name}}</b>, there is one apple",
  "apples_other": "Hello <b>{{name}}</b>, there are {{count}} apples"
}

C. /components/i18Next/TransComponents.tsx

import React from 'react';
import { Text } from 'react-native';

export const transComponents = {
    // Bold text
    b: <Text style={{ fontWeight: 'bold' }} />,
    bold: <Text style={{ fontWeight: 'bold' }} />,

    // Italic text
    i: <Text style={{ fontStyle: 'italic' }} />,
    italic: <Text style={{ fontStyle: 'italic' }} />,

    // Line breaks
    br: <Text>{'\n'}</Text>,

    // Span (generic inline container)
    span: <Text />,
};

D. Usage in component

<Text>
    <Trans
        i18nKey="models:apples"
        values={{ name: 'Ozner', count: 0 }}
        components={transComponents}
    />
</Text>

E. Output

Hello Ozner, 
there are none

✅ Notes

The parser:
  • Converts Laravel’s pipe plural syntax ('user|users') into _one and _other keys.
  • Supports Laravel’s pluralization brackets ({0}, {1}, [2,*]) and converts them to *_zero, *_one, *_other.
  • Placeholders are automatically transformed:
     • :key → {{key}}
     • :Key → {{key, capitalize}}
     • :KEY → {{key, uppercase}}
  • Strips HTML attributes from tags, e.g., <b class="highlight"> → <b>.
  • Adds versions.json for change detection and frontend cache management.

📖 Changelog

See CHANGELOG.md for release notes and breaking changes.

🤝 Contributing

Contributions, issues, and feature requests are welcome!

🔗 Report Issues
🔗 Submit Pull Requests

🔑 License

🔑 MIT License © Lorenzo Wynberg / Ozner Omali

🎵 Like my code? You'll love my music!