diff --git a/README.md b/README.md index 05f94a2..55f180f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ of your codebase. - [Removing Icons](#removing-icons) - [Listing Icons](#listing-icons) - [Managing Aliases](#managing-aliases) + - [Standalone Components](#standalone-components) - [Using Icons in Components](#using-icons-in-components) - [Restoring Icons](#restoring-icons) - [Cleaning the Cache](#cleaning-the-cache) @@ -193,6 +194,64 @@ Or assign an alias when first adding an icon: typedicons add lucide:house --alias House ``` +### Standalone Components + +Standalone components let you use icons directly as named Blazor components, without +the `` wrapper: + +```razor + @* by set + name *@ + @* via alias *@ +``` + +> ⚠️ **Required setup:** Due to Blazor's source generator pipeline, standalone components +> require the following property in your `.csproj`: +> ```xml +> +> false +> +> ``` +> Without this, standalone components will not be rendered at runtime. + +Add a standalone component to an existing icon: + +```bash +typedicons standalone add mdi:home +``` + +Remove a standalone component from an icon: + +```bash +typedicons standalone remove mdi:home +``` + +List all icons with standalone components: + +```bash +typedicons standalone list +``` + +Or generate a standalone component when first adding an icon: + +```bash +typedicons add mdi:home --standalone +``` + +Combine with an alias for the cleanest usage: + +```bash +typedicons add mdi:home --alias Home --standalone +``` + +This gives you all four ways to use the icon: + +```razor + @* full path *@ + @* alias *@ + @* standalone *@ + @* standalone + alias *@ +``` + ### Using Icons in Components Once icons are added, use them via the `` component anywhere in your Blazor markup: @@ -261,6 +320,7 @@ COMMANDS: init Initialize TypedIcons in the current project add Add an icon by name (:) --alias Assign a shorthand alias to the icon + --standalone Generate a standalone component for the icon remove Remove an icon by name (:) list [search] List all icons in the current project --set Filter by icon set @@ -271,6 +331,10 @@ COMMANDS: add Add an alias for an icon (:) remove Remove an alias by icon name (:) or alias name list [search] List all icons with aliases + standalone Manage standalone icon components + add Add a standalone component for an icon (:) + remove Remove a standalone component from an icon (:) + list [search] List all standalone icon components ``` --- diff --git a/samples/TypedIcons.Sandbox/Components/Components/TestIcon.cs b/samples/TypedIcons.Sandbox/Components/Components/TestIcon.cs index 6622d24..56197e5 100644 --- a/samples/TypedIcons.Sandbox/Components/Components/TestIcon.cs +++ b/samples/TypedIcons.Sandbox/Components/Components/TestIcon.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; namespace TypedIcons.Sandbox.Components.Components; @@ -11,49 +10,5 @@ public partial class TestIcon : TestIconBase /// The icon to render. Required. [Parameter, EditorRequired] public TestIconDefinition Source { get; set; } - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - if (Source.IsEmpty) - return; - - var (width, height) = CalculateDimensions(Source); - var isDecorative = Title is null; - - builder.OpenElement(0, "svg"); - builder.AddAttribute(10, "xmlns", "http://www.w3.org/2000/svg"); - builder.AddAttribute(20, "viewBox", Source.ViewBox); - builder.AddAttribute(30, "width", width); - builder.AddAttribute(40, "height", height); - builder.AddAttribute(50, "class", Class); - builder.AddAttribute(60, "style", Style); - - if (isDecorative) - { - builder.AddAttribute(70, "aria-hidden", "true"); - builder.AddAttribute(71, "focusable", "false"); - } - else - { - builder.AddAttribute(70, "role", "img"); - } - - builder.AddMultipleAttributes(80, AdditionalAttributes); - - if (!isDecorative) - { - builder.OpenElement(90, "title"); - builder.AddContent(91, Title); - builder.CloseElement(); - } - - builder.AddMarkupContent(100, Source.SvgContent); - builder.AddContent(110, ChildContent); - builder.CloseElement(); - } - - private (string width, string height) CalculateDimensions(TestIconDefinition iconData) => - Size is not null ? (Size, Size) : - Width is not null && Height is not null ? (Width, Height) : - Width is not null ? (Width, Width) : - Height is not null ? (Height, Height) : (iconData.Width, iconData.Height); + protected override TestIconDefinition IconSource => Source; } \ No newline at end of file diff --git a/samples/TypedIcons.Sandbox/Components/Components/TestIconBase.cs b/samples/TypedIcons.Sandbox/Components/Components/TestIconBase.cs index 9ea89a2..f32c9a3 100644 --- a/samples/TypedIcons.Sandbox/Components/Components/TestIconBase.cs +++ b/samples/TypedIcons.Sandbox/Components/Components/TestIconBase.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; namespace TypedIcons.Sandbox.Components.Components; @@ -50,4 +51,53 @@ public abstract class TestIconBase : ComponentBase /// Additional attributes passed through to the root <svg> element. [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + /// The icon definition to render. Provided by subclasses. + protected abstract TestIconDefinition IconSource { get; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (IconSource.IsEmpty) + return; + + var (width, height) = CalculateDimensions(IconSource); + var isDecorative = Title is null; + + builder.OpenElement(0, "svg"); + builder.AddAttribute(10, "xmlns", "http://www.w3.org/2000/svg"); + builder.AddAttribute(20, "viewBox", IconSource.ViewBox); + builder.AddAttribute(30, "width", width); + builder.AddAttribute(40, "height", height); + builder.AddAttribute(50, "class", Class); + builder.AddAttribute(60, "style", Style); + + if (isDecorative) + { + builder.AddAttribute(70, "aria-hidden", "true"); + builder.AddAttribute(71, "focusable", "false"); + } + else + { + builder.AddAttribute(70, "role", "img"); + } + + builder.AddMultipleAttributes(80, AdditionalAttributes); + + if (!isDecorative) + { + builder.OpenElement(90, "title"); + builder.AddContent(91, Title); + builder.CloseElement(); + } + + builder.AddMarkupContent(100, IconSource.SvgContent); + builder.AddContent(110, ChildContent); + builder.CloseElement(); + } + + private (string width, string height) CalculateDimensions(TestIconDefinition iconData) => + Size is not null ? (Size, Size) : + Width is not null && Height is not null ? (Width, Height) : + Width is not null ? (Width, Width) : + Height is not null ? (Height, Height) : (iconData.Width, iconData.Height); } \ No newline at end of file diff --git a/samples/TypedIcons.Sandbox/Components/Components/TestServerIcon.cs b/samples/TypedIcons.Sandbox/Components/Components/TestServerIcon.cs deleted file mode 100644 index 21ae47b..0000000 --- a/samples/TypedIcons.Sandbox/Components/Components/TestServerIcon.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Components.Rendering; - -namespace TypedIcons.Sandbox.Components.Components; - -/// -/// Renders the Server icon from Heroicons. See for full documentation. -/// -public class TestServerIcon : TestIconBase -{ - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - builder.OpenComponent(0); - builder.AddComponentParameter(10, nameof(TestIcon.Source), TestIcons.Heroicons.Server); - builder.AddComponentParameter(20, nameof(Size), Size); - builder.AddComponentParameter(30, nameof(Width), Width); - builder.AddComponentParameter(40, nameof(Height), Height); - builder.AddComponentParameter(50, nameof(Class), Class); - builder.AddComponentParameter(60, nameof(Style), Style); - builder.AddComponentParameter(70, nameof(Title), Title); - builder.AddComponentParameter(80, nameof(ChildContent), ChildContent); - builder.AddMultipleAttributes(90, AdditionalAttributes); - builder.CloseComponent(); - } -} \ No newline at end of file diff --git a/samples/TypedIcons.Sandbox/Components/Components/TestStandalone.cs b/samples/TypedIcons.Sandbox/Components/Components/TestStandalone.cs new file mode 100644 index 0000000..f63ce24 --- /dev/null +++ b/samples/TypedIcons.Sandbox/Components/Components/TestStandalone.cs @@ -0,0 +1,17 @@ +namespace TypedIcons.Sandbox.Components.Components; + +/// +/// Renders the Server icon from Heroicons. See for full documentation. +/// +public partial class TestHeroiconsServer : TestIconBase +{ + protected override TestIconDefinition IconSource => TestIcons.Heroicons.Server; +} + +/// +/// Renders the Server icon from Heroicons. See for full documentation. +/// +public partial class TestServer : TestIconBase +{ + protected override TestIconDefinition IconSource => TestIcons.Heroicons.Server; +} \ No newline at end of file diff --git a/samples/TypedIcons.Sandbox/Components/Pages/Home.razor b/samples/TypedIcons.Sandbox/Components/Pages/Home.razor index 53416d0..3715cac 100644 --- a/samples/TypedIcons.Sandbox/Components/Pages/Home.razor +++ b/samples/TypedIcons.Sandbox/Components/Pages/Home.razor @@ -3,12 +3,22 @@ Home - +@* Test icons from sandbox *@ + - + - + - + - +
+ +@* Icons from generation *@ + + + + + + + diff --git a/samples/TypedIcons.Sandbox/TypedIcons.Sandbox.csproj b/samples/TypedIcons.Sandbox/TypedIcons.Sandbox.csproj index 539a7a9..dd430a5 100644 --- a/samples/TypedIcons.Sandbox/TypedIcons.Sandbox.csproj +++ b/samples/TypedIcons.Sandbox/TypedIcons.Sandbox.csproj @@ -5,6 +5,9 @@ enable enable true + + + false diff --git a/samples/TypedIcons.Sandbox/typedicons.json b/samples/TypedIcons.Sandbox/typedicons.json index 3a836be..c6f9f1d 100644 --- a/samples/TypedIcons.Sandbox/typedicons.json +++ b/samples/TypedIcons.Sandbox/typedicons.json @@ -3,15 +3,19 @@ { "set": "mdi", "name": "home", - "alias": "House" + "alias": "House", + "standalone": true }, { "set": "mdi", - "name": "account" + "name": "account", + "alias": "Account", + "standalone": true }, { "set": "mdi", - "name": "settings" + "name": "settings", + "standalone": true }, { "set": "mdi", diff --git a/src/TypedIcons.Cli/Commands/AddIconCommand.cs b/src/TypedIcons.Cli/Commands/Icon/AddIconCommand.cs similarity index 78% rename from src/TypedIcons.Cli/Commands/AddIconCommand.cs rename to src/TypedIcons.Cli/Commands/Icon/AddIconCommand.cs index b98719a..3a3d5ca 100644 --- a/src/TypedIcons.Cli/Commands/AddIconCommand.cs +++ b/src/TypedIcons.Cli/Commands/Icon/AddIconCommand.cs @@ -3,7 +3,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Icon; public class AddIconCommand(IconService iconService) : AsyncCommand { @@ -16,6 +16,11 @@ public class Settings : GlobalSettings [CommandOption("--alias")] [Description("Assign an alias to the icon")] public string? Alias { get; init; } + + [CommandOption("--standalone")] + [Description("Add standalone component")] + [DefaultValue(false)] + public bool? Standalone { get; init; } } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken ct) @@ -26,7 +31,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return -1; } - var result = await iconService.AddIconAsync(settings.Name, settings.Alias, ct); + var result = await iconService.AddIconAsync(settings.Name, settings.Alias, settings.Standalone, ct); return result ? 0 : -1; } } \ No newline at end of file diff --git a/src/TypedIcons.Cli/Commands/ListIconsCommand.cs b/src/TypedIcons.Cli/Commands/Icon/ListIconsCommand.cs similarity index 95% rename from src/TypedIcons.Cli/Commands/ListIconsCommand.cs rename to src/TypedIcons.Cli/Commands/Icon/ListIconsCommand.cs index c12bd10..d8b7577 100644 --- a/src/TypedIcons.Cli/Commands/ListIconsCommand.cs +++ b/src/TypedIcons.Cli/Commands/Icon/ListIconsCommand.cs @@ -2,7 +2,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Icon; public class ListIconsCommand(IconService iconService) : AsyncCommand { diff --git a/src/TypedIcons.Cli/Commands/RemoveIconCommand.cs b/src/TypedIcons.Cli/Commands/Icon/RemoveIconCommand.cs similarity index 95% rename from src/TypedIcons.Cli/Commands/RemoveIconCommand.cs rename to src/TypedIcons.Cli/Commands/Icon/RemoveIconCommand.cs index a657ae3..3ec8669 100644 --- a/src/TypedIcons.Cli/Commands/RemoveIconCommand.cs +++ b/src/TypedIcons.Cli/Commands/Icon/RemoveIconCommand.cs @@ -3,7 +3,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Icon; public class RemoveIconCommand(IconService iconService) : AsyncCommand { diff --git a/src/TypedIcons.Cli/Commands/CleanCacheCommand.cs b/src/TypedIcons.Cli/Commands/Project/CleanCacheCommand.cs similarity index 91% rename from src/TypedIcons.Cli/Commands/CleanCacheCommand.cs rename to src/TypedIcons.Cli/Commands/Project/CleanCacheCommand.cs index 75e72d6..40fdabd 100644 --- a/src/TypedIcons.Cli/Commands/CleanCacheCommand.cs +++ b/src/TypedIcons.Cli/Commands/Project/CleanCacheCommand.cs @@ -1,7 +1,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Project; public class CleanCacheCommand(IconService iconService) : AsyncCommand { diff --git a/src/TypedIcons.Cli/Commands/InitProjectCommand.cs b/src/TypedIcons.Cli/Commands/Project/InitProjectCommand.cs similarity index 94% rename from src/TypedIcons.Cli/Commands/InitProjectCommand.cs rename to src/TypedIcons.Cli/Commands/Project/InitProjectCommand.cs index 93ee34f..51e7c1c 100644 --- a/src/TypedIcons.Cli/Commands/InitProjectCommand.cs +++ b/src/TypedIcons.Cli/Commands/Project/InitProjectCommand.cs @@ -2,7 +2,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Project; public class InitProjectCommand(InitializationService initializationService) : AsyncCommand { diff --git a/src/TypedIcons.Cli/Commands/RestoreIconsCommand.cs b/src/TypedIcons.Cli/Commands/Project/RestoreIconsCommand.cs similarity index 94% rename from src/TypedIcons.Cli/Commands/RestoreIconsCommand.cs rename to src/TypedIcons.Cli/Commands/Project/RestoreIconsCommand.cs index 7679037..490bc72 100644 --- a/src/TypedIcons.Cli/Commands/RestoreIconsCommand.cs +++ b/src/TypedIcons.Cli/Commands/Project/RestoreIconsCommand.cs @@ -2,7 +2,7 @@ using Spectre.Console.Cli; using TypedIcons.Cli.Services; -namespace TypedIcons.Cli.Commands; +namespace TypedIcons.Cli.Commands.Project; public class RestoreIconsCommand(IconService iconService) : AsyncCommand { diff --git a/src/TypedIcons.Cli/Commands/Standalone/AddStandaloneCommand.cs b/src/TypedIcons.Cli/Commands/Standalone/AddStandaloneCommand.cs new file mode 100644 index 0000000..f741c79 --- /dev/null +++ b/src/TypedIcons.Cli/Commands/Standalone/AddStandaloneCommand.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using TypedIcons.Cli.Services; + +namespace TypedIcons.Cli.Commands.Standalone; + +public class AddStandaloneCommand(IconService iconService) : AsyncCommand +{ + public class Settings : GlobalSettings + { + [CommandArgument(0, "")] + [Description("The name of the icon (:)")] + public string IconifyName { get; init; } = string.Empty; + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken ct) + { + if (string.IsNullOrEmpty(settings.IconifyName)) + { + AnsiConsole.MarkupLine("[red]Icon name is required (:)[/]"); + return -1; + } + + var result = await iconService.AddStandaloneAsync(settings.IconifyName, ct); + return result ? 0 : -1; + } +} \ No newline at end of file diff --git a/src/TypedIcons.Cli/Commands/Standalone/ListStandaloneCommand.cs b/src/TypedIcons.Cli/Commands/Standalone/ListStandaloneCommand.cs new file mode 100644 index 0000000..70aacea --- /dev/null +++ b/src/TypedIcons.Cli/Commands/Standalone/ListStandaloneCommand.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using Spectre.Console.Cli; +using TypedIcons.Cli.Services; + +namespace TypedIcons.Cli.Commands.Standalone; + +public class ListStandaloneCommand(IconService iconService) : AsyncCommand +{ + public class Settings : GlobalSettings + { + [CommandArgument(0, "[search]")] + [Description("Search filter")] + public string Search { get; init; } = string.Empty; + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken ct) + { + var result = await iconService.ListStandaloneComponentsAsync(settings.Search, ct); + return result ? 0 : -1; + } +} \ No newline at end of file diff --git a/src/TypedIcons.Cli/Commands/Standalone/RemoveStandaloneCommand.cs b/src/TypedIcons.Cli/Commands/Standalone/RemoveStandaloneCommand.cs new file mode 100644 index 0000000..a9d3cee --- /dev/null +++ b/src/TypedIcons.Cli/Commands/Standalone/RemoveStandaloneCommand.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using Spectre.Console.Cli; +using TypedIcons.Cli.Services; + +namespace TypedIcons.Cli.Commands.Standalone; + +public class RemoveStandaloneCommand(IconService iconService) : AsyncCommand +{ + public class Settings : GlobalSettings + { + [CommandArgument(0, "")] + [Description("The icon name (:) or its alias")] + public string Icon { get; init; } = string.Empty; + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken ct) + { + var result = await iconService.RemoveStandaloneAsync(settings.Icon, ct); + return result ? 0 : -1; + } +} \ No newline at end of file diff --git a/src/TypedIcons.Cli/Defaults.cs b/src/TypedIcons.Cli/Defaults.cs index 211a43e..35f619d 100644 --- a/src/TypedIcons.Cli/Defaults.cs +++ b/src/TypedIcons.Cli/Defaults.cs @@ -2,5 +2,5 @@ namespace TypedIcons.Cli; public static class Defaults { - public const string Version = "0.4.0"; + public const string Version = "0.5.0"; } \ No newline at end of file diff --git a/src/TypedIcons.Cli/Program.cs b/src/TypedIcons.Cli/Program.cs index 9bd30c8..d380a62 100644 --- a/src/TypedIcons.Cli/Program.cs +++ b/src/TypedIcons.Cli/Program.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using Spectre.Console.Cli; using TypedIcons.Cli; -using TypedIcons.Cli.Commands; using TypedIcons.Cli.Commands.Alias; +using TypedIcons.Cli.Commands.Icon; +using TypedIcons.Cli.Commands.Project; +using TypedIcons.Cli.Commands.Standalone; using TypedIcons.Cli.Infrastructure; using TypedIcons.Cli.Services; @@ -56,6 +58,20 @@ alias.AddCommand("list") .WithDescription("List icon aliases"); }); + + config.AddBranch("standalone", standalone => + { + standalone.SetDescription("Manage standalone icon components"); + + standalone.AddCommand("add") + .WithDescription("Add standalone icon component for an icon (:)"); + + standalone.AddCommand("remove") + .WithDescription("Remove standalone component for an icon (:) or standalone component name"); + + standalone.AddCommand("list") + .WithDescription("List all standalone icon components"); + }); }); return await app.RunAsync(args); \ No newline at end of file diff --git a/src/TypedIcons.Cli/Services/IconService.cs b/src/TypedIcons.Cli/Services/IconService.cs index 4967143..4a15948 100644 --- a/src/TypedIcons.Cli/Services/IconService.cs +++ b/src/TypedIcons.Cli/Services/IconService.cs @@ -42,7 +42,7 @@ private async Task EnsureConfigExistsAsync(CancellationToken ct) return initResult && configService.ConfigExists; } - public async Task AddIconAsync(string iconifyName, string? alias, CancellationToken ct) + public async Task AddIconAsync(string iconifyName, string? alias, bool? standalone, CancellationToken ct) { if (!IconReference.TryParse(iconifyName, out var iconReference)) { @@ -80,7 +80,13 @@ public async Task AddIconAsync(string iconifyName, string? alias, Cancella AnsiConsole.MarkupLine($"[green]Icon: '{iconifyName}' added successfully[/]"); if (alias is not null) - return await AddAliasAsync(iconifyName, alias, ct); + { + if (!(await AddAliasAsync(iconifyName, alias, ct))) + return false; + } + + if (standalone == true) + return await AddStandaloneAsync(iconifyName, ct); return true; } @@ -268,7 +274,7 @@ public async Task RemoveAliasAsync(string icon, CancellationToken ct) AnsiConsole.MarkupLine($"[red]Icon: '{icon}' doesn't exist[/]"); return false; } - + var iconReference = config.FindIcon(iconReferenceSearch)!; var oldAlias = iconReference.Alias; @@ -277,9 +283,9 @@ public async Task RemoveAliasAsync(string icon, CancellationToken ct) AnsiConsole.MarkupLine($"[yellow]Icon: '{icon}' doesn't have an alias[/]"); return false; } - + iconReference.Alias = null; - + await configService.SaveConfigAsync(config, ct); AnsiConsole.MarkupLine($"[green]Icon alias: '{oldAlias}' removed successfully[/]"); @@ -295,30 +301,154 @@ public async Task ListAliasesAsync(string search, CancellationToken ct) var icons = config.Icons .Where(x => x.Alias is not null); - + if (!string.IsNullOrEmpty(search)) icons = icons.Where(i => i.Alias?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false); - + var iconList = icons.ToList(); - if (config.Icons.Count == 0) + if (iconList.Count == 0) { AnsiConsole.MarkupLine($"[gray]No aliases found.[/]"); return true; } - + var filterLabel = new StringBuilder(); if (!string.IsNullOrEmpty(search)) filterLabel.Append($" — search: '{search}'"); - + AnsiConsole.MarkupLine($"[gray]Aliases ({iconList.Count}){filterLabel}[/]"); AnsiConsole.WriteLine(); + + foreach (var icon in iconList) + { + var sb = new StringBuilder(); + sb.Append($" [yellow]{icon.Alias}[/] [gray]→[/] {icon}"); + + AnsiConsole.MarkupLine(sb.ToString()); + } + + return true; + } + + public async Task AddStandaloneAsync(string iconifyName, CancellationToken ct) + { + if (!IconReference.TryParse(iconifyName, out var iconReferenceSearch)) + { + AnsiConsole.MarkupLine($"[red]{iconifyName} is not a valid icon name[/]"); + return false; + } + + var (configSuccess, config) = await GetValidatedConfigAsync(ct); + if (!configSuccess || config is null) + return false; + + var iconReference = config.FindIcon(iconReferenceSearch); + if (iconReference is null) + { + AnsiConsole.MarkupLine($"[red]Icon: '{iconifyName}' doesn't exist[/]"); + return false; + } + + if (iconReference.Standalone == true) + { + AnsiConsole.MarkupLine( + $"[red]Icon: '{iconifyName}' is already a standalone component: '{iconReference.StandaloneComponentName()}'[/]"); + return false; + } + + iconReference.Standalone = true; + await configService.SaveConfigAsync(config, ct); + + AnsiConsole.MarkupLine( + $"[green]Standalone component: '{iconReference.StandaloneComponentName()}' added successfully for icon '{iconifyName}'[/]"); + + if (iconReference.Alias is not null) + { + AnsiConsole.MarkupLine( + $"[green]Standalone component: '{iconReference.StandaloneComponentAlias()}' added successfully for icon '{iconifyName}'[/]"); + } + + return true; + } + + public async Task RemoveStandaloneAsync(string icon, CancellationToken ct) + { + var (configSuccess, config) = await GetValidatedConfigAsync(ct); + if (!configSuccess || config is null) + return false; + + if (!IconReference.TryParse(icon, out var iconReferenceSearch)) + { + iconReferenceSearch = config.FindIconByAlias(icon) ?? config.FindIconByStandaloneName(icon); + } + + if (iconReferenceSearch is null || !config.ContainsIcon(iconReferenceSearch)) + { + AnsiConsole.MarkupLine($"[red]Icon: '{icon}' doesn't exist[/]"); + return false; + } + + var iconReference = config.FindIcon(iconReferenceSearch)!; + iconReference.Standalone = null; + await configService.SaveConfigAsync(config, ct); + + AnsiConsole.MarkupLine($"[green]Removed standalone component for icon '{iconReference}'[/]"); + + return true; + } + + public async Task ListStandaloneComponentsAsync(string search, CancellationToken ct) + { + var (configSuccess, config) = await GetValidatedConfigAsync(ct); + if (!configSuccess || config is null) + return false; + + var icons = config.Icons + .Where(x => x.Standalone is not null); + + if (!string.IsNullOrEmpty(search)) + { + icons = icons.Where(i => + (i.Alias?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false) || + i.StandaloneComponentName().Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + var iconList = icons.ToList(); + + if (iconList.Count == 0) + { + AnsiConsole.MarkupLine($"[gray]No standalone components found.[/]"); + return true; + } + + var filterLabel = new StringBuilder(); + if (!string.IsNullOrEmpty(search)) + filterLabel.Append($" — search: '{search}'"); + + AnsiConsole.MarkupLine($"[gray]Standalone components ({iconList.Count}){filterLabel}[/]"); + AnsiConsole.WriteLine(); + + var withAlias = iconList.Where(x => x.Alias is not null).ToList(); + if (withAlias.Count > 0) + { + AnsiConsole.MarkupLine("[gray]With alias:[/]"); + } + foreach (var icon in withAlias) + { + var sb = new StringBuilder(); + sb.Append($" [yellow]{icon.StandaloneComponentAlias()}[/] [gray]→[/] {icon}"); + + AnsiConsole.MarkupLine(sb.ToString()); + } + + AnsiConsole.MarkupLine("\n[gray]Without alias:[/]"); foreach (var icon in iconList) { var sb = new StringBuilder(); - sb.Append($" [yellow]{icon.Alias}[/] [gray]→[/] {icon.Set}:{icon.Name}"); - + sb.Append($" [yellow]{icon.StandaloneComponentName()}[/] [gray]→[/] {icon}"); + AnsiConsole.MarkupLine(sb.ToString()); } diff --git a/src/TypedIcons.Cli/TypedIcons.Cli.csproj b/src/TypedIcons.Cli/TypedIcons.Cli.csproj index a04bf8d..31bd04e 100644 --- a/src/TypedIcons.Cli/TypedIcons.Cli.csproj +++ b/src/TypedIcons.Cli/TypedIcons.Cli.csproj @@ -8,7 +8,7 @@ enable TypedIcons - 0.4.0 + 0.5.0 TypedIcons CLI .NET CLI tool for adding and managing type-safe Iconify icons for Blazor projects. Works with the TypedIcons source generator to provide IntelliSense and compile-time safety. diff --git a/src/TypedIcons.Core/Models/IconConfig.cs b/src/TypedIcons.Core/Models/IconConfig.cs index 2df6248..42a36eb 100644 --- a/src/TypedIcons.Core/Models/IconConfig.cs +++ b/src/TypedIcons.Core/Models/IconConfig.cs @@ -18,7 +18,11 @@ public bool IsAliasAvailable(string alias) => !Icons.Any(x => string.Equals(x.Alias, alias, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Set, alias, StringComparison.OrdinalIgnoreCase)); - + public IconReference? FindIconByAlias(string alias) => Icons.FirstOrDefault(x => x.Alias == alias); + + public IconReference? FindIconByStandaloneName(string standaloneName) => + Icons.FirstOrDefault(x => + string.Equals(x.StandaloneComponentName(), standaloneName, StringComparison.OrdinalIgnoreCase)); } \ No newline at end of file diff --git a/src/TypedIcons.Core/Models/IconReference.cs b/src/TypedIcons.Core/Models/IconReference.cs index 5b593c4..d376646 100644 --- a/src/TypedIcons.Core/Models/IconReference.cs +++ b/src/TypedIcons.Core/Models/IconReference.cs @@ -1,17 +1,27 @@ +using TypedIcons.Core.Extensions; + namespace TypedIcons.Core.Models; -public class IconReference(string set, string name, string? alias = null) +public class IconReference(string set, string name, string? alias = null, bool? standalone = null) { - public IconReference() : this(string.Empty, string.Empty) { } - + public IconReference() : this(string.Empty, string.Empty) + { + } + public string Set { get; set; } = set; public string Name { get; set; } = name; public string? Alias { get; set; } = alias; - + + public bool? Standalone { get; set; } = standalone; + + public string StandaloneComponentName() => $"{Set.ToPascalCase()}{Name.ToPascalCase()}"; + + public string? StandaloneComponentAlias() => Alias?.ToPascalCase(); + public override string ToString() => $"{Set}:{Name}"; - + public static bool TryParse(string input, out IconReference result) { var parts = input.Trim().Split(':'); diff --git a/src/TypedIcons.Generator/CodeTemplates.cs b/src/TypedIcons.Generator/CodeTemplates.cs index 0ca6a66..10a4b78 100644 --- a/src/TypedIcons.Generator/CodeTemplates.cs +++ b/src/TypedIcons.Generator/CodeTemplates.cs @@ -27,9 +27,10 @@ public readonly partial record struct IconDefinition( // #nullable enable using Microsoft.AspNetCore.Components; - + using Microsoft.AspNetCore.Components.Rendering; + namespace TypedIcons; - + /// /// Base class for SVG icon components. /// @@ -53,62 +54,46 @@ public abstract class IconBase : ComponentBase /// Sets both width and height to the same value. Takes precedence over and . /// [Parameter] public string? Size { get; set; } - + /// Override for the rendered width. Falls back to the icon's intrinsic width. [Parameter] public string? Width { get; set; } - + /// Override for the rendered height. Falls back to the icon's intrinsic height. [Parameter] public string? Height { get; set; } - + /// CSS class(es) applied to the root <svg> element. [Parameter] public string? Class { get; set; } - + /// Inline styles applied to the root <svg> element. [Parameter] public string? Style { get; set; } - + /// /// Accessible title for the icon. When set, a <title> element is injected and the icon /// gets role="img". When , the icon is decorative (aria-hidden="true"). /// [Parameter] public string? Title { get; set; } - + /// Optional content rendered inside the <svg> after the icon paths. [Parameter] public RenderFragment? ChildContent { get; set; } - + /// Additional attributes passed through to the root <svg> element. [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - } - """; - - public const string IconComponent = - """ - // - #nullable enable - using Microsoft.AspNetCore.Components; - using Microsoft.AspNetCore.Components.Rendering; - - namespace TypedIcons; - - /// - /// Renders an SVG icon from an . See for full documentation. - /// - public partial class Icon : IconBase - { - /// The icon to render. Required. - [Parameter, EditorRequired] public IconDefinition Source { get; set; } - + + /// The icon definition to render. Provided by subclasses. + protected abstract IconDefinition IconSource { get; } + protected override void BuildRenderTree(RenderTreeBuilder builder) { - if (Source.IsEmpty) + if (IconSource.IsEmpty) return; - var (width, height) = CalculateDimensions(Source); + var (width, height) = CalculateDimensions(IconSource); var isDecorative = Title is null; - + builder.OpenElement(0, "svg"); builder.AddAttribute(10, "xmlns", "http://www.w3.org/2000/svg"); - builder.AddAttribute(20, "viewBox", Source.ViewBox); + builder.AddAttribute(20, "viewBox", IconSource.ViewBox); builder.AddAttribute(30, "width", width); builder.AddAttribute(40, "height", height); builder.AddAttribute(50, "class", Class); @@ -125,7 +110,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } builder.AddMultipleAttributes(80, AdditionalAttributes); - + if (!isDecorative) { builder.OpenElement(90, "title"); @@ -133,16 +118,35 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.CloseElement(); } - builder.AddMarkupContent(100, Source.SvgContent); + builder.AddMarkupContent(100, IconSource.SvgContent); builder.AddContent(110, ChildContent); builder.CloseElement(); } - - private (string width, string height) CalculateDimensions(IconDefinition iconDefinition) => + + private (string width, string height) CalculateDimensions(IconDefinition iconData) => Size is not null ? (Size, Size) : Width is not null && Height is not null ? (Width, Height) : Width is not null ? (Width, Width) : - Height is not null ? (Height, Height) : (iconDefinition.Width, iconDefinition.Height); + Height is not null ? (Height, Height) : (iconData.Width, iconData.Height); + } + """; + + public const string IconComponent = + """ + // + using Microsoft.AspNetCore.Components; + + namespace TypedIcons; + + /// + /// Renders an SVG icon from an . See for full documentation. + /// + public partial class Icon : IconBase + { + /// The icon to render. Required. + [Parameter, EditorRequired] public IconDefinition Source { get; set; } + + protected override IconDefinition IconSource => Source; } """; @@ -178,7 +182,7 @@ public static partial class {{className}} /// Gets the {{iconName}} icon. public static readonly IconDefinition {{iconName}} = new("{{svgContent}}", "{{viewBox}}", "{{width}}", "{{height}}"); """; - + public const string IconAliasesClassTemplate = """ // @@ -189,10 +193,29 @@ public static partial class Icons {{fields}} } """; - + public const string IconAliasesClassFieldTemplate = """ /// Alias for . public static readonly IconDefinition {{iconAlias}} = {{iconSet}}.{{iconName}}; """; + + public const string StandaloneComponentFileTemplate = + """ + // + namespace TypedIcons; + + {{content}} + """; + + public const string StandaloneComponentClassTemplate = + """ + /// + /// Renders the {{iconName}} icon from {{iconSet}}. See for full documentation. + /// + public partial class {{standaloneClassName}} : IconBase + { + protected override IconDefinition IconSource => Icons.{{iconSet}}.{{iconName}}; + } + """; } \ No newline at end of file diff --git a/src/TypedIcons.Generator/IconGenerator.cs b/src/TypedIcons.Generator/IconGenerator.cs index 7108ee5..064c735 100644 --- a/src/TypedIcons.Generator/IconGenerator.cs +++ b/src/TypedIcons.Generator/IconGenerator.cs @@ -79,14 +79,19 @@ private static void Generate(SourceProductionContext context, iconSets.Select(x => x.Key.ToPascalCase()), StringComparer.OrdinalIgnoreCase ); - var numberOfAliases = 0; var isFirstAlias = true; + var standaloneAliasComponentFieldStringBuilder = new StringBuilder(); + var isFirstStandaloneAliasComponent = true; + foreach (var iconSet in iconSets) { var pascalIconSetName = iconSet.Key.ToPascalCase(); var fieldStringBuilder = new StringBuilder(); + var standaloneComponentFieldStringBuilder = new StringBuilder(); + var isFirstStandaloneComponent = true; + var isFirstIcon = true; foreach (var iconReference in iconSet) { @@ -140,7 +145,34 @@ private static void Generate(SourceProductionContext context, .Replace("{{iconName}}", iconName) .Replace("{{iconAlias}}", iconAlias) ); - numberOfAliases++; + + if (iconReference.Standalone == true) + { + if (!isFirstStandaloneAliasComponent) + standaloneAliasComponentFieldStringBuilder.AppendLine(); + isFirstStandaloneAliasComponent = false; + + standaloneAliasComponentFieldStringBuilder.AppendLine( + CodeTemplates.StandaloneComponentClassTemplate + .Replace("{{standaloneClassName}}", iconReference.StandaloneComponentAlias()) + .Replace("{{iconSet}}", pascalIconSetName) + .Replace("{{iconName}}", iconName) + ); + } + } + + if (iconReference.Standalone == true) + { + if (!isFirstStandaloneComponent) + standaloneComponentFieldStringBuilder.AppendLine(); + isFirstStandaloneComponent = false; + + standaloneComponentFieldStringBuilder.AppendLine( + CodeTemplates.StandaloneComponentClassTemplate + .Replace("{{standaloneClassName}}", iconReference.StandaloneComponentName()) + .Replace("{{iconSet}}", pascalIconSetName) + .Replace("{{iconName}}", iconName) + ); } } @@ -150,14 +182,31 @@ private static void Generate(SourceProductionContext context, .Replace("{{fields}}", fieldStringBuilder.ToString().TrimEnd()); context.AddSource($"{pascalIconSetName}.Icons.g.cs", SourceText.From(iconSetClass, Encoding.UTF8)); + + if (!isFirstStandaloneComponent) + { + var standaloneComponentFile = CodeTemplates.StandaloneComponentFileTemplate + .Replace("{{content}}", standaloneComponentFieldStringBuilder.ToString().TrimEnd()); + + context.AddSource($"{pascalIconSetName}.Standalone.g.cs", + SourceText.From(standaloneComponentFile, Encoding.UTF8)); + } } - if (numberOfAliases > 0) + if (!isFirstAlias) { var iconAliasesClass = CodeTemplates.IconAliasesClassTemplate .Replace("{{fields}}", aliasFieldStringBuilder.ToString().TrimEnd()); context.AddSource($"IconAliases.g.cs", SourceText.From(iconAliasesClass, Encoding.UTF8)); } + + if (!isFirstStandaloneAliasComponent) + { + var standaloneAliasComponentFile = CodeTemplates.StandaloneComponentFileTemplate + .Replace("{{content}}", standaloneAliasComponentFieldStringBuilder.ToString().TrimEnd()); + + context.AddSource("Standalone.g.cs", SourceText.From(standaloneAliasComponentFile, Encoding.UTF8)); + } } } \ No newline at end of file diff --git a/src/TypedIcons.Generator/TypedIcons.Generator.csproj b/src/TypedIcons.Generator/TypedIcons.Generator.csproj index e320264..c515258 100644 --- a/src/TypedIcons.Generator/TypedIcons.Generator.csproj +++ b/src/TypedIcons.Generator/TypedIcons.Generator.csproj @@ -9,7 +9,7 @@ true TypedIcons.Generator - 0.4.0 + 0.5.0 TypedIcons.Generator Type-safe icons for Blazor with IntelliSense and compile-time safety. Powered by a .NET tool and source generator, using Iconify icon sets without string-based names.