Skip to content
Open
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
65 changes: 61 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,71 @@ dotnet tool uninstall --global OrchardCoreContrib.PoExtractor

OrchardCoreContrib.PoExtractor assumes, the code follows several conventions:

* `IStringLocalizer` or a derived class is accessed via a field named `S` (This is a convention used in Orchard Core)
* `IHtmlLocalizer` or a derived class is accessed via a field named `H` (This is a convention used in Orchard Core)
* `IStringLocalizer` or `IHtmlLocalizer` is accessed via a field named `T` (This is a older convention used in Orchard Core)
* `IStringLocalizer` or a derived class is accessed via a field, property, or helper named `S` (This is a convention used in Orchard Core)
* `IHtmlLocalizer` or a derived class is accessed via a field, property, or helper named `H` (This is a convention used in Orchard Core)
* `IStringLocalizer` or `IHtmlLocalizer` is accessed via a field, property, or helper named `T` (This is a older convention used in Orchard Core)
* Liquid templates use the filter named `t` (This is a convention used in Fluid)
* context of the localizable string is the full name (with namespace) of the containing class for C# or VB code
* context of the localizable string is the dot-delimited relative path the to view for Razor templates
* context of the localizable string is the dot-delimited relative path the to template for Liquid templates

* code extraction supports Orchard-style accessors such as `S["Text"]` and `T["Text"]`, instance/helper calls such as `S("Text")` and `T("Text")`, and typed static helper calls such as `S<MyClass>("Text")`, `T<MyClass>("Text")`, and `.Plural(...)`

## Static localization helpers

The extractor can recognize typed static helper calls such as `S<MyClass>("Text")`, `T<MyClass>("Text")`, `S<MyClass>.Plural(...)`, and `T<MyClass>.Plural(...)`, but this tool doesn't provide those runtime helpers for you. If you want to use them in your project then you need to define them in the consuming application.

Use a typed helper instead of a single global untyped localizer. This keeps runtime localization aligned with the class-based extraction context written into the POT file.

Example C# setup:

```csharp
using Microsoft.Extensions.Localization;

public static class StaticLocalizers
{
private static IStringLocalizerFactory _stringLocalizerFactory;

public static void Configure(IStringLocalizerFactory stringLocalizerFactory) =>
_stringLocalizerFactory = stringLocalizerFactory;

public static TypedStringLocalizer<T> S<T>() => new(_stringLocalizerFactory.Create(typeof(T)));

public static TypedStringLocalizer<T> T<T>() => new(_stringLocalizerFactory.Create(typeof(T)));
}

public readonly struct TypedStringLocalizer<T>(IStringLocalizer localizer)
{
public LocalizedString this[string name] => localizer[name];

public LocalizedString this[string name, params object[] arguments] => localizer[name, arguments];

public LocalizedString Invoke(string name) => localizer[name];

public LocalizedString Invoke(string name, params object[] arguments) => localizer[name, arguments];

public LocalizedString Plural(int count, string singular, string plural) =>
localizer[count == 1 ? singular : plural, count];
}
```

Register the factory once during startup:

```csharp
StaticLocalizers.Configure(app.Services.GetRequiredService<IStringLocalizerFactory>());
```

Then create project-level aliases matching the extractor-supported shapes:

```csharp
using static StaticLocalizers;

var title = S<MyClass>()["Title"];
var message = T<MyClass>().Invoke("Hello");
var itemCount = T<MyClass>().Plural(totalItems, "1 item", "{0} items");
```

If you want call sites like `S<MyClass>("Text")` or `T<MyClass>.Plural(...)`, add your own thin wrapper methods or types with those exact names. The important part is that the helper remains typed by the containing class, not backed by one shared untyped localizer.

## Example

C# code:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace OrchardCoreContrib.PoExtractor.DotNet.CS;

