From e00c6032d16b6fa5eece8597e98f30d2855bcb66 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 19 Jan 2026 11:58:52 +0700 Subject: [PATCH] feat: Implement client-side localization using JSON files, a custom localizer, and a new language switcher component. --- .../Components/LanguageSwitcher.razor | 58 +++++++++++++++++++ .../Layout/MainLayout.razor | 18 +++--- .../Localization/JsonStringLocalizer.cs | 54 +++++++++++++++++ .../JsonStringLocalizerFactory.cs | 26 +++++++++ .../Localization/LocalizationCache.cs | 50 ++++++++++++++++ .../src/WebClientBase.Client/Program.cs | 20 ++++++- .../WebClientBase.Client.csproj | 1 + .../src/WebClientBase.Client/_Imports.razor | 3 + .../WebClientBase.Client/wwwroot/index.html | 14 +++-- .../wwwroot/locales/en-US.json | 8 +++ .../wwwroot/locales/vi-VN.json | 8 +++ .../src/WebClientBase.Server/Program.cs | 50 +++++++++++++++- 12 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Components/LanguageSwitcher.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Localization/JsonStringLocalizer.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Localization/JsonStringLocalizerFactory.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Localization/LocalizationCache.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Components/LanguageSwitcher.razor b/apps/web-client-base-net/src/WebClientBase.Client/Components/LanguageSwitcher.razor new file mode 100644 index 00000000..f6e30ad4 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Components/LanguageSwitcher.razor @@ -0,0 +1,58 @@ +@using System.Globalization +@inject NavigationManager Navigation + + + + + + @(CultureInfo.CurrentCulture.Name.StartsWith("vi") ? "VI" : "EN") + + + + + + + + 🇻🇳 + Tiếng Việt + + + + + 🇺🇸 + English + + + + + +@code { + private void SwitchLanguage(string targetCulture) + { + var uri = new Uri(Navigation.Uri); + var path = uri.PathAndQuery; + + // Simple logic to replace or prepend culture segment + // Check if path starts with a supported culture + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + string newPath; + if (segments.Length > 0 && (segments[0].Equals("vi-VN", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("en-US", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("vi", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("en", StringComparison.OrdinalIgnoreCase))) + { + // Replace first segment + segments[0] = targetCulture; + newPath = "/" + string.Join('/', segments); + } + else + { + // Prepend + if (path == "/") path = ""; + newPath = $"/{targetCulture}{path}"; + } + + Navigation.NavigateTo(newPath, forceLoad: true); + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor index 0e141bd0..0658f10c 100644 --- a/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor +++ b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor @@ -1,4 +1,5 @@ @inherits LayoutComponentBase +@inject IStringLocalizer L @@ -8,12 +9,12 @@ - + @@ -92,7 +94,7 @@ document.documentElement.setAttribute('data-theme', theme); } - + \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json new file mode 100644 index 00000000..2c210599 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json @@ -0,0 +1,8 @@ +{ + "Home": "Home", + "Solutions": "Solutions", + "Enterprise": "Enterprise", + "Company": "Company", + "GetInTouch": "Get in Touch", + "ToggleTheme": "Toggle theme" +} \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json new file mode 100644 index 00000000..43bc056c --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json @@ -0,0 +1,8 @@ +{ + "Home": "Trang chủ", + "Solutions": "Giải pháp", + "Enterprise": "Doanh nghiệp", + "Company": "Công ty", + "GetInTouch": "Liên hệ", + "ToggleTheme": "Đổi giao diện" +} \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Server/Program.cs b/apps/web-client-base-net/src/WebClientBase.Server/Program.cs index c128fdcb..c4d43548 100644 --- a/apps/web-client-base-net/src/WebClientBase.Server/Program.cs +++ b/apps/web-client-base-net/src/WebClientBase.Server/Program.cs @@ -3,6 +3,8 @@ /// VI: ASP.NET Core BFF (Backend for Frontend) với YARP Reverse Proxy. /// +using Microsoft.AspNetCore.Rewrite; + var builder = WebApplication.CreateBuilder(args); // ═══════════════════════════════════════════════════════════════════════════════ @@ -54,10 +56,18 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// EN: Enable CORS +// VI: Kích hoạt CORS // EN: Enable CORS // VI: Kích hoạt CORS app.UseCors("BlazorClient"); +// EN: Rewrite localized framework/content requests to root +// VI: Viết lại các yêu cầu framework/content từ đường dẫn ngôn ngữ về root +var rewriteOptions = new RewriteOptions() + .AddRewrite(@"^(en-US|vi-VN)/(_framework|_content)/(.*)", "$2/$3", skipRemainingRules: true); +app.UseRewriter(rewriteOptions); + // EN: Serve static files with fingerprinting support (.NET 10+) // VI: Phục vụ static files với hỗ trợ fingerprinting (.NET 10+) app.MapStaticAssets(); @@ -70,8 +80,44 @@ app.MapHealthChecks("/health"); // VI: Map các routes YARP Reverse Proxy đến microservices app.MapReverseProxy(); -// EN: Fallback to index.html for SPA routing -// VI: Fallback đến index.html cho SPA routing +// EN: Localization Support - Serve index.html with dynamic base tag for specific cultures +// VI: Hỗ trợ đa ngôn ngữ - Phục vụ index.html với base tag động cho các ngôn ngữ cụ thể +var supportedCultures = new[] { "en-US", "vi-VN" }; +var localizationOptions = new RequestLocalizationOptions() + .SetDefaultCulture("en-US") + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); + +app.UseRequestLocalization(localizationOptions); + +// Handle mapped culture routes (e.g. /en-US/home, /vi-VN/solutions) +app.Map("{culture:regex(^(en-US|vi-VN)$)}/{**slug}", async (string culture, HttpContext context, IWebHostEnvironment env) => +{ + // Try to find index.html + var fileInfo = env.WebRootFileProvider.GetFileInfo("index.html"); + if (!fileInfo.Exists) + { + // In Development with Hosted Blazor, index.html might not be in Server's wwwroot strictly directly depending on setup, + // but typically it is served via StaticFiles/BlazorFrameworkFiles. + // If we can't find it easily via IWebHostEnvironment in Dev, we might fail. + // However, for this task let's assume standard structure or handle gracefully. + return Results.NotFound("index.html not found in wwwroot. Ensure the Client project is built."); + } + + using var stream = fileInfo.CreateReadStream(); + using var reader = new StreamReader(stream); + var html = await reader.ReadToEndAsync(); + + // Replace base tag: -> + // Be robust with spaces or standard format + var modifiedHtml = html.Replace("", $"") + .Replace("", $""); + + return Results.Content(modifiedHtml, "text/html"); +}); + +// EN: Fallback to index.html for SPA routing (default culture) +// VI: Fallback đến index.html cho SPA routing (ngôn ngữ mặc định) app.MapFallbackToFile("index.html"); app.Run();