This library is helpful for .NET developers that creates applications intended for an international market.
This library is developed to support the follwing scenarios:
- A unified model for retriving translations from a varity of language resource types.
- A centralised language translation service. It is posible to have all resources in one project, also accessible from GUI and other usage.
- Provide consistent ways in code to retrieve translations.
- Easy to add new language translations, eventually with help from AI.
- Full control over translations, which is specially important in applications targeting special domain areas where terminology is important.
When devloping cross-platform applications, it is important that localisation behaves consistent on each platform.
.NET 10 uses the International Components for Unicode (ICU) which is supportet on both Windows and Linux including macOS. This ensure consistent behaviour. Minor differences may occur depending on what version of ICU that is installed on the machine the app runs.
In order for applications to use localized resources, invariant globalisation must be turned off. This can be declared in the project file. If omitted,the default behaviour is that invariant globaisation is off, so you don't need to declare it explicit as in the example below.
<PropertyGroup>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>This library has an extensible model for adding new sources of language resources, Resource Providers. This library implements three resource providers;
- ResxResourceProvider for getting resources from .NET RESX-files. This provider uses standard .NET mechanism - the Resource Manager class.
- MarkdownResourceProvider gets resources in form of markdown files with a naming convenstion resourcename.language/culture.md. Markdown files should be structure in a base-folder, but can contain sub-folders.
- ObjectResourceProvider uses reflection to find string properties that matches the language, and returns the text of the property. This is useful for example when you load an object from a database with columns for each supported language.
You can add new providers, for example getting trainslations online or in other file formats.
You must define a default language. This will be the fallback if a translation for the specific language is not found.
When seaching for a translation of a specific resource key, the search uses a fallback mechanism:
- Use specific language with culture, example sv-SE.
- Else use language, example sv.
- Else use default language with culture, example en-GB.
- Else use language, example en.
- Else if no translation is found, return resource key to indicate that a translation is missing.
.NET Localization needs to know what languages the application
support. This is configured using the Language record:
public record Language(string TwoLetterCode, bool IsFullySupported)
{
public bool IsFallback { get; init; }
public string? CultureCode { get; init; }
public bool CapitalizesNouns { get; init; } = false;
}Properties:
- TwoLetterCode - The ISO 639-1 two letter language code, e.g.
en,sv,de. - IsFullySupported - Indicates if the language has complete translations.
- IsFallback - Marks the default language. If no language is marked, the first one in the list is used as the fallback.
- CultureCode - Optional culture specifier, e.g.
GBfor British English (en-GB). - CapitalizesNouns - Indicates if the language capitalizes nouns (e.g. German).
Example:
var languages = new List<Language>
{
new("en", true) { IsFallback = true, CultureCode = "GB" }, // British English (default)
new("sv", true) { CultureCode = "SE" }, // Swedish
new("de", false) { CapitalizesNouns = true }, // German (partial support)
};| Interface | Description |
|---|---|
ILanguageService |
Provides information about supported languages and the fallback language. |
IResourceProvider |
Retrieves translations from a specific source (RESX, Markdown, Object). |
IResourceProviderGroup |
Manages multiple resource providers of the same type. |
| Class | Description |
|---|---|
Language |
Record representing a supported language with its properties. |
LanguageService |
Implementation of ILanguageService. |
TextContent |
Record containing the translated text, file suffix, and last modified timestamp. |
Settings |
Configuration class for dependency injection setup. |
ResxResourceProvider |
Provides translations from .NET RESX files via ResourceManager. |
ResxResourceProviders |
Groups multiple RESX providers for different resource types. |
MarkdownResourceProvider |
Provides translations from markdown files. |
ObjectResourceProvider |
Provides translations from object properties using reflection. |
All resource providers return a TextContent record:
public record TextContent(string Text, string FileSuffix, DateTimeOffset? LastModified = null);- Text - The translated string.
- FileSuffix - Source format (
.resx,.md,.obj). - LastModified - Timestamp for cache invalidation (mainly used by Markdown provider).
The library uses Settings for configuration:
public class Settings
{
public IEnumerable<Language> Languages { get; set; } = [];
public IEnumerable<string> ResxTypeNames { get; set; } = [];
public string? MarkdownFilesBasePath { get; set; }
}using Microsoft.Extensions.Options;
using Tellurian.Localization;
using Tellurian.Localization.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Configure settings
builder.Services.Configure<Settings>(options =>
{
options.Languages = new List<Language>
{
new("en", true) { IsFallback = true, CultureCode = "GB" },
new("sv", true) { CultureCode = "SE" },
new("de", false)
};
// Type names for RESX resources (fully qualified)
options.ResxTypeNames = new[]
{
"MyApp.Resources.Labels, MyApp",
"MyApp.Resources.Messages, MyApp"
};
// Base path for markdown files
options.MarkdownFilesBasePath = "Content/Translations";
});
// Register localization services
var options = builder.Services.BuildServiceProvider()
.GetRequiredService<IOptions<Settings>>();
builder.Services.AddTellurianLocalization(options);Blazor WebAssembly:
AddTellurianLocalizationregisters the file-basedMarkdownResourceProvider, which has no file-system access in the browser. Register the providers individually instead and useAddHttpMarkdownResourceProviderfor markdown (it fetches over HTTP relative to the app base address):builder.Services.AddLanguageService(languages); builder.Services.AddResxResourceProviders([typeof(Labels)]); builder.Services.AddHttpMarkdownResourceProvider("Content"); builder.Services.AddObjectResourceProvider();
The providers are registered as keyed singletons:
// Get language service
var languageService = serviceProvider.GetRequiredService<ILanguageService>();
// Get RESX providers group
var resxProviders = serviceProvider.GetRequiredKeyedService<IResourceProviderGroup>("Resx");
// Get Markdown provider
var markdownProvider = serviceProvider.GetRequiredKeyedService<IResourceProvider>("Markdown");
// Get Object provider
var objectProvider = serviceProvider.GetRequiredKeyedService<IResourceProvider>("Object");public class MyService(ILanguageService languageService)
{
public void ShowSupportedLanguages()
{
var languages = languageService.GetSupportedLanguages();
var fallback = languageService.FallbackLangauge;
foreach (var lang in languages)
{
Console.WriteLine($"{lang.TwoLetterCode}: Fully supported = {lang.IsFullySupported}");
}
}
public bool IsLanguageSupported(CultureInfo culture)
{
return languageService.SupportsLanguage(culture);
}
}RESX files are compiled into satellite assemblies by .NET.
The ResxResourceProvider uses the standard ResourceManager to retrieve translations.
public class TranslationService(
[FromKeyedServices("Resx")] IResourceProviderGroup resxProviders)
{
public async Task<string> GetLabel(string key)
{
// Uses CultureInfo.CurrentUICulture automatically
var content = await resxProviders.Translated<Labels>(key);
return content.Text;
}
public async Task<string> GetLabelForCulture(string key, CultureInfo culture)
{
var content = await resxProviders.Translated<Labels>(key, culture);
return content.Text;
}
}public class ContentService(
[FromKeyedServices("Markdown")] IResourceProvider markdownProvider)
{
public async Task<string> GetPageContent(string resourceKey)
{
var content = await markdownProvider.GetTranslationAsync(
resourceKey,
CultureInfo.CurrentUICulture);
return content.Text;
}
}Useful for objects loaded from a database with language-specific columns:
// Example: Entity with language properties
public class ProductDescription
{
public int Id { get; set; }
public string en { get; set; } = ""; // English description
public string sv { get; set; } = ""; // Swedish description
public string de { get; set; } = ""; // German description
}
public class ProductService(
[FromKeyedServices("Object")] IResourceProvider objectProvider)
{
public async Task<string> GetDescription(ProductDescription product)
{
// Returns the property value matching CurrentUICulture.TwoLetterISOLanguageName
var content = await objectProvider.GetTranslationAsync(
product,
CultureInfo.CurrentUICulture);
return content.Text;
}
}The library provides convenient extension methods:
// Uses CultureInfo.CurrentUICulture automatically
var content = await resourceProvider.Translated("MyResourceKey");
// For objects with language properties
var content = await objectProvider.Translate(myDatabaseEntity);Markdown files follow the pattern: {resourcekey}.{language}.md
Examples: English is assumed to be the neutral language that is used as fallback if specific translation is missing.
welcome.md- English welcome contentwelcome.sv.md- Swedish welcome contentabout-us.md- English "about us" contentabout-us.sv.md- Swedish "about us" content
Resource keys with hyphens are converted to directory paths:
- Key
help-faq-intromaps to file pathhelp/faq/intro.{lang}.md
Example folder structure:
Content/Translations/
├── welcome.md
├── welcome.sv.md
├── welcome.de.md
├── about-us.md
├── about-us.sv.md
├── about-us.de.md
└── help/
└── faq/
├── intro.md
└── intro.sv.md
└── intro.de.md
The Markdown provider searches in this order:
{path}.{TwoLetterISOLanguageName}.md(e.g.,welcome.sv.md){path}.md(default file without language suffix)- Returns the resource key if no file is found