Tellurian.Localization
1.5.0
Prefix Reserved
dotnet add package Tellurian.Localization --version 1.5.0
NuGet\Install-Package Tellurian.Localization -Version 1.5.0
<PackageReference Include="Tellurian.Localization" Version="1.5.0" />
<PackageVersion Include="Tellurian.Localization" Version="1.5.0" />
<PackageReference Include="Tellurian.Localization" />
paket add Tellurian.Localization --version 1.5.0
#r "nuget: Tellurian.Localization, 1.5.0"
#:package Tellurian.Localization@1.5.0
#addin nuget:?package=Tellurian.Localization&version=1.5.0
#tool nuget:?package=Tellurian.Localization&version=1.5.0
Tellurian.Localization
This library is helpful for .NET developers that creates applications intended for an international market.
Objectives
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.
Cross-platform Considerations
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.
Invariant Globalisation
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>
Resource Providers
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 the string property whose
name matches the language (the culture's two-letter code, matched
case-insensitively — so both
ENandenresolve), and returns the text of that 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.
Fallback Behaviour
Fallback is handled by .NET, not by this library. Providers resolve translations from
CultureInfo.CurrentUICulture, and the framework walks the culture's parent chain
(e.g. sv-SE → sv → the assembly's neutral language) automatically. The neutral
language is configured with <NeutralLanguage> in the project file
(NeutralResourcesLanguageAttribute), en-GB in this library.
How each provider behaves when no translation exists:
- ResxResourceProvider - the .NET
ResourceManagerprobessv-SE→sv→ neutral, then returns the resource key if nothing is found. - MarkdownResourceProvider - tries
{key}.{lang}.md, then the suffixless{key}.md, then returns the resource key. SetSettings.NeutralLanguageto the language the suffixless files are written in, and the culture-specific file is looked for only for the other languages — for the neutral one it could only ever miss. - ObjectResourceProvider - reads the property matching the culture's two-letter code (case-insensitively), returning an empty result if there is none.
Supported Languages
.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 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.
- CultureCode - Optional culture specifier, e.g.
GBfor British English (en-GB). - CapitalizesNouns - Indicates if the language capitalizes nouns (e.g. German).
The default/fallback language is not marked on
Language; it is the assembly's neutral language set via<NeutralLanguage>in the project file. See Fallback Behaviour above.
Example:
var languages = new List<Language>
{
new("en", true) { CultureCode = "GB" }, // British English
new("sv", true) { CultureCode = "SE" }, // Swedish
new("de", false) { CapitalizesNouns = true }, // German (partial support)
};
Code Structure
Core Interfaces
| Interface | Description |
|---|---|
ILanguageService |
Provides information about supported languages and the fallback language. |
IResourceProvider |
Retrieves translations from a specific source (RESX, Markdown, Object). |
ISynchronousResourceProvider |
Implemented by providers whose lookups are genuinely in-memory (RESX, Object), exposing a synchronous GetTranslation. File/HTTP-backed providers do not implement it, so they can't be called synchronously by mistake. |
IResourceProviderGroup |
Manages multiple resource providers of the same type. |
Key Classes
| 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. |
TextContent
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).
Dependency Injection Setup
Configuration
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; }
public string? NeutralLanguage { get; set; }
}
Registration in Program.cs
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) { 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";
// The language the suffixless markdown files are written in
options.NeutralLanguage = "en";
});
// 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", neutralLanguage: "en"); builder.Services.AddObjectResourceProvider();
Retrieving Services
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");
Usage Examples
Using the Language Service
public class MyService(ILanguageService languageService)
{
public void ShowSupportedLanguages()
{
var languages = languageService.GetSupportedLanguages();
foreach (var lang in languages)
{
Console.WriteLine($"{lang.TwoLetterCode}: Fully supported = {lang.IsFullySupported}");
}
}
public bool IsLanguageSupported(CultureInfo culture)
{
return languageService.SupportsLanguage(culture);
}
}
Using RESX Resources
RESX files are compiled into satellite assemblies by .NET.
The ResxResourceProvider uses the standard ResourceManager to retrieve translations.
Because RESX (and Object) lookups are genuinely in-memory, IResourceProviderGroup
exposes both a synchronous Translated<T> and an asynchronous TranslatedAsync<T>.
Use the synchronous overload from synchronous contexts (e.g. Razor component markup)
to avoid sync-over-async; use the async overload where you are already in an async flow.
public class TranslationService(
[FromKeyedServices("Resx")] IResourceProviderGroup resxProviders)
{
public string GetLabel(string key)
{
// Synchronous, uses CultureInfo.CurrentUICulture automatically
var content = resxProviders.Translated<Labels>(key);
return content.Text;
}
public async Task<string> GetLabelAsync(string key, CultureInfo culture)
{
// Asynchronous overload
var content = await resxProviders.TranslatedAsync<Labels>(key, culture);
return content.Text;
}
}
Using Markdown Resources
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;
}
}
Using Object Resources
Useful for objects loaded from a database with language-specific columns:
// Example: Entity with language properties. Property names match the two-letter
// language code and are resolved case-insensitively, so conventional C# casing
// (EN, SV, DE) works just as well as lowercase (en, sv, de).
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;
}
}
Using Extension Methods
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 File Organization
Naming Convention
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
Path Mapping
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
Fallback Behavior
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
Step 1 is skipped when the requested culture is the Settings.NeutralLanguage, since step 2
already holds that language's content. This matters most for HttpMarkdownResourceProvider,
where the miss costs a round trip and a 404 for every lookup — and fails outright when the
client is offline, so a progressive web app would show nothing at all in its neutral language.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Tellurian.Localization:
| Package | Downloads |
|---|---|
|
Tellurian.Trains.Schedules.Model
Import operations for layouts, timetables and schedules. |
GitHub repositories
This package is not used by any popular GitHub repositories.
1.5.0: The markdown providers can be told which language the suffixless files are written in, through the new Settings.NeutralLanguage or the neutralLanguage argument on AddMarkdownResourceProvider/AddHttpMarkdownResourceProvider. For that language the culture-specific file is no longer looked for, since the suffixless file already holds its content. HttpMarkdownResourceProvider previously spent a round trip and a 404 on that lookup for every translation, and in an offline progressive web app the failed request left the neutral language with no content at all. Leaving the setting unset keeps the previous two-step lookup, so the change is behaviour-neutral for existing callers.
1.4.1: ObjectResourceProvider now matches the language property case-insensitively. CultureInfo.TwoLetterISOLanguageName is always lowercase (en, sv, da, …), so the previous case-sensitive reflection lookup missed conventional C# property names (EN, SV, DA). Callers can now use either casing. Behaviour-neutral for objects that already used lowercase property names.
1.4.0: Removed the Language.IsFallback property. Fallback is handled entirely by .NET — providers resolve from CultureInfo.CurrentUICulture and the framework walks the culture parent chain down to the assembly neutral language (<NeutralLanguage>). No explicit fallback language needs to be marked. Behaviour-neutral, since nothing consulted IsFallback after FallbackLangauge was removed.
1.3.0: Removed the unused ILanguageService.FallbackLangauge property. It was never consulted by any provider, so the change is behaviour-neutral. RESX fallback is governed by the assembly neutral language (NeutralResourcesLanguageAttribute / <NeutralLanguage>); markdown and object providers fall back per provider. Language.IsFallback remains for callers' own use.
1.2.0: Added synchronous resource lookup. In-memory providers (resx, object) now implement the new ISynchronousResourceProvider with a GetTranslation method. On IResourceProviderGroup the synchronous lookup is Translated<T> and the async one is renamed to TranslatedAsync<T> (was Translated<T>). File/HTTP-backed providers remain async-only. This lets resx be translated from synchronous contexts (e.g. component markup) without sync-over-async.
1.1.0: IResourceProviderGroup.Translated<T>/Provider<T> now take the resource type (e.g. Translated<Labels>) — the previous 'where T : ResxResourceProvider' constraint made them uncallable. Language.IsFallback is now 'init' so the fallback language can be set explicitly. Fixed inverted string.HasValue extension.