diff --git a/apps/api/src/locales/@vitnode/blog/en.json b/apps/api/src/locales/@vitnode/blog/en.json
new file mode 100644
index 000000000..65f92dfff
--- /dev/null
+++ b/apps/api/src/locales/@vitnode/blog/en.json
@@ -0,0 +1,70 @@
+{
+ "@vitnode/blog": {
+ "title": "Blog",
+ "admin": {
+ "nav": {
+ "posts": "Posts",
+ "categories": "Categories"
+ },
+ "categories": {
+ "desc": "Manage categories for blog posts.",
+ "table": {
+ "title": "Title",
+ "updated_at": "Updated At"
+ },
+ "delete": {
+ "title": "Delete Category",
+ "desc": "Are you sure you want to delete
category? This action cannot be undone.",
+ "confirm": "Yes, delete this category",
+ "success": "Category has been deleted successfully."
+ },
+ "create": {
+ "title": "Create Category",
+ "desc": "A new category for your blog posts.",
+ "form": {
+ "title": {
+ "label": "Title",
+ "already_exists": "This category title already exists."
+ }
+ },
+ "submit": "Create"
+ },
+ "edit": {
+ "title": "Edit Category",
+ "submit": "Save Changes"
+ }
+ },
+ "posts": {
+ "desc": "Write and manage your blog posts.",
+ "table": {
+ "title": "Title",
+ "category": "Category",
+ "updated_at": "Updated At"
+ },
+ "create": {
+ "title": "Create Post",
+ "desc": "Write a new article for your blog.",
+ "form": {
+ "title": {
+ "label": "Title",
+ "already_exists": "This post title already exists."
+ },
+ "content": "Content",
+ "category": "Category"
+ },
+ "submit": "Create Post"
+ },
+ "edit": {
+ "title": "Edit Post",
+ "submit": "Save Changes"
+ },
+ "delete": {
+ "title": "Delete Post",
+ "desc": "Are you sure you want to delete post? This action cannot be undone.",
+ "confirm": "Yes, delete this post",
+ "success": "Post has been deleted successfully."
+ }
+ }
+ }
+ }
+}
diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json
new file mode 100644
index 000000000..96a4a7254
--- /dev/null
+++ b/apps/api/src/locales/@vitnode/core/en.json
@@ -0,0 +1,217 @@
+{
+ "core": {
+ "global": {
+ "theme_switcher": "Toggle Theme",
+ "language_switcher": "Switch Language",
+ "toggle_sidebar": "Toggle Sidebar",
+ "no_results": {
+ "title": "No results found",
+ "desc": "Try adjusting your search or filter criteria."
+ },
+ "are_you_sure_want_to_leave_form": {
+ "title": "Are you sure you want to leave this form?",
+ "desc": "Your changes will not be saved.",
+ "cancel": "Cancel",
+ "confirm": "Yes, leave"
+ },
+ "confirm_action": {
+ "title": "Are you sure?",
+ "desc": "This action cannot be undone.",
+ "cancel": "Cancel",
+ "confirm": "Yes, confirm"
+ },
+ "search_placeholder": "Search...",
+ "results_not_found": "No results found",
+ "date": "{date, date}",
+ "date_medium": "{date, date, medium}",
+ "date_short": "{date, date, short}",
+ "register": "Register",
+ "login": "Login",
+ "save": "Save",
+ "submit": "Submit",
+ "cancel": "Cancel",
+ "optional": "Optional",
+ "loading": "Loading...",
+ "or": "or",
+ "back_home": "Back to home",
+ "go_back": "Go back",
+ "select_option": "Select an option",
+ "select_options": "Select options",
+ "go_to_prev_page": "Go to previous page",
+ "go_to_next_page": "Go to next page",
+ "errors": {
+ "title": "Oops! Something went wrong.",
+ "internal_server_error": "Internal server error.",
+ "field_required": "This field is required.",
+ "field_min_length": "This field must be at least {min} characters.",
+ "captcha_internal_error": "Captcha validation failed. Please try again later.",
+ "404": {
+ "title": "Page Not Found",
+ "desc": "Oops! The page you're looking for doesn't exist."
+ },
+ "500": {
+ "title": "Internal Server Error",
+ "desc": "Sorry, we're experiencing technical difficulties on our server."
+ },
+ "400": {
+ "title": "Bad Request",
+ "desc": "The request couldn't be processed due to invalid parameters."
+ },
+ "403": {
+ "title": "Forbidden",
+ "desc": "You don't have permission to access this resource."
+ }
+ },
+ "user_bar": {
+ "log_out": "Log out",
+ "admin_cp": "Admin CP"
+ }
+ },
+ "auth": {
+ "sso": {
+ "or": "Or continue With",
+ "access_denied": "You have denied access to the application or the request has expired. Please try again."
+ },
+ "sign_up": {
+ "desc": "Hello there! Create your account to get started.",
+ "already_have_account": "You already have an account? Sign in.",
+ "submit": "Register",
+ "username": {
+ "label": "Username",
+ "min_length": "Username must be at least 3 characters long.",
+ "max_length": "Username must be at most 32 characters long.",
+ "exists": "Username already exists.",
+ "your_user_code": "Your user code: "
+ },
+ "email": {
+ "label": "Email",
+ "invalid": "Invalid email address.",
+ "exists": "Email already exists."
+ },
+ "password": {
+ "label": "Password",
+ "invalid": "Password is too weak.",
+ "requirements": {
+ "label": "Password should contain:",
+ "min_length": "At least 8 characters",
+ "uppercase": "At least one uppercase letter",
+ "number": "At least one number",
+ "special_char": "At least one special character"
+ }
+ },
+ "terms": {
+ "label": "Accept terms and conditions",
+ "required": "You must accept the terms and conditions.",
+ "desc": "You agree to our Legal documents & Policies."
+ },
+ "newsletter": {
+ "label": "Newsletter",
+ "desc": "Receive the latest news and updates."
+ },
+ "email_confirmation": {
+ "title": "Check your email",
+ "desc": "We've sent a confirmation link to your email address",
+ "check_spam": "If you don't see the email in your inbox, please check your spam folder."
+ }
+ },
+ "sign_in": {
+ "desc": "Welcome back! Sign in to your account.",
+ "do_not_have_account": "Don't have an account? Sign up.",
+ "email": {
+ "label": "Email",
+ "invalid": "Invalid email address."
+ },
+ "password": {
+ "label": "Password",
+ "required": "Password is required."
+ },
+ "errors": {
+ "access_denied": {
+ "title": "Invalid credentials",
+ "desc": "The email address or password was incorrect. Please try again (make sure your caps lock is off)."
+ }
+ },
+ "submit": "Login"
+ }
+ }
+ },
+ "admin": {
+ "dashboard": {
+ "dev_mode": "Development Mode",
+ "version": "Version: {version}"
+ },
+ "global": {
+ "nav": {
+ "core": "Core",
+ "dashboard": "Dashboard",
+ "users": {
+ "title": "Users",
+ "list": "User List"
+ },
+ "user_bar": {
+ "home_page": "Home Page",
+ "debug": "Debug Panel",
+ "log_out": "Log Out"
+ }
+ }
+ },
+ "user": {
+ "list": {
+ "desc": "Manage users of your application.",
+ "user": "User",
+ "createdAt": "Created At",
+ "emailNotVerified": "Email Not Verified"
+ }
+ },
+ "debug": {
+ "title": "Debug Panel",
+ "desc": "Check logs, errors, and other debug information.",
+ "actions": {
+ "clear_cache": {
+ "label": "Clear Cache",
+ "title": "Are you sure you want to clear the cache?",
+ "desc": "This action will remove all cached data, which may affect performance temporarily.",
+ "success": "Cache cleared successfully.",
+ "confirm": "Yes, clear cache"
+ }
+ },
+ "logs": {
+ "title": "System Logs",
+ "created_at": "Created At",
+ "plugin": "Plugin",
+ "content": "Content",
+ "type": "Type",
+ "types": {
+ "warn": "Warning",
+ "error": "Error",
+ "debug": "Debug"
+ },
+ "status_code": "Status Code",
+ "more": {
+ "title": "More Details",
+ "desc": "Log ID: ",
+ "log_overview": {
+ "title": "Log Overview",
+ "log_type": "Log Type",
+ "status_code": "Status Code",
+ "log_id": "Log ID",
+ "created_at": "Created At",
+ "plugin": "Plugin",
+ "user": "User"
+ },
+ "request_information": {
+ "title": "Request Information",
+ "ip_address": "IP Address",
+ "request_method": "Request Method",
+ "request_url": "Request URL",
+ "user_agent": "User Agent"
+ },
+ "log_content": {
+ "title": "Log Content",
+ "full_log": "Full Log Message"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts
index 8988fdf8a..b94db000f 100644
--- a/apps/api/src/vitnode.api.config.ts
+++ b/apps/api/src/vitnode.api.config.ts
@@ -1,3 +1,4 @@
+import { NodemailerEmailAdapter } from '@vitnode/core/api/adapters/email/nodemailer';
import { buildApiConfig } from '@vitnode/core/vitnode.config';
import * as dotenv from 'dotenv';
import { drizzle } from 'drizzle-orm/postgres-js';
@@ -12,10 +13,19 @@ export const POSTGRES_URL =
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
+ pathToMessages: async path => await import(`./locales/${path}`),
dbProvider: drizzle({
connection: POSTGRES_URL,
casing: 'camelCase',
}),
+ email: {
+ adapter: NodemailerEmailAdapter({
+ from: process.env.NODE_MAILER_FROM,
+ host: process.env.NODE_MAILER_HOST,
+ password: process.env.NODE_MAILER_PASSWORD,
+ user: process.env.NOD_EMAILER_USER,
+ }),
+ },
metadata: {
title: 'VitNode API',
shortTitle: 'VitNode',
diff --git a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx
index 705a91568..7aa8558b3 100644
--- a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx
+++ b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx
@@ -19,7 +19,7 @@ export const metadata: Metadata = {
export default function HomePage() {
return (
-
+
Extendable Framework for
diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts
index f9675ff13..7d2cc75c0 100644
--- a/apps/docs/src/vitnode.api.config.ts
+++ b/apps/docs/src/vitnode.api.config.ts
@@ -11,6 +11,7 @@ export const POSTGRES_URL =
process.env.POSTGRES_URL || 'postgresql://root:root@localhost:5432/vitnode';
export const vitNodeApiConfig = buildApiConfig({
+ pathToMessages: async path => await import(`./locales/${path}`),
captcha: {
type: 'cloudflare_turnstile',
siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts
index 333637483..ba53fa6d2 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts
@@ -6,6 +6,7 @@ export const POSTGRES_URL =
process.env.POSTGRES_URL || 'postgresql://root:root@localhost:5432/vitnode';
export const vitNodeApiConfig = buildApiConfig({
+ pathToMessages: async path => await import(`./locales/${path}`),
metadata: {
title: 'VitNode',
shortTitle: 'VitNode',
diff --git a/packages/create-vitnode-app/src/create/create-vitnode.ts b/packages/create-vitnode-app/src/create/create-vitnode.ts
index 3353c8f6e..70b023d51 100644
--- a/packages/create-vitnode-app/src/create/create-vitnode.ts
+++ b/packages/create-vitnode-app/src/create/create-vitnode.ts
@@ -217,11 +217,26 @@ export const createVitNode = async ({
cwd: root,
});
- if (mode !== 'onlyApi') {
- spinner.text = 'Initializing VitNode files...';
+ spinner.text = 'Initializing VitNode files...';
+ if (mode === 'apiMonorepo') {
+ await Promise.all([
+ initFilesVitnode({
+ packageManager,
+ cwd: monorepoStructure.web,
+ }),
+ initFilesVitnode({
+ packageManager,
+ cwd: monorepoStructure.api,
+ }),
+ initFilesVitnode({
+ packageManager,
+ cwd: root,
+ }),
+ ]);
+ } else {
initFilesVitnode({
packageManager,
- cwd: mode === 'apiMonorepo' || monorepo ? monorepoStructure.web : root,
+ cwd: root,
});
}
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 855de85f1..62da96082 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -27,12 +27,12 @@
"drizzle-kit": "0.31.x",
"drizzle-orm": "^0.44.x",
"hono": "4.8.x",
+ "motion": "^12.x.x",
"next": "15.3.x",
"next-intl": "4.x.x",
"react": "19.1.x",
"react-dom": "19.1.x",
"react-hook-form": "^7.x.x",
- "motion": "^12.x.x",
"typescript": "^5.8.x",
"zod": "4.x.x"
},
@@ -126,6 +126,7 @@
"resend": "^4.7.0",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.5",
+ "use-intl": "^4.3.4",
"vaul": "^1.1.2"
}
}
diff --git a/packages/vitnode/scripts/plugin.ts b/packages/vitnode/scripts/plugin.ts
index fdbc25e45..86c7109d5 100644
--- a/packages/vitnode/scripts/plugin.ts
+++ b/packages/vitnode/scripts/plugin.ts
@@ -1,6 +1,12 @@
/* eslint-disable no-console */
import chokidar from 'chokidar';
-import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ unlinkSync,
+} from 'fs';
import { basename, join, relative } from 'path';
import {
@@ -38,38 +44,135 @@ export const processPlugin = ({ initMessage }: { initMessage: string }) => {
// Transform plugin name for path usage
const pluginPathName = pluginName.replace(/\//g, '-').replace(/@/g, '');
- const mainDest = join(
- repoRoot,
- 'apps',
- 'docs',
- 'src',
- 'app',
- '[locale]',
- '(main)',
- join('(plugins)', `(${pluginPathName})`),
- );
- const adminDest = join(
- repoRoot,
- 'apps',
- 'docs',
- 'src',
- 'app',
- '[locale]',
- 'admin',
- '(auth)',
- join('(plugins)', `(${pluginPathName})`),
- );
- const langDest = join(repoRoot, 'apps', 'docs', 'src', 'locales', pluginName);
+ // Detect app types by checking for config files
+ const detectAppType = (appPath: string) => {
+ const hasWebConfig = existsSync(join(appPath, 'src', 'vitnode.config.ts'));
+ const hasApiConfig = existsSync(
+ join(appPath, 'src', 'vitnode.api.config.ts'),
+ );
+
+ if (hasApiConfig && !hasWebConfig) return 'api';
+ if (hasWebConfig) return 'web';
+
+ return null;
+ };
+
+ // Check if we're in a monorepo by looking for apps directories
+ const appsDir = join(repoRoot, 'apps');
+ const isMonorepo = existsSync(appsDir);
+
+ const sources: SourceConfig[] = [];
+
+ if (isMonorepo) {
+ // Monorepo: scan all apps and detect their types
+ const appDirs = existsSync(appsDir)
+ ? readdirSync(appsDir, { withFileTypes: true })
+ .filter(dirent => dirent.isDirectory())
+ .map(dirent => dirent.name)
+ : [];
+
+ for (const appName of appDirs) {
+ const appPath = join(repoRoot, 'apps', appName);
+ const appType = detectAppType(appPath);
+
+ if (appType === 'web') {
+ // Web app: copy app, app_admin, and locales
+ const mainDest = join(
+ appPath,
+ 'src',
+ 'app',
+ '[locale]',
+ '(main)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const adminDest = join(
+ appPath,
+ 'src',
+ 'app',
+ '[locale]',
+ 'admin',
+ '(auth)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const langDest = join(appPath, 'src', 'locales', pluginName);
+
+ sources.push(
+ {
+ sourceDir: join(pluginDir, 'src', 'app_admin'),
+ destinationDir: adminDest,
+ },
+ {
+ sourceDir: join(pluginDir, 'src', 'app'),
+ destinationDir: mainDest,
+ },
+ {
+ sourceDir: join(pluginDir, 'src', 'locales'),
+ destinationDir: langDest,
+ },
+ );
+ } else if (appType === 'api') {
+ // API app: copy only locales
+ const apiLangDest = join(appPath, 'src', 'locales', pluginName);
+
+ sources.push({
+ sourceDir: join(pluginDir, 'src', 'locales'),
+ destinationDir: apiLangDest,
+ });
+ }
+ }
+ } else {
+ // Standalone project: check if we're running from within an app directory
+ // or if we need to copy to the current working directory
+ const cwd = process.cwd();
+ const projectType = detectAppType(cwd);
+
+ if (projectType === 'web') {
+ // Web project: copy all files to current working directory
+ const mainDest = join(
+ cwd,
+ 'src',
+ 'app',
+ '[locale]',
+ '(main)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const adminDest = join(
+ cwd,
+ 'src',
+ 'app',
+ '[locale]',
+ 'admin',
+ '(auth)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const langDest = join(cwd, 'src', 'locales', pluginName);
+
+ sources.push(
+ {
+ sourceDir: join(pluginDir, 'src', 'app_admin'),
+ destinationDir: adminDest,
+ },
+ {
+ sourceDir: join(pluginDir, 'src', 'app'),
+ destinationDir: mainDest,
+ },
+ {
+ sourceDir: join(pluginDir, 'src', 'locales'),
+ destinationDir: langDest,
+ },
+ );
+ } else if (projectType === 'api') {
+ // API project: copy only locales to current working directory
+ const langDest = join(cwd, 'src', 'locales', pluginName);
+
+ sources.push({
+ sourceDir: join(pluginDir, 'src', 'locales'),
+ destinationDir: langDest,
+ });
+ }
+ }
// tell the copier about both trees
- const sources: SourceConfig[] = [
- {
- sourceDir: join(pluginDir, 'src', 'app_admin'),
- destinationDir: adminDest,
- },
- { sourceDir: join(pluginDir, 'src', 'app'), destinationDir: mainDest },
- { sourceDir: join(pluginDir, 'src', 'locales'), destinationDir: langDest },
- ];
// Create destination directories if they don't exist and source directories are not empty
for (const { sourceDir, destinationDir } of sources) {
@@ -144,39 +247,50 @@ export const processPlugin = ({ initMessage }: { initMessage: string }) => {
persistent: true,
});
- const getDestinationPath = (srcPath: string): string => {
+ const getDestinationPaths = (srcPath: string): string[] => {
// collect all matching sourceConfigs
- const candidates = sources.filter(({ sourceDir }) =>
- srcPath.startsWith(sourceDir),
- );
+ const candidates = sources.filter(({ sourceDir }) => {
+ // Ensure exact directory matching by checking if the path starts with sourceDir
+ // followed by a path separator (or is exactly the sourceDir)
+ const normalizedSrcPath = srcPath.replace(/\\/g, '/');
+ const normalizedSourceDir = sourceDir.replace(/\\/g, '/');
+
+ return (
+ normalizedSrcPath === normalizedSourceDir ||
+ normalizedSrcPath.startsWith(normalizedSourceDir + '/')
+ );
+ });
if (candidates.length === 0) {
throw new Error(`No matching source directory for: ${srcPath}`);
}
- // pick the one with the longest sourceDir (most specific)
- const sourceConfig = candidates.reduce((best, cur) =>
- cur.sourceDir.length > best.sourceDir.length ? cur : best,
- );
-
- // now append the relative path
- const relativePath = relative(sourceConfig.sourceDir, srcPath);
+ // Return all matching destination paths instead of just one
+ return candidates.map(sourceConfig => {
+ const relativePath = relative(sourceConfig.sourceDir, srcPath);
- return join(sourceConfig.destinationDir, relativePath);
+ return join(sourceConfig.destinationDir, relativePath);
+ });
};
watcher
.on('add', filePath => {
- const destPath = getDestinationPath(filePath);
- copyFileWrapper(filePath, destPath);
+ const destPaths = getDestinationPaths(filePath);
+ destPaths.forEach(destPath => {
+ copyFileWrapper(filePath, destPath);
+ });
})
.on('change', filePath => {
- const destPath = getDestinationPath(filePath);
- copyFileWrapper(filePath, destPath);
+ const destPaths = getDestinationPaths(filePath);
+ destPaths.forEach(destPath => {
+ copyFileWrapper(filePath, destPath);
+ });
})
.on('unlink', filePath => {
- const destPath = getDestinationPath(filePath);
- removeFile(destPath);
+ const destPaths = getDestinationPaths(filePath);
+ destPaths.forEach(destPath => {
+ removeFile(destPath);
+ });
})
.on('error', error => {
console.error('\x1b[31mWatcher error:\x1b[0m', error);
diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts
index ccd074031..da82196a1 100644
--- a/packages/vitnode/scripts/prepare-database.ts
+++ b/packages/vitnode/scripts/prepare-database.ts
@@ -160,10 +160,14 @@ export const prepareDatabase = async ({
if (flag === '--web') {
steps.push({
label: 'Prepare plugins files...',
- action: preparePluginsFiles,
+ action: async () => await preparePluginsFiles(flag),
});
} else if (flag === '--api') {
steps.push(
+ {
+ label: 'Prepare plugins files...',
+ action: async () => await preparePluginsFiles(flag),
+ },
{
label: 'Generate migrations...',
action: generateDatabaseMigrations,
@@ -181,7 +185,7 @@ export const prepareDatabase = async ({
steps.push(
{
label: 'Prepare plugins files...',
- action: preparePluginsFiles,
+ action: async () => await preparePluginsFiles(flag),
},
{
label: 'Generate migrations...',
diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts
index 37c4c2510..156b8bef6 100644
--- a/packages/vitnode/scripts/prepare-plugins-files.ts
+++ b/packages/vitnode/scripts/prepare-plugins-files.ts
@@ -1,5 +1,5 @@
/* eslint-disable no-console */
-import { existsSync } from 'fs';
+import { existsSync, readdirSync } from 'fs';
import { readFile } from 'fs/promises';
import { join, relative } from 'path';
@@ -13,8 +13,31 @@ import {
type SourceConfig,
} from './shared/file-utils';
-export const preparePluginsFiles = async () => {
- const config = await getConfig({});
+export const preparePluginsFiles = async (flag?: string) => {
+ // Detect which config file to load based on flag or auto-detection
+ const cwd = process.cwd();
+ const hasWebConfig = existsSync(join(cwd, 'src', 'vitnode.config.ts'));
+ const hasApiConfig = existsSync(join(cwd, 'src', 'vitnode.api.config.ts'));
+
+ let config: { plugins: { pluginId: string }[] };
+
+ if (flag === '--api' && hasApiConfig) {
+ // Force API config when --api flag is used
+ config = await getConfig({ type: 'api.config' });
+ } else if (flag === '--web' && hasWebConfig) {
+ // Force web config when --web flag is used
+ config = await getConfig({});
+ } else if (hasApiConfig && !hasWebConfig) {
+ // API config only (auto-detect)
+ config = await getConfig({ type: 'api.config' });
+ } else if (hasWebConfig) {
+ // Web config (may also have API config but web takes precedence in auto-detect)
+ config = await getConfig({});
+ } else {
+ // No config found, use empty plugins array
+ config = { plugins: [] };
+ }
+
const plugins: string[] = [
...config.plugins.map(plugin => plugin.pluginId),
'@vitnode/core',
@@ -81,43 +104,121 @@ export const preparePluginsFiles = async () => {
// Transform plugin name for path usage
const pluginPathName = pluginName.replace(/\//g, '-').replace(/@/g, '');
- // All projects (both monorepo apps and standalone) use the same structure
- const mainDest = join(
- baseDir,
- 'src',
- 'app',
- '[locale]',
- '(main)',
- join('(plugins)', `(${pluginPathName})`),
- );
-
- const adminDest = join(
- baseDir,
- 'src',
- 'app',
- '[locale]',
- 'admin',
- '(auth)',
- join('(plugins)', `(${pluginPathName})`),
- );
-
- const langDest = join(baseDir, 'src', 'locales', pluginName);
+ // Detect app types by checking for config files
+ const detectAppType = (appPath: string) => {
+ const hasWebConfig = existsSync(
+ join(appPath, 'src', 'vitnode.config.ts'),
+ );
+ const hasApiConfig = existsSync(
+ join(appPath, 'src', 'vitnode.api.config.ts'),
+ );
+
+ if (hasApiConfig && !hasWebConfig) return 'api';
+ if (hasWebConfig) return 'web';
+
+ return null;
+ };
+
+ // Check if we're in a monorepo by looking for apps directories
+ const appsDir = join(repoRoot, 'apps');
+ const isMonorepo = existsSync(appsDir);
// Define source configurations for this plugin
- const sources: SourceConfig[] = [
- {
- sourceDir: join(pluginPath, 'src', 'app_admin'),
- destinationDir: adminDest,
- },
- {
- sourceDir: join(pluginPath, 'src', 'app'),
- destinationDir: mainDest,
- },
- {
- sourceDir: join(pluginPath, 'src', 'locales'),
- destinationDir: langDest,
- },
- ];
+ const sources: SourceConfig[] = [];
+
+ if (isMonorepo) {
+ // Monorepo: scan all apps and detect their types
+ const appDirs = existsSync(appsDir)
+ ? readdirSync(appsDir, { withFileTypes: true })
+ .filter(dirent => dirent.isDirectory())
+ .map(dirent => dirent.name)
+ : [];
+
+ for (const appName of appDirs) {
+ const appPath = join(repoRoot, 'apps', appName);
+ const appType = detectAppType(appPath);
+
+ if (appType === 'web') {
+ // Web app: copy app, app_admin, and locales
+ const mainDest = join(
+ appPath,
+ 'src',
+ 'app',
+ '[locale]',
+ '(main)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const adminDest = join(
+ appPath,
+ 'src',
+ 'app',
+ '[locale]',
+ 'admin',
+ '(auth)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const langDest = join(appPath, 'src', 'locales', pluginName);
+
+ sources.push(
+ {
+ sourceDir: join(pluginPath, 'src', 'app_admin'),
+ destinationDir: adminDest,
+ },
+ {
+ sourceDir: join(pluginPath, 'src', 'app'),
+ destinationDir: mainDest,
+ },
+ {
+ sourceDir: join(pluginPath, 'src', 'locales'),
+ destinationDir: langDest,
+ },
+ );
+ } else if (appType === 'api') {
+ // API app: copy only locales
+ const apiLangDest = join(appPath, 'src', 'locales', pluginName);
+
+ sources.push({
+ sourceDir: join(pluginPath, 'src', 'locales'),
+ destinationDir: apiLangDest,
+ });
+ }
+ }
+ } else {
+ // Standalone project: use current directory as base
+ const mainDest = join(
+ baseDir,
+ 'src',
+ 'app',
+ '[locale]',
+ '(main)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const adminDest = join(
+ baseDir,
+ 'src',
+ 'app',
+ '[locale]',
+ 'admin',
+ '(auth)',
+ join('(plugins)', `(${pluginPathName})`),
+ );
+ const langDest = join(baseDir, 'src', 'locales', pluginName);
+
+ sources.push(
+ {
+ sourceDir: join(pluginPath, 'src', 'app_admin'),
+ destinationDir: adminDest,
+ },
+ {
+ sourceDir: join(pluginPath, 'src', 'app'),
+ destinationDir: mainDest,
+ },
+ {
+ sourceDir: join(pluginPath, 'src', 'locales'),
+ destinationDir: langDest,
+ },
+ );
+ }
// Copy files for each source directory
for (const { sourceDir, destinationDir } of sources) {
diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts
index e1559440c..6c957d714 100644
--- a/packages/vitnode/scripts/scripts.ts
+++ b/packages/vitnode/scripts/scripts.ts
@@ -51,7 +51,7 @@ switch (command) {
break;
case 'prepare-plugins':
- await preparePluginsFiles();
+ await preparePluginsFiles(flag);
console.log(`${initMessage} \x1b[32mPlugins prepared successfully.\x1b[0m`);
process.exit(0);
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index e146ca113..f97de73b4 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -59,11 +59,13 @@ export function VitNodeAPI({
app.use(
'*',
globalMiddleware({
+ pathToMessages: vitNodeApiConfig.pathToMessages,
email: vitNodeApiConfig.email,
metadata: vitNodeApiConfig.metadata,
authorization: vitNodeApiConfig.authorization,
dbProvider: vitNodeApiConfig.dbProvider,
captcha: vitNodeApiConfig.captcha,
+ plugins: vitNodeApiConfig.plugins,
}),
);
app.use(async (c, next) => {
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 096abf673..fa4a230a8 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -63,6 +63,8 @@ export interface EnvVariablesVitNode {
shortTitle?: string;
title: string;
};
+ pathToMessages: (path: string) => Promise<{ default: object }>;
+ plugins: { id: string }[];
};
db: Pick['dbProvider'];
email: {
@@ -98,9 +100,16 @@ export const globalMiddleware = ({
email,
dbProvider,
captcha,
+ plugins,
+ pathToMessages,
}: Pick<
VitNodeApiConfig,
- 'authorization' | 'captcha' | 'dbProvider' | 'email'
+ | 'authorization'
+ | 'captcha'
+ | 'dbProvider'
+ | 'email'
+ | 'pathToMessages'
+ | 'plugins'
> &
Pick) => {
return async (c: Context, next: Next) => {
@@ -152,6 +161,7 @@ export const globalMiddleware = ({
c.set('email', new EmailModel(c));
c.set('core', {
+ pathToMessages,
metadata,
email,
authorization: {
@@ -168,6 +178,9 @@ export const globalMiddleware = ({
cookieSecure: authorization?.cookieSecure ?? true,
},
captcha,
+ plugins: plugins.map(plugin => ({
+ id: plugin.pluginId,
+ })),
});
const user = await new SessionModel(c).getUser();
diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts
index 534476216..fbe61b3b8 100644
--- a/packages/vitnode/src/api/models/email.ts
+++ b/packages/vitnode/src/api/models/email.ts
@@ -19,7 +19,7 @@ export interface EmailApiPlugin {
}
export interface EmailModelSendArgs {
- content: React.ReactNode;
+ content: (props: { locale: string }) => React.ReactNode;
html?: string;
replyTo?: string;
subject: string;
@@ -42,15 +42,40 @@ export class EmailModel {
});
}
+ const locale = 'en';
+ const pluginIds: string[] = [
+ '@vitnode/core',
+ ...this.c.get('core').plugins.map(plugin => plugin.id),
+ ];
+
+ const messagesPromises = pluginIds.map(async pluginId => {
+ try {
+ const path = `${pluginId}/${locale}.json`;
+ const messages = await core.pathToMessages(path);
+
+ return messages.default;
+ } catch {
+ return {};
+ }
+ });
+
+ const allMessages = await Promise.all(messagesPromises);
+ const messages = allMessages.reduce(
+ (acc, curr) => ({ ...acc, ...curr }),
+ {},
+ ) as Record;
+
const htmlContent =
html ??
DefaultTemplateEmail({
- children: content,
+ children: content({ locale }),
metadata: {
...core.metadata,
url: CONFIG.web.href,
},
logo: core.email?.options?.logo,
+ locale,
+ messages,
});
try {
diff --git a/packages/vitnode/src/api/modules/users/routes/test.route.ts b/packages/vitnode/src/api/modules/users/routes/test.route.ts
index 21f595b0d..3f54fe468 100644
--- a/packages/vitnode/src/api/modules/users/routes/test.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/test.route.ts
@@ -32,7 +32,7 @@ export const testRoute = buildRoute({
await c.get('email').send({
to: 'ithereplay@gmail.com',
subject: 'Test Email',
- content: 'This is a test email',
+ content: ({ locale }) => `This is a test email in ${locale} locale.`,
});
// throw new Error('Test error');
diff --git a/packages/vitnode/src/emails/default-template.tsx b/packages/vitnode/src/emails/default-template.tsx
index a4b69d546..dfb6f3a72 100644
--- a/packages/vitnode/src/emails/default-template.tsx
+++ b/packages/vitnode/src/emails/default-template.tsx
@@ -12,17 +12,19 @@ import {
Tailwind,
Text,
} from '@react-email/components';
-import { createTranslator } from 'next-intl';
+import { createTranslator } from 'use-intl';
import { CONFIG } from '../lib/config';
interface DefaultTemplateEmailProps {
children: React.ReactNode;
head?: React.ReactNode;
+ locale: string;
logo?: {
className?: string;
src: Blob | string;
};
+ messages: Record;
metadata: {
shortTitle?: string;
title: string;
@@ -37,18 +39,13 @@ export default function DefaultTemplateEmail({
children,
logo,
metadata,
+ messages,
+ locale,
}: DefaultTemplateEmailProps) {
- const intl = createTranslator({
- messages: {
- email: {
- previewText: 'This is a preview text for the email template.',
- },
- },
- locale: 'en',
- });
+ const t = createTranslator({ locale, messages });
return (
-
+
{head}
{previewText && {previewText}}
- Join Us for an Exciting Event! - {intl('email.previewText')}
+ Join Us for an Exciting Event! - {t('core.auth.sign_in.desc')}
Hello
@@ -121,4 +118,6 @@ DefaultTemplateEmail.PreviewProps = {
logo: {
src: 'https://www.reactemailtemplate.com/_next/static/media/reactemailtemplate-logo.b3fb12d9.png',
},
+ messages: {},
+ locale: 'en',
} satisfies DefaultTemplateEmailProps;
diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts
index 62d01382b..8c9ba4dde 100644
--- a/packages/vitnode/src/vitnode.config.ts
+++ b/packages/vitnode/src/vitnode.config.ts
@@ -54,6 +54,7 @@ export interface VitNodeApiConfig {
shortTitle?: string;
title: string;
};
+ pathToMessages: (path: string) => Promise<{ default: object }>;
plugins: BuildPluginApiReturn[];
rateLimiter?: Omit;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a967b1d02..0778e0733 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -351,6 +351,9 @@ importers:
use-debounce:
specifier: ^10.0.5
version: 10.0.5(react@19.1.0)
+ use-intl:
+ specifier: ^4.3.4
+ version: 4.3.4(react@19.1.0)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)