Introduction

Quand j'ai commencé à industrialiser ma bibliothèque SuperBlazorComponents, je me suis vite heurté à un problème classique : tous mes composants (SuperDataGrid, SuperDialogs, SuperDateRangePicker, SuperNotifications, SuperTabs, etc.) contenaient des libellés en dur, en français, parfois en anglais. Pour une bibliothèque destinée à être publiée sur NuGet et utilisée par d'autres équipes, ce n'était évidemment pas acceptable.

Je voulais quelque chose de simple, sans dépendance lourde, qui s'appuie sur le contrat standard IStringLocalizer de .NET, mais sans imposer aux consommateurs de la lib la machinerie habituelle des fichiers .resx. J'ai donc construit un petit moteur de localisation basé sur des fichiers JSON embarqués, extensible avec des sources externes, et entièrement compatible avec l'écosystème ASP.NET Core / Blazor.

Dans cet article, je vous explique :

  1. Comment utiliser IStringLocalizer côté composant Blazor
  2. L'architecture que j'ai retenue (factory + localizer JSON + options)
  3. Comment enregistrer le tout via AddSuperComponents()
  4. Comment ajouter des langues supplémentaires depuis l'application consommatrice
  5. Quelques exemples concrets en C# et Razor

1. Comment utiliser IStringLocalizer dans un composant

IStringLocalizer est l'interface standard de Microsoft.Extensions.Localization. Elle expose deux indexeurs et une méthode GetAllStrings. Voici sa signature simplifiée :

public interface IStringLocalizer
{
    LocalizedString this[string name] { get; }
    LocalizedString this[string name, params object[] arguments] { get; }
    IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures);
}

L'objet LocalizedString retourné se comporte comme une string (il a un opérateur implicite vers string), ce qui le rend très pratique en Razor.

Utilisation dans un composant Razor

Dans n'importe quel composant SuperBlazorComponents, j'injecte simplement IStringLocalizer et je l'appelle via la propriété Loc :

@inject IStringLocalizer Loc

<button class="btn btn-secondary btn-sm dropdown-toggle"
        title="@Loc["DataGrid.Columns.Visible"]">
    <i class="fa-solid fa-eye"></i>
    <span class="ms-1">@Loc["DataGrid.Columns.Label"]</span>
</button>

Pour des libellés paramétrés, j'utilise le second indexeur, qui s'appuie sur string.Format :

<span>@Loc["DataGrid.Selection.Count", selectedCount]</span>

Côté ressources, la clé DataGrid.Selection.Count est définie ainsi :

"DataGrid.Selection.Count": "Sélection ({0})"

Pourquoi pas IStringLocalizer<T> ?

Le générique IStringLocalizer<T> est très utile quand chaque type a son propre fichier de ressources. Dans mon cas, toutes les traductions de la lib partagent une seule source JSON par culture, donc je n'ai pas besoin de typer le localizer. J'enregistre une instance unique de IStringLocalizer non générique, ce qui simplifie l'injection dans tous les composants.


2. L'architecture retenue

L'idée directrice : un localizer JSON alimenté par des ressources embarquées dans la DLL, surchargeable par des sources externes fournies par l'application hôte.

flowchart LR
    A[Composant Razor] -->|inject| B[IStringLocalizer]
    B --> C[JsonStringLocalizer]
    C --> D[Cache par culture<br/>ConcurrentDictionary]
    D --> E[Embedded Resources<br/>SuperBlazorComponents.fr.json<br/>SuperBlazorComponents.en.json]
    D --> F[ExternalSources<br/>JSON file ou Stream]
    G[IStringLocalizerFactory] -->|Create| C
    H[SuperLocalizationOptions] --> C
    H --> G

Trois pièces principales :

Composant Rôle
JsonStringLocalizer Implémentation de IStringLocalizer. Charge les JSON, gère le fallback de culture, met en cache.
JsonStringLocalizerFactory Implémentation de IStringLocalizerFactory. Mutualise les instances.
SuperLocalizationOptions Options exposées au consommateur : culture par défaut, sources externes.

Résolution d'une clé

Le mécanisme de résolution suit la stratégie classique culture exacte → culture parente → culture par défaut :

flowchart TD
    Start([Loc Ma.Cle]) --> A{Trouvee dans<br/>CurrentUICulture<br/>ex fr-CA ?}
    A -- Oui --> Found[Retourne la valeur]
    A -- Non --> B{Trouvee dans<br/>la culture parente<br/>ex fr ?}
    B -- Oui --> Found
    B -- Non --> C{Trouvee dans<br/>la culture par defaut<br/>fr ?}
    C -- Oui --> Found
    C -- Non --> NotFound[Retourne la cle<br/>resourceNotFound true]

3. Le JsonStringLocalizer

Voici les points essentiels de l'implémentation. La classe est internal sealed, thread-safe via un ConcurrentDictionary, et elle ne charge un fichier de culture qu'à la première demande.

internal sealed class JsonStringLocalizer : IStringLocalizer
{
    private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache
        = new(StringComparer.OrdinalIgnoreCase);
    private readonly SuperLocalizationOptions _options;
    private readonly string _defaultCulture;

