From 721a7c75dc0cc7b08964776cb2f47ad95a9ea2d1 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 23 Sep 2026 13:39:10 -0700 Subject: [PATCH 1/5] Extract strings from new LocalizedString("Foo", "Foo") Add a LocalizedStringExtractor for C# and VB that extracts the name argument of a LocalizedString constructor call when it is a string literal. This is in addition to the existing S["Foo"] / H["Foo"] / T["Foo"] conventions. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../CSharpProjectProcessor.cs | 1 + .../LocalizedStringExtractor.cs | 49 +++++++++++++ .../SingularStringExtractor.cs | 2 +- .../LocalizedStringExtractor.cs | 54 ++++++++++++++ .../VisualBasicProjectProcessor.cs | 1 + .../LocalizedStringExtractorTests.cs | 70 +++++++++++++++++++ .../LocalizedStringExtractorTests.cs | 58 +++++++++++++++ 8 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs create mode 100644 test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs create mode 100644 test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs diff --git a/README.md b/README.md index 13109c8..8dfdcd2 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ 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` instances created directly with a string literal name, e.g. `new LocalizedString("Text", "Text")`, are extracted using the name as the message ID * 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 diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs index 72e9a0c..22b3731 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs @@ -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), diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs new file mode 100644 index 0000000..cc2eb3c --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; + +namespace OrchardCoreContrib.PoExtractor.DotNet.CS; + +/// +/// Extracts with the singular text from the C# AST node +/// +/// +/// The localizable string is identified by the constructor call - new LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE") +/// +/// +/// Creates a new instance of a . +/// +/// The . +public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) +{ + private const string LocalizedStringTypeName = "LocalizedString"; + + /// + public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) + { + ArgumentNullException.ThrowIfNull(node); + + result = null; + + if (node is ObjectCreationExpressionSyntax creation && + IsLocalizedStringType(creation.Type) && + creation.ArgumentList != null && + creation.ArgumentList.Arguments.Count >= 2 && + SingularStringExtractor.TryGetString(creation.ArgumentList.Arguments[0].Expression, out var value)) + { + result = CreateLocalizedString(value, null, node); + + return true; + } + + return false; + } + + private static bool IsLocalizedStringType(TypeSyntax type) => type switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.Text == LocalizedStringTypeName, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.Text == LocalizedStringTypeName, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.Text == LocalizedStringTypeName, + _ => false + }; +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs index 43e1432..62326d9 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs @@ -43,7 +43,7 @@ accessor.Expression is IdentifierNameSyntax identifierName && return false; } - private static bool TryGetString(ExpressionSyntax expression, out string value) + internal static bool TryGetString(ExpressionSyntax expression, out string value) { if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) { diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs new file mode 100644 index 0000000..516ad74 --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; +using System; + +namespace OrchardCoreContrib.PoExtractor.DotNet.VB; + +/// +/// Extracts with the singular text from the VB AST node +/// +/// +/// The localizable string is identified by the constructor call - New LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE") +/// +/// +/// Creates a new instance of a . +/// +/// The . +public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) +{ + private const string LocalizedStringTypeName = "LocalizedString"; + + /// + public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) + { + ArgumentNullException.ThrowIfNull(node); + + result = null; + + if (node is ObjectCreationExpressionSyntax creation && + IsLocalizedStringType(creation.Type) && + creation.ArgumentList != null && + creation.ArgumentList.Arguments.Count >= 2 && + creation.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 IsLocalizedStringType(TypeSyntax type) => type switch + { + IdentifierNameSyntax identifierName => IsLocalizedStringTypeName(identifierName.Identifier.ValueText), + QualifiedNameSyntax qualifiedName => IsLocalizedStringTypeName(qualifiedName.Right.Identifier.ValueText), + _ => false + }; + + // Visual Basic identifiers are case-insensitive. + private static bool IsLocalizedStringTypeName(string name) + => string.Equals(name, LocalizedStringTypeName, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs index dc7c97f..e353933 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs @@ -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), diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs new file mode 100644 index 0000000..e32aff1 --- /dev/null +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -0,0 +1,70 @@ +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", "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 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 = GetObjectCreationNode(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("Thing");""")] + [InlineData("""new LocalizedHtmlString("Thing", "Thing");""")] + [InlineData("""new Thing("Thing", "Thing");""")] + public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) + { + // Arrange + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetObjectCreationNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.False(extracted); + Assert.Null(result); + } + + private static ObjectCreationExpressionSyntax GetObjectCreationNode(string source) + => CSharpSyntaxTree.ParseText(source, path: "DummyPath") + .GetRoot() + .DescendantNodes() + .OfType() + .First(); +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs new file mode 100644 index 0000000..f3c060f --- /dev/null +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -0,0 +1,58 @@ +using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; +using OrchardCoreContrib.PoExtractor.DotNet.VB.MetadataProviders; + +namespace OrchardCoreContrib.PoExtractor.DotNet.VB.Tests; + +public class LocalizedStringExtractorTests +{ + [Theory] + [InlineData("""New LocalizedString("Thing", "Thing")""", "Thing")] + [InlineData("""New LocalizedString("Thing", "Other thing")""", "Thing")] + [InlineData("""New localizedstring("Thing", "Thing")""", "Thing")] + [InlineData("""New Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing")""", "Thing")] + public void ExtractString(string source, string expected) + { + // Arrange + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetObjectCreationNode(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("Thing")""")] + [InlineData("""New Thing("Thing", "Thing")""")] + public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) + { + // Arrange + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetObjectCreationNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.False(extracted); + Assert.Null(result); + } + + private static ObjectCreationExpressionSyntax GetObjectCreationNode(string source) + => VisualBasicSyntaxTree.ParseText($"Dim value = {source}", path: "DummyPath") + .GetRoot() + .DescendantNodes() + .OfType() + .First(); +} From d860165403bcf313ab64f09f867c42ee5d7be1d0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 23 Sep 2026 13:41:21 -0700 Subject: [PATCH 2/5] Also extract strings from new LocalizedHtmlString("Foo", "Foo") Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../LocalizedStringExtractor.cs | 14 ++++++++++---- .../LocalizedStringExtractor.cs | 10 ++++++++-- .../LocalizedStringExtractorTests.cs | 5 ++++- .../LocalizedStringExtractorTests.cs | 3 +++ 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8dfdcd2..40a281a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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` instances created directly with a string literal name, e.g. `new LocalizedString("Text", "Text")`, are extracted using the name as the message ID +* `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 * 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 diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs index cc2eb3c..1ce350a 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; +using System.Linq; namespace OrchardCoreContrib.PoExtractor.DotNet.CS; @@ -9,6 +10,7 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.CS; /// /// /// 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") /// /// /// Creates a new instance of a . @@ -16,7 +18,11 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.CS; /// The . public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) { - private const string LocalizedStringTypeName = "LocalizedString"; + private static readonly string[] _localizedStringTypeNames = + [ + "LocalizedString", + "LocalizedHtmlString" + ]; /// public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) @@ -41,9 +47,9 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence private static bool IsLocalizedStringType(TypeSyntax type) => type switch { - IdentifierNameSyntax identifierName => identifierName.Identifier.Text == LocalizedStringTypeName, - QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.Text == LocalizedStringTypeName, - AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.Text == LocalizedStringTypeName, + IdentifierNameSyntax identifierName => _localizedStringTypeNames.Contains(identifierName.Identifier.Text), + QualifiedNameSyntax qualifiedName => _localizedStringTypeNames.Contains(qualifiedName.Right.Identifier.Text), + AliasQualifiedNameSyntax aliasQualifiedName => _localizedStringTypeNames.Contains(aliasQualifiedName.Name.Identifier.Text), _ => false }; } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs index 516ad74..f414f7b 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.VisualBasic; using Microsoft.CodeAnalysis.VisualBasic.Syntax; using System; +using System.Linq; namespace OrchardCoreContrib.PoExtractor.DotNet.VB; @@ -10,6 +11,7 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.VB; /// /// /// 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") /// /// /// Creates a new instance of a . @@ -17,7 +19,11 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.VB; /// The . public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) { - private const string LocalizedStringTypeName = "LocalizedString"; + private static readonly string[] _localizedStringTypeNames = + [ + "LocalizedString", + "LocalizedHtmlString" + ]; /// public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) @@ -50,5 +56,5 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence // Visual Basic identifiers are case-insensitive. private static bool IsLocalizedStringTypeName(string name) - => string.Equals(name, LocalizedStringTypeName, StringComparison.OrdinalIgnoreCase); + => _localizedStringTypeNames.Contains(name, StringComparer.OrdinalIgnoreCase); } diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs index e32aff1..80ef625 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -13,6 +13,9 @@ public class LocalizedStringExtractorTests [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( """ new LocalizedString(@"This is a multi-line @@ -43,7 +46,7 @@ public void ExtractString(string source, string expected) [InlineData("""new LocalizedString(nameof(Thing), "Thing");""")] [InlineData("""new LocalizedString(name, "Thing");""")] [InlineData("""new LocalizedString("Thing");""")] - [InlineData("""new LocalizedHtmlString("Thing", "Thing");""")] + [InlineData("""new LocalizedHtmlString(nameof(Thing), "Thing");""")] [InlineData("""new Thing("Thing", "Thing");""")] public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) { diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs index f3c060f..4e674e4 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -11,6 +11,8 @@ public class LocalizedStringExtractorTests [InlineData("""New LocalizedString("Thing", "Other thing")""", "Thing")] [InlineData("""New localizedstring("Thing", "Thing")""", "Thing")] [InlineData("""New Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing")""", "Thing")] + [InlineData("""New LocalizedHtmlString("Thing", "Thing")""", "Thing")] + [InlineData("""New Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString("Thing", "Thing")""", "Thing")] public void ExtractString(string source, string expected) { // Arrange @@ -32,6 +34,7 @@ public void ExtractString(string source, string expected) [InlineData("""New LocalizedString(NameOf(Thing), "Thing")""")] [InlineData("""New LocalizedString(name, "Thing")""")] [InlineData("""New LocalizedString("Thing")""")] + [InlineData("""New LocalizedHtmlString(NameOf(Thing), "Thing")""")] [InlineData("""New Thing("Thing", "Thing")""")] public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) { From 40a61bc3cd91a892669bebe48c01eec06ba66ba7 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 23 Sep 2026 13:50:53 -0700 Subject: [PATCH 3/5] Support the single argument LocalizedString and LocalizedHtmlString form Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../LocalizedStringExtractor.cs | 5 +++-- .../LocalizedStringExtractor.cs | 5 +++-- .../LocalizedStringExtractorTests.cs | 5 ++++- .../LocalizedStringExtractorTests.cs | 5 ++++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 40a281a..62ba40d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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 +* `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 * 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 diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs index 1ce350a..9ee272a 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs @@ -10,7 +10,8 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.CS; /// /// /// 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") +/// 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. /// /// /// Creates a new instance of a . @@ -34,7 +35,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence if (node is ObjectCreationExpressionSyntax creation && IsLocalizedStringType(creation.Type) && creation.ArgumentList != null && - creation.ArgumentList.Arguments.Count >= 2 && + creation.ArgumentList.Arguments.Count > 0 && SingularStringExtractor.TryGetString(creation.ArgumentList.Arguments[0].Expression, out var value)) { result = CreateLocalizedString(value, null, node); diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs index f414f7b..db4a319 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs @@ -11,7 +11,8 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.VB; /// /// /// 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") +/// 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. /// /// /// Creates a new instance of a . @@ -35,7 +36,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence if (node is ObjectCreationExpressionSyntax creation && IsLocalizedStringType(creation.Type) && creation.ArgumentList != null && - creation.ArgumentList.Arguments.Count >= 2 && + creation.ArgumentList.Arguments.Count > 0 && creation.ArgumentList.Arguments[0].GetExpression() is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) { diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs index 80ef625..618f4f4 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -8,6 +8,8 @@ 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")] @@ -45,7 +47,8 @@ public void ExtractString(string source, string expected) [Theory] [InlineData("""new LocalizedString(nameof(Thing), "Thing");""")] [InlineData("""new LocalizedString(name, "Thing");""")] - [InlineData("""new LocalizedString("Thing");""")] + [InlineData("""new LocalizedString();""")] + [InlineData("""new LocalizedHtmlString();""")] [InlineData("""new LocalizedHtmlString(nameof(Thing), "Thing");""")] [InlineData("""new Thing("Thing", "Thing");""")] public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs index 4e674e4..b408208 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -8,6 +8,8 @@ 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")""", "Thing")] [InlineData("""New Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing")""", "Thing")] @@ -33,7 +35,8 @@ public void ExtractString(string source, string expected) [Theory] [InlineData("""New LocalizedString(NameOf(Thing), "Thing")""")] [InlineData("""New LocalizedString(name, "Thing")""")] - [InlineData("""New LocalizedString("Thing")""")] + [InlineData("""New LocalizedString()""")] + [InlineData("""New LocalizedString""")] [InlineData("""New LocalizedHtmlString(NameOf(Thing), "Thing")""")] [InlineData("""New Thing("Thing", "Thing")""")] public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) From c2d0eb221f91b6f9b905f517dac128bb1d83a79c Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 23 Sep 2026 14:05:34 -0700 Subject: [PATCH 4/5] Support LocalizedString.Create and address review feedback - Extract LocalizedString.Create("...") and LocalizedHtmlString.Create("...") factory calls, including the LocalizedStringExtensions and LocalizedHtmlStringExtensions forms. - Move the type names to LocalizedStringTypes in the shared DotNet project. - Move TryGetString to an internal ExpressionSyntaxExtensions helper. - Apply README wording suggestions. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- .../ExpressionSyntaxExtensions.cs | 37 +++++++++++++ .../LocalizedStringExtractor.cs | 47 +++++++++++------ .../SingularStringExtractor.cs | 27 +--------- .../LocalizedStringExtractor.cs | 49 ++++++++++------- .../LocalizedStringTypes.cs | 52 +++++++++++++++++++ .../LocalizedStringExtractorTests.cs | 23 ++++++-- .../LocalizedStringExtractorTests.cs | 21 ++++++-- 8 files changed, 187 insertions(+), 72 deletions(-) create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs diff --git a/README.md b/README.md index 62ba40d..985997c 100644 --- a/README.md +++ b/README.md @@ -73,7 +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` 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 argument as the message ID * 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 diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs new file mode 100644 index 0000000..1df85cc --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs @@ -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 +{ + /// + /// Gets the value of a string literal, or of string literals joined with the + operator. + /// + /// The . + /// The string value. + 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; + } +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs index 9ee272a..2fbce7d 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs @@ -12,6 +12,9 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.CS; /// 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. /// /// /// Creates a new instance of a . @@ -19,12 +22,6 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.CS; /// The . public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) { - private static readonly string[] _localizedStringTypeNames = - [ - "LocalizedString", - "LocalizedHtmlString" - ]; - /// public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) { @@ -32,11 +29,16 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result = null; - if (node is ObjectCreationExpressionSyntax creation && - IsLocalizedStringType(creation.Type) && - creation.ArgumentList != null && - creation.ArgumentList.Arguments.Count > 0 && - SingularStringExtractor.TryGetString(creation.ArgumentList.Arguments[0].Expression, out var value)) + 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); @@ -46,11 +48,22 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence return false; } - private static bool IsLocalizedStringType(TypeSyntax type) => type switch + 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) { - IdentifierNameSyntax identifierName => _localizedStringTypeNames.Contains(identifierName.Identifier.Text), - QualifiedNameSyntax qualifiedName => _localizedStringTypeNames.Contains(qualifiedName.Right.Identifier.Text), - AliasQualifiedNameSyntax aliasQualifiedName => _localizedStringTypeNames.Contains(aliasQualifiedName.Name.Identifier.Text), - _ => false - }; + 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); + } } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs index 62326d9..cb85e1e 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Linq; @@ -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; @@ -42,28 +41,4 @@ accessor.Expression is IdentifierNameSyntax identifierName && return false; } - - internal 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; - } } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs index db4a319..f026581 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs @@ -13,6 +13,9 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.VB; /// 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. /// /// /// Creates a new instance of a . @@ -20,12 +23,6 @@ namespace OrchardCoreContrib.PoExtractor.DotNet.VB; /// The . public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) { - private static readonly string[] _localizedStringTypeNames = - [ - "LocalizedString", - "LocalizedHtmlString" - ]; - /// public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) { @@ -33,11 +30,16 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result = null; - if (node is ObjectCreationExpressionSyntax creation && - IsLocalizedStringType(creation.Type) && - creation.ArgumentList != null && - creation.ArgumentList.Arguments.Count > 0 && - creation.ArgumentList.Arguments[0].GetExpression() is LiteralExpressionSyntax literal && + 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); @@ -48,14 +50,25 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence return false; } - private static bool IsLocalizedStringType(TypeSyntax type) => type switch + 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) { - IdentifierNameSyntax identifierName => IsLocalizedStringTypeName(identifierName.Identifier.ValueText), - QualifiedNameSyntax qualifiedName => IsLocalizedStringTypeName(qualifiedName.Right.Identifier.ValueText), - _ => false - }; + 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 IsLocalizedStringTypeName(string name) - => _localizedStringTypeNames.Contains(name, StringComparer.OrdinalIgnoreCase); + private static bool IsName(string name, string expected) + => string.Equals(name, expected, StringComparison.OrdinalIgnoreCase); } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs new file mode 100644 index 0000000..27eefb3 --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs @@ -0,0 +1,52 @@ +namespace OrchardCoreContrib.PoExtractor.DotNet; + +/// +/// Represents a class that contains the names of the localized string types and their factory methods. +/// +public static class LocalizedStringTypes +{ + /// + /// Gets the name of the type that represents a localized string. + /// + public static readonly string LocalizedStringTypeName = "LocalizedString"; + + /// + /// Gets the name of the type that represents a localized HTML string. + /// + public static readonly string LocalizedHtmlStringTypeName = "LocalizedHtmlString"; + + /// + /// Gets the name of the class that contains the LocalizedString factory method. + /// + public static readonly string LocalizedStringExtensionsTypeName = "LocalizedStringExtensions"; + + /// + /// Gets the name of the class that contains the LocalizedHtmlString factory method. + /// + public static readonly string LocalizedHtmlStringExtensionsTypeName = "LocalizedHtmlStringExtensions"; + + /// + /// Gets the name of the factory method that creates a localized string from its name. + /// + public static readonly string FactoryMethodName = "Create"; + + /// + /// Gets the names of the localized string types that can be created with a constructor. + /// + public static readonly string[] TypeNames = + [ + LocalizedStringTypeName, + LocalizedHtmlStringTypeName + ]; + + /// + /// Gets the names of the types that contain the factory method. + /// + public static readonly string[] FactoryTypeNames = + [ + LocalizedStringTypeName, + LocalizedHtmlStringTypeName, + LocalizedStringExtensionsTypeName, + LocalizedHtmlStringExtensionsTypeName + ]; +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs index 618f4f4..892590c 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -18,6 +18,13 @@ public class LocalizedStringExtractorTests [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( """ new LocalizedString(@"This is a multi-line @@ -33,7 +40,7 @@ public void ExtractString(string source, string expected) var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); var extractor = new LocalizedStringExtractor(metadataProvider); - var node = GetObjectCreationNode(source); + var node = GetNode(source); // Act var extracted = extractor.TryExtract(node, out var result); @@ -51,13 +58,19 @@ public void ExtractString(string source, string expected) [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 = GetObjectCreationNode(source); + var node = GetNode(source); // Act var extracted = extractor.TryExtract(node, out var result); @@ -67,10 +80,10 @@ public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string Assert.Null(result); } - private static ObjectCreationExpressionSyntax GetObjectCreationNode(string source) + private static ExpressionSyntax GetNode(string source) => CSharpSyntaxTree.ParseText(source, path: "DummyPath") .GetRoot() .DescendantNodes() - .OfType() - .First(); + .OfType() + .First(node => node is ObjectCreationExpressionSyntax or InvocationExpressionSyntax); } diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs index b408208..34ec049 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -15,13 +15,19 @@ public class LocalizedStringExtractorTests [InlineData("""New Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing")""", "Thing")] [InlineData("""New LocalizedHtmlString("Thing", "Thing")""", "Thing")] [InlineData("""New Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString("Thing", "Thing")""", "Thing")] + [InlineData("""LocalizedString.Create("Thing")""", "Thing")] + [InlineData("""LocalizedHtmlString.Create("Thing")""", "Thing")] + [InlineData("""localizedstring.create("Thing")""", "Thing")] + [InlineData("""Microsoft.Extensions.Localization.LocalizedString.Create("Thing")""", "Thing")] + [InlineData("""LocalizedStringExtensions.Create("Thing")""", "Thing")] + [InlineData("""LocalizedHtmlStringExtensions.Create("Thing")""", "Thing")] public void ExtractString(string source, string expected) { // Arrange var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); var extractor = new LocalizedStringExtractor(metadataProvider); - var node = GetObjectCreationNode(source); + var node = GetNode(source); // Act var extracted = extractor.TryExtract(node, out var result); @@ -39,13 +45,18 @@ public void ExtractString(string source, string expected) [InlineData("""New LocalizedString""")] [InlineData("""New LocalizedHtmlString(NameOf(Thing), "Thing")""")] [InlineData("""New Thing("Thing", "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 VisualBasicMetadataProvider("DummyBasePath"); var extractor = new LocalizedStringExtractor(metadataProvider); - var node = GetObjectCreationNode(source); + var node = GetNode(source); // Act var extracted = extractor.TryExtract(node, out var result); @@ -55,10 +66,10 @@ public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string Assert.Null(result); } - private static ObjectCreationExpressionSyntax GetObjectCreationNode(string source) + private static ExpressionSyntax GetNode(string source) => VisualBasicSyntaxTree.ParseText($"Dim value = {source}", path: "DummyPath") .GetRoot() .DescendantNodes() - .OfType() - .First(); + .OfType() + .First(node => node is ObjectCreationExpressionSyntax or InvocationExpressionSyntax); } From b870a836f20d2d606dbbb5a7e54e6101a24a5a9b Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 24 Sep 2026 13:13:26 -0700 Subject: [PATCH 5/5] Cover Create calls with format arguments LocalizedString.Create and LocalizedHtmlString.Create accept format arguments. The first argument is the message ID, and the other arguments are ignored. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../LocalizedStringExtractorTests.cs | 3 +++ .../LocalizedStringExtractorTests.cs | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 985997c..9a7eb15 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ OrchardCoreContrib.PoExtractor assumes, the code follows several conventions: * `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 argument as the message ID +* `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 diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs index 892590c..788ba08 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -25,6 +25,9 @@ public class LocalizedStringExtractorTests [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("{0} and {1}", first, second);""", "{0} and {1}")] + [InlineData("""LocalizedString.Create("Manage things", typeof(Permissions));""", "Manage things")] [InlineData( """ new LocalizedString(@"This is a multi-line diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs index 34ec049..2f6429d 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -21,6 +21,8 @@ public class LocalizedStringExtractorTests [InlineData("""Microsoft.Extensions.Localization.LocalizedString.Create("Thing")""", "Thing")] [InlineData("""LocalizedStringExtensions.Create("Thing")""", "Thing")] [InlineData("""LocalizedHtmlStringExtensions.Create("Thing")""", "Thing")] + [InlineData("""LocalizedStringExtensions.Create("Hello {0}", name)""", "Hello {0}")] + [InlineData("""LocalizedStringExtensions.Create("Manage things", GetType(Permissions))""", "Manage things")] public void ExtractString(string source, string expected) { // Arrange