internal static class LocalizerAccessorSyntax
{
public static bool IsLocalizerAccessor(ExpressionSyntax expression) =>
GetLocalizerIdentifier(expression) is { } identifier &&
LocalizerAccessors.IsLocalizerIdentifier(identifier);

private static string GetLocalizerIdentifier(ExpressionSyntax expression) => expression switch
{
IdentifierNameSyntax identifier => identifier.Identifier.Text,
GenericNameSyntax genericName => genericName.Identifier.Text,
_ => null
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence

if (node is InvocationExpressionSyntax invocation &&
invocation.Expression is MemberAccessExpressionSyntax accessor &&
accessor.Expression is IdentifierNameSyntax identifierName &&
LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) &&
LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) &&
accessor.Name.Identifier.Text == "Plural")
{

var arguments = invocation.ArgumentList.Arguments;
if (arguments.Count >= 2 &&
arguments[1].Expression is ArrayCreationExpressionSyntax array)
Expand All @@ -43,9 +41,7 @@ accessor.Expression is IdentifierNameSyntax identifierName &&
array.Initializer.Expressions[0] is LiteralExpressionSyntax singularLiteral && singularLiteral.IsKind(SyntaxKind.StringLiteralExpression) &&
array.Initializer.Expressions[1] is LiteralExpressionSyntax pluralLiteral && pluralLiteral.IsKind(SyntaxKind.StringLiteralExpression))
{

result = CreateLocalizedString(singularLiteral.Token.ValueText, pluralLiteral.Token.ValueText, node);

return true;
}
}
Expand All @@ -55,9 +51,7 @@ accessor.Expression is IdentifierNameSyntax identifierName &&
arguments[1].Expression is LiteralExpressionSyntax singularLiteral && singularLiteral.IsKind(SyntaxKind.StringLiteralExpression) &&
arguments[2].Expression is LiteralExpressionSyntax pluralLiteral && pluralLiteral.IsKind(SyntaxKind.StringLiteralExpression))
{

result = CreateLocalizedString(singularLiteral.Token.ValueText, pluralLiteral.Token.ValueText, node);

return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,9 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence
result = null;

if (node is ElementAccessExpressionSyntax accessor &&
accessor.Expression is IdentifierNameSyntax identifierName &&
LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) &&
LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) &&
accessor.ArgumentList != null)
{

var argument = accessor.ArgumentList.Arguments.FirstOrDefault();
if (argument != null && argument.Expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
{
Expand All @@ -40,6 +38,17 @@ accessor.Expression is IdentifierNameSyntax identifierName &&
}
}

if (node is InvocationExpressionSyntax invocation &&
LocalizerAccessorSyntax.IsLocalizerAccessor(invocation.Expression))
{
var argument = invocation.ArgumentList.Arguments.FirstOrDefault();
if (argument != null && argument.Expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
{
result = CreateLocalizedString(literal.Token.ValueText, null, node);
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.CodeAnalysis.VisualBasic.Syntax;

namespace OrchardCoreContrib.PoExtractor.DotNet.VB;

internal static class LocalizerAccessorSyntax
{
public static bool IsLocalizerAccessor(ExpressionSyntax expression) =>
GetLocalizerIdentifier(expression) is { } identifier &&
LocalizerAccessors.IsLocalizerIdentifier(identifier);

private static string GetLocalizerIdentifier(ExpressionSyntax expression) => expression switch
{
IdentifierNameSyntax identifier => identifier.Identifier.Text,
GenericNameSyntax genericName => genericName.Identifier.Text,
_ => null
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence

if (node is InvocationExpressionSyntax invocation &&
invocation.Expression is MemberAccessExpressionSyntax accessor &&
accessor.Expression is IdentifierNameSyntax identifierName &&
LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) &&
LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) &&
accessor.Name.Identifier.Text == "Plural")
{
var arguments = invocation.ArgumentList.Arguments;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence
result = null;

if (node is InvocationExpressionSyntax accessor &&
accessor.Expression is IdentifierNameSyntax identifierName &&
LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) &&
LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) &&
accessor.ArgumentList != null)
{
var argument = accessor.ArgumentList.Arguments.FirstOrDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public static class LocalizerAccessors
[
DefaultLocalizerIdentifier,
StringLocalizerIdentifier,
HtmlLocalizerIdentifier
HtmlLocalizerIdentifier,
];

/// <summary>
/// Determines whether the identifier matches a supported localizer accessor name.
/// </summary>
public static bool IsLocalizerIdentifier(string identifier) =>
LocalizerIdentifiers.Contains(identifier, StringComparer.Ordinal);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using OrchardCoreContrib.PoExtractor.DotNet.CS.MetadataProviders;

namespace OrchardCoreContrib.PoExtractor.DotNet.CS.Tests;

public class PluralStringExtractorTests
{
[Fact]
public void ShouldExtractValidString()
[Theory]
[InlineData("S")]
[InlineData("T")]
public void ShouldExtractValidString(string localizer)
{
// Arrange
var text = "{0} thing";
Expand All @@ -15,7 +18,7 @@ public void ShouldExtractValidString()
var extractor = new PluralStringExtractor(metadataProvider);

var syntaxTree = CSharpSyntaxTree
.ParseText($"S.Plural(1, \"{text}\", \"{pluralText}\");", path: "DummyPath");
.ParseText($@"{localizer}.Plural(1, ""{text}"", ""{pluralText}"");", path: "DummyPath");

var node = syntaxTree
.GetRoot()
Expand All @@ -30,4 +33,73 @@ public void ShouldExtractValidString()
Assert.Equal(text, result.Text);
Assert.Equal(pluralText, result.TextPlural);
}
}

[Theory]
[InlineData("S<ContainingType>.Plural")]
[InlineData("T<ContainingType>.Plural")]
public void ShouldExtractTypedStaticPluralString(string localizer)
{
// Arrange
var text = "{0} thing";
var pluralText = "{0} things";
var metadataProvider = new CSharpMetadataProvider("DummyBasePath");
var extractor = new PluralStringExtractor(metadataProvider);

var syntaxTree = CSharpSyntaxTree.ParseText($@"
namespace OrchardCore.Tests;

public class ContainingType
{{
public void Run()
{{
{localizer}(1, ""{text}"", ""{pluralText}"");
}}
}}
", path: "DummyPath");

var node = syntaxTree
.GetRoot()
.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Single();

// Act
var extracted = extractor.TryExtract(node, out var result);

// Assert
Assert.True(extracted);
Assert.Equal(text, result.Text);
Assert.Equal(pluralText, result.TextPlural);
Assert.Equal("OrchardCore.Tests.ContainingType", result.Context);
}

[Fact]
public void ShouldNotExtractUntypedStaticPluralString()
{
// Arrange
var text = "{0} thing";
var pluralText = "{0} things";
var metadataProvider = new CSharpMetadataProvider("DummyBasePath");
var extractor = new PluralStringExtractor(metadataProvider);

var syntaxTree = CSharpSyntaxTree.ParseText($@"
namespace OrchardCore.Tests;

public class ContainingType
{{
public void Run()
{{
SharedLocalizer.S.Plural(1, ""{text}"", ""{pluralText}"");
}}
}}
", path: "DummyPath");

var node = syntaxTree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>().Single();

// Act
var extracted = extractor.TryExtract(node, out _);

// Assert
Assert.False(extracted);
}
}
Loading
Loading