diff --git a/.editorconfig b/.editorconfig
index e15d309..ddcbe2b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -286,7 +286,7 @@ charset = unset
trim_trailing_whitespace = true
# Project files
-[{*.csproj,*.slnx,Directory.**.props}]
+[{**/*.csproj,*.slnx,Directory.**.props}]
# Indentation and spacing
indent_size = 2
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
new file mode 100644
index 0000000..f76c244
--- /dev/null
+++ b/.github/workflows/run_tests.yml
@@ -0,0 +1,29 @@
+name: Run tests
+
+on:
+ pull_request:
+ branches: [ "main" ]
+ push:
+ branches: [ "main" ]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Run tests
+ run: dotnet test --no-build --configuration Release --verbosity normal
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 91493f3..ca8b9bf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,8 +11,11 @@
+
+
+
diff --git a/StringTemplates.slnx b/StringTemplates.slnx
index 6dc68fa..125b180 100644
--- a/StringTemplates.slnx
+++ b/StringTemplates.slnx
@@ -12,11 +12,15 @@
+
+
+
+
\ No newline at end of file
diff --git a/examples/StringTemplates.Demo/Program.cs b/examples/StringTemplates.Demo/Program.cs
index 29b61d5..a6eb318 100644
--- a/examples/StringTemplates.Demo/Program.cs
+++ b/examples/StringTemplates.Demo/Program.cs
@@ -1,6 +1,12 @@
+using Microsoft.AspNetCore.Mvc;
+using StringTemplates.Extensions;
+using StringTemplates.Services;
+
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
+builder.Services.AddStringTemplates();
+
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
@@ -12,9 +18,22 @@
app.MapOpenApi();
}
+// Map endpoints
+app.MapPost("system", (
+ [FromBody] SystemRequest request,
+ [FromServices] ITemplateService templateService) =>
+ Results.Ok(templateService.ReplacePlaceholders(request.Template)));
+app.MapPost("dictionary", (
+ [FromBody] DictionaryRequest request,
+ [FromServices] ITemplateService> templateService) =>
+ Results.Ok(templateService.ReplacePlaceholders(request.Template, request.Model)));
+
app.UseHttpsRedirection();
app.Run();
// Needed so WebApplicationFactory in tests can find the entry point
public partial class Program;
+
+internal record SystemRequest(string Template);
+internal record DictionaryRequest(string Template, Dictionary Model);
diff --git a/examples/StringTemplates.Demo/StringTemplates.Demo.csproj b/examples/StringTemplates.Demo/StringTemplates.Demo.csproj
index a16bf28..263b40d 100644
--- a/examples/StringTemplates.Demo/StringTemplates.Demo.csproj
+++ b/examples/StringTemplates.Demo/StringTemplates.Demo.csproj
@@ -1,7 +1,11 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/examples/StringTemplates.Demo/StringTemplates.http b/examples/StringTemplates.Demo/StringTemplates.http
new file mode 100644
index 0000000..5368867
--- /dev/null
+++ b/examples/StringTemplates.Demo/StringTemplates.http
@@ -0,0 +1,18 @@
+### POST 1 request to use system template
+POST https://localhost:7228/system
+Content-Type: application/json
+
+{
+ "template": "The year is \u007B\u007B#System#Year#System#\u007D\u007D."
+}
+###
+
+### POST 2 request to use system template
+POST https://localhost:7228/system
+Content-Type: application/json
+
+{
+ "template": "The time and date now is \u007B\u007B#System#Time.Now#System#\u007D\u007D of \u007B\u007B#System#Day.OfMonth#System#\u007D\u007Dth of \u007B\u007B#System#Month#System#\u007D\u007D \u007B\u007B#System#Year#System#\u007D\u007D."
+}
+###
+###
diff --git a/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs b/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs
index 9d181dc..1e3d918 100644
--- a/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs
+++ b/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs
@@ -24,6 +24,7 @@ public sealed class SystemTemplatePlugin : ITemplatePlugin
"DateTime.Now" => DateTime.Now.ToString(CultureInfo.InvariantCulture),
"DateTime.UtcNow" => DateTime.UtcNow.ToString(CultureInfo.InvariantCulture),
"Day" => DateTime.Now.DayOfWeek.ToString(),
+ "Day.OfMonth" => DateTime.Now.Day.ToString(),
"Month" => DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture),
"Time.Now" => DateTime.Now.ToString("T", CultureInfo.InvariantCulture),
"Time.UtcNow" => DateTime.UtcNow.ToString("T", CultureInfo.InvariantCulture),
diff --git a/src/StringTemplates/StringTemplates.csproj b/src/StringTemplates/StringTemplates.csproj
index 7bad09f..2b9425b 100644
--- a/src/StringTemplates/StringTemplates.csproj
+++ b/src/StringTemplates/StringTemplates.csproj
@@ -11,8 +11,8 @@
Copyright (c) Stratis Dermanoutsos 2025
LICENSE
- https://github.com/Stratis-Dermanoutsos/StringTemplates
- https://github.com/Stratis-Dermanoutsos/StringTemplates
+ https://github.com/Stratis-OSS/StringTemplates.Net
+ https://github.com/Stratis-OSS/StringTemplates.Net
The best way to handle templates and replace placeholders.
open-source oss csharp dotnet strings templates placeholders
diff --git a/tests/StringTemplates.IntegrationTests/ApiFactory.cs b/tests/StringTemplates.IntegrationTests/ApiFactory.cs
new file mode 100644
index 0000000..d632876
--- /dev/null
+++ b/tests/StringTemplates.IntegrationTests/ApiFactory.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace StringTemplates.IntegrationTests;
+
+public class ApiFactory : WebApplicationFactory
+{
+ public HttpClient CreateHttpsClient()
+ {
+ return CreateClient(new WebApplicationFactoryClientOptions
+ {
+ BaseAddress = new Uri("https://localhost")
+ });
+ }
+}
diff --git a/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs b/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs
new file mode 100644
index 0000000..d123f77
--- /dev/null
+++ b/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs
@@ -0,0 +1,17 @@
+using System.Net.Http.Json;
+
+namespace StringTemplates.IntegrationTests.Services.Abstractions;
+
+public abstract class ApiTests(ApiFactory factory)
+{
+ protected readonly HttpClient Client = factory.CreateHttpsClient();
+
+ protected async Task PostForStringAsync(string path, object payload)
+ {
+ var resp = await Client.PostAsJsonAsync(path, payload);
+ resp.EnsureSuccessStatusCode();
+
+ var jsonString = await resp.Content.ReadFromJsonAsync();
+ return jsonString ?? await resp.Content.ReadAsStringAsync();
+ }
+}
diff --git a/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs b/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs
new file mode 100644
index 0000000..785369e
--- /dev/null
+++ b/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using StringTemplates.IntegrationTests.Services.Abstractions;
+
+namespace StringTemplates.IntegrationTests.Services;
+
+public class DictionaryServiceTests(ApiFactory factory) : ApiTests(factory), IClassFixture
+{
+ [Fact]
+ public async Task Dictionary_Replaces_String_And_Int()
+ {
+ // Arrange
+ var payload = new
+ {
+ template = "Hello {{#Dictionary#Name#Dictionary#}}, age {{#Dictionary#Age#Dictionary#}}!",
+ model = new Dictionary
+ {
+ ["Name"] = "Stratis",
+ ["Age"] = 30
+ }
+ };
+
+ // Act
+ var result = await PostForStringAsync("dictionary", payload);
+
+ // Assert
+ result.ShouldBe("Hello Stratis, age 30!");
+ }
+
+ [Fact]
+ public async Task Dictionary_Replaces_Multiple_Keys_And_Repeats()
+ {
+ // Arrange
+ var payload = new
+ {
+ template = "User: {{#Dictionary#User#Dictionary#}} ({{#Dictionary#User#Dictionary#}}), Active={{#Dictionary#Active#Dictionary#}}",
+ model = new Dictionary
+ {
+ ["User"] = "demo",
+ ["Active"] = true
+ }
+ };
+
+ // Act
+ var result = await PostForStringAsync("dictionary", payload);
+
+ // Assert
+ result.ShouldBe("User: demo (demo), Active=True");
+ }
+
+ [Fact]
+ public async Task Dictionary_Unknown_Key_Remains()
+ {
+ // Arrange
+ var template = "Pay {{#Dictionary#Amount#Dictionary#}} EUR. Ref: {{#Dictionary#Ref#Dictionary#}}";
+ var payload = new
+ {
+ template,
+ model = new Dictionary
+ {
+ ["Ref"] = "ORD-123"
+ }
+ };
+
+ // Act
+ var result = await PostForStringAsync("dictionary", payload);
+
+ // Assert
+ result.ShouldBe("Pay {{#Dictionary#Amount#Dictionary#}} EUR. Ref: ORD-123");
+ }
+}
diff --git a/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs b/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs
new file mode 100644
index 0000000..5b84634
--- /dev/null
+++ b/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs
@@ -0,0 +1,121 @@
+using System.Globalization;
+using Shouldly;
+using StringTemplates.IntegrationTests.Services.Abstractions;
+
+namespace StringTemplates.IntegrationTests.Services;
+
+public class SystemServiceTests(ApiFactory factory) : ApiTests(factory), IClassFixture
+{
+ [Fact]
+ public async Task System_Date_Now_Replaced()
+ {
+ // Arrange
+ var expected = DateTime.Now.ToString("d", CultureInfo.InvariantCulture);
+ var payload = new { template = "Today is {{#System#Date.Now#System#}}." };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldBe($"Today is {expected}.");
+ }
+
+ [Fact]
+ public async Task System_Date_UtcNow_Replaced()
+ {
+ // Arrange
+ var expected = DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture);
+ var payload = new { template = "UTC date: {{#System#Date.UtcNow#System#}}" };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldBe($"UTC date: {expected}");
+ }
+
+ [Fact]
+ public async Task System_DateTime_Now_Format_Is_Correct()
+ {
+ // Arrange
+ var payload = new { template = "[{{#System#DateTime.Now#System#}}]" };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldMatch(@"^\[\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\]$");
+ }
+
+ [Fact]
+ public async Task System_DateTime_UtcNow_Format_Is_Correct()
+ {
+ // Arrange
+ var payload = new { template = "<{{#System#DateTime.UtcNow#System#}}>" };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldMatch(@"^<\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}>$");
+ }
+
+ [Fact]
+ public async Task System_Day_And_Month_Replaced()
+ {
+ // Arrange
+ var expectedDay = DateTime.Now.DayOfWeek.ToString();
+ var expectedMonth = DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture);
+
+ var dayPayload = new { template = "Happy {{#System#Day#System#}}!" };
+ var monthPayload = new { template = "It is {{#System#Month#System#}} now." };
+
+ // Act
+ var dayResult = await PostForStringAsync("system", dayPayload);
+ var monthResult = await PostForStringAsync("system", monthPayload);
+
+ // Assert
+ dayResult.ShouldBe($"Happy {expectedDay}!");
+ monthResult.ShouldBe($"It is {expectedMonth} now.");
+ }
+
+ [Fact]
+ public async Task System_Time_Now_Format_Is_Correct()
+ {
+ // Arrange
+ var payload = new { template = "time={{#System#Time.Now#System#}}" };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldMatch(@"^time=\d{2}:\d{2}:\d{2}$");
+ }
+
+ [Fact]
+ public async Task System_Time_UtcNow_Format_Is_Correct()
+ {
+ // Arrange
+ var payload = new { template = "utc={{#System#Time.UtcNow#System#}}" };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldMatch(@"^utc=\d{2}:\d{2}:\d{2}$");
+ }
+
+ [Fact]
+ public async Task System_Unknown_Placeholder_Remains()
+ {
+ // Arrange
+ var template = "Unknown -> {{#System#Does.Not.Exist#System#}} <- keep it";
+ var payload = new { template };
+
+ // Act
+ var result = await PostForStringAsync("system", payload);
+
+ // Assert
+ result.ShouldBe(template);
+ }
+}
diff --git a/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj b/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj
new file mode 100644
index 0000000..1483151
--- /dev/null
+++ b/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs b/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs
index c12acc1..04f65ae 100644
--- a/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs
+++ b/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs
@@ -7,7 +7,7 @@ namespace StringTemplates.UnitTests.Services.Plugins;
public class DictionaryTemplatePluginTests
{
[Fact]
- public void Replaces_Single_Key_In_Sentence()
+ public void Dictionary_Replaces_Single_Key()
{
// Arrange
var dict = new Dictionary
@@ -25,7 +25,7 @@ public void Replaces_Single_Key_In_Sentence()
}
[Fact]
- public void Replaces_Multiple_Keys_In_Paragraph()
+ public void Dictionary_Replaces_Multiple_Keys()
{
// Arrange
var dict = new Dictionary
@@ -50,7 +50,7 @@ public void Replaces_Multiple_Keys_In_Paragraph()
}
[Fact]
- public void Replaces_Mixed_Types_And_Repeated_Keys()
+ public void Dictionary_Replaces_Mixed_Types_And_Repeats()
{
// Arrange
var dict = new Dictionary
@@ -78,7 +78,7 @@ public void Replaces_Mixed_Types_And_Repeated_Keys()
}
[Fact]
- public void Leaves_Unknown_Keys_Untouched_While_Replacing_Known_Ones()
+ public void Dictionary_Unknown_Key_Remains()
{
// Arrange
var dict = new Dictionary
@@ -101,7 +101,7 @@ public void Leaves_Unknown_Keys_Untouched_While_Replacing_Known_Ones()
}
[Fact]
- public void Replaces_Values_In_Long_Form_Text()
+ public void Dictionary_Replaces_Values_In_Long_Text()
{
// Arrange
var start = new DateTime(2025, 09, 16, 8, 30, 0);
@@ -128,7 +128,7 @@ public void Replaces_Values_In_Long_Form_Text()
}
[Fact]
- public void Leaves_Template_As_Is_When_Dictionary_Is_Null()
+ public void Dictionary_Leaves_Template_As_Is_When_Model_Is_Null()
{
// Arrange
Dictionary? dict = null;
diff --git a/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs b/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs
index 504f834..cd4bb56 100644
--- a/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs
+++ b/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs
@@ -8,7 +8,7 @@ namespace StringTemplates.UnitTests.Services.Plugins;
public class SystemTemplatePluginTests
{
[Fact]
- public void Replaces_Placeholder_Date_Now()
+ public void System_Date_Now_Replaced()
{
// Arrange
var expected = DateTime.Now.ToString("d", CultureInfo.InvariantCulture);
@@ -23,7 +23,7 @@ public void Replaces_Placeholder_Date_Now()
}
[Fact]
- public void Replaces_Placeholder_Date_UtcNow()
+ public void System_Date_UtcNow_Replaced()
{
// Arrange
var expected = DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture);
@@ -38,7 +38,7 @@ public void Replaces_Placeholder_Date_UtcNow()
}
[Fact]
- public void Replaces_Placeholder_DateTime_Now()
+ public void System_DateTime_Now_Format_Is_Correct()
{
// Arrange
var template = "[{{#System#DateTime.Now#System#}}]";
@@ -53,7 +53,7 @@ public void Replaces_Placeholder_DateTime_Now()
}
[Fact]
- public void Replaces_Placeholder_DateTime_UtcNow()
+ public void System_DateTime_UtcNow_Format_Is_Correct()
{
// Arrange
var template = "<{{#System#DateTime.UtcNow#System#}}>";
@@ -68,7 +68,7 @@ public void Replaces_Placeholder_DateTime_UtcNow()
}
[Fact]
- public void Replaces_Placeholder_Day()
+ public void System_Day_Replaced()
{
// Arrange
var expected = DateTime.Now.DayOfWeek.ToString();
@@ -83,7 +83,7 @@ public void Replaces_Placeholder_Day()
}
[Fact]
- public void Replaces_Placeholder_Month()
+ public void System_Month_Replaced()
{
// Arrange
var expected = DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture);
@@ -98,7 +98,7 @@ public void Replaces_Placeholder_Month()
}
[Fact]
- public void Replaces_Placeholder_Time_Now()
+ public void System_Time_Now_Format_Is_Correct()
{
// Arrange
var template = "time={{#System#Time.Now#System#}}";
@@ -113,7 +113,7 @@ public void Replaces_Placeholder_Time_Now()
}
[Fact]
- public void Replaces_Placeholder_Time_UtcNow()
+ public void System_Time_UtcNow_Format_Is_Correct()
{
// Arrange
var template = "utc={{#System#Time.UtcNow#System#}}";
@@ -127,9 +127,8 @@ public void Replaces_Placeholder_Time_UtcNow()
Assert.True(m.Success, $"Result '{result}' did not match expected time format.");
}
-
[Fact]
- public void Replaces_Placeholder_Year()
+ public void System_Year_Replaced()
{
// Arrange
var expectedYear = DateTime.Now.Year.ToString();
@@ -144,7 +143,7 @@ public void Replaces_Placeholder_Year()
}
[Fact]
- public void Leaves_Unknown_Placeholder_Untouched()
+ public void System_Unknown_Placeholder_Remains()
{
// Arrange
var template = "Unknown -> {{#System#Does.Not.Exist#System#}} <- keep it";