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
5 changes: 5 additions & 0 deletions .agents/references/code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ public and wants it first in its group — but counts an explicit **event** as p
`ResXLocalization.slnx.DotSettings` has one "Explicit interface …" entry per kind and none for events:
giving events one puts an explicit event ahead of a public one and breaks `SA1202`.

Public static methods have an entry of their own, ahead of "Explicit interface methods". StyleCop counts
an explicit method as a public instance method, so a public static method after it breaks `SA1204`; the
extra entry keeps a class that has both - such as `MessageArguments`, with its factory methods and
`IEnumerable.GetEnumerator` - in an order both tools accept.

## XAML

XamlStyler owns `.xaml` and `.axaml`, and CSharpier is kept away from them by `.csharpierignore`.
Expand Down
27 changes: 24 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ this project adheres to [Semantic Versioning](https://semver.org/).
- `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).
result (culture, formatter, lookup-failure text builder, enum key builder, registered resource
managers).
- `MessageArguments`, the arguments of a lookup, built with `FromValues`, `FromTuples`,
`FromDictionary` or `FromObject` and checked when they are built: positional, named or mixed.
- Enum lookups with format arguments: `Get(sortOrder, MessageArguments.FromValues(count))`.
- `ILocalizer.EnumKeyBuilder`, one replaceable enum key convention for code and XAML.

### Changed

Expand All @@ -30,15 +35,31 @@ this project adheres to [Semantic Versioning](https://semver.org/).
- **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
`MessageFormatter`, `RefreshTriggeringRevision` and `LookupFailureTextBuilder`. 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).
- **BREAKING:** Format arguments are built with `MessageArguments`: `Get("Key", a, b)` becomes
`Get("Key", MessageArguments.FromValues(a, b))`; the `params object?[]` overloads are removed. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** A scoped lookup names the manager: `Get("Key", rm)` becomes
`Get("Key", resourceManager: rm)`, and `Get("Key", rm, a, b)` becomes
`Get("Key", MessageArguments.FromValues(a, b), rm)`.
See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** An enum key prefix is named: `Get(sortOrder, "Display_")` becomes
`Get(sortOrder, keyPrefix: "Display_")`. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** A `null` resource manager means search-all and a `null` key prefix the default of
`ILocalizer.EnumKeyBuilder`, instead of throwing `ArgumentNullException`;
`LocalizeEnumExtension.KeyPrefix` and `LocalizeEnumConverter.KeyPrefix` are `string?` and default to
`null`. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** Every 1.x `Get` signature is removed, including `Get(string)` and `Get(ResourceKey)`;
assemblies compiled against 1.x throw `MissingMethodException` until recompiled. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** A custom `ILocalizer` implementation has exactly three `Get` overloads - string,
`ResourceKey` and enum keys, each with an optional `MessageArguments` - and implements
`EnumKeyBuilder`. See the [migration guide](docs/guides/migration-2.0.md).

## [1.1.0] - 2026-07-20

Expand Down
12 changes: 12 additions & 0 deletions ResXLocalization.slnx.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@
&lt;Name /&gt;
&lt;/Entry.SortBy&gt;
&lt;/Entry&gt;
&lt;Entry DisplayName="Public static methods"&gt;
&lt;Entry.Match&gt;
&lt;And&gt;
&lt;Kind Is="Method" /&gt;
&lt;Access Is="Public" /&gt;
&lt;Static /&gt;
&lt;/And&gt;
&lt;/Entry.Match&gt;
&lt;Entry.SortBy&gt;
&lt;Name /&gt;
&lt;/Entry.SortBy&gt;
&lt;/Entry&gt;
&lt;Entry DisplayName="Explicit interface methods"&gt;
&lt;Entry.Match&gt;
&lt;And&gt;
Expand Down
47 changes: 44 additions & 3 deletions docs/guides/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,22 @@ a language switch:
Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)"
```

An enum label can have placeholders and take [format arguments](format-arguments.md), like any other
lookup. With a `Count_FileSortOrder_Ascending` entry of `{0} files, ascending`:

```csharp
Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), keyPrefix: "Count_"); // "3 files, ascending"
Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), SortingStrings.ResourceManager, "Count_");
```

The optional parameters after the value are `arguments`, `resourceManager` and `keyPrefix`; name the
ones you pass when you skip an earlier one.

## Custom prefix and scoping

The markup extension, the converter and the code API all accept a **`KeyPrefix`** (default `Enum_`)
and an optional **`ResourceManager`** that scopes the lookup to one `.resx` file. That is what lets
The markup extension, the converter and the code API all accept a **`KeyPrefix`** (by default
`null`, which means `Enum_` - see [Changing the key convention](#changing-the-key-convention)) and an
optional **`ResourceManager`** that scopes the lookup to one `.resx` file. That is what lets
the same enum carry different label sets, or keeps enum labels in a file of their own:

```xml
Expand All @@ -68,7 +80,7 @@ the same enum carry different label sets, or keeps enum labels in a file of thei
```

```csharp
Localizer.Current.Get(FileSortOrder.Ascending, SortingStrings.ResourceManager, "Display_");
Localizer.Current.Get(FileSortOrder.Ascending, resourceManager: SortingStrings.ResourceManager, keyPrefix: "Display_");
```

`LocalizeEnumConverter.Default` is read-only, so a converter with a custom prefix is declared as its
Expand All @@ -78,6 +90,35 @@ own instance in resources:
<l:LocalizeEnumConverter x:Key="DisplayEnumConverter" KeyPrefix="Display_" />
```

## Changing the key convention

`ILocalizer.EnumKeyBuilder` is the function that turns an enum value and an optional key prefix into
the resource key. Set it once at startup, and it applies to every enum lookup - `Get(Enum, …)`,
`{l:LocalizeEnum}` and `LocalizeEnumConverter`:

```csharp
Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) =>
(keyPrefix ?? "Text_") + value.GetType().Name + "." + value;

Localizer.Current.Get(FileSortOrder.Ascending); // looks up "Text_FileSortOrder.Ascending"
```

The function receives two parameters:

1. The enum value of the lookup; never `null`.
2. The key prefix the lookup passed - `keyPrefix` in code, `KeyPrefix` in XAML - or `null` when it
passed none. `null` means "use your own default"; a non-null value is an explicit override.

The default is the convention above, `{keyPrefix ?? "Enum_"}{EnumTypeName}_{MemberName}`, for example
`Enum_FileSortOrder_Ascending`. `keyPrefix` and `KeyPrefix` therefore override only the prefix: they
are passed to the builder, which decides what to do with them. An application that passes the same
prefix on every enum lookup can make it the builder's default instead.

Assigning a different function later re-resolves bound text, like a language switch; assigning the
current one again does nothing. The builder must not throw or return `null` or an empty string: an
exception it throws propagates out of the lookup, and a `null` or empty result makes the lookup throw
`InvalidOperationException`. Assigning `null` throws `ArgumentNullException`.

## When a member has no entry

An enum member with no matching resource entry resolves like any other missing key: the
Expand Down
58 changes: 50 additions & 8 deletions docs/guides/format-arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,50 @@ view model. Add the entry as usual:

## In code

Arguments are a `MessageArguments`, built with one of its static methods:

```csharp
Localizer.Current.Get(AppStringsKeys.PeopleInvited, peopleCount);
Localizer.Current.Get(AppStringsKeys.PeopleInvited, MessageArguments.FromValues(peopleCount));
```

Every `Get` overload - string keys, typed keys and enum values - takes the arguments the same way, as
its optional second parameter.

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.

### Building arguments

Each method returns a `MessageArguments` whose arguments have the positions 0, 1, 2, … in the order you pass
them:

- **`MessageArguments.FromValues(a, b, …)`** - positional arguments, for `{0}`, `{1}`, … This is the one to use with the
default formatter: `Get("FilePosition", MessageArguments.FromValues(3, 10))` renders `File {0} of {1}` as
`File 3 of 10`.
- **`MessageArguments.FromTuples(("Count", 5), ("Total", 10))`** - named arguments, in declaration order.
A `null` name makes that argument positional only, so named and unnamed arguments can be mixed:
`MessageArguments.FromTuples((null, file), ("Total", 10))`.
- **`MessageArguments.FromDictionary(dictionary)`** - named arguments from an existing
`IReadOnlyDictionary<string, object?>`; positions follow its enumeration order.
- **`MessageArguments.FromObject(new { Count = 5, Total = 10 })`** - named arguments from the public instance properties
of the object's static type, in declaration order. It is trim and Native AOT safe; a value typed
`object` has no properties and yields no arguments.

The methods reject an empty string as a name and two arguments with the same name (compared
ordinally), so a `MessageArguments` is always valid. It cannot be changed after it is built;
`MessageArguments.Empty` stands for no arguments. An argument's position is its
`MessageArgument.Position`: its index in the enumeration of a `MessageArguments` is not guaranteed to be
the same, so a custom formatter reads `Position`.

**The `MessageArguments.FromValues(dictionary)` trap.** `FromValues` takes `params object?[]`, so
`MessageArguments.FromValues(dictionary)` is *one* positional argument whose value is the dictionary itself. Use
`MessageArguments.FromDictionary(dictionary)` for named arguments.

**Names matter only to a formatter that understands them.** The default `string.Format` formatter
ignores names and uses positions, so `MessageArguments.FromObject(new { Count = 1.5, Where = "x" })` fills `{0}` and `{1}`
exactly like `MessageArguments.FromValues(1.5, "x")`. A formatter that knows names (ICU MessageFormat) finds a named
argument both under its position and under its name.

## In XAML

Bind the **`LocalizeArgs.Arg0`…`Arg8` attached properties** on the element that carries the localized
Expand All @@ -40,8 +77,8 @@ place as `5 Personen eingeladen`.
- **Per-argument format specifiers belong in the resource string** — `{0:N0} people invited` — where
a translator can adjust them per language.
- **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
resource value unchanged - in code `Localizer.Current.Get(key, MessageArguments.FromValues())` or
`Get(key, null)`, 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
Expand All @@ -53,7 +90,7 @@ place as `5 Personen eingeladen`.
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
(with empty arguments), 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:

Expand All @@ -62,13 +99,18 @@ place as `5 Personen eingeladen`.

sealed class MyFormatter : IMessageFormatter
{
public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) =>
arguments.Length == 0
public string Format(string pattern, MessageArguments arguments, CultureInfo culture) =>
arguments.Count == 0
? pattern // no arguments: do not format, so a literal { keeps working
: string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray());
: string.Format(
culture,
pattern,
// Order by Position: the index in the enumeration is not guaranteed to be the position.
arguments.OrderBy(argument => argument.Position).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.
`Get(key, MessageArguments.FromValues(…))` rather than wiring many argument slots.
- **Composite formatting does not pluralize.** `1 people invited` is on the resource author; use
separate singular and plural resources where it matters.
22 changes: 19 additions & 3 deletions docs/guides/lookup-and-fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,25 @@ resolve.
Localizer.Current.Get(AppStringsKeys.Greeting);

// Scoped: this key, in this file.
Localizer.Current.Get("Greeting", AppStrings.ResourceManager);
Localizer.Current.Get("Greeting", resourceManager: AppStrings.ResourceManager);

// Search-all: this key, in whichever registered file has it first.
Localizer.Current.Get("Greeting");
```

`ILocalizer` has one `Get` per key kind - a string key, a typed `ResourceKey` and an enum value - and
each takes optional [format arguments](format-arguments.md) as its second parameter:

```csharp
string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null);
string Get(ResourceKey key, MessageArguments? arguments = null);
string Get(Enum value, MessageArguments? arguments = null, ResourceManager? resourceManager = null, string? keyPrefix = null);
```

A scoped lookup therefore names the manager: `Get("Greeting", resourceManager: rm)`, or
`Get("Greeting", MessageArguments.FromValues(a), rm)` with arguments. Without a manager, or with
`null`, the lookup searches all registered managers.

The same three modes exist in XAML:

```xml
Expand Down Expand Up @@ -120,8 +133,11 @@ 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
of a lookup: the culture, the formatter, the lookup-failure text builder, the enum key builder
(`EnumKeyBuilder`, see [Localizing enum values](enums.md#changing-the-key-convention)), or the registered
resource managers (a manager registered or unregistered, or the registrations cleared). For the
formatter and the two builders, assigning a different instance counts; assigning the current instance
again does nothing. 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
Expand Down
Loading
Loading