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 4d89f5fe..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 @@ -81,7 +84,8 @@ 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 '(?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..cd45bb2c 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 @@ -289,9 +306,12 @@ 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")] + private static partial Regex DisplayNamePattern(); + [GeneratedRegex("(?\"AuthProvider\"\\s*:\\s*\")(?[^\"]*)\"")] private static partial Regex SettingPattern(); }