diff --git a/.gitignore b/.gitignore index de1430a5..79539fed 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 ba0556b6..358e28ac 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/email/emails/static/vercel-arrow.png b/examples/email/emails/static/vercel-arrow.png new file mode 100644 index 00000000..018f64d2 Binary files /dev/null and b/examples/email/emails/static/vercel-arrow.png differ diff --git a/examples/email/emails/static/vercel-logo.png b/examples/email/emails/static/vercel-logo.png new file mode 100644 index 00000000..5b970948 Binary files /dev/null and b/examples/email/emails/static/vercel-logo.png differ diff --git a/examples/email/emails/static/vercel-team.png b/examples/email/emails/static/vercel-team.png new file mode 100644 index 00000000..d3de7d93 Binary files /dev/null and b/examples/email/emails/static/vercel-team.png differ diff --git a/examples/email/emails/static/vercel-user.png b/examples/email/emails/static/vercel-user.png new file mode 100644 index 00000000..81beac69 Binary files /dev/null and b/examples/email/emails/static/vercel-user.png differ diff --git a/examples/email/emails/vercel-invite-user.tsx b/examples/email/emails/vercel-invite-user.tsx new file mode 100644 index 00000000..276f1800 --- /dev/null +++ b/examples/email/emails/vercel-invite-user.tsx @@ -0,0 +1,148 @@ +import { setupI18n } from "@languine/react-email"; +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import * as React from "react"; + +interface VercelInviteUserEmailProps { + locale: string; + username?: string; + userImage?: string; + invitedByUsername?: string; + invitedByEmail?: string; + teamName?: string; + teamImage?: string; + inviteLink?: string; + inviteFromIp?: string; + inviteFromLocation?: string; +} + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ""; + +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 ( + <Html> + <Head /> + <Preview>{i18n.t("previewText", { invitedByUsername })}</Preview> + <Tailwind> + <Body className="bg-white my-auto mx-auto font-sans px-2"> + <Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]"> + <Section className="mt-[32px]"> + <Img + src={`${baseUrl}/static/vercel-logo.png`} + width="40" + height="37" + alt={i18n.t("logoAlt")} + className="my-0 mx-auto" + /> + </Section> + <Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0"> + {i18n.t("joinTeamHeading", { + teamName: teamName, + company: "Vercel", + })} + </Heading> + <Text className="text-black text-[14px] leading-[24px]"> + {i18n.t("greeting", { username })} + </Text> + <Text className="text-black text-[14px] leading-[24px]"> + {i18n.t("invitationText", { + invitedByUsername: <strong>{invitedByUsername}</strong>, + email: ( + <Link href={`mailto:${invitedByEmail}`}> + {invitedByEmail} + </Link> + ), + teamName: <strong>{teamName}</strong>, + company: "Vercel", + })} + </Text> + <Section> + <Row> + <Column align="right"> + <Img + className="rounded-full" + src={userImage} + width="64" + height="64" + /> + </Column> + <Column align="center"> + <Img + src={`${baseUrl}/static/vercel-arrow.png`} + width="12" + height="9" + alt={i18n.t("invitedToAlt")} + /> + </Column> + <Column align="left"> + <Img + className="rounded-full" + src={teamImage} + width="64" + height="64" + /> + </Column> + </Row> + </Section> + <Section className="text-center mt-[32px] mb-[32px]"> + <Button + className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3" + href={inviteLink} + > + {i18n.t("joinTeamButton")} + </Button> + </Section> + <Text className="text-black text-[14px] leading-[24px]"> + {i18n.t("copyUrlText")}{" "} + <Link href={inviteLink} className="text-blue-600 no-underline"> + {inviteLink} + </Link> + </Text> + <Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" /> + <Text className="text-[#666666] text-[12px] leading-[24px]"> + {i18n.t("footerText", { + username: <span className="text-black">{username}</span>, + ip: <span className="text-black">{inviteFromIp}</span>, + location: ( + <span className="text-black">{inviteFromLocation}</span> + ), + })} + </Text> + </Container> + </Body> + </Tailwind> + </Html> + ); +}; + +export default VercelInviteUserEmail; diff --git a/examples/email/languine.config.ts b/examples/email/languine.config.ts new file mode 100644 index 00000000..e8e3b3a3 --- /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 new file mode 100644 index 00000000..8be44dcc --- /dev/null +++ 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 00000000..40bdab2b --- /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/pt.json b/examples/email/locales/pt.json new file mode 100644 index 00000000..2bed970f --- /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 00000000..ecfa1a8d --- /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 new file mode 100644 index 00000000..4dbdab10 --- /dev/null +++ b/examples/email/package.json @@ -0,0 +1,20 @@ +{ + "name": "react-email-starter", + "version": "0.1.6", + "private": true, + "scripts": { + "build": "email build", + "dev": "email dev", + "export": "email export" + }, + "dependencies": { + "@react-email/components": "0.0.31", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/react": "19.0.1", + "@types/react-dom": "19.0.1", + "react-email": "3.0.4" + } +} \ No newline at end of file diff --git a/examples/email/readme.md b/examples/email/readme.md new file mode 100644 index 00000000..fc875bfa --- /dev/null +++ b/examples/email/readme.md @@ -0,0 +1,27 @@ +# React Email Starter + +A live preview right in your browser so you don't need to keep sending real emails during development. + +## Getting Started + +First, install the dependencies: + +```sh +npm install +# or +yarn +``` + +Then, run the development server: + +```sh +npm run dev +# or +yarn dev +``` + +Open [localhost:3000](http://localhost:3000) with your browser to see the result. + +## License + +MIT License diff --git a/examples/next-international/languine.config.ts b/examples/next-international/languine.config.ts index 6adbc53e..ac0346c1 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/README.md b/packages/react-email/README.md new file mode 100644 index 00000000..a45c331f --- /dev/null +++ b/packages/react-email/README.md @@ -0,0 +1,60 @@ +<p align="center"> + <img src="https://github.com/midday-ai/languine/blob/main/packages/react-email/image.png" /> +</p> + +<p align="center"> + A lightweight i18n library for React email templates. +</p> + +--- + +```bash +$ npm install @languine/react-email +``` + +## What is this? + +This is a lightweight i18n library for React email templates. It is built on top of `i18n-js`. + +Automatically included language files are in the `locales` folder. + +## How to use + +```tsx +import { setupI18n } from "@languine/react-email"; + +export function WelcomeEmail({ locale, name }) { + const i18n = setupI18n(locale); + + return ( + <Html> + <Head /> + <Preview>{i18n.t("preview")}</Preview> + <Body> + <Text>{i18n.t("welcome", { name })}</Text> + </Body> + </Html> + ); +} +``` + +### Rendering the email +```tsx +import { render } from '@react-email/render'; +import { WelcomeEmail } from "./emails/welcome"; + +const html = await render(<WelcomeEmail locale="en" name="John" />, { + pretty: true, +}); + +console.log(html); +``` + + +## Works together with Languine CLI + +Automatically add and translate your email templates with [Languine CLI](https://languine.ai). + +```bash +$ npx languine@latest +``` diff --git a/packages/react-email/image.png b/packages/react-email/image.png new file mode 100644 index 00000000..0d584164 Binary files /dev/null and b/packages/react-email/image.png differ diff --git a/packages/react-email/package.json b/packages/react-email/package.json new file mode 100644 index 00000000..7d53747e --- /dev/null +++ b/packages/react-email/package.json @@ -0,0 +1,23 @@ +{ + "name": "@languine/react-email", + "version": "0.1.0", + "files": ["dist", "README.md"], + "main": "dist/index.mjs", + "types": "dist/index.d.ts", + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "biome check .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit", + "build": "tsup --clean src/index.tsx" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2" + }, + "dependencies": { + "i18n-js": "^4.5.1", + "react-string-replace": "^1.1.1" + } +} diff --git a/packages/react-email/src/index.tsx b/packages/react-email/src/index.tsx new file mode 100644 index 00000000..f7c3fb34 --- /dev/null +++ b/packages/react-email/src/index.tsx @@ -0,0 +1,21 @@ +import { I18n } from "i18n-js"; +import { interpolate } from "./interpolate"; +import { translations } from "./loader"; + +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", + ); + } + + const i18n = new I18n(translations); + + // 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; + + return i18n; +} diff --git a/packages/react-email/src/interpolate.tsx b/packages/react-email/src/interpolate.tsx new file mode 100644 index 00000000..16edbca9 --- /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 <React.Fragment key={i}>{value}</React.Fragment>; + }); +} diff --git a/packages/react-email/src/loader.ts b/packages/react-email/src/loader.ts new file mode 100644 index 00000000..da08e98e --- /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<string, Record<string, string>> = {}; + +/** + * 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 }; diff --git a/packages/react-email/tsconfig.json b/packages/react-email/tsconfig.json new file mode 100644 index 00000000..951ae6c1 --- /dev/null +++ b/packages/react-email/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "target": "esnext", + "module": "NodeNext", + "moduleResolution": "nodenext", + "allowJs": true, + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "outDir": "dist", + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": false, + "customConditions": ["source"], + "jsx": "react-jsx" + } + } \ No newline at end of file diff --git a/packages/react-email/tsup.config.ts b/packages/react-email/tsup.config.ts new file mode 100644 index 00000000..1e9a253a --- /dev/null +++ b/packages/react-email/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + format: "esm", + entry: ["src/index.ts"], +});