Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- `IMessageFormatter` and `ILocalizer.MessageFormatter`: one replaceable formatter that every resolved
value passes through, with or without arguments. The default, `StringFormatMessageFormatter`, uses
`string.Format` and does not format a value without arguments.
- `MessageArgument`, one argument of a lookup: its position, an optional name and its value.
- `ILocalizer.LookupFailed` with `LookupFailedEventArgs`, raised for every failed lookup: a key that no
resource file contains, or a value the formatter cannot format.
- `LookupFailure` and `LookupFailureReason`, which describe a failed lookup to `LookupFailed` and to
`ILocalizer.LookupFailureTextBuilder`.
- `ILocalizer.RefreshTriggeringRevision`, which changes on every change that can change a lookup's
result (culture, formatter, lookup-failure text builder, registered resource managers).

### Changed

- **BREAKING:** A formatting failure in `Get(key, args)` and its scoped and typed siblings returns the
failure text and raises `ILocalizer.LookupFailed`; it no longer throws `FormatException`. See
the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** In XAML, a formatting failure in `{l:Localize}` renders the failure text instead of an
Avalonia binding error or an empty WPF text. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** `Get(key, args)` with an empty array returns the resource value unformatted: `{{x}}`
stays `{{x}}`. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** A custom `ILocalizer` implementation must implement `LookupFailed`,
`MessageFormatter`, `RefreshTriggeringRevision`, `LookupFailureTextBuilder` and the hidden
`Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`. See the
[migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** `ILocalizer.MissingTranslationFormat` is replaced by `ILocalizer.LookupFailureTextBuilder`,
a `Func<LookupFailure, string>` that also produces the failure text for a formatting failure. See the
[migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** `ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by
`ILocalizer.LookupFailed` and `LookupFailedEventArgs`, which report formatting failures too. See the
[migration guide](docs/guides/migration-2.0.md).

## [1.1.0] - 2026-07-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ own instance in resources:
## When a member has no entry

An enum member with no matching resource entry resolves like any other missing key: the
`!Enum_FileSortOrder_Ascending!` sentinel, and a `TranslationNotFound` event. See
`!Enum_FileSortOrder_Ascending!` failure text, and a `LookupFailed` event. See
[Lookup modes and culture fallback](lookup-and-fallback.md).
36 changes: 31 additions & 5 deletions docs/guides/format-arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ view model. Add the entry as usual:
Localizer.Current.Get(AppStringsKeys.PeopleInvited, peopleCount);
```

The resolved text and the arguments go to `string.Format` in the localizer's current culture, so a
number or a date formats the way that culture writes it.
The resolved text and the arguments go to the localizer's formatter - by default `string.Format` in
the localizer's current culture, so a number or a date formats the way that culture writes it.

## In XAML

Expand All @@ -39,9 +39,35 @@ place as `5 Personen eingeladen`.
when they need different arguments.
- **Per-argument format specifiers belong in the resource string** — `{0:N0} people invited` — where
a translator can adjust them per language.
- **No arguments set, no formatting.** An element without any `ArgN` resolves without composite
formatting at all, so a resource value containing a literal `{` or `}` keeps working without `{{`
escaping. The missing-key sentinel is likewise never formatted.
- **No arguments, no formatting.** Under the default formatter, a lookup without arguments returns the
resource value unchanged - in code `Localizer.Current.Get(key, args)` with an empty array, like
`Get(key)`, and in XAML an element without any `ArgN`. A resource value containing a literal `{` or
`}` keeps working without `{{` escaping. The failure text of a missing key is never formatted.
- **Formatter exceptions are caught.** When a value cannot be formatted - `Broken {0`, or
`File {0} of {1}` with one argument - the localizer raises `LookupFailed` and returns the
failure text (by default `!key!`), in code and in XAML alike, provided the event handlers and
`LookupFailureTextBuilder` complete successfully. An exception from an event handler or from
`LookupFailureTextBuilder`, and a `null` result of the builder, propagate. See
[Lookup modes and culture fallback](lookup-and-fallback.md#failure-text).
- **The formatter is replaceable.** Assign `Localizer.Current.MessageFormatter = ...`, usually at
startup; changing it later re-resolves bound text like a language switch
(`RefreshTriggeringRevision`). The default is `string.Format` in the localizer's current culture.
The formatter sees **every** resolved value, in code and in XAML, also lookups without arguments
(with an empty array), so it is the one place to post-process all translated texts; a missing key's
failure text does not pass through it. A custom formatter should return the value unchanged when
`arguments` is empty, unless it means to handle plain texts:

```csharp
Localizer.Current.MessageFormatter = new MyFormatter();

sealed class MyFormatter : IMessageFormatter
{
public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) =>
arguments.Length == 0
? pattern // no arguments: do not format, so a literal { keeps working
: string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray());
}
```
- **Keep it simple.** For a very complex string, compose the text in the view model with
`Get(key, args…)` rather than wiring many argument slots.
- **Composite formatting does not pluralize.** `1 people invited` is on the resource author; use
Expand Down
73 changes: 63 additions & 10 deletions docs/guides/lookup-and-fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,35 +46,88 @@ that loads resources dynamically.
> [!TIP]
> In Avalonia, put the registration and the initial `CurrentCulture` inside `BuildAvaloniaApp()`
> rather than `Main`. `BuildAvaloniaApp` runs at run time *and* under the XAML previewer, so
> search-all lookups resolve at design time too instead of showing the `!Greeting!` sentinel.
> search-all lookups resolve at design time too instead of showing the `!Greeting!` failure text.

## Culture fallback

Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sits on top of it.

| Situation | Result | Raises `TranslationNotFound` |
| Situation | Result | Raises `LookupFailed` |
| --- | --- | --- |
| `de-DE` entry exists | the `de-DE` value | No |
| Missing in `de-DE`, exists in `de` | the parent `de` value | No |
| Missing in every satellite, exists in the neutral resources | the neutral value | No |
| Missing across the complete fallback chain | `!key!` by default | Yes |

Because fallback runs first, a key that resolves from a parent or from the neutral file does **not**
count as missing. The sentinel and the event report keys that cannot be resolved at all — they are
not a report of incomplete per-language coverage.
count as missing. The failure text and the event report keys that cannot be resolved at all — they
are not a report of incomplete per-language coverage.

## Missing-translation diagnostics

```csharp
// Change the sentinel, or make it invisible in production.
Localizer.MissingTranslationFormat = "«{0}»";
// Log every key that cannot be resolved, and every value that cannot be formatted.
Localizer.Current.LookupFailed += (_, args) =>
{
var failure = args.Failure;

if (failure.Reason == LookupFailureReason.TranslationNotFound)
{
logger.LogWarning("No translation for {Key} in {Culture}", failure.Key, failure.Culture);
}
else
{
logger.LogWarning(failure.Exception, "Cannot format {Key} in {Culture}: {Pattern}", failure.Key, failure.Culture, failure.Pattern);
}
};
```

`LookupFailed` reports both kinds of failure, and `LookupFailure.Reason` tells them apart:
`TranslationNotFound` for a key that no resource file contains, `MessageFormatFailed` for a key that
was found but whose value could not be formatted - for example `Broken {0`, or `File {0} of {1}` with
one argument. Both lookups return the failure text.

## Failure text

The failure text is what a lookup returns instead of a translation when it cannot produce one: the key
is in no resource file, or its value could not be formatted. It is produced by the function
`LookupFailureTextBuilder`, which receives the same `LookupFailure` that `LookupFailed` reports - the
reason, the key, the culture, the resource manager of a scoped or typed lookup, the arguments, and for a
formatting failure the pattern and the exception.

```csharp
// Wrap the key differently, or make it invisible in production.
Localizer.Current.LookupFailureTextBuilder = failure => $"«{failure.Key}»";

// Show only the last segment of a dotted key.
Localizer.Current.LookupFailureTextBuilder = failure => failure.Key[(failure.Key.LastIndexOf('.') + 1)..];

// Log every unresolvable key.
Localizer.Current.TranslationNotFound += (_, args) => logger.LogWarning("No translation for {Key}", args.Key);
// Tell the two reasons apart.
Localizer.Current.LookupFailureTextBuilder = failure =>
failure.Reason == LookupFailureReason.TranslationNotFound
? $"[missing: {failure.Key}]"
: $"[broken: {failure.Key}]";
```

The default sentinel is `!key!`, which is deliberately ugly: a missing translation should be
noticeable in a screenshot.
The default produces `!key!`, which is deliberately ugly: a missing translation should be noticeable
in a screenshot. A formatting failure uses the same function.

The function is called on every failed lookup, so keep it cheap. It must not throw or return `null`:
an exception it throws propagates out of the lookup, and a `null` result throws
`InvalidOperationException`. An empty string is fine and shows nothing. Changing the function
re-resolves bound text.

## Refreshing localized text

`ILocalizer.RefreshTriggeringRevision` changes whenever something changes that can change the result
of a lookup: the culture, the formatter, the lookup-failure text builder, or the registered resource
managers (a manager registered or unregistered, or the registrations cleared). Only whether the value
differs is meaningful, never the number itself.

Bound text re-resolves on each of these changes. Strings fetched in code with `Get` are not
updated; re-fetch them when the localizer raises
`PropertyChanged(nameof(ILocalizer.RefreshTriggeringRevision))`. Make these changes on the UI thread
once views exist.

## Available cultures

Expand Down
162 changes: 162 additions & 0 deletions docs/guides/migration-2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Migrating to 2.0

2.0 is a major version; each section below is one breaking change, with what to do.

## A formatting failure returns the failure text instead of throwing

A resource value that cannot be formatted with the supplied arguments - `Broken {0`, or
`File {0} of {1}` with one argument - no longer throws `FormatException` from
`Localizer.Current.Get(key, args)` and its scoped and typed siblings. The lookup returns the failure
text (by default `!key!`) and raises `ILocalizer.LookupFailed` with
`LookupFailureReason.MessageFormatFailed`. Code that caught `FormatException` subscribes to the event
instead.

Before:

```csharp
try
{
text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied);
}
catch (FormatException exception)
{
logger.LogWarning(exception, "Broken translation");
}
```

After:

```csharp
Localizer.Current.LookupFailed += (_, args) =>
logger.LogWarning(args.Failure.Exception, "Broken translation {Key} in {Culture}", args.Failure.Key, args.Failure.Culture);

text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied);
```

## A formatting failure in XAML renders the failure text

`{l:Localize}` with `LocalizeArgs` arguments whose resource value cannot be formatted renders the
failure text, in both frameworks, and raises `LookupFailed`. In 1.x, Avalonia reported a binding
error and kept the previous text, and WPF showed an empty text.

Before (1.x): an Avalonia binding error, or an empty WPF `TextBlock`.

After:

```xml
<!-- Resource "FilesCopied" = "Copied {0 files" -->
<TextBlock l:LocalizeArgs.Arg0="{Binding Copied}"
Text="{l:Localize {x:Static res:AppStringsKeys.FilesCopied}}" />
<!-- renders "!FilesCopied!" and raises Localizer.Current.LookupFailed -->
```

## An empty arguments array no longer formats

`Localizer.Current.Get(key, args)` with an empty array returns the resource value unformatted, exactly
like `Get(key)`: `{{x}}` stays `{{x}}`, and a value containing `{0}` no longer throws.

Before:

```csharp
// Resource "Template" = "Literal {{braces}} for {0}"
Localizer.Current.Get("Template", Array.Empty<object?>()); // 1.x: FormatException
```

After:

```csharp
Localizer.Current.Get("Template", Array.Empty<object?>()); // "Literal {{braces}} for {0}"
```

## Custom `ILocalizer` implementations have new members

A custom `ILocalizer` implementation must implement:

- `ILocalizer.LookupFailed`, replacing `TranslationNotFound` (see below);
- `ILocalizer.MessageFormatter`;
- `ILocalizer.RefreshTriggeringRevision` - advance it and raise `PropertyChanged` on every change that
can change a lookup's result; from 2.0 the XAML bindings re-resolve on it, not on `CurrentCulture`;
- the hidden `ILocalizer.Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`,
the one entry point every lookup goes through.

It also replaces `MissingTranslationFormat` with `LookupFailureTextBuilder` (see below), raises
`LookupFailed` and builds the failure text for both `LookupFailureReason`s.

Before:

```csharp
public sealed class MyLocalizer : ILocalizer
{
public event EventHandler<TranslationNotFoundEventArgs>? TranslationNotFound;
public string MissingTranslationFormat { get; set; } = "!{0}!";
// ...
}
```

After:

```csharp
public sealed class MyLocalizer : ILocalizer
{
public event EventHandler<LookupFailedEventArgs>? LookupFailed;
public IMessageFormatter MessageFormatter { get; set; } = StringFormatMessageFormatter.Instance;
public int RefreshTriggeringRevision { get; private set; }
public Func<LookupFailure, string> LookupFailureTextBuilder { get; set; } = failure => $"!{failure.Key}!";

public string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager) =>
/* resolve, then MessageFormatter.Format(value, arguments, CurrentCulture);
on a failure raise LookupFailed, then return LookupFailureTextBuilder(failure) */;
// ...
}
```

## `MissingTranslationFormat` is replaced by `LookupFailureTextBuilder`

`ILocalizer.MissingTranslationFormat`, a composite format string, is replaced by
`ILocalizer.LookupFailureTextBuilder`, a `Func<LookupFailure, string>`. It produces the failure text for
a missing translation and, new in 2.0, for a formatting failure; `LookupFailure.Reason` tells them
apart. The compiler reports every use of the old property; code that read the format string calls the
function instead. The `ArgumentException` for a malformed format no longer exists. The documentation
calls the text the "failure text" instead of the "sentinel".

Before:

```csharp
Localizer.Current.MissingTranslationFormat = "[{0}]";
```

After:

```csharp
Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]";
```

See [Lookup modes and culture fallback](lookup-and-fallback.md#failure-text) for more examples.

## `TranslationNotFound` is replaced by `LookupFailed`

`ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by
`ILocalizer.LookupFailed` and `LookupFailedEventArgs`. The new event reports every failed lookup - a key
that no resource file contains, and, new in 2.0, a value that cannot be formatted. Its
`LookupFailedEventArgs.Failure` is a `LookupFailure`, the same value `LookupFailureTextBuilder`
receives; `Failure.Reason` tells the two cases apart. To keep logging only missing translations, check
the reason.

Before:

```csharp
Localizer.Current.TranslationNotFound += (_, args) =>
logger.LogWarning("No translation for {Key} in {Culture}", args.Key, args.Culture);
```

After:

```csharp
Localizer.Current.LookupFailed += (_, args) =>
{
if (args.Failure.Reason == LookupFailureReason.TranslationNotFound)
{
logger.LogWarning("No translation for {Key} in {Culture}", args.Failure.Key, args.Failure.Culture);
}
};
```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ installation and a quick start. These pages are the full documentation.
| [Localizing enum values](guides/enums.md) | The naming convention, item templates, the converter, custom prefixes and scoping |
| [Dynamic format arguments](guides/format-arguments.md) | `Get(key, args…)` and the `LocalizeArgs.Arg0`…`Arg8` attached properties |
| [Native AOT and trimming](guides/native-aot.md) | What to declare, how to publish, and why the repository's own gate runs the native binary |
| [Migrating to 2.0](guides/migration-2.0.md) | Each breaking change of 2.0, with what to do |

## Reference

Expand Down
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
href: guides/format-arguments.md
- name: Native AOT and trimming
href: guides/native-aot.md
- name: Migrating to 2.0
href: guides/migration-2.0.md
- name: Reference
items:
- name: Generated keys
Expand Down
1 change: 0 additions & 1 deletion src/ResXLocalization.Avalonia/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
global using System.Globalization;
global using System.Resources;
global using Avalonia;
global using Avalonia.Data;
global using Avalonia.Data.Converters;
global using Avalonia.Markup.Xaml;
global using Avalonia.Reactive;
Expand Down
Loading
Loading