From 8f4b24fd6f5a2afdae18e41e6aab947add05e9c1 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 11:14:08 +0200 Subject: [PATCH 1/3] test(init): fail when any form of the template name is left after init The init job looked only for FullstackTemplate in any case. It missed the spaced title "Fullstack Template", the kebab form "fullstack-template", and the sidebar, which splits the name over two elements. This check fails until the init script renames them. --- .github/workflows/init.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 4d89f5fe..10f84988 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -81,7 +81,7 @@ jobs: # The links to the upstream repo keep the template name, and the README loses its init section - name: Fail if the template name is left or an upstream link changed run: | - if git grep -inP '(? Date: Sun, 25 Oct 2026 11:15:48 +0200 Subject: [PATCH 2/3] fix(init): rename the display name and the kebab-case name of the template An app made with --name Fleet showed "Fullstack Template" in the browser tab and "FS Fullstack Template" in the sidebar, because the script renamed only FullstackTemplate and fullstacktemplate. The script now has a --display-name option. Its default is the --name value. It replaces "Fullstack Template" with the display name, and "fullstack-template" with the name in kebab case, for example acme-shop. The sidebar shows the display name in one string and takes its initials from it, so that the rename reaches it. Closes #91 --- .../features/weather-forecast.md | 2 +- .github/workflows/init.yml | 8 +++- README.md | 3 +- docs/authentication.md | 2 +- frontend/src/components/app-sidebar.tsx | 13 ++++-- scripts/init.cs | 46 +++++++++++++------ 6 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.agents/skills/verify-fullstack-template/features/weather-forecast.md b/.agents/skills/verify-fullstack-template/features/weather-forecast.md index 1ee93dcd..2398aaca 100644 --- a/.agents/skills/verify-fullstack-template/features/weather-forecast.md +++ b/.agents/skills/verify-fullstack-template/features/weather-forecast.md @@ -12,7 +12,7 @@ The home page shows a forecast of five days from the public weather API. Each ca ## How to get to it (user POV) - Open the app root `/`. -- Choose `Home` in the sidebar, or the `FS Fullstack Template` logo link. +- Choose `Home` in the sidebar, or the logo link with the app name. ## Driving it with browse.mjs diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 10f84988..470a4afc 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -38,6 +38,8 @@ jobs: if dotnet run scripts/init.cs --name 1Bad; then exit 1; fi if dotnet run scripts/init.cs --auth-provider Nope; then exit 1; fi if dotnet run scripts/init.cs --auth-provider Conformance; then exit 1; fi + if dotnet run scripts/init.cs --name Acme.Shop --display-name 'Shop'; then exit 1; fi + if dotnet run scripts/init.cs --display-name 'Acme Shop'; then exit 1; fi echo "change" >> README.md if dotnet run scripts/init.cs --name Acme.Shop; then exit 1; fi git checkout README.md @@ -49,16 +51,17 @@ jobs: git worktree add --detach ../holds-name cd ../holds-name dotnet run scripts/init.cs --name Acme.FullstackTemplate + grep -q 'Acme.FullstackTemplate' frontend/index.html dotnet build Acme.FullstackTemplate.AppHost/Acme.FullstackTemplate.AppHost.csproj # The rename uses a dotted name, because Aspire turns the dots of a project name into underscores - name: Run the init script - run: dotnet run scripts/init.cs --name Acme.Shop --auth-provider Keycloak + run: dotnet run scripts/init.cs --name Acme.Shop --display-name 'Acme Shop' --auth-provider Keycloak - name: Fail if a second run changes a file or a second rename runs run: | git add --all - dotnet run scripts/init.cs --name Acme.Shop --auth-provider Keycloak + dotnet run scripts/init.cs --name Acme.Shop --display-name 'Acme Shop' --auth-provider Keycloak git diff --exit-code --stat if dotnet run scripts/init.cs --name Other.App; then exit 1; fi @@ -82,6 +85,7 @@ jobs: - name: Fail if the template name is left or an upstream link changed run: | if git grep -inP '(?Acme Shop' frontend/index.html git grep -q 'github.com/pdevito3/FullstackTemplate/issues' -- docs if git grep -n 'init.cs' -- README.md; then exit 1; fi diff --git a/README.md b/README.md index d78addb8..4cd08ca1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Fullstack Aspire React Template +# Fullstack Template A production-ready .NET Aspire template featuring: - **React 19** frontend with TanStack Router and React Query @@ -27,6 +27,7 @@ All auth providers stay in the app, and the `AuthProvider` setting selects one a | Option | Description | |--------|-------------| | `--name` | Renames `FullstackTemplate` to this name in file names, folder names, and file contents. Use a C# namespace, for example `Acme.Shop`. | +| `--display-name` | Renames the display name `Fullstack Template`, such as the page title and the sidebar, to this name. The default is the `--name` value. Use with `--name`, for example `--display-name "Acme Shop"`. | | `--auth-provider` | Sets the default `AuthProvider` of the AppHost. The value is the name of a folder in `FullstackTemplate.AppHost/AuthProviders/`. | See [ADR 0007](docs/adr/0007-regular-repo-instead-of-template.md) for the reasons. diff --git a/docs/authentication.md b/docs/authentication.md index 03ea0f70..98240928 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -376,7 +376,7 @@ The OpenID Foundation certifies a deployment, not this repository. To certify yo - `Auth__UseAudienceAsScope`: `false` - `Auth__MetadataRefreshInterval`: `00:00:01`, because each test module has new signing keys 6. For each test module, start the module, then restart the BFF. The suite gives the discovery document only while a module runs, and the BFF reads it at startup. -7. Do the login, or the login and the logout, in a browser. [`run-conformance.ts`](https://github.com/pdevito3/FullstackTemplate/blob/main/frontend/conformance/run-conformance.ts) of the FullstackTemplate repository shows the steps for each plan. +7. Do the login, or the login and the logout, in a browser. [`run-conformance.ts`](https://github.com/pdevito3/FullstackTemplate/blob/main/frontend/conformance/run-conformance.ts) of the pdevito3/FullstackTemplate repository shows the steps for each plan. 8. Export the results of each plan. Follow the instructions at to submit them. Use a separate deployment for the test run. The suite is the identity provider of that deployment while the test runs. diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index befbc2ee..5c942253 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -32,6 +32,14 @@ import { sidebarMenuButtonVariants } from "@/components/ui/sidebar-menu-button-v import { useSidebar } from "@/components/ui/use-sidebar" import { ThemeToggle } from "@/components/theme-toggle" +const appName = "Fullstack Template" +const appInitials = appName + .split(/[ .-]/) + .map((word) => word[0]) + .join("") + .slice(0, 2) + .toUpperCase() + const navItems = [ { title: "Home", @@ -75,11 +83,10 @@ export function AppSidebar() {
- FS + {appInitials}
- Fullstack - Template + {appName}
diff --git a/scripts/init.cs b/scripts/init.cs index c8748130..62159dbe 100644 --- a/scripts/init.cs +++ b/scripts/init.cs @@ -9,10 +9,13 @@ using System.Text.RegularExpressions; const string Usage = """ - Usage: dotnet run scripts/init.cs [--name ] [--auth-provider ] + Usage: dotnet run scripts/init.cs [--name ] [--display-name ] [--auth-provider ] --name Rename FullstackTemplate to in file names, folder names, and file contents. is a C# namespace, for example Acme.Shop. + --display-name + Rename the display name "Fullstack Template", such as the page title, to + . The default is . Use with --name, for example "Acme Shop". --auth-provider Set the default AuthProvider of the AppHost. is the name of a folder in .AppHost/AuthProviders/, for example Keycloak. @@ -23,6 +26,7 @@ conformance suite with its provider and the userinfo check of the server. """; string? name = null; +string? displayName = null; string? authProvider = null; for (var i = 0; i < args.Length; i++) { @@ -31,6 +35,9 @@ conformance suite with its provider and the userinfo check of the server. case "--name" when i + 1 < args.Length: name = args[++i]; break; + case "--display-name" when i + 1 < args.Length: + displayName = args[++i]; + break; case "--auth-provider" when i + 1 < args.Length: authProvider = args[++i]; break; @@ -41,6 +48,12 @@ conformance suite with its provider and the userinfo check of the server. } } +if (displayName is not null && name is null) +{ + Console.Error.WriteLine("--display-name renames the template, so it needs --name."); + return 1; +} + if (name is null && authProvider is null) { Console.Error.WriteLine(Usage); @@ -56,7 +69,7 @@ conformance suite with its provider and the userinfo check of the server. // Check the arguments first. Then require a clean tree only if a change is due, so that a second run with the // same arguments passes and changes nothing. - var renameDue = name is not null && app.RenameIsDue(name); + var renameDue = name is not null && app.RenameIsDue(name, displayName ?? name); var provider = authProvider is null ? null : app.KnownProvider(authProvider); var providerDue = provider is not null && provider != app.AuthProvider; if (renameDue || providerDue) @@ -65,7 +78,7 @@ conformance suite with its provider and the userinfo check of the server. changing = true; } if (renameDue) - app.Rename(name!); + app.Rename(name!, displayName ?? name!); if (providerDue) app.SetAuthProvider(provider!); @@ -127,10 +140,12 @@ internal sealed partial class AppRepo(string root) private string AuthProvidersPath => Path.Combine(root, $"{Name}.AppHost", "AuthProviders"); private string AppSettingsPath => Path.Combine(root, $"{Name}.AppHost", "appsettings.json"); - public bool RenameIsDue(string newName) + public bool RenameIsDue(string newName, string displayName) { if (!NamePattern().IsMatch(newName)) throw new InitException($"'{newName}' is not a C# namespace. Use letters and digits, with dots between the parts."); + if (!DisplayNamePattern().IsMatch(displayName)) + throw new InitException($"'{displayName}' is not a display name. Use words of letters and digits, with one space, dot, hyphen, or apostrophe between them."); if (Name == newName) return false; if (Name != TemplateName) @@ -155,7 +170,7 @@ public void FailIfTreeHasChanges() throw new InitException("The git tree has changes. Commit or stash them, then run the script again."); } - public void Rename(string newName) + public void Rename(string newName, string displayName) { foreach (var path in RepoOnlyPaths.Select(path => Path.Combine(root, path))) { @@ -170,7 +185,7 @@ public void Rename(string newName) var changed = 0; foreach (var file in files) { - if (ReplaceInFile(Path.Combine(root, file), text => RemoveRepoOnlyText(ReplaceName(text, newName)).Replace(TemplateUserSecretsId, userSecretsId, StringComparison.Ordinal))) + if (ReplaceInFile(Path.Combine(root, file), text => RemoveRepoOnlyText(ReplaceName(text, newName, displayName)).Replace(TemplateUserSecretsId, userSecretsId, StringComparison.Ordinal))) changed++; } @@ -178,24 +193,24 @@ public void Rename(string newName) var folders = files .SelectMany(Ancestors) .Distinct() - .Where(folder => ReplaceName(Path.GetFileName(folder), newName) != Path.GetFileName(folder)) + .Where(folder => ReplaceName(Path.GetFileName(folder), newName, displayName) != Path.GetFileName(folder)) .OrderByDescending(folder => folder.Count(c => c == '/')); foreach (var folder in folders) { var parent = Path.GetDirectoryName(folder) ?? ""; - Directory.Move(Path.Combine(root, folder), Path.Combine(root, parent, ReplaceName(Path.GetFileName(folder), newName))); + Directory.Move(Path.Combine(root, folder), Path.Combine(root, parent, ReplaceName(Path.GetFileName(folder), newName, displayName))); } foreach (var file in files) { - var folder = ReplaceName(Path.GetDirectoryName(file) ?? "", newName); + var folder = ReplaceName(Path.GetDirectoryName(file) ?? "", newName, displayName); var source = Path.Combine(root, folder, Path.GetFileName(file)); - var target = Path.Combine(root, folder, ReplaceName(Path.GetFileName(file), newName)); + var target = Path.Combine(root, folder, ReplaceName(Path.GetFileName(file), newName, displayName)); if (source != target && File.Exists(source)) File.Move(source, target); } - Console.WriteLine($"Renamed {TemplateName} to {newName} in {changed} files."); + Console.WriteLine($"Renamed {TemplateName} to {newName}, with the display name {displayName}, in {changed} files."); } public void SetAuthProvider(string provider) @@ -216,10 +231,12 @@ private List Providers() => Directory.GetDirectories(AuthProvidersPath) // One pass, so that a new name that holds the template name is not replaced again. // Aspire names the project types with underscores, for example Projects.Acme_Shop_Server. // The links to the upstream repo, github.com/pdevito3/FullstackTemplate, keep the template name. - private static string ReplaceName(string text, string newName) => NameFormsPattern().Replace(text, match => match.Value switch + private static string ReplaceName(string text, string newName, string displayName) => NameFormsPattern().Replace(text, match => match.Value switch { TemplateName => newName, "fullstacktemplate" => newName.ToLowerInvariant(), + "fullstack-template" => newName.ToLowerInvariant().Replace('.', '-'), + "Fullstack Template" => displayName, _ => $"Projects.{newName.Replace('.', '_')}_", }); @@ -278,7 +295,7 @@ private string Git(params string[] arguments) return output; } - [GeneratedRegex(@"Projects\.FullstackTemplate_|(?, //, or #, so that a section works in Markdown, C#, YAML, and .gitignore @@ -292,6 +309,9 @@ private string Git(params string[] arguments) [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)*$")] private static partial Regex NamePattern(); + [GeneratedRegex(@"^[\p{L}\p{M}\p{N}]+([ .'-][\p{L}\p{M}\p{N}]+)*\z")] + private static partial Regex DisplayNamePattern(); + [GeneratedRegex("(?\"AuthProvider\"\\s*:\\s*\")(?[^\"]*)\"")] private static partial Regex SettingPattern(); } From a3d3426a25cf631dd5a9261e89965eccfb62aeb3 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 11:50:20 +0200 Subject: [PATCH 3/3] fix(init): reject a name that ends in a line break --- scripts/init.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/init.cs b/scripts/init.cs index 62159dbe..cd45bb2c 100644 --- a/scripts/init.cs +++ b/scripts/init.cs @@ -306,7 +306,7 @@ private string Git(params string[] arguments) [GeneratedRegex(@"^[ \t]*""(?:test:conformance|conformance:down)"":[^\n]*\n", RegexOptions.Multiline)] private static partial Regex RepoOnlyScriptPattern(); - [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)*$")] + [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)*\z")] private static partial Regex NamePattern(); [GeneratedRegex(@"^[\p{L}\p{M}\p{N}]+([ .'-][\p{L}\p{M}\p{N}]+)*\z")]