Skip to content

Commit 4bfba5c

Browse files
Tom BrewerTom Brewer
authored andcommitted
feat: razor previews
1 parent b0227fb commit 4bfba5c

11 files changed

Lines changed: 1101 additions & 72 deletions

File tree

Apollo.Compilation.Worker/Program.cs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Buffers.Text;
1+
using System.Buffers.Text;
22
using System.Collections.Concurrent;
33
using System.Reflection;
44
using System.Runtime.Loader;
@@ -64,9 +64,41 @@ await resolver.GetMetadataReferenceAsync("System.Console.wasm"),
6464
await resolver.GetMetadataReferenceAsync("xunit.assert.wasm"),
6565
await resolver.GetMetadataReferenceAsync("xunit.core.wasm")
6666
};
67-
67+
68+
var isRazorProject = solution.Type == ProjectType.RazorClassLibrary ||
69+
solution.Items.Any(i => i.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase));
70+
71+
if (isRazorProject)
72+
{
73+
try
74+
{
75+
references.Add(await resolver.GetMetadataReferenceAsync("System.Threading.Tasks.wasm"));
76+
references.Add(await resolver.GetMetadataReferenceAsync("System.Collections.wasm"));
77+
references.Add(await resolver.GetMetadataReferenceAsync("System.Linq.wasm"));
78+
references.Add(await resolver.GetMetadataReferenceAsync("System.ObjectModel.wasm"));
79+
references.Add(await resolver.GetMetadataReferenceAsync("System.ComponentModel.wasm"));
80+
references.Add(await resolver.GetMetadataReferenceAsync("System.ComponentModel.Primitives.wasm"));
81+
LogMessageWriter.Log("Added system references for Razor project", LogSeverity.Debug);
82+
}
83+
catch (Exception ex)
84+
{
85+
LogMessageWriter.Log($"Warning: Could not load system references: {ex.Message}", LogSeverity.Warning);
86+
}
87+
88+
try
89+
{
90+
references.Add(await resolver.GetMetadataReferenceAsync("Microsoft.AspNetCore.Components.wasm"));
91+
references.Add(await resolver.GetMetadataReferenceAsync("Microsoft.AspNetCore.Components.Web.wasm"));
92+
LogMessageWriter.Log("Added Blazor component references for Razor project", LogSeverity.Debug);
93+
}
94+
catch (Exception ex)
95+
{
96+
LogMessageWriter.Log($"Warning: Could not load Blazor references: {ex.Message}", LogSeverity.Warning);
97+
}
98+
}
99+
68100
nugetAssemblyCache = solution.NuGetReferences ?? [];
69-
101+
70102
foreach (var nugetRef in nugetAssemblyCache)
71103
{
72104
if (nugetRef.AssemblyData?.Length > 0)
@@ -76,8 +108,10 @@ await resolver.GetMetadataReferenceAsync("xunit.core.wasm")
76108
LogMessageWriter.Log($"Added NuGet reference: {nugetRef.AssemblyName}", LogSeverity.Debug);
77109
}
78110
}
79-
80-
var result = new CompilationService().Compile(solution, references);
111+
112+
var result = isRazorProject
113+
? new RazorCompilationService().Compile(solution, references)
114+
: new CompilationService().Compile(solution, references);
81115

82116
asmCache = result.Assembly;
83117

Apollo.Compilation/Apollo.Compilation.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFramework>net10.0</TargetFramework>
@@ -13,6 +13,7 @@
1313

