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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/init.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<b>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
Expand All @@ -49,16 +51,17 @@ jobs:
git worktree add --detach ../holds-name
cd ../holds-name
dotnet run scripts/init.cs --name Acme.FullstackTemplate
grep -q '<title>Acme.FullstackTemplate</title>' 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

Expand All @@ -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 '(?<!pdevito3/)fullstacktemplate' -- ':!scripts/init.cs'; then exit 1; fi
if git grep -inP '(?<!pdevito3/)fullstack|full[ ._-]stack[ ._-]?template' -- ':!scripts/init.cs'; then exit 1; fi
grep -q '<title>Acme Shop</title>' frontend/index.html
git grep -q 'github.com/pdevito3/FullstackTemplate/issues' -- docs
if git grep -n 'init.cs' -- README.md; then exit 1; fi

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://openid.net/certification/> to submit them.

Use a separate deployment for the test run. The suite is the identity provider of that deployment while the test runs.
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -75,11 +83,10 @@ export function AppSidebar() {
<SidebarMenuItem>
<SidebarMenuButton size="lg" href="/" onPress={closeMobile}>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<span className="text-xs font-bold">FS</span>
<span className="text-xs font-bold">{appInitials}</span>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Fullstack</span>
<span className="truncate text-xs text-muted-foreground">Template</span>
<span className="truncate font-semibold">{appName}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
Expand Down
48 changes: 34 additions & 14 deletions scripts/init.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
using System.Text.RegularExpressions;

const string Usage = """
Usage: dotnet run scripts/init.cs [--name <Name>] [--auth-provider <Provider>]
Usage: dotnet run scripts/init.cs [--name <Name>] [--display-name <DisplayName>] [--auth-provider <Provider>]

--name <Name> Rename FullstackTemplate to <Name> in file names, folder names, and file contents.
<Name> is a C# namespace, for example Acme.Shop.
--display-name <DisplayName>
Rename the display name "Fullstack Template", such as the page title, to
<DisplayName>. The default is <Name>. Use with --name, for example "Acme Shop".
--auth-provider <Provider> Set the default AuthProvider of the AppHost. <Provider> is the name of a folder
in <Name>.AppHost/AuthProviders/, for example Keycloak.

Expand All @@ -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++)
{
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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)
Expand All @@ -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!);

Expand Down Expand Up @@ -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)
Expand All @@ -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)))
{
Expand All @@ -170,32 +185,32 @@ 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++;
}

// Rename the deepest folders first, so that the path of each folder that is still to move stays the same
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)
Expand All @@ -216,10 +231,12 @@ private List<string> 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('.', '_')}_",
});

Expand Down Expand Up @@ -278,7 +295,7 @@ private string Git(params string[] arguments)
return output;
}

[GeneratedRegex(@"Projects\.FullstackTemplate_|(?<!pdevito3/)FullstackTemplate|(?<!pdevito3/)fullstacktemplate")]
[GeneratedRegex(@"Projects\.FullstackTemplate_|(?<!pdevito3/)FullstackTemplate|(?<!pdevito3/)fullstacktemplate|fullstack-template|Fullstack Template")]
private static partial Regex NameFormsPattern();

// The comment can be <!-- -->, //, or #, so that a section works in Markdown, C#, YAML, and .gitignore
Expand All @@ -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("(?<prefix>\"AuthProvider\"\\s*:\\s*\")(?<value>[^\"]*)\"")]
private static partial Regex SettingPattern();
}
Expand Down
Loading