    public JsonStringLocalizer(SuperLocalizationOptions options)
    {
        _options = options;
        _defaultCulture = options.DefaultCulture;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var value = GetString(name);
            return new LocalizedString(name, value ?? name, resourceNotFound: value is null);
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var value = GetString(name);
            var formatted = value is not null
                ? string.Format(CultureInfo.CurrentUICulture, value, arguments)
                : name;
            return new LocalizedString(name, formatted, resourceNotFound: value is null);
        }
    }
}

Chargement combiné : embedded + externe

Le chargement effectif fusionne deux niveaux : d'abord les ressources embarquées dans l'assembly (fournies par la bibliothèque), puis les sources externes (fournies par l'application consommatrice). Les sources externes peuvent surcharger une clé existante sans modifier la bibliothèque.

Voici la logique de fusion :

private Dictionary<string, string> LoadTranslations(string cultureName)
{
    var result = new Dictionary<string, string>(StringComparer.Ordinal);

    // 1. Chargement des ressources embarquées (définies dans la lib)
    LoadEmbeddedResource(cultureName, result);

    // 2. Chargement des sources externes (fournies par l'application)
    // Les clés externes écrasent celles de la lib si elles existent déjà
    LoadExternalSources(cultureName, result);

    return result;
}

Chargement des ressources embarquées (LoadEmbeddedResource)

Cette méthode tente de lire le fichier JSON depuis le manifeste de l'assembly. Elle essaie d'abord la culture complète (ex: fr-CA), puis la culture courte (ex: fr).

private static void LoadEmbeddedResource(string cultureName, Dictionary<string, string> target)
{
    var assembly = typeof(JsonStringLocalizer).Assembly;
    // Préfixe complet basé sur l'organisation des dossiers dans le projet
    var resourcePrefix = "SuperBlazorComponents.Localization.Resources.SuperBlazorComponents";

    // Essai avec le nom complet, puis le code court
    string[] candidates = [cultureName, GetLanguageCode(cultureName)];

    foreach (var candidate in candidates)
    {
        var resourceName = $"{resourcePrefix}.{candidate}.json";
        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream is not null)
        {
            MergeJsonStream(stream, target);
            return; // On s'arrête au premier trouvé
        }
    }
}

Chargement des sources externes (LoadExternalSources)

Les sources externes sont définies via SuperLocalizationOptions. Elles permettent à l'application de fournir ses propres fichiers JSON ou des flux (Streams).

private void LoadExternalSources(string cultureName, Dictionary<string, string> target)
{
    foreach (var source in _options.ExternalSources)
    {
        if (source.CultureCode.Equals(cultureName, StringComparison.OrdinalIgnoreCase))
        {
            if (source.Stream != null)
            {
                MergeJsonStream(source.Stream, target);
            }
            else if (File.Exists(source.FilePath))
            {
                using var stream = File.OpenRead(source.FilePath);
                MergeJsonStream(stream, target);
            }
        }
    }
}

Fusion des données (MergeJsonStream)

La méthode de fusion lit le JSON et met à jour le dictionnaire. Si une clé existe déjà (venant de l'embedded), elle est remplacée par la valeur externe.

private static void MergeJsonStream(Stream stream, Dictionary<string, string> target)
{
    using var reader = new StreamReader(stream);
    var json = reader.ReadToEnd();
    var doc = JsonDocument.Parse(json);

    foreach (var property in doc.RootElement.EnumerateObject())
    {
        if (property.Value.ValueKind == JsonValueKind.String)
        {
            // La source externe prend le pas sur l'embedded si la clé existe déjà
            target[property.Name] = property.Value.GetString()!;
        }
    }
}

Le code des ressources embarquées exploite Assembly.GetManifestResourceStream. Les fichiers sont déclarés dans le .csproj :

<ItemGroup>
  <EmbeddedResource Include="Localization\Resources\*.json" WithCulture="false" />
</ItemGroup>

WithCulture="false" est important : sans cela, MSBuild essaie d'interpréter SuperBlazorComponents.fr.json comme une ressource satellite, ce qui pollue les répertoires fr/. Ici, je veux un nom de manifeste plat et lisible : SuperBlazorComponents.Localization.Resources.SuperBlazorComponents.fr.json.


4. La JsonStringLocalizerFactory

La factory mutualise les instances par clé (nom de type ou base name). Toutes partagent les mêmes options, donc finalement les mêmes traductions :

internal sealed class JsonStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly ConcurrentDictionary<string, IStringLocalizer> _cache
        = new(StringComparer.Ordinal);
    private readonly SuperLocalizationOptions _options;

    public JsonStringLocalizerFactory(SuperLocalizationOptions options)
    {
        _options = options;
    }

    public IStringLocalizer Create(Type resourceSource)
        => _cache.GetOrAdd(
            resourceSource.FullName ?? resourceSource.Name,
            _ => new JsonStringLocalizer(_options));

    public IStringLocalizer Create(string baseName, string location)
        => _cache.GetOrAdd($"{location}.{baseName}",
            _ => new JsonStringLocalizer(_options));
}

