mcpField
L
localization-globalization

localization-globalization

github.com/wshaddix開発8672 回インストール35 週間前に更新
リポジトリ

localization-globalization について

ASP.NET Core Razor Pages の開発者向けに、ローカライズとグローバリゼーションのパターンをまとめたスキルです。リソース管理、カルチャ設定、書式、言語切り替え、複数形、右から左へのレイアウトを扱います。

主な機能

  1. 01機能別のリソースファイル構成
  2. 02カルチャ検出とフォールバック設定
  3. 03Razor Pages のビューとモデルのローカライズ
  4. 04カルチャに応じた日付・数値・通貨の書式
  5. 05言語切り替えと選択の保持
  6. 06複数形と RTL レイアウトのサポート

活用シーン

  1. 01Razor Pages への多言語対応の追加
  2. 02ユーザーのカルチャに応じた値の書式設定
  3. 03選択を保持する言語スイッチャーの実装
  4. 04複数形と RTL 言語への対応

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill localization-globalization

インストール後、Claude Code で/localization-globalizationを入力して読み込まれたか確認します。

詳細

理由

グローバルアプリケーションでは、複数の言語、文化、書式規則への対応が必要です。ローカライゼーションの実装が不十分だと、メンテナンスが困難になり、ユーザーエクスペリエンスに一貫性がなくなるほか、日付、数値、通貨に関する見つけにくいバグが発生します。これらのパターンは、真にグローバルな Razor Pages アプリケーションを保守しやすい形で構築するためのアプローチを示します。

パターン

パターン 1:リソースファイルの構成

適切な命名規則とカルチャー階層に従って、機能ごとにリソースを整理します。

/Pages
  /Shared
    _Layout.cshtml
    Resources/
      _Layout.resx          (default/en)
      _Layout.es.resx       (Spanish)
      _Layout.fr.resx       (French)
      _Layout.de.resx       (German)
  /Account
    Login.cshtml
    Login.cshtml.cs
    Resources/
      Login.resx
      Login.es.resx
      Login.fr.resx
  /Products
    Index.cshtml
    Resources/
      Index.resx
      Index.es.resx
/Resources  (Shared resources)
  SharedResources.resx
  SharedResources.es.resx
  ValidationMessages.resx
// SharedResources.cs - Type-safe resource access
public class SharedResources
{
    // This class is just a marker for IStringLocalizer<SharedResources>
}

// Strongly-typed resources with code generator
public static class ResourceKeys
{
    public const string WelcomeMessage = "WelcomeMessage";
    public const string SaveButton = "SaveButton";
    public const string CancelButton = "CancelButton";
    public const string ErrorOccurred = "ErrorOccurred";
    public const string RequiredField = "RequiredField";
}
パターン 2:ローカライゼーションの構成

適切なカルチャー検出とフォールバックを設定して、リクエストのローカライゼーションを構成します。

// Program.cs
var supportedCultures = new[]
{
    new CultureInfo("en-US"),
    new CultureInfo("en-GB"),
    new CultureInfo("es-ES"),
    new CultureInfo("es-MX"),
    new CultureInfo("fr-FR"),
    new CultureInfo("de-DE")
};

builder.Services.AddLocalization(options =>
{
    options.ResourcesPath = "Resources";
});

builder.Services.AddRequestLocalization(options =>
{
    options.DefaultRequestCulture = new RequestCulture("en-US");
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
    
    // Culture detection order:
    // 1. Query string (?culture=es-ES)
    // 2. Cookie (.AspNetCore.Culture)
    // 3. Accept-Language header
    // 4. Default culture
    options.RequestCultureProviders = new List<IRequestCultureProvider>
    {
        new QueryStringRequestCultureProvider(),
        new CookieRequestCultureProvider(),
        new AcceptLanguageHeaderRequestCultureProvider()
    };
});

// Register view localization
builder.Services.AddRazorPages()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
    .AddDataAnnotationsLocalization(options =>
    {
        options.DataAnnotationLocalizerProvider = (type, factory) =>
            factory.Create(typeof(SharedResources));
    });

