Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ 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)
* `LocalizedString` or `LocalizedHtmlString` instances created directly with a string literal name, e.g., `new LocalizedString("Text", "Text")` or `new LocalizedHtmlString("Text", "Text")`, are extracted using the name as the message ID. The single-argument form, e.g., `new LocalizedString("Text")` or `new LocalizedHtmlString("Text")`, is also supported
* `LocalizedString.Create("Text")` and `LocalizedHtmlString.Create("Text")` factory calls (and the `LocalizedStringExtensions.Create("Text")` and `LocalizedHtmlStringExtensions.Create("Text")` forms) are extracted using the first argument as the message ID. Any other arguments, such as format arguments, e.g., `LocalizedString.Create("Hello {0}", name)`, are ignored
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public virtual void Process(string path, string basePath, LocalizableStringColle
[
new SingularStringExtractor(csharpMetadataProvider),
new PluralStringExtractor(csharpMetadataProvider),
new LocalizedStringExtractor(csharpMetadataProvider),
new ErrorMessageAnnotationStringExtractor(csharpMetadataProvider),
new DisplayAttributeDescriptionStringExtractor(csharpMetadataProvider),
new DisplayAttributeNameStringExtractor(csharpMetadataProvider),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace OrchardCoreContrib.PoExtractor.DotNet.CS;

internal static class ExpressionSyntaxExtensions
{
/// <summary>
/// Gets the value of a string literal, or of string literals joined with the + operator.
/// </summary>
/// <param name="expression">The <see cref="ExpressionSyntax"/>.</param>
/// <param name="value">The string value.</param>
public static bool TryGetString(this ExpressionSyntax expression, out string value)
{
if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
{
value = literal.Token.ValueText;

return true;
}

if (expression is BinaryExpressionSyntax binary &&
binary.IsKind(SyntaxKind.AddExpression) &&
binary.Left.TryGetString(out var left) &&
binary.Right.TryGetString(out var right))
{
value = left + right;

return true;
}

value = null;

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Linq;

namespace OrchardCoreContrib.PoExtractor.DotNet.CS;

/// <summary>
/// Extracts <see cref="LocalizableStringOccurence"/> with the singular text from the C# AST node
/// </summary>
/// <remarks>
/// The localizable string is identified by the constructor call - new LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE")
/// or new LocalizedHtmlString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE"). The single argument form
/// new LocalizedString("TEXT TO TRANSLATE") or new LocalizedHtmlString("TEXT TO TRANSLATE") is also supported.
/// The factory method call - LocalizedString.Create("TEXT TO TRANSLATE") or LocalizedHtmlString.Create("TEXT TO TRANSLATE")
/// is also supported, including the LocalizedStringExtensions.Create("TEXT TO TRANSLATE") and
/// LocalizedHtmlStringExtensions.Create("TEXT TO TRANSLATE") forms.
/// </remarks>
/// <remarks>
/// Creates a new instance of a <see cref="LocalizedStringExtractor"/>.
/// </remarks>
/// <param name="metadataProvider">The <see cref="IMetadataProvider{TNode}"/>.</param>
public class LocalizedStringExtractor(IMetadataProvider<SyntaxNode> metadataProvider) : LocalizableStringExtractor<SyntaxNode>(metadataProvider)
{
/// <inheritdoc/>
public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result)
{
ArgumentNullException.ThrowIfNull(node);

result = null;

var argumentList = node switch
{
ObjectCreationExpressionSyntax creation when IsTypeName(creation.Type, LocalizedStringTypes.TypeNames) => creation.ArgumentList,
InvocationExpressionSyntax invocation when IsFactoryMethod(invocation.Expression) => invocation.ArgumentList,
_ => null
};

if (argumentList != null &&
argumentList.Arguments.Count > 0 &&
argumentList.Arguments[0].Expression.TryGetString(out var value))
{
result = CreateLocalizedString(value, null, node);

return true;
}

return false;
}

private static bool IsFactoryMethod(ExpressionSyntax expression)
=> expression is MemberAccessExpressionSyntax memberAccess &&
memberAccess.Name.Identifier.Text == LocalizedStringTypes.FactoryMethodName &&
IsTypeName(memberAccess.Expression, LocalizedStringTypes.FactoryTypeNames);

private static bool IsTypeName(ExpressionSyntax expression, string[] typeNames)
{
var name = expression switch
{
IdentifierNameSyntax identifierName => identifierName.Identifier.Text,
QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.Text,
AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.Text,
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
_ => null
};

return name != null && typeNames.Contains(name);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Linq;
Expand Down Expand Up @@ -33,7 +32,7 @@ accessor.Expression is IdentifierNameSyntax identifierName &&
{

var argument = accessor.ArgumentList.Arguments.FirstOrDefault();
if (argument != null && TryGetString(argument.Expression, out var value))
if (argument != null && argument.Expression.TryGetString(out var value))
{
result = CreateLocalizedString(value, null, node);
return true;
Expand All @@ -42,28 +41,4 @@ accessor.Expression is IdentifierNameSyntax identifierName &&

return false;
}

private static bool TryGetString(ExpressionSyntax expression, out string value)
{
if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
{
value = literal.Token.ValueText;

return true;
}

if (expression is BinaryExpressionSyntax binary &&
binary.IsKind(SyntaxKind.AddExpression) &&
TryGetString(binary.Left, out var left) &&
TryGetString(binary.Right, out var right))
{
value = left + right;

return true;
}

value = null;

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

namespace OrchardCoreContrib.PoExtractor.DotNet.VB;

/// <summary>
/// Extracts <see cref="LocalizableStringOccurence"/> with the singular text from the VB AST node
/// </summary>
/// <remarks>
/// The localizable string is identified by the constructor call - New LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE")
/// or New LocalizedHtmlString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE"). The single argument form
/// New LocalizedString("TEXT TO TRANSLATE") or New LocalizedHtmlString("TEXT TO TRANSLATE") is also supported.
/// The factory method call - LocalizedString.Create("TEXT TO TRANSLATE") or LocalizedHtmlString.Create("TEXT TO TRANSLATE")
/// is also supported, including the LocalizedStringExtensions.Create("TEXT TO TRANSLATE") and
/// LocalizedHtmlStringExtensions.Create("TEXT TO TRANSLATE") forms.
/// </remarks>
/// <remarks>
/// Creates a new instance of a <see cref="LocalizedStringExtractor"/>.
/// </remarks>
/// <param name="metadataProvider">The <see cref="IMetadataProvider{TNode}"/>.</param>
public class LocalizedStringExtractor(IMetadataProvider<SyntaxNode> metadataProvider) : LocalizableStringExtractor<SyntaxNode>(metadataProvider)
{
/// <inheritdoc/>
public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result)
{
ArgumentNullException.ThrowIfNull(node);

result = null;

var argumentList = node switch
{
ObjectCreationExpressionSyntax creation when IsTypeName(creation.Type, LocalizedStringTypes.TypeNames) => creation.ArgumentList,
InvocationExpressionSyntax invocation when IsFactoryMethod(invocation.Expression) => invocation.ArgumentList,
_ => null
};

if (argumentList != null &&
argumentList.Arguments.Count > 0 &&
argumentList.Arguments[0].GetExpression() is LiteralExpressionSyntax literal &&
literal.IsKind(SyntaxKind.StringLiteralExpression))
{
result = CreateLocalizedString(literal.Token.ValueText, null, node);

return true;
}

return false;
}

private static bool IsFactoryMethod(ExpressionSyntax expression)
=> expression is MemberAccessExpressionSyntax memberAccess &&
IsName(memberAccess.Name.Identifier.ValueText, LocalizedStringTypes.FactoryMethodName) &&
IsTypeName(memberAccess.Expression, LocalizedStringTypes.FactoryTypeNames);

private static bool IsTypeName(ExpressionSyntax expression, string[] typeNames)
{
var name = expression switch
{
IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText,
QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText,
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText,
_ => null
};

return name != null && typeNames.Any(typeName => IsName(name, typeName));
}

// Visual Basic identifiers are case-insensitive.
private static bool IsName(string name, string expected)
=> string.Equals(name, expected, StringComparison.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public void Process(string path, string basePath, LocalizableStringCollection lo
[
new SingularStringExtractor(visualBasicMetadataProvider),
new PluralStringExtractor(visualBasicMetadataProvider),
new LocalizedStringExtractor(visualBasicMetadataProvider),
new ErrorMessageAnnotationStringExtractor(visualBasicMetadataProvider),
new DisplayAttributeDescriptionStringExtractor(visualBasicMetadataProvider),
new DisplayAttributeNameStringExtractor(visualBasicMetadataProvider),
Expand Down
52 changes: 52 additions & 0 deletions src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace OrchardCoreContrib.PoExtractor.DotNet;

/// <summary>
/// Represents a class that contains the names of the localized string types and their factory methods.
/// </summary>
public static class LocalizedStringTypes
{
/// <summary>
/// Gets the name of the type that represents a localized string.
/// </summary>
public static readonly string LocalizedStringTypeName = "LocalizedString";

/// <summary>
/// Gets the name of the type that represents a localized HTML string.
/// </summary>
public static readonly string LocalizedHtmlStringTypeName = "LocalizedHtmlString";

/// <summary>
/// Gets the name of the class that contains the LocalizedString factory method.
/// </summary>
public static readonly string LocalizedStringExtensionsTypeName = "LocalizedStringExtensions";

/// <summary>
/// Gets the name of the class that contains the LocalizedHtmlString factory method.
/// </summary>
public static readonly string LocalizedHtmlStringExtensionsTypeName = "LocalizedHtmlStringExtensions";

/// <summary>
/// Gets the name of the factory method that creates a localized string from its name.
/// </summary>
public static readonly string FactoryMethodName = "Create";

/// <summary>
/// Gets the names of the localized string types that can be created with a constructor.
/// </summary>
public static readonly string[] TypeNames =
[
LocalizedStringTypeName,
LocalizedHtmlStringTypeName
];

/// <summary>
/// Gets the names of the types that contain the factory method.
/// </summary>
public static readonly string[] FactoryTypeNames =
[
LocalizedStringTypeName,
LocalizedHtmlStringTypeName,
LocalizedStringExtensionsTypeName,
LocalizedHtmlStringExtensionsTypeName
Comment on lines +49 to +50

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have these been introduced or is it Claude :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To allow LocalizedString.Create("...") and LocalizedHtmlString.Create("...") so we are not typing the same string twice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the Create method, AFAIK, LocalizedString doesn't have such a method

@MikeAlhayek MikeAlhayek Sep 24, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not. I added it in OC. OrchardCMS/OrchardCore#19933

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked about it during triage and that is a sound approach.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using OrchardCoreContrib.PoExtractor.DotNet.CS.MetadataProviders;

namespace OrchardCoreContrib.PoExtractor.DotNet.CS.Tests;

public class LocalizedStringExtractorTests
{
[Theory]
[InlineData("""new LocalizedString("Thing", "Thing");""", "Thing")]
[InlineData("""new LocalizedString("Thing");""", "Thing")]
[InlineData("""new LocalizedHtmlString("Thing");""", "Thing")]
[InlineData("""new LocalizedString("Thing", "Other thing");""", "Thing")]
[InlineData("""new LocalizedString("Thing", "Thing", true);""", "Thing")]
[InlineData("""new Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing");""", "Thing")]
[InlineData("""new global::Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing");""", "Thing")]
[InlineData("""new LocalizedString("my " + "text", "my text");""", "my text")]
[InlineData("""new LocalizedHtmlString("Thing", "Thing");""", "Thing")]
[InlineData("""new LocalizedHtmlString("Thing {0}", "Thing {0}", false, 1);""", "Thing {0}")]
[InlineData("""new Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString("Thing", "Thing");""", "Thing")]
[InlineData("""LocalizedString.Create("Thing");""", "Thing")]
[InlineData("""LocalizedHtmlString.Create("Thing");""", "Thing")]
[InlineData("""LocalizedString.Create("my " + "text");""", "my text")]
[InlineData("""Microsoft.Extensions.Localization.LocalizedString.Create("Thing");""", "Thing")]
[InlineData("""global::Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString.Create("Thing");""", "Thing")]
[InlineData("""LocalizedStringExtensions.Create("Thing");""", "Thing")]
[InlineData("""LocalizedHtmlStringExtensions.Create("Thing");""", "Thing")]
[InlineData("""LocalizedString.Create("Hello {0}", name);""", "Hello {0}")]
[InlineData("""LocalizedHtmlString.Create("<b>{0}</b> and {1}", first, second);""", "<b>{0}</b> and {1}")]
[InlineData("""LocalizedString.Create("Manage things", typeof(Permissions));""", "Manage things")]
[InlineData(
"""
new LocalizedString(@"This is a multi-line
string.", "value");
""",
"""
This is a multi-line
string.
""")]
public void ExtractString(string source, string expected)
{
// Arrange
var metadataProvider = new CSharpMetadataProvider("DummyBasePath");
var extractor = new LocalizedStringExtractor(metadataProvider);

var node = GetNode(source);

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

// Assert
Assert.True(extracted);
Assert.Equal(expected, result.Text);
Assert.Null(result.TextPlural);
}

[Theory]
[InlineData("""new LocalizedString(nameof(Thing), "Thing");""")]
[InlineData("""new LocalizedString(name, "Thing");""")]
[InlineData("""new LocalizedString();""")]
[InlineData("""new LocalizedHtmlString();""")]
[InlineData("""new LocalizedHtmlString(nameof(Thing), "Thing");""")]
[InlineData("""new Thing("Thing", "Thing");""")]
[InlineData("""new LocalizedStringExtensions("Thing");""")]
[InlineData("""LocalizedString.Create(nameof(Thing));""")]
[InlineData("""LocalizedString.Create(name);""")]
[InlineData("""LocalizedString.Create();""")]
[InlineData("""LocalizedString.Parse("Thing");""")]
[InlineData("""Thing.Create("Thing");""")]
public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source)
{
// Arrange
var metadataProvider = new CSharpMetadataProvider("DummyBasePath");
var extractor = new LocalizedStringExtractor(metadataProvider);

var node = GetNode(source);

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

// Assert
Assert.False(extracted);
Assert.Null(result);
}

private static ExpressionSyntax GetNode(string source)
=> CSharpSyntaxTree.ParseText(source, path: "DummyPath")
.GetRoot()
.DescendantNodes()
.OfType<ExpressionSyntax>()
.First(node => node is ObjectCreationExpressionSyntax or InvocationExpressionSyntax);
}
Loading
Loading