diff --git a/Apollo.Compilation.Worker/Program.cs b/Apollo.Compilation.Worker/Program.cs index abdf6e3..2a585ae 100644 --- a/Apollo.Compilation.Worker/Program.cs +++ b/Apollo.Compilation.Worker/Program.cs @@ -1,4 +1,4 @@ -using System.Buffers.Text; +using System.Buffers.Text; using System.Collections.Concurrent; using System.Reflection; using System.Runtime.Loader; @@ -64,9 +64,41 @@ await resolver.GetMetadataReferenceAsync("System.Console.wasm"), await resolver.GetMetadataReferenceAsync("xunit.assert.wasm"), await resolver.GetMetadataReferenceAsync("xunit.core.wasm") }; - + + var isRazorProject = solution.Type == ProjectType.RazorClassLibrary || + solution.Items.Any(i => i.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase)); + + if (isRazorProject) + { + try + { + references.Add(await resolver.GetMetadataReferenceAsync("System.Threading.Tasks.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("System.Collections.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("System.Linq.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("System.ObjectModel.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("System.ComponentModel.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("System.ComponentModel.Primitives.wasm")); + LogMessageWriter.Log("Added system references for Razor project", LogSeverity.Debug); + } + catch (Exception ex) + { + LogMessageWriter.Log($"Warning: Could not load system references: {ex.Message}", LogSeverity.Warning); + } + + try + { + references.Add(await resolver.GetMetadataReferenceAsync("Microsoft.AspNetCore.Components.wasm")); + references.Add(await resolver.GetMetadataReferenceAsync("Microsoft.AspNetCore.Components.Web.wasm")); + LogMessageWriter.Log("Added Blazor component references for Razor project", LogSeverity.Debug); + } + catch (Exception ex) + { + LogMessageWriter.Log($"Warning: Could not load Blazor references: {ex.Message}", LogSeverity.Warning); + } + } + nugetAssemblyCache = solution.NuGetReferences ?? []; - + foreach (var nugetRef in nugetAssemblyCache) { if (nugetRef.AssemblyData?.Length > 0) @@ -76,8 +108,10 @@ await resolver.GetMetadataReferenceAsync("xunit.core.wasm") LogMessageWriter.Log($"Added NuGet reference: {nugetRef.AssemblyName}", LogSeverity.Debug); } } - - var result = new CompilationService().Compile(solution, references); + + var result = isRazorProject + ? new RazorCompilationService().Compile(solution, references) + : new CompilationService().Compile(solution, references); asmCache = result.Assembly; diff --git a/Apollo.Compilation/Apollo.Compilation.csproj b/Apollo.Compilation/Apollo.Compilation.csproj index d85d291..81d3222 100644 --- a/Apollo.Compilation/Apollo.Compilation.csproj +++ b/Apollo.Compilation/Apollo.Compilation.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -13,6 +13,8 @@ + + diff --git a/Apollo.Compilation/RazorCompilationService.cs b/Apollo.Compilation/RazorCompilationService.cs new file mode 100644 index 0000000..ccff40d --- /dev/null +++ b/Apollo.Compilation/RazorCompilationService.cs @@ -0,0 +1,357 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using Apollo.Contracts.Compilation; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor; +using Solution = Apollo.Contracts.Solutions.Solution; +using SolutionItem = Apollo.Contracts.Solutions.SolutionItem; + +namespace Apollo.Compilation; + +/// +/// Compiles Razor component files (.razor) to assemblies. +/// +public class RazorCompilationService +{ + /// + /// Compiles a solution containing Razor files to an assembly. + /// + public CompilationReferenceResult Compile(Solution solution, IEnumerable references) + { + var stopwatch = Stopwatch.StartNew(); + var syntaxTrees = new List(); + var diagnostics = new List(); + + diagnostics.Add($"Starting Razor compilation for {solution.Name} with {solution.Items.Count} items"); + + var importFiles = solution.Items + .Where(item => item.Path.EndsWith("_Imports.razor", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + diagnostics.Add($"Found {importFiles.Count} _Imports.razor file(s)"); + + var importSourceDocuments = importFiles + .Select(import => RazorSourceDocument.Create(import.Content, import.Path)) + .ToImmutableArray(); + + foreach (var item in solution.Items) + { + if (item.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) && + !item.Path.EndsWith("_Imports.razor", StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add($"Processing Razor file: {item.Path}"); + + var (generatedCode, genDiagnostics) = GenerateComponentCode(item.Path, item.Content, importSourceDocuments, solution.Items); + diagnostics.AddRange(genDiagnostics); + + if (!string.IsNullOrEmpty(generatedCode)) + { + generatedCode = EnsureEssentialUsings(generatedCode); + syntaxTrees.Add(CSharpSyntaxTree.ParseText(generatedCode, path: item.Path + ".g.cs")); + diagnostics.Add($"Generated {generatedCode.Length} chars of C# code for {Path.GetFileName(item.Path)}"); + } + else + { + diagnostics.Add($"Failed to generate code for {item.Path}"); + } + } + else if (item.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + syntaxTrees.Add(CSharpSyntaxTree.ParseText(item.Content, path: item.Path)); + } + } + + if (syntaxTrees.Count == 0) + { + stopwatch.Stop(); + diagnostics.Add("No compilable files found"); + return new CompilationReferenceResult(false, null, diagnostics, stopwatch.Elapsed); + } + + diagnostics.Add($"Compiling {syntaxTrees.Count} syntax trees with {references.Count()} references"); + + var compilation = CSharpCompilation.Create( + solution.Name, + syntaxTrees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, concurrentBuild: true) + ); + + using var memoryStream = new MemoryStream(); + var emitResult = compilation.Emit(memoryStream); + + stopwatch.Stop(); + + if (!emitResult.Success) + { + var emitDiagnostics = emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Location}: {d.GetMessage()}") + .ToList(); + diagnostics.AddRange(emitDiagnostics); + diagnostics.Add($"Compilation failed with {emitDiagnostics.Count} errors"); + return new CompilationReferenceResult(false, null, diagnostics, stopwatch.Elapsed); + } + + memoryStream.Seek(0, SeekOrigin.Begin); + var assemblyBytes = memoryStream.ToArray(); + + diagnostics.Add($"Compilation successful, assembly size: {assemblyBytes.Length} bytes"); + + return new CompilationReferenceResult(true, assemblyBytes, diagnostics, stopwatch.Elapsed); + } + + /// + /// Generates C# code from a Razor component file. + /// + private (string code, List diagnostics) GenerateComponentCode( + string filePath, + string razorContent, + ImmutableArray importSources, + IEnumerable solutionItems) + { + var diagnostics = new List(); + + try + { + var fileSystem = new VirtualRazorProjectFileSystem(solutionItems); + + var projectEngine = RazorProjectEngine.Create( + RazorConfiguration.Default, + fileSystem, + builder => + { + builder.SetRootNamespace("UserComponents"); + + CompilerFeatures.Register(builder); + }); + + var fileName = Path.GetFileName(filePath); + var properties = new RazorSourceDocumentProperties(filePath, fileName); + var sourceDocument = RazorSourceDocument.Create(razorContent, Encoding.UTF8, properties); + + var codeDocument = projectEngine.Process( + sourceDocument, + FileKinds.Component, + importSources, + tagHelpers: null); + + var csharpDocument = codeDocument.GetCSharpDocument(); + + if (csharpDocument == null) + { + diagnostics.Add("Razor compiler returned null C# document, using fallback"); + return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics); + } + + foreach (var diag in csharpDocument.Diagnostics) + { + diagnostics.Add($"Razor: {diag.GetMessage()}"); + } + + var generatedCode = csharpDocument.GeneratedCode; + + if (string.IsNullOrEmpty(generatedCode)) + { + diagnostics.Add("Razor compiler returned empty code, using fallback"); + return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics); + } + + var classMatch = System.Text.RegularExpressions.Regex.Match(generatedCode, @"public partial class (\w+)"); + var className = classMatch.Success ? classMatch.Groups[1].Value : "unknown"; + diagnostics.Add($"Razor compiler succeeded, generated class: {className}"); + return (generatedCode, diagnostics); + } + catch (Exception ex) + { + diagnostics.Add($"Razor compiler exception: {ex.Message}, using fallback"); + return (GenerateSimpleComponentCode(filePath, razorContent), diagnostics); + } + } + + /// + /// Fallback simple code generation when Razor compiler fails. + /// Generates a minimal but functional component. + /// + private string GenerateSimpleComponentCode(string filePath, string razorContent) + { + var componentName = Path.GetFileNameWithoutExtension(filePath); + + var codeBlockMatch = Regex.Match( + razorContent, + @"@code\s*\{([\s\S]*)\}\s*$", + RegexOptions.Multiline); + + var codeBlock = codeBlockMatch.Success ? codeBlockMatch.Groups[1].Value.Trim() : ""; + + var usings = new List(); + var usingMatches = Regex.Matches(razorContent, @"@using\s+([\w\.]+)"); + foreach (Match match in usingMatches) + { + usings.Add($"using {match.Groups[1].Value};"); + } + + var usingsBlock = string.Join("\n", usings); + + return $$""" +// +#pragma warning disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Rendering; +{{usingsBlock}} + +namespace UserComponents +{ + public partial class {{componentName}} : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder __builder) + { + __builder.OpenElement(0, "div"); + __builder.AddAttribute(1, "class", "component-preview"); + __builder.AddContent(2, "{{componentName}} Component"); + __builder.CloseElement(); + } + +{{codeBlock}} + } +} +#pragma warning restore +"""; + } + + /// + /// Ensures essential using statements are present in the generated C# code. + /// The Razor compiler may not emit all necessary usings for async/Task support. + /// + private static string EnsureEssentialUsings(string generatedCode) + { + const string taskUsing = "using System.Threading.Tasks;"; + + if (generatedCode.Contains(taskUsing)) + return generatedCode; + + var insertIndex = 0; + var pragmaIndex = generatedCode.IndexOf("#pragma warning disable", StringComparison.Ordinal); + if (pragmaIndex >= 0) + { + var lineEnd = generatedCode.IndexOf('\n', pragmaIndex); + if (lineEnd >= 0) + insertIndex = lineEnd + 1; + } + + return generatedCode.Insert(insertIndex, taskUsing + "\n"); + } +} + +/// +/// A virtual file system implementation for the Razor project engine. +/// Provides in-memory file content to enable proper component name generation. +/// +internal class VirtualRazorProjectFileSystem : RazorProjectFileSystem +{ + private readonly Dictionary _files = new(); + + public VirtualRazorProjectFileSystem() + { + } + + public VirtualRazorProjectFileSystem(IEnumerable razorFiles) + { + foreach (var file in razorFiles.Where(f => f.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase))) + { + var normalizedPath = NormalizePath(file.Path); + _files[normalizedPath] = (file.Content, GetBasePath(file.Path)); + } + } + + private static string NormalizePath(string path) + { + return "/" + path.Replace("\\", "/").TrimStart('/'); + } + + private static string GetBasePath(string path) + { + var normalized = path.Replace("\\", "/"); + var lastSlash = normalized.LastIndexOf('/'); + return lastSlash > 0 ? "/" + normalized.Substring(0, lastSlash) : "/"; + } + + public override IEnumerable EnumerateItems(string basePath) + { + return _files + .Where(kvp => kvp.Value.basePath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) + .Select(kvp => new VirtualProjectItem(kvp.Value.basePath, kvp.Key, kvp.Value.content, FileKinds.Component)); + } + + public override RazorProjectItem GetItem(string path) + { + return GetItem(path, FileKinds.Component); + } + + public override RazorProjectItem GetItem(string path, string? fileKind) + { + var normalizedPath = NormalizePath(path); + if (_files.TryGetValue(normalizedPath, out var file)) + { + return new VirtualProjectItem(file.basePath, normalizedPath, file.content, fileKind ?? FileKinds.Component); + } + return new NotFoundProjectItem(string.Empty, path, fileKind ?? FileKinds.Component); + } +} + +/// +/// Represents a project item that was not found. +/// +internal class NotFoundProjectItem : RazorProjectItem +{ + public NotFoundProjectItem(string basePath, string path, string fileKind) + { + BasePath = basePath; + FilePath = path; + FileKind = fileKind; + } + + public override string BasePath { get; } + public override string FilePath { get; } + public override string FileKind { get; } + public override bool Exists => false; + public override string PhysicalPath => FilePath; + + public override Stream Read() + { + throw new InvalidOperationException("Item does not exist"); + } +} + +/// +/// Represents a virtual project item with in-memory content. +/// +internal class VirtualProjectItem : RazorProjectItem +{ + private readonly string _content; + + public VirtualProjectItem(string basePath, string filePath, string content, string fileKind) + { + BasePath = basePath; + FilePath = filePath; + _content = content; + FileKind = fileKind; + } + + public override string BasePath { get; } + public override string FilePath { get; } + public override string FileKind { get; } + public override bool Exists => true; + public override string PhysicalPath => FilePath; + + public override Stream Read() => new MemoryStream(Encoding.UTF8.GetBytes(_content)); +} diff --git a/Apollo.Components/Editor/TabViewState.cs b/Apollo.Components/Editor/TabViewState.cs index a6960ea..a035c67 100644 --- a/Apollo.Components/Editor/TabViewState.cs +++ b/Apollo.Components/Editor/TabViewState.cs @@ -232,17 +232,17 @@ private List InitializeDefaultLayout() { AreaIdentifier = DropZones.Right, IsActive = false + }, + new PreviewTab() + { + AreaIdentifier = DropZones.None, + IsActive = false } ]; - + if(_environment.IsDevelopment()) { defaultTabs.Add(new SystemLogViewer()); - defaultTabs.Add(new PreviewTab() - { - AreaIdentifier = DropZones.None, - IsActive = false - }); } return defaultTabs; diff --git a/Apollo.Components/Infrastructure/RegistrationExtensions.cs b/Apollo.Components/Infrastructure/RegistrationExtensions.cs index a3bfef9..3e77cc0 100644 --- a/Apollo.Components/Infrastructure/RegistrationExtensions.cs +++ b/Apollo.Components/Infrastructure/RegistrationExtensions.cs @@ -1,6 +1,7 @@ using System.Reflection; using Apollo.Components.Analysis; using Apollo.Components.Code; +using Apollo.Components.Preview; using Apollo.Components.Console; using Apollo.Components.Editor; using Apollo.Components.Hosting; @@ -58,6 +59,7 @@ public static IServiceCollection AddComponentsAndServices(this IServiceCollectio services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Apollo.Components/Library/LibraryState.cs b/Apollo.Components/Library/LibraryState.cs index 44e853c..672b5a5 100644 --- a/Apollo.Components/Library/LibraryState.cs +++ b/Apollo.Components/Library/LibraryState.cs @@ -18,6 +18,7 @@ public class LibraryState public List Projects { get; set; } = [ FullStackProject.Create(), + RazorComponentsProject.Create(), FizzBuzzProject.Create(), MinimalApiProject.Create(), UntitledProject.Create(), diff --git a/Apollo.Components/Library/SampleProjects/RazorComponentsProject.cs b/Apollo.Components/Library/SampleProjects/RazorComponentsProject.cs new file mode 100644 index 0000000..ec77fef --- /dev/null +++ b/Apollo.Components/Library/SampleProjects/RazorComponentsProject.cs @@ -0,0 +1,307 @@ +using Apollo.Components.Solutions; +using Apollo.Contracts.Solutions; + +namespace Apollo.Components.Library.SampleProjects; + +public static class RazorComponentsProject +{ + public static SolutionModel Create() + { + var solution = new SolutionModel + { + Name = "RazorComponentsDemo", + Description = "Razor class library with reusable UI and interactive components", + ProjectType = ProjectType.RazorClassLibrary, + Items = new List() + }; + + var rootFolder = new Folder + { + Name = "RazorComponentsDemo", + Uri = "virtual/RazorComponentsDemo", + Items = new List() + }; + solution.Items.Add(rootFolder); + + solution.Items.Add(new SolutionFile + { + Name = "_Imports.razor", + Uri = "virtual/RazorComponentsDemo/_Imports.razor", + Data = ImportsCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "Button.razor", + Uri = "virtual/RazorComponentsDemo/Button.razor", + Data = ButtonCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "Card.razor", + Uri = "virtual/RazorComponentsDemo/Card.razor", + Data = CardCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "Counter.razor", + Uri = "virtual/RazorComponentsDemo/Counter.razor", + Data = CounterCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "Toggle.razor", + Uri = "virtual/RazorComponentsDemo/Toggle.razor", + Data = ToggleCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "Alert.razor", + Uri = "virtual/RazorComponentsDemo/Alert.razor", + Data = AlertCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + return solution; + } + + private const string ImportsCode = """ +@using System.Threading.Tasks +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +"""; + + private const string ButtonCode = """ + + +@code { + [Parameter] public string Text { get; set; } = "Click me"; + [Parameter] public string Color { get; set; } = "primary"; + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + + private string ButtonClass => $"btn btn-{Color}"; + + private string ButtonStyle => Color switch + { + "primary" => "background: #6366f1; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 1rem;", + "success" => "background: #22c55e; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 1rem;", + "danger" => "background: #ef4444; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 1rem;", + "warning" => "background: #f59e0b; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 1rem;", + _ => "background: #6b7280; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 1rem;" + }; + + private async Task HandleClick() + { + if (!Disabled) + { + await OnClick.InvokeAsync(); + } + } +} +"""; + + private const string CardCode = """ +
+ @if (!string.IsNullOrEmpty(Title)) + { +
+

@Title

+
+ } +
+ @ChildContent +
+ @if (Footer != null) + { + + } +
+ +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public RenderFragment? Footer { get; set; } + [Parameter] public bool Elevated { get; set; } = true; + + private string CardStyle => Elevated + ? "background: #1e1e2e; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); overflow: hidden; color: #e0e0e0;" + : "background: #1e1e2e; border-radius: 12px; border: 1px solid #333; overflow: hidden; color: #e0e0e0;"; + + private string HeaderStyle => "padding: 1rem; border-bottom: 1px solid #333; background: rgba(255,255,255,0.02);"; + private string FooterStyle => "padding: 0.75rem 1rem; border-top: 1px solid #333; background: rgba(255,255,255,0.02);"; +} +"""; + + private const string CounterCode = """ +
+
+ @currentCount +
+
+ + + +
+ @if (currentCount != StartValue) + { +
+ Changed by @(currentCount - StartValue) +
+ } +
+ +@code { + [Parameter] public int StartValue { get; set; } = 0; + [Parameter] public int Step { get; set; } = 1; + [Parameter] public EventCallback OnCountChanged { get; set; } + + private int currentCount; + + protected override void OnInitialized() + { + currentCount = StartValue; + } + + private async Task Increment() + { + currentCount += Step; + await OnCountChanged.InvokeAsync(currentCount); + } + + private async Task Decrement() + { + currentCount -= Step; + await OnCountChanged.InvokeAsync(currentCount); + } + + private async Task Reset() + { + currentCount = StartValue; + await OnCountChanged.InvokeAsync(currentCount); + } + + private string ContainerStyle => "text-align: center; padding: 2rem; background: #1e1e2e; border-radius: 12px;"; + private string ButtonStyle => "width: 48px; height: 48px; font-size: 1.5rem; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer;"; +} +"""; + + private const string ToggleCode = """ + + +@code { + [Parameter] public bool Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public string? Label { get; set; } + [Parameter] public bool Disabled { get; set; } + + private async Task Toggle() + { + if (Disabled) return; + + Value = !Value; + await ValueChanged.InvokeAsync(Value); + } + + private string ContainerStyle => Disabled + ? "display: inline-block; opacity: 0.5; cursor: not-allowed;" + : "display: inline-block;"; + + private string TrackStyle => Value + ? "width: 48px; height: 26px; background: #6366f1; border-radius: 13px; position: relative; transition: background 0.2s;" + : "width: 48px; height: 26px; background: #4b5563; border-radius: 13px; position: relative; transition: background 0.2s;"; + + private string ThumbStyle => Value + ? "width: 22px; height: 22px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 24px; transition: left 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.2);" + : "width: 22px; height: 22px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: left 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"; +} +"""; + + private const string AlertCode = """ +@if (!_dismissed) +{ + +} + +@code { + [Parameter] public string Severity { get; set; } = "info"; + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public bool Dismissible { get; set; } = true; + [Parameter] public EventCallback OnDismissed { get; set; } + + private bool _dismissed; + + private async Task Dismiss() + { + _dismissed = true; + await OnDismissed.InvokeAsync(); + } + + private string Icon => Severity switch + { + "success" => "\u2714", + "warning" => "\u26A0", + "error" => "\u2716", + _ => "\u2139" + }; + + private string AlertStyle => Severity switch + { + "success" => "padding: 1rem; border-radius: 8px; background: rgba(34, 197, 94, 0.1); border: 1px solid #22c55e; color: #22c55e;", + "warning" => "padding: 1rem; border-radius: 8px; background: rgba(245, 158, 11, 0.1); border: 1px solid #f59e0b; color: #f59e0b;", + "error" => "padding: 1rem; border-radius: 8px; background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444;", + _ => "padding: 1rem; border-radius: 8px; background: rgba(99, 102, 241, 0.1); border: 1px solid #6366f1; color: #6366f1;" + }; + + private string CloseButtonStyle => "background: none; border: none; font-size: 1.5rem; cursor: pointer; color: inherit; padding: 0; line-height: 1;"; +} +"""; +} diff --git a/Apollo.Components/Preview/ComponentParameterEditor.razor b/Apollo.Components/Preview/ComponentParameterEditor.razor new file mode 100644 index 0000000..e41c712 --- /dev/null +++ b/Apollo.Components/Preview/ComponentParameterEditor.razor @@ -0,0 +1,144 @@ +@using System.Reflection + + + Component Parameters + + @if (_parameterInfos.Any()) + { + + @foreach (var param in _parameterInfos) + { +
+ @param.Name + @if (param.PropertyType == typeof(string)) + { + + } + else if (param.PropertyType == typeof(bool)) + { + + } + else if (param.PropertyType == typeof(int)) + { + + } + else if (param.PropertyType == typeof(double)) + { + + } + else if (param.PropertyType.IsEnum) + { + + @foreach (var enumValue in Enum.GetValues(param.PropertyType)) + { + @enumValue.ToString() + } + + } + else + { + + (@param.PropertyType.Name - not editable) + + } +
+ } +
+ } + else + { + + No editable parameters + + } +
+ +@code { + [Parameter] public Type? ComponentType { get; set; } + [Parameter] public Dictionary Parameters { get; set; } = new(); + [Parameter] public EventCallback> OnParametersChanged { get; set; } + + private List _parameterInfos = new(); + + protected override void OnParametersSet() + { + base.OnParametersSet(); + RefreshParameterInfos(); + } + + private void RefreshParameterInfos() + { + if (ComponentType == null) + { + _parameterInfos = new(); + return; + } + + _parameterInfos = ComponentType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .Where(p => IsEditableType(p.PropertyType)) + .Select(p => new ComponentParameterInfo + { + Name = p.Name, + PropertyType = p.PropertyType + }) + .ToList(); + } + + private bool IsEditableType(Type type) + { + return type == typeof(string) || + type == typeof(bool) || + type == typeof(int) || + type == typeof(double) || + type.IsEnum; + } + + private T GetParameterValue(string name) + { + if (Parameters.TryGetValue(name, out var value) && value is T typedValue) + { + return typedValue; + } + return default!; + } + + private async Task UpdateParameter(string name, object? value) + { + var newParams = new Dictionary(Parameters) + { + [name] = value + }; + Parameters = newParams; + await OnParametersChanged.InvokeAsync(newParams); + } + + private class ComponentParameterInfo + { + public string Name { get; set; } = string.Empty; + public Type PropertyType { get; set; } = typeof(object); + } +} diff --git a/Apollo.Components/Preview/ComponentTypeResolver.cs b/Apollo.Components/Preview/ComponentTypeResolver.cs new file mode 100644 index 0000000..97ae8f1 --- /dev/null +++ b/Apollo.Components/Preview/ComponentTypeResolver.cs @@ -0,0 +1,161 @@ +using System.Reflection; +using Apollo.Components.Analysis; +using Microsoft.AspNetCore.Components; + +namespace Apollo.Components.Preview; + +/// +/// Resolves component types from the user's compiled assembly. +/// +public class ComponentTypeResolver +{ + private readonly UserAssemblyStore _assemblyStore; + private Assembly? _loadedAssembly; + private byte[]? _lastAssemblyBytes; + + public ComponentTypeResolver(UserAssemblyStore assemblyStore) + { + _assemblyStore = assemblyStore; + } + + /// + /// Gets a component type by name from the compiled assembly. + /// + public Type? GetComponentType(string componentName) + { + ReloadAssemblyIfNeeded(); + + if (_loadedAssembly == null) + return null; + + return _loadedAssembly.GetTypes() + .FirstOrDefault(t => + t.Name.Equals(componentName, StringComparison.OrdinalIgnoreCase) && + typeof(IComponent).IsAssignableFrom(t) && + !t.IsAbstract); + } + + /// + /// Gets all available component types from the compiled assembly. + /// + public IEnumerable GetAllComponentTypes() + { + ReloadAssemblyIfNeeded(); + + if (_loadedAssembly == null) + return Enumerable.Empty(); + + return _loadedAssembly.GetTypes() + .Where(t => typeof(IComponent).IsAssignableFrom(t) && !t.IsAbstract && t.IsPublic) + .Select(t => new ComponentInfo + { + Name = t.Name, + FullName = t.FullName ?? t.Name, + Type = t, + Parameters = GetComponentParameters(t) + }) + .OrderBy(c => c.Name); + } + + /// + /// Gets the parameters for a component type. + /// + public IEnumerable GetComponentParameters(Type componentType) + { + return componentType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .Select(p => new ComponentParameterInfo + { + Name = p.Name, + PropertyType = p.PropertyType, + IsRequired = p.GetCustomAttribute() != null, + DefaultValue = GetDefaultValue(p.PropertyType) + }); + } + + /// + /// Gets default parameter values for a component type. + /// + public Dictionary GetDefaultParameters(Type? componentType) + { + if (componentType == null) + return new Dictionary(); + + var parameters = new Dictionary(); + + foreach (var prop in componentType.GetProperties()) + { + var paramAttr = prop.GetCustomAttribute(); + if (paramAttr != null) + { + parameters[prop.Name] = GetDefaultValue(prop.PropertyType); + } + } + + return parameters; + } + + private object? GetDefaultValue(Type type) + { + if (type == typeof(string)) + return string.Empty; + if (type == typeof(bool)) + return false; + if (type == typeof(int)) + return 0; + if (type == typeof(double)) + return 0.0; + if (type.IsValueType) + return Activator.CreateInstance(type); + return null; + } + + private void ReloadAssemblyIfNeeded() + { + var currentBytes = _assemblyStore.CurrentAssembly; + + if (currentBytes == null) + { + _loadedAssembly = null; + _lastAssemblyBytes = null; + return; + } + + // Only reload if the assembly has changed + if (_lastAssemblyBytes != null && currentBytes.SequenceEqual(_lastAssemblyBytes)) + return; + + try + { + _loadedAssembly = Assembly.Load(currentBytes); + _lastAssemblyBytes = currentBytes; + } + catch (Exception) + { + _loadedAssembly = null; + _lastAssemblyBytes = null; + } + } +} + +/// +/// Information about a discovered component. +/// +public class ComponentInfo +{ + public string Name { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public Type? Type { get; set; } + public IEnumerable Parameters { get; set; } = Enumerable.Empty(); +} + +/// +/// Information about a component parameter. +/// +public class ComponentParameterInfo +{ + public string Name { get; set; } = string.Empty; + public Type PropertyType { get; set; } = typeof(object); + public bool IsRequired { get; set; } + public object? DefaultValue { get; set; } +} diff --git a/Apollo.Components/Preview/PreviewTab.razor b/Apollo.Components/Preview/PreviewTab.razor index 68d6b21..5568752 100644 --- a/Apollo.Components/Preview/PreviewTab.razor +++ b/Apollo.Components/Preview/PreviewTab.razor @@ -1,46 +1,87 @@ @using Apollo.Components.DynamicTabs @using Apollo.Components.Solutions @using Apollo.Components.Infrastructure +@using Apollo.Components.Analysis @using Mythetech.Framework.Infrastructure.MessageBus @using Apollo.Components.Shared @using Microsoft.JSInterop @inherits DynamicTabView @implements IDisposable -
- - - @if (_availableComponents != null) - { - @foreach (var component in _availableComponents) +
+ +
+ + @if (_availableComponents != null) { - @component.Name + @foreach (var component in _availableComponents) + { + @component.Name + } } + + + + +
+ + @if (!_hasCompiledComponents) + { + + + Build your Razor project to preview components. + Click the Build button or press Ctrl+B. + + + } + +
+ @if (_selectedComponent?.Type != null) + { + + + + + + + Component Error + @ex.Message + + + } - - -
- @if (_selectedPreviewFile != null) + else if (_selectedPreviewFile != null && IsHtmlFile(_selectedPreviewFile)) { - @if (IsComponentFile(_selectedPreviewFile)) - { - - } - else if (IsHtmlFile(_selectedPreviewFile)) - { -
- } +
} else { - Select a component to preview +
+ + + Select a component to preview + +
}
+ + @if (_selectedComponent != null && _selectedComponent.Parameters.Any()) + { + + }
@@ -48,10 +89,16 @@ [Inject] public SolutionsState State { get; set; } = default!; [Inject] public IMessageBus Bus { get; set; } = default!; [Inject] public IJSRuntime JsRuntime { get; set; } = default!; + [Inject] public ComponentTypeResolver TypeResolver { get; set; } = default!; + [Inject] public UserAssemblyStore AssemblyStore { get; set; } = default!; private SolutionFile? _selectedPreviewFile; - private List? _availableComponents; + private ComponentInfo? _selectedComponent; + private List? _availableComponents; + private Dictionary _componentParameters = new(); private ElementReference _htmlPreviewContainer; + private ErrorBoundary? _errorBoundary; + private bool _hasCompiledComponents; public override string Name { get; set; } = "Preview"; public override Type ComponentType { get; set; } = typeof(PreviewTab); @@ -61,84 +108,129 @@ { base.OnInitialized(); State.ActiveFileChanged += HandleActiveFileChanged; - State.SolutionFilesChanged += RefreshAvailableComponents; + State.SolutionFilesChanged += HandleSolutionFilesChanged; + AssemblyStore.OnAssemblyUpdated += HandleAssemblyUpdated; + RefreshComponentList(); } public void Dispose() { State.ActiveFileChanged -= HandleActiveFileChanged; - State.SolutionFilesChanged -= RefreshAvailableComponents; + State.SolutionFilesChanged -= HandleSolutionFilesChanged; + AssemblyStore.OnAssemblyUpdated -= HandleAssemblyUpdated; + } + + private async Task HandleAssemblyUpdated(byte[] assembly) + { + await InvokeAsync(() => + { + RefreshComponentList(); + _errorBoundary?.Recover(); + StateHasChanged(); + }); } private async void HandleActiveFileChanged(SolutionFile? file) { - if (file != null && (IsComponentFile(file) || IsHtmlFile(file))) + if (file == null) return; + + if (IsComponentFile(file)) + { + _selectedPreviewFile = file; + var componentName = Path.GetFileNameWithoutExtension(file.Name); + var component = _availableComponents?.FirstOrDefault(c => + c.Name.Equals(componentName, StringComparison.OrdinalIgnoreCase)); + + if (component != null) + { + await HandleComponentSelected(component); + } + } + else if (IsHtmlFile(file)) { _selectedPreviewFile = file; + _selectedComponent = null; try { - await RefreshPreview(); + await RefreshHtmlPreview(); } - catch(Exception ex) + catch (Exception ex) { Console.WriteLine(ex.Message); } } } - private async Task HandlePreviewFileChange(SolutionFile? file) + private async Task HandleComponentSelected(ComponentInfo? component) { - _selectedPreviewFile = file; - await RefreshPreview(); + _selectedComponent = component; + _componentParameters = TypeResolver.GetDefaultParameters(component?.Type); + _errorBoundary?.Recover(); + await InvokeAsync(StateHasChanged); } - private void RefreshAvailableComponents() + private void HandleSolutionFilesChanged() { - // TODO: Implement component discovery from compiled solution - _availableComponents = State.Project?.Files - .Where(f => IsComponentFile(f) || IsHtmlFile(f)) - .ToList(); - StateHasChanged(); } - private async Task RefreshPreview() + private void RefreshComponentList() { - if (_selectedPreviewFile == null) return; + _availableComponents = TypeResolver.GetAllComponentTypes().ToList(); + _hasCompiledComponents = _availableComponents.Count > 0; - if (IsHtmlFile(_selectedPreviewFile)) + // If we had a selected component, try to reselect it + if (_selectedComponent != null) { - try + var refreshedComponent = _availableComponents + .FirstOrDefault(c => c.Name == _selectedComponent.Name); + if (refreshedComponent != null) { - await JsRuntime.InvokeVoidAsync("updateHtmlPreview", - _htmlPreviewContainer, - _selectedPreviewFile.Data); + _selectedComponent = refreshedComponent; + _componentParameters = TypeResolver.GetDefaultParameters(refreshedComponent.Type); } - catch (Exception ex) + else { - Console.WriteLine(ex.Message); + _selectedComponent = null; + _componentParameters = new(); } } - + + StateHasChanged(); + } + + private async Task RefreshHtmlPreview() + { + if (_selectedPreviewFile == null || !IsHtmlFile(_selectedPreviewFile)) return; + + try + { + await JsRuntime.InvokeVoidAsync("updateHtmlPreview", + _htmlPreviewContainer, + _selectedPreviewFile.Data); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private void HandleParametersChanged(Dictionary parameters) + { + _componentParameters = parameters; + _errorBoundary?.Recover(); StateHasChanged(); } private bool IsComponentFile(SolutionFile file) { - // TODO: Implement proper component file detection - return file.Extension.Equals(".razor", StringComparison.OrdinalIgnoreCase); + return file.Extension.Equals(".razor", StringComparison.OrdinalIgnoreCase) && + !file.Name.Equals("_Imports.razor", StringComparison.OrdinalIgnoreCase); } private bool IsHtmlFile(SolutionFile file) { - //TODO: Implement proper web file detection return file.Extension.Equals(".html", StringComparison.OrdinalIgnoreCase) || file.Extension.Equals(".htm", StringComparison.OrdinalIgnoreCase); } - - private Type? GetComponentType(SolutionFile file) - { - // TODO: Implement component type resolution from compiled solution - return null; - } -} \ No newline at end of file +} diff --git a/Apollo.Components/Solutions/SolutionsState.cs b/Apollo.Components/Solutions/SolutionsState.cs index 7c97d7c..bed1aad 100644 --- a/Apollo.Components/Solutions/SolutionsState.cs +++ b/Apollo.Components/Solutions/SolutionsState.cs @@ -34,11 +34,13 @@ public SolutionsState( var minimalApiProject = MinimalApiProject.Create(); var simpleLibraryProject = SimpleLibraryProject.Create(); var fullStackProject = FullStackProject.Create(); + var razorComponentsProject = RazorComponentsProject.Create(); Project = untitledProject; Solutions = [ untitledProject, + razorComponentsProject, fizzBuzzProject, minimalApiProject, simpleLibraryProject, diff --git a/Directory.Packages.props b/Directory.Packages.props index 6afd2e7..ba590f7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ +