Skip to content
Merged
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
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,43 @@ Add `<Fluidify>` items to your project file. Each item points to a `.fluid` temp

```xml
<ItemGroup>
<Fluidify Include="Models/Greeting.cs.fluid" Greeting="HELLO" />
<Fluidify Include="WelcomeMessage.cs.fluid" Name="Alice" />
<Fluidify Include="Config/appsettings.json.fluid" AppName="MyApp" Version="1.0.0" />
</ItemGroup>
```

Templates use standard [Liquid syntax](https://shopify.github.io/liquid/):

**Models/Greeting.cs.fluid**
**WelcomeMessage.cs.fluid**
```liquid
public static class Greeting
public static class WelcomeMessage
{
public const string Value = "{{ Greeting }}, World!";
public const string Text = "Welcome, {{ Name }}!";
}
```

Building the project renders each template before compilation. The output path is inferred by stripping the `.fluid` extension, so `Models/Greeting.cs.fluid` produces `Models/Greeting.cs`.
Building the project renders each template before compilation. The output path is inferred by stripping the `.fluid` extension, so `WelcomeMessage.cs.fluid` produces `WelcomeMessage.cs`.

Fluidify works with any text format — C#, JSON, HTML, YAML, or anything else. The `.fluid` extension is just a convention; the template itself is plain text with Liquid tags.

### Compilation

Generated `.cs` files are automatically included in compilation. To opt out, set `Compile="false"`:

```xml
<ItemGroup>
<Fluidify Include="WelcomeMessage.cs.fluid" Name="Alice" Compile="false" />
</ItemGroup>
```

For non-`.cs` outputs that should be compiled, set `Compile="true"` explicitly:

```xml
<ItemGroup>
<Fluidify Include="Templates/Helper.fluid" Compile="true" />
</ItemGroup>
```

### Custom output path

Use the `Destination` metadata to write the output to a different location:
Expand All @@ -55,7 +73,7 @@ Relative paths are resolved from the project directory. Directories are created
Fluidify registers an MSBuild target that runs before `CoreCompile`. For each `<Fluidify>` item it:

1. Parses the `.fluid` file using the Fluid template engine
2. Passes all item metadata (except `Destination`) as template variables
2. Passes all item metadata (except `Destination` and `Compile`) as template variables
3. Writes the rendered output to the inferred or specified destination

MSBuild tracks input and output timestamps, so templates are only re-rendered when the source file changes.
Expand Down
2 changes: 1 addition & 1 deletion src/Fluidify/Fluidify.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<PackageId>Fluidify</PackageId>
<Version>0.1.0</Version>
<Version>0.0.0-local</Version>
<Authors>stanoddly</Authors>
<Description>A modern alternative to T4 — an MSBuild task that processes Fluid (Liquid) template files at build time to generate code, configuration, or any other text output.</Description>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
6 changes: 5 additions & 1 deletion src/Fluidify/FluidifyTask.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Fluid;
using Microsoft.Build.Framework;
Expand All @@ -9,6 +10,9 @@ namespace Fluidify;

public class FluidifyTask : Task
{
private static readonly HashSet<string> ExcludedMetadataKeys =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Destination", "Compile" };

[Required]
public ITaskItem[] Templates { get; set; } = Array.Empty<ITaskItem>();

Expand Down Expand Up @@ -47,7 +51,7 @@ public override bool Execute()
foreach (DictionaryEntry entry in item.CloneCustomMetadata())
{
string key = entry.Key.ToString();
if (!string.Equals(key, "Destination", StringComparison.OrdinalIgnoreCase))
if (!ExcludedMetadataKeys.Contains(key))
{
context.SetValue(key, entry.Value?.ToString() ?? "");
}
Expand Down
18 changes: 18 additions & 0 deletions src/Fluidify/build/Fluidify.targets
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,22 @@
<FluidifyTask Templates="@(Fluidify)" ProjectDirectory="$(MSBuildProjectDirectory)" />
</Target>

<Target Name="AddFluidifyCompileItems" BeforeTargets="CoreCompile"
DependsOnTargets="ProcessFluidTemplates"
Condition="'@(Fluidify)' != ''">
<ItemGroup>
<!-- Collect outputs that should be compiled: .cs by default (unless Compile="false"), others only with Compile="true". -->
<_FluidifyCompileOutput Include="%(Fluidify.OutputPath)"
Condition="('%(Fluidify.Compile)' == 'true') or
($([System.IO.Path]::GetExtension('%(Fluidify.OutputPath)')) == '.cs' and '%(Fluidify.Compile)' != 'false')" />
</ItemGroup>
<ItemGroup>
<!-- Remove then Include: prevents duplicate Compile entries when the file already exists on disk
from a previous build and was picked up by the SDK glob at project load time. -->
<Compile Remove="@(_FluidifyCompileOutput)" />
<Compile Include="@(_FluidifyCompileOutput)" />
<_FluidifyCompileOutput Remove="@(_FluidifyCompileOutput)" />
</ItemGroup>
</Target>

</Project>
91 changes: 91 additions & 0 deletions tests/Fluidify.Tests.Functional/CompileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Diagnostics;
using NUnit.Framework;

namespace Fluidify.Tests.Functional;

[TestFixture]
public class CompileTests
{
private static readonly string SolutionRoot = Path.GetFullPath(
Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", ".."));

private static readonly string FluidifyProject = Path.Combine(
SolutionRoot, "src", "Fluidify", "Fluidify.csproj");

private static readonly string CompileAppDir = Path.Combine(
SolutionRoot, "tests", "Fluidify.Tests.Functional", "Fixtures", "CompileApp");

[OneTimeSetUp]
public async Task PackAndBuildFluidify()
{
// Clear any cached Fluidify package from the global NuGet cache
(int ExitCode, string Output) localsResult = await RunDotnet("nuget locals global-packages --list");
string globalPackagesDir = localsResult.Output
.Replace("global-packages:", "")
.Replace("info :", "")
.Trim();
string fluidifyCache = Path.Combine(globalPackagesDir, "fluidify");
if (Directory.Exists(fluidifyCache))
Directory.Delete(fluidifyCache, true);

// Build Fluidify (GeneratePackageOnBuild produces the nupkg)
(int ExitCode, string Output) buildResult = await RunDotnet(
$"build \"{FluidifyProject}\" -c Release");
Assert.That(buildResult.ExitCode, Is.EqualTo(0),
$"Fluidify build failed:\n{buildResult.Output}");
}

private static readonly string CompileFalseAppDir = Path.Combine(
SolutionRoot, "tests", "Fluidify.Tests.Functional", "Fixtures", "CompileFalseApp");

[Test]
public async Task GeneratedCsFile_IsCompiledSuccessfully()
{
string generatedFile = Path.Combine(CompileAppDir, "Models", "Greeter.cs");

// Delete generated file to simulate a clean build
if (File.Exists(generatedFile))
File.Delete(generatedFile);

string compileAppProject = Path.Combine(CompileAppDir, "CompileApp.csproj");
(int ExitCode, string Output) result = await RunDotnet($"build \"{compileAppProject}\" --force");

Assert.That(result.ExitCode, Is.EqualTo(0),
$"CompileApp build failed — generated .cs file was not included in compilation:\n{result.Output}");
}

[Test]
public async Task GeneratedCsFile_CompileFalse_IsExcludedFromCompilation()
{
string generatedFile = Path.Combine(CompileFalseAppDir, "Models", "Greeter.cs");

// Delete generated file to simulate a clean build
if (File.Exists(generatedFile))
File.Delete(generatedFile);

string project = Path.Combine(CompileFalseAppDir, "CompileFalseApp.csproj");
(int ExitCode, string Output) result = await RunDotnet($"build \"{project}\" --force");

Assert.That(result.ExitCode, Is.Not.EqualTo(0),
$"Build should have failed — Compile=\"false\" should exclude the generated .cs from compilation:\n{result.Output}");
Assert.That(result.Output, Does.Contain("CS0246").Or.Contains("CS0103"),
"Expected a missing type/namespace compiler error");
}

private static async Task<(int ExitCode, string Output)> RunDotnet(string arguments)
{
ProcessStartInfo psi = new ProcessStartInfo("dotnet", arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};

using Process process = Process.Start(psi)!;
string stdout = await process.StandardOutput.ReadToEndAsync();
string stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();

return (process.ExitCode, stdout + stderr);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<!-- Version must match src/Fluidify/Fluidify.csproj; resolved from the local artifacts feed, not nuget.org -->
<PackageReference Include="Fluidify" Version="0.0.0-local" />
</ItemGroup>

<ItemGroup>
<Fluidify Include="Models/Greeter.cs.fluid" Greeting="HELLO" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CompileApp.Models;

public static class Greeter
{
public static string Message => "HELLO, World!";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CompileApp.Models;

public static class Greeter
{
public static string Message => "{{ Greeting }}, World!";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using CompileApp.Models;

Console.WriteLine(Greeter.Message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="Local" value="../../../../artifacts/pkg" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<!-- Version must match src/Fluidify/Fluidify.csproj; resolved from the local artifacts feed, not nuget.org -->
<PackageReference Include="Fluidify" Version="0.0.0-local" />
</ItemGroup>

<ItemGroup>
<Fluidify Include="Models/Greeter.cs.fluid" Greeting="HELLO" Compile="false" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CompileFalseApp.Models;

public static class Greeter
{
public static string Message => "HELLO, World!";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CompileFalseApp.Models;

public static class Greeter
{
public static string Message => "{{ Greeting }}, World!";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using CompileFalseApp.Models;

Console.WriteLine(Greeter.Message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="Local" value="../../../../artifacts/pkg" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Fluidify" Version="0.1.0" />
<!-- Version must match src/Fluidify/Fluidify.csproj; resolved from the local artifacts feed, not nuget.org -->
<PackageReference Include="Fluidify" Version="0.0.0-local" />
</ItemGroup>

<ItemGroup>
Expand Down