Skip to content

Commit ebb84e4

Browse files
committed
Add public ISassCompiler interface to call dart-sass executable directly
1 parent 529d51a commit ebb84e4

10 files changed

+399
-92
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<SassCompilerIncludeRuntime>true</SassCompilerIncludeRuntime>
5+
</PropertyGroup>
6+
7+
<!-- Only needed because we're using a ProjectReference, this is done implicitly for PackageReference's -->
8+
<Import Project="..\AspNetCore.SassCompiler\build\AspNetCore.SassCompiler.props" />
9+
10+
<PropertyGroup>
11+
<TargetFramework>net6.0</TargetFramework>
12+
<Nullable>enable</Nullable>
13+
<ImplicitUsings>enable</ImplicitUsings>
14+
15+
<IsPackable>false</IsPackable>
16+
</PropertyGroup>
17+
18+
<PropertyGroup>
19+
<SassCompilerTasksAssembly Condition=" '$(Configuration)' != '' ">$(MSBuildThisFileDirectory)..\AspNetCore.SassCompiler.Tasks\bin\$(Configuration)\netstandard2.0\AspNetCore.SassCompiler.Tasks.dll</SassCompilerTasksAssembly>
20+
<SassCompilerTasksAssembly Condition=" '$(Configuration)' == '' ">$(MSBuildThisFileDirectory)..\AspNetCore.SassCompiler.Tasks\bin\Debug\netstandard2.0\AspNetCore.SassCompiler.Tasks.dll</SassCompilerTasksAssembly>
21+
</PropertyGroup>
22+
23+
<ItemGroup>
24+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
25+
<PackageReference Include="xunit" Version="2.7.0" />
26+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
27+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
28+
<PrivateAssets>all</PrivateAssets>
29+
</PackageReference>
30+
<PackageReference Include="coverlet.collector" Version="6.0.1">
31+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
32+
<PrivateAssets>all</PrivateAssets>
33+
</PackageReference>
34+
</ItemGroup>
35+
36+
<ItemGroup>
37+
<ProjectReference Include="..\AspNetCore.SassCompiler\AspNetCore.SassCompiler.csproj" />
38+
</ItemGroup>
39+
40+
<!-- Only needed because we're using a ProjectReference, this is done implicitly for PackageReference's -->
41+
<Import Project="..\AspNetCore.SassCompiler\build\AspNetCore.SassCompiler.targets" />
42+
43+
</Project>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Text;
2+
using Xunit;
3+
4+
namespace AspNetCore.SassCompiler.Tests;
5+
6+
public class SassCompilerTests
7+
{
8+
[Fact]
9+
public async Task CompileAsync_WithoutStreams_Success()
10+
{
11+
// Arrange
12+
var sassCompiler = new SassCompiler();
13+
14+
var tempDirectory = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
15+
Directory.CreateDirectory(tempDirectory);
16+
17+
try
18+
{
19+
await File.WriteAllTextAsync(Path.Join(tempDirectory, "input"), "body { color: black; }");
20+
21+
// Act
22+
await sassCompiler.CompileAsync(new[] { Path.Join(tempDirectory, "input"), Path.Join(tempDirectory, "output"), "--no-source-map" });
23+
var result = await File.ReadAllTextAsync(Path.Join(tempDirectory, "output"));
24+
25+
// Assert
26+
Assert.Equal("body {\n color: black;\n}\n", result);
27+
}
28+
finally
29+
{
30+
Directory.Delete(tempDirectory, true);
31+
}
32+
}
33+
34+
[Theory]
35+
[InlineData("--watch")]
36+
[InlineData("--interactive")]
37+
public async Task CompileAsync_ThrowsWithInvalidArguments(string argument)
38+
{
39+
// Arrange
40+
var sassCompiler = new SassCompiler();
41+
42+
var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));
43+
44+
// Act
45+
async Task Act() => await sassCompiler.CompileAsync(input, new[] { argument });
46+
47+
// Assert
48+
var exception = await Assert.ThrowsAsync<SassCompilerException>(Act);
49+
Assert.Equal($"The sass {argument} option is not supported.", exception.Message);
50+
Assert.Null(exception.ErrorOutput);
51+
}
52+
53+
[Fact]
54+
public async Task CompileAsync_ThrowsWithInvalidInput()
55+
{
56+
// Arrange
57+
var sassCompiler = new SassCompiler();
58+
59+
var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black;"));
60+
61+
// Act
62+
async Task Act() => await sassCompiler.CompileAsync(input, Array.Empty<string>());
63+
64+
// Assert
65+
var exception = await Assert.ThrowsAsync<SassCompilerException>(Act);
66+
Assert.Equal("Sass process exited with non-zero exit code: 65.", exception.Message);
67+
Assert.StartsWith("Error: expected \"}\".", exception.ErrorOutput);
68+
}
69+
70+
[Fact]
71+
public async Task CompileAsync_ReturningOutputStream_Success()
72+
{
73+
// Arrange
74+
var sassCompiler = new SassCompiler();
75+
76+
var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));
77+
78+
// Act
79+
var output = await sassCompiler.CompileAsync(input, Array.Empty<string>());
80+
var result = await new StreamReader(output).ReadToEndAsync();
81+
82+
// Assert
83+
Assert.Equal("body {\n color: black;\n}\n", result);
84+
}
85+
86+
[Fact]
87+
public async Task CompileAsync_WithInputAndOutputStream_Success()
88+
{
89+
// Arrange
90+
var sassCompiler = new SassCompiler();
91+
92+
var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));
93+
var output = new MemoryStream();
94+
95+
// Act
96+
await sassCompiler.CompileAsync(input, output, Array.Empty<string>());
97+
var result = Encoding.UTF8.GetString(output.ToArray());
98+
99+
// Assert
100+
Assert.Equal("body {\n color: black;\n}\n", result);
101+
}
102+
103+
[Fact]
104+
public async Task CompileToStringAsync_Success()
105+
{
106+
// Arrange
107+
var sassCompiler = new SassCompiler();
108+
109+
var input = new MemoryStream(Encoding.UTF8.GetBytes("body { color: black; }"));
110+
111+
// Act
112+
var result = await sassCompiler.CompileToStringAsync(input, Array.Empty<string>());
113+
114+
// Assert
115+
Assert.Equal("body {\n color: black;\n}\n", result);
116+
}
117+
}

AspNetCore.SassCompiler.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.Bla
2020
EndProject
2121
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.RazorClassLibrary", "Samples\AspNetCore.SassCompiler.RazorClassLibrary\AspNetCore.SassCompiler.RazorClassLibrary.csproj", "{B318D98D-B145-4ACF-8B48-D601FB5C91E4}"
2222
EndProject
23+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.SassCompiler.Tests", "AspNetCore.SassCompiler.Tests\AspNetCore.SassCompiler.Tests.csproj", "{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}"
24+
EndProject
2325
Global
2426
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2527
Debug|Any CPU = Debug|Any CPU
@@ -50,6 +52,10 @@ Global
5052
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
5153
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
5254
{B318D98D-B145-4ACF-8B48-D601FB5C91E4}.Release|Any CPU.Build.0 = Release|Any CPU
55+
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
56+
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Debug|Any CPU.Build.0 = Debug|Any CPU
57+
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Release|Any CPU.ActiveCfg = Release|Any CPU
58+
{0ACD7A9B-8D2A-4274-86F8-0FA4A2413903}.Release|Any CPU.Build.0 = Release|Any CPU
5359
EndGlobalSection
5460
GlobalSection(SolutionProperties) = preSolution
5561
HideSolutionNode = FALSE

AspNetCore.SassCompiler/AspNetCore.SassCompiler.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
<ProjectReference Include="..\AspNetCore.SassCompiler.Tasks\AspNetCore.SassCompiler.Tasks.csproj" ReferenceOutputAssembly="false" PrivateAssets="All" />
3333
</ItemGroup>
3434

35+
<ItemGroup>
36+
<InternalsVisibleTo Include="AspNetCore.SassCompiler.Tests" />
37+
</ItemGroup>
38+
3539
<ItemGroup>
3640
<None Include="build\*" Pack="true" PackagePath="build" />
3741
<None Include="runtimes\**" Pack="true" PackagePath="runtimes" />

AspNetCore.SassCompiler/ChildProcessTracker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace AspNetCore.SassCompiler
1111
/// <remarks>References:
1212
/// https://stackoverflow.com/a/4657392/386091
1313
/// https://stackoverflow.com/a/9164742/386091 </remarks>
14-
public static class ChildProcessTracker
14+
internal static class ChildProcessTracker
1515
{
1616
/// <summary>
1717
/// Add the process to be tracked. If our current process is killed, the child processes
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Reflection;
6+
using System.Runtime.InteropServices;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
10+
namespace AspNetCore.SassCompiler;
11+
12+
public interface ISassCompiler
13+
{
14+
Task CompileAsync(string[] args);
15+
16+
Task<Stream> CompileAsync(Stream input, string[] args);
17+
18+
Task CompileAsync(Stream input, Stream output, string[] args);
19+
20+
Task<string> CompileToStringAsync(Stream input, string[] args);
21+
}
22+
23+
internal class SassCompiler : ISassCompiler
24+
{
25+
public async Task CompileAsync(string[] args)
26+
{
27+
var escapedArgs = args.Length > 0 ? '"' + string.Join("\" \"", args) + '"' : "";
28+
ValidateArgs(escapedArgs);
29+
var process = CreateSassProcess(escapedArgs);
30+
if (process == null)
31+
throw new SassCompilerException("Sass executable not found");
32+
33+
var errorOutput = new MemoryStream();
34+
35+
process.Start();
36+
ChildProcessTracker.AddProcess(process);
37+
await process.StandardOutput.BaseStream.CopyToAsync(Stream.Null);
38+
await process.StandardError.BaseStream.CopyToAsync(errorOutput);
39+
await process.WaitForExitAsync();
40+
41+
if (process.ExitCode != 0)
42+
{
43+
var errorOutputText = Encoding.UTF8.GetString(errorOutput.ToArray());
44+
throw new SassCompilerException($"Sass process exited with non-zero exit code: {process.ExitCode}.", errorOutputText);
45+
}
46+
47+
process.Dispose();
48+
}
49+
50+
public async Task<Stream> CompileAsync(Stream input, string[] args)
51+
{
52+
var output = new MemoryStream();
53+
await CompileAsync(input, output, args);
54+
55+
output.Position = 0;
56+
return output;
57+
}
58+
59+
public async Task CompileAsync(Stream input, Stream output, string[] args)
60+
{
61+
var escapedArgs = args.Length > 0 ? '"' + string.Join("\" \"", args) + '"' : "";
62+
ValidateArgs(escapedArgs);
63+
var process = CreateSassProcess(escapedArgs + " --stdin");
64+
if (process == null)
65+
throw new SassCompilerException("Sass executable not found");
66+
67+
process.StartInfo.RedirectStandardInput = true;
68+
69+
var errorOutput = new MemoryStream();
70+
71+
process.Start();
72+
ChildProcessTracker.AddProcess(process);
73+
await input.CopyToAsync(process.StandardInput.BaseStream);
74+
await process.StandardInput.DisposeAsync();
75+
await process.StandardOutput.BaseStream.CopyToAsync(output);
76+
await process.StandardError.BaseStream.CopyToAsync(errorOutput);
77+
await process.WaitForExitAsync();
78+
79+
if (process.ExitCode != 0)
80+
{
81+
var errorOutputText = Encoding.UTF8.GetString(errorOutput.ToArray());
82+
throw new SassCompilerException($"Sass process exited with non-zero exit code: {process.ExitCode}.", errorOutputText);
83+
}
84+
85+
process.Dispose();
86+
}
87+
88+
public async Task<string> CompileToStringAsync(Stream input, string[] args)
89+
{
90+
using var output = new MemoryStream();
91+
await CompileAsync(input, output, args);
92+
return Encoding.UTF8.GetString(output.ToArray());
93+
}
94+
95+
private static void ValidateArgs(string args)
96+
{
97+
if (args.Contains("--watch"))
98+
throw new SassCompilerException("The sass --watch option is not supported.");
99+
100+
if (args.Contains("--interactive"))
101+
throw new SassCompilerException("The sass --interactive option is not supported.");
102+
}
103+
104+
internal static Process CreateSassProcess(string arguments)
105+
{
106+
var command = GetSassCommand();
107+
if (command.Filename == null)
108+
return null;
109+
110+
if (!string.IsNullOrEmpty(command.Snapshot))
111+
arguments = $"{command.Snapshot} {arguments}";
112+
113+
var process = new Process();
114+
process.StartInfo.FileName = command.Filename;
115+
process.StartInfo.Arguments = arguments;
116+
process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
117+
process.StartInfo.CreateNoWindow = true;
118+
process.StartInfo.UseShellExecute = false;
119+
process.StartInfo.RedirectStandardOutput = true;
120+
process.StartInfo.RedirectStandardError = true;
121+
process.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory();
122+
123+
return process;
124+
}
125+
126+
internal static (string Filename, string Snapshot) GetSassCommand()
127+
{
128+
var attribute = Assembly.GetEntryAssembly()?.GetCustomAttributes<SassCompilerAttribute>().FirstOrDefault();
129+
130+
if (attribute != null)
131+
return (attribute.SassBinary, string.IsNullOrWhiteSpace(attribute.SassSnapshot) ? "" : $"\"{attribute.SassSnapshot}\"");
132+
133+
var assemblyLocation = typeof(SassCompilerHostedService).Assembly.Location;
134+
135+
var (exePath, snapshotPath) = GetExeAndSnapshotPath();
136+
if (exePath == null)
137+
return (null, null);
138+
139+
var directory = Path.GetDirectoryName(assemblyLocation);
140+
while (!string.IsNullOrEmpty(directory) && directory != "/")
141+
{
142+
if (File.Exists(Path.Join(directory, exePath)))
143+
return (Path.Join(directory, exePath), snapshotPath == null ? null : "\"" + Path.Join(directory, snapshotPath) + "\"");
144+
145+
directory = Path.GetDirectoryName(directory);
146+
}
147+
148+
return (null, null);
149+
}
150+
151+
internal static (string ExePath, string SnapshotPath) GetExeAndSnapshotPath()
152+
{
153+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
154+
{
155+
return RuntimeInformation.OSArchitecture switch
156+
{
157+
Architecture.X64 => ("runtimes\\win-x64\\src\\dart.exe", "runtimes\\win-x64\\src\\sass.snapshot"),
158+
Architecture.Arm64 => ("runtimes\\win-x64\\src\\dart.exe", "runtimes\\win-x64\\src\\sass.snapshot"),
159+
_ => (null, null),
160+
};
161+
}
162+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
163+
{
164+
return RuntimeInformation.OSArchitecture switch
165+
{
166+
Architecture.X64 => ("runtimes/linux-x64/src/dart", "runtimes/linux-x64/src/sass.snapshot"),
167+
Architecture.Arm64 => ("runtimes/linux-arm64/src/dart", "runtimes/linux-x64/src/sass.snapshot"),
168+
_ => (null, null),
169+
};
170+
}
171+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
172+
{
173+
return RuntimeInformation.OSArchitecture switch
174+
{
175+
Architecture.X64 => ("runtimes/osx-x64/src/dart", "runtimes/osx-x64/src/sass.snapshot"),
176+
Architecture.Arm64 => ("runtimes/osx-arm64/src/dart", "runtimes/osx-arm64/src/sass.snapshot"),
177+
_ => (null, null),
178+
};
179+
}
180+
181+
return (null, null);
182+
}
183+
}

0 commit comments

Comments
 (0)