diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 00000000..6303b707 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Installs the .NET 10 SDK so dotnet build/restore/test work in this session. +# Idempotent: re-running is cheap once the SDK is already on disk. +set -euo pipefail + +# Only run in the Claude Code on the web remote container. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}" +INSTALL_SCRIPT="$HOME/.dotnet-install.sh" +# Channel "10.0" tracks the latest .NET 10 SDK, which Umbraco 18 requires. +CHANNEL="10.0" + +mkdir -p "$DOTNET_ROOT" + +if [ ! -x "$DOTNET_ROOT/dotnet" ]; then + if [ ! -f "$INSTALL_SCRIPT" ]; then + curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$INSTALL_SCRIPT" + chmod +x "$INSTALL_SCRIPT" + fi + "$INSTALL_SCRIPT" --channel "$CHANNEL" --install-dir "$DOTNET_ROOT" --no-path +fi + +# Persist DOTNET_ROOT and PATH for the rest of the session. +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export DOTNET_ROOT=\"$DOTNET_ROOT\"" + echo "export PATH=\"$DOTNET_ROOT:\$PATH\"" + echo "export DOTNET_CLI_TELEMETRY_OPTOUT=1" + echo "export DOTNET_NOLOGO=1" + } >> "$CLAUDE_ENV_FILE" +fi + +"$DOTNET_ROOT/dotnet" --version >&2 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e06b0338 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/.github/umbraco-18-upgrade-plan.md b/.github/umbraco-18-upgrade-plan.md new file mode 100644 index 00000000..69da8313 --- /dev/null +++ b/.github/umbraco-18-upgrade-plan.md @@ -0,0 +1,115 @@ +# Umbraco 18 Beta Upgrade Plan for Clean + +Investigation of Umbraco 18 beta breaking changes and the plan to make Clean +compatible. Target: Umbraco `18.0.0-beta2`, Clean package version `8.0.0-beta1`. + +## Current state + +All 5 projects (`Clean`, `Clean.Core`, `Clean.Headless`, `Clean.Blog`, +`Clean.Models`) target `net10.0` and reference Umbraco `17.1.0`. Package +version is stamped at `7.0.5`. Compatible extras: `uSync 17.0.1` and +`uSync.Command.Setup 16.1.0`. + +## Decisions + +- **Target Umbraco version**: `18.0.0-beta2` (latest beta as of 2026-05-14). +- **Clean package version**: bump to `8.0.0-beta1` (matches the existing + convention: Umbraco 17 → Clean 7, Umbraco 18 → Clean 8). +- **uSync**: keep current pins (`uSync 17.0.1`, `uSync.Command.Setup 16.1.0`) + in `Clean.Blog`. No v18-compatible release exists yet on NuGet; expect a + follow-up bump once Kevin Jump publishes one. + +## Breaking changes that affect Clean + +### 1. OpenAPI: Swashbuckle removed, replaced by `Microsoft.AspNetCore.OpenApi` + +- `template/Clean.Headless/Startup/ConfigureSwaggerGenOptions.cs` uses + `IConfigureOptions` + `SwaggerDoc(...)` — rewrite using + `builder.Services.AddOpenApi("clean-starter", options => + options.AddDocumentTransformer(...))`, setting + `Title = "Clean starter kit"`, `Version = "Latest"`, + `Description = "Contains headless endpoints for search, dictionaries and forms"`, + plus `AddOpenApiDocumentToUi("clean-starter", "Clean starter kit")`. +- `template/Clean.Headless/Startup/WorkshopComposer.cs` (line 11) — replace + the `ConfigureOptions()` call with the new + registration. The `ConfigureSwaggerGenOptions` class can collapse into the + composer or be kept as a small helper. +- The Swagger UI URL changes from `/umbraco/swagger` to `/umbraco/openapi`. + +### 2. `IPublishedContent.Parent` / `.Children` properties removed + +Both are now extension methods in `Umbraco.Extensions` (already imported via +`_ViewImports.cshtml`). Razor views need `()` added: + +- `template/Clean.Blog/Views/Author.cshtml:8` — + `Model.Parent as AuthorList` → `Model.Parent() as AuthorList` +- `template/Clean.Blog/Views/Partials/mainNavigation.cshtml:21` — + `homePage.Children.Where(...)` → `homePage.Children().Where(...)` +- `template/Clean.Blog/Views/Partials/xmlSitemap.cshtml:14,16,29` — three + `.Children` references → `.Children()` + +Existing `Children()` calls in `Partials/authors.cshtml` and +`latestArticlesRow.cshtml` are already method calls and don't need changes. + +### 3. csproj package bumps + +For every csproj, bump `Umbraco.Cms.*` from `17.1.0` to `18.0.0-beta2`: + +- `template/Clean.Models/Clean.Models.csproj` +- `template/Clean.Core/Clean.Core.csproj` +- `template/Clean.Headless/Clean.Headless.csproj` +- `template/Clean/Clean.csproj` (also bump the two `Clean.Core` / + `Clean.Headless` PackageReferences to `8.0.0-beta1`) +- `template/Clean.Blog/Clean.Blog.csproj` (bump `Umbraco.Cms` and + `Umbraco.Cms.DevelopmentMode.Backoffice`; leave uSync untouched) + +Bump ``, ``, `` from `7.0.5` +to `8.0.0-beta1` on the four packaged projects (`Clean.Models` is unversioned). + +## Confirmed NOT broken (do not change) + +- **`IEmailSender`** — interface remains; only obsolete `SendAsync` overloads + without an `expires` parameter were removed. Current call + `_emailSender.SendAsync(message, emailType: "Contact")` still binds to the + surviving overload. No change needed in `ContactSurfaceController.cs` or + `ContactV1Controller.cs`. +- **`IContentTypeBaseServiceProvider`** in `ImportPackageXmlMigration.cs` — + the `Provider` variant survived; only `IContentTypeBaseService` (without + "Provider") was removed. +- **`AsyncPackageMigrationBase`** — kept in v18; only the sync + `MigrationBase` and `PackageMigrationBase` were removed. +- **`IDictionaryItemService.GetAtRootAsync()`** in + `DictionaryApiV1Controller.cs` and `PackageController.cs` — a different + API from the removed `GetAtRoot()` (which was on `UmbracoHelper`, + `IPublishedContentCache`, `IUmbracoContext.Content`). + +## Phased execution + +1. **Phase 1 — packaged libraries**: bump `Umbraco.Cms.*` to + `18.0.0-beta2` in all 5 csproj files. Rewrite `ConfigureSwaggerGenOptions.cs` + and update `WorkshopComposer.cs`. Bump package `` to + `8.0.0-beta1`. +2. **Phase 2 — `Clean.Blog` test host**: bump `Umbraco.Cms` and + `Umbraco.Cms.DevelopmentMode.Backoffice`. Update the four Razor views to + call `Parent()` / `Children()`. +3. **Phase 3 — verification + docs**: local `dotnet build`, smoke test + (login, publish home, hit OpenAPI UI at `/umbraco/openapi`). Add an + "Umbraco 18" install section to `README.md`. + +## Risks and open items + +- **uSync 17 against Umbraco 18 may fail at runtime.** The pins are kept per + the chosen strategy; expect a follow-up bump when + [uSync](https://www.nuget.org/packages/uSync) ships a v18 build (see + [KevinJump/uSync releases](https://github.com/KevinJump/uSync/releases)). +- **Workflows** under `.github/workflows/` (e.g. + `test-umbraco-latest-nuget.yml`, `update-packages.yml`) may need a separate + look if they pin to non-prerelease versions — out of scope for this plan. + +## Sources + +- [Breaking Changes Overview — CMS 18.latest (Beta)](https://docs.umbraco.com/umbraco-cms/18.latest/get-started/upgrading-and-migrating/version-specific) +- [Umbraco-CMS v18.0.0 release notes](https://releases.umbraco.com/release/umbraco/Umbraco-CMS/18.0.0) +- [API versioning and OpenAPI — CMS 18.latest](https://docs.umbraco.com/umbraco-cms/18.latest/extend-your-project/server-side-extensions/api-versioning-and-openapi) +- [Umbraco.Cms on NuGet](https://www.nuget.org/packages/Umbraco.Cms) +- [uSync on NuGet](https://www.nuget.org/packages/uSync) diff --git a/template-pack.csproj b/template-pack.csproj index 24d462f6..c43318dd 100644 --- a/template-pack.csproj +++ b/template-pack.csproj @@ -1,6 +1,6 @@ - 7.0.5 + 8.0.0-beta01 Template Umbraco.Community.Templates.Clean Umbraco.Community.Templates.Clean @@ -20,8 +20,8 @@ https://github.com/prjseal/Clean git MIT - 7.0.5 - 7.0.5 + 8.0.0 + 8.0.0 diff --git a/template/Clean.Blog/Clean.Blog.csproj b/template/Clean.Blog/Clean.Blog.csproj index 8a94821d..2ddaf900 100644 --- a/template/Clean.Blog/Clean.Blog.csproj +++ b/template/Clean.Blog/Clean.Blog.csproj @@ -7,13 +7,14 @@ - - - + + - + + + diff --git a/template/Clean.Blog/Composing/ApiClientSetup.cs b/template/Clean.Blog/Composing/ApiClientSetup.cs new file mode 100644 index 00000000..34e70435 --- /dev/null +++ b/template/Clean.Blog/Composing/ApiClientSetup.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Install; +using Umbraco.Cms.Infrastructure.Security; + +namespace Clean.Blog.Composing; + +internal static class ApiClientSetup +{ + // Run the Clean.Blog package migration synchronously so the runtime is at + // Run before the listener accepts requests. By default Umbraco defers package + // migrations to a hosted service that races with first incoming traffic, and + // /umbraco/management/api/v1/security/back-office/token returns 503 + // "application is currently being upgraded" until that hosted service + // finishes. + public static async Task RunPackageMigrationsAsync(IServiceProvider rootServices, string packageName) + { + using var scope = rootServices.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + await runner.RunPendingPackageMigrations(packageName); + } + + public static async Task EnsureAsync(IServiceProvider rootServices, CancellationToken ct = default) + { + using var scope = rootServices.CreateScope(); + var sp = scope.ServiceProvider; + var configuration = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + + try + { + if (configuration.GetValue("uSync:Command:AddIfMissing", false) is false) + { + return; + } + + var clientId = configuration.GetValue("uSync:Command:ClientId", string.Empty); + var clientSecret = configuration.GetValue("uSync:Command:Secret", string.Empty); + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + logger.LogWarning("Clean.Blog API client setup is enabled but ClientId or Secret is missing from configuration"); + return; + } + + var userService = sp.GetRequiredService(); + if (await userService.FindByClientIdAsync(clientId) is not null) + { + return; + } + + var fallbackEmail = $"{Path.GetFileNameWithoutExtension(Path.GetRandomFileName())}@example.com"; + var userGroupKeys = new HashSet + { + configuration.GetValue("uSync:Command:UserGroupKey", Constants.Security.AdminGroupKey), + }; + + var createAttempt = await userService.CreateAsync( + Constants.Security.SuperUserKey, + new UserCreateModel + { + Email = configuration.GetValue("uSync:Command:Email", fallbackEmail), + UserName = configuration.GetValue("uSync:Command:Username", fallbackEmail), + Kind = UserKind.Api, + Name = configuration.GetValue("uSync:Command:Name", "Clean API User"), + UserGroupKeys = userGroupKeys, + }, + approveUser: false); + + if (createAttempt.Success is false) + { + logger.LogWarning("Could not create the API user: {status}", createAttempt.Status); + return; + } + + var userKey = createAttempt.Result.CreatedUser?.Key; + if (userKey.HasValue is false) + { + logger.LogWarning("Could not create the API user: no key returned"); + return; + } + + var addClientIdStatus = await userService.AddClientIdAsync(userKey.Value, clientId); + if (addClientIdStatus != UserClientCredentialsOperationStatus.Success) + { + logger.LogWarning("Could not attach client id to user: {status}", addClientIdStatus); + return; + } + + var applicationManager = sp.GetService(); + if (applicationManager is null) + { + logger.LogWarning("IBackOfficeApplicationManager is not available; OpenIddict client not registered"); + return; + } + + await applicationManager.EnsureBackOfficeClientCredentialsApplicationAsync(clientId, clientSecret, ct); + await userService.EnableAsync(userKey.Value, new HashSet { userKey.Value }); + + logger.LogInformation("Provisioned API client '{clientId}' for Clean.Blog", clientId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error provisioning the Clean.Blog API client"); + } + } + + private sealed class ApiClientSetupMarker { } +} diff --git a/template/Clean.Blog/Composing/BlogComposer.cs b/template/Clean.Blog/Composing/BlogComposer.cs new file mode 100644 index 00000000..4d78138a --- /dev/null +++ b/template/Clean.Blog/Composing/BlogComposer.cs @@ -0,0 +1,14 @@ +using Clean.Blog.Migrations; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Infrastructure.Migrations.Notifications; + +namespace Clean.Blog.Composing; + +public class BlogComposer : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler(); + } +} diff --git a/template/Clean.Blog/Migrations/BlogPackageMigrationPlan.cs b/template/Clean.Blog/Migrations/BlogPackageMigrationPlan.cs new file mode 100644 index 00000000..c73e9805 --- /dev/null +++ b/template/Clean.Blog/Migrations/BlogPackageMigrationPlan.cs @@ -0,0 +1,16 @@ +using Umbraco.Cms.Core.Packaging; + +namespace Clean.Blog.Migrations; + +public class BlogPackageMigrationPlan : PackageMigrationPlan +{ + public BlogPackageMigrationPlan() + : base("Clean.Blog") + { + } + + protected override void DefinePlan() + { + To(new Guid("B1C9D0E1-2F34-4A56-9B78-C0D1E2F3A4B5")); + } +} diff --git a/template/Clean.Blog/Migrations/ImportPackageXmlMigration.cs b/template/Clean.Blog/Migrations/ImportPackageXmlMigration.cs new file mode 100644 index 00000000..e37fd612 --- /dev/null +++ b/template/Clean.Blog/Migrations/ImportPackageXmlMigration.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Packaging; + +namespace Clean.Blog.Migrations; + +public class ImportPackageXmlMigration : AsyncPackageMigrationBase +{ + public ImportPackageXmlMigration( + IPackagingService packagingService, + IMediaService mediaService, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IMigrationContext context, + IOptions packageMigrationSettings) + : base(packagingService, + mediaService, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + contentTypeBaseServiceProvider, + context, packageMigrationSettings) + { + } + + protected override Task MigrateAsync() + { + ImportPackage.FromEmbeddedResource(GetType()).Do(); + return Task.CompletedTask; + } +} diff --git a/template/Clean.Blog/Migrations/PostMigrationNotificationHandler.cs b/template/Clean.Blog/Migrations/PostMigrationNotificationHandler.cs new file mode 100644 index 00000000..f71e5c33 --- /dev/null +++ b/template/Clean.Blog/Migrations/PostMigrationNotificationHandler.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Migrations.Notifications; + +namespace Clean.Blog.Migrations; + +public class PostMigrationNotificationHandler : INotificationAsyncHandler +{ + private readonly IContentService _contentService; + private readonly ILogger _logger; + + public PostMigrationNotificationHandler( + IContentService contentService, + ILogger logger) + { + _contentService = contentService; + _logger = logger; + } + + public Task HandleAsync(MigrationPlansExecutedNotification notification, CancellationToken cancellationToken) + { + if (HasMigrationRun(notification.ExecutedPlans) is false) + { + return Task.CompletedTask; + } + + var contentHome = _contentService.GetRootContent().FirstOrDefault(x => x.ContentType.Alias == "home"); + if (contentHome is null) + { + _logger.LogWarning("The installed Home page was not found"); + return Task.CompletedTask; + } + + var publishResult = _contentService.PublishBranch(contentHome, PublishBranchFilter.All, []); + foreach (var item in publishResult) + { + _logger.LogInformation("Result of publishing '{nodeName}' = {publishResult}", item.Content.Name, item.Result.ToString()); + } + return Task.CompletedTask; + } + + private static bool HasMigrationRun(IEnumerable executedMigrationPlans) + { + foreach (var executedMigrationPlan in executedMigrationPlans) + { + foreach (var transition in executedMigrationPlan.CompletedTransitions) + { + if (transition.MigrationType == typeof(ImportPackageXmlMigration)) + { + return true; + } + } + } + return false; + } +} diff --git a/template/Clean.Blog/Program.cs b/template/Clean.Blog/Program.cs index dc749b7f..e37d2613 100644 --- a/template/Clean.Blog/Program.cs +++ b/template/Clean.Blog/Program.cs @@ -1,3 +1,4 @@ +using Clean.Blog.Composing; using Clean.Blog.Middleware; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -12,6 +13,12 @@ await app.BootUmbracoAsync(); +// Run package migrations and provision the backoffice API client before the +// Kestrel listener accepts requests, so CreateNuGetPackages.ps1's first /token +// call doesn't race against Umbraco's still-upgrading state. +await ApiClientSetup.RunPackageMigrationsAsync(app.Services, "Clean.Blog"); +await ApiClientSetup.EnsureAsync(app.Services); + // Add security headers middleware app.UseMiddleware(); diff --git a/template/Clean.Blog/Views/Author.cshtml b/template/Clean.Blog/Views/Author.cshtml index 41b2777c..cd73db54 100644 --- a/template/Clean.Blog/Views/Author.cshtml +++ b/template/Clean.Blog/Views/Author.cshtml @@ -5,7 +5,7 @@ @{ Layout = "master.cshtml"; - var authorListPage = Model.Parent as AuthorList; + var authorListPage = Model.Parent() as AuthorList; } diff --git a/template/Clean.Blog/Views/Partials/blockgrid/items.cshtml b/template/Clean.Blog/Views/Partials/blockgrid/items.cshtml index 2703fa57..677c9aae 100644 --- a/template/Clean.Blog/Views/Partials/blockgrid/items.cshtml +++ b/template/Clean.Blog/Views/Partials/blockgrid/items.cshtml @@ -12,7 +12,7 @@ class="umb-block-grid__layout-item" data-content-element-type-alias="@item.Content.ContentType.Alias" data-content-element-type-key="@item.Content.ContentType.Key" - data-element-udi="@item.ContentUdi" + data-element-key="@item.ContentKey" data-col-span="@item.ColumnSpan" data-row-span="@item.RowSpan" style=" --umb-block-grid--item-column-span: @item.ColumnSpan; --umb-block-grid--item-row-span: @item.RowSpan; "> diff --git a/template/Clean.Blog/Views/Partials/blocklist/default.cshtml b/template/Clean.Blog/Views/Partials/blocklist/default.cshtml index 065b16b7..bbffe989 100644 --- a/template/Clean.Blog/Views/Partials/blocklist/default.cshtml +++ b/template/Clean.Blog/Views/Partials/blocklist/default.cshtml @@ -5,7 +5,7 @@ @foreach (var block in Model) { - if (block?.ContentUdi == null) { continue; } + if (block?.Content == null) { continue; } var data = block.Content; @await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block) diff --git a/template/Clean.Blog/Views/Partials/grid/editors/macro.cshtml b/template/Clean.Blog/Views/Partials/grid/editors/macro.cshtml index a4450d1c..a783259f 100644 --- a/template/Clean.Blog/Views/Partials/grid/editors/macro.cshtml +++ b/template/Clean.Blog/Views/Partials/grid/editors/macro.cshtml @@ -1,15 +1,5 @@ @inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage - -@if (Model?.value is not null) -{ - string macroAlias = Model.value.macroAlias.ToString(); - var parameters = new Dictionary(); - foreach (var mpd in Model.value.macroParamsDictionary) - { - parameters.Add(mpd.Name, mpd.Value); - } - - - @await Umbraco.RenderMacroAsync(macroAlias, parameters) - -} +@* + Macros were removed in Umbraco 14+. The legacy grid macro editor is no longer + supported. Migrate macro grid content to the Block Grid or Block List editors. +*@ diff --git a/template/Clean.Blog/Views/Partials/mainNavigation.cshtml b/template/Clean.Blog/Views/Partials/mainNavigation.cshtml index 5b216009..8a2df05b 100644 --- a/template/Clean.Blog/Views/Partials/mainNavigation.cshtml +++ b/template/Clean.Blog/Views/Partials/mainNavigation.cshtml @@ -18,7 +18,7 @@ - @foreach (var page in homePage.Children.Where(x => !x.Value("hideFromTopNavigation"))) + @foreach (var page in homePage.Children().Where(x => !x.Value("hideFromTopNavigation"))) {