// Middleware placement (must be before routing)
var app = builder.Build();
app.UseRequestLocalization();
app.UseRouting();
app.MapRazorPages();
パターン 3:Razor Pages のローカライゼーション

適切なリソースの注入を行い、ビューと PageModel のローカライゼーションを実装します。

// PageModel with localization
public class ProductEditModel : PageModel
{
    private readonly IStringLocalizer<ProductEditModel> _localizer;
    private readonly IStringLocalizer<SharedResources> _sharedLocalizer;
    private readonly ILogger<ProductEditModel> _logger;

    public ProductEditModel(
        IStringLocalizer<ProductEditModel> localizer,
        IStringLocalizer<SharedResources> sharedLocalizer,
        ILogger<ProductEditModel> logger)
    {
        _localizer = localizer;
        _sharedLocalizer = sharedLocalizer;
        _logger = logger;
    }

    [BindProperty]
    public ProductInput Input { get; set; } = new();

    public string PageTitle => _localizer["EditProductTitle"];

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        try
        {
            await SaveProductAsync(Input);
            
            TempData["SuccessMessage"] = _localizer["ProductSaved"];
            return RedirectToPage("/Products/Index");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to save product");
            ModelState.AddModelError(string.Empty, _sharedLocalizer["ErrorOccurred"]);
            return Page();
        }
    }
}

// View with localization
@page "{id:guid}"
@model ProductEditModel
@inject IStringLocalizer<SharedResources> SharedLocalizer
@inject IViewLocalizer ViewLocalizer

@{
    ViewData["Title"] = Model.PageTitle;
}

<h1>@ViewLocalizer["EditProductTitle"]</h1>

<form method="post">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    
    <div class="form-group">
        <label asp-for="Input.Name">@ViewLocalizer["ProductName"]</label>
        <input asp-for="Input.Name" class="form-control" 
               placeholder="@ViewLocalizer["NamePlaceholder"]" />
        <span asp-validation-for="Input.Name" class="text-danger"></span>
    </div>
    
    <div class="form-group">
        <label asp-for="Input.Price">@ViewLocalizer["Price"]</label>
        <input asp-for="Input.Price" class="form-control" 
               type="number" step="0.01" />
        <small class="form-text text-muted">
            @ViewLocalizer["PriceHelpText"]
        </small>
    </div>
    
    <button type="submit" class="btn btn-primary">
        @SharedLocalizer["SaveButton"]
    </button>
    <a asp-page="/Products/Index" class="btn btn-secondary">
        @SharedLocalizer["CancelButton"]
    </a>
</form>
パターン 4:カルチャーに応じた書式設定

日付、数値、通貨には、カルチャーに応じた書式設定を使用します。

// Extension methods for consistent formatting
public static class FormattingExtensions
{
    public static string ToCurrency(this decimal amount, IFormatProvider? provider = null)
    {
        return amount.ToString("C", provider ?? CultureInfo.CurrentCulture);
    }

    public static string ToShortDate(this DateTime date, IFormatProvider? provider = null)
    {
        return date.ToString("d", provider ?? CultureInfo.CurrentCulture);
    }

    public static string ToLongDate(this DateTime date, IFormatProvider? provider = null)
    {
        return date.ToString("D", provider ?? CultureInfo.CurrentCulture);
    }

    public static string ToCompactNumber(this int number, IFormatProvider? provider = null)
    {
        return number.ToString("N0", provider ?? CultureInfo.CurrentCulture);
    }
}

// View usage
@inject IStringLocalizer<SharedResources> Localizer

<div class="product-details">
    <p>@Localizer["PriceLabel"]: @Model.Product.Price.ToCurrency()</p>
    <p>@Localizer["AvailableFrom"]: @Model.Product.AvailableDate.ToLongDate()</p>
    <p>@Localizer["StockQuantity"]: @Model.Product.Stock.ToCompactNumber()</p>
</div>

// Culture-specific validation messages
public class ProductInput
{
    [Required(ErrorMessageResourceName = "RequiredField", 
              ErrorMessageResourceType = typeof(SharedResources))]
    [StringLength(100, ErrorMessageResourceName = "MaxLength",
                  ErrorMessageResourceType = typeof(SharedResources))]
    public required string Name { get; set; }

    [Range(0.01, 999999.99, ErrorMessageResourceName = "InvalidPrice",
           ErrorMessageResourceType = typeof(SharedResources))]
    [DataType(DataType.Currency)]
    public decimal Price { get; set; }

    [DataType(DataType.Date)]
    [Display(ResourceType = typeof(SharedResources), Name = "AvailableDateLabel")]
    public DateTime AvailableDate { get; set; }
}
パターン 5:言語切り替え機能の実装

カルチャーの選択を保持する言語切り替え機能を作成します。

// Culture controller for switching languages
public class CultureController : Controller
{
    [HttpPost]
    public IActionResult SetCulture(string culture, string returnUrl)
    {
        if (!string.IsNullOrEmpty(culture))
        {
            Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
            );
        }

        return LocalRedirect(returnUrl ?? "/");
    }
}

// Language switcher partial view (_LanguageSwitcher.cshtml)
@inject IOptions<RequestLocalizationOptions> LocOptions

@{
    var requestCulture = Context.Features.Get<IRequestCultureFeature>()!;
    var cultureItems = LocOptions.Value.SupportedUICultures!
        .Select(c => new SelectListItem { 
            Value = c.Name, 
            Text = c.DisplayName,
            Selected = c.Name == requestCulture.RequestCulture.UICulture.Name
        })
        .ToList();
    var returnUrl = string.IsNullOrEmpty(Context.Request.Path) 
        ? "~/" 
        : $"~{Context.Request.Path.Value}{Context.Request.QueryString}";
}

<form asp-controller="Culture" asp-action="SetCulture" 
      asp-route-returnUrl="@returnUrl" method="post" 
      class="form-inline">
    <select name="culture" 
            class="form-control form-control-sm" 
            onchange="this.form.submit();"
            asp-for="@requestCulture.RequestCulture.UICulture.Name" 
            asp-items="cultureItems">
    </select>
</form>

// Include in _Layout.cshtml
<div class="navbar-nav">
    <partial name="_LanguageSwitcher" />
</div>
パターン 6:複数形と複雑な文字列

複数形とパラメーター付き文字列を正しく処理します。

// Using Humanizer for pluralization
public static class LocalizationHelpers
{
    public static string FormatItemsCount(IStringLocalizer localizer, int count)
    {
        var key = count switch
        {
            0 => "ItemsCountZero",
            1 => "ItemsCountOne",
            _ => "ItemsCountMany"
        };
        
        return localizer[key, count];
    }

    public static string FormatTimeRemaining(IStringLocalizer localizer, TimeSpan time)
    {
        if (time.TotalDays >= 1)
            return localizer["DaysRemaining", (int)time.TotalDays];
        if (time.TotalHours >= 1)
            return localizer["HoursRemaining", (int)time.TotalHours];
        if (time.TotalMinutes >= 1)
            return localizer["MinutesRemaining", (int)time.TotalMinutes];
        
        return localizer["LessThanMinute"];
    }
}

// Resource file entries
// ItemsCountZero: "No items"
// ItemsCountOne: "One item"
// ItemsCountMany: "{0} items"
// DaysRemaining: "{0} days remaining"
// HoursRemaining: "{0} hours remaining"
// MinutesRemaining: "{0} minutes remaining"
// LessThanMinute: "Less than a minute"

// View usage
<p>@LocalizationHelpers.FormatItemsCount(Localizer, Model.Items.Count)</p>
<p>@LocalizationHelpers.FormatTimeRemaining(Localizer, Model.TimeRemaining)</p>
パターン 7:RTL(右から左)への対応

