diff --git a/.gitignore b/.gitignore index de1430a..79539fe 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ yarn-error.log* # turbo .turbo -dist \ No newline at end of file +dist + +.react-email \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 4af6b93..80be422 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/email/emails/vercel-invite-user.tsx b/examples/email/emails/vercel-invite-user.tsx index 09c1dfe..276f180 100644 --- a/examples/email/emails/vercel-invite-user.tsx +++ b/examples/email/emails/vercel-invite-user.tsx @@ -1,4 +1,4 @@ -import { withI18n } from "@languine/react-email"; +import { setupI18n } from "@languine/react-email"; import { Body, Button, @@ -19,6 +19,7 @@ import { import * as React from "react"; interface VercelInviteUserEmailProps { + locale: string; username?: string; userImage?: string; invitedByUsername?: string; @@ -34,125 +35,114 @@ const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : ""; -export const VercelInviteUserEmail = withI18n( - ({ - username, - userImage, - invitedByUsername, - invitedByEmail, - teamName, - teamImage, - inviteLink, - inviteFromIp, - inviteFromLocation, - }: VercelInviteUserEmailProps) => { - const previewText = `Join ${invitedByUsername} on Vercel`; +export const VercelInviteUserEmail = ({ + locale = "es", + username = "alanturing", + userImage = `${baseUrl}/static/vercel-user.png`, + invitedByUsername = "Alan", + invitedByEmail = "alan.turing@example.com", + teamName = "Enigma", + teamImage = `${baseUrl}/static/vercel-team.png`, + inviteLink = "https://vercel.com/teams/invite/foo", + inviteFromIp = "204.13.186.218", + inviteFromLocation = "São Paulo, Brazil", +}: VercelInviteUserEmailProps) => { + const i18n = setupI18n(locale); - return ( - - - {previewText} - - - -
- Vercel -
- - Join {teamName} on Vercel - - - Hello {username}, - - - {invitedByUsername} ( - - {invitedByEmail} - - ) has invited you to the {teamName} team on{" "} - Vercel. - -
- - - - - - invited you to - - - - - -
-
- -
- - or copy and paste this URL into your browser:{" "} - - {inviteLink} - - -
- - This invitation was intended for{" "} - {username}. This invite was - sent from {inviteFromIp}{" "} - located in{" "} - {inviteFromLocation}. If you - were not expecting this invitation, you can ignore this email. - If you are concerned about your account's safety, please reply - to this email to get in touch with us. - -
- -
- - ); - }, - "en", -); - -VercelInviteUserEmail.PreviewProps = { - username: "alanturing", - userImage: `${baseUrl}/static/vercel-user.png`, - invitedByUsername: "Alan", - invitedByEmail: "alan.turing@example.com", - teamName: "Enigma", - teamImage: `${baseUrl}/static/vercel-team.png`, - inviteLink: "https://vercel.com/teams/invite/foo", - inviteFromIp: "204.13.186.218", - inviteFromLocation: "São Paulo, Brazil", -} as VercelInviteUserEmailProps; + return ( + + + {i18n.t("previewText", { invitedByUsername })} + + + +
+ {i18n.t("logoAlt")} +
+ + {i18n.t("joinTeamHeading", { + teamName: teamName, + company: "Vercel", + })} + + + {i18n.t("greeting", { username })} + + + {i18n.t("invitationText", { + invitedByUsername: {invitedByUsername}, + email: ( + + {invitedByEmail} + + ), + teamName: {teamName}, + company: "Vercel", + })} + +
+ + + + + + {i18n.t("invitedToAlt")} + + + + + +
+
+ +
+ + {i18n.t("copyUrlText")}{" "} + + {inviteLink} + + +
+ + {i18n.t("footerText", { + username: {username}, + ip: {inviteFromIp}, + location: ( + {inviteFromLocation} + ), + })} + +
+ +
+ + ); +}; export default VercelInviteUserEmail; diff --git a/examples/email/languine.config.ts b/examples/email/languine.config.ts new file mode 100644 index 0000000..e8e3b3a --- /dev/null +++ b/examples/email/languine.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "languine"; + +export default defineConfig({ + version: "7.0.0", + locale: { + source: "en", + targets: ["es", "sv", "pt"], + }, + files: { + json: { + include: ["locales/[locale].json"], + }, + }, + llm: { + provider: "openai", + model: "gpt-4-turbo", + }, +}); \ No newline at end of file diff --git a/examples/email/locales/en.json b/examples/email/locales/en.json index e69de29..8be44dc 100644 --- a/examples/email/locales/en.json +++ b/examples/email/locales/en.json @@ -0,0 +1,15 @@ +{ + "previewText": "Join %{invitedByUsername} on %{company}", + "company": "%{company}", + "logoAlt": "Vercel Logo", + "joinTeamHeading": "Join %{teamName} on %{company}", + "greeting": "Hi %{username},", + "invitationText": + "%{invitedByUsername} (%{email}) has invited you to join the %{teamName} team on %{company}.", + "invitedToAlt": "Invited to", + "joinTeamButton": "Join the team", + "copyUrlText": "Or copy and paste this URL into your browser:", + "footerText": + "This invitation was intended for %{username} (%{ip} from %{location}). If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us." + } + \ No newline at end of file diff --git a/examples/email/locales/es.json b/examples/email/locales/es.json new file mode 100644 index 0000000..40bdab2 --- /dev/null +++ b/examples/email/locales/es.json @@ -0,0 +1,12 @@ +{ + "previewText": "Únete a %{invitedByUsername} en %{company}", + "company": "%{company}", + "logoAlt": "Logo de Vercel", + "joinTeamHeading": "Únete al equipo %{teamName} en %{company}", + "greeting": "Hola %{username},", + "invitationText": "%{invitedByUsername} (%{email}) te ha invitado a unirte al equipo %{teamName} en %{company}.", + "invitedToAlt": "Invitado a", + "joinTeamButton": "Únete al equipo", + "copyUrlText": "O copia y pega esta URL en tu navegador:", + "footerText": "Esta invitación fue destinada para %{username} (%{ip} desde %{location}). Si no esperabas esta invitación, puedes ignorar este correo electrónico. Si te preocupa la seguridad de tu cuenta, por favor responde a este correo electrónico para ponerte en contacto con nosotros." +} \ No newline at end of file diff --git a/examples/email/locales/i18n.config.ts b/examples/email/locales/i18n.config.ts deleted file mode 100644 index a5b014d..0000000 --- a/examples/email/locales/i18n.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - en: require("./en.json"), - // es: require("./es.json"), -}; diff --git a/examples/email/locales/pt.json b/examples/email/locales/pt.json new file mode 100644 index 0000000..2bed970 --- /dev/null +++ b/examples/email/locales/pt.json @@ -0,0 +1,12 @@ +{ + "previewText": "Junte-se a %{invitedByUsername} na %{company}", + "company": "%{company}", + "logoAlt": "Logotipo da Vercel", + "joinTeamHeading": "Junte-se ao time %{teamName} na %{company}", + "greeting": "Oi %{username},", + "invitationText": "%{invitedByUsername} (%{email}) convidou você para se juntar ao time %{teamName} na %{company}.", + "invitedToAlt": "Convidado para", + "joinTeamButton": "Entrar no time", + "copyUrlText": "Ou copie e cole este URL no seu navegador:", + "footerText": "Este convite foi destinado para %{username} (%{ip} de %{location}). Se você não estava esperando este convite, pode ignorar este e-mail. Se estiver preocupado com a segurança da sua conta, por favor responda a este e-mail para entrar em contato conosco." +} \ No newline at end of file diff --git a/examples/email/locales/sv.json b/examples/email/locales/sv.json new file mode 100644 index 0000000..ecfa1a8 --- /dev/null +++ b/examples/email/locales/sv.json @@ -0,0 +1,12 @@ +{ + "previewText": "Gå med %{invitedByUsername} på %{company}", + "company": "%{company}", + "logoAlt": "Vercel-logotyp", + "joinTeamHeading": "Gå med i %{teamName} på %{company}", + "greeting": "Hej %{username},", + "invitationText": "%{invitedByUsername} (%{email}) har bjudit in dig att gå med i %{teamName}-teamet på %{company}.", + "invitedToAlt": "Inbjuden till", + "joinTeamButton": "Gå med i teamet", + "copyUrlText": "Eller kopiera och klistra in denna URL i din webbläsare:", + "footerText": "Denna inbjudan var avsedd för %{username} (%{ip} från %{location}). Om du inte förväntade dig denna inbjudan kan du ignorera detta e-postmeddelande. Om du är orolig för säkerheten på ditt konto, vänligen svara på detta e-postmeddelande för att komma i kontakt med oss." +} \ No newline at end of file diff --git a/examples/email/package.json b/examples/email/package.json index 88c7cd6..51cb474 100644 --- a/examples/email/package.json +++ b/examples/email/package.json @@ -8,10 +8,10 @@ "export": "email export" }, "dependencies": { - "@languine/react-email": "workspace:*", + "languine": "link:languine", "@react-email/components": "0.0.31", - "react-dom": "19.0.0", - "react": "19.0.0" + "react": "19.0.0", + "react-dom": "19.0.0" }, "devDependencies": { "@types/react": "19.0.1", diff --git a/examples/next-international/languine.config.ts b/examples/next-international/languine.config.ts index 6adbc53..ac0346c 100644 --- a/examples/next-international/languine.config.ts +++ b/examples/next-international/languine.config.ts @@ -14,6 +14,5 @@ export default defineConfig({ llm: { provider: "ollama", model: "mistral:latest", - temperature: 0, }, }); diff --git a/packages/react-email/package.json b/packages/react-email/package.json index 526638a..dd3f850 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -1,6 +1,6 @@ { "name": "@languine/react-email", - "version": "1.0.0", + "version": "0.0.1", "files": ["dist", "README.md"], "main": "dist/index.mjs", "types": "dist/index.d.ts", @@ -18,5 +18,8 @@ "devDependencies": { "tsup": "^8.3.5", "typescript": "^5.7.2" + }, + "dependencies": { + "react-string-replace": "^1.1.1" } } diff --git a/packages/react-email/src/index.tsx b/packages/react-email/src/index.tsx index 4041700..f7c3fb3 100644 --- a/packages/react-email/src/index.tsx +++ b/packages/react-email/src/index.tsx @@ -1,30 +1,21 @@ -import { join } from "node:path"; import { I18n } from "i18n-js"; +import { interpolate } from "./interpolate"; +import { translations } from "./loader"; -export function withI18n( - Component: React.ComponentType, - locale: string, -) { - const WithI18nComponent = (props: Props) => { - // Read and parse i18n config file - const configPath = join(process.cwd(), "locales/i18n.config.ts"); - const config = require(configPath); +export function setupI18n(locale?: string) { + if (Object.keys(translations).length === 0) { + throw new Error( + "No translation files found in locales directory, make sure it's in the root of the package", + ); + } - if (!config) { - throw new Error("i18n.config.ts not found"); - } + const i18n = new I18n(translations); - const i18n = new I18n(config); + // Set locale to first available locale if no locale is provided + i18n.locale = locale || Object.keys(translations).at(0) || "en"; + i18n.enableFallback = true; + // @ts-ignore + i18n.interpolate = interpolate; - i18n.locale = locale; - i18n.enableFallback = true; - - return ; - }; - - WithI18nComponent.displayName = `withI18n(${ - Component.displayName || Component.name || "Component" - })`; - - return WithI18nComponent; + return i18n; } diff --git a/packages/react-email/src/interpolate.tsx b/packages/react-email/src/interpolate.tsx new file mode 100644 index 0000000..16edbca --- /dev/null +++ b/packages/react-email/src/interpolate.tsx @@ -0,0 +1,49 @@ +import type { TranslateOptions } from "i18n-js"; +import type { I18n } from "i18n-js"; +import React from "react"; +import reactStringReplace from "react-string-replace"; + +export function interpolate( + i18n: I18n, + message: string, + options: TranslateOptions, +) { + const transformedOptions = Object.keys(options).reduce((buffer, key) => { + buffer[i18n.transformKey(key)] = options[key]; + return buffer; + }, {} as TranslateOptions); + + return reactStringReplace(message, i18n.placeholder, (match, i) => { + let value: React.ReactNode = ""; + const placeholder = match as string; + const name = placeholder.replace(i18n.placeholder, "$1"); + + if (transformedOptions[name] != null) { + if (React.isValidElement(transformedOptions[name])) { + value = transformedOptions[name]; + } else if (Array.isArray(transformedOptions[name])) { + value = transformedOptions[name]; + } else if (typeof transformedOptions[name] === "object") { + value = transformedOptions[name]; + } else { + value = transformedOptions[name].toString().replace(/\$/gm, "_#$#_"); + } + } else if (name in transformedOptions) { + value = i18n.nullPlaceholder( + i18n, + placeholder, + message, + transformedOptions, + ); + } else { + value = i18n.missingPlaceholder( + i18n, + placeholder, + message, + transformedOptions, + ); + } + + return {value}; + }); +} diff --git a/packages/react-email/src/loader.ts b/packages/react-email/src/loader.ts new file mode 100644 index 0000000..da08e98 --- /dev/null +++ b/packages/react-email/src/loader.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import path from "node:path"; + +const translations: Record> = {}; + +/** + * Recursively searches up directory tree for package.json + */ +const findPackageRoot = (dir: string): string => { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir; + } + + const parentDir = path.dirname(dir); + if (parentDir === dir) { + throw new Error("Could not find package.json in directory tree"); + } + + return findPackageRoot(parentDir); +}; + +/** + * Recursively loads all JSON translation files from locales directory + */ +const loadTranslations = (dir: string, baseDir: string) => { + const files = fs.readdirSync(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + loadTranslations(fullPath, baseDir); + continue; + } + + if (!file.endsWith(".json")) { + continue; + } + + const relativePath = path.relative(baseDir, fullPath); + const locale = path.basename(relativePath, ".json"); + try { + const content = fs.readFileSync(fullPath, "utf-8"); + translations[locale] = JSON.parse(content); + } catch (err) { + throw new Error( + `Failed to load translation file ${fullPath}: ${ + (err as Error).message + }`, + ); + } + } +}; + +// Load translations from locales directory in package root +const packageRoot = findPackageRoot(__dirname); +const localesDir = path.join(packageRoot, "locales"); + +if (!fs.existsSync(localesDir)) { + throw new Error("No locales directory found in package root"); +} + +loadTranslations(localesDir, localesDir); + +export { translations };