5. Les options : SuperLocalizationOptions

C'est le point d'extension public. Le consommateur peut ajouter des langues supplémentaires, soit depuis un fichier sur disque, soit depuis un Stream (utile pour les ressources embarquées de l'application hôte) :

public sealed class SuperLocalizationOptions
{
    public string DefaultCulture { get; set; } = "fr";

    public SuperLocalizationOptions AddJsonFile(string filePath, string cultureCode) { /* ... */ }
    public SuperLocalizationOptions AddJsonStream(Stream jsonStream, string cultureCode) { /* ... */ }
}

6. Enregistrement dans le DI

Tout est branché dans AddSuperComponents(). La configuration utilisateur est appliquée, puis les services sont enregistrés en singleton :

public static IServiceCollection AddSuperComponents(
    this IServiceCollection services,
    Action<SuperComponentsConfiguration>? options = null)
{
    var configuration = new SuperComponentsConfiguration();
    options?.Invoke(configuration);

    services.AddSingleton(configuration);
    services.AddSingleton(configuration.Localization);
    services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
    services.AddSingleton<IStringLocalizer>(sp =>
        sp.GetRequiredService<IStringLocalizerFactory>()
          .Create(typeof(StartupExtensions)));

    // ... autres services
    return services;
}

Le diagramme de séquence côté application :

sequenceDiagram
    participant App as Program.cs
    participant DI as IServiceCollection
    participant Factory as JsonStringLocalizerFactory
    participant Loc as JsonStringLocalizer
    participant Comp as Composant Razor

    App->>DI: AddSuperComponents(opts)
    DI->>DI: enregistre Options, Factory, IStringLocalizer
    Comp->>DI: inject IStringLocalizer
    DI->>Factory: Create(typeof StartupExtensions)
    Factory->>Loc: new JsonStringLocalizer(options)
    Loc-->>Comp: instance partagee
    Comp->>Loc: Loc DataGrid.Columns.Label
    Loc-->>Comp: Colonnes

7. Exemple côté application consommatrice

Côté Program.cs, l'utilisation reste minimale. Si on se contente du français et de l'anglais, il n'y a rien à configurer :

builder.Services.AddSuperComponents();

Pour ajouter de l'allemand depuis un fichier, et basculer la culture par défaut sur l'anglais :

builder.Services.AddSuperComponents(options =>
{
    options.Localization.DefaultCulture = "en";
    options.Localization.AddJsonFile("Localization/SuperBlazorComponents.de.json", "de");
});

Ou, plus propre, en chargeant un JSON embarqué dans l'assembly de l'application :

builder.Services.AddSuperComponents(options =>
{
    var asm = typeof(Program).Assembly;
    var stream = asm.GetManifestResourceStream("MyApp.Resources.Super.de.json");
    if (stream is not null)
    {
        options.Localization.AddJsonStream(stream, "de");
    }
});

Et bien sûr, il faut configurer la Request Localization d'ASP.NET Core comme d'habitude, pour que CultureInfo.CurrentUICulture soit correctement positionnée à chaque requête / circuit Blazor :

var supportedCultures = new[] { "fr", "en", "de" };
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.SetDefaultCulture(supportedCultures[0])
           .AddSupportedCultures(supportedCultures)
           .AddSupportedUICultures(supportedCultures);
});

// ...
app.UseRequestLocalization();

8. Surcharger une traduction existante

Comme les sources externes sont fusionnées par-dessus les ressources embarquées, il suffit de fournir un fichier JSON avec uniquement les clés à modifier. Par exemple, pour changer le libellé du bouton « Colonnes » uniquement dans une application :

Localization/Super.fr.overrides.json :

{
  "DataGrid.Columns.Label": "Champs",
  "DataGrid.Columns.Visible": "Champs visibles"
}
options.Localization.AddJsonFile("Localization/Super.fr.overrides.json", "fr");

Aucune autre clé n'est touchée, le fallback continue d'opérer sur les ressources embarquées.


9. Bilan

Ce que j'apprécie après quelques mois d'utilisation :

  • Zéro configuration pour les apps qui se contentent du français et de l'anglais.
  • Contrat standard : tout passe par IStringLocalizer, ce qui rend la lib remplaçable / testable.
  • Extensibilité forte : ajout de langues sans recompiler, surcharge de clés sans rebuild.
  • Performance : chargement paresseux par culture, cache ConcurrentDictionary, parsing JSON une seule fois.
  • Footprint minimal : pas de dépendance externe, juste Microsoft.Extensions.Localization (déjà dans ASP.NET Core).

Si vous construisez vous aussi une bibliothèque de composants Blazor et que vous hésitez entre .resx et un système plus moderne, je vous recommande chaudement cette approche JSON embarqué. Elle reste alignée sur les standards .NET tout en évitant la friction des ressources satellites.


Ressources

À très vite pour un prochain article — sans doute sur la persistance des préférences du SuperDataGrid !