From 24d11f325519de1ee1f50c84cc68517d9ff15644 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 2 Sep 2026 23:19:41 +0200 Subject: [PATCH 1/8] feat: implement static factory dispatcher for icons to streamline rendering and reduce namespace pollution --- src/ShellIcons.Generator/IconGenerator.cs | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/ShellIcons.Generator/IconGenerator.cs b/src/ShellIcons.Generator/IconGenerator.cs index a901b92..d2e3e81 100644 --- a/src/ShellIcons.Generator/IconGenerator.cs +++ b/src/ShellIcons.Generator/IconGenerator.cs @@ -86,6 +86,7 @@ private static void EmitAll(SourceProductionContext ctx, System.Collections.Immu ctx.AddSource($"Icons/{d.PascalName}.g.cs", SourceText.From(EmitTypedComponent(d), Encoding.UTF8)); ctx.AddSource("ShellIcon.g.cs", SourceText.From(EmitDispatcher(ordered), Encoding.UTF8)); + ctx.AddSource("Icon.g.cs", SourceText.From(EmitFactoryDispatcher(ordered), Encoding.UTF8)); } private static string EmitTypedComponent(IconDefinition d) @@ -161,6 +162,69 @@ private static string EmitDispatcher(IconDefinition[] defs) return sb.ToString(); } + private static string EmitFactoryDispatcher(IconDefinition[] defs) + { + // Emits `namespace ShellIcons; public static class Icon` with one static + // RenderFragment-returning method per icon. Consumers reach every icon through a + // single `Icon` type — zero flat-namespace pollution (no `@using ShellIcons.Icons` + // needed, so no RZ9985 collision with UI-kit names like Badge/Table/Router). + // + // (Named `Icon` singular, not `Icons` — the plural would collide with the existing + // `ShellIcons.Icons` sub-namespace where the typed components live.) + // + // Trimmer-friendly: each factory method roots exactly its own typed component, so + // only the icons a consumer actually calls survive linking. Contrast with `ShellIcon` + // which uses a runtime Dictionary keyed by name and therefore roots the full catalog. + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using Microsoft.AspNetCore.Components;"); + sb.AppendLine("using Microsoft.AspNetCore.Components.Rendering;"); + sb.AppendLine(); + sb.AppendLine($"namespace {DispatcherNamespace};"); + sb.AppendLine(); + sb.AppendLine("/// "); + sb.AppendLine("/// Static factory dispatcher for every icon in the catalog. Each method returns a"); + sb.AppendLine("/// that renders the corresponding typed icon component."); + sb.AppendLine("/// "); + sb.AppendLine("/// Use this when your project already has UI-library types that would collide with the"); + sb.AppendLine("/// flat ShellIcons.Icons namespace (Badge, Table, Menu, Progress, Router, …)."); + sb.AppendLine("/// Only Icons enters the caller's tag lookup, and only the icons you reference are"); + sb.AppendLine("/// rooted for trimming."); + sb.AppendLine("/// "); + sb.AppendLine("/// "); + sb.AppendLine("/// @using ShellIcons"); + sb.AppendLine("/// @Icon.Plus()"); + sb.AppendLine("/// @Icon.Bell(size: \"16\", strokeWidth: 1.5, cssClass: \"text-primary\")"); + sb.AppendLine("/// "); + sb.AppendLine("/// "); + sb.AppendLine("public static class Icon"); + sb.AppendLine("{"); + + for (var i = 0; i < defs.Length; i++) + { + var d = defs[i]; + var typeRef = $"global::{IconsNamespace}.{d.PascalName}"; + sb.AppendLine(); + sb.AppendLine($" /// Renders the {d.KebabName} icon (source: {d.SourcePack})."); + sb.AppendLine($" public static RenderFragment {d.PascalName}(string size = \"24\", double strokeWidth = 2, bool absoluteStroke = false, string? cssClass = null, string? title = null, global::System.Collections.Generic.IReadOnlyDictionary? additionalAttributes = null) =>"); + sb.AppendLine(" builder =>"); + sb.AppendLine(" {"); + sb.AppendLine($" builder.OpenComponent<{typeRef}>(0);"); + sb.AppendLine(" builder.AddAttribute(1, \"Size\", size);"); + sb.AppendLine(" builder.AddAttribute(2, \"StrokeWidth\", strokeWidth);"); + sb.AppendLine(" builder.AddAttribute(3, \"AbsoluteStroke\", absoluteStroke);"); + sb.AppendLine(" if (cssClass is not null) builder.AddAttribute(4, \"Class\", cssClass);"); + sb.AppendLine(" if (title is not null) builder.AddAttribute(5, \"Title\", title);"); + sb.AppendLine(" if (additionalAttributes is not null) builder.AddMultipleAttributes(6, additionalAttributes);"); + sb.AppendLine(" builder.CloseComponent();"); + sb.AppendLine(" };"); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + private static string ToVerbatimStringLiteral(string value) { var escaped = value.Replace("\"", "\"\""); From dc09fbe6d69c6296c5b658162ad8c4713677b907 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 2 Sep 2026 23:20:20 +0200 Subject: [PATCH 2/8] test: add unit tests for Icon factory dispatcher to validate rendering and attribute forwarding --- .../IconFactoryTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs diff --git a/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs new file mode 100644 index 0000000..9d606b8 --- /dev/null +++ b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs @@ -0,0 +1,78 @@ +using Bunit; +using AngleSharp.Dom; + +namespace ShellIcons.Blazor.Tests; + +// Tests for the static `Icon` factory dispatcher (Icon.Plus(), Icon.Bell(), etc.) +// added for consumers who can't import the flat `ShellIcons.Icons` namespace +// without colliding with UI-library types (Badge, Table, Router, …). +public class IconFactoryTests : IDisposable +{ + private readonly TestContext _ctx = new(); + + public void Dispose() => _ctx.Dispose(); + + [Fact] + public void Factory_RendersMatchingShape() + { + // chevron-right's Lucide path data + var cut = _ctx.Render(Icon.ChevronRight()); + var path = cut.Find("svg > path"); + + Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d")); + } + + [Fact] + public void Factory_ForwardsSizeAndStrokeWidth() + { + var svg = _ctx.Render(Icon.Zap(size: "16", strokeWidth: 1.5)).Find("svg"); + + Assert.Equal("16", svg.GetAttribute("width")); + Assert.Equal("16", svg.GetAttribute("height")); + Assert.Equal("1.5", svg.GetAttribute("stroke-width")); + } + + [Fact] + public void Factory_ForwardsCssClass() + { + var svg = _ctx.Render(Icon.TriangleAlert(cssClass: "text-warning")).Find("svg"); + + Assert.Equal("text-warning", svg.GetAttribute("class")); + } + + [Fact] + public void Factory_TitleSwitchesToImgRole() + { + var svg = _ctx.Render(Icon.X(title: "Close dialog")).Find("svg"); + + Assert.Equal("img", svg.GetAttribute("role")); + Assert.NotNull(svg.GetAttribute("aria-labelledby")); + // aria-hidden should NOT be set when a title is provided + Assert.Null(svg.GetAttribute("aria-hidden")); + } + + [Fact] + public void Factory_NoTitle_IsDecorative() + { + var svg = _ctx.Render(Icon.House()).Find("svg"); + + // Decorative: hidden from AT, no role/title id + Assert.Equal("true", svg.GetAttribute("aria-hidden")); + Assert.Null(svg.GetAttribute("role")); + } + + [Fact] + public void Factory_ForwardsAdditionalAttributes() + { + var extras = new Dictionary + { + ["data-testid"] = "my-icon", + ["style"] = "color: crimson" + }; + + var svg = _ctx.Render(Icon.Bell(additionalAttributes: extras)).Find("svg"); + + Assert.Equal("my-icon", svg.GetAttribute("data-testid")); + Assert.Equal("color: crimson", svg.GetAttribute("style")); + } +} From e4b3be1534a61057362f8bd91aa14b1c0f7ee6a6 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 27 Sep 2026 08:37:16 +0200 Subject: [PATCH 3/8] docs: update README and installation guides to clarify icon usage, namespace imports, and rendering methods for ShellIcons --- README.md | 111 +++++++++++++++--- .../docs/getting-started/installation.md | 26 ++-- .../docs/getting-started/quick-start.md | 23 ++-- .../docs/guides/typed-vs-dispatcher.md | 2 + 4 files changed, 123 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d32b9d8..0c8758c 100644 --- a/README.md +++ b/README.md @@ -37,37 +37,120 @@ Not yet published. During development: ``` -Typed icons (tree-shakeable) — import the `ShellIcons.Icons` namespace **per page** (imports live in a sub-namespace so common names like `Router` or `Activity` don't collide with Blazor/system types unless you opt in): +### Pick your API + +Four ways to render an icon — all produce identical SVG. Only the first two are safe to use next to a UI kit. + +| API | Import | Collides with UI kits? | Tree-shakeable? | Use for | +|---|---|---|---|---| +| **``** suffixed component | `@using ShellIcons` | ✅ No | ✅ Yes | **Default for markup.** Reads as an icon, full parameter binding. | +| **`@Icon.ChevronRight()`** factory | `@using ShellIcons` | ✅ No | ✅ Yes | **Icons as values** — `RenderFragment` parameters, nav-item lists, configs. | +| `` dispatcher | `@using ShellIcons` | ✅ No | ❌ Roots all 1,555 | Names only known at runtime (CMS, JSON, markdown). | +| `` flat component | `@using ShellIcons.Icons` | ⚠️ Yes — RZ9985 on Badge, Table, Menu, Router, Card… | ✅ Yes | Icon-only files with no UI kit imported. | + +One `@using ShellIcons` in `_Imports.razor` gives you the first three. + +#### `` — suffixed components (recommended for markup) + +Every icon is also emitted as `{Name}Icon` in the root `ShellIcons` namespace. The suffix keeps names clear of UI-kit components and tells readers "this is an icon" at a glance — the same alias `lucide-react` ships. ```razor -@using ShellIcons.Icons +@using ShellIcons - - - + + + + ``` -Dispatcher (ships the full 1555-icon catalog — trimmer-hostile, use for dynamic lookups) — lives in `ShellIcons`: +One exception: Lucide's `shell` icon would be `ShellIcon`, which is the dispatcher, so it has no suffixed form — use `@Icon.Shell()`. + +#### `@Icon.ChevronRight()` — factory (icons as values) + +Every icon has a static method on `Icon` returning a `RenderFragment`. Use it wherever an icon is *data* rather than markup: ```razor -@using ShellIcons +@* A component parameter typed RenderFragment *@ + + +@* A list built in C# *@ +@code { + private readonly (string Label, RenderFragment Icon)[] _items = + [ + ("Inbox", Icon.Inbox()), + ("Settings", Icon.Settings(size: "16")), + ]; +} +``` +A call with no arguments returns a cached fragment, so `@Icon.Plus()` in a hot render path doesn't allocate. + +#### `` — dispatcher + +Runtime lookup by kebab-case name, for icons whose identity is data-driven. Referencing it roots the full catalog, so prefer the two forms above when the name is known at compile time. + +```razor ``` -Colors inherit from CSS: +#### `` — flat components (⚠️ RZ9985 trap) + +`@using ShellIcons.Icons` puts all 1,555 unprefixed names into your tag lookup. With a UI kit that has `Badge`, `Table`, `Router`, etc., you get RZ9985 collisions. + +**Workarounds that don't work** (verified by real integrators): + +- `@using Icon = ShellIcons.Icons` then `` — Razor's tag matcher ignores namespace aliases; the tag compiles as an unknown HTML element and **renders blank**. +- Type aliases (`@using Badge = MyApp.UI.Badge`) — ignored by the tag matcher too. + +Use the suffixed components instead, or fully qualify: ``. + +### Using icons inside component libraries + +**Buttons and similar containers — just put the icon in the content.** No `Icon` slot needed; the component's CSS sizes child SVGs (shadcn's `[&_svg]:size-4`): -```html - - - +```razor + +``` + +**Components that place the icon somewhere specific** (input adornments, alerts, nav items) take a `RenderFragment` parameter — pass the factory: + +```razor + +``` + +### Click handlers + +Put handlers on a wrapping ` @* ✅ recommended *@ + @* ✅ works *@ + @* ❌ throws — see below *@ +``` + +On a *component*, Razor passes `@onclick="Save"` as a plain string named `@onclick`; the browser then rejects that attribute name and the render batch fails. ShellIcons throws a clear `InvalidOperationException` instead. For the factory, the dictionary key is `"onclick"` (no `@`): + +```razor +@Icon.Bell(additionalAttributes: new Dictionary +{ + ["onclick"] = EventCallback.Factory.Create(this, HandleClick), + ["data-testid"] = "notifications", +}) +``` + +### Colors and accessibility + +Colors inherit from CSS `color` (`stroke="currentColor"`): + +```razor + ``` -Accessibility: +Without `Title` icons are decorative (`aria-hidden="true"`); with it they get `role="img"` + ``: ```razor -<X Title="Close dialog" /> +<XIcon Title="Close dialog" /> ``` ## Repo layout diff --git a/docs/ShellIcons.Docs/content/docs/getting-started/installation.md b/docs/ShellIcons.Docs/content/docs/getting-started/installation.md index 4260db6..c791bf6 100644 --- a/docs/ShellIcons.Docs/content/docs/getting-started/installation.md +++ b/docs/ShellIcons.Docs/content/docs/getting-started/installation.md @@ -20,36 +20,30 @@ That's the whole install. No CSS import, no JS reference, no config file. - **`net9.0`** - **`net10.0`** works via forward-compat -## Namespace layout +## Import -ShellIcons splits into two namespaces so importing them globally doesn't collide with anything Blazor or the BCL ships: - -| Namespace | What lives there | When to import | -|---|---|---| -| `ShellIcons` | `IconCore`, `ShellIcon` dispatcher | Import globally in `_Imports.razor` | -| `ShellIcons.Icons` | All 1,555 typed icon components | Import **per page** — protects Blazor's `Router`, `System.Diagnostics.Activity`, `List<T>`, etc. from being shadowed | - -**Global** — add to `Components/_Imports.razor`: +Add one line to `Components/_Imports.razor`: ```razor @using ShellIcons ``` -**Per page** — at the top of any `.razor` file that uses typed icons: +That brings in everything you normally need, none of which collides with UI-kit components: -```razor -@page "/dashboard" -@using ShellIcons.Icons +| Form | Example | Use for | +|---|---|---| +| Suffixed components | `<ChevronRightIcon />` | Markup — the default | +| Factory | `@Icon.ChevronRight()` | Icons as values (`RenderFragment` parameters, lists) | +| Dispatcher | `<ShellIcon Name="chevron-right" />` | Names only known at runtime | -<ChevronRight /> -``` +The unsuffixed components (`<ChevronRight />`) live in `ShellIcons.Icons`. Don't import that namespace globally: its 1,555 short names collide with UI-kit components such as `Badge`, `Table` and `Router` (RZ9985). See [Typed vs dispatcher](/docs/guides/typed-vs-dispatcher). ## Verify Drop this anywhere and run: ```razor:preview -<Zap Size="32" /> +<ZapIcon Size="32" /> ``` If you see a lightning bolt above, you're wired. diff --git a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md index 6c99b2c..ff60cb6 100644 --- a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md +++ b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md @@ -12,28 +12,33 @@ order: 2 dotnet add package ShellIcons.Blazor ``` -## 2. Import the icons namespace +## 2. Import the namespace -At the top of any `.razor` page that will render icons: +Once, in `Components/_Imports.razor`: ```razor -@page "/" -@using ShellIcons.Icons +@using ShellIcons ``` ## 3. Render icons -Every icon is a typed component. IntelliSense the name you want — `<Ch` will suggest `ChevronDown`, `ChevronLeft`, `ChevronRight`, `ChevronUp`, and so on. +Every icon is a component named `{Name}Icon`. IntelliSense the name you want — `<Chevron` will suggest `ChevronDownIcon`, `ChevronLeftIcon`, `ChevronRightIcon`, `ChevronUpIcon`, and so on. ```razor:preview <PreviewRow> - <ChevronLeft /> - <ChevronRight /> - <ChevronDown /> - <ChevronUp /> + <ChevronLeftIcon /> + <ChevronRightIcon /> + <ChevronDownIcon /> + <ChevronUpIcon /> </PreviewRow> ``` +When the icon is a *value* — a `RenderFragment` parameter on another component, or an item in a list — use the factory instead: + +```razor +<NavItem Href="/" Label="Home" Icon="@Icon.House()" /> +``` + ## 4. Size and stroke ```razor:preview diff --git a/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md b/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md index 299a472..8c62ff8 100644 --- a/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md +++ b/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md @@ -8,6 +8,8 @@ order: 5 ShellIcons ships two ways to render an icon. Both produce identical HTML. The difference is at build time. +> **Which typed form?** The examples below use the flat `<ChevronRight />` names from `ShellIcons.Icons`. In an app that also uses a UI kit, use the suffixed twins from `ShellIcons` instead — `<ChevronRightIcon />` — or the factory `@Icon.ChevronRight()`. They tree-shake the same way, and they can't collide with UI-kit components like `Badge` or `Table` (RZ9985). + ## Typed form — the default ```razor From 57afd6cf7130a0b7a4207baa0304abc23fcbacaa Mon Sep 17 00:00:00 2001 From: Shephard Tseisi <shephard@kernel.co.zw> Date: Sun, 27 Sep 2026 08:43:06 +0200 Subject: [PATCH 4/8] feat: enhance icon handling by adding IconFragment for optimized rendering and updating icon snippet logic in documentation --- .../Components/Pages/IconsPage.razor | 5 +- src/ShellIcons.Blazor/IconCore.cs | 24 +++++++++- src/ShellIcons.Blazor/IconFragment.cs | 47 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/ShellIcons.Blazor/IconFragment.cs diff --git a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor b/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor index a7cd394..436bc5d 100644 --- a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor +++ b/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor @@ -175,7 +175,7 @@ raf = requestAnimationFrame(filter); }); - // Focus the search when user starts typing anywhere on the page + // "/" focuses the search. document.addEventListener('keydown', (e) => { if (e.key === '/' && document.activeElement !== input) { e.preventDefault(); @@ -187,7 +187,8 @@ function copyIconSnippet(cell) { const name = cell.dataset.name; const pascal = name.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(''); - const snippet = '<' + pascal + ' />'; + // `shell` has no suffixed form (ShellIcon is the dispatcher). + const snippet = name === 'shell' ? '@Icon.Shell()' : '<' + pascal + 'Icon />'; navigator.clipboard?.writeText(snippet).then(() => { const toast = document.getElementById('copy-toast'); toast.textContent = 'Copied: ' + snippet; diff --git a/src/ShellIcons.Blazor/IconCore.cs b/src/ShellIcons.Blazor/IconCore.cs index a1cf23c..5fbf601 100644 --- a/src/ShellIcons.Blazor/IconCore.cs +++ b/src/ShellIcons.Blazor/IconCore.cs @@ -29,7 +29,10 @@ public abstract class IconCore : ComponentBase /// <summary>Accessible title. When set, the icon is announced by screen readers via <title> + <c>role="img"</c> + <c>aria-labelledby</c>. When null, the icon is decorative and gets <c>aria-hidden="true"</c>.</summary> [Parameter] public string? Title { get; set; } - /// <summary>Extra attributes forwarded to the root <svg> element (e.g. <c>style</c>, <c>@onclick</c>).</summary> + /// <summary> + /// Extra attributes forwarded to the root <svg> (e.g. <c>style</c>, <c>data-*</c>). Pass handlers + /// without the <c>@</c> — <c>onclick="@(() => Handler())"</c> — or put them on a wrapping <c><button></c>. + /// </summary> [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; } @@ -70,7 +73,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } if (AdditionalAttributes is not null) + { + ThrowOnEventDirectiveMisuse(AdditionalAttributes); builder.AddMultipleAttributes(14, AdditionalAttributes); + } if (Title is not null) { @@ -85,6 +91,22 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.CloseElement(); } + /* On a component, Razor passes @onclick="Save" as a string attribute named "@onclick"; + the browser's setAttribute rejects that name and the render batch fails. */ + private void ThrowOnEventDirectiveMisuse(IReadOnlyDictionary<string, object> attributes) + { + foreach (var key in attributes.Keys) + { + if (key.StartsWith("@on", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"'{key}' on icon '{IconName}' has no effect: on a component Razor passes it as a plain string, " + + $"not an event handler. Put the handler on a wrapping <button {key}=\"…\"> (recommended for accessibility), " + + $"or pass it without the @: {key[1..]}=\"@(() => Handler())\"."); + } + } + } + private string ComputeStrokeWidth() { if (!AbsoluteStroke) diff --git a/src/ShellIcons.Blazor/IconFragment.cs b/src/ShellIcons.Blazor/IconFragment.cs new file mode 100644 index 0000000..85cc61c --- /dev/null +++ b/src/ShellIcons.Blazor/IconFragment.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Components; + +namespace ShellIcons; + +// Shared body of the generated Icon.* factory methods, so each of them stays a one-liner. +internal static class IconFragment +{ + /* All-default calls return a cached fragment (no allocation per render); otherwise only + non-default arguments are passed as parameters. */ + public static RenderFragment Create<T>( + string size, + double strokeWidth, + bool absoluteStroke, + string? cssClass, + string? title, + IReadOnlyDictionary<string, object>? additionalAttributes) + where T : IconCore + { + if (size == "24" && strokeWidth == 2 && !absoluteStroke + && cssClass is null && title is null && additionalAttributes is null) + { + return Cache<T>.Default; + } + + return builder => + { + builder.OpenComponent<T>(0); + if (size != "24") builder.AddAttribute(1, nameof(IconCore.Size), size); + if (strokeWidth != 2) builder.AddAttribute(2, nameof(IconCore.StrokeWidth), strokeWidth); + if (absoluteStroke) builder.AddAttribute(3, nameof(IconCore.AbsoluteStroke), true); + if (cssClass is not null) builder.AddAttribute(4, nameof(IconCore.Class), cssClass); + if (title is not null) builder.AddAttribute(5, nameof(IconCore.Title), title); + if (additionalAttributes is not null) builder.AddMultipleAttributes(6, additionalAttributes); + builder.CloseComponent(); + }; + } + + // Generic holder: a cache entry exists only for icons actually used, so the trimmer can drop the rest. + private static class Cache<T> where T : IconCore + { + public static readonly RenderFragment Default = builder => + { + builder.OpenComponent<T>(0); + builder.CloseComponent(); + }; + } +} From ffa65b73b6238571a42380b052c919fef5832661 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi <shephard@kernel.co.zw> Date: Sun, 27 Sep 2026 08:43:38 +0200 Subject: [PATCH 5/8] test: add fixture components for collision handling and event directive misuse in ShellIcons Blazor tests --- .../Fixtures/CollisionPage.razor | 16 ++++++++++++++++ .../Fixtures/EventDirectiveMisuse.razor | 5 +++++ .../Fixtures/FakeUiKit.cs | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor create mode 100644 tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor create mode 100644 tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor b/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor new file mode 100644 index 0000000..7d5cf28 --- /dev/null +++ b/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor @@ -0,0 +1,16 @@ +@namespace CollisionFixture +@using ShellIcons +@using FakeUiKit +@using Microsoft.AspNetCore.Components.Routing + +@* If a {Name}Icon alias ever collides with a UI-kit name, this page stops compiling (RZ9985). *@ + +<Badge>New</Badge> +<BadgeIcon Size="16" Class="markup-icon" onclick="@(() => Increment())" /> +<RouterIcon Class="router-icon" /> +@Icon.Table(cssClass: "factory-icon") + +@code { + public int Clicks { get; private set; } + private void Increment() => Clicks++; +} diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor b/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor new file mode 100644 index 0000000..6cfacf1 --- /dev/null +++ b/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor @@ -0,0 +1,5 @@ +@namespace CollisionFixture +@using ShellIcons + +@* @onclick on a component is a string, not a handler — IconCore must throw. *@ +<PlusIcon @onclick="() => { }" /> diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs b/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs new file mode 100644 index 0000000..d976b99 --- /dev/null +++ b/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace FakeUiKit; + +// Stand-in for a UI kit component that shares a name with a Lucide icon. +public sealed class Badge : ComponentBase +{ + [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "fake-badge"); + builder.AddContent(2, ChildContent); + builder.CloseElement(); + } +} From 6c1cb513978b9c58e574c5628d090110c918f9dc Mon Sep 17 00:00:00 2001 From: Shephard Tseisi <shephard@kernel.co.zw> Date: Sun, 27 Sep 2026 08:43:54 +0200 Subject: [PATCH 6/8] feat: enhance IconGenerator to support suffixed icon components and reserved name diagnostics --- src/ShellIcons.Generator/IconGenerator.cs | 102 +++++++++++----------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/src/ShellIcons.Generator/IconGenerator.cs b/src/ShellIcons.Generator/IconGenerator.cs index d2e3e81..b5d84ce 100644 --- a/src/ShellIcons.Generator/IconGenerator.cs +++ b/src/ShellIcons.Generator/IconGenerator.cs @@ -7,17 +7,8 @@ namespace ShellIcons.Generator; -/// <summary> -/// Emits one <c>{PascalName}.g.cs</c> per SVG icon file passed in via <c>AdditionalFiles</c>, -/// plus a <c>ShellIcon.g.cs</c> dispatcher that looks up icons by kebab-case name. -/// -/// Two source packs are supported by convention on file path: -/// <list type="bullet"> -/// <item><description>files under <c>catalog/lucide/icons/</c> are tagged <c>lucide</c></description></item> -/// <item><description>files under <c>catalog/custom/icons/</c> are tagged <c>custom</c></description></item> -/// </list> -/// On name collision, custom wins and a <c>SHELLICONS001</c> diagnostic is emitted. -/// </summary> +/* Emits typed components, their {Name}Icon aliases, the ShellIcon dispatcher and the Icon factory + from catalog/lucide and catalog/custom SVGs. A custom icon overrides a Lucide one of the same name. */ [Generator] public sealed class IconGenerator : IIncrementalGenerator { @@ -32,6 +23,20 @@ public sealed class IconGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor SuffixReservedDescriptor = new( + id: "SHELLICONS003", + title: "Suffixed icon component skipped", + messageFormat: "Icon '{0}' has no <{1} /> suffixed component because '{1}' is a reserved ShellIcons type; use Icon.{2}() or <ShellIcons.Icons.{2} /> instead", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + // Existing root-namespace types a {Name}Icon alias would clash with (Lucide's `shell` → ShellIcon). + private static readonly HashSet<string> ReservedRootNames = new(System.StringComparer.Ordinal) + { + "ShellIcon", "Icon", "IconCore", "IconFragment", + }; + public void Initialize(IncrementalGeneratorInitializationContext context) { var svgs = context.AdditionalTextsProvider @@ -85,6 +90,7 @@ private static void EmitAll(SourceProductionContext ctx, System.Collections.Immu foreach (var d in ordered) ctx.AddSource($"Icons/{d.PascalName}.g.cs", SourceText.From(EmitTypedComponent(d), Encoding.UTF8)); + ctx.AddSource("SuffixedIcons.g.cs", SourceText.From(EmitSuffixedComponents(ctx, ordered), Encoding.UTF8)); ctx.AddSource("ShellIcon.g.cs", SourceText.From(EmitDispatcher(ordered), Encoding.UTF8)); ctx.AddSource("Icon.g.cs", SourceText.From(EmitFactoryDispatcher(ordered), Encoding.UTF8)); } @@ -100,7 +106,7 @@ private static string EmitTypedComponent(IconDefinition d) namespace {{IconsNamespace}}; /// <summary>Icon <c>{{d.KebabName}}</c> (source: {{d.SourcePack}}).</summary> - public sealed class {{d.PascalName}} : global::ShellIcons.IconCore + public class {{d.PascalName}} : global::ShellIcons.IconCore { /// <inheritdoc /> protected override string IconName => "{{d.KebabName}}"; @@ -112,6 +118,33 @@ protected override void EmitChildren(RenderTreeBuilder builder, int seq) => """; } + private static string EmitSuffixedComponents(SourceProductionContext ctx, IconDefinition[] defs) + { + /* The suffix keeps names clear of UI-kit components (RZ9985) under a single @using ShellIcons. + Empty subclasses, so they cost metadata only. */ + var sb = new StringBuilder(); + sb.AppendLine("// <auto-generated/>"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine($"namespace {DispatcherNamespace};"); + + foreach (var d in defs) + { + var suffixed = d.PascalName + "Icon"; + if (ReservedRootNames.Contains(suffixed)) + { + ctx.ReportDiagnostic(Diagnostic.Create(SuffixReservedDescriptor, Location.None, d.KebabName, suffixed, d.PascalName)); + continue; + } + + sb.AppendLine(); + sb.AppendLine($"/// <summary>Icon <c>{d.KebabName}</c> (source: {d.SourcePack}). Suffixed alias of <see cref=\"global::{IconsNamespace}.{d.PascalName}\"/>.</summary>"); + sb.AppendLine($"public sealed class {suffixed} : global::{IconsNamespace}.{d.PascalName} {{ }}"); + } + + return sb.ToString(); + } + private static string EmitDispatcher(IconDefinition[] defs) { var sb = new StringBuilder(); @@ -124,14 +157,8 @@ private static string EmitDispatcher(IconDefinition[] defs) sb.AppendLine(); sb.AppendLine($"namespace {DispatcherNamespace};"); sb.AppendLine(); - sb.AppendLine("/// <summary>"); - sb.AppendLine("/// Dynamic icon dispatcher. Renders any icon in the catalog by kebab-case name:"); - sb.AppendLine("/// <c><ShellIcon Name=\"chevron-right\" /></c>."); - sb.AppendLine("/// <para>"); - sb.AppendLine("/// Trimmer-hostile — referencing this component roots the full catalog. For tree-shakeable"); - sb.AppendLine("/// icons, use the typed form (e.g. <c><ChevronRight /></c>)."); - sb.AppendLine("/// </para>"); - sb.AppendLine("/// </summary>"); + sb.AppendLine("/// <summary>Renders any icon by kebab-case name, e.g. <c><ShellIcon Name=\"chevron-right\" /></c>."); + sb.AppendLine("/// Referencing it keeps the whole catalog; prefer the typed components when the name is known.</summary>"); sb.AppendLine("public sealed class ShellIcon : IconCore"); sb.AppendLine("{"); sb.AppendLine(" /// <summary>Kebab-case name of the icon to render (e.g. <c>\"chevron-right\"</c>).</summary>"); @@ -164,34 +191,17 @@ private static string EmitDispatcher(IconDefinition[] defs) private static string EmitFactoryDispatcher(IconDefinition[] defs) { - // Emits `namespace ShellIcons; public static class Icon` with one static - // RenderFragment-returning method per icon. Consumers reach every icon through a - // single `Icon` type — zero flat-namespace pollution (no `@using ShellIcons.Icons` - // needed, so no RZ9985 collision with UI-kit names like Badge/Table/Router). - // - // (Named `Icon` singular, not `Icons` — the plural would collide with the existing - // `ShellIcons.Icons` sub-namespace where the typed components live.) - // - // Trimmer-friendly: each factory method roots exactly its own typed component, so - // only the icons a consumer actually calls survive linking. Contrast with `ShellIcon` - // which uses a runtime Dictionary keyed by name and therefore roots the full catalog. + /* `Icon`, not `Icons`: the plural is taken by the ShellIcons.Icons namespace. + Each method references only its own component, so unused icons still trim. */ var sb = new StringBuilder(); sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine("using Microsoft.AspNetCore.Components;"); - sb.AppendLine("using Microsoft.AspNetCore.Components.Rendering;"); sb.AppendLine(); sb.AppendLine($"namespace {DispatcherNamespace};"); sb.AppendLine(); sb.AppendLine("/// <summary>"); - sb.AppendLine("/// Static factory dispatcher for every icon in the catalog. Each method returns a"); - sb.AppendLine("/// <see cref=\"RenderFragment\"/> that renders the corresponding typed icon component."); - sb.AppendLine("/// <para>"); - sb.AppendLine("/// Use this when your project already has UI-library types that would collide with the"); - sb.AppendLine("/// flat <c>ShellIcons.Icons</c> namespace (Badge, Table, Menu, Progress, Router, …)."); - sb.AppendLine("/// Only <c>Icons</c> enters the caller's tag lookup, and only the icons you reference are"); - sb.AppendLine("/// rooted for trimming."); - sb.AppendLine("/// </para>"); + sb.AppendLine("/// Every icon as a <see cref=\"RenderFragment\"/> — for icons as values (component parameters, lists)."); sb.AppendLine("/// <code>"); sb.AppendLine("/// @using ShellIcons"); sb.AppendLine("/// @Icon.Plus()"); @@ -208,17 +218,7 @@ private static string EmitFactoryDispatcher(IconDefinition[] defs) sb.AppendLine(); sb.AppendLine($" /// <summary>Renders the <c>{d.KebabName}</c> icon (source: {d.SourcePack}).</summary>"); sb.AppendLine($" public static RenderFragment {d.PascalName}(string size = \"24\", double strokeWidth = 2, bool absoluteStroke = false, string? cssClass = null, string? title = null, global::System.Collections.Generic.IReadOnlyDictionary<string, object>? additionalAttributes = null) =>"); - sb.AppendLine(" builder =>"); - sb.AppendLine(" {"); - sb.AppendLine($" builder.OpenComponent<{typeRef}>(0);"); - sb.AppendLine(" builder.AddAttribute(1, \"Size\", size);"); - sb.AppendLine(" builder.AddAttribute(2, \"StrokeWidth\", strokeWidth);"); - sb.AppendLine(" builder.AddAttribute(3, \"AbsoluteStroke\", absoluteStroke);"); - sb.AppendLine(" if (cssClass is not null) builder.AddAttribute(4, \"Class\", cssClass);"); - sb.AppendLine(" if (title is not null) builder.AddAttribute(5, \"Title\", title);"); - sb.AppendLine(" if (additionalAttributes is not null) builder.AddMultipleAttributes(6, additionalAttributes);"); - sb.AppendLine(" builder.CloseComponent();"); - sb.AppendLine(" };"); + sb.AppendLine($" global::ShellIcons.IconFragment.Create<{typeRef}>(size, strokeWidth, absoluteStroke, cssClass, title, additionalAttributes);"); } sb.AppendLine("}"); From aa4a43d45edbde3ae0e1896f95b57880cc06d389 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi <shephard@kernel.co.zw> Date: Sun, 27 Sep 2026 08:44:20 +0200 Subject: [PATCH 7/8] test: expand IconFactoryTests and introduce SuffixedIconTests for comprehensive icon rendering validation --- .../IconFactoryTests.cs | 47 ++++++++-- .../ShellIcons.Blazor.Tests.csproj | 2 +- .../SuffixedIconTests.cs | 87 +++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs diff --git a/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs index 9d606b8..878bba1 100644 --- a/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs +++ b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs @@ -1,11 +1,9 @@ using Bunit; +using Microsoft.AspNetCore.Components; using AngleSharp.Dom; namespace ShellIcons.Blazor.Tests; -// Tests for the static `Icon` factory dispatcher (Icon.Plus(), Icon.Bell(), etc.) -// added for consumers who can't import the flat `ShellIcons.Icons` namespace -// without colliding with UI-library types (Badge, Table, Router, …). public class IconFactoryTests : IDisposable { private readonly TestContext _ctx = new(); @@ -15,7 +13,6 @@ public class IconFactoryTests : IDisposable [Fact] public void Factory_RendersMatchingShape() { - // chevron-right's Lucide path data var cut = _ctx.Render(Icon.ChevronRight()); var path = cut.Find("svg > path"); @@ -47,7 +44,6 @@ public void Factory_TitleSwitchesToImgRole() Assert.Equal("img", svg.GetAttribute("role")); Assert.NotNull(svg.GetAttribute("aria-labelledby")); - // aria-hidden should NOT be set when a title is provided Assert.Null(svg.GetAttribute("aria-hidden")); } @@ -56,7 +52,6 @@ public void Factory_NoTitle_IsDecorative() { var svg = _ctx.Render(Icon.House()).Find("svg"); - // Decorative: hidden from AT, no role/title id Assert.Equal("true", svg.GetAttribute("aria-hidden")); Assert.Null(svg.GetAttribute("role")); } @@ -75,4 +70,44 @@ public void Factory_ForwardsAdditionalAttributes() Assert.Equal("my-icon", svg.GetAttribute("data-testid")); Assert.Equal("color: crimson", svg.GetAttribute("style")); } + + [Fact] + public void Factory_AllDefaults_ReturnsCachedFragment() + { + // No-arg calls must not allocate a new delegate per render. + Assert.Same(Icon.Plus(), Icon.Plus()); + Assert.NotSame(Icon.Plus(), Icon.Minus()); + } + + [Fact] + public void Factory_NonDefaultArgs_ReturnsFreshFragment() + { + Assert.NotSame(Icon.Plus(size: "16"), Icon.Plus(size: "16")); + } + + [Fact] + public void Factory_DefaultArgs_RenderLucideDefaults() + { + var svg = _ctx.Render(Icon.Plus()).Find("svg"); + + Assert.Equal("24", svg.GetAttribute("width")); + Assert.Equal("2", svg.GetAttribute("stroke-width")); + Assert.Null(svg.GetAttribute("class")); + } + + [Fact] + public void Factory_OnClickViaAdditionalAttributes_UsesPlainEventName() + { + // "onclick", not "@onclick": the @ form is Razor markup syntax only. + var clicks = 0; + var extras = new Dictionary<string, object> + { + ["onclick"] = EventCallback.Factory.Create(this, () => clicks++) + }; + + var cut = _ctx.Render(Icon.Bell(additionalAttributes: extras)); + cut.Find("svg").Click(); + + Assert.Equal(1, clicks); + } } diff --git a/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj b/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj index 8f767fa..555c1ae 100644 --- a/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj +++ b/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk.Razor"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> diff --git a/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs b/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs new file mode 100644 index 0000000..7a99256 --- /dev/null +++ b/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs @@ -0,0 +1,87 @@ +using Bunit; +using AngleSharp.Dom; +using CollisionFixture; + +namespace ShellIcons.Blazor.Tests; + +public class SuffixedIconTests : IDisposable +{ + private readonly TestContext _ctx = new(); + + public void Dispose() => _ctx.Dispose(); + + [Fact] + public void Suffixed_RendersSameShapeAsFlatComponent() + { + var path = _ctx.RenderComponent<ChevronRightIcon>().Find("svg > path"); + Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d")); + } + + [Fact] + public void Suffixed_LivesInRootNamespace_AndDerivesFromFlatComponent() + { + Assert.Equal("ShellIcons", typeof(ChevronRightIcon).Namespace); + Assert.Equal(typeof(Icons.ChevronRight), typeof(ChevronRightIcon).BaseType); + } + + [Fact] + public void Suffixed_CoversCatalog_ExceptReservedShell() + { + var suffixed = typeof(IconCore).Assembly.GetTypes() + .Where(t => t.Namespace == "ShellIcons" && t.Name.EndsWith("Icon") && t.IsSubclassOf(typeof(IconCore))) + .Where(t => t != typeof(ShellIcon)) + .ToArray(); + + // `shell` has no alias: ShellIcon is the dispatcher. + Assert.Equal(ShellIcon.Names.Count - 1, suffixed.Length); + Assert.Contains("shell", ShellIcon.Names); + } + + [Fact] + public void ShellIcon_IsStillTheDispatcher() + { + Assert.NotNull(typeof(ShellIcon).GetProperty(nameof(ShellIcon.Name))); + var path = _ctx.RenderComponent<ShellIcon>(p => p.Add(x => x.Name, "chevron-right")).Find("svg > path"); + Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d")); + } + + [Fact] + public void ShellSeashell_ReachableThroughFactory() + { + var svg = _ctx.Render(Icon.Shell()).Find("svg"); + Assert.NotEmpty(svg.Children); + } + + [Fact] + public void RazorFixture_CompilesNextToCollidingUiKit_AndRendersEverything() + { + var cut = _ctx.RenderComponent<CollisionPage>(); + + Assert.Equal("New", cut.Find("span.fake-badge").TextContent); // UI kit's Badge + Assert.NotEmpty(cut.Find("svg.markup-icon").Children); // <BadgeIcon /> + Assert.NotEmpty(cut.Find("svg.router-icon").Children); // <RouterIcon /> + Assert.NotEmpty(cut.Find("svg.factory-icon").Children); // @Icon.Table() + Assert.Equal("16", cut.Find("svg.markup-icon").GetAttribute("width")); + } + + [Fact] + public void RazorFixture_OnClickWithoutAt_ReachesTheSvg() + { + var cut = _ctx.RenderComponent<CollisionPage>(); + + cut.Find("svg.markup-icon").Click(); + cut.Find("svg.markup-icon").Click(); + + Assert.Equal(2, cut.Instance.Clicks); + } + + [Fact] + public void EventDirectiveOnComponent_ThrowsWithGuidance() + { + var ex = Assert.Throws<InvalidOperationException>(() => _ctx.RenderComponent<EventDirectiveMisuse>()); + + Assert.Contains("@onclick", ex.Message); + Assert.Contains("wrapping <button", ex.Message); + Assert.Contains("onclick=\"@(() => Handler())\"", ex.Message); + } +} From eeaed81fd3f7a7415d018a575e53a25d4f578b14 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi <shephard@kernel.co.zw> Date: Sun, 27 Sep 2026 08:45:39 +0200 Subject: [PATCH 8/8] chore: clean up Directory.Build.props by removing commented sections and update .gitignore for local Claude Code configuration --- .gitignore | 3 ++- Directory.Build.props | 9 +-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index f9985d0..9070dda 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,8 @@ x86/ .vs/ .vscode/ .idea/ -.claude/ # local Claude Code launch config, per-machine +# local Claude Code launch config, per-machine +.claude/ *.user *.suo diff --git a/Directory.Build.props b/Directory.Build.props index 5fa7c8e..d1a13bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,5 @@ <Project> - <!-- Language + compilation defaults --> <PropertyGroup> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> @@ -15,7 +14,6 @@ <Version>0.1.0-alpha</Version> </PropertyGroup> - <!-- Package metadata (applies to any project with IsPackable=true) --> <PropertyGroup> <Authors>shellui-dev</Authors> <Company>ShellUI</Company> @@ -28,7 +26,6 @@ <PackageReadmeFile>README.md</PackageReadmeFile> </PropertyGroup> - <!-- Symbols + Source Link — enables source-stepping in consumers' debuggers --> <PropertyGroup> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> @@ -42,15 +39,11 @@ <IsPackable>false</IsPackable> </PropertyGroup> - <!-- Auto-include the repo README + NOTICE in every packable project --> <ItemGroup Condition="'$(IsPackable)' == 'true'"> <None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" /> <None Include="$(MSBuildThisFileDirectory)NOTICE.md" Pack="true" PackagePath="\" /> </ItemGroup> - <!-- Source Link package (GitHub) — added to every packable project --> - <ItemGroup Condition="'$(IsPackable)' == 'true'"> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" /> - </ItemGroup> + <!-- Source Link is built into the .NET 8+ SDK; the SourceLink package pulled in a vulnerable dependency (NU1902). --> </Project>