適切な CSS とレイアウトを使用して、アラビア語やヘブライ語などの RTL 言語に対応します。

// View component for RTL detection
public class RtlLayoutViewComponent : ViewComponent
{
    private static readonly string[] RtlCultures = { "ar", "he", "fa", "ur" };

    public IViewComponentResult Invoke()
    {
        var currentCulture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
        var isRtl = RtlCultures.Contains(currentCulture);
        
        return View(isRtl);
    }
}

// _Layout.cshtml modifications
@inject IViewLocalizer Localizer

@{
    var isRtl = CultureInfo.CurrentCulture.TextInfo.IsRightToLeft;
    var dir = isRtl ? "rtl" : "ltr";
}

<!DOCTYPE html>
<html lang="@CultureInfo.CurrentCulture.TwoLetterISOLanguageName" dir="@dir">
<head>
    @if (isRtl)
    {
        <link rel="stylesheet" href="~/css/bootstrap.rtl.min.css" />
        <link rel="stylesheet" href="~/css/site.rtl.css" />
    }
    else
    {
        <link rel="stylesheet" href="~/css/bootstrap.min.css" />
        <link rel="stylesheet" href="~/css/site.css" />
    }
</head>
<body>
    
</body>
</html>

// CSS with logical properties (works for both LTR and RTL)
.sidebar {
    margin-inline-start: 1rem; /* Replaces margin-left */
    padding-inline-end: 0.5rem; /* Replaces padding-right */
    border-inline-start: 1px solid #ddd;
}

アンチパターン

// ❌ BAD: Hard-coded strings
public string GetMessage() => "Hello, World!";

// ✅ GOOD: Use localizer
public string GetMessage() => _localizer["HelloWorld"];

// ❌ BAD: Concatenating strings for pluralization
var message = $"You have {count} item{(count != 1 ? "s" : "")}";

// ✅ GOOD: Use resource keys for each case
var message = count switch
{
    0 => _localizer["NoItems"],
    1 => _localizer["OneItem"],
    _ => _localizer["ManyItems", count]
};

// ❌ BAD: Formatting without culture
var price = $"${amount:F2}"; // Wrong currency symbol, wrong format

// ✅ GOOD: Culture-aware formatting
var price = amount.ToString("C", CultureInfo.CurrentCulture);

// ❌ BAD: Storing culture in session
HttpContext.Session.SetString("Culture", "es-ES"); // Not thread-safe

// ✅ GOOD: Use built-in cookie or query string providers
Response.Cookies.Append(
    CookieRequestCultureProvider.DefaultCookieName,
    CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("es-ES")));

// ❌ BAD: Not providing fallback resources
// Only es-ES.resx exists, user requests es-MX

// ✅ GOOD: Provide neutral culture resources
// es.resx (neutral) + es-ES.resx (specific) + es-MX.resx (specific)

// ❌ BAD: Loading all resources on startup
foreach (var culture in supportedCultures)
{
    var resources = LoadResourcesForCulture(culture); // Expensive!
}

// ✅ GOOD: Let ASP.NET Core lazy-load resources as needed

// ❌ BAD: Mixing UI culture and data culture
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");    // Numbers/dates
Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES");  // Resources

// ✅ GOOD: Usually keep them in sync unless you have specific requirements
var culture = new CultureInfo(userPreferredLanguage);
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;

// ❌ BAD: Not validating culture codes
public void SetCulture(string culture)
{
    CultureInfo.CurrentCulture = new CultureInfo(culture); // Throws on invalid!
}

// ✅ GOOD: Validate against supported cultures
public bool TrySetCulture(string cultureCode)
{
    var culture = supportedCultures.FirstOrDefault(c => 
        c.Name.Equals(cultureCode, StringComparison.OrdinalIgnoreCase));
    
    if (culture is null) return false;
    
    CultureInfo.CurrentCulture = culture;
    CultureInfo.CurrentUICulture = culture;
    return true;
}

参考資料

コメント