diff --git a/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs b/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs
new file mode 100644
index 0000000..40d3e6b
--- /dev/null
+++ b/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs
@@ -0,0 +1,17 @@
+namespace OliverBooth.Middleware;
+
+///
+/// Extension methods for .
+///
+internal static class CultureRedirectExtensions
+{
+ ///
+ /// Adds the to the application's request pipeline.
+ ///
+ /// The application builder.
+ /// The application builder.
+ public static IApplicationBuilder UseCultureRedirect(this IApplicationBuilder builder)
+ {
+ return builder.UseMiddleware();
+ }
+}
diff --git a/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs b/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs
new file mode 100644
index 0000000..a1d8612
--- /dev/null
+++ b/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+
+namespace OliverBooth.Middleware;
+
+///
+/// Redirects requests to the default culture if the culture is not specified in the URL.
+///
+internal sealed class CultureRedirectMiddleware
+{
+ private readonly RequestDelegate _next;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The next request delegate.
+ public CultureRedirectMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ ///
+ /// Invokes the middleware.
+ ///
+ /// The HTTP context.
+ public async Task Invoke(HttpContext context)
+ {
+ const StringComparison comparison = StringComparison.OrdinalIgnoreCase;
+
+ HttpRequest request = context.Request;
+ PathString requestPath = request.Path;
+
+ if (requestPath.HasValue)
+ {
+ string[] pathSegments = requestPath.Value.Split('/');
+ CultureInfo[] cultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
+
+ if (pathSegments.Length >= 2 && cultures.Any(CultureMatch))
+ {
+ await _next(context);
+ return;
+ }
+
+ bool CultureMatch(CultureInfo culture)
+ {
+ string segment = pathSegments[1].Split('-')[0];
+ return string.Equals(culture.TwoLetterISOLanguageName, segment, comparison);
+ }
+ }
+
+ const string defaultCulture = "en";
+ var redirectUrl = $"/{defaultCulture}{requestPath}{request.QueryString}";
+ context.Response.Redirect(redirectUrl);
+ }
+}
\ No newline at end of file
diff --git a/oliverbooth.dev/Pages/Error.cshtml b/oliverbooth.dev/Pages/Error.cshtml
index b5105b6..2296a01 100644
--- a/oliverbooth.dev/Pages/Error.cshtml
+++ b/oliverbooth.dev/Pages/Error.cshtml
@@ -1,4 +1,4 @@
-@page
+@page "/{culture=en}/error/{code:int?}"
@model ErrorModel
@{
ViewData["Title"] = "Error";
diff --git a/oliverbooth.dev/Pages/Index.cshtml b/oliverbooth.dev/Pages/Index.cshtml
index fd28464..be8b96a 100644
--- a/oliverbooth.dev/Pages/Index.cshtml
+++ b/oliverbooth.dev/Pages/Index.cshtml
@@ -1,4 +1,4 @@
-@page
+@page "/{culture=en}"
@model IndexModel
@{
ViewData["Title"] = "Home page";
diff --git a/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml b/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml
index 452c86f..812f954 100644
--- a/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml
+++ b/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml
@@ -1,4 +1,4 @@
-@page "/privacy/google-play"
+@page "/{culture=en}/privacy/google-play"
@model OliverBooth.Pages.Privacy.GooglePlay
@{
ViewData["Title"] = "Google Play Privacy Policy";
diff --git a/oliverbooth.dev/Pages/Privacy/Index.cshtml b/oliverbooth.dev/Pages/Privacy/Index.cshtml
index fae874a..3e34a50 100644
--- a/oliverbooth.dev/Pages/Privacy/Index.cshtml
+++ b/oliverbooth.dev/Pages/Privacy/Index.cshtml
@@ -1,4 +1,4 @@
-@page
+@page "/{culture=en}/privacy"
@model OliverBooth.Pages.Privacy.Index
@{
ViewData["Title"] = "Privacy Policy";
diff --git a/oliverbooth.dev/Program.cs b/oliverbooth.dev/Program.cs
index 7b26c0c..0b42b14 100644
--- a/oliverbooth.dev/Program.cs
+++ b/oliverbooth.dev/Program.cs
@@ -1,8 +1,24 @@
+using Microsoft.AspNetCore.Localization;
+using OliverBooth.Middleware;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
-builder.Services.AddRazorPages();
+builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
+builder.Services.AddRazorPages().AddViewLocalization().AddDataAnnotationsLocalization();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
+var supportedCultures = new[]
+{
+ CultureInfo.GetCultureInfo("en"),
+ CultureInfo.GetCultureInfo("fr")
+};
+
+builder.Services.Configure(options =>
+{
+ options.DefaultRequestCulture = new RequestCulture(supportedCultures[0]);
+ options.SupportedCultures = supportedCultures;
+ options.SupportedUICultures = supportedCultures;
+});
+
WebApplication app = builder.Build();
if (!app.Environment.IsDevelopment())
@@ -17,6 +33,17 @@ app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
+
+app.UseRequestLocalization(options =>
+{
+ options.ApplyCurrentCultureToResponseHeaders = true;
+ options.DefaultRequestCulture = new RequestCulture(supportedCultures[0]);
+ options.SupportedCultures = supportedCultures;
+ options.SupportedUICultures = supportedCultures;
+ options.RequestCultureProviders.Insert(0, new RouteCultureProvider(supportedCultures[0]));
+});
+app.UseCultureRedirect();
+app.MapControllerRoute("default", "{culture=en}/{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.Run();
diff --git a/oliverbooth.dev/RouteCultureProvider.cs b/oliverbooth.dev/RouteCultureProvider.cs
new file mode 100644
index 0000000..2629111
--- /dev/null
+++ b/oliverbooth.dev/RouteCultureProvider.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Localization;
+
+namespace OliverBooth;
+
+internal sealed partial class RouteCultureProvider : IRequestCultureProvider
+{
+ private static readonly Regex CultureRegex = GetCultureRegex();
+ private readonly CultureInfo _defaultCulture;
+ private readonly CultureInfo _defaultUiCulture;
+
+ public RouteCultureProvider(CultureInfo requestCulture) : this(new RequestCulture(requestCulture))
+ {
+ }
+
+ public RouteCultureProvider(RequestCulture requestCulture)
+ {
+ _defaultCulture = requestCulture.Culture;
+ _defaultUiCulture = requestCulture.UICulture;
+ }
+
+ public Task DetermineProviderCultureResult(HttpContext httpContext)
+ {
+ PathString url = httpContext.Request.Path;
+
+ string defaultCulture = _defaultCulture.TwoLetterISOLanguageName;
+ string defaultUiCulture = _defaultUiCulture.TwoLetterISOLanguageName;
+
+ if (url.ToString().Length <= 1)
+ {
+ return Task.FromResult(new ProviderCultureResult(defaultCulture, defaultUiCulture))!;
+ }
+
+ string[]? parts = httpContext.Request.Path.Value?.Split('/');
+ string requestCulture = parts?[1] ?? string.Empty;
+
+ bool isMatch = CultureRegex.IsMatch(requestCulture);
+ string culture = isMatch ? requestCulture : defaultCulture;
+ string uiCulture = isMatch ? requestCulture : defaultUiCulture;
+
+ return Task.FromResult(new ProviderCultureResult(culture, uiCulture))!;
+ }
+
+ [GeneratedRegex("^[a-z]{2}(-[A-Z]{2})*$")]
+ private static partial Regex GetCultureRegex();
+}