Skip to content

Commit

Permalink
Add public ISassCompiler interface to call dart-sass executable directly
Browse files Browse the repository at this point in the history
  • Loading branch information
sleeuwen committed Mar 11, 2024
1 parent 529d51a commit ebb84e4
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 92 deletions.
43 changes: 43 additions & 0 deletions AspNetCore.SassCompiler.Tests/AspNetCore.SassCompiler.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<SassCompilerIncludeRuntime>true</SassCompilerIncludeRuntime>
</PropertyGroup>

<!-- Only needed because we're using a ProjectReference, this is done implicitly for PackageReference's -->
<Import Project="..\AspNetCore.SassCompiler\build\AspNetCore.SassCompiler.props" />

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

<IsPackable>false</IsPackable>
</PropertyGroup>

<PropertyGroup>
<SassCompilerTasksAssembly Condition=" '$(Configuration)' != '' ">$(MSBuildThisFileDirectory)..\AspNetCore.SassCompiler.Tasks\bin\$(Configuration)\netstandard2.0\AspNetCore.SassCompiler.Tasks.dll</SassCompilerTasksAssembly>
<SassCompilerTasksAssembly Condition=" '$(Configuration)' == '' ">$(MSBuildThisFileDirectory)..\AspNetCore.SassCompiler.Tasks\bin\Debug\netstandard2.0\AspNetCore.SassCompiler.Tasks.dll</SassCompilerTasksAssembly>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCore.SassCompiler\AspNetCore.SassCompiler.csproj" />
</ItemGroup>

<!-- Only needed because we're using a ProjectReference, this is done implicitly for PackageReference's -->
<Import Project="..\AspNetCore.SassCompiler\build\AspNetCore.SassCompiler.targets" />

</Project>
117 changes: 117 additions & 0 deletions AspNetCore.SassCompiler.Tests/SassCompilerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Text;
using Xunit;

namespace AspNetCore.SassCompiler.Tests;

public class SassCompilerTests
{
[Fact]
public async Task CompileAsync_WithoutStreams_Success()
{
// Arrange
var sassCompiler = new SassCompiler();

var tempDirectory = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDirectory);

try
{
await File.WriteAllTextAsync(Path.Join(tempDirectory, "input"), "body { color: black; }");

// Act
await sassCompiler.CompileAsync(new[] { Path.Join(tempDirectory, "input"), Path.Join(tempDirectory, "output"), "--no-source-map" });
var result = await File.ReadAllTextAsync(Path.Join(tempDirectory, "output"));

// Assert
Assert.Equal("body {\n color: black;\n}\n", result);
}
finally
{
Directory.Delete(tempDirectory, true);
}
}

[Theory]
[InlineData("--watch")]
[InlineData("--interactive")]
public async Task CompileAsync_ThrowsWithInvalidArguments(string argument)
{
// Arrange
var sassCompiler = new SassCompiler();

var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));

// Act
async Task Act() => await sassCompiler.CompileAsync(input, new[] { argument });

// Assert
var exception = await Assert.ThrowsAsync<SassCompilerException>(Act);
Assert.Equal($"The sass {argument} option is not supported.", exception.Message);
Assert.Null(exception.ErrorOutput);
}

[Fact]
public async Task CompileAsync_ThrowsWithInvalidInput()
{
// Arrange
var sassCompiler = new SassCompiler();

var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black;"));

// Act
async Task Act() => await sassCompiler.CompileAsync(input, Array.Empty<string>());

// Assert
var exception = await Assert.ThrowsAsync<SassCompilerException>(Act);
Assert.Equal("Sass process exited with non-zero exit code: 65.", exception.Message);
Assert.StartsWith("Error: expected \"}\".", exception.ErrorOutput);
}

[Fact]
public async Task CompileAsync_ReturningOutputStream_Success()
{
// Arrange
var sassCompiler = new SassCompiler();

var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));

// Act
var output = await sassCompiler.CompileAsync(input, Array.Empty<string>());
var result = await new StreamReader(output).ReadToEndAsync();

// Assert
Assert.Equal("body {\n color: black;\n}\n", result);
}

[Fact]
public async Task CompileAsync_WithInputAndOutputStream_Success()
{
// Arrange
var sassCompiler = new SassCompiler();

var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));
var output = new MemoryStream();

// Act
await sassCompiler.CompileAsync(input, output, Array.Empty<string>());
var result = Encoding.UTF8.GetString(output.ToArray());

// Assert
Assert.Equal("body {\n color: black;\n}\n", result);
}

[Fact]
public async Task CompileToStringAsync_Success()
{
// Arrange
var sassCompiler = new SassCompiler();

var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));

// Act
var result = await sassCompiler.CompileToStringAsync(input, Array.Empty<string>());

// Assert
Assert.Equal("body {\n color: black;\n}\n", result);
}
}
6 changes: 6 additions & 0 deletions AspNetCore.SassCompiler.sln
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.Bla
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.RazorClassLibrary", "Samples\AspNetCore.SassCompiler.RazorClassLibrary\AspNetCore.SassCompiler.RazorClassLibrary.csproj", "{B318D98D-B145-4ACF-8B48-D601FB5C91E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.Tests", "AspNetCore.SassCompiler.Tests\AspNetCore.SassCompiler.Tests.csproj", "{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -50,6 +52,10 @@ Global
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Release|Any CPU.Build.0 = Release|Any CPU
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
4 changes: 4 additions & 0 deletions AspNetCore.SassCompiler/AspNetCore.SassCompiler.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
<ProjectReference Include="..\AspNetCore.SassCompiler.Tasks\AspNetCore.SassCompiler.Tasks.csproj" ReferenceOutputAssembly="false" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="AspNetCore.SassCompiler.Tests" />
</ItemGroup>

<ItemGroup>
<None Include="build\*" Pack="true" PackagePath="build" />
<None Include="runtimes\**" Pack="true" PackagePath="runtimes" />
Expand Down
2 changes: 1 addition & 1 deletion AspNetCore.SassCompiler/ChildProcessTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace AspNetCore.SassCompiler
/// <remarks>References:
/// https://stackoverflow.com/a/4657392/386091
/// https://stackoverflow.com/a/9164742/386091 </remarks>
public static class ChildProcessTracker
internal static class ChildProcessTracker
{
/// <summary>
/// Add the process to be tracked. If our current process is killed, the child processes
Expand Down
183 changes: 183 additions & 0 deletions AspNetCore.SassCompiler/SassCompiler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace AspNetCore.SassCompiler;

public interface ISassCompiler
{
Task CompileAsync(string[] args);

Task<Stream> CompileAsync(Stream input, string[] args);

Task CompileAsync(Stream input, Stream output, string[] args);

Task<string> CompileToStringAsync(Stream input, string[] args);
}

internal class SassCompiler : ISassCompiler
{
public async Task CompileAsync(string[] args)
{
var escapedArgs = args.Length > 0 ? '"' + string.Join("\" \"", args) + '"' : "";
ValidateArgs(escapedArgs);
var process = CreateSassProcess(escapedArgs);
if (process == null)
throw new SassCompilerException("Sass executable not found");

var errorOutput = new MemoryStream();

process.Start();
ChildProcessTracker.AddProcess(process);
await process.StandardOutput.BaseStream.CopyToAsync(Stream.Null);
await process.StandardError.BaseStream.CopyToAsync(errorOutput);
await process.WaitForExitAsync();

if (process.ExitCode != 0)
{
var errorOutputText = Encoding.UTF8.GetString(errorOutput.ToArray());
throw new SassCompilerException($"Sass process exited with non-zero exit code: {process.ExitCode}.", errorOutputText);
}

process.Dispose();
}

public async Task<Stream> CompileAsync(Stream input, string[] args)
{
var output = new MemoryStream();
await CompileAsync(input, output, args);

output.Position = 0;
return output;
}

public async Task CompileAsync(Stream input, Stream output, string[] args)
{
var escapedArgs = args.Length > 0 ? '"' + string.Join("\" \"", args) + '"' : "";
ValidateArgs(escapedArgs);
var process = CreateSassProcess(escapedArgs + " --stdin");
if (process == null)
throw new SassCompilerException("Sass executable not found");

process.StartInfo.RedirectStandardInput = true;

var errorOutput = new MemoryStream();

process.Start();
ChildProcessTracker.AddProcess(process);
await input.CopyToAsync(process.StandardInput.BaseStream);
await process.StandardInput.DisposeAsync();
await process.StandardOutput.BaseStream.CopyToAsync(output);
await process.StandardError.BaseStream.CopyToAsync(errorOutput);
await process.WaitForExitAsync();

if (process.ExitCode != 0)
{
var errorOutputText = Encoding.UTF8.GetString(errorOutput.ToArray());
throw new SassCompilerException($"Sass process exited with non-zero exit code: {process.ExitCode}.", errorOutputText);
}

process.Dispose();
}

public async Task<string> CompileToStringAsync(Stream input, string[] args)
{
using var output = new MemoryStream();
await CompileAsync(input, output, args);
return Encoding.UTF8.GetString(output.ToArray());
}

private static void ValidateArgs(string args)
{
if (args.Contains("--watch"))
throw new SassCompilerException("The sass --watch option is not supported.");

if (args.Contains("--interactive"))
throw new SassCompilerException("The sass --interactive option is not supported.");
}

internal static Process CreateSassProcess(string arguments)
{
var command = GetSassCommand();
if (command.Filename == null)
return null;

if (!string.IsNullOrEmpty(command.Snapshot))
arguments = $"{command.Snapshot} {arguments}";

var process = new Process();
process.StartInfo.FileName = command.Filename;
process.StartInfo.Arguments = arguments;
process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory();

return process;
}

internal static (string Filename, string Snapshot) GetSassCommand()
{
var attribute = Assembly.GetEntryAssembly()?.GetCustomAttributes<SassCompilerAttribute>().FirstOrDefault();

if (attribute != null)
return (attribute.SassBinary, string.IsNullOrWhiteSpace(attribute.SassSnapshot) ? "" : $"\"{attribute.SassSnapshot}\"");

var assemblyLocation = typeof(SassCompilerHostedService).Assembly.Location;

var (exePath, snapshotPath) = GetExeAndSnapshotPath();
if (exePath == null)
return (null, null);

var directory = Path.GetDirectoryName(assemblyLocation);
while (!string.IsNullOrEmpty(directory) && directory != "/")
{
if (File.Exists(Path.Join(directory, exePath)))
return (Path.Join(directory, exePath), snapshotPath == null ? null : "\"" + Path.Join(directory, snapshotPath) + "\"");

directory = Path.GetDirectoryName(directory);
}

return (null, null);
}

internal static (string ExePath, string SnapshotPath) GetExeAndSnapshotPath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => ("runtimes\\win-x64\\src\\dart.exe", "runtimes\\win-x64\\src\\sass.snapshot"),
Architecture.Arm64 => ("runtimes\\win-x64\\src\\dart.exe", "runtimes\\win-x64\\src\\sass.snapshot"),
_ => (null, null),
};
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => ("runtimes/linux-x64/src/dart", "runtimes/linux-x64/src/sass.snapshot"),
Architecture.Arm64 => ("runtimes/linux-arm64/src/dart", "runtimes/linux-x64/src/sass.snapshot"),
_ => (null, null),
};
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => ("runtimes/osx-x64/src/dart", "runtimes/osx-x64/src/sass.snapshot"),
Architecture.Arm64 => ("runtimes/osx-arm64/src/dart", "runtimes/osx-arm64/src/sass.snapshot"),
_ => (null, null),
};
}

return (null, null);
}
}
Loading

0 comments on commit ebb84e4

Please sign in to comment.