Migrating to 2.0
2.0 is a major version; each section below is one breaking change, with what to do, starting with the changes that affect the most applications. Two sections need no action: the optional section on the enum key prefix, and the last section, a design-time change.
LocalizeArgs is replaced by <l:Argument>
The attached properties LocalizeArgs.Arg0...Arg8 are removed from both packages. Format arguments
are <l:Argument> children of {l:Localize}, each with a constant or bound Value and an optional
Name. A localized text with arguments is written in element syntax.
Before:
<TextBlock l:LocalizeArgs.Arg0="{Binding PeopleCount}"
Text="{l:Localize {x:Static res:AppStringsKeys.PeopleInvited}}" />
After:
<TextBlock>
<TextBlock.Text>
<l:Localize Key="{x:Static res:AppStringsKeys.PeopleInvited}">
<l:Argument Value="{Binding PeopleCount}" />
</l:Localize>
</TextBlock.Text>
</TextBlock>
LocalizeExtension.ResourceKey is replaced by Key
LocalizeExtension.ResourceKey is removed. Key takes a string or a ResourceKey; a ResourceKey
resolves through its own resource manager and ignores ResourceManager. Code that read
LocalizeExtension.Key as a string reads an object?. A constructor argument or a Key that is
neither a string nor a ResourceKey throws an InvalidOperationException when the view loads;
before, it produced an empty key.
Before:
<TextBlock Text="{l:Localize ResourceKey={x:Static res:AppStringsKeys.Greeting}}" />
After:
<TextBlock Text="{l:Localize Key={x:Static res:AppStringsKeys.Greeting}}" />
<TextBlock Text="{l:Localize {x:Static res:AppStringsKeys.Greeting}}" />
Format arguments are built with MessageArguments
The Get overloads with params object?[] are removed. Arguments are a MessageArguments, built with
its static methods. A dictionary, tuple or anonymous object passed to the old overloads silently became
argument {0}; the new parameter accepts nothing but a MessageArguments.
Before:
Localizer.Current.Get("FilePosition", a, b);
After:
Localizer.Current.Get("FilePosition", MessageArguments.FromValues(a, b));
A scoped lookup names the manager
The resource manager of a scoped lookup is an optional parameter after the arguments, so it is passed by name or after the arguments.
Before:
Localizer.Current.Get("Key", rm);
Localizer.Current.Get("Key", rm, a, b);
After:
Localizer.Current.Get("Key", resourceManager: rm);
Localizer.Current.Get("Key", MessageArguments.FromValues(a, b), rm);
An enum key prefix is named
The enum overload's optional parameters are arguments, resourceManager and keyPrefix, in that
order.
Before:
Localizer.Current.Get(sortOrder, "Display_");
Localizer.Current.Get(sortOrder, rm, "Display_");
After:
Localizer.Current.Get(sortOrder, keyPrefix: "Display_");
Localizer.Current.Get(sortOrder, resourceManager: rm, keyPrefix: "Display_");
Optional: set the enum key prefix once
Not a break. An application that passes the same prefix on every enum lookup can make it the default
of ILocalizer.EnumKeyBuilder instead, for code and XAML alike.
Before:
Localizer.Current.Get(sortOrder, "MyPrefix_");
<TextBlock Text="{l:LocalizeEnum KeyPrefix=MyPrefix_}" />
After:
Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) =>
(keyPrefix ?? "MyPrefix_") + value.GetType().Name + "_" + value;
Localizer.Current.Get(sortOrder);
<TextBlock Text="{l:LocalizeEnum Value={Binding}}" />
{l:LocalizeEnum} takes its value from Value
{l:LocalizeEnum} no longer reads the enum value from the target's DataContext; Value is required,
and a {l:LocalizeEnum} without it throws InvalidOperationException when the view loads. In an item
template, where the item is the DataContext, write Value={Binding}; for a view-model property, bind
it; for a fixed member, use {x:Static}.
Before:
<TextBlock Text="{l:LocalizeEnum}" />
After:
<TextBlock Text="{l:LocalizeEnum Value={Binding}}" /> <!-- item templates -->
<TextBlock Text="{l:LocalizeEnum Value={Binding SortOrder}}" /> <!-- a bound property -->
<TextBlock Text="{l:LocalizeEnum Value={x:Static m:FileSortOrder.Ascending}}" /> <!-- a fixed member -->
LocalizeEnumConverter has a new child order
LocalizeEnumConverter reads child 0 as the refresh trigger, RefreshTriggeringRevision of
Localizer.Current, child 1 as the enum value, and children from index 2 on as format arguments. A
MultiBinding in the old order - the enum first, then CurrentCulture - renders an empty string until
it is rewritten, and nothing reports it, so search for every use of LocalizeEnumConverter. Extra
children that served only to re-trigger the conversion are now format arguments; remove them.
Before:
<MultiBinding Converter="{x:Static l:LocalizeEnumConverter.Default}">
<Binding Path="SortOrder" />
<Binding Path="CurrentCulture" Source="{x:Static core:Localizer.Current}" />
</MultiBinding>
After:
<MultiBinding Converter="{x:Static l:LocalizeEnumConverter.Default}">
<Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" />
<Binding Path="SortOrder" />
</MultiBinding>
Typed keys use the generated ResourceManager
Every generated <Name>Keys class has its own ResourceManager property, created from the name the
.resx is embedded under, and every key of the class uses it instead of the designer's
<Name>.ResourceManager. Lookups return the same text. Registering or scoping with the designer's
instance still works, but it is a second instance of the same file with its own cache; use
<Name>Keys.ResourceManager.
The generator reads that name from the analyzer config file the SDK writes into obj/, so every keys
class - with or without a designer - now depends on it. A project that sets
GenerateMSBuildEditorConfigFile to false gets RXLGEN003 and no keys classes; remove that setting.
Before:
ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // true
Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager);
Localizer.Current.Get("Greeting", resourceManager: AppStrings.ResourceManager);
After:
ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStringsKeys.ResourceManager); // true
ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // false
Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager);
Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager);
More .resx files produce keys classes
Every neutral .resx now produces a keys class: a file without a .Designer.cs, a neutral file with a
dot in its name, and the files of a project that uses the SDK's GenerateResxSource. RXLGEN002 is no
longer reported. If a new <Name>Keys class collides with a type of your own, rename the .resx.
Before:
Resources/Errors.resx (no designer) -> no ErrorsKeys, warning RXLGEN002
Resources/My.Strings.resx -> no keys class (taken for a satellite)
After:
Resources/Errors.resx (no designer) -> ErrorsKeys
Resources/My.Strings.resx -> My_StringsKeys
Two files that produce the same keys class are no longer merged
Two neutral .resx files that produce the same namespace and class name - for example two
Strings.resx in different folders whose designers declare the same namespace - were merged into one
partial class. They now report RXLGEN004, and neither class is generated. Rename one of the files, or
change its designer's namespace or its CustomToolNamespace.
Before:
A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> one partial MyApp.StringsKeys
After:
A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> RXLGEN004 at each file, no StringsKeys
B/Strings.resx renamed to B/MoreStrings.resx -> MyApp.StringsKeys and MyApp.MoreStringsKeys
A wrong base name fails at registration
Localizer.RegisterResourceManager checks a plain ResourceManager when it is registered. If no
resources exist under its base name - usually a hand-written base name with a typo - it throws an
ArgumentException that names the base name, and the manager is not registered. Before, the manager
was registered, and every search-all lookup that reached it threw MissingManifestResourceException.
A subclass of ResourceManager is registered without that check, as before.
Fix the base name, or register the generated <Name>Keys.ResourceManager.
Before:
Localizer.Current.RegisterResourceManager(new ResourceManager("MyApp.Strngs", typeof(App).Assembly)); // registered
Localizer.Current.Get("Greeting"); // MissingManifestResourceException
After:
Localizer.Current.RegisterResourceManager(new ResourceManager("MyApp.Strngs", typeof(App).Assembly));
// ArgumentException: No resources exist under the base name 'MyApp.Strngs'. ...
Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager);
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:
Localizer.Current.TranslationNotFound += (_, args) =>
logger.LogWarning("No translation for {Key} in {Culture}", args.Key, args.Culture);
After:
Localizer.Current.LookupFailed += (_, args) =>
{
if (args.Failure.Reason == LookupFailureReason.TranslationNotFound)
{
logger.LogWarning("No translation for {Key} in {Culture}", args.Failure.Key, args.Failure.Culture);
}
};
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:
Localizer.Current.MissingTranslationFormat = "[{0}]";
After:
Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]";
See Lookup modes and culture fallback for more examples.
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 and checks LookupFailure.Reason, because the event reports missing translations too.
Before:
try
{
text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied);
}
catch (FormatException exception)
{
logger.LogWarning(exception, "Broken translation");
}
After:
Localizer.Current.LookupFailed += (_, args) =>
{
if (args.Failure.Reason == LookupFailureReason.MessageFormatFailed)
{
logger.LogWarning(args.Failure.Exception, "Broken translation {Key} in {Culture}", args.Failure.Key, args.Failure.Culture);
}
};
text = Localizer.Current.Get(AppStringsKeys.FilesCopied, MessageArguments.FromValues(copied));
A formatting failure in XAML renders the failure text
{l:Localize} with 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:
<!-- Resource "FilesCopied" = "Copied {0 files" -->
<TextBlock l:LocalizeArgs.Arg0="{Binding Copied}"
Text="{l:Localize {x:Static res:AppStringsKeys.FilesCopied}}" />
<!-- Avalonia: a binding error, and the previous text stays; WPF: an empty text -->
After:
<!-- Resource "FilesCopied" = "Copied {0 files" -->
<TextBlock>
<TextBlock.Text>
<l:Localize Key="{x:Static res:AppStringsKeys.FilesCopied}">
<l:Argument Value="{Binding Copied}" />
</l:Localize>
</TextBlock.Text>
</TextBlock>
<!-- renders "!FilesCopied!" and raises Localizer.Current.LookupFailed -->
Avalonia: arguments in a style setter or a resource are formatted
In Avalonia, a {l:Localize} with arguments in a Style setter or in a resource resolved unformatted,
because the arguments were read from a target element that a setter or a resource does not have. The
arguments now live on the binding, so the text is formatted there too. Check such texts after
upgrading.
Before:
<!-- Resource "PeopleInvited" = "{0} people invited" -->
<Style Selector="TextBlock.invited">
<Setter Property="Text" Value="{l:Localize {x:Static res:AppStringsKeys.PeopleInvited}}" />
</Style>
<!-- renders "{0} people invited", whatever LocalizeArgs.Arg0 holds -->
After:
<Style Selector="TextBlock.invited">
<Setter Property="Text">
<l:Localize Key="{x:Static res:AppStringsKeys.PeopleInvited}">
<l:Argument Value="{Binding PeopleCount}" />
</l:Localize>
</Setter>
</Style>
<!-- renders "5 people invited" for PeopleCount = 5 -->
An empty arguments array no longer formats
Localizer.Current.Get(key, args) with an empty array (from 2.0, Get(key, MessageArguments.Empty)) returns the resource value unformatted, exactly
like Get(key): {{x}} stays {{x}}, and a value containing {0} no longer throws.
Before:
// Resource "Template" = "Literal {{braces}} for {0}"
Localizer.Current.Get("Template", Array.Empty<object?>()); // 1.x: FormatException
After:
Localizer.Current.Get("Template", MessageArguments.Empty); // "Literal {{braces}} for {0}"
null no longer throws for a manager or a key prefix
A null resource manager means search-all, and a null key prefix means the default convention of
ILocalizer.EnumKeyBuilder. In 1.x both threw ArgumentNullException. This applies in code and in
XAML: LocalizeEnumExtension.KeyPrefix and LocalizeEnumConverter.KeyPrefix are string? and default
to null, and KeyPrefix="{x:Null}" no longer throws.
Before:
Localizer.Current.Get("Key", (ResourceManager)null!); // 1.x: ArgumentNullException
Localizer.Current.Get(sortOrder, (string)null!); // 1.x: ArgumentNullException
After:
Localizer.Current.Get("Key", resourceManager: null); // search-all
Localizer.Current.Get(sortOrder, keyPrefix: null); // the EnumKeyBuilder's default, Enum_
Recompile assemblies that call Get
Every 1.x Get signature is removed, including Get(string) and Get(ResourceKey), whose calls still
compile unchanged against 2.0. An assembly compiled against 1.x throws MissingMethodException at the
first Get call until it is recompiled.
Before:
MyLibrary.dll, compiled against 1.x, calls Localizer.Current.Get("Greeting") under 2.0
-> MissingMethodException: Method not found: 'System.String RentADeveloper.ResXLocalization.ILocalizer.Get(System.String)'.
After:
MyLibrary.dll, recompiled against 2.0 without a source change, calls Localizer.Current.Get("Greeting")
-> the translation of "Greeting", as with 1.x
Custom ILocalizer implementations have new members
A custom ILocalizer implementation must implement:
ILocalizer.LookupFailed, replacingTranslationNotFound(seeTranslationNotFoundis replaced byLookupFailed);ILocalizer.MessageFormatter;ILocalizer.RefreshTriggeringRevision- advance it and raisePropertyChangedon every change that can change a lookup's result; from 2.0 the XAML bindings re-resolve on it, not onCurrentCulture;ILocalizer.Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null), the one entry point every lookup goes through, together with the other twoGetoverloads andEnumKeyBuilder(see A customILocalizerimplements threeGetoverloads).
It also replaces MissingTranslationFormat with LookupFailureTextBuilder (see
MissingTranslationFormat is replaced by LookupFailureTextBuilder), raises
LookupFailed and builds the failure text for both LookupFailureReasons.
Before:
public sealed class MyLocalizer : ILocalizer
{
public event EventHandler<TranslationNotFoundEventArgs>? TranslationNotFound;
public string MissingTranslationFormat { get; set; } = "!{0}!";
// ...
}
After:
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, MessageArguments? arguments = null, ResourceManager? resourceManager = null) =>
/* resolve, then MessageFormatter.Format(value, arguments, CurrentCulture);
on a failure raise LookupFailed, then return LookupFailureTextBuilder(failure) */;
// ...
}
A custom ILocalizer implements three Get overloads and EnumKeyBuilder
A custom ILocalizer implementation replaces its Get overloads with exactly three, one per key kind,
and implements EnumKeyBuilder.
Before:
public string Get(string key) => /* ... */;
public string Get(string key, ResourceManager resourceManager) => /* ... */;
public string Get(string key, params object?[] arguments) => /* ... */;
public string Get(string key, ResourceManager resourceManager, params object?[] arguments) => /* ... */;
public string Get(ResourceKey key) => /* ... */;
public string Get(ResourceKey key, params object?[] arguments) => /* ... */;
public string Get(Enum value, string keyPrefix = "Enum_") => /* ... */;
public string Get(Enum value, ResourceManager resourceManager, string keyPrefix = "Enum_") => /* ... */;
After:
public Func<Enum, string?, string> EnumKeyBuilder { get; set; } =
static (value, keyPrefix) => (keyPrefix ?? "Enum_") + value.GetType().Name + "_" + value;
public string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null) =>
/* the one lookup; null arguments means none, a null manager means search-all */;
public string Get(ResourceKey key, MessageArguments? arguments = null) =>
this.Get(key.Name, arguments, key.Manager);
public string Get(Enum value, MessageArguments? arguments = null, ResourceManager? resourceManager = null, string? keyPrefix = null) =>
this.Get(this.EnumKeyBuilder(value, keyPrefix), arguments, resourceManager);
Rider's WPF previewer renders raw patterns (not a breaking change)
Listed for completeness; nothing to do. In Rider's WPF XAML previewer, localized strings rendered nothing,
because of a defect in the previewer (JetBrains
RIDER-143207).
As a workaround, they now render their raw resource pattern there under the default formatter, for
example {0} people invited. With ICU MessageFormat installed, that lookup without arguments interprets
escaping and returns the failure text for a variable it is not given. The running application, the Visual
Studio WPF designer and the Avalonia previewer are unchanged. See
Design time and previewers.