Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ jobs:
# And the DataTable models file must land at Components/UI/Models/, not be missing.
test -f Components/UI/Models/DataTableModels.cs || (echo "shellui add data-table did not install data-table-models"; exit 1)

# chart-styles ships the CSS for the custom tooltip + ApexCharts chrome.
# Without it, hovering a chart shows invisible white-on-white text.
test -f wwwroot/css/charts.css || (echo "shellui add chart did not install chart-styles CSS"; exit 1)
grep -q '<link href="css/charts.css"' Components/App.razor || (echo "shellui add chart did not link charts.css in App.razor"; exit 1)

dotnet build -c Debug

- name: Upload build artifacts
Expand Down
118 changes: 118 additions & 0 deletions ShellUI.Tests/ChartStylesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using ShellUI.CLI.Services;
using ShellUI.Templates;
using Xunit;

namespace ShellUI.Tests;

public class ChartStylesRegistryTests
{
[Fact]
public void ChartStyles_IsRegisteredAndHidden()
{
var metadata = ComponentRegistry.GetMetadata("chart-styles");
Assert.NotNull(metadata);
Assert.False(metadata!.IsAvailable, "chart-styles is a CSS asset bundled with chart, not a standalone install target");
}

[Fact]
public void Chart_DependsOnChartStyles()
{
var metadata = ComponentRegistry.GetMetadata("chart");
Assert.NotNull(metadata);
Assert.Contains("chart-styles", metadata!.Dependencies);
}

[Fact]
public void ChartStyles_FilePathTargetsWwwroot()
{
var metadata = ComponentRegistry.GetMetadata("chart-styles");
Assert.NotNull(metadata);
// The `../../wwwroot/` prefix walks out of Components/UI/ to project root,
// matching the shellui-js convention. Without it the file lands inside the
// component tree and is never served.
Assert.StartsWith("../../wwwroot/", metadata!.FilePath);
Assert.EndsWith(".css", metadata.FilePath);
}

[Fact]
public void ChartStyles_ContentCoversCustomTooltipAndApexClasses()
{
var content = ComponentRegistry.GetComponentContent("chart-styles");
Assert.NotNull(content);
// Custom tooltip emitted by ChartVariants' Custom HTML — these were invisible
// before this branch because the CSS template existed but was never installed.
Assert.Contains(".custom-tooltip", content);
Assert.Contains(".custom-tooltip-title", content);
Assert.Contains(".custom-tooltip-item", content);
Assert.Contains(".custom-tooltip-marker", content);
Assert.Contains(".custom-tooltip-label", content);
Assert.Contains(".custom-tooltip-value", content);
// ApexCharts built-in classes also styled so charts without the custom HTML still look right.
Assert.Contains(".apexcharts-tooltip", content);
Assert.Contains(".apexcharts-legend", content);
Assert.Contains(".apexcharts-xaxis-label", content);
// Theme-aware: values come from the CSS variables the init script ships.
Assert.Contains("var(--popover", content);
Assert.Contains("var(--foreground", content);
Assert.Contains("var(--border", content);
}
}

public class StylesheetInjectionTests
{
[Fact]
public void ResolveHostStylesheetHref_StripsWwwrootPrefix()
{
Assert.Equal("css/charts.css", ComponentInstaller.ResolveHostStylesheetHref("../../wwwroot/css/charts.css"));
}

[Fact]
public void ResolveHostStylesheetHref_IgnoresNonWwwrootFilePaths()
{
Assert.Null(ComponentInstaller.ResolveHostStylesheetHref("Button.razor"));
Assert.Null(ComponentInstaller.ResolveHostStylesheetHref("Variants/ButtonVariants.cs"));
}

[Fact]
public void ResolveHostStylesheetHref_IgnoresNonCssAssets()
{
// shellui.js also uses ../../wwwroot/ but is a script, not a stylesheet.
Assert.Null(ComponentInstaller.ResolveHostStylesheetHref("../../wwwroot/shellui.js"));
}

[Fact]
public void InjectStylesheetLink_AddsLinkBeforeHeadClose()
{
const string app = "<html><head><HeadOutlet /></head><body></body></html>";

var result = InitService.InjectStylesheetLink(app, "css/charts.css");

Assert.Contains("<link href=\"css/charts.css\" rel=\"stylesheet\" />", result);
var linkIdx = result.IndexOf("<link href=\"css/charts.css\"");
var headCloseIdx = result.IndexOf("</head>");
Assert.True(linkIdx > 0 && linkIdx < headCloseIdx, "link tag must sit inside <head>");
}

[Fact]
public void InjectStylesheetLink_IsIdempotent()
{
const string app = "<html><head><HeadOutlet /></head><body></body></html>";

var once = InitService.InjectStylesheetLink(app, "css/charts.css");
var twice = InitService.InjectStylesheetLink(once, "css/charts.css");

Assert.Equal(once, twice);
}

[Fact]
public void InjectStylesheetLink_SupportsMultipleDifferentHrefs()
{
const string app = "<html><head><HeadOutlet /></head><body></body></html>";

var result = InitService.InjectStylesheetLink(app, "css/charts.css");
result = InitService.InjectStylesheetLink(result, "css/another.css");

Assert.Contains("css/charts.css", result);
Assert.Contains("css/another.css", result);
}
}
26 changes: 26 additions & 0 deletions src/ShellUI.CLI/Services/ComponentInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ await AnsiConsole.Status()
// (so `dotnet add package` doesn't restore between every component).
await InstallNuGetDependenciesAsync(projectInfo, pendingNuGetDeps);

// Wire any installed wwwroot/ stylesheets into the host so the user doesn't
// have to add <link> tags by hand. Detected via FilePath, which uses the
// `../../wwwroot/` traversal trick that asset templates already follow.
foreach (var name in installedSet)
{
var metadata = ComponentRegistry.GetMetadata(name);
if (metadata == null) continue;
var href = ResolveHostStylesheetHref(metadata.FilePath);
if (href != null)
{
await InitService.InjectStylesheetIntoHostAsync(href);
}
}

// Update config
var updatedJson = JsonSerializer.Serialize(config, new JsonSerializerOptions
{
Expand Down Expand Up @@ -327,6 +341,18 @@ private static async Task InstallNuGetDependenciesAsync(ProjectInfo projectInfo,
}
}

// Returns the href that should appear in the host's <link> tag, or null if the
// component's FilePath isn't a CSS asset under wwwroot/. Strips the `../../wwwroot/`
// prefix that asset templates use to escape Components/UI/.
internal static string? ResolveHostStylesheetHref(string filePath)
{
if (!filePath.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) return null;
var normalized = filePath.Replace('\\', '/');
const string prefix = "../../wwwroot/";
if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) return null;
return normalized.Substring(prefix.Length);
}

private enum InstallResult
{
Success,
Expand Down
44 changes: 44 additions & 0 deletions src/ShellUI.CLI/Services/InitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,50 @@ internal static string RewriteWasmIndexHtml(string content)
return content;
}

// Idempotent injection of a stylesheet <link> before </head>. Used by post-install
// hooks when a component ships a CSS asset (e.g. chart-styles → css/charts.css).
internal static string InjectStylesheetLink(string content, string href)
{
var linkTag = $"<link href=\"{href}\" rel=\"stylesheet\" />";
if (content.Contains($"href=\"{href}\""))
{
return content;
}
return Regex.Replace(content, @"</head>", $" {linkTag}\n</head>", RegexOptions.IgnoreCase);
}

// Used by ComponentInstaller after `shellui add` so newly-installed CSS assets
// are wired into the host without requiring the user to edit App.razor by hand.
internal static async Task InjectStylesheetIntoHostAsync(string href)
{
var cwd = Directory.GetCurrentDirectory();

var appRazor = Path.Combine(cwd, "Components", "App.razor");
if (File.Exists(appRazor))
{
var original = await File.ReadAllTextAsync(appRazor);
var patched = InjectStylesheetLink(original, href);
if (patched != original)
{
await File.WriteAllTextAsync(appRazor, patched);
AnsiConsole.MarkupLine($"[green]Linked:[/] Components/App.razor → {href}");
}
return;
}

var indexHtml = Path.Combine(cwd, "wwwroot", "index.html");
if (File.Exists(indexHtml))
{
var original = await File.ReadAllTextAsync(indexHtml);
var patched = InjectStylesheetLink(original, href);
if (patched != original)
{
await File.WriteAllTextAsync(indexHtml, patched);
AnsiConsole.MarkupLine($"[green]Linked:[/] wwwroot/index.html → {href}");
}
}
}

private static void RemoveBootstrapFiles()
{
try
Expand Down
2 changes: 2 additions & 0 deletions src/ShellUI.Templates/ComponentRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ public static class ComponentRegistry
{ "carousel-dots", CarouselDotsTemplate.Metadata },
{ "file-upload", FileUploadTemplate.Metadata },
{ "chart-variants", ChartVariantsTemplate.Metadata },
{ "chart-styles", ChartStylesTemplate.Metadata },
{ "chart", ChartTemplate.Metadata },
{ "bar-chart", BarChartTemplate.Metadata },
{ "line-chart", LineChartTemplate.Metadata },
Expand Down Expand Up @@ -331,6 +332,7 @@ public static class ComponentRegistry
"carousel-dots" => CarouselDotsTemplate.Content,
"file-upload" => FileUploadTemplate.Content,
"chart-variants" => ChartVariantsTemplate.Content,
"chart-styles" => ChartStylesTemplate.Content,
"chart" => ChartTemplate.Content,
"bar-chart" => BarChartTemplate.Content,
"line-chart" => LineChartTemplate.Content,
Expand Down
4 changes: 3 additions & 1 deletion src/ShellUI.Templates/Templates/ChartStylesTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ public class ChartStylesTemplate
DisplayName = "Chart Styles",
Description = "CSS styles for ApexCharts integration with ShellUI theme system",
Category = ComponentCategory.DataDisplay,
FilePath = "wwwroot/css/charts.css",
// FilePath walks up out of Components/UI/ to project root then into wwwroot/css/,
// matching the trick shellui-js uses for its own asset path.
FilePath = "../../wwwroot/css/charts.css",
IsAvailable = false,
Dependencies = new List<string>(),
Variants = new List<string>(),
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Templates/Templates/ChartTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class ChartTemplate
Description = "Base chart component with ShellUI theming and ApexCharts integration",
Category = ComponentCategory.DataDisplay,
FilePath = "Chart.razor",
Dependencies = new List<string> { "chart-variants" },
Dependencies = new List<string> { "chart-variants", "chart-styles" },
NuGetDependencies = new List<NuGetDependency>
{
new() { PackageId = "Blazor-ApexCharts", Version = "6.0.2" }
Expand Down
Loading