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
25 changes: 18 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ on:
tags:
- 'v*'

permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
Expand All @@ -16,6 +13,11 @@ jobs:
publish:
runs-on: ubuntu-latest
if: github.event.repository.fork == false
# Must match the environment in the nuget.org Trusted Publishing policy (see docs/RELEASING.md).
environment: release
permissions:
contents: write # GitHub Release
id-token: write # OIDC token exchanged for a short-lived NuGet key

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -74,16 +76,25 @@ jobs:
- name: Pack NuGet packages
run: dotnet pack ShellUI.slnx --no-build --configuration Release -p:ContinuousIntegrationBuild=true

# Trusted Publishing: no stored key; the key this returns expires after an hour.
- name: NuGet login
id: nuget-login
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

# Core and Templates are internal; only the CLI and Components are published.
- name: Publish to NuGet
env:
NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
run: |
dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
if ls src/ShellUI.CLI/bin/Release/*.snupkg 1> /dev/null 2>&1; then
dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
fi
dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
if ls src/ShellUI.Components/bin/Release/*.snupkg 1> /dev/null 2>&1; then
dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
fi

- name: Create GitHub Release
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ ShellUI uses semantic Blazor and Tailwind patterns, but accessibility depends on
- [Quick start](docs/QUICKSTART.md)
- [CLI syntax](docs/CLI_SYNTAX.md)
- [Tailwind setup](docs/tailwind-setup.md)
- [Using ShellUI from a Razor class library](docs/razor-class-library.md)
- [Contributing](docs/CONTRIBUTING.md)
- [Release notes](docs/RELEASE_NOTES.md)

Expand Down
78 changes: 78 additions & 0 deletions ShellUI.Tests/RazorClassLibraryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using ShellUI.CLI.Services;
using Xunit;

namespace ShellUI.Tests;

public class RazorClassLibraryTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIRcl_{Guid.NewGuid():N}");

public RazorClassLibraryTests() => Directory.CreateDirectory(_dir);

public void Dispose() => Directory.Delete(_dir, true);

[Fact]
public void RazorClassLibrary_IsServedUnderItsName()
{
var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor");

Assert.Equal("_content/MyApp.UI", InitService.RazorClassLibraryAssetBase(project));
}

[Theory]
[InlineData("<PackageId>Acme.Ui</PackageId>", "_content/Acme.Ui")]
[InlineData("<AssemblyName>Acme.Components</AssemblyName>", "_content/Acme.Components")]
[InlineData("<StaticWebAssetBasePath>/ui</StaticWebAssetBasePath>", "ui")]
public void RazorClassLibrary_HonoursItsAssetName(string property, string expected)
{
var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor", property);

Assert.Equal(expected, InitService.RazorClassLibraryAssetBase(project));
}

[Fact]
public void WebAppsAreNotLibraries()
{
Assert.Null(InitService.RazorClassLibraryAssetBase(Project("MyApp.Web", "Microsoft.NET.Sdk.Web")));

var razorSdkWithProgram = Project("Hosted", "Microsoft.NET.Sdk.Razor");
File.WriteAllText(Path.Combine(Path.GetDirectoryName(razorSdkWithProgram)!, "Program.cs"), "");
Assert.Null(InitService.RazorClassLibraryAssetBase(razorSdkWithProgram));
}

[Fact]
public void HostSteps_UseTheLibrarysAssetPath()
{
var steps = InitService.RazorClassLibraryHostSteps("_content/MyApp.UI");

Assert.Contains(steps, s => s.Contains("_content/MyApp.UI/app.css") && s.Contains("_content/MyApp.UI/shellui.js"));
Assert.Contains(steps, s => s.Contains("@source"));
Assert.Contains(steps, s => s.Contains("prerender: false"));
Assert.Contains(steps, s => s.Contains(InitService.RazorClassLibraryGuide));
}

[Fact]
public void Guide_ShowsTheThemeScriptInitWrites()
{
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (!File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent!;
var guide = File.ReadAllText(Path.Combine(root.FullName, "docs", "razor-class-library.md")).Replace("\r\n", "\n");
var appRazor = InitService.RewriteAppRazor("<html><head>\n</head><body></body></html>").Replace("\r\n", "\n");

var start = appRazor.IndexOf("// ShellUI theme bootstrap", StringComparison.Ordinal);
var script = appRazor[start..appRazor.IndexOf("</script>", start, StringComparison.Ordinal)];
Assert.Contains(Unindent(script), Unindent(guide));
}

private static string Unindent(string text) =>
string.Join("\n", text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0));

private string Project(string name, string sdk, string properties = "")
{
var dir = Path.Combine(_dir, name);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, name + ".csproj");
File.WriteAllText(path, $@"<Project Sdk=""{sdk}""><PropertyGroup>{properties}</PropertyGroup></Project>");
return path;
}
}
2 changes: 1 addition & 1 deletion VERSIONING_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Release tags use the `v` prefix, for example `v0.3.0-rc.2`. Pushing a `v*` tag r
3. Merge, then on an up-to-date `main` run `pwsh ./prepare-release.ps1 -Version <base> -Suffix <suffix> -DryRun` (builds, tests and checks the notes section).
4. Tag the merged commit and push the tag: `git tag v<version>` then `git push origin v<version>`.

The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging.
The release workflow publishes with NuGet Trusted Publishing; the one-time GitHub and nuget.org setup, and the full release checklist, are in [docs/RELEASING.md](docs/RELEASING.md). The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging.

The verification commands are:

Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing to ShellUI

ShellUI targets .NET 10 and Tailwind CSS `4.3.2`. This guide describes the contribution and verification workflow; releases are covered in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md).
ShellUI targets .NET 10 and Tailwind CSS `4.3.2`. This guide describes the contribution and verification workflow; releases are covered in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md) and [RELEASING.md](RELEASING.md).

## Contribution Policy

Expand Down
4 changes: 4 additions & 0 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ Pass them in `Class`. Components combine their own classes with yours through `S

The button's own `px-4` and `bg-primary` are dropped, so you don't need Tailwind's `!` important modifier. Variants are separate: `hover:bg-primary/90` stays unless you also pass a `hover:bg-…` class. Classes `Cn` doesn't recognise are always kept.

### Can I put ShellUI in a Razor class library shared by several apps?

Yes, for example one library used by a Blazor Web App and a MAUI Blazor Hybrid app. Run `shellui init` in the library; it recognises a Razor class library and prints what each app needs: links to `_content/<Library>/app.css` and `shellui.js`, the theme script, `@source` lines for markup that lives in an app, and `prerender: false` for apps that restore state from browser storage. See [Using ShellUI from a Razor class library](razor-class-library.md).

### Can I use ShellUI in an existing Blazor app?

Yes. Run the CLI from the project root:
Expand Down
70 changes: 70 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Releasing ShellUI

How `ShellUI.CLI` and `ShellUI.Components` get to nuget.org. Pushing a `v*` tag runs `.github/workflows/release.yml`: it checks the version, rebuilds the precompiled CSS bundle, builds, tests, packs, publishes both packages and creates the GitHub release from `docs/RELEASE_NOTES.md`. A tag with a `-` (`v0.4.0-alpha.3`) is published as a prerelease.

## One-time setup: NuGet Trusted Publishing

The workflow doesn't store a NuGet API key. On each run it exchanges a GitHub OIDC token for a key that's valid for one hour ([NuGet docs](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing)). This needs three things, set up once:

1. **GitHub environment.** Repository → Settings → Environments → New environment, named exactly `release`. Nothing to configure inside (add required reviewers if you want a manual approval before each publish).
2. **`NUGET_USER` secret.** Repository → Settings → Secrets and variables → Actions → New repository secret: name `NUGET_USER`, value your **nuget.org profile name** (the name in `nuget.org/profiles/<name>`, not your email).
3. **Trusted Publishing policy on nuget.org.** Sign in → your username → **Trusted Publishing** → **Add**, choose the account that owns the `ShellUI.*` packages, and fill in:
- Repository Owner: `shellui-dev`
- Repository: `shellui`
- Workflow File: `release.yml` (the file name only, no `.github/workflows/`)
- Environment: `release`

Then run one release. When it has published, finish the switch:

4. **Delete the old `NUGET_API_KEY` secret** from the repository, and **revoke that key** on nuget.org (your username → API Keys).

The `release/v0.3` branch has its own copy of `release.yml`, and a tag runs the workflow from the tagged commit. Bring the same change to `release/v0.3` before deleting the old key, or the next 0.3.x tag can't publish.

If a run fails at **NuGet login**, check in this order: the `NUGET_USER` secret is missing or wrong; the policy's Workflow File has a path in it; the policy's Environment doesn't match `environment: release`.

## Cutting a release

The version rules and the `prepare-release.ps1` dry run are in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md#release-workflow). In short:

### 1. Version

`Directory.Build.props` sets the version for every project:

```xml
<ShellUIVersion>0.4.0</ShellUIVersion>
<ShellUIVersionSuffix>alpha.3</ShellUIVersionSuffix> <!-- empty for a stable release -->
```

Also update the version mentioned in `README.md`, `VERSIONING_STRATEGY.md`, `docs/CLI_INSTALLATION.md`, `docs/CLI_SYNTAX.md`, `docs/FAQ.md`, `docs/QUICKSTART.md`, and the fallback in `src/ShellUI.Templates/VersionHelper.cs` and `src/ShellUI.Core/Models/ComponentMetadata.cs`. `git grep` the previous version to find them all.

### 2. Release notes

Add a `# ShellUI v0.4.0-alpha.3` section at the top of `docs/RELEASE_NOTES.md`: what's new, fixes, breaking changes, and how to upgrade. Each section describes only its own release. Check that the workflow will find it:

```bash
bash scripts/extract-release-notes.sh 0.4.0-alpha.3
```

### 3. Check

- If component CSS changed, regenerate the safelist (`dotnet run --project tools/ShellUI.SafelistGenerator -- …`, full command in VERSIONING_STRATEGY.md).
- `pwsh ./prepare-release.ps1 -Version 0.4.0 -Suffix alpha.3 -DryRun` builds, tests and checks the notes section.
- Pack both projects to a local folder, install the CLI from it into a tool path, and run `shellui init` and `shellui add` in fresh apps on .NET 8, 9 and 10 (all direct targets together, app named `my-app`).
- Use the packed `ShellUI.Components` in a fresh app and click through the components the release changed, in light and dark mode.
- Delete the local test packages from `~/.nuget/packages` afterwards, so later restores fetch the published ones.

### 4. Merge and tag

Open a `chore(release): v0.4.0-alpha.3` pull request, merge it, then tag the merge commit:

```bash
git switch main && git pull --ff-only
git tag v0.4.0-alpha.3
git push origin v0.4.0-alpha.3
```

Watch the **Release** workflow in the Actions tab. If it fails before **Publish to NuGet**, fix it, delete the tag (`git push --delete origin v0.4.0-alpha.3`, `git tag -d v0.4.0-alpha.3`) and tag again. Once a version is on nuget.org it can't be reused, only unlisted.

## 0.3.x patches

0.3 lives on `release/v0.3`, which is never merged into `main`. For a patch: branch `fix/v0.3.N` off `release/v0.3`, cherry-pick the fixes from `main`, bump the version and add a `# ShellUI v0.3.N` section to that branch's release notes, open the pull request **into `release/v0.3`**, and tag the merge commit there. The same workflow publishes it as a stable release.
109 changes: 109 additions & 0 deletions docs/razor-class-library.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Using ShellUI from a Razor class library

Put ShellUI components in a Razor class library when several apps share one UI: for example a Blazor Web App and a .NET MAUI Blazor Hybrid app with the same pages. The library owns the components, the theme and the generated CSS; each app references the library and links its assets.

```text
MyApp.UI/ Razor class library: ShellUI components, pages, wwwroot/input.css and app.css
MyApp.Web/ Blazor Web App → references MyApp.UI
MyApp.Mobile/ MAUI Blazor Hybrid → references MyApp.UI
```

## 1. Initialise ShellUI in the library

Run the CLI in the library's folder, the one with its `.csproj`:

```bash
cd MyApp.UI
shellui init --yes --tailwind standalone
shellui add button card dialog
```

`init` recognises a Razor class library (`Microsoft.NET.Sdk.Razor` with no `Program.cs`) and prints the steps below for each app. Components go into `MyApp.UI/Components/UI`, and the library's build compiles `wwwroot/input.css` into `wwwroot/app.css`.

The apps reference the library as usual:

```xml
<ProjectReference Include="..\MyApp.UI\MyApp.UI.csproj" />
```

The library's `wwwroot` is served to each app under `_content/<LibraryName>/`, where the name is the library's `PackageId` or assembly name (`MyApp.UI` here), unless it sets `StaticWebAssetBasePath`. The examples below use `_content/MyApp.UI`.

## 2. Link the CSS and JavaScript in each app

Link the library's files from each app's host page, and load `shellui.js` **before** the Blazor script. Don't copy `app.css` into the app's own `wwwroot`: the app would keep a stale copy when the library's components change.

**Blazor Web App** (`MyApp.Web/Components/App.razor`):

```razor
<head>
...
<link rel="stylesheet" href="_content/MyApp.UI/app.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_content/MyApp.UI/shellui.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
```

**MAUI Blazor Hybrid** (`MyApp.Mobile/wwwroot/index.html`):

```html
<head>
...
<link rel="stylesheet" href="_content/MyApp.UI/app.css" />
</head>
<body>
<div id="app">Loading...</div>
<script src="_content/MyApp.UI/shellui.js"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
```

## 3. Add the theme script to each app

`ThemeToggle` stores the choice in `localStorage` and sets the `dark` class on `<html>`, but only after the first render. In a normal app, `init` puts this script in the host page's `<head>` so the right theme applies before the first paint. A library has no host page, so add it to each app yourself:

```html
<script>
// ShellUI theme bootstrap — runs before Blazor mounts to avoid a light-flash on dark pages.
if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'light'); }
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
</script>
```

Without it, a user who picked dark mode sees a flash of the light theme on every load, and the toggle starts out of step.

## 4. Classes used in an app's own markup

Tailwind generates CSS only for classes it finds in the files it scans, and the library's build scans the library. Pages and components that live in the library are covered. If an app has its own markup (a page or layout only one app has), point Tailwind at it from the library's `wwwroot/input.css`. Paths are relative to `input.css`:

```css
@import "tailwindcss";
@source "../../MyApp.Web/Components";
@source "../../MyApp.Mobile/Components";
```

Rebuild the library after adding a `@source` line.

## 5. Apps that keep state in browser storage

Apps that read `ProtectedLocalStorage`, `ProtectedSessionStorage` or `localStorage` during startup (for example to restore a signed-in session) can't do it while prerendering, because the page renders on the server before the browser is connected. Render those apps without prerendering:

```razor
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
```

MAUI Blazor Hybrid apps don't prerender, so this applies only to the web app.

## Checklist

- [ ] `shellui init` ran in the library, and components are in `MyApp.UI/Components/UI`
- [ ] Each app references the library and links `_content/<LibraryName>/app.css` and `shellui.js` (before the Blazor script)
- [ ] Each app's host page has the theme script in `<head>`
- [ ] `@source` lines for markup that lives in an app rather than the library
- [ ] Web apps that restore state from browser storage use `prerender: false`
- [ ] `dotnet build` the library after changing components or `input.css`, then run each app and toggle dark mode
Loading
Loading