1414
<ItemGroup>
1515
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
16+
<PackageReference Include="Microsoft.AspNetCore.Razor.Language" />
1617
<PackageReference Include="Microsoft.Extensions.Hosting" />
1718
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
1819
<PackageReference Include="System.Console" />
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
using System.Collections.Immutable;
2+
using System.Diagnostics;
3+
using System.Text.RegularExpressions;
4+
using Apollo.Contracts.Compilation;
5+
using Microsoft.AspNetCore.Razor.Language;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CSharp;
8+
using Solution = Apollo.Contracts.Solutions.Solution;
9+
10+
namespace Apollo.Compilation;
11+
12+
/// <summary>
13+
/// Compiles Razor component files (.razor) to assemblies.
14+
/// </summary>
15+
public class RazorCompilationService
16+
{
17+
/// <summary>
18+
/// Compiles a solution containing Razor files to an assembly.
19+
/// </summary>
20+
public CompilationReferenceResult Compile(Solution solution, IEnumerable<MetadataReference> references)
21+
{
22+
var stopwatch = Stopwatch.StartNew();
23+
var syntaxTrees = new List<SyntaxTree>();
24+
var diagnostics = new List<string>();
25+
26+
diagnostics.Add($"Starting Razor compilation for {solution.Name} with {solution.Items.Count} items");
27+
28+
var importFiles = solution.Items
29+
.Where(item => item.Path.EndsWith("_Imports.razor", StringComparison.OrdinalIgnoreCase))
30+
.ToList();
31+
32+
diagnostics.Add($"Found {importFiles.Count} _Imports.razor file(s)");
33+
34+
var importSourceDocuments = importFiles
35+
.Select(import => RazorSourceDocument.Create(import.Content, import.Path))
36+
.ToImmutableArray();
37+
38+
foreach (var item in solution.Items)
39+
{
40+
if (item.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) &&
41+
!item.Path.EndsWith("_Imports.razor", StringComparison.OrdinalIgnoreCase))
42+
{
43+
diagnostics.Add($"Processing Razor file: {item.Path}");
44+
45+
var (generatedCode, genDiagnostics) = GenerateComponentCode(item.Path, item.Content, importSourceDocuments);
46+
diagnostics.AddRange(genDiagnostics);
47+
48+
if (!string.IsNullOrEmpty(generatedCode))
49+
{
50+
generatedCode = EnsureEssentialUsings(generatedCode);
51+
syntaxTrees.Add(CSharpSyntaxTree.ParseText(generatedCode, path: item.Path + ".g.cs"));
52+
diagnostics.Add($"Generated {generatedCode.Length} chars of C# code for {Path.GetFileName(item.Path)}");
53+
}
54+
else
55+
{
56+
diagnostics.Add($"Failed to generate code for {item.Path}");
57+
}
58+
}
59+
else if (item.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
60+
{
61+
syntaxTrees.Add(CSharpSyntaxTree.ParseText(item.Content, path: item.Path));
62+
}
63+
}
64+
65+
if (syntaxTrees.Count == 0)
66+
{
67+
stopwatch.Stop();
68+
diagnostics.Add("No compilable files found");
69+
return new CompilationReferenceResult(false, null, diagnostics, stopwatch.Elapsed);
70+
}
71+
72+
diagnostics.Add($"Compiling {syntaxTrees.Count} syntax trees with {references.Count()} references");
73+
74+
var compilation = CSharpCompilation.Create(
75+
solution.Name,
76+
syntaxTrees,
77+
references,
78+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, concurrentBuild: true)
79+
);
80+
81+
using var memoryStream = new MemoryStream();
82+
var emitResult = compilation.Emit(memoryStream);
83+
84+
stopwatch.Stop();
85+
86+
if (!emitResult.Success)
87+
{
88+
var emitDiagnostics = emitResult.Diagnostics
89+
.Where(d => d.Severity == DiagnosticSeverity.Error)
90+
.Select(d => $"{d.Location}: {d.GetMessage()}")
91+
.ToList();
92+
diagnostics.AddRange(emitDiagnostics);
93+
diagnostics.Add($"Compilation failed with {emitDiagnostics.Count} errors");
94+
return new CompilationReferenceResult(false, null, diagnostics, stopwatch.Elapsed);
95+
}
96+
97+
memoryStream.Seek(0, SeekOrigin.Begin);
98+
var assemblyBytes = memoryStream.ToArray();
99+
100+
diagnostics.Add($"Compilation successful, assembly size: {assemblyBytes.Length} bytes");
101+
102+
return new CompilationReferenceResult(true, assemblyBytes, diagnostics, stopwatch.Elapsed);
103+
}
104+
105+
/// <summary>
106+
/// Generates C# code from a Razor component file.
107+
/// </summary>
108+
private (string code, List<string> diagnostics) GenerateComponentCode(string filePath, string razorContent, ImmutableArray<RazorSourceDocument> importSources)
109+
{
110+
var diagnostics = new List<string>();
111+
112+
try
113+
{
114+
var fileSystem = new VirtualRazorProjectFileSystem();
115+
116+
var projectEngine = RazorProjectEngine.Create(
117+
RazorConfiguration.Default,
118+
fileSystem,
119+
builder =>
120+
{
121+
builder.SetRootNamespace("UserComponents");
122+
});
123+
124+
var sourceDocument = RazorSourceDocument.Create(razorContent, filePath);
125+
126+
var codeDocument = projectEngine.Process(
127+
sourceDocument,
128+
FileKinds.Component,
129+
importSources,
130+
tagHelpers: null);
131+
132+
var csharpDocument = codeDocument.GetCSharpDocument();
133+
134+
if (csharpDocument == null)
135+
{
136+
diagnostics.Add("Razor compiler returned null C# document, using fallback");
137+
return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics);
138+
}
139+
140+
foreach (var diag in csharpDocument.Diagnostics)
141+
{
142+
diagnostics.Add($"Razor: {diag.GetMessage()}");
143+
}
144+
145+
var generatedCode = csharpDocument.GeneratedCode;
146+
147+
if (string.IsNullOrEmpty(generatedCode))
148+
{
149+
diagnostics.Add("Razor compiler returned empty code, using fallback");
150+
return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics);
151+
}
152+
153+
diagnostics.Add("Razor compiler succeeded");
154+
return (generatedCode, diagnostics);
155+
}
156+
catch (Exception ex)
157+
{
158+
diagnostics.Add($"Razor compiler exception: {ex.Message}, using fallback");
159+
return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics);
160+
}
161+
}
162+
163+
/// <summary>
164+
/// Fallback simple code generation when Razor compiler fails.
165+
/// Generates a minimal but functional component.
166+
/// </summary>
167+
private string GenerateSimpleComponentCode(string filePath, string razorContent)
168+
{
169+
var componentName = Path.GetFileNameWithoutExtension(filePath);
170+
171+
var codeBlockMatch = Regex.Match(
172+
razorContent,
173+
@"@code\s*\{([\s\S]*)\}\s*$",
174+
RegexOptions.Multiline);
175+
176+
var codeBlock = codeBlockMatch.Success ? codeBlockMatch.Groups[1].Value.Trim() : "";
177+
178+
var usings = new List<string>();
179+
var usingMatches = Regex.Matches(razorContent, @"@using\s+([\w\.]+)");
180+
foreach (Match match in usingMatches)
181+
{
182+
usings.Add($"using {match.Groups[1].Value};");
183+
}
184+
185+
var usingsBlock = string.Join("\n", usings);
186+
187+
return $$"""
188+
// <auto-generated/>
189+
#pragma warning disable
190+
using System;
191+
using System.Collections.Generic;
192+
using System.Linq;
193+
using System.Threading.Tasks;
194+
using Microsoft.AspNetCore.Components;
195+
using Microsoft.AspNetCore.Components.Web;
196+
using Microsoft.AspNetCore.Components.Rendering;
197+
{{usingsBlock}}
198+
199+
namespace UserComponents
200+
{
201+
public partial class {{componentName}} : ComponentBase
202+
{
203+
protected override void BuildRenderTree(RenderTreeBuilder __builder)
204+
{
205+
__builder.OpenElement(0, "div");
206+
__builder.AddAttribute(1, "class", "component-preview");
207+
__builder.AddContent(2, "{{componentName}} Component");
208+
__builder.CloseElement();
209+
}
210+
211+
{{codeBlock}}
212+
}
213+
}
214+
#pragma warning restore
215+
""";
216+
}
217+
218+
/// <summary>
219+
/// Ensures essential using statements are present in the generated C# code.
220+
/// The Razor compiler may not emit all necessary usings for async/Task support.
221+
/// </summary>
222+
private static string EnsureEssentialUsings(string generatedCode)
223+
{
224+
const string taskUsing = "using System.Threading.Tasks;";
225+
226+
if (generatedCode.Contains(taskUsing))
227+
return generatedCode;
228+
229+
var insertIndex = 0;
230+
var pragmaIndex = generatedCode.IndexOf("#pragma warning disable", StringComparison.Ordinal);
231+
if (pragmaIndex >= 0)
232+
{
233+
var lineEnd = generatedCode.IndexOf('\n', pragmaIndex);
234+
if (lineEnd >= 0)
235+
insertIndex = lineEnd + 1;
236+
}
237+
238+
return generatedCode.Insert(insertIndex, taskUsing + "\n");
239+
}
240+
}
241+
242+
/// <summary>
243+
/// A virtual file system implementation for the Razor project engine.
244+
/// </summary>
245+
internal class VirtualRazorProjectFileSystem : RazorProjectFileSystem
246+
{
247+
public override IEnumerable<RazorProjectItem> EnumerateItems(string basePath)
248+
{
249+
return Enumerable.Empty<RazorProjectItem>();
250+
}
251+
252+
public override RazorProjectItem GetItem(string path)
253+
{
254+
return new NotFoundProjectItem(string.Empty, path, FileKinds.Component);
255+
}
256+
257+
public override RazorProjectItem GetItem(string path, string? fileKind)
258+
{
259+
return new NotFoundProjectItem(string.Empty, path, fileKind ?? FileKinds.Component);
260+
}
261+
}
262+
263+
/// <summary>
264+
/// Represents a project item that was not found.
265+
/// </summary>
266+
internal class NotFoundProjectItem : RazorProjectItem
267+
{
268+
public NotFoundProjectItem(string basePath, string path, string fileKind)
269+
{
270+
BasePath = basePath;
271+
FilePath = path;
272+
FileKind = fileKind;
273+
}
274+
275+
public override string BasePath { get; }
276+
public override string FilePath { get; }
277+
public override string FileKind { get; }
278+
public override bool Exists => false;
279+
public override string PhysicalPath => FilePath;
280+
281+
public override Stream Read()
282+
{
283+
throw new InvalidOperationException("Item does not exist");
284+
}
285+
}

Apollo.Components/Editor/TabViewState.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,17 +232,17 @@ private List<DynamicTabView> InitializeDefaultLayout()
232232
{
233233
AreaIdentifier = DropZones.Right,
234234
IsActive = false
235+
},
236+
new PreviewTab()
237+
{
238+
AreaIdentifier = DropZones.None,
239+
IsActive = false
235240
}
236241
];
237-
242+
238243
if(_environment.IsDevelopment())
239244
{
240245
defaultTabs.Add(new SystemLogViewer());
241-
defaultTabs.Add(new PreviewTab()
242-
{
243-
AreaIdentifier = DropZones.None,
244-
IsActive = false
245-
});
246246
}
247247

248248
return defaultTabs;

0 commit comments

Comments
 (0)