# skttl.dev — Full Content > Søren Kottal — Umbraco MVP and tinkerer of all kinds of stuff. > Topics: Umbraco CMS, ASP.NET, frontend development, DevOps, developer tools. --- # AI is changing how I approach Umbraco development URL: https://skttl.dev/blog/ai-is-changing-how-i-approach-umbraco-development/ Date: 2026-10-05 Description: Building a PDF metadata dashboard with AI made me reconsider when I reach for Umbraco's standard tools and when a custom extension is worth the maintenance. Tags: umbraco, ai, backoffice A customer needed to fill in missing title metadata in PDFs uploaded to Umbraco's media archive. I built a health check to find the files and offer actions to fix them. It worked. Then I replaced it with a dashboard in the Media section and added a box showing PDF metadata on the media item's Info tab. Building those additions with AI assistance took at most a couple of hours, including directing the agent, reviewing its work, and trying it out. That changed which approach made sense for this task. A custom interface had previously felt like something I would have to justify spending several hours on. This time, it was practical to build the interface around the editor's job. ## A PDF cleanup job The ongoing part of the problem was straightforward. I used saved notification handlers to keep the PDF's title metadata in sync with the media item's name. When an editor saves a media item, the handler checks whether the titles match and updates the PDF if necessary. For new uploads, it takes the PDF's existing title and uses it as the media name. Umbraco's [MediaService notifications](https://docs.umbraco.com/umbraco-cms/17.latest/extend-your-project/server-side-extensions/notifications/mediaservice-notifications) provide the hook for that work. The PDF handling is custom code in the customer's project. But this was an existing site with plenty of PDFs already missing titles. Keeping future changes in sync did not clear that backlog. The editors needed a way to find the problematic files and decide what their titles should be. That is where I reached for a health check. ## The health check worked I have used this approach before. Umbraco's [health check framework](https://docs.umbraco.com/umbraco-cms/extending/health-check) lets me implement a class that returns results and provides actions to resolve them. Umbraco supplies the interface and the flow around it. From a developer's perspective, that is a useful shortcut. I can focus on detecting and correcting the problem instead of building the UI and figuring out where and how to integrate it into the backoffice. ![The original PDF metadata health check in Settings, with a correction action and a Read more button for each result](/images/blog/ai-is-changing-how-i-approach-umbraco-development/health-check.png) The problem was the editing experience. The check lived in Settings, somewhere an editor might not think to look, or might not have access to. Even with access, getting to the check took several clicks. Each result took up a fair amount of space, and I had put the link to the individual media item behind a button labelled "Read more". It could find and fix the PDFs. It was less suitable as the place where an editor would work through an archive of them. I had picked an interface because it was convenient to implement. Looking at it again, a dashboard in Media was a better fit for the actual task. ## A better place to do the work The dashboard finds PDFs with missing or mismatched titles and presents the media name and PDF title side by side. The media name is a direct link to the item. For each PDF, the editor can use the media name as the PDF title, use the existing PDF title as the media name, or enter a new name for both. ![The PDF titles dashboard in Media, showing ten files with their media names, PDF titles, and correction actions](/images/blog/ai-is-changing-how-i-approach-umbraco-development/dashboard.png) Those choices matter because a mismatch does not tell me which title is right. Sometimes the media name is useful and the PDF title is empty. Sometimes the PDF already has a better title. Sometimes neither is what the editor wants. ![The dialog for entering a shared name for the Umbraco media item and the PDF title](/images/blog/ai-is-changing-how-i-approach-umbraco-development/modal.png) I also added a PDF metadata box to the media item's Info tab. A custom condition makes it appear only for PDF files. It shows the current metadata so an editor can see what is in the file before changing its name. ![The media item's Info tab with a custom box showing the PDF title, author, other metadata, page count, and digital signature status](/images/blog/ai-is-changing-how-i-approach-umbraco-development/metadata.png) I did not actually know that the Info tab's boxes could be extended like this. The extension type is called [`workspaceInfoApp`](https://apidocs.umbraco.com/v17/ui-api/interfaces/packages_core_workspace.ManifestWorkspaceInfoApp.html), and it was a useful discovery for this job. Otherwise, I would probably have built a separate workspace view. Or, slightly embarrassingly, a property editor whose only job was to display data. Extending the existing Info tab was a better fit. The customer is now working through the PDFs. The dashboard is easy for them to find in Media, and correcting the titles is straightforward. ## A couple of hours changed the decision The new backoffice has plenty of extension points for building this kind of editing experience. Yes, I still call it new. I have been doing Umbraco long enough that this may take a while to wear off. The possibilities were already there. Writing the code to use them was the part that made me hesitate. At Codegarden 2025, I watched Niels Lyngsø's [Next-Level Backoffice talk](https://www.youtube.com/watch?v=P0xxTIlHayg). He demonstrated a whole collection of extensions that improved the editor experience. I remember sitting in the audience thinking that it looked lovely, but I would never get the budget from my customers to do that sort of work. This PDF task made me reconsider that assumption. With an AI agent doing much of the implementation, the extra work became small enough that I chose a different approach. I still had to direct it. I had not installed [Umbraco's backoffice skills](https://github.com/umbraco/Umbraco-CMS-Backoffice-Skills) for this project, so I had to tell the agent to use `uui-table` for the table, for example. I have [written about those skills before](/blog/umbraco-skills-ai-agents). In this case, I managed to give myself a practical reminder of why they are useful. The agent can produce the code, but I still need to check whether it uses the right components and whether the resulting interface makes sense. A table that displays the data is only part of the job. The choices and their wording need to make sense to the person correcting the PDFs. Previously, the development time for these additions would have been difficult to sell. The health check already worked, so I would have had to justify several more hours to make the same task easier for the editor. At most a couple of hours is a much easier case to make. In our customer work, I am also seeing more arrangements closer to fixed pricing, where the conversation focuses less on individual hours and more on value. Spending less time on implementation makes it more practical to include improvements like this, rather than stopping at the first interface that gets the job done. ## Custom still has to earn its place There is a limit to this. If I build every custom extension an agent can produce, I can still end up with a Frankenstein's monster of a backoffice. Each little addition might make sense on its own, while the whole thing becomes harder to understand and maintain. Standard components still have a lot of value. I prefer them where they fit because they leave less custom behaviour for me to maintain. Using Umbraco's components and extension points also helps a necessary addition fit into the existing interface. My test is whether the extension makes a concrete task noticeably easier or more editor-friendly. Being quick to build is not enough reason to add it. For this customer, the dashboard passes that test. The editors have the overview and choices they need, in the section where they already manage their files. AI assistance made that approach affordable enough for me to choose it. The next time I reach for a health check or another convenient shortcut, I will spend a moment checking whether it is also a good place for the editor to do the work. --- # Introducing RazorSearch for Umbraco URL: https://skttl.dev/blog/introducing-razorsearch-for-umbraco/ Date: 2026-10-01 Description: RazorSearch is my successor to Full Text Search for Umbraco, with weighted titles and headings, background rendering and tools for inspecting extracted text. Tags: umbraco, razor, package, backoffice I've maintained [Full Text Search](https://marketplace.umbraco.com/package/our.umbraco.fulltextsearch) to solve a particular problem: searching the text a visitor sees, including blocks and related content assembled by Razor templates. Umbraco Search gave me a reason to rethink how the package does that. I built [RazorSearch](https://marketplace.umbraco.com/package/umbraco.community.razorsearch) as the successor to Full Text Search for Umbraco 17 and 18. It renders published pages, extracts their text, and indexes the result through Umbraco Search. I've added a small API for building a search page, separate weighting for rendered titles and headings, and backoffice tools for inspecting the rendering work. ## Why a new package? Full Text Search did this by adding fields containing the rendered text to Umbraco's `ExternalIndex`. It also provided a simpler way to query Examine and turn the results into something useful on a search page. I've written about [earlier improvements to Full Text Search](/blog/4-new-features-in-full-text-search-umbraco) before. [Umbraco Search](https://github.com/umbraco/Umbraco.Cms.Search) introduces a search abstraction with providers underneath it. According to Umbraco's [Umbraco Search announcement](https://github.com/umbraco/Announcements/issues/36), it will be included in Umbraco 19 and replace the existing core Examine indexes. Examine will remain the default provider, but search implementations should use the Umbraco Search abstractions. I built RazorSearch on that abstraction, with a separate index for the text extracted from stored page snapshots. I've kept the core package independent of the search provider and put the Examine index setup in a companion package. I also wanted a different name. "Full Text Search" never quite described what made the package useful. RazorSearch is a separate package with the same starting point, and the new name makes that distinction clearer. ## Giving titles and headings more weight This is the change I wanted most. Once the rendered text is split into separate parts, the search can distinguish a match in a heading from one in the body of the page. By default, RazorSearch extracts and indexes these parts: | Part of the page | Default source | Relevance level | | --- | --- | --- | | Title | `` | Highest, using `TextsR1` | | Headings | `<h1>` through `<h6>` | Next, using `TextsR2` | | Body | `<body>` | Normal, using `Texts` | These are [Umbraco Search's relevance levels](https://github.com/umbraco/Umbraco.Cms.Search/blob/main/src/Umbraco.Cms.Search.Core/Models/Indexing/IndexValue.cs). The search provider applies them when scoring results. All heading levels share the same relevance level here. I already had a title boost for configured index fields in Full Text Search. In RazorSearch, I extract the rendered title and headings separately, then pass them to Umbraco Search with their intended relevance. For a query like "installation", I want a match in a heading to carry more weight than a mention halfway through a paragraph. The separate fields give the provider that information, though other scoring factors still affect the final order. The extraction sources are configurable. You can use CSS selectors to select parts of the HTML, or read published Umbraco properties when those are a better source. The [configuration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/configuration/README.md) covers the available options. ## Seeing what RazorSearch is doing Full Text Search already lets you trigger reindexing from the content tree and includes health checks. Older versions also had a dashboard. In RazorSearch, I've added a view of the rendering queue and the text extracted from individual pages. The operations view shows pending, running, completed and failed jobs, along with progress for the current batch. You can queue a single page, a page and its descendants, or a backfill of published content. ![RazorSearch operations view showing an active rendering batch](/images/blog/introducing-razorsearch-for-umbraco/queue-running.png) For an individual page, you can inspect the stored title, headings, summary and body text, along with the culture and rendering status. Failed jobs also expose their error messages. That gives you somewhere to start when a page is missing from search or contains unexpected text. You can check whether the rendering failed and whether the text you expected is present in the snapshot. ![RazorSearch snapshot details showing the Danish title, heading and rendering status, with empty summary and body fields](/images/blog/introducing-razorsearch-for-umbraco/snapshot-details.png) Rendering happens in the background. Publishing queues the work, and saving the resulting snapshot triggers an update of RazorSearch's index. That means there can be a short delay between publishing a page and seeing its updated text in search. Changing a template, shared content or extraction rules requires a manual rebuild of the affected snapshots. Rebuilding the search index alone reads the stored snapshots without rendering the pages again. The rendering queue lives in memory, so a restart loses pending work. If a restart interrupts a batch, queue another rebuild. The [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) covers these operational differences too. ## Coming from Full Text Search I've changed both the setup and the search API, so migrating involves more than swapping the NuGet package. I've written a [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) covering the full process. Here are the main changes. ### Set up Umbraco Search Replace `Our.Umbraco.FullTextSearch` with `Umbraco.Community.RazorSearch`. If you use the Examine provider, install `Umbraco.Community.RazorSearch.Examine`, which includes the core package as a dependency. For Umbraco 17: ```bash dotnet add package Umbraco.Community.RazorSearch.Examine --version 17.0.0 ``` For Umbraco 18: ```bash dotnet add package Umbraco.Community.RazorSearch.Examine --version 18.0.0 ``` I target Umbraco `17.0.0` and later in the 17.x line, and Umbraco `18.0.0` and later in the 18.x line. Both package lines require .NET 10 and Umbraco Search. Choose the RazorSearch package line matching your Umbraco major version. Enable Umbraco Search and its provider in your existing `Program.cs`. For Examine, add these namespaces and registrations to the usual builder chain: ```csharp using Umbraco.Cms.Search.Core.DependencyInjection; using Umbraco.Cms.Search.Provider.Examine.DependencyInjection; builder.CreateUmbracoBuilder() .AddBackOffice() .AddWebsite() .AddComposers() .AddSearchCore() .AddExamineSearchProvider() .Build(); ``` Keep the rest of your Umbraco startup code. I've made RazorSearch register its own services automatically; your application still needs to register the search provider. After installation, queue a backfill in the backoffice so RazorSearch can render the existing pages and populate its index. The old Full Text Search cache does not provide those new snapshots. See the [installation guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/installation/README.md) for the complete setup. ### Update the search request Replace the injected `ISearchService` with `IRazorSearchService`, which lives in the `Umbraco.Community.RazorSearch` namespace. The request model changes from `Search` to `RazorSearch`, and the search call becomes asynchronous. For example, with a non-empty `query` and a one-based `pageNumber`, a Full Text Search request might look like this: ```csharp using Our.Umbraco.FullTextSearch.Models; var search = new Search(query) .SetCulture(CurrentPage.GetCultureFromDomains()) .AddRootNodeId(CurrentPage.Root().Id) .AddAllowedContentTypes("article") .SetPageLength(10); var result = _searchService.Search(search, pageNumber); ``` The corresponding RazorSearch request is: ```csharp using Umbraco.Community.RazorSearch.Models; using Umbraco.Extensions; var search = new RazorSearch(query) .InCulture(CurrentPage.GetCultureFromDomains()) .UnderRoot(CurrentPage.Root().Key) .IncludeContentTypes("article") .Page(pageNumber, 10); var result = await _razorSearchService.SearchAsync(search); ``` The root now uses the content's GUID `Key`, and paging is part of the request. Set the culture to a configured Umbraco language to search that language and invariant content. Without a culture, RazorSearch searches invariant content only. The [usage guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/usage/README.md) includes a complete Razor template for a search page. I've kept the results focused on what a search page needs: a title, URL, published content and highlighted `SummaryHtml`. ### Translate configuration and rendering helpers Configuration moves from `Umbraco:FullTextSearch` to `Umbraco:Community:RazorSearch` in `appsettings.json`. I've included an appsettings schema that Umbraco picks up automatically when you build the application. That gives you IntelliSense for RazorSearch's settings when you edit `appsettings.json`. XPath rules such as `XPathsToRemove` become CSS selectors in `SnapshotExtraction.RemoveSelectors`. Separate source lists control the title, summary, headings and body. If you use a helper in your templates to omit navigation or other markup during search rendering, replace `FullTextSearchHelper.IsRenderingActive()` with `SearchRenderingContext.IsActive`. > **Note:** HTTP rendering was already the default in Full Text Search, so the site still needs to be able to request its own pages. The alternative `RenderTemplate()` renderer was there for backwards compatibility, and I haven't carried it over to RazorSearch. Some other Full Text Search options also have no direct equivalent in RazorSearch's API, including custom Lucene queries, automatic wildcard and fuzziness settings, and selecting an arbitrary index. Result items no longer expose raw Examine fields or scores. If your implementation uses those features, review that code as part of the migration. ## What happens to Full Text Search? Full Text Search is now in maintenance mode. Its [18.0.0 release](https://www.nuget.org/packages/Our.Umbraco.FullTextSearch/18.0.0) supports Umbraco 18. I don't expect to add more features, but I may still release occasional patches if something needs fixing. You can continue using the existing releases on Umbraco 17 and 18. Installing RazorSearch is a separate migration, so an existing search implementation does not need to change simply because this package exists. I plan to put future development into RazorSearch rather than Full Text Search. For projects moving to Umbraco Search, it is the successor I intend people to use. Because Umbraco Search will become part of Umbraco 19, projects on Umbraco 17 or 18 can start the migration before that breaking change arrives. ## Get RazorSearch I've released `17.0.0` for Umbraco 17 and `18.0.0` for Umbraco 18. The [release notes](https://github.com/skttl/umbraco-razor-search/blob/main/docs/release-notes.md) cover the features and remaining limitations. [RazorSearch is available on Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.razorsearch), free and MIT-licensed. Start with the [installation guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/installation/README.md), or the [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) if you're coming from Full Text Search. --- # Manage video in Umbraco, stream elsewhere URL: https://skttl.dev/blog/manage-video-in-umbraco-stream-elsewhere/ Date: 2026-09-14 Description: uMux now supports Umbraco 18, joined by uBun and uGum for syncing local videos to Mux, Bunny Stream or Gumlet without changing the editor workflow. Tags: umbraco, package, video, umbraco-cloud Serving a 50 MB video one hundred times uses about 5 GB of bandwidth. Do that across ten videos and you have used the entire monthly bandwidth allowance for an Umbraco Cloud Starter project. Video delivery is therefore something I would rather leave to a video platform. Editors should not have to care about that choice, though. They should still be able to upload and manage videos in Umbraco without copying IDs or embed codes between systems. That was the idea behind [uMux](/blog/umux-announcement), my integration for [Mux](https://www.mux.com/video-api). I have now released an Umbraco 18 version, along with two alternatives: uBun for [Bunny Stream](https://bunny.net/stream/) and uGum for [Gumlet](https://www.gumlet.com/). ## Why move video delivery elsewhere? The original file stays in Umbraco. This gives me a copy if a project stops using its current provider or needs to move elsewhere later. The website uses the provider's playback URL instead of serving that original file to visitors. This moves the playback traffic away from Umbraco Cloud, where the current monthly allowances are [50 GB for Starter, 300 GB for Standard and 1,000 GB for Professional](https://umbraco.com/products/umbraco-cloud/pricing/). Mux, Bunny Stream and Gumlet all process the uploaded file into HLS streams. An HLS-compatible player can switch between different renditions as the viewer's available bandwidth changes. It also streams the video in segments instead of treating it as one large download. ## One workflow, three providers The editor workflow is the same with all three packages: 1. Upload a video to an Umbraco Upload property, for example on the default Video media type. 2. Save the item. 3. The package sends the file to the configured provider. 4. The provider's asset ID and playback details are stored in a second property. 5. The website uses those playback details when rendering the video. [uMux](https://marketplace.umbraco.com/package/umbraco.community.umux) is the original package I built for this workflow. Its latest release adds support for Umbraco 18. [uBun](https://marketplace.umbraco.com/package/umbraco.community.ubun) and [uGum](https://marketplace.umbraco.com/package/umbraco.community.ugum) are now available on the Umbraco Marketplace. Both have releases for Umbraco 17 and 18. I considered building a shared package with replaceable providers. In the end, each integration had enough settings, API behaviour and provider-specific details to make separate packages simpler. They can share the same general idea without forcing every future difference through a common abstraction. ## Small differences between the providers This is not intended as a full comparison, but each provider has a useful angle: - I find [Gumlet's fixed plans and included usage](https://www.gumlet.com/blog/gumlet-pricing-update-2026/) easier to explain than a price assembled from several kinds of usage. - [bunny.net is based in Europe](https://bunny.net/about/), which can matter when a client prefers a European supplier. - Mux has a strong set of developer tools. [Mux Player includes Mux Data](https://www.mux.com/docs/guides/player-core-functionality), and a playback ID can also produce [thumbnails, animated GIFs and storyboards](https://www.mux.com/docs/guides/get-images-from-a-video). The right choice still depends on the project. The point of the three packages is that the Umbraco workflow does not have to depend on that choice. ## Could Umbraco Automate do this? Yes, but Automate does not remove the provider-specific work. [Umbraco Automate supports custom actions](https://docs.umbraco.com/umbraco-automate/extending/custom-action), so an automation could react to a CMS event and send a video to an external service. A complete video integration still needs code to read the media file, authenticate with the provider, follow its upload process, check the processing status, store the playback details in Umbraco and remove remote assets when videos are replaced or deleted. It also needs a way to show that status to the editor. At that point, the custom action has started to look a lot like one of these packages. Automate makes more sense when video upload is one step in a larger workflow that editors need to configure. uMux, uBun and uGum cover the fixed case where saving an Umbraco item should keep its video in sync with one provider. ## Getting started Install the package for the provider you want to use: ```bash dotnet add package Umbraco.Community.uMux --version 18.0.0 dotnet add package Umbraco.Community.uBun --version 18.0.0 dotnet add package Umbraco.Community.uGum --version 18.0.0 ``` > **Note:** Install one of these packages, not all three. For Umbraco 17, use version `17.0.0` of the corresponding package. Add the matching provider settings to `appsettings.json`. Only the section for the installed package is needed. ```json { "Umbraco": { "Mux": { "ApiTokenId": "YOUR_TOKEN_ID", "ApiSecret": "YOUR_TOKEN_SECRET" }, "BunnyStream": { "LibraryId": 123456, "ApiKey": "YOUR_STREAM_API_KEY", "PullZoneHostname": "vz-example.b-cdn.net" }, "Gumlet": { "ApiKey": "YOUR_GUMLET_API_KEY", "SourceId": "YOUR_GUMLET_SOURCE_ID" } } } ``` Then create a data type using **Mux Sync**, **Bunny Stream Sync** or **Gumlet Sync**. Set **Upload Property Alias** to the alias of the Upload property containing the local video, and add the sync property to the same media, content or member type. The stored playback value differs slightly by provider: | Package | Stored playback details | | --- | --- | | uMux | `PlaybackId`, used with Mux Player or `https://stream.mux.com/{PLAYBACK_ID}.m3u8` | | uBun | `HlsUrl` and `EmbedUrl` | | uGum | `PlaybackUrl` containing the HLS URL | The repositories contain the complete setup and playback examples for [uMux](https://github.com/skttl/umbraco-mux), [uBun](https://github.com/skttl/umbraco-ubun) and [uGum](https://github.com/skttl/umbraco-ugum). ## One thing to get right The sync property and Upload property must be on the same Umbraco type. The value in **Upload Property Alias** must also match the actual alias exactly. For a video uploaded before adding the sync property, save the media item again to start the first synchronization. ## Final thoughts These packages keep the part editors care about in Umbraco. They upload and manage a normal media item, while the chosen provider handles processing and playback. The local original remains available if the project needs to change provider later. uBun and uGum are the first alternatives to uMux. I have more providers I want to integrate, and keeping each package independent leaves room for their differences. --- # Bringing Excel export back to Umbraco Forms URL: https://skttl.dev/blog/bringing-excel-export-back-to-umbraco-forms/ Date: 2026-09-07 Description: A client preferred Excel exports, so I built a small Umbraco Forms package to bring them back using SpreadCheetah. Tags: umbraco, package, dotnet, ai > **Note:** Rick Butterfield is also working on [Umbraco.Community.Forms.Excel](https://www.nuget.org/packages/Umbraco.Community.Forms.Excel), which covers more or less the same ground using MiniExcel instead of SpreadCheetah. You can find its source on [GitHub](https://github.com/rickbutterfield/Umbraco.Community.Forms.Excel). After an upgrade from Umbraco 13 to 17, a client wondered where the Excel export in Umbraco Forms had gone. The built-in export now produced CSV files, but they were more comfortable working with Excel files. That was enough reason to build [Umbraco Forms Excel Export](https://marketplace.umbraco.com/package/umbraco.community.forms.excelexport). It adds `.xlsx` exports to the existing Forms export menu, so editors can download their submissions in the format they prefer. ## Where did Excel export go? Umbraco Forms used to include Excel export. In Forms 16, Umbraco replaced it with CSV export using CsvHelper. The reason was the library behind it. Forms depended on EPPlus 4, the last LGPL-licensed version, which was no longer supported. Newer versions had different licensing requirements that Umbraco did not want for a built-in feature. The [Forms 16 upgrade notes](https://docs.umbraco.com/umbraco-forms/16.latest/upgrading/version-specific) explain the change. I wanted to bring Excel export back with a dependency that could work well in a free, open source package. ## Choosing a spreadsheet library Some Googling and AI-assisted research gave me a shortlist of ClosedXML, MiniExcel, the old EPPlus version, and SpreadCheetah. I ended up choosing [SpreadCheetah](https://github.com/sveinungf/spreadcheetah). It has an MIT license and could do what I needed: write form submissions into a new Excel file. One limitation I came across was that it cannot read or modify existing spreadsheets. That would matter for other projects, but this export only needs to create a new file. It writes the workbook row by row, which fits the job quite well. The [published benchmarks](https://github.com/sveinungf/spreadcheetah#benchmarks) looked promising too, with lower execution times and memory allocations than ClosedXML and EPPlusFree in the project's test. I didn't run my own benchmarks, so there is a bit of "trust me bro" involved in that part of the decision. It did what I needed, and that was the main thing. ## About 30 questions and a plan The implementation was fairly uneventful. I started with a grilling session using [Matt Pocock's grilling skill](https://github.com/mattpocock/skills). About 30 questions later, I had a detailed plan that Luna could implement on its own. That included setting up automatic releases through GitHub Actions. Pushing a version tag triggers the workflow to build, test, publish the package to NuGet, and create a GitHub release. In my experience, Umbraco Forms is relatively straightforward to extend, and its [documentation for adding export types](https://docs.umbraco.com/umbraco-forms/17.latest/developer/extending/adding-a-exporttype) gives you a useful starting point. There weren't many moving parts to keep track of here. The package plugs into the existing export system and uses its filters for the columns and submissions to include. I also spent a little time finding a logo on Noun Project. That was more or less it. ## How to use it Install the package version that matches your Umbraco Forms major version. For Forms 17: ```bash dotnet add package Umbraco.Community.Forms.ExcelExport --version 17.0.3 ``` For Forms 18: ```bash dotnet add package Umbraco.Community.Forms.ExcelExport --version 18.0.3 ``` These are the current releases at the time of writing. You can find newer releases on [NuGet](https://www.nuget.org/packages/Umbraco.Community.Forms.ExcelExport). After installation, open the export menu for your form's entries. Two additional options appear alongside the built-in export types: - **Excel** exports the stored values, preserving supported dates, booleans, and integers as native Excel cells. Other values are written as text, and multiple values are joined with commas. - **Excel (display values)** writes values as text and replaces stored prevalue keys with their captions. This is useful for dropdowns, checkboxes, and radio buttons where the stored value differs from the label people recognise. Both produce an `.xlsx` file containing an `Entries` worksheet. No configuration is required. ![Excel export types available from the default export action in Umbraco Forms](/images/blog/bringing-excel-export-back-to-umbraco-forms/bringing-excel-export-back-to-umbraco-forms.png) <!-- Show the Forms export menu with Excel and Excel (display values) available. --> The package is free and MIT-licensed. You can find it on the [Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.forms.excelexport), with the source code and issue tracker on [GitHub](https://github.com/skttl/umbraco-forms-excel-export). --- # The ultimate Umbraco site structure, revisited URL: https://skttl.dev/blog/the-ultimate-umbraco-site-structure-revisited/ Date: 2026-09-03 Description: How I use the Ultimate Site Structure, with a custom URL provider and content finder that keep the frontpage separate from the site root. Tags: umbraco, csharp, contentfinder, architecture At the Umbraco meetup in Odense on September 2, 2026, [Kaspar Boel Kjeldsen](https://kjeldsen.dev/) included the Ultimate Site Structure in his talk, [The 10 Umbracommandments](https://www.meetup.com/umbracodkmeetup/events/315773679/). It was good to hear somebody recommend a pattern that I have used since Umbraco 7. The original article, [The "ultimate" site structure setup](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/), was written by Sebastiaan Janssen in December 2010. It is more than 15 years old and starts with a warning that it is probably outdated. The implementation certainly is. The structure itself still holds up. I use it on every Umbraco project I work on, without exception. ## What the Ultimate Site Structure looks like The content tree has a site node at the root. The frontpage and all other top-level pages are children of that node. ```text Content └── Site ├── Home ├── Products ├── About └── Contact ``` The site node owns things that apply to the whole site: - Hostnames and cultures - Navigation and footer settings - Other global settings - The selected frontpage - Rules for which document types editors can create below it The site node does not need to render its own content. Its job is to represent the site. ## The frontpage is not the site A common Umbraco content tree puts the frontpage at the root and every other page below it. I do not think that hierarchy describes the content correctly. The contact page is part of the site, but it is not a child of the frontpage. Keeping the site and frontpage separate also removes an awkward dependency. If the frontpage needs a different document type or a complete replacement, I can create a new page and select it as the frontpage. I do not have to move every other page below a new root node. The hostname stays on the site node. So do cultures and global settings. They do not move just because the frontpage changes. This separation is also useful for site-specific features. My approach to [editor-friendly 404 pages in Umbraco](/blog/editor-friendly-404-page-in-umbraco) stores the selected 404 page on the same site node. ## The missing routing pieces Sebastiaan's original solution used a Content Picker with the alias `umbracoInternalRedirectId` on the site node. Umbraco then renders the selected frontpage when somebody requests the site root. The remaining problem is outbound routing. When Umbraco generates a link to the Home node, its default URL is still `/home`. Hardcoding `/` in navigation only hides part of the problem. An editor can still select Home in the rich text editor or a link picker and get `/home`. The complete solution needs to work in both directions: - A URL provider must return the site URL when Umbraco asks for the frontpage URL. - A content finder must resolve the site URL to the selected frontpage. Umbraco's [routing documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline) explains this as the outbound and inbound request pipelines. > **Note:** `umbracoInternalRedirectId` is one of Umbraco's special property aliases. Add it to a document type with a Content Picker, and Umbraco renders the picked page without changing the URL in the browser. The [special routing property aliases](https://docs.umbraco.com/umbraco-cms/reference/routing/routing-properties) also include `umbracoRedirect`, `umbracoUrlName`, and `umbracoUrlAlias`. ## Returning the site URL for the frontpage The URL provider only handles content selected as a frontpage. For every other content item, it returns `null` and lets Umbraco continue through the provider collection. ```csharp public class FrontpageUrlProvider : DefaultUrlProvider { private readonly FrontpageUrlHelper _frontpageUrlHelper; // The Umbraco 18 DefaultUrlProvider dependencies are omitted here. public override UrlInfo? GetUrl( IPublishedContent content, UrlMode mode, string? culture, Uri current) { // A frontpage uses the URL of the site node that owns it. var site = _frontpageUrlHelper.GetSiteOfFrontpage( content.Key, culture); // Returning null lets the next URL provider handle normal pages. return site is null ? null : base.GetUrl(site, mode, culture, current); } } ``` `FrontpageUrlHelper` is not an Umbraco class. It is a small project class I wrote to keep the routing rules in one place. Given a frontpage key and culture, it finds the site node that owns the frontpage. Given a site key and culture, it returns the selected frontpage or the fallback page. The URL provider uses the first operation. The content finder uses the second. I show the relevant parts of the helper further down. Delegating to `DefaultUrlProvider` is important. Umbraco already knows how to choose between relative and absolute URLs and how to handle schemes, hostnames, and cultures. My provider only swaps the content item from the frontpage to its site node. The official [outbound request pipeline documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/outbound-pipeline) covers provider ordering and the full constructor required when inheriting from `DefaultUrlProvider`. ## Resolving the site URL to the frontpage The content finder handles the other direction. When a request maps to a site node, it replaces the published content with that site's frontpage. The finder has `FrontpageUrlHelper`, `IUmbracoContextAccessor`, and `IDocumentUrlService` injected. The shortened method below shows where each one is used. ```csharp public Task<bool> TryFindContent(IPublishedRequestBuilder request) { IPublishedContent? frontpage = null; var path = request.Uri.AbsolutePath; if (request.HasDomain() && request.Domain!.Uri.AbsolutePath.Length > 1) { // Remove a domain path such as /dk before resolving the route. path = path[request.Domain.Uri.AbsolutePath.Length..]; } if (path.InvariantEquals("/") && request.HasDomain()) { // The matched domain already points to the site node. var site = _umbracoContextAccessor .GetRequiredUmbracoContext() .Content? .GetById(request.Domain!.ContentId); if (site is not null) { frontpage = _frontpageUrlHelper.GetFrontpageOfSite( site.Key, request.Culture); } } else { // Resolve the remaining path when the domain did not identify the site. var siteKey = _documentUrlService.GetDocumentKeyByRoute( path.EnsureStartsWith("/"), request.Domain?.Culture, request.Domain?.ContentId, false); if (siteKey.HasValue) { frontpage = _frontpageUrlHelper.GetFrontpageOfSite( siteKey.Value, request.Culture); } } if (frontpage is null) { return Task.FromResult(false); } // Returning true stops the remaining content finders. request.SetPublishedContent(frontpage); return Task.FromResult(true); } ``` The first branch handles a request to `/` when Umbraco has matched a domain directly to the site node. In that case, the domain already gives us the site node ID. The second branch handles requests where the path still needs to be resolved. This includes a site reached without a hostname and a domain with a path such as `example.com/dk`. For the latter, the code removes `/dk` from the request path first. An empty remainder becomes `/` through `EnsureStartsWith("/")`. `IDocumentUrlService.GetDocumentKeyByRoute()` can then resolve the route to a document key. `GetFrontpageOfSite()` only returns content when that key belongs to a site node. Umbraco runs content finders in collection order until one returns `true`. The [IContentFinder documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/icontentfinder) explains the interface and registration options. ## Falling back to the first page The content finder improves on the original setup in one small but useful way. If an editor has not selected a frontpage, it uses the first child with a template. ```csharp private IPublishedContent? GetFrontpage( IPublishedContent site, string? culture) { var frontpageUdi = site.Value<GuidUdi?>( "umbracoInternalRedirectId", culture, fallback: Fallback.ToLanguage) ?? site.Value<GuidUdi?>( "umbracoInternalRedirectId", culture, fallback: Fallback.ToDefaultLanguage); if (frontpageUdi is not null) { // Use the page selected on the site node. return _umbracoContextAccessor .GetRequiredUmbracoContext() .Content .GetById(frontpageUdi.Guid); } // A site without a selection uses its first renderable child. return site.FirstChild(child => child.TemplateId is not null); } ``` The picker lookup first uses the requested culture, then its fallback language, and finally the default language. If none of them has a value, the first renderable child becomes the frontpage. This means the site keeps working before somebody configures the picker. It also makes `umbracoInternalRedirectId` optional if the content finder handles all inbound requests in the project. ## Do not traverse the tree for every URL The first versions of this provider caused performance problems that were difficult to explain. Profiling eventually led back to frontpage URL generation. A URL provider can run many times during one request. A navigation, link collection, or sitemap may ask Umbraco to generate a URL for every item it contains. The early implementation searched through a large part of the content tree each time it had to decide whether an item was a frontpage. The fix was to build the mapping once and cache it per culture. ```csharp private sealed record CachedFrontpages( Dictionary<Guid, Guid> SiteKeyByFrontpageKey); private CachedFrontpages GetAllFrontpages(string? culture) { // Each culture can select a different frontpage. var cacheKey = $"Skttl.FrontpageUrl.FrontpageSettings__{culture}"; // Build the mapping only when this culture is not cached yet. return _runtimeCache.Get( cacheKey, () => BuildFrontpageMappings(culture)) as CachedFrontpages ?? new CachedFrontpages([]); } private CachedFrontpages BuildFrontpageMappings(string? culture) { var content = _umbracoContextAccessor .GetRequiredUmbracoContext() .Content; var siteKeyByFrontpageKey = new Dictionary<Guid, Guid>(); // This simplified example assumes every site is a root node of type site. var sites = content.GetAtRoot() .Where(node => node.ContentType.Alias.InvariantEquals("site")); foreach (var site in sites) { if (GetFrontpage(site, culture) is { } frontpage) { // The URL provider needs to look up the site from the frontpage. siteKeyByFrontpageKey[frontpage.Key] = site.Key; } } return new CachedFrontpages(siteKeyByFrontpageKey); } public IPublishedContent? GetSiteOfFrontpage( Guid frontpageKey, string? culture) { var frontpages = GetAllFrontpages(culture); return frontpages.SiteKeyByFrontpageKey.TryGetValue( frontpageKey, out var siteKey) ? _umbracoContextAccessor .GetRequiredUmbracoContext() .Content .GetById(siteKey) : null; } ``` This shortened version assumes that every site uses the `site` document type and lives at the content root. If a project allows nested site nodes or several site document types, I use `IDocumentNavigationQueryService` to collect those keys instead. Either way, building the cache reads each site's frontpage once. Normal URL generation then uses a dictionary lookup instead of repeating that work for every link. I no longer have useful before-and-after numbers from the original issue. It happened many years ago. The important lesson is in Umbraco's own guidance for custom URL providers: cache the work used to decide which provider owns a URL. ## Invalidate the cache when the site changes A cache of frontpage mappings is only correct until an editor selects another frontpage. The implementation clears it whenever a configured site node is published. ```csharp public class ClearFrontpageCache( AppCaches appCaches) : INotificationHandler<ContentPublishedNotification> { public void Handle(ContentPublishedNotification notification) { var siteWasPublished = notification.PublishedEntities.Any( content => content.ContentType.Alias.InvariantEquals("site")); if (siteWasPublished) { // ClearByKey removes the cached entry for every culture suffix. appCaches.RuntimeCache.ClearByKey( "Skttl.FrontpageUrl.FrontpageSettings"); } } } ``` The individual cache keys end with a culture, but `ClearByKey` clears every key that starts with the supplied value. Clearing the base key therefore invalidates all culture-specific frontpage mappings. ## Gotchas There are a few assumptions behind this implementation. **One frontpage belongs to one site.** Umbraco's Content Picker allows two site nodes to select the same content item. The cache maps one frontpage key to one site key, so the last mapping would replace the first. I have never needed to share one frontpage between sites, and the implementation deliberately does not support it. **The picker is not restricted to the current site.** I use a standard Content Picker and keep the setup simple. The helper does not verify that the selected page is a descendant of the site node. A stricter project could add that validation or configure a more constrained picker. I have not needed the extra guard so far. **The cache must vary by culture.** A variant site can select different frontpages for different languages. A single global mapping would return the wrong site or frontpage for at least one culture. **Domains can contain paths.** A culture or site may start at `/dk` rather than `/`. Remove the configured domain path from the request path, then add a leading slash before passing the remainder to `GetDocumentKeyByRoute()`. **Collection order matters.** Both URL providers and content finders stop when one handles the item or request. Register the frontpage implementations early enough to run before Umbraco's default handling. ## How to wire it together The main registrations fit in a composer. The exact `DefaultUrlProvider` constructor changes between Umbraco versions, which is why the earlier example focused on the overridden method. ```csharp public class FrontpageUrlComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.Services.AddSingleton<FrontpageUrlHelper>(); // Insert both routing components before Umbraco's default handling. builder.UrlProviders().Insert<FrontpageUrlProvider>(); builder.ContentFinders().Insert<FrontpageContentFinder>(); builder.AddNotificationHandler< ContentPublishedNotification, ClearFrontpageCache>(); } } ``` The helper examples use `umbracoInternalRedirectId` as the frontpage property and `site` as the site document type alias. Replace those strings in the helper and cache notification if your project uses different aliases. You can use a normal property alias such as `frontpage` when the custom content finder owns inbound routing. For multi-site solutions, put the hostname and culture on each site node. If the project has several site document types, check each alias when building and invalidating the cache. Umbraco's [multisite setup guide](https://docs.umbraco.com/umbraco-cms/tutorials/multisite-setup) covers domain mapping in the backoffice. ## Final thoughts The Ultimate Site Structure has lasted because it models the site more accurately. The site node is the stable boundary for domains, cultures, settings, and routing. The frontpage is a page that can change without reorganizing the rest of the content tree. The 2010 solution needed a modern routing implementation, but it did not need a new structure. A small URL provider, a content finder, and a carefully invalidated cache are enough to keep the pattern useful in current Umbraco projects. --- # Make Razor Partials Navigable in VS Code URL: https://skttl.dev/blog/go-to-partial/ Date: 2026-07-09 Description: A small VS Code extension that adds Go to Definition and live diagnostics for Razor partial view references in .cshtml files. Tags: vscode, razor, aspnet, umbraco, developer-tools I spend a lot of time in `.cshtml` files. That usually means jumping between views, partials, layout files, blocks, macros, and whatever naming convention the project has accumulated over the years. Most of that navigation works well enough in VS Code. But partial views have always had one small annoyance: the editor can show me the string `"_Header"` inside `@Html.Partial("_Header")`, but it does not know where that file actually lives. So I built [Go To Partial](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial), a small VS Code extension that adds Go to Definition support for Razor partial view references. The source is available on [GitHub](https://github.com/skttl/vsc-go-to-partial), and it is also published on [Open VSX](https://open-vsx.org/extension/skttl/vsc-go-to-partial). ## The problem Razor partials are usually referenced by name: ```html @Html.Partial("_Hero") @await Html.PartialAsync("_ArticleCard") <partial name="_Footer" /> ``` That is perfectly normal ASP.NET MVC and Razor Pages code, but it leaves a gap in the editor experience. When I want to inspect `_ArticleCard`, I either search for the file manually or use the file picker and hope the partial name is unique enough. On smaller projects this is just a minor interruption. On Umbraco projects, especially older ones, partials can live in several places: - next to the current view - in `Views/Shared/` - in `Views/Partials/` - in `Pages/Shared/` - inside an Area That means the string in the `.cshtml` file is not just text. It is a reference. I wanted VS Code to treat it like one. ## What Go To Partial does After installing the extension, you can press `F12` or `Ctrl+Click` a supported partial reference and jump directly to the resolved `.cshtml` file. It supports the common helper methods: ```html @Html.Partial("_Name") @Html.PartialAsync("_Name") @await Html.PartialAsync("_Name") @Html.RenderPartial("_Name") @Html.RenderPartialAsync("_Name") ``` It also supports the built-in partial tag helper: ```html <partial name="_Name" /> ``` Both short names and rooted paths work: ```html @await Html.PartialAsync("_ArticleCard") @await Html.PartialAsync("~/Views/Shared/_ArticleCard.cshtml") ``` For short names, the extension follows the expected ASP.NET lookup pattern. It checks the current folder first, then shared and partial folders, and it also handles `Areas/{areaName}/Views/Shared/` when the current file is inside an Area. ## Live diagnostics Navigation is useful when the reference exists. Broken references are the other half of the problem. Go To Partial also adds live diagnostics for unresolved partial references. If a partial cannot be found, VS Code shows a warning squiggle on the reference. That helps catch the boring mistakes early: - a renamed partial - a missing underscore - a typo in a string literal - a deleted view that is still referenced somewhere - a partial moved from `Views/Partials/` to `Views/Shared/` It is intentionally simple. It does not try to compile Razor, understand every possible C# expression, or become a full language server. It looks for the common static patterns and makes those nicer to work with. ## Custom tag helpers Some projects wrap partial rendering in custom tag helpers. For example: ```html <ec-partial name="_Footer" /> ``` You can tell the extension about those element names in your workspace settings: ```jsonc { "goToPartial.additionalPartialTagNames": ["ec-partial"] } ``` If your tag helper uses a custom attribute instead of `name`, you can configure that too: ```html <cached-partial view-name="_Navigation" /> ``` ```jsonc { "goToPartial.additionalTagHelperAttributes": ["view-name"] } ``` With that in place, `view-name="_Navigation"` becomes navigable in the same way as a regular partial reference. ## Installing You can install it from the VS Code Extensions panel by searching for **Go To Partial**. Or install it directly from: - [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial) - [Open VSX Registry](https://open-vsx.org/extension/skttl/vsc-go-to-partial) If you prefer installing from a `.vsix` file, grab the latest release from [GitHub Releases](https://github.com/skttl/vsc-go-to-partial/releases), then run **Extensions: Install from VSIX...** from the Command Palette. ## When it helps This is one of those tools that does not change how you build a site. It just removes a small bit of repeated friction. I find it most useful when working in: - Umbraco sites with many partial views - ASP.NET MVC projects with old view conventions - Razor Pages projects with shared page fragments - projects using custom tag helpers for rendering view fragments It is also handy when reviewing code. If I see: ```html @await Html.PartialAsync("_ProductTeaser", product) ``` I can jump straight into the partial instead of searching for it, checking whether it lives under `Views/Shared`, `Views/Partials`, or somewhere nearby. ## Small Tools, Less Friction Go To Partial is deliberately small. It adds editor navigation and warnings for a set of Razor patterns that I hit often, and it stays out of the way when those patterns are not present. If you work in `.cshtml` files and partial views are part of your daily route through a codebase, it should save a few tiny interruptions. Sometimes that is enough to make the editor feel a little more connected to the code you are actually reading. --- # Small UX Details Make AI Coding Better URL: https://skttl.dev/blog/small-ux-details-ai-coding/ Date: 2026-06-30 Description: After trying Devin, Windsurf, Codeium, Claude Code, and now the Codex desktop app, I keep coming back to the same thing: small interface details matter a lot when AI is writing code. Tags: ai, codex, umbraco, developer-tools For the past few months, I have been using Devin (the artist formerly known as Windsurf, formerly known as Codeium), plus a few detours through Claude Code and other AI coding tools. I was generally happy with them. Then a colleague lured me into trying the Codex desktop app. I was probably also getting slightly confused by the different agent and editor modes in Devin. Not in a dramatic "I can no longer operate a computer" way. More in the familiar developer way where you know the feature you need exists somewhere, but your brain has already wandered off before you find the right button. I have only used Codex for about a week, mostly on Umbraco projects, so this is not a grand verdict. It is more of a first impression from someone who has spent a lot of time letting AI write code, then trying to understand whether the code is something I actually want to keep. And my main takeaway so far is simple: small UX details make AI coding much better. ## Projects in the sidebar In Codex, my projects are just there in the sidebar. That sounds almost too basic to mention, but it makes a real difference. I can switch between project-specific chats without having to mentally reconstruct where I was. There is also a general chat for things that are not tied to a repository. That could be something useful, like: > Do I have access to the Figma MCP server? Or something more practical: > Should I walk, or take the car to the car wash? The useful bit is that I do not have to keep the whole map in my head. Project work lives with the project. Random questions live somewhere else. When you work across multiple client projects, package ideas, blog drafts, and experiments, that little bit of structure removes friction. I had even started building small VS Code extensions to improve the editors I was already using, including Devin and the many other VS Code-shaped editors. [Project Lobby](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-project-lobby) gives me a start page for jumping between repositories. [Go To Partial](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial) adds Go to Definition support for Razor partial references in `.cshtml` files, mostly because that felt like a feature that should exist. Neither of them are huge ideas. They are small quality-of-life improvements. Project Lobby solves one of the workflow papercuts that Codex also happens to handle nicely: project switching and keeping an overview. ## Vertical chat tabs are underrated Another tiny thing: chat tabs are listed vertically. This should not feel like a revelation, but after a long AI coding session it really does. Horizontal tabs quickly become a row of truncated titles where the important part is always the part you cannot see: - "Update Umbraco..." - "Update Umbraco..." - "Update Umbraco..." - "Continue from..." - "Continue from..." - "Fix review..." - "Fix review..." Those might be seven completely different tasks. One might be a package update, one might be a content model change, one might be a build fix, and one might be the thread where I finally explained the weird edge case properly. At that point, finding the right thread becomes more guessing than navigation. With vertical tabs, I can scan the list properly. Codex is also reasonably good at naming threads, which helps more than I expected. I do not need the interface to be clever all the time. I just need it to keep me oriented while I am juggling a task, a diff, a terminal, and a half-remembered requirement from three hours ago. ## Code review feels built in This is the part that has made the biggest difference for me. Codex opens a panel on the right with the changes it has made. I can go through them, line by line, and add comments directly on the code. Codex can then pick up those comments and continue from there. That workflow feels much closer to how I want AI coding to work. Not: 1. Ask AI to implement something 2. Receive a pile of code 3. Try to understand what happened 4. Hope I catch the weird parts before committing But more: 1. Define a contained task 2. Let the agent work 3. Review the changes in context 4. Leave specific comments 5. Let it adjust the implementation That is especially useful on Umbraco projects, where a change is often isolated enough to describe clearly, but still specific enough that I need to check the details. Backoffice extensions, package code, content model tweaks, build scripts, deployment helpers, these are all places where AI can move quickly, but where I still need to stay in control. The review experience makes that easier. It also helps reduce AI slop. Not because the model magically stops producing it, but because the interface makes it easier to catch, challenge, and reshape the output before it becomes part of the codebase. ## Git is close enough to use The Git integration is also simple in the right way. When the changes look good, committing them is only a click or two away. Codex can stage the relevant changes and write a commit message, or I can choose exactly what should be staged. Again, none of this is impossible elsewhere. It is just close enough to the work that I actually use it. That matters. AI coding already creates a lot of small decisions: - Is this task scoped well enough? - Did the agent understand the existing patterns? - Are the tests meaningful? - Is this change too broad? - Should this be one commit or three? If the tool can remove a few bits of ceremony around the boring parts, I have more attention left for the code itself. ## The built-in terminal helps Codex also has a built-in terminal, so I can run the project manually from the same place. My colleague is already at the point where he just asks Codex to start the project. I am not quite there yet. I still like typing the command myself. There is a small comfort in seeing `npm run dev`, `dotnet test`, or whatever else the project needs, and knowing exactly what is running. Maybe that will change. Maybe I just need a few more weeks before I fully accept my new colleague as the person operating the terminal. For now, I like that both options exist. Codex can run commands, but I can also take the wheel when I want to. ## I miss adaptive mode The one thing I miss from Devin is adaptive mode. In Devin's model selector, adaptive mode can decide how much thinking a task needs and which model makes sense for it. That suits me very well, because I am distractible by nature. I will absolutely forget what I set the tool to. And I am apparently patient enough that it can take a disturbingly long time before I notice I have had it on maximum thinking all day. In Codex, I mostly just sit on GPT 5.5 all the time. In Devin's adaptive mode, I would probably bounce between Claude Opus, Claude Sonnet, and one of the cheaper models, like SWE or Kimi, depending on the task. That kind of automatic model selection is not just about cost. It is also about rhythm. Some tasks need deep reasoning. Some tasks need a quick pass over a file. Some tasks are basically "rename this, update the tests, do not make a meal out of it." I would love Codex to have a similar mode. ## This is not really tool versus tool All of the things above are probably possible in other tools too. Whichever name or logo is currently being announced while I am still learning the previous one. This is not meant as a "Codex wins, everyone else go home" post. That would be silly, and it would probably age badly before I had finished writing it. What Codex has done for me, at least in this first week, is make the whole AI coding loop feel a little easier to stay inside: - The projects are easy to find - The threads are easier to scan - The diff is always close - Comments can become follow-up work - Git is nearby - The terminal is there when I need it None of those are magic features. They are small product decisions. But together they make the difference between "AI wrote some code somewhere" and "I can actually work with this." That is the part I care about. AI coding tools are useful when they help me move faster without making me feel less responsible for the result. The model matters, of course. But the interface around the model matters too. Sometimes it matters more than I expected. ## Be careful who you let users dance with I have only used Codex for a little under a week, so ask me again in a month and I may have defected to something else with a nicer sidebar, a better review flow, or a model selector that reads my mind. As the legendary Danish football coach Richard Møller Nielsen said: > You have to be careful about letting your wife dance with another man. So yes, another tool may come along and dance with me instead of Codex very soon. But for now, I am pretty happy. --- # Browsing Umbraco Logs in Kudu URL: https://skttl.dev/blog/browsing-umbraco-logs-in-kudu/ Date: 2026-05-26 Description: A new userscript that brings a proper Serilog log viewer into the Kudu interface: file picker, expression search, saved queries, and a momentum graph, all with no build step required. Tags: umbraco, kudu, userscripts, devops A while back I wrote [two userscripts that improve the Kudu interface for Umbraco Cloud](/blog/kudu-userscripts) (one for the Windows event log, one for deployment monitoring). Both make raw data that's already there a lot more usable. The same problem exists for application logs. Umbraco writes structured logs in Serilog compact JSON format, one JSON object per line. Finding the right file is already a needle-in-a-haystack problem before you've even opened it. On a long-running project there can be hundreds of log files in the directory, and Kudu's file listing caps out at a fixed number of entries, so older files simply don't show up, unless you "hack it" using javascript (by increasing the value of `maxViewItems` in localStorage to a huge number). And when you do open one, you're staring at a wall of unformatted JSON. Umbraco's backoffice already has a decent log viewer. It parses the same Serilog JSON, lets you filter by level, and supports expression queries. The problem is it's only available when the site is running. If your site won't start (a failed upgrade, a missing dependency, a bad config change), the backoffice is exactly what you can't reach. Kudu is still there, the log files are still there, but there's nothing to help you read them. For Umbraco Cloud projects, you can download the file through the Cloud portal, but it's SLOW, and you still have to open it in a text editor - or the amazing [Compact Log Viewer](https://apps.microsoft.com/detail/9n8rv8lktxrj) by Warren Buckley. So I built a third script: a log viewer that runs inside the Kudu tab you already have open, inspired by the backoffice Log Viewer but available even when the site is completely down. ![Kudu Umbraco Log Viewer](/images/blog/browsing-umbraco-logs-in-kudu/umbraco_log_viewer.png) ## What it does Installing the [Kudu Umbraco Log Viewer](https://github.com/skttl/umbraco-userscripts) adds an "Umbraco Logs" link to the Kudu navbar. Click it and you get: - **File picker**: lists `.json` log files from `/umbraco/Logs/`, sorted newest first, grouped by month so large archives are scannable at a glance. - **Parsed log entries**: each line is parsed from Serilog compact JSON. The `@mt` message template is rendered with its property values when the pre-rendered `@m` field is absent. - **Expandable rows**: click any entry to see the exception stack trace, the raw message template, and all structured properties in a nested table. - **Color-coded level labels**: Badges for Verbose, Debug, Information, Warning, Error, and Fatal. - **Level filter**: a dropdown with per-level checkboxes. - **Sort toggle** and **client-side pagination** at 100 entries per page. ## Expression search The search box accepts either plain text (substring match) or a Serilog-compatible expression (the same syntax you'd use in the backoffice Log Viewer). Some examples: ``` @Level='Error' Not(@Level='Verbose') and Not(@Level='Debug') @Message like '*timeout*' or @Level='Fatal' MachineName='web01' @Exception like '*SqlException*' ``` Supported: `@Level`, `@Message`, `@Exception`, `@mt`, `@t`, any structured property, operators `=` / `!=` / `like` (glob `*`/`?`) / `>` / `>=` / `<` / `<=`, and boolean `And` / `Or` / `Not(...)`. There's also a **?** button that opens a cheat sheet with clickable example queries. For queries you use often, the **★ Save** button persists named searches to `localStorage`. ## Momentum graph Above the log table there's a stacked bar chart split into 60 time buckets covering the full span of the log file. Each bar is broken down by level. The bars are clickable: clicking a bucket jumps to the page containing those entries. For a large log file this gives you immediate orientation before you've typed a single query. You can see exactly when an error spike happened and click straight into it. ## Updates to the existing scripts Both the Event Log Viewer and the Deployment Viewer got a v1.1 update alongside this release: - **`@updateURL` and `@downloadURL`** added to all three scripts. Tampermonkey can now auto-update them without a reinstall. - **`@match`** replaces `@include` for better cross-browser compatibility. - **XSS fixes**: user-facing values rendered into `innerHTML` are now properly escaped in both scripts. - **Deployment Viewer**: log line rendering was duplicated in three places; extracted into a single `renderLogLines` function. The `triggerNewDeployment` handler was also fixed: v1.0 fired a success toast on a `setTimeout` regardless of the response; v1.1 awaits the fetch and only toasts on the actual HTTP result. ## Installing All three scripts are in the [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository. Each has a one-click install link in its README. You need [Tampermonkey](https://www.tampermonkey.net/) (available for Chrome, Firefox, and Edge). The scripts are zero-dependency, single-file IIFEs. No build step, no npm. Install from the raw GitHub URL and you're done. > **Tip:** With `@updateURL` in place, Tampermonkey will check for updates automatically. You can also trigger a manual check from the dashboard. ## Final Thoughts I hope these scripts save you some time. They're simple, focused, and designed to work with the tools you already have. If you find them useful, great! If you have ideas for improvements or run into issues, feel free to open an issue on the repository. 🇩🇰👑 And a happy birthday to King Frederik X of Denmark! --- # Smarter redirects when editors trash content in Umbraco URL: https://skttl.dev/blog/smarter-redirects-when-editors-trash-content-in-umbraco/ Date: 2026-05-21 Description: A new Umbraco package that replaces the default trash action. It captures the URLs of doomed content first, then suggests redirect targets using Umbraco.AI.Search, with a non-AI fallback for bulk trashes. Tags: umbraco, ai, package, hackathon, backoffice Last week, a good handful of Umbraco developers met up at Ecreo's office in Odense for an Umbraco AI Hackathon, guided by the DevRel team from Umbraco HQ. One of the ideas Sebastiaan Janssen pitched on the day was an old one: when an editor trashes a published page, the URL it used to live on quietly stops working, and Umbraco doesn't help you redirect it anywhere. I recognised the idea instantly. I'm fairly sure I saw an issue about exactly this on the old CodePlex tracker, somewhere around the time built-in URL tracking shipped in Umbraco 7.5. It's been on my mental wishlist for roughly a decade. A hackathon day felt like a good excuse to finally build it. The result is a new package: [Umbraco.Community.SmartRedirectSuggester](https://marketplace.umbraco.com/package/umbraco.community.smartredirectsuggester). ## What it does When an editor trashes a published document, the package replaces the default trash dialog with one that: - captures the document's current URL (and the URLs of every published descendant, if you trash a parent) - asks an Umbraco searcher to suggest likely redirect targets for each of those URLs - lets the editor confirm, change, or skip each target - registers the chosen redirects in Umbraco's built-in URL tracker after the trash succeeds ![Single trash with suggested redirect](/images/blog/smart-redirect-suggester/single-trash-suggested-redirects.png) ## The wrong turn: hooking into a post-trash event My first instinct was to keep the standard trash flow intact and just react afterwards. My AI agent helpfully pointed at a client-side backoffice notification that fires once a document has been moved to the recycle bin. I wired up a modal to pop on that event and got something running quickly. It took embarrassingly little testing to realise the obvious problem. By the time my modal opened, the document was already in the recycle bin. Its published route was gone. `GetUrl(...)` had nothing to return. I could happily ask the user to pick a redirect *target*, but I'd already lost the redirect *source*. The very URL I needed to redirect from was gone. The thing I wanted to preserve had been forgotten between the click and the callback. ## The right turn: replacing the trash action Instead of listening for a post-trash event, I needed to take over the trash action itself. The new backoffice makes that fairly clean. Entity actions are extension points, and an extension can declare that it overwrites an existing alias. Point that at the core `Umb.EntityAction.Document.RecycleBin.Trash` alias (and its bulk equivalent), reuse the same repositories and conditions as the original, and the extension registry quietly swaps my action in for the built-in one. Only the UX is replaced, not the authorisation rules. With that hook in place, the flow becomes: 1. **Prepare.** When the user clicks "Trash", the action calls a `prepare` API endpoint that expands the selection to include published descendants, captures their current URLs, and asks for redirect suggestions. Crucially, this happens while the documents are still published and routable. 2. **Trash.** The action runs the standard trash logic and the document(s) move to the recycle bin. 3. **Commit.** Once the trash succeeds, the action calls a `commit` endpoint that registers the chosen redirects against the cached old URLs. The legacy URLs are kept in a short-lived server-side cache between `prepare` and `commit`. That's how the package can still create redirects after the routes have technically vanished. ## AI suggestions, with a sensible fallback For the actual suggestion step, the package asks Umbraco's search infrastructure for similar pages. It tries two searchers in order: 1. **`UmbAI_Search`**, provided by [Umbraco.AI.Search](https://www.nuget.org/packages/Umbraco.AI.Search). This is the preferred path. In my experience, AI-powered search matches "this page is about a discontinued product" to "this is the page about its successor" far better than keyword search ever does. 2. **`Umb_Content`**, provided by [Umbraco.Cms.Search](https://www.nuget.org/packages/Umbraco.Cms.Search). The non-AI fallback. Faster, free, and lower-quality, but still useful, especially for bulk operations. If neither searcher is registered, the package still loads and the modal still opens. The editor just gets an empty suggestion list and can pick a target manually or skip the redirect. ## Bulk trashes and the 500-node moment The package handles bulk trashes too. There's an entity bulk action on document collections, plus the implicit "trash a parent and take its descendants with it" case. Both go through the same `prepare` step, which means a single bulk trash can produce dozens or hundreds of suggestion lookups. While testing, I trashed about 500 nodes in one go to see what would happen. It worked. The modal listed every URL with its suggested target and let me eyeball them. Then I remembered I'd done all 500 lookups against `UmbAI_Search`. I haven't gone back to look at my AI billing for that day, and I'd rather not. But this is the headline gotcha of the package: AI searchers are great for accuracy, and potentially expensive when you point them at a lot of content at once. The `Umb_Content` fallback exists partly *because* of this. It's a deliberate feature, not just a degraded mode. > **Heads up:** if you're trashing a parent with a large subtree, be aware that every published descendant becomes a search call. With `UmbAI_Search` enabled, that's a real cost. ## How to install The package needs Umbraco 17 and at least one searcher. ```bash dotnet add package Umbraco.Community.SmartRedirectSuggester dotnet add package Umbraco.Cms.Search ``` For AI-powered suggestions, also install: ```bash dotnet add package Umbraco.AI.Search ``` That's it. Restart the site, trash a published page, and the new dialog takes over. ![Multiple trashes with suggested redirects](/images/blog/smart-redirect-suggester/multiple-trashes-suggested-redirects.png) ![Changing the redirect for an individual item in a bulk operation](/images/blog/smart-redirect-suggester/change-redirect-for-individual-in-multiple.png) ![Redirect management overview](/images/blog/smart-redirect-suggester/redirect-management.png) ## What I'd change A hackathon day buys you a working package. It doesn't buy you every nice-to-have. The thing I'd add next is a guard against the bulk-AI cost trap: - A config switch to force `Umb_Content` regardless of whether `Umbraco.AI.Search` is installed. - Or, better, a soft prompt in the modal when the bulk count crosses a threshold: *"You're about to ask AI Search for suggestions on 500 documents. Use the non-AI fallback instead?"* Both feel more honest than just hoping editors notice the bill at the end of the month. ## Final thoughts This is the kind of feature that's been sitting in Umbraco's "yeah, that'd be nice" pile for years, and it took a hackathon day with a clear pitch and good company to finally push it out the door. Thanks to the Umbraco HQ DevRel team for organising, to Sebastiaan Janssen for the idea, and to Ecreo for hosting. The repo, with installation docs, screenshots and the full source, lives at [skttl/umbraco-smart-redirect-suggester](https://github.com/skttl/umbraco-smart-redirect-suggester). You are welcome to create an issue, especially if you have opinions on how the bulk-AI guardrails should look. --- # Releasing Image Hotspot 17.0.0 URL: https://skttl.dev/blog/releasing-image-hotspot-17/ Date: 2026-05-18 Description: What's new in version 17.0.0 of the Image Hotspot property editor: Umbraco 17 support, block editor fixes, localization in 29 languages, and a new versioning scheme. Tags: umbraco, package A couple of things had been bothering me about the Image Hotspot package for a while. The image sometimes failed to appear in the editor due to a race condition, and the hotspot didn't work at all inside block editors. Version 17.0.0 fixes both of those, and picks up a few other improvements along the way. ## What's new **Block editor support.** Previously the editor only resolved the image property from the document workspace context, which meant it broke when the hotspot was placed inside a Block List or Block Grid element. It now consumes `UMB_PROPERTY_DATASET_CONTEXT` first (which covers block items), then falls back to the parent dataset context, then the document workspace, and finally walks the document tree recursively until it finds a match. **Race condition fix.** When the image property value changed quickly, or when multiple contexts resolved in quick succession, you could end up with the wrong image or no image at all. The load logic now uses a generation counter: each new load attempt increments the counter, and any async result that arrives after a newer attempt has started is simply discarded. **UUI components throughout.** The editor UI now uses `uui-box`, `uui-tag`, and `uui-table` wherever it makes sense, rather than bare HTML elements. Previously, if the image property alias wasn't configured correctly, the editor would just render broken with no indication of why. It now shows a proper warning with a collapsible debug table that lists exactly where it looked and what it found. ![The hotspot editor showing a placed hotspot on an image](/images/blog/releasing-image-hotspot-17/hotspot-editor-example.png) ![The hotspot editor warning state when the image property alias is not found](/images/blog/releasing-image-hotspot-17/hotspot-editor-failed.png) **Localization in 29 languages.** I asked my AI agent to find all hardcoded strings in the UI and add proper localization for them. It replaced them with `<umb-localize>` and generated locale files for all languages that ship with Umbraco. A task that would have taken an afternoon of copy-pasting, done in minutes. ## A new versioning scheme The package has switched from rolling SemVer to an Umbraco-based scheme: `[UmbracoMajor].[PackageMajor].[PackageMinor]`. So `17.0.0` means: targets Umbraco 17, first major release for that version, no minor changes yet. The old scheme (`0.1.0`, `0.2.0`, `0.3.0`) worked fine until I needed to remember which version supported which Umbraco. I never could. Browsing NuGet for a compatible version meant checking the package description or release notes rather than just reading the number. The new scheme makes the minimum Umbraco dependency obvious at a glance, both when installing and when planning an Umbraco major upgrade. Other community packages do the same. [ManifestPeek](https://www.nuget.org/packages/Umbraco.Community.ManifestPeek) and [SimpleWorkspaceViews](https://www.nuget.org/packages/Umbraco.Community.SimpleWorkspaceViews) both ship at 17.0.0 for Umbraco 17, and it makes sense to me. > **Note:** This versioning scheme is not standard SemVer. If your tooling enforces strict SemVer, the major version bump on every Umbraco upgrade may look alarming. It just means "this release targets a new Umbraco major". Which to me is also quite breaking, and in need of a major version bump. ## Installing ```bash dotnet add package Umbraco.Community.ImageHotspot ``` The package is available on [NuGet](https://www.nuget.org/packages/Umbraco.Community.ImageHotspot) and listed on the [Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.imagehotspot). Source is on [GitHub](https://github.com/skttl/umbraco-image-hotspot). ## Final Thoughts The block editor fix and the race condition fix were the main reasons for this release. Everything else came along for the ride once I was in there. The localization in particular is the kind of thing that's easy to skip when you're building a small package. It feels disproportionate to the effort, but with AI doing the translation work, there's not much reason not to. --- # Two Umbraco Cloud CLI spinoffs: fetch DB and media into your local dev URL: https://skttl.dev/blog/umbraco-cloud-fetch-database-and-media/ Date: 2026-05-04 Description: Two wizard CLIs that pull a fresh database and media folder from Umbraco Cloud straight into your local dev project - spinoffs from the archiver, useful on their own. Tags: umbraco, umbraco-cloud, cli, devops While building [`umbraco-cloud-archiver`](/blog/umbraco-cloud-archiver/) I realised I had 80% of the plumbing for a tool I actually wanted day-to-day: a one-shot way to refresh my **local dev environment** with real Cloud data, without doing the whole archive dance every time. So I pulled the database and blob bits out into two small spinoff CLIs: - [`umbraco-cloud-fetch-database`](https://www.npmjs.com/package/umbraco-cloud-fetch-database) - downloads the database from a Cloud environment and replaces the LocalDB `.mdf` / `.ldf` files of your local Umbraco project. - [`umbraco-cloud-fetch-media`](https://www.npmjs.com/package/umbraco-cloud-fetch-media) - downloads the `media/` folder from a Cloud environment's blob storage into your project's `wwwroot/media`. They are not companions to the archiver - they do completely different things. You may find great use in these two without ever needing the archiver, and vice versa. ## Try Umbraco Deploy first Before I go any further: if you are on Umbraco Cloud, **Umbraco Deploy is the right tool for syncing schema and content between environments**. It is what it is built for, and it does it well. Always reach for that first. The reason these CLIs exist is that on **large content-heavy sites**, pulling content via Deploy can take a while. Sometimes I just want a fresh snapshot of the database and a copy of the media folder on disk, fast, so I can debug a content-shaped bug, reproduce something a client is seeing, or onboard a new project to my laptop. That is the gap these two fill. ## umbraco-cloud-fetch-database Run it from the root of your Umbraco web project (the folder that contains `*.csproj`): ```sh npx umbraco-cloud-fetch-database@latest ``` The wizard: 1. Verifies you are inside an Umbraco web project. 2. Lets you pick an `appsettings.*.json` file (defaults to `appsettings.Development.json`). 3. Detects how LocalDB is configured in that file (more on that below). 4. Verifies the existing `.mdf` / `.ldf` files exist and are not locked. 5. Prompts for **server / login / password / database** of the source Cloud database. 6. Shows a summary and asks for confirmation. 7. Ensures `sqlpackage` is on PATH, or offers to download it into a per-user cache. 8. Backs up the existing `.mdf` / `.ldf` to `<dataDir>/backup/<timestamp>/` (keeping the 3 most recent). 9. Detaches the local database from `(LocalDB)\MSSQLLocalDB`. 10. Exports a `.bacpac` from the Cloud server with `sqlpackage /Action:Export`. 11. Imports the bacpac into LocalDB under a temporary name with `sqlpackage /Action:Import`. 12. Detaches that temporary database and moves its `.mdf` / `.ldf` into the paths your project expects. 13. Cleans up the bacpac on success. Umbraco re-attaches the database on next start because of `AttachDbFileName`. ![The umbraco-cloud-fetch-database wizard prompting for the source database details](/images/blog/umbraco-cloud-fetch-database-and-media/fetch-database-wizard.png) ### The two LocalDB shapes it detects The tool recognises both common ways an Umbraco ASP.NET Core project can be configured to use LocalDB. The classic connection-string shape: ```json { "ConnectionStrings": { "umbracoDbDSN": "Server=(LocalDB)\\MSSQLLocalDB;AttachDbFileName=|DataDirectory|\\Umbraco.mdf;Integrated Security=True" } } ``` And the Deploy-driven shape, which is what you get on a Cloud-style local setup: ```json { "Umbraco": { "Deploy": { "Settings": { "PreferLocalDbConnectionString": true } } } } ``` In the second case the tool assumes the standard Cloud project layout with the database files at `umbraco/Data/Umbraco.mdf` (and matching `.ldf`). > **Note:** The Cloud SQL password is entered interactively at the prompt. It is never written to disk or to any config. ![umbraco-cloud-fetch-database exporting the bacpac and swapping the LocalDB files](/images/blog/umbraco-cloud-fetch-database-and-media/fetch-database-running.png) ## umbraco-cloud-fetch-media Same story, different payload. Run it from the project root: ```sh npx umbraco-cloud-fetch-media@latest ``` The wizard: 1. Verifies the current directory is an Umbraco web project. 2. Asks for the **container-level SAS URL** of the blob container (e.g. `https://<account>.blob.core.windows.net/<container>?sv=...`). 3. If `wwwroot/media` already contains files, asks whether to back it up first. 4. Shows a summary and asks for confirmation. 5. Ensures `azcopy` is available, or offers to download it into a per-user cache. 6. Optionally copies the current `wwwroot/media` to `<project-root>/.media-backup/<timestamp>/` (keeping the last 3 backups). 7. Deletes the existing `wwwroot/media` folder and recreates it empty. 8. Runs `azcopy copy "<container>/media/*?<sas>" "<wwwroot/media>" --recursive=true`. The backup folder is `.`-prefixed and lives outside `wwwroot`, so the ASP.NET Core static file middleware will not serve it, and it is easy to add to `.gitignore`. ![The umbraco-cloud-fetch-media wizard asking for the container SAS URL](/images/blog/umbraco-cloud-fetch-database-and-media/fetch-media-wizard.png) ### Finding the SAS URL In the Umbraco Cloud portal: open your project → environment → **Configuration** → **Connections** → **Blob Storage**. You can copy the SAS url from there. ![umbraco-cloud-fetch-media running azcopy to pull the media folder into wwwroot/media](/images/blog/umbraco-cloud-fetch-database-and-media/fetch-media-running.png) ## Pitfalls and gotchas A few things to be aware of: - **Windows only.** Both tools refuse to run elsewhere. LocalDB is a Windows feature, and the media tool currently follows suit. - **Run from the project root.** Both tools expect to find a `*.csproj` referencing `Umbraco.Cms*` in the current directory. - **Stop the site first.** If Umbraco / IIS Express / `dotnet run` is still running, the `.mdf` is locked and the swap will fail. Kill the site before running the database tool. - **The DB files must already exist.** The database tool *replaces* the existing `.mdf` / `.ldf`. If you have just cloned the repo and never started the project, there is nothing to replace yet - run Umbraco once first so it creates the files. - **Container-level SAS only.** The media tool needs a SAS URL scoped to the **container**, not to a single blob. A blob-level SAS will not work. - **Whitelist your IP.** Cloud SQL is firewalled. Add your current IP under the SQL server's firewall settings on the Connections page in the Cloud portal, otherwise `sqlpackage` will sit there until it times out. - **Backups rotate to the last 3.** Both tools keep the three most recent backup folders and delete older ones. Treat them as a "let me undo the last refresh" safety net, not as long-term archives - that is what [`umbraco-cloud-archiver`](/blog/umbraco-cloud-archiver/) is for. - **Password is interactive, never stored.** The SQL password is prompted for each run and is not persisted anywhere. ## Outside Umbraco Cloud Although these tools are built around Umbraco Cloud's conventions, there is nothing Cloud-specific about the actual work they do. Any Umbraco ASP.NET Core project with a reachable SQL Server and an Azure blob container can be the source. The catch is the **detection logic**: both tools sniff `appsettings.*.json` and the project layout assuming a Cloud-style setup. If your self-hosted project uses a different connection string shape, a different media path, or stores blobs somewhere else, the wizard may not recognise it. In that case you will probably get further by extending the tool than by working around it - PRs welcome. ## Final thoughts The archiver was the "this old project is being retired, save everything" tool. These two are the everyday-dev versions: one `npx` for the database, one `npx` for the media, and your local site is running on a fresh Cloud snapshot. Use Deploy as the primary way to sync content - but when you just need the bits on disk in a hurry, these are nice to have around. Source, issues and PRs are on GitHub: - [skttl/umbraco-cloud-fetch-database](https://github.com/skttl/umbraco-cloud-fetch-database) - [skttl/umbraco-cloud-fetch-media](https://github.com/skttl/umbraco-cloud-fetch-media) And if you want the whole-project archive flavour, that one lives over at [skttl/umbraco-cloud-archiver](https://github.com/skttl/umbraco-cloud-archiver). Feedback very welcome on all three. --- # I built a CLI to archive Umbraco Cloud projects URL: https://skttl.dev/blog/umbraco-cloud-archiver/ Date: 2026-04-29 Description: When a client upgrade meant retiring an old Umbraco Cloud project, I wanted a complete local archive before pulling the plug. So I built a wizard CLI to do it. Tags: umbraco, umbraco-cloud, cli, devops I recently finished upgrading a client from Umbraco 8 all the way to Umbraco 17. Because of the switch from old ASP.net Framework in Umbraco 8 to the modern ASP.net Core in Umbraco 17, the only path was a fresh build on a brand new Umbraco Cloud project. Once the v17 site was live, the old v8 Cloud project had to go. Before clicking that scary "delete project" button, I wanted a safety net. Not just a SQL backup, but **everything**: the full git history, the media blobs, and the database. Future me might want to dig something up - a content snippet, a piece of code, an asset I forgot we had - and "we deleted it" is a terrible answer. So I built a small CLI to do exactly that, and published it to npm: [`umbraco-cloud-archiver`](https://www.npmjs.com/package/umbraco-cloud-archiver). ## What "archive" actually means A Cloud project has three things worth keeping, and each has its own quirk: - **Git repository** - Cloud gives you a git remote per environment. You want both a `--mirror` clone (full history, all branches, all tags) and a working copy you can actually browse without git commands. - **Blob storage** - The media files live in Azure Blob Storage. You can grab them with a container-level SAS URL from the Cloud portal. - **Database** - The trickiest one. You can either export a `.bacpac` with `sqlpackage` if you have SQL credentials, or download the latest backup manually from the Cloud portal. Doing all of that by hand, for `live`, `stage`, and `dev`, is the kind of repetitive task I'd rather automate once than do carefully three times. ## Introducing umbraco-cloud-archiver It's a wizard-based CLI. One command, no flags to remember: ```sh npx umbraco-cloud-archiver@latest ``` It walks you through the archive step by step, asks for the bits it needs, and dumps everything into a tidy folder structure on disk. ![The umbraco-cloud-archiver wizard running in the terminal](/images/blog/umbraco-cloud-archiver/wizard.png) ## How to use it The wizard has four phases: 1. **Pick an output base folder.** Everything ends up here, one subfolder per environment. 2. **Choose database backup mode.** Either skip the DB entirely, or use `sqlpackage` to export a `.bacpac`. If you skip, the tool drops a `MANUAL_BACKUP_REQUIRED.txt` reminder so you don't forget to grab it from the portal. 3. **Add environments one by one.** For each environment you give it a name (`live`, `stage`, `dev`), the git clone URL, the blob storage SAS URL, and optionally DB credentials. After each one it asks "add another?" until you're done. 4. **Confirm and run.** It prints a summary, you confirm, and it gets to work. > **Tip:** You can find the SQL connection details and container-level SAS URL in the Umbraco Cloud portal under the environment's Configuration section, on the Connections page. Remember to whitelist your IP address in the SQL server's firewall settings before running the wizard. This is done on the Connections page too. The git clone URL is on the environment overview page. If `azcopy` or `sqlpackage` aren't on your PATH, the tool offers to download them into a per-user cache (`~/.umbraco-cloud-archiver/bin/`) so you don't have to install anything globally. ![The wizard downloading blobs and exporting the database](/images/blog/umbraco-cloud-archiver/downloading.png) ## What you end up with ``` <base>/ live/ git-mirror/ # bare clone, full history repo/ # working copy (browseable) blobs/ # blob storage contents database/ <dbname>.bacpac # if sqlpackage was used MANUAL_BACKUP_REQUIRED.txt # otherwise stage/ ... archive-info.json # metadata about this run ``` A self-contained snapshot of the whole project that you can zip up, drop on a NAS, hand to the client, or just keep around in cold storage. ![The resulting folder structure on disk](/images/blog/umbraco-cloud-archiver/folderstructure.png) ## Prerequisites - **Node.js 20+** - **git** in your PATH - A **container-level SAS URL** per environment (Umbraco Cloud → environment → Storage) - For DB export: SQL Server credentials with permission to export, *or* grab the backup manually from the Cloud portal ## Final thoughts This started as a one-off script for a single client shutdown, but it solved the problem cleanly enough that I figured I'd polish it up and share it. If you're about to retire an old Umbraco Cloud project - whether because of an upgrade, a migration, or a project ending - give it a try and let me know how it goes. Source, issues and PRs are on GitHub: [skttl/umbraco-cloud-archiver](https://github.com/skttl/umbraco-cloud-archiver). Feedback very welcome. --- # Auto-Register Block Previews in Umbraco URL: https://skttl.dev/blog/auto-register-block-preview-in-umbraco/ Date: 2026-04-17 Description: Skip manual registration and let your project automatically discover which blocks should have preview enabled based on existing views. Tags: umbraco, csharp The [Block Preview package](https://github.com/rickbutterfield/BlockPreview) by Rick Butterfield is a fantastic addition to any Umbraco project using Block List or Block Grid. It gives editors a real-time preview of their blocks right in the backoffice, instead of a generic placeholder. By default, the package wants to preview all blocks, which might be fine for some. But I use block editors for lots of other things, where a preview doesn't make sense. So you can configure which blocks to preview, but that's still manual work and as your project grows and you add more blocks, you need to keep remembering to register each one in your `BlockPreviewOptions`. That's easy to forget, and it means developers less comfortable with C# need to touch configuration code every time they create a new block. What if registration just... happened automatically? ## The Idea The Block Preview package looks for views in specific default locations. If you follow those conventions, there's no reason you should also have to manually tell the package which blocks to enable - it should be able to figure that out itself. My approach: 1. Scan the default view locations for `.cshtml` files 2. Also check for precompiled Razor views in the assembly 3. Match file names to content type aliases 4. Register only the blocks that have a corresponding view This way, adding a new block with a preview is as simple as creating the view file. No C# changes required. ## The Extension Method Here's an extension method that replaces the standard `AddBlockPreview()` registration: ```csharp using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using System.Reflection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Extensions; using BlockPreview; using BlockPreview.Settings; public static class AddBlockPreviewsExtension { public static IUmbracoBuilder AddBlockPreviews(this IUmbracoBuilder builder) { var stylesheet = new string[] { "/build/css/main.css" }; builder.AddBlockPreview(); builder.Services.AddSingleton<IConfigureOptions<BlockPreviewOptions>>(sp => { var env = sp.GetRequiredService<IWebHostEnvironment>(); var contentTypeService = sp.GetRequiredService<IContentTypeService>(); var contentTypeAliases = contentTypeService.GetAllContentTypeAliases().ToList(); return new ConfigureOptions<BlockPreviewOptions>(options => { options.RichText = GetSettings( env, contentTypeAliases, BlockPreviewConstants.DefaultViewLocations.RichText, stylesheets); options.BlockGrid = GetSettings( env, contentTypeAliases, BlockPreviewConstants.DefaultViewLocations.BlockGrid, stylesheets); options.BlockList = GetSettings( env, contentTypeAliases, BlockPreviewConstants.DefaultViewLocations.BlockList, stylesheets); }); }); return builder; } private static BlockWithStylesheetSettings GetSettings( IWebHostEnvironment hostingEnvironment, List<string> contentTypeAliases, string defaultViewLocation, List<string>? viewLocations = null, string[]? stylesheets = null) { viewLocations = viewLocations.EmptyIfNull().ToList(); var contentTypes = GetViewNames( hostingEnvironment, contentTypeAliases, viewLocations, defaultViewLocation); return new() { ViewLocations = viewLocations, ContentTypes = contentTypes, Enabled = contentTypes.Any(), Stylesheets = stylesheets }; } private static List<string> GetViewNames( IWebHostEnvironment hostingEnvironment, List<string> contentTypeAliases, List<string>? viewLocations, string? defaultLocation = null) { var locations = (viewLocations ?? []) .Append(defaultLocation) .WhereNotNull() .Select(CleanPath) .Select(x => x.TrimEnd("{0}.cshtml")) .ToList(); // Find view files on disk var files = locations .Select(hostingEnvironment.MapPathWebRoot) .Where(Directory.Exists) .SelectMany(location => Directory.EnumerateFiles(location, "*.cshtml", SearchOption.AllDirectories)) .Select(CleanPath) .WhereNotNull(); // Find precompiled Razor views in the assembly var assembly = Assembly.GetExecutingAssembly(); var allRazorViews = assembly .GetCustomAttributes<RazorCompiledItemAttribute>() .Select(x => x.Identifier) .Select(CleanPath) .WhereNotNull(); var razorViews = locations .SelectMany(location => allRazorViews .Where(view => view.InvariantStartsWith(location))); // Match view names to content type aliases return ((List<string>)[.. files, .. razorViews]) .Select(Path.GetFileNameWithoutExtension) .Select(view => contentTypeAliases .FirstOrDefault(alias => alias.InvariantEquals(view))) .WhereNotNull() .ToList(); } private static string CleanPath(string path) { return path .Replace('/', Path.DirectorySeparatorChar) .Replace('\\', Path.DirectorySeparatorChar) .EnsureStartsWith(Path.DirectorySeparatorChar); } } ``` ## How It Works The extension method does three things: 1. **Calls the standard registration** - `builder.AddBlockPreview()` still sets up the package as normal. 2. **Configures options via DI** - By registering an `IConfigureOptions<BlockPreviewOptions>`, we can modify the options after the default setup, with access to Umbraco services. 3. **Auto-discovers content types** - For each editor type (Block Grid, Block List, Rich Text), it scans the default view locations and matches file names to content type aliases. The `GetViewNames` method handles both scenarios: - **Views on disk** in `wwwroot` (development) - **Precompiled Razor views** baked into the assembly (production/deployment) # Using It Replace your existing Block Preview registration in `Program.cs` or your composer: ```csharp // Before builder.AddBlockPreview(options => { options.BlockGrid.ContentTypes = ["heroBlock", "textBlock", "imageBlock"]; options.BlockGrid.Stylesheets = ["/build/css/main.css"]; }); // After builder.AddBlockPreviews(); ``` That's it. Now any block with a view in the default location gets preview automatically. ## Customization You might want to customize the stylesheet path or add additional view locations. Simply modify the extension method: ```csharp public static IUmbracoBuilder AddBlockPreviews( this IUmbracoBuilder builder, string[] stylesheets = ["/css/main.css"]) { // ... use the stylesheet parameter } ``` Or if you have blocks that should explicitly *not* have preview (like a tracking code snippet block), you could add an exclusion list: ```csharp var excludedBlocks = new[] { "htmlSnippetBlock", "trackingCodeBlock" }; var contentTypes = GetViewNames(/* ... */) .Where(x => !excludedBlocks.InvariantContains(x)) .ToList(); ``` ## Benefits - **Zero maintenance** - New blocks with views are automatically registered - **Developer-friendly** - Frontend developers can add block views without touching C# - **Convention over configuration** - Follow the default view locations and everything just works - **No forgotten registrations** - If a view exists, it's registered ## Final Thoughts This approach trades explicit configuration for convention-based discovery. For teams where multiple developers work on blocks, this removes a common friction point and source of "why isn't my preview working?" questions. --- # Editor Friendly 404 Pages in Umbraco URL: https://skttl.dev/blog/editor-friendly-404-page-in-umbraco/ Date: 2026-04-09 Description: How to create customizable 404 pages that editors can manage, with support for multi-site setups using a ContentLastChanceFinder. Tags: umbraco, csharp, contentfinder When a visitor lands on a page that doesn't exist, a helpful 404 page can make all the difference. But hardcoded error pages are a maintenance burden and don't give editors the flexibility they need. Let's fix that by creating editor-friendly 404 pages in Umbraco. ## The Site Structure This approach builds on the ["ultimate" site structure](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/) pattern, where each site has a root node containing site-wide settings. On this site node, we add a content picker property that lets editors choose which page should be displayed when a 404 occurs. The property setup is simple: - **Property name**: Not Found Page - **Alias**: `notFoundPage` - **Type**: Content Picker This gives editors full control over the 404 experience. They can create a dedicated "Page Not Found" content page with custom messaging, helpful links, search functionality, or whatever else might help visitors find their way. ## The ContentLastChanceFinder Umbraco's `IContentLastChanceFinder` is called when no other content finder can match the requested URL. This is our hook for serving the custom 404 page. The challenge with multi-site setups is figuring out *which* site's 404 page to show. A request to `/dk/some/nonexistent/page` should show the Danish site's 404, while `/en/missing` should show the English one. The solution: find the closest ancestor of the requested URL in the content tree, then traverse up to find the site node and its configured 404 page. ```csharp using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Extensions; public class NotFoundContentFinder : IContentLastChanceFinder { private readonly IUmbracoContextAccessor _umbracoContextAccessor; public NotFoundContentFinder(IUmbracoContextAccessor umbracoContextAccessor) { _umbracoContextAccessor = umbracoContextAccessor; } public Task<bool> TryFindContent(IPublishedRequestBuilder request) { if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext)) { return Task.FromResult(false); } var notFoundPage = FindNotFoundPage(request, umbracoContext); if (notFoundPage is null) { return Task.FromResult(false); } request.SetPublishedContent(notFoundPage); request.SetResponseStatus(404); return Task.FromResult(true); } private IPublishedContent? FindNotFoundPage( IPublishedRequestBuilder request, IUmbracoContext umbracoContext) { var closestPage = GetClosestPageFromRequest(request, umbracoContext); if (closestPage is null) { // Fallback: try to get from the first root node var root = umbracoContext.Content?.GetAtRoot().FirstOrDefault(); return root is not null ? GetNotFoundPageFromAncestors(root) : null; } // Found the closest ancestor, now find the 404 page return GetNotFoundPageFromAncestors(closestPage); } private IPublishedContent? GetClosestPageFromRequest(IPublishedRequestBuilder request, IUmbracoContext umbracoContext) { // Try to find the closest ancestor based on the URL segments var path = request.AbsolutePathDecoded.Trim('/'); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); // Start from the full path and work backwards for (var i = segments.Length; i >= 0; i--) { var testPath = "/" + string.Join("/", segments.Take(i)); var content = umbracoContext.Content?.GetByRoute(testPath); if (content is not null) { return content; } } return null; } private IPublishedContent? GetNotFoundPageFromAncestors(IPublishedContent content) { // Traverse up to find a node with the notFoundPage property var current = content; while (current is not null) { var notFoundPage = current.Value<IPublishedContent>("notFoundPage"); if (notFoundPage is not null) { return notFoundPage; } current = current.Parent; } return null; } } ``` ## Registering the ContentLastChanceFinder Register the finder in your composer: ```csharp using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.DependencyInjection; public class NotFoundComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.SetContentLastChanceFinder<NotFoundContentFinder>(); } } ``` ## How It Works 1. A visitor requests `/products/nonexistent-item` 2. Umbraco's content finders fail to match the URL 3. Our `NotFoundContentFinder` kicks in 4. It tries `/products/nonexistent-item`, then `/products`, then `/` 5. When it finds `/products` exists in the content tree, it has the closest ancestor 6. It traverses up from `/products` looking for a `notFoundPage` property 7. The site node has the property set, so it returns that page 8. Umbraco renders the 404 page with a 404 status code ## Multi-Site Benefits This approach shines in multi-site setups: - **Each site can have its own 404 page** - Different branding, language, and content - **Editors manage everything** - No developer intervention needed to update 404 content - **Automatic site detection** - The correct 404 page is served based on where in the content tree the request would have been ## Final Thoughts By combining a simple content picker property with a `ContentLastChanceFinder`, you give editors full control over the 404 experience while ensuring visitors always see the right page for the site they're on. It's a small investment that pays off in maintainability and editor happiness. --- # Umbraco Skills for AI Agents - A Game Changer for Backoffice Development URL: https://skttl.dev/blog/umbraco-skills-ai-agents/ Date: 2026-02-04 Description: How the new Umbraco Backoffice Skills repository supercharges AI agents like Windsurf/Cascade to understand and generate Umbraco backoffice extensions Tags: umbraco, ai, windsurf, backoffice, property-editors ![Umbraco Skills for AI Agents](/images/blog/umbraco-skills-ai-agents/cover.jpg) Umbraco has released a repository called [Umbraco-CMS-Backoffice-Skills](https://github.com/umbraco/Umbraco-CMS-Backoffice-Skills) that you can install in any AI agent. It essentially teaches your AI assistant everything about Umbraco backoffice development. ## Getting Started Installation is straightforward: ```bash npx skills add umbraco/Umbraco-CMS-Backoffice-Skills ``` This will prompt you to select which AI agents you want to install the skills for (like Windsurf, Cursor, or others) and whether the skills should be installed globally or just for your current project. Once installed, your AI agent gains deep knowledge of the new Umbraco backoffice architecture, including: - Property editor development - Context APIs - Extension registration - UUI component library - And much more ## A Real-World Test I recently needed to migrate a custom property editor to the new backoffice. The editor reads blocks from a Block List elsewhere on the page and presents them as checkboxes for selecting "related blocks" - useful for things like feature comparison matrices. ![The original property editor in the old backoffice](/images/blog/umbraco-skills-ai-agents/v13.png) The challenge? In the new backoffice, the checkbox list needs to **automatically update** when the source Block List changes. That's not trivial - you need to observe the right contexts and react to changes. With the skills installed in Windsurf, I created a plan first, reviewed it, then had Windsurf implement the entire property editor in one go: > I need to create a new property editor for Umbraco. It should be used to select references to blocks from another block list on the current page. For data type configuration, the user needs to input the alias of the block list, from where references are picked. And there should be an input where the user can specify the alias of the field containing the referenced blocks name. When showing the property editor, it should find the block list from the current page, and list the blocks available for picking as a check box list. It should observe the block list, so when new blocks are added, the checkbox list gets updated automatically. The AI generated a working solution on the first try. It only needed one additional iteration to handle the edge case of being used inside nested blocks. ![The new property editor in the Umbraco 17 backoffice](/images/blog/umbraco-skills-ai-agents/v17.png) What's even better? The new editor is actually an improvement over the original. In the old backoffice, users had to click a somewhat hidden refresh button when blocks were added or removed from the source Block List. The new implementation observes the block list value directly and updates the checkbox list automatically - a better user experience that came "for free" because the AI understood the reactive patterns in the new backoffice architecture. ## What Impressed Me The resulting code is clean - about 176 lines for the main `BlockReferencePickerElement`. But what's remarkable is **how the AI figured out which contexts to use**. Finding the right context APIs in the new backoffice can be tricky. There's `UmbDocumentWorkspaceContext`, block contexts, property contexts - it's easy to get lost. The AI, armed with the Umbraco skills, navigated this complexity without any hand-holding. It also correctly used UUI (Umbraco's UI library) for the checkboxes. This is something I've struggled with before when asking AI to generate Umbraco backoffice code - it would often fall back to generic HTML or other component libraries. ## Final Thoughts If you're doing any Umbraco backoffice development with AI assistance, install these skills. The difference is night and day compared to generic AI assistance. The skills bridge the knowledge gap between what AI models learned during training and the relatively new Umbraco backoffice architecture. Instead of generating outdated AngularJS code or making up APIs that don't exist, your AI actually understands the current patterns and can generate working code. This is exactly the kind of tooling that makes AI-assisted development practical for specialized domains like Umbraco. --- # Customizing Umbraco ModelsBuilder Output URL: https://skttl.dev/blog/customize-umbraco-models-builder-output/ Date: 2026-02-02 Description: How to modify what Umbraco ModelsBuilder generates by creating a custom ModelsGenerator - adding property alias constants and more. Tags: umbraco ![Cover](/images/blog/customize-umbraco-models-builder-output/cover.jpg) Umbraco's ModelsBuilder is a fantastic tool that generates strongly-typed models from your document types. But what if you want to customize the generated output? Maybe you want to add property alias constants or other custom code to your models. In this article, I'll show you how to create a custom `ModelsGenerator` that post-processes the generated files to add your own customizations. ## The Problem When working with Umbraco, you often need to reference property aliases as strings - for example, when using `SetValue()` while creating or editing content through the ContentService, or maybe you need the dynamic property access from `IPublishedContent`. This means scattering magic strings throughout your codebase: ```csharp // setting a property value on IContent var title = content.SetValue("pageTitle", "New Title"); // checking for a property on IPublishedContent var items = content.Children.Where(x => x.HasValue("featuredImage")); ``` If a property alias changes, you'll need to hunt down every occurrence. It would be much better to have strongly-typed constants for each property alias, so you get compile-time safety and IntelliSense support. ModelsBuilder does provide a way to get the property aliases from the document type, but it is rather verbose, and it requires a dependency on `IPublishedContentTypeCache`: ```csharp @using Umbraco.Cms.Core.PublishedCache @inject IPublishedContentTypeCache _contentTypeCache; @{ var blocksPropertyAlias = ContentPage.GetModelPropertyType(_contentTypeCache, x => x.Blocks)?.Alias; } ``` ## The Solution We can create a custom `ModelsGenerator` that runs after the standard generation and post-processes the output files. This approach: - Doesn't require modifying Umbraco's source code - Works with the existing ModelsBuilder pipeline - Allows for any kind of text manipulation on generated files Here's the full implementation: ```csharp using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using System.Text; using System.Text.RegularExpressions; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Infrastructure.ModelsBuilder; using Umbraco.Cms.Infrastructure.ModelsBuilder.Building; using Umbraco.Extensions; namespace MyProject.ModelsGenerators; public class MyOwnModelsGenerator( UmbracoServices umbracoService, IOptionsMonitor<ModelsBuilderSettings> config, OutOfDateModelsStatus outOfDateModels, IHostEnvironment hostingEnvironment) : ModelsGenerator(umbracoService, config, outOfDateModels, hostingEnvironment), IModelsGenerator { public new void GenerateModels() { base.GenerateModels(); var modelsDirectory = config.CurrentValue.ModelsDirectoryAbsolute(hostingEnvironment); if (!Directory.Exists(modelsDirectory)) return; foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) { var fileContents = File.ReadAllText(file); var newFileContents = AddPropertyAliasConstants(fileContents); if (newFileContents != fileContents) { File.WriteAllText(file, newFileContents); } } } private static string AddPropertyAliasConstants(string fileContents) { var marker = "public new const string ModelTypeAlias ="; var index = fileContents.IndexOf(marker, StringComparison.Ordinal); if (index == -1) return fileContents; var pattern = @"\[.*ImplementPropertyType\(""([^""]+)""\)\][\s\S]*?public\s+(?:virtual\s+)?[\w\.:<>]+\s+([A-Za-z0-9_]+)\s*(?:{|=>)"; var matches = Regex.Matches(fileContents, pattern); var sb = new StringBuilder(); sb.Append("public new static class ModelPropertyAliases\n\t\t{\n"); foreach (Match match in matches) { var alias = match.Groups[1].Value; var propName = match.Groups[2].Value; sb.Append($"\t\t\tpublic const string {propName} = \"{alias}\";\n"); } sb.Append("\t\t}\n\n\t\t"); return fileContents.Insert(index, sb.ToString()); } } ``` ## How It Works The custom generator does a few key things: ### 1. Inheriting from ModelsGenerator By inheriting from `ModelsGenerator` and implementing `IModelsGenerator`, we can override the generation behavior while still using all the built-in functionality. ```csharp public class MyOwnModelsGenerator : ModelsGenerator, IModelsGenerator ``` ### 2. Post-Processing Generated Files The `GenerateModels()` method first calls `base.GenerateModels()` to run the standard generation, then iterates through all `*.generated.cs` files to apply our customizations: ```csharp public new void GenerateModels() { base.GenerateModels(); var modelsDirectory = _config.ModelsDirectoryAbsolute(_hostingEnvironment); foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) { // Post-process each file... } } ``` ### 3. Adding Property Alias Constants The `AddPropertyAliasConstants` method uses regex to find all properties marked with `[ImplementPropertyType]` and extracts both the alias and property name. It then generates a nested static class containing constants for each property: ```csharp public new static class ModelPropertyAliases { public const string PageTitle = "pageTitle"; public const string FeaturedImage = "featuredImage"; public const string Blocks = "blocks"; } ``` ## Registering the Custom Generator To use your custom generator, you need to register it with Umbraco's dependency injection. Add this to your startup configuration: ```csharp builder.Services.AddSingleton<IModelsGenerator, MyOwnModelsGenerator>(); ``` Or if you're using a composer: ```csharp public class ModelsGeneratorComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.Services.AddSingleton<IModelsGenerator, MyOwnModelsGenerator>(); } } ``` ## Using the Generated Constants Once the models are regenerated, you can use the constants throughout your code: ```csharp // Before: magic strings content.SetValue("pageTitle", "New Title"); // After: strongly-typed constants content.SetValue(HomePage.ModelPropertyAliases.PageTitle, "New Title"); // Works great in LINQ queries too var featured = content.Children .Where(x => x.HasValue(Article.ModelPropertyAliases.FeaturedImage)); ``` Now if a property alias changes, you'll get a compile-time error instead of a runtime surprise. ## Final Thoughts Customizing ModelsBuilder output gives you more control over your generated code without fighting against the built-in tooling. By post-processing the generated files, you can add compile-time safety features like property alias constants while keeping all the benefits of automatic model generation. The approach shown here - inheriting from `ModelsGenerator` and processing files after generation - is clean, maintainable, and survives Umbraco upgrades since it doesn't modify any core code. --- # Enhanced Event Logs and Deployment Monitoring for Kudu in Umbraco Cloud URL: https://skttl.dev/blog/kudu-userscripts/ Date: 2026-01-20 Description: Two userscripts that make working with Kudu in Umbraco Cloud easier - view event logs and monitor deployments with enhanced interfaces. Tags: umbraco, kudu, userscripts ![Cover](/images/blog/kudu-userscripts/cover.png) When working with Umbraco Cloud, I occasionally need to access Kudu for debugging and deployment tasks. You can access Kudu by clicking the "Kudu" button in your site's dashboard, or directly via the URL: `https://<site-name>.scm.<cloud-region>.umbraco.io` (where the cloud region might be `euwest01`, for example). When a site fails to start or a deployment fails without clear errors, the event log in Kudu can provide crucial insights. However, the raw experience leaves much to be desired. To solve this problem, I created two userscripts that enhance the Kudu interface. > > **What is a userscript?** > > A userscript is a program, usually written in JavaScript, for modifying web pages to augment browsing. Uses include adding shortcut buttons and keyboard shortcuts, controlling playback speeds, adding features to sites, and enhancing the browsing history. > > To use a userscript, you need a userscript manager. There are several available, but the most popular is [Tampermonkey](https://www.tampermonkey.net/). Tampermonkey is available for Chrome, Firefox, and Edge. > ## Visualizing Event Logs ![Screenshot of the Event Log Viewer provided by the Kudu Event Log Viewer userscript](/images/blog/kudu-userscripts/img-02.png) First thing to look at is the event log of the site. The event log contains information from IIS, and tells you any problems when starting the site. It can also contain information about failed deployments, and other issues. But, it's not the most user-friendly experience. It's a raw XML file, and you have to manually parse it. - The event log is located at `/LogFiles/eventlog.xml` - It can be a massive XML file with thousands of entries - The most recent (and relevant) information is at the bottom - There's no syntax highlighting or formatting - You have to manually parse XML in your head This userscript adds a new "Event Log" tab to the Kudu interface, providing a much better experience: - **Parsed and formatted**: Events are displayed in a clean table format - **Most recent first**: The latest events appear at the top (no more scrolling) - **Color-coded**: Each event type has its own color for quick identification - **Structured columns**: Event time, type, message, and source are clearly separated The event log helps you diagnose issues like missing DLLs, IIS startup failures, configuration problems, and data-related errors. ### How to use it 1. Install the Kudu Event Log Viewer userscript from my [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository 2. Navigate to your Umbraco Cloud Kudu interface (e.g., `*.scm.euwest01.umbraco.io`) 3. Click the "Event Log" link in the navbar 4. Click "Load Event Log" to fetch and display events 5. Events are displayed in Bootstrap panels with full details ## Tracking Deployments ![Screenshot of the Deployment Viewer provided by the Umbraco Cloud Deployment Viewer userscript](/images/blog/kudu-userscripts/img-03.png) When using CI/CD with Umbraco Cloud, deployment visibility is limited compared to traditional git push deployments. You lose the immediate feedback from git logs about build status and deployment progress. Normally, when a deployment fails, you need to manually navigate through files in `/site/deployments` to find: - Which deployment is currently active - Individual deployment folders with logs and status files - Scattered information across multiple locations The Deployment Viewer userscript consolidates all this information into a single dashboard: - **Deployment status**: See the current state of all deployments - **Live logs**: View logs for each deployment - **Auto-refresh**: Enable live updates to monitor deployments in real-time - **Manual triggers**: Start a new deployment without making a git change ### How to use it 1. Install the Umbraco Cloud Deployment Viewer userscript from my [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository 2. Navigate to your Umbraco Cloud Kudu interface (e.g., `*.scm.euwest01.umbraco.io`) 3. Click the "Deployments" link in the navbar 4. View the latest deployment status and log 5. Click "Auto-refresh" to enable live log and status updates 6. Click "Trigger New Deployment" to start a new deployment 7. Click on any deployment in the history table to view full details in a modal 8. Click file counts to view the deployment manifest ## Final Thoughts While Umbraco Cloud's dashboard provides excellent visibility for most scenarios, these userscripts fill important gaps when you need to dig deeper. They transform Kudu from a basic file browser into a powerful debugging and deployment monitoring tool. Both userscripts are available on [GitHub](https://github.com/skttl/umbraco-userscripts). Give them a try the next time you're troubleshooting a deployment issue or investigating a site failure. --- # Make Umbraco’s Welcome Dashboard Your Own URL: https://skttl.dev/blog/customize-umbraco-welcome-dashboard/ Date: 2026-01-16 Description: Umbraco’s default Welcome dashboard is useful for developers, but often irrelevant for clients. This article shows how to extend the Welcome dashboard by implementing INewsDashboardService, allowing agencies to display their own news, updates, and links directly inside the Umbraco backoffice. Tags: umbraco ![Cover](/images/blog/customize-umbraco-welcome-dashboard/cover.png) Umbraco comes with a default Welcome dashboard in the backoffice. It displays news from Umbraco HQ along with handy links to documentation, training, and other community resources. That’s great for developers, but when you’re building Umbraco sites for clients, those links are often not the most relevant. In fact, the traditional recommendation has often been to simply remove the Welcome dashboard entirely. But with the current backoffice (which I should probably stop calling *new* by now), there are plenty of extension points - and the Welcome dashboard is one of them. So instead of removing it, why not use it for your own content? ## Extending the Welcome dashboard The Welcome dashboard is powered by a service in the backend, and you can replace or extend its behaviour by implementing the `INewsDashboardService` interface in your own C# code. Once you do that, Umbraco will use your implementation to populate the dashboard. All you need to return is a `NewsDashboardResponseModel`, which contains a collection of items to display. Each item supports: * A header * Body text * An image (URL + alt text) * A link URL * Button text * A priority (High, Medium, or Low) The dashboard will automatically sort the items by priority before rendering them. ## A (slightly useless) example implementation As a simple demonstration, here’s an intentionally useless implementation that displays the weather from 12 random locations on Earth every time the dashboard loads. ```cs using System.Text.Json; using Umbraco.Cms.Api.Management.Services.NewsDashboard; using Umbraco.Cms.Api.Management.ViewModels.NewsDashboard; public class UselessNewsDashboardService : INewsDashboardService { private readonly HttpClient _httpClient; private readonly AppCaches _appCaches; public GiNewsDashboardService(HttpClient httpClient, AppCaches appCaches) { _httpClient = httpClient; _appCaches = appCaches; } public async Task<NewsDashboardResponseModel> GetItemsAsync() { // fetch some data - this depends on your usecase! var locations = await _appCaches.RuntimeCache.GetCacheItemAsync( "randomWeather", async () => await GetWeatherFromRandomLocationsAsync(), TimeSpan.FromHours(1) ) ?? []; // build the response model var response = new NewsDashboardResponseModel() { Items = locations.Select(x => new NewsDashboardItemResponseModel() { Header = x.Title, Priority = "High", // or Medium or Low Url = x.Url, ImageUrl = x.ImageUrl, Body = x.ShortDescription, ButtonText = "Go to website", }), }; return response; } // below are methods for communicating with the weather API // - you don't need these public async Task<WeatherResult> GetWeatherAsync(double lat, double lon) { // this is just to prevent 529 Too many requests await Task.Delay(Random.Shared.Next(100, 1000)); var url = $"https://api.open-meteo.com/v1/forecast" + $"?latitude={lat}&longitude={lon}" + $"¤t_weather=true"; var json = await _httpClient.GetStringAsync(url); using var doc = JsonDocument.Parse(json); var current = doc.RootElement.GetProperty("current_weather"); var temperature = current.GetProperty("temperature").GetDouble(); var weatherCode = current.GetProperty("weathercode").GetInt32(); var description = GetDescription(weatherCode); var imageUrl = GetImageUrl(weatherCode); return new WeatherResult( Title: $"{description}, {temperature}°C", ShortDescription: $"Weather at {lat}/{lon}", ImageUrl: imageUrl, Url: $"https://www.open-meteo.com/en/forecast?latitude={lat}&longitude={lon}" ); } public record WeatherResult(string Title, string ShortDescription, string ImageUrl, string Url); public static string GetDescription(int code) => code switch { 0 => "Clear sky", 1 or 2 or 3 => "Partly cloudy", 45 or 48 => "Fog", 51 or 53 or 55 => "Drizzle", 61 or 63 or 65 => "Rain", 71 or 73 or 75 => "Snow", 80 or 81 or 82 => "Rain showers", 95 => "Thunderstorm", _ => "Unknown weather", }; public static string GetImageUrl(int code) => code switch { 0 => "https://cdn-icons-png.flaticon.com/512/869/869869.png", 1 or 2 or 3 => "https://cdn-icons-png.flaticon.com/512/1163/1163661.png", 61 or 63 or 65 => "https://cdn-icons-png.flaticon.com/512/3351/3351979.png", 71 or 73 or 75 => "https://cdn-icons-png.flaticon.com/512/642/642102.png", 95 => "https://cdn-icons-png.flaticon.com/512/1146/1146869.png", _ => "https://cdn-icons-png.flaticon.com/512/1779/1779940.png", }; } ``` You also have to register the service with a Composer, like so: ```cs using Umbraco.Cms.Core.Composing; public class UselessNewsDashboardComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.Services.AddSingleton<INewsDashboardService, UselessNewsDashboardComposer >(); } } ``` And there you have it: ![A useless news dashboard in Umbraco showing weather information from random places on Earth](/images/blog/customize-umbraco-welcome-dashboard/img-02.png) This example obviously isn’t very useful - but it shows how little code is required to take control of the dashboard content. ## A practical use case Where this does become useful is for agencies and teams building Umbraco sites for clients. Instead of showing Umbraco HQ news, you can use the Welcome dashboard to: * Share important project updates * Announce new features or releases * Provide onboarding instructions * Link to internal documentation or support channels * Communicate maintenance windows or known issues In other words, it becomes a direct communication channel inside your client’s CMS. ## Final thoughts Rather than removing the Welcome dashboard altogether, extending it allows you to turn an otherwise generic screen into something genuinely valuable for your clients. With just a small custom service, you can make the Umbraco backoffice feel more tailored, more informative, and more aligned with the people actually using it. --- # Readable, simple Chinese URL segments in Umbraco with NPinyin URL: https://skttl.dev/blog/pinyin-url-segments/ Date: 2026-01-09 Description: When working with Chinese content in Umbraco, URL segments are often generated using Chinese characters, which can hurt readability and sharing. This article shows how to use NPinyin and a custom URL segment provider to automatically convert Chinese titles into clean, SEO-friendly Latin URLs with just a few lines of code. Tags: umbraco ![Cover](/images/blog/pinyin-url-segments/cover.jpg) On a recent project, the client had a Chinese version of their site. The texts and titles were, of course, written using Chinese characters, which meant that the URLs were automatically generated using those characters as well. For SEO and UX reasons, we wanted the URLs to use Latin characters instead. While search engines are perfectly capable of indexing Chinese URLs, we felt that Latin URLs would perform better in practice. Our reasoning was: * Latin URLs are more readable (especially since the audience was not exclusively Chinese), which we assumed would lead to higher click-through rates * Hyphenated Latin words provide clearer URL keyword signals * Sharing URLs and linking to them is much easier with Latin characters than with encoded Chinese characters For example, a title like: ``` 击掌!你太棒了! ``` would normally generate a URL segment using Chinese characters. With pinyin conversion applied, it instead becomes: ``` /ji-zhang-ni-tai-bang-le ``` The latter is much easier to read, share, and reason about, especially in mixed-language environments. ## Converting Chinese characters to Latin using pinyin Umbraco already tries to convert special characters when generating URLs, but the default character replacement set is quite limited. You can see the default configuration in the request handler settings in the documentation article about [Request Handler Settings](https://docs.umbraco.com/umbraco-cms/reference/configuration/requesthandlersettings). Because of the nature of Chinese characters, having more than 50,000 of them, mapping each character to a Latin equivalent is not feasible. Instead, we can use pinyin, which is a Latin-based phonetic transcription system for Chinese characters. Using pinyin allows us to convert Chinese text into readable Latin equivalents without maintaining huge character maps. There is an open-source NuGet package called [NPinyin.Core](https://www.nuget.org/packages/NPinyin.Core) that makes this conversion straightforward. It’s lightweight and integrates cleanly into existing logic. This approach also works well for mixed-language titles, such as: ``` 击掌!You Rock! ``` which results in: ``` /ji-zhang-you-rock ``` ## Using a custom URL segment provider in Umbraco With the conversion logic in place, the next step was to make Umbraco use it when generating URL segments. When Umbraco generates URL segments, it runs through a set of URL segment providers, and it’s possible to create a custom one. The process is simple and [well documented](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/outbound-pipeline#url-segment-provider). By adding a small amount of additional logic to separate Chinese characters with dashes, we ended up with a short and simple custom segment provider that generates readable, SEO-friendly Latin URLs for Chinese pages. > ** Note: ** > This approach converts each Chinese character to its pinyin representation and inserts dashes between characters. While this is not full linguistic word segmentation, it produces consistent, readable URL segments without adding unnecessary complexity. ## Example implementation ```cs using System.Text; using NPinyin; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Strings; namespace Project.Core.Urls; // Custom URL segment provider that wraps Umbraco's default provider // and applies Chinese to Pinyin conversion on top. public class ChineseUrlSegmentProvider : IUrlSegmentProvider { // We delegate to the default provider first, so we keep // Umbraco's built-in behavior for non-Chinese characters. private readonly DefaultUrlSegmentProvider _defaultUrlSegmentProvider; public ChineseUrlSegmentProvider(IShortStringHelper shortStringHelper) { _defaultUrlSegmentProvider = new DefaultUrlSegmentProvider(shortStringHelper); } public string? GetUrlSegment(IContentBase content, string? culture = null) { // Let Umbraco generate the initial URL segment, // then post-process it with our custom logic. return ChineseToPinyinWithDashes( _defaultUrlSegmentProvider.GetUrlSegment(content, culture) ); } // Converts Chinese characters to pinyin and inserts dashes // between consecutive Chinese characters to improve readability. private static string? ChineseToPinyinWithDashes(string? input) { if (input is null) { return input; } var sb = new StringBuilder(); var lastWasChinese = false; foreach (var c in input) { if (IsChinese(c)) { // Insert a dash between consecutive Chinese characters if (sb.Length > 0 && lastWasChinese) { sb.Append('-'); } // Convert the individual character to pinyin sb.Append(Pinyin.GetPinyin(c.ToString())); lastWasChinese = true; } else { // Non-Chinese characters are passed through unchanged sb.Append(c); lastWasChinese = false; } } return sb.ToString(); } // Simple Unicode range check for CJK Unified Ideographs private static bool IsChinese(char c) { return c >= 0x4E00 && c <= 0x9FFF; } } ``` ## Results With this approach in place, Chinese pages now automatically generate clean, readable, and shareable URL segments without any manual intervention. It works equally well for fully Chinese titles and mixed-language content, and it integrates cleanly into Umbraco’s existing routing pipeline. The solution is lightweight, easy to maintain, and avoids the complexity of large character-mapping tables or full linguistic word segmentation — making it a practical choice for real-world Umbraco projects. --- # Sync videos from Umbraco to Mux effortlessly URL: https://skttl.dev/blog/umux-announcement/ Date: 2025-12-23 Description: I just released uMux, the easiest way to sync video files from Umbraco to Mux! Tags: umbraco, mux, video, packages uMux is a brand new, free and open source, package for Umbraco 17+, that takes the hassle out of hosting videos on your website. Just drop your videos into Umbraco, and uMux will automatically upload and sync them with Mux. That means you get all the power of Mux’s streaming, thumbnails, and analytics, while still working in the CMS you love. ## Why uMux? For years, I found myself recommending platforms like Vimeo or similar to clients because they’re user-friendly and handle video streaming well. But there was always a catch: editors had to jump between systems, upload videos to Vimeo, then copy and paste IDs or embed codes back into Umbraco. It’s clunky, error-prone, and just not the smooth experience editors deserve. With uMux, my goal was to keep everything in one place. Editors can upload and manage videos right inside Umbraco, just like they’re used to, while still getting all the benefits of a modern video platform like Mux. No more juggling logins, no more copy-pasting codes, just a seamless workflow. ## What does it do? uMux handles all the behind-the-scenes magic—your videos get sent to Mux, and Mux handles encoding, so the videos are ready to stream anywhere. Editors don’t have to worry about file types, encoding settings, or technical video stuff. It just works. ## Why use a third party video host? Serving big video files from your own server can be a pain, especially if you’re on Umbraco Cloud or have bandwidth limits. Here’s why letting Mux handle your videos is a game-changer: - **Save your bandwidth** Mux takes care of the heavy lifting, so your Umbraco site stays speedy and you don’t get hit with surprise bandwidth bills. - **Smooth streaming for everyone** Mux automatically streams the best quality for each viewer’s device and connection, so videos load fast and play without hiccups. - **Global delivery** Your videos are delivered from servers close to your users, wherever they are in the world. In addition to those obvious benefits, Mux also gives you some handy features around the videos you host with them. - **Instant thumbnails & GIFs** Need a thumbnail or animated preview? Mux generates them for you—no extra work needed. - **Awesome analytics** See how your videos are performing and where viewers might be dropping off. - **Security & scalability** No worries about traffic spikes or unauthorized access—Mux has you covered. - **Works everywhere** Embed videos in any player that supports HLS, or use Mux’s own slick player. - **Automatic encoding** Mux encodes your videos in all the right formats, so you don’t have to think about codecs or compatibility. ## Getting started 1. Install uMux: ```sh dotnet add package Umbraco.Community.uMux ``` 2. Add your Mux credentials to your appsettings.json or as environment variables. 3. Add the Mux Sync property editor to your media types in Umbraco. 4. Upload your videos as usual - uMux takes care of the rest! For all the nitty-gritty details, check out the [README](https://github.com/skttl/umbraco-mux). --- # Umbraco Marketplace Package Sync Bookmarklet URL: https://skttl.dev/blog/umbraco-marketplace-sync-bookmarklet/ Date: 2025-03-28 Description: With this bookmarklet, it gets a lot easier, to list packages in Umbraco Marketplace Tags: umbraco, nuget ![Cover](/images/blog/umbraco-marketplace-sync-bookmarklet/cover.png) The [Umbraco Marketplace](https://marketplace.umbraco.com/) automatically lists NuGet packages with a specific tag. New packages are indexed daily at 4:00 AM UTC, while updates to existing packages are checked every two hours. However, waiting for the automatic indexing can be a test of patience when you're eager to see your new package listed and verify everything is correct. Fortunately, the Umbraco Marketplace API allows you to trigger indexing for a single package using an HTTP POST request, as [detailed in the documentation](https://docs.umbraco.com/umbraco-dxp/marketplace/listing-your-package#synchronization-with-nuget-and-package-owner-data). To streamline this process, I've created a bookmarklet that you can use directly from the NuGet package details page. This bookmarklet automatically extracts the package ID and sends it to the Marketplace endpoint for immediate indexing. I'm happy to share it with the community, so feel free to grab it and use it yourself! {% codepen https://codepen.io/skttl/pen/bNGLBvm %} ## How to Use 1. Navigate to a NuGet package details page on nuget.org (e.g., https://www.nuget.org/packages/Umbraco.Community.Contentment). 2. Click the "Sync with Marketplace" bookmarklet in your bookmarks bar. 3. An alert will confirm if the sync was initiated successfully or display any error messages. Eg. error 429, if you are being too eager, with too many requests. --- # Say Goodbye to Tedious Icon Imports in Umbraco with Icoover! URL: https://skttl.dev/blog/icoover-announcement/ Date: 2025-03-17 Description: A new package helps you add custom icons to Umbraco, without messing with javascript! Tags: umbraco ![Cover](/images/blog/icoover-announcement/cover.jpg) We all know how important icons are for a clean and intuitive backoffice experience in Umbraco. They help content editors quickly identify document types, elements, and actions, making their lives easier. But manually importing and managing those icons? That can be a real time-sink. That's why I'm thrilled to introduce **Icoover**, a new Umbraco package designed to automate the process of importing SVG icons into your Umbraco backoffice. Say goodbye to the days of manually adding icons one by one! ## What is Icoover? [Icoover](https://marketplace.umbraco.com/package/umbraco.community.icoover) is a simple yet powerful package that automatically imports SVG icons from a dedicated folder within your Umbraco project. Just drop your SVG files into the designated directory, restart Umbraco, and boom! Your icons are ready to be used across your document types and the entire backoffice. ## Key Features: * **Automatic Import:** Icoover automatically detects and imports SVG icons, eliminating manual uploads. * **Simple Setup:** Just install the package and add your icons to the correct folder. Icoover automatically "hoovers" up SVG files in the `App_Plugins/Icoover/icons` folder and imports them into Umbraco. ## Best Practices for SVG Icons in Umbraco To ensure your icons render correctly and are fully customizable within the Umbraco backoffice, keep these tips in mind: ### Use `viewBox`: Instead of setting `width` and `height` attributes on your root SVG element, use `viewBox`. This allows Umbraco to handle the sizing of the icon dynamically. **Do:** ```html <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> ``` **Don't:** ```html <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"> ``` ### Use `currentColor`: For any strokes or fills that should inherit the color from Umbraco's theme, use `currentColor`. **Do:** ```html <path d="M12 12L12 12" stroke="currentColor" stroke-width="2"/> ``` **Don't:** ```html <path d="M12 12L12 12" stroke="#283a97" stroke-width="2"/> ``` Icoover is designed to simplify your Umbraco development workflow by automating the icon import process. Give it a try and let me know what you think! I hope this package saves you valuable time and makes your Umbraco projects even better. --- # Vibe coding a nifty Umbraco tool URL: https://skttl.dev/blog/vibe-coding-block-thumbnail-generator/ Date: 2025-03-15 Description: What started as a slow Friday turned into a productive coding session using Windsurf to create a block thumbnail generator for Umbraco block editors. Tags: umbraco, codeium ![Cover](/images/blog/vibe-coding-block-thumbnail-generator/cover.jpg) What started as a slow Friday turned into a productive coding session using [Windsurf](https://codeium.com/), Codeiums AI-powered IDE, to create a block thumbnail generator for Umbraco block editors. > TLDR; I made a [block thumbnail generator for Umbraco](https://skttl.github.io/umbraco-block-thumbnail-generator/) Thumbnails for blocks in Umbraco is typically examples of what the block renders and they greatly improve the content editing experience. I wanted to try out "[vibe coding](https://en.wikipedia.org/wiki/Vibe_coding)" on something useful, and this project aims to automate that process, saving time and ensuring consistency across Umbraco projects. I opened up Windsurf, and gave it the following prompt: > Create a webcomponent (possible with webcomponents inside) that contains an upload field (you should also be able to drop a file into it, or have a button where you can get image data from the clipboard). When uploading an image it should render it into a canvas of 500×300, but crop the image to fit the width. The top part of the image should be visible. If there is remaining vertical space, it should fill with the most prominent color of the outer 1 pixel of the uploaded image. The initial results were surprisingly accurate. Over the next few hours, I refined the component, adding several key features: - **Custom Background Color** I added the option to manually set the background color. While the automatic color detection generally worked well, providing a manual override ensures consistently good-looking thumbnails, especially when the automatically detected color isn't ideal. - **Tinting/Colorization** To avoid overly colorful block catalogs, I implemented a tinting feature. This grayscales the uploaded image and then overlays a selected color using the screen blending mode. The screen blending mode was chosen because it creates a subtle color overlay that preserves the image's details while applying the desired tint. This is especially useful for applying a brand color to all thumbnails. - **Padding** To easily incorporate screenshots taken directly from design tools like Figma (where layers can be copied), I added padding options. The padding is filled with either the automatically detected background color or the custom-selected background color, ensuring a clean and consistent border around the image. - **Fit Options** I expanded the image fitting behavior. Instead of always fitting the uploaded image to the width of the canvas, users can now select whether to fit to the width or the height, providing greater control over the final composition. - **Download Options** Finally, I added options for downloading the resulting image in PNG, JPEG, and WebP formats. The filesize of each format is displayed, allowing users to make informed decisions about image quality and file size optimization. - **Saving preferences to local storage** It also saves your selected preferences for background color, tint, padding and fit to the local storage in the browser. This way you don't have to set these things for every thumbnail you generate. ## Adding polish The first result was somewhat pretty, but still very prototype like. I wanted to give it an Umbraco feel, so I picked some colors from the Umbraco 12 login background, and fed it into the AI, asking it to recolor the site using them, without any further restrictions. It then colored everything nicely, including versions for both light and dark mode. I did however had to make it reconsider some of its choices for accessibility and contrast reasons. ## Refactoring with Lit and TypeScript To improve the component's maintainability and prepare it for potential use within an Umbraco package, I had Windsurf convert the web component to use Lit and TypeScript. This also provided a more structured and type-safe codebase. Although - I haven't really read the code, and this is also one of my painpoints with this kind of technology. You never really feel in control of the output, unless you meticulously read through every change it does. And just for the sake of it, I had Windsurf turn it into a Progressive Web App, so it can be "installed" for local use. So now I have a thumbnail generator sitting on the taskbar, ready to be used. ## Was it worth it One of the biggest challenges was that Windsurf occasionally reintroduced older bugs or reverted to previous requirements, such as the original image size of 500×300 (which should have been 400×250). These regressions required careful monitoring and reiteration of the correct specifications. These issues occurred approximately 2-3 times during the development process. All in all, using Windsurf as an "assistant developer" was a surprisingly productive experience. While I could have likely implemented some of the individual features manually in a similar timeframe, Windsurf significantly accelerated the overall development process. For this type of fun idea, it was definitely worth it. Getting a project from idea to working project was fast. I'm not sure if I would use it on anything non-trivial, that I need to support going forward though. I really don't feel like I know what the code is doing, and while I can read through the code for this project - it was never my intention to do that. But for simple changes and features, I can definitely see the benefits. ## Try out the thumbnail generator yourself! You can try out (and install if you like) the thumbnail generator tool at https://skttl.github.io/umbraco-block-thumbnail-generator/ – it's completely free to use, so go nuts! ![Screenshot of Umbraco Block Thumbnail Generator](/images/blog/vibe-coding-block-thumbnail-generator/img-02.png) --- # Easier Responsive Emails for Umbraco Forms with MJML URL: https://skttl.dev/blog/umbraco-forms-and-mjml/ Date: 2025-02-26 Description: Tired of writing your own tag soup of nested tables when working with emails? MJML got you covered! Tags: umbraco, mjml, email ![Cover](/images/blog/umbraco-forms-and-mjml/cover.jpg) HTML emails are notoriously difficult to style due to inconsistent rendering across email clients. Fortunately, MJML provides a modern solution that simplifies email creation while ensuring compatibility. Basically, HTML emails are a Frankenstein’s monster of old web practices, because they have to work in the wild west of email clients. If they were too advanced, half of your audience wouldn't be able to read them properly. ## Exhibit A: The default Umbraco Forms Email Template Here’s an excerpt of the default email template in Umbraco Forms. While functional, it requires extensive inline styling, uses outdated table-based layouts, and lacks flexibility. ```html <style type="text/css"> /* CLIENT-SPECIFIC STYLES */ body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } img { -ms-interpolation-mode: bicubic; } /* RESET STYLES */ img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; } table { border-collapse: collapse !important; } body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; } /* iOS BLUE LINKS */ a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } /* MOBILE STYLES */ @@media screen and (max-width:600px){ h1 { font-size: 32px !important; line-height: 32px !important; } } /* ANDROID CENTER FIX */ div[style*="margin: 16px 0;"] { margin: 0 !important; } </style> </head> <body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;"> <table border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 40px;"> <!-- LOGO --> <tr> <td bgcolor="#413659" align="center"> <!--[if (gte mso 9)|(IE)]> <table align="center" border="0" cellspacing="0" cellpadding="0" width="600"> <tr> <td align="center" valign="top" width="600"> <![endif]--> <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> <tr> <td align="center" valign="top" style="padding: 40px 10px 40px 10px;"> <a href="http://umbraco.com" target="_blank"> <img alt="Logo" src="@assetUrl/umbraco-logo.png" width="40" height="40" style="display: block; width: 40px; max-width: 40px; min-width: 40px; font-family: 'Lato', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;" border="0"> </a> </td> </tr> </table> <!--[if (gte mso 9)|(IE)]> </td> </tr> </table> <![endif]--> </td> </tr> ``` ## Introducing MJML: The simpler approach Writing HTML email templates like this can be tedious and error-prone. This is where MJML comes in. [MJML (Mailjet Markup Language)](https://mjml.io/) is a powerful open-source framework designed to simplify the creation of responsive email templates. Instead of manually coding complex HTML tables and inline styles, MJML offers an intuitive, component-based syntax that automatically generates well-structured and mobile-friendly emails. Instead of handwriting nested tables, and conditional Internet Explorer comments, you use easy to remember elements like `<mj-section>`, `<mj-column>` , `<mj-text>` and so forth. MJML then converts your markup to real robot-barf-like HTML tables, with all the needed weird quirks to make it work in different email clients. I won’t go into detail about how to write MJML, you can see that in their documentation. In this article, I want to show you how you can utilize MJML for creating email templates to be used with Umbraco Forms. First up, I’ve created an MJML implementation of the default example email template provided with Umbraco Forms. Converting it from real robot-barf-like HTML tables to clean MJML, brings it down from 221 lines of code to just 103. And with the added benefit of getting much cleaner markup, that you would be less afraid of touching. [You can see the difference here](https://www.diffchecker.com/x4dBlmHt/). But how would you get the MJML converted to HTML before sending the email? ## Rendering MJML to HTML in .NET Since MJML doesn’t have official .NET support, you need a library to convert MJML into standard HTML before sending the email. Fortunately, a [community-driven fork of MJML](https://github.com/SebastianStehle/mjml-net) is available on NuGet, implementing all MJML features and actively maintained. Without having to create your own workflow for Umbraco Forms, to convert the MJML into HTML renderable by email clients, you can use Mjml.Net directly in the razor views used by the default Razor workflow provided with Umbraco Forms. This would typically require you to save all your markup to a string variable, and run that through the MjmlRenderer, like this: ```csharp var mjmlRenderer = new MjmlRenderer(); string text = @" <mjml> <mj-head> <mj-title>Hello World Example</mj-title> </mj-head> <mj-body> <mj-section> <mj-column> <mj-text> Hello World! </mj-text> </mj-column> </mj-section> </mj-body> </mjml>"; var (html, errors) = mjmlRenderer.Render(text, options); ``` This converts the MJML into fully compatible HTML, which you can then send as an email. ## Using MJML in Razor Views I don’t like putting everything into a variable like this. Especially when I need to loop over form fields, and concatenate values in. Instead of storing MJML as a string in your code, a cleaner approach is to use Razor views and the layout feature to process MJML dynamically. You can create an `MjmlLayout.cshtml` file like this: ```csharp @using Mjml.Net @{ // get the body content as string var content = RenderBody(); // render using mjml.net var mjmlRenderer = new MjmlRenderer(); var (html, errors) = mjmlRenderer.Render(content); // print the html @Html.Raw(html) } ``` Then, in your email template, simply reference the layout: ```csharp @{ Layout = "MjmlLayout.cshtml"; } ``` This way, the email template content is processed through the MJML renderer dynamically, allowing you to use Razor’s built-in functionality while leveraging MJML’s cleaner syntax. ## Conclusion By integrating MJML with Umbraco Forms, you achieve cleaner, more maintainable email templates while ensuring broad compatibility across email clients. This approach allows developers to focus on design and content rather than battling email client inconsistencies, ultimately simplifying the email creation process. --- # Make your layouts dynamic with Quantity Queries in Tailwind CSS URL: https://skttl.dev/blog/tailwindcss-quantity-queries-plugin/ Date: 2025-02-18 Description: Style content based on the number of elements easily with quantity queries in TailwindCSS Tags: css, tailwindcss CSS has come a long way, giving us all sorts of cool ways to handle layouts. One neat trick that’s often overlooked is quantity queries. These let you style elements based on how many of them exist inside a container. That means you can create more flexible designs without relying on JavaScript or other external programming languages. ## What Are Quantity Queries? Quantity queries are basically CSS selectors that let you style things based on how many child elements a parent has. While CSS doesn’t have a built-in feature for this, you can get creative using the :has selector with :nth-child() and :nth-last-child() to make it work. For example, you can style a container differently depending on how many items it holds. This is super handy for building adaptable grid layouts, menus, lists, and more. ### Example 1: Changing Styles Based on the Number of Items ```css .container:has(:nth-child(3)) { background-color: lightblue; } ``` Here, if a `.container` has at least three children, it gets a light blue background. {% codepen https://codepen.io/skttl/pen/NPWWjVO %} ### Example 2: Adjusting a Grid Layout Automatically ```css .container:has(:nth-child(5)) { display: grid; grid-template-columns: repeat(5, 1fr); } .container:has(:nth-child(3)):not(:has(:nth-child(5))) { display: grid; grid-template-columns: repeat(3, 1fr); } ``` This setup makes a container use a 5-column grid if it has exactly 5 children. If it only has 3 (but not 5), it switches to a 3-column layout instead. ## Why This Is Awesome Quantity queries let you tweak styles without needing media queries or JavaScript. Elements adjust their look based on how many siblings they have, making things more flexible. ## Quantity Queries with TailwindCSS If you're a Tailwind CSS user, you can use the [tailwindcss-quantity-queries](https://www.npmjs.com/package/tailwindcss-quantity-queries) plugin to integrate quantity queries into your workflow. This plugin allows you to apply Tailwind utility classes based on the number of child elements, making it easy to create dynamic, responsive designs without writing custom CSS. For example: ```html <div class="children-[>3]:bg-blue-500 children-[5]:grid-cols-5"> <div>Item 1</div> <div>Item 2</div> <div>Item 3</div> </div> ``` With this setup, the parent div gets a blue background if it has three children and switches to a 5-column grid if it has five children. This plugin is a great way to make your Tailwind projects even more flexible and powerful. You can also use the plugin in combination with the `group` utility to style the children, based on a groups number of children. For example: ```html <div class="group"> <div class="group-children-[<3]:bg-red-500">Child 1</div> <div class="group-children-[>5]:bg-blue-500">Child 2</div> <div class="group-children-[2-5]:bg-green-500">Child 3</div> </div> ``` The plugin is compatible with both the new v4 of Tailwind, and the old v3. You simply drop in the plugin in your config, eg. in the new css style config: ```css @plugin "tailwindcss-quantity-queries" ``` or in the old javascript format: ```js module.exports = { plugins: [ require('tailwindcss-quantity-queries') ], }; ``` ## Wrapping Up Quantity queries in CSS give you new ways to create flexible and responsive designs. Give it a shot and see how it changes your workflow! --- # Enhancing 404 Pages with Search in Umbraco URL: https://skttl.dev/blog/enhance-404-pages-with-search-in-umbraco/ Date: 2025-02-10 Description: Turn your 404 pages into a helpful experience by integrating automated search results in Umbraco. Learn how to guide users to relevant content instead of dead ends using Full Text Search. Tags: umbraco ![Cover](/images/blog/enhance-404-pages-with-search-in-umbraco/cover.jpg) A well-designed 404 page enhances user experience when a requested page doesn’t exist. There are tons of examples of [fun and creative error pages](https://www.awwwards.com/awwwards/collections/404-error-page/) if you want to lighten up your user's mood. But you can also try to be helpful for users who just want to find the content they’re looking for. 404 errors can have many causes. One common cause is a page being moved to a new URL. Luckily, [Umbraco handles this automatically for us](https://docs.umbraco.com/umbraco-cms/reference/routing/url-tracking), creating the necessary redirects from the old url to the new one. But they can also come from mistyped urls, or simply the user guessing the location of a page. This is where this idea comes in handy. If you combine your error page, with search results based on the current context - in this example, the requested URL - the user might get lucky, and get pointed towards the content they were actually looking for. ## Demo Time To demonstrate for you, I've created a new site based on the infamous Umbraco Starter Kit, containing some demo content ready to get going. Umbraco comes with [Examine](https://docs.umbraco.com/umbraco-cms/reference/searching/examine) out of the box for searching the site content. And while that is often enough for most users, I can't resist using this opportunity to create some awareness for my package [Full Text Search](https://marketplace.umbraco.com/package/our.umbraco.fulltextsearch). This package renders every page, and indexes all text content into Examine, including content referred from other pages or simply hardcoded into your templates. The result is a Google like index, where you can search for each and every word, the user sees on each page. It also makes more complex pages, like pages built with blocks in either Block List or Block Grid easier to search. And it offers an [easy API](https://github.com/skttl/umbraco-fulltextsearch8/blob/v5/dev/docs/developers-guide-v5.md#the-easy-way) for creating the search, handling things like multi relevance, boosting, summarizing etc. for you without breaking a sweat. ## Creating the 404 page ![Screenshot of Umbraco setup containing a content type for the Not Found Page, and the Not Found page itsefl](/images/blog/enhance-404-pages-with-search-in-umbraco/img-02.png) I created a document type to contain the page itself. To integrate with the starter kit, I added the existing Navigation Base composition. This is a composition content type found in the starter kit, that I use just to get the necessary properties needed to hide the page from the navigation. You can do what you need in your own project. I also create a page in the Content Tree, for the 404 page, and to make it work as the actual 404 page, I add the necessary setup in my appsettings.json file. ```json { "Umbraco": { "CMS": { "Content": { "Error404Collection": [ { "Culture": "en-US", "ContentKey": "4b7b8f17-9a64-4165-b753-9584ca36bc52" } ] } } } } ``` Note, I don't think this way of adding 404 pages is very editor friendly. What if the editor deletes it and creates it over, then you have to change config of your site. Or what if you have multiple sites sharing the same language, then you can't configure 404 pages like this. I have a better way, which I might show in another blogpost. ## Adding search results to 404 page Instead of just showing an error, let’s make our 404 page actually useful — by adding smart search results! In the template for the 404 page, I reused some HTML from existing templates to maintain consistency and reduce effort.. But instead of the content, I create a search result using Full Text Search. To do this, I first need to inject in the Full Text Search helper using ```cs @inject Our.Umbraco.FullTextSearch.Helpers.FullTextSearchHelper FullTextSearchHelper ``` and I can then go and create the search result using the helper. To generate the search query, I extract the requested path and replace non-alphanumeric characters with spaces. For example, `/this/path/does/not/exist` becomes `this path does not exist`. I could also have looked at the referrer, or other data. The search process is straightforward - simply passing the query to Full Text Search handles the rest. But there is several extra options, if you wish to [configure it yourself](https://github.com/skttl/umbraco-fulltextsearch8/blob/v5/dev/docs/developers-guide-v5.md#configuring-the-search). If the search then returns any results, they get shown, leading the user to other related pages. ```html <section class="section"> <div class="container"> <div> <p>The page you were looking for, could not be found!</p> @{ // generate search query from request path var query = Context.Request.Path.ToString().ReplaceNonAlphanumericChars(" "); // search for the generated query var search = FullTextSearchHelper.Search(query, Model.GetCultureFromDomains(), 1); @if (search.TotalResults > 0) { <p>Maybe these pages can help you?</p> <div class="blogposts"> @foreach (var result in search.Results) { <a href="@result.Content.Url()" class="blogpost"> <h3 class="blogpost-title">@result.Title</h3> <div class="blogpost-excerpt">@result.Summary</div> </a> } </div> } } </div> </div> </section> ``` ## Conclusion Now, if a user visits non-existing URLs like `/produits`, `/products/unikorn`, `/blog/another`, or `/jan-skovgaard`, they are guided toward relevant pages instead of encountering a dead end. ![Screenshots of the Not Found Page in action on four different non-existing pages](/images/blog/enhance-404-pages-with-search-in-umbraco/img-03.png) You can try this yourself! The source code, including uSync files, is available on [GitHub](https://github.com/skttl/umbraco-example-search-enhanced-404-page) for easy download and experimentation. --- # Learnings from upgrading an existing Umbraco project to TailwindCSS 4 URL: https://skttl.dev/blog/upgrade_tailwindcss_4_in_umbraco/ Date: 2025-01-23 Description: TailwindCSS version 4 was recently released, and I had to try it out immediately. Tags: umbraco, tailwindcss, dotnet ![Cover](/images/blog/upgrade_tailwindcss_4_in_umbraco/cover.jpg) As a developer relying on TailwindCSS for crafting responsive designs effortlessly, the release of TailwindCSS v4 was an exciting occurrence. I jumped immediately into the project I’m currently working on, and started upgrading to the latest Tailwind version. We use a somewhat simple approach to TailwindCSS in our Umbraco (ASP.net MVC) projects. In the website project, we initalize an npm package with `npm init` and then install TailwindCSS and necessary scripts for building. The source files is placed in `./UI/CSS/main.css` and the config in `./UI/CSS/tailwind.config.js` ## Moving config to CSS, spacing changed One of the big headlines of the new Tailwind version, is the addition of [config in CSS](https://tailwindcss.com/blog/tailwindcss-v4#css-first-configuration). With that, all configurable values are now defined as custom properties, which adds some exciting new possibilities. I like to stay as close to the default config as possible, so moving this over to CSS was not a big hurdle. Though, I used to generate spacing values using an npm package I built, [generate-tailwind-scale](https://www.npmjs.com/package/generate-tailwind-scale), I opted to leave this one behind, as the new [spacing scale is dynamic](https://tailwindcss.com/blog/tailwindcss-v4#dynamic-utility-values-and-variants). I did use the same package for defining font-sizes though, but for this project, most of the font-sizes used already matched the default font-sizes. And the rest I simply changed into using arbitrary values, eg. `text-[10px]` ## Automatic content scanning In TailwindCSS v3, developers had to manually specify content paths to ensure Tailwind scanned the correct files for class usage. TailwindCSS v4 automates this process, scanning the entire directory it's executed from (except files specified in `.gitignore`). This clever tweak reduces configuration overhead. Initially, I was worried that my `output.css` file would be considered for class generation since we don’t git-ignore it in our projects. If it were considered, removing a class would be impossible. Fortunately, it's not included, and when I added a one-time class like `m-[1337ch]`, it appeared in the output but was promptly removed when unused elsewhere. For content files ignored by .gitignore or located outside the project path, developers can specify paths via `@source "../path/to/something"`, offering flexibility for more complex directory structures. ## No more content transforms (for now?) One missing feature in this content definition method is the ability to transform content during scanning. Previously, I configured content to allow writing `@@container` to add the `@container` class used by the container query plugin (now built into Tailwind!). Since my frontend is written in Razor/cshtml, `@container` would cause errors, while `@@` worked as an escape character. For now, I have to wrap those like `@("@container")`. ## UTF-8 BOM encoding issue when working from Visual Studio A notable challenge during the upgrade was related to file encoding. If your main Tailwind input file (`main.css`) was created using Visual Studio, it might be saved in UTF-8 BOM format. TailwindCSS v4 cannot correctly process UTF-8 BOM, causing incorrect or incomplete outputs. This issue initially puzzled me, as the `output.css` file still contained Tailwind-specific directives like `@utility` and `@import "tailwindcss"`. The simple solution is to save your CSS file as UTF-8 without BOM. Once converted, TailwindCSS functions as expected. ## Safelisting classes is deprecated In a significant change, TailwindCSS v4 no longer supports class safelisting. For simple classes, I recommend documenting them in comments within your CSS files. I previously used regex for safelisted classes but had to refactor. Instead of using direct dynamic class definitions like `class="bg-@Model.Color"`, I transitioned to custom properties: `class="bg-(--my-bg)" style="--my-bg: var(--color-@Model.Color)"`. This approach also results in a cleaner, more maintainable solution, and fewer `bg-` and `color-` classes. ## New method for custom utilities and variants Another significant change is the method for defining custom utilities. The reliance on `@layer utilities` blocks is gone. Instead, in TailwindCSS v4, you can define them using a new directive: `@utility`. For example, to create a utility to hide scrollbars: ```css @utility scrollbar-hidden { /* css here */ } ``` For variants it has become very simple, heres an example of a custom variant I had in this project: ```css @custom-variant mouse-only (@media screen and (pointer: fine)); ``` ## Simpler plugin setup TailwindCSS v4 introduces a more streamlined way to incorporate plugins. Plugins are now embedded directly using directives such as `@plugin "@tailwindcss/typography"`. This approach simplifies the integration process, reducing boilerplate code and setup time. And it seems like v3 plugins are supported out of the box. At least my [quantity query plugin](https://www.npmjs.com/package/tailwindcss-quantity-queries) just works. ## JavaScript config still possible While TailwindCSS v4 still supports JavaScript configuration files, the authors advises against using them. I think they will end up completely deprecated at some time, but at least not before an eventual v5 or v6 of TailwindCSS. ## Conclusion Updating to TailwindCSS v4 was quite straight forward. If not for the BOM encoding issue, I would say it was as easy as pie. Next, I think I need to link into using the [standalone CLI](https://tailwindcss.com/blog/standalone-cli) in the build pipeline, or maybe even when starting the website, to make our projects even simpler to work with. ## Bonus tip Working with TailwindCSS is a lot better, with the right tools. For VSCode, Tailwind Labs has their own [intellisense extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss). For full Visual Studio, an [unofficial extension](https://marketplace.visualstudio.com/items?itemName=TheronWang.TailwindCSSIntellisense) offers similar functionality. Although it's not supporting v4 yet, the author is trying to make it happen in the next few weeks. --- # How to Deploy Your Branches to Umbraco Cloud Without Triggering Site Deployment URL: https://skttl.dev/blog/push-branches-to-cloud-without-site-deploy/ Date: 2024-12-19 Description: Are you also tired of waiting when pushing code to other branches in Umbraco Cloud? There is an easy fix! ![Cover](/images/blog/push-branches-to-cloud-without-site-deploy/cover.jpg) Umbraco Cloud is an excellent hosting platform for most Umbraco projects. Built on Azure Web Apps, it provides a ready-to-use environment that allows you to start your project within minutes. One of its primary features is the automatic deployment of your code each time you commit to the Git repository, focusing mainly on the master branch. However, a downside is that deployments are triggered for all branches you commit to, even though only the master branch gets built. This can result in unnecessary waiting times for unrelated code builds, which can be frustrating. Several issues related to this behavior have been reported on the [Cloud issue tracker](https://github.com/umbraco/Umbraco.Cloud.Issues/issues), including: - [Deleting branch in Git triggers deployment](https://github.com/umbraco/Umbraco.Cloud.Issues/issues/823) - [Don't run deployment commands when pushing branches other than master](https://github.com/umbraco/Umbraco.Cloud.Issues/issues/14) If other aspects of Umbraco Cloud are causing issues, consider posting them on the issue tracker to help facilitate improvements. ## Customizing Deployment Behavior with Git Hooks Since Umbraco Cloud is hosted on Microsoft Azure, you have access to the Kudu tool for advanced hosting tasks. Kudu allows you to view the remote Git repository and add or modify Git hooks as necessary. The default auto-deployment process operates through the post-receive Git hook, which executes a Kudu command upon every push, irrespective of the branch. Using Kudu, navigate to `C:\home\site\repository\.git\hooks` and find the `post-receive` file. It should look like this: ```bash #!/bin/sh read i echo $i > pushinfo "$KUDU_EXE" "$KUDU_APPPATH" "$KUDU_MSBUILD" "$KUDU_DEPLOYER" ``` To avoid triggering deployments for non-master branches, you can modify this hook to check the branch before executing the deployment script. Here is the modified post-receive hook: ```bash #!/bin/sh read i echo $i > pushinfo # Read input data from Git and determine the branch while read oldrev newrev refname do # Proceed with deployment only for the master branch if [ "refs/heads/master" = "$refname" ]; then echo "$oldrev $newrev $refname" > pushinfo "$KUDU_EXE" "$KUDU_APPPATH" "$KUDU_MSBUILD" "$KUDU_DEPLOYER" fi done ``` By implementing this change, pushes to work-in-progress branches complete quickly, significantly reducing waiting times. This allows you to focus on development without the interruption of unnecessary build processes. --- # Quick fix for IPublishedSnapshotAccessor issues when upgrading to Umbraco 17 URL: https://skttl.dev/blog/quick-fix-for-ipublishedsnapshotaccessorissues-upgrade-umbraco-17/ Date: 2024-10-23 Description: Upgrading to Umbraco 15 and running into issues with ModelsBuilder? The removal of the IPublishedSnapshotAccessor interface can cause build failures. This guide shows you a simple workaround to get your models up and running smoothly. Tags: umbraco ![Cover](/images/blog/quick-fix-for-ipublishedsnapshotaccessorissues-upgrade-umbraco-17/cover.jpeg) If you are using Umbraco ModelsBuilder in one of the SourceCode modes like me, chances are that you'll run into problems when upgrading to Umbraco 15. Because of the new cache in Umbraco things have changed around how content is fed to the models, and with that, the IPublishedSnapshotAccessor interface has removed. As all the ModelsBuilder models depend on that, they will fail to build, once you have upgraded the necessary NuGet packages to Umbraco. I think the official advice is to uncomment all models and code that uses them. As a lazy dev, this is not what I want. In stead, you can piggy back the interface back into Umbraco. Simply, add the following snippet to one of your `.generated.cs` model files, and your project can build again. ```cs namespace Umbraco.Cms.Core.PublishedCache { public interface IPublishedSnapshotAccessor : IPublishedContentTypeCache { } } ``` You then have to go into Umbraco, and rebuild your models. This will generate models that are compatible with Umbraco 15, and you are good to go. If you use IPublishedSnapshotAccessor elsewhere, you would need to refactor that code too though. --- # Make your own Umbraco Starter Kit without making a Starter Kit URL: https://skttl.dev/blog/make-your-own-umbraco-starterkit-without-making-a-starterkit/ Date: 2024-10-11 Description: At work, we create many Umbraco projects each year, and to make this easier, we used to rely on a Starter Kit. However, maintaining it became cumbersome, leading us to develop a more streamlined approach — Schemex Exporter and Importer. Tags: umbraco, dotnet ![Cover](/images/blog/make-your-own-umbraco-starterkit-without-making-a-starterkit/cover.jpeg) Every project we build should preferably be built somewhat the same way, so it's easy to jump between projects and know that stuff like meta data on pages, extra html snippets etc. are handled the same way. For this task, we've been using (and maintaining) our own Starter Kit containing some Content Types, Data Types, Templates etc. for building out the absolute basics of a site. This contains for example a `SiteNode` Content Type, a `ContentPage` Content Type, properties for setting the 404 page, meta data, etc. > A Starter Kit is a kind of Umbraco Package, that includes eg. schema data and content nodes, to get a head start on building an Umbraco project. Starter Kits can serve as a demonstration of what Umbraco can do, but can also be used for scaffolding, or turn Umbraco into a complete point-and-click style website builder. ## Starter Kit Maintenance gives me headaches Maintaining a Starter Kit can be cumbersome and error-prone. You often risk missing dependencies or including incorrect content types, leading to additional rework. Everytime you make changes, you need to go to your Created Package in the Umbraco project where you build your Starter Kit, and make sure that everything is checked and okay. There is little to no validation, so you can easily end up creating a package containing eg. Content Types depending on Data Types that you forgot to include in your package. The package that you create in the backoffice, is then exported as a package.xml file (or package.zip if you include media items), and you can then create a NuGet package, with a migration for actually installing the package.xml file. ## Deploy and uSync doesn't play nice with Starter Kits Another thing I dislike about this approach - at least when building sites like we do at my work. Is that you have to remember to remove the Starter Kit once it's been installed, as the migration will run every time you recreate the database. Eg. when a colleague clones the project and starts working on it. We use Umbraco Deploy or uSync (depending on the client) to keep track of schema changes, and these tools can get in a fight with the Starter Kit about what schema to create. For example, if you change your `SiteNode` on a project basis (which we always do, our Starter Kit is just a very simple baseline), you can end up having Deploy/uSync create your schema as per the .uda/.usync files, and then the migration for the Starter Kit runs and overwrites everything again. I know, you could just have a "Starter Kit" based on .uda/.usync files, but then you suddenly have two "Starter Kits" to maintain. ## The solution The [Schemex Exporter](https://marketplace.umbraco.com/package/umbraco.community.schemex.exporter) package addresses this by allowing you to generate a package.xml of all schema and content automatically, without the hassle of manual package creation. With the Exporter package, I keep the Umbraco project for actually maintaining the Starter Kit, but throw away the manual export of package.xml file, and updating of internal Nuget packages. Let's just call this the Source Project. The package works by simply exposing an API, which when invoked, generates a package.xml of all content, schema etc. of the project and returns to the user. This package is then installed in the Source Project, and I can then go to `/umbraco/schemex/export/get` to download a fresh package.xml of everything in the Source Project. **Note:** The package.xml also contains all content, or at least the first content node and its descendants. So it's probably not wise to have this package installed in anything not supposed to be used as a baseline. Eg. if you have sensitive data in your content. With the package.xml file generated automatically, I could then just go and do the rest of the work, updating the NuGet package of my Starter Kit and be done with that. But as I wrote, I don't really like the Starter Kit approach for the type of projects we build. ## Importing the schema in new projects So instead of having to manually create new versions of a Starter Kit, and make sure that new projects actually gets the newest version of the Starter Kit installed, I created a companion package ([Schemex Importer](https://marketplace.umbraco.com/package/umbraco.community.schemex.importer)). This package exposes an API that can fetch the package.xml file from a project using the Exporter package and install it. The Importer package is installed in the newly created project. Let's just call this the Target Project. It works by going to `/umbraco/schemex/import/push/?source=[HOST_NAME_OF_SOURCE_PROJECT_HERE]`, which will make the package download the package.xml from the hostname mentioned, and install the contents in the Target Project. Giving you essentially the same as what a Starter Kit would do. The import will only run if the Target Project is running in Development mode, and it will also prevent installing if the project already has Content Types, meaning it probably isn't a newly created empty project. You can skip this check by adding `&force=true` to the import URL though. The only thing it doesn't do, is installing any dependent NuGet packages from the Source Project. For example, we are big fans of [Contentment](https://marketplace.umbraco.com/package/umbraco.community.contentment) and have a few Data Types from that package in our Starter Kit. To me, this is a responsibility of the dotnet template that we roll anyway, so for me it's no big deal. One could also argue that the Importer package should be removed after the first use, but it isn't strictly necessary, as it is made to prevent overwriting existing schema (unless you set `&force=true`), and it only runs in Development mode. ## Get the package now The package is released under an MIT license, and you can get it today from the [Marketplace](https://marketplace.umbraco.com/author/skttl). It supports both v13 and v14 - but doesn't support importing package.xml files generated in v13 though. --- # Server-side registering of Package Manifest in Umbraco 14 URL: https://skttl.dev/blog/serverside-registering-of-package-manifest-in-umbraco-14/ Date: 2024-08-08 Description: Umbraco 14 packages are registered with umbraco-package.json files, but you can also register using C#. I'll show you! Tags: umbraco ![Cover](/images/blog/serverside-registering-of-package-manifest-in-umbraco-14/cover.jpeg) Previously in Umbraco, if you had a package you wanted to register without including frontend files, you could add the package manifest as an `IManifestFilter` and append it to the manifest filter collection using an `IComposer` like this: ```csharp public class MyManifestFilter : IManifestFilter { public void Filter(List<PackageManifest> manifests) { manifests.Add(new PackageManifest() { PackageName = "MyPackage" }); } } public class MyComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.ManifestFilters().Append<ImageSharpRemoteImagesManifestFilter>(); } } ``` This would register "MyPackage" as a package in the Umbraco installation. With the new backoffice, everything backoffice-related, including package registration, should be handled in the frontend only. ## Umbraco 14 says it’s frontend only In Umbraco 14, you now need to supply a static umbraco-package.json file in a folder inside the App_Plugins folder. This isn't a big deal, especially if your package also contains frontend components like property editors. However, if your packages only contain backend code, you might not want to include static files. Luckily, there's a new interface called `IManifestReader`, which reads and communicates all manifests in App_Plugins/*/umbraco-package.json to the backoffice. With some C#, you can use this interface to register your package in backend code. > **Note:** If your package has frontend components, you should register it with the JSON file approach. You won't gain anything by doing it in C#. The interface must implement a method called `ReadPackageManifestsAsync` that returns a list of `PackageManifest`s, similar to the old `IManifestFilter`. ## Example: Registering "MyPackage" in code Here's an example of how to register "MyPackage" in C# in Umbraco 14: ```csharp public class MyManifestReader : IManifestReader { public async Task<IEnumerable<PackageManifest>> ReadPackageManifestsAsync() { return await Task.Run(() => { return new List<PackageManifest>() { new PackageManifest() { Name = "MyPackage", Extensions = Array.Empty<object>() } }; }); } } public class MyComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.AddSingleton<IManifestReader, MyManifestReader>(); } } ``` As you can see, it's similar to before, with the main difference being that `Extensions` is required. Extensions is the way you register all your frontend components, like dashboards, property editors etc. If your package is containing only backend code, you don’t have any extensions. ## Using Package Manifests for Telemetry Data Package manifests are also useful for showing up in Umbraco's telemetry data. If you set `AllowTelemetry` to true on your PackageManifest (`AllowPackageTelemetry` in the old `IManifestFilter`), data about the package will be included in Umbraco's telemetry data. This telemetry data is useful for getting an overview of which versions of Umbraco and packages are running in your projects. ## I just released a package that does this So if you want to register your package from C#, it's still possible. But if you have frontend assets in your package, you shouldn't need to do it. By the way, one of my packages that registers this way has just been released for Umbraco 14: [![Remote Image Provider for ImageSharp](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ub3niq61esdxm84c1d1.png 'Remote Image Provider for ImageSharp')](https://marketplace.umbraco.com/package/umbraco.community.imagesharpremoteimages) --- # Remote Image Provider for ImageSharp URL: https://skttl.dev/blog/remote-image-provider-for-imagesharp/ Date: 2024-03-13 Description: Want to process remote images using ImageSharp? I've got you! Tags: umbraco, dotnet, imagesharp ![Cover](/images/blog/remote-image-provider-for-imagesharp/cover.jpg) When integrating websites with third parties, you are often required to show images from third parties too. I don't like not having control over these images, this being either their dimensions, their formats or simply just the availability. Using Umbraco, I've become addicted to having the control I need over images. Being able to define crops, resize them, and deliver optimal imageformats (eg. WebP) to the end user. This is easy with my own images. Umbraco comes with ImageSharp, a fantastic product that does all kinds of image magic, to help me with delivering just the right amount of image I need. But getting images from third parties, I am left to use only what the third party provides. Not optimal. So I've made a provider for ImageSharp, that can download images from certain URLs, and run them through the machinery to get me the control I need. You can too - it's on NuGet as [ImageSharpCommunity.Providers.Remote](https://www.nuget.org/packages/ImageSharpCommunity.Providers.Remote/#readme-body-tab). It's simple to [setup](https://github.com/skttl/ImageSharpCommunity.Providers.Remote/blob/main/docs/setup.md), [configure](https://github.com/skttl/ImageSharpCommunity.Providers.Remote/blob/main/docs/configure.md) and [use](https://github.com/skttl/ImageSharpCommunity.Providers.Remote/blob/main/docs/setup.md), and lets you load external images like they are your own. If you are using Umbraco, I've made it even easier to [setup](https://github.com/skttl/ImageSharpCommunity.Providers.Remote/blob/main/docs/setup-umbraco.md), by registering everything for you - you just need to configure it. Get the Umbraco package from the [Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.imagesharpremoteimages). Photo by <a href="https://unsplash.com/@sigmund?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Sigmund</a> on <a href="https://unsplash.com/photos/black-flat-screen-tv-turned-on-near-green-plant-eTgMFFzroGc?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a> --- # 4 notable new features in Full Text Search for Umbraco URL: https://skttl.dev/blog/4-new-features-in-full-text-search-umbraco/ Date: 2023-12-09 Description: Full Text Search for Umbraco, is released in version 4.0.0 with a bunch of bugfixes, performance improvements, and some new languages. With great help from the community the package is now better than ever. Tags: umbraco, examine, lucene ![Cover](/images/blog/4-new-features-in-full-text-search-umbraco/cover.jpg) Full Text Search for Umbraco, is released in version 4.0.0 with a bunch of bugfixes, performance improvements, and some new languages. With great help from the community the package is now better than ever. In this release there is 4 notable new features, that I will describe below. If you are interested, you can see the [complete changelog](https://github.com/skttl/umbraco-fulltextsearch8/blob/master/CHANGELOG.md). **[Download Full Text Search on Umbraco Marketplace](https://marketplace.umbraco.com/package/our.umbraco.fulltextsearch)** ## Configure through appsettings.json To improve DX, and make it a little bit easier to install, you can now configure FullTextSearch from your appsettings.json file. In addition to that, Full Text Search, will automatically "add it self" to your project. No more need for putting `.AddFullTextSearch()` in your Startup.cs file. You can configure Full Text Search like this in your appsettings.json file: ```json { "Umbraco": { "FullTextSearch": { "DefaultTitleField": "searchResultTitle", "DisallowedContentTypeAliases": [ "verySecretContent" ], "DisallowedPropertyAliases": [ "hideInSearch" ], "Enabled": true, "FullTextPathField": "MyCustomPathField", "FullTextContentField": "MyCustomContentField", "HighlightPattern": "<span class=\"bold\">{0}</span>", "RenderingActiveKey": "HiEverybody!", "XPathsToRemove": [ "//script" ] }, } } ``` Full Text Search also now ships with an appsettings-schema.json file, so you'll get Intellisense when configuring through appsettings.json. ## Pages are rendered using Http Client Your pages are now being rendered using a Http Client, before being indexed by Full Text Search. This has the benefit, that route hijacked pages now actually works. Before, when we relied on Umbracos internal `RenderTemplate` method, it wouldn't pass through any custom controllers, and thus not get assigned any custom view models. Because of this, it's required that your webserver can "call it self", in order to fetch the content of the pages. Usually this is not a problem, but if you can't, and you have no way of allowing it, you can revert back to the old rendering method. This is done by registering `RazorPageRenderer` as the `IPageRenderer`. This is usually done in a composer, or in your Startup.cs file, eg.: ```cs public void ConfigureServices(IServiceCollection services) { // omitted... services.AddUnique<IPageRenderer, RazorPageRenderer>(); } ``` ## Use Full Text Search to search non-content Previously Full Text Search, would only let you search published content with a template defined in Umbraco. But sometimes you might want to leverage the search helpers, to search other content from Examine, eg. custom indexes and stuff that might not be Umbraco content at all. When [configuring the search query](https://github.com/skttl/umbraco-fulltextsearch8/blob/master/docs/developers-guide-v4.md#configuring-the-search), you can now use `SetIndex(string index)` to define which index to search (default is the default ExternalIndex in Umbraco), or `SetSearcher(string searcher)` to define a custom searcher to use. This lets you search custom indexes instead of the default ExternalIndex. To allow searching content that maybe isn't published, is Umbraco content, or doesn't have a template, you can use `SetContentOnly(bool contentOnly)`, `SetPublishedOnly(bool publishedOnly)` or `SetRequireTemplate(bool requireTemplate)`. Or you could combine them all, by using `SearchEverything()`, which will set the search to NOT content only, NOT published only, and no required template. And if you need some special Lucene query in your search query, you can now add `SetCustomQuery(string customQuery)`, which will add the defined `customQuery` to your complete search query. ## Listen for notifications when caching content Full Text Search caches the rendered content of all pages, to make reindexing faster. Content gets cached everytime a node is published, to make sure to content is up to date. We've now added [notifications](https://github.com/skttl/umbraco-fulltextsearch8/blob/master/docs/developers-guide-v4.md#using-notifications) for this, so you can use Full Text Search, as a feeding mechanism for other providers, like Relewise, Algolia or others. The cache service sends a `CacheSavingNotification` when it starts saving content to the cache. In here you can also modify the content being cached. And when the content is saved to the cache, it sends a `CacheSavedNotification`, where you could hook in, and send the content to other providers too. ## Thanks to all contributors All this wouldn't have been possible without contributions from - [Markus Johansson](https://github.com/enkelmedia) - [Heather Floyd](https://github.com/hfloyd) - [Owain Jones](https://github.com/OwainJ) - [Michael Latouche](https://github.com/mikecp) --- # Creating Duplicate Content in Umbraco without Duplicating Content URL: https://skttl.dev/blog/duplicate-content-in-umbraco-without-duplicating-content/ Date: 2023-05-11 Description: A step-by-step guide to creating duplicate content in Umbraco without duplicating content. Tags: Umbraco ![Cover](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/cover.jpg) From time to time, I run into projects where I need an exact copy of a specific page in another part of the content tree in Umbraco. The simple and easy way to handle this out of the box is to copy the page to where you need it, but then you have to maintain the same page in multiple places. Can it be done in a better way? Of course, pretty much anything in Umbraco can be done the way you like it. So here's how I did it... The basics of this are a Content Type called Copy Of Node. In this, I add a content picker where the editor can pick the page to copy. ![Screenshot of the Content Type editor in Umbraco editing the Content Type named Copy Of Node, containing a content picker, where the editor can pick the page to copy.](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-02.png) For the sake of example, I have added this to [Paul Seal](https://codeshare.co.uk/)s wonderful [Clean Starter Kit](https://marketplace.umbraco.com/package/clean) and created a duplicate of the blog article about popular blogs. ![Screenshot of the content editor in Umbraco edited the node of type Copy of a node, referencing a blog post](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-03.png) Going to the website, we can see that our new copy of the node is exactly the same as the original. Happy days! ![Screenshot of the Hello node, that copies the content from the Popular blogs node](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-04.png) ![Screenshot of the Popular blogs node](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-05.png) And in the source of our copy, we can see the canonical tag pointing to the original content, eliminating any SEO concerns for duplicate content. ![Screenshot of the source code of the Hello node, showing the canonical link pointing the the Popular blogs node](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-06.png) ## How does it work? This all works by fiddling with the routing in Umbraco. I have created a [notification handler](https://docs.umbraco.com/umbraco-cms/reference/notifications), listening to a RoutingRequestNotification. You can see below, how I've done. Remember to add the notification handler, either in your Startup.cs, or by using a composer. ```csharp using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Web.Common.PublishedModels; namespace Umb11Clean; public class PublishedRequestHandler : INotificationHandler<RoutingRequestNotification> { private readonly IRequestCache _requestCache; public PublishedRequestHandler(AppCaches appCaches) { _requestCache = appCaches.RequestCache; } public void Handle(RoutingRequestNotification notification) { var requestBuilder = notification.RequestBuilder; var content = requestBuilder.PublishedContent; if (content is CopyOfNode copyOfNode && copyOfNode.CopiedContent is not null) { // redirect internally to the copied content, and set the right template notification.RequestBuilder.SetInternalRedirect(copyOfNode.CopiedContent); notification.RequestBuilder.TrySetTemplate(copyOfNode.CopiedContent.GetTemplateAlias()); // Add the actual content to the request cache for later retrieval _requestCache.Set("ActualContent", content); } } } ``` In the notification handler, you can see that I fetch the published content from the request, and check if the content is of the content type CopyOfNode and there is copied content. If there is, I set an internal redirect to the copied content, and set the template to the copied contents template. Note, an internal redirect in Umbraco is more like a rewrite, so the url doesn't change - but all the content does. All this will make Umbraco think that we are actually on the copied node, making eg. tree crawling from the copy impossible. As an example, if you generate navigations based on the current node being viewed, you don't want to generate it based on the copied node, but the copy of the copied node - the actual node. Because of this, I add the actual content - the copy - to the request cache for later retrieval. ## Treecrawling and working of the node context To use the actual content as a base for crawling the content tree, or do other stuff, I add a ContentHelper, that can fetch me the actual current content. (Also, remember to register this, so you can inject it in later) It looks like this: ```csharp using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Web; namespace Umb11Clean.Helpers; public class ContentHelper { private readonly IRequestCache _requestCache; private readonly IUmbracoContextAccessor _umbracoContextAccessor; public ContentHelper( AppCaches appCaches, IUmbracoContextAccessor umbracoContextAccessor) { _requestCache = appCaches.RequestCache; _umbracoContextAccessor = umbracoContextAccessor; } public IPublishedContent? GetActualContent() { var actualContent = _requestCache.GetCacheItem<IPublishedContent>("ActualContent"); if (actualContent is not null) return actualContent; return _umbracoContextAccessor.GetRequiredUmbracoContext().PublishedRequest?.PublishedContent; } } ``` The ContentHelpers GetActualContent method fetches the actual content from the request cache, and returns that. If there is no actual content in the request cache, it simply falls back to the published content from the Umbraco context. So wherever you are getting the content using the PublishedRequest on the UmbracoContext, change this to use the ContentHelper instead. With this helper in place, we can now generate eg. navigations based on where we actually are in the tree - the copy of a node in stead of the copied node. In Clean Starter Kit, I found this snippet for generating navigation, where a link is underlined, if it is an ancestor (or self) of the current page. ```csharp @foreach (var page in homePage.Children.Where(x => !x.Value<bool>("umbracoNaviHide"))) { <li class="nav-item"> <a class="nav-link px-lg-3 py-3 py-lg-4 @(page.IsAncestorOrSelf(Model) ? "text-decoration-underline" : "")" href="@(page.Url())"> @(page.Name) </a> </li> } ``` This will make it underline the path where the node is copied from - alas the copied node. In stead we want it to underline where the node is copied to - alas the copy. You can see it on the screenshot below, where Blogs are underlined, instead of Hello. ![Screenshot of navigation underlining Blogs](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-07.png) To fix this, inject the ContentHelper into the view, and fetch the actual content node. ```csharp @inject Umb11Clean.Helpers.ContentHelper _contentHelper @using ContentModels = Umbraco.Cms.Web.Common.PublishedModels @{ var homepage = Model.AncestorOrSelf<ContentModels.Home>(); var actualContent = _contentHelper.GetActualContent() ?? Model; } // ... removed for brevity @foreach (var page in homePage.Children.Where(x => !x.Value<bool>("umbracoNaviHide"))) { <li class="nav-item"> <a class="nav-link px-lg-3 py-3 py-lg-4 @(page.IsAncestorOrSelf(actualContent) ? "text-decoration-underline" : "")" href="@(page.Url())"> @(page.Name) </a> </li> } ``` Now we determine the underlining by checking on the actual content, and the result is as expected. ![Screenshot of navigation underlining Hello](/images/blog/duplicate-content-in-umbraco-without-duplicating-content/img-08.png) So with this, we can now copy content to all kinds of places in the content tree and only maintain them from their origin, while keeping SEO friendlyness making sure that all the copies points their canonical links back to the original. --- # Simple redirect engine from the Umbraco content tree URL: https://skttl.dev/blog/simple-redirect-engine-from-umbraco-content/ Date: 2023-01-09 Description: Create redirects from the content tree in Umbraco in a rock solid way, using an IUrlProvider ![Cover](/images/blog/simple-redirect-engine-from-umbraco-content/cover.png) Yes, yes, I know... Skybrud.Redirects has everything you need and looks fantastic. But sometimes you need redirects to be part of your site navigation, and often you generate your navigation based on the hierarchy in the Content tree. For this a simple Redirect document type is handy. So let's build one of those. We want to let the editor select where to redirect the user, so I add a Multi Url Picker set to minimum 1 and maximum 1 link. Or in other words 1 link required, nothing more, nothing less. ![Image description](/images/blog/simple-redirect-engine-from-umbraco-content/img-02.png) The sweet thing about the Multi Url Picker is that it let's the editor pick between content, media and external (or internal) urls, and the interface is intuitive and easy to work with. To actually create the redirect, the simplest option is to add a template to the document type and in there add the following code: ```cshtml @using Umbraco.Cms.Web.Common.PublishedModels; @inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.RedirectPage> @using ContentModels = Umbraco.Cms.Web.Common.PublishedModels; @{ Layout = null; Context.Response.Redirect(Model.Redirect.Url, permanent: true); } ``` This will, when the page renders, redirect the user to the selected link, using a permanent (301) redirect. Good enough for starts, but we can make it even better. When using a 301 redirect, "link juice" will not flow as much as when using a real link. SEO experts can speak for days about this. And to me its not very nice to lure the user onto an internal url, just to redirect them away to some other site. So why not make Umbraco use the linked url as the url for the node? We can do that using the IUrlProvider interface. Here's an example of how you can change the url of the redirect nodes from being generated based on the content tree, to actually be the end destinations url. ```cs using Umbraco.Cms.Core.Routing; namespace MyProject.UrlProviders; public class RedirectPageUrlProvider : IUrlProvider { public IEnumerable<UrlInfo> GetOtherUrls(int id, Uri current) { return Enumerable.Empty<UrlInfo>(); } public UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current) { if (content is not ContentModels.RedirectPage redirectPage) return null; return new UrlInfo(redirectPage.Redirect?.Url ?? "#", true, culture); } } ``` In here we implement the GetUrl method from the interface, checking if the content node in question is our Redirect Page, and then returning the url selected in the Url Picker in the node. To register this UrlProvider in Umbraco, you can either implement an IUserComposer like this: ```cs public class RegisterCustomUrlProviderComposer : IComposer { public void Compose(IUmbracoBuilder builder) { builder.UrlProviders().Insert<RedirectPageUrlProvider>(); } } ``` This will automatically get picked up when Umbraco starts, and register your new UrlProvider. When generating the url for a node, Umbraco runs through all registered UrlProviders for each node it needs to generate urls for, so we start by short circuiting this provider by returning null if the actual node is not our redirect content type. If it is our redirect type, we fetch the url from our multi url picker and return that one. And voila, in stead of /path/to/redirect, the url is now (url). So we actually don't need our template anymore. But I like to wear both belt and braces (as we say in Denmark), so I actually want to keep it. But instead of having the logic in a template, I move it to a RenderController instead. ```cs public class RedirectPageController : RenderController { public RedirectPageController(ILogger<RenderController> logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) : base(logger, compositeViewEngine, umbracoContextAccessor) { } public override IActionResult Index() { var redirectPage = CurrentPage as ContentModels.RedirectPage; if (redirectPage.Redirect.Url.IsNullOrWhiteSpace()) return NotFound(); return RedirectPermanent(redirectPage.Redirect.Url); } } ``` As you can see, the logic is pretty much the same. I get the url, I redirect to the url. If no url is selected I return 404. I don't think this should be a replacement for a proper redirects solution, but it's a great fit when you need external links and other redirects in your sitemap. --- # Stop spammers from abusing ImageProcessor to flood your Umbraco 8 logs URL: https://skttl.dev/blog/stop-imageprocessor-log-spam-umbraco-8/ Date: 2022-03-15 Description: By requesting non-existing images in your Umbraco 8 project, spammers can flood your tracelogs. Tags: umbraco, imageprocessor, serilog, log4net ![Cover](/images/blog/stop-imageprocessor-log-spam-umbraco-8/cover.jpg) I have a bunch of client projects, where I started seeing error logs mentioning ImageProcessor like `ImageProcessor.Common.Exceptions.ImageProcessingException: ProcessImageAsync 609 : Not Found` or `ImageProcessor.Common.Exceptions.ImageProcessingException: ProcessImageAsync 609 : No image exists at C:\home\site\wwwroot\test.jpg` I found out, that every time you request an image, with querystring parameters triggering ImageProcessor - like `width`, `height`, `format` and so forth - ImageProcessor will log an error in your tracelogs like the above if the image is not found. At first, I didn't care, I had rather good control over which images were requested with ImageProcessor parameters, so a quick broken link check later, and no broken image urls were left. But one project kept logging these errors. Apparently, someone was deeplinking to non-existing images with parameters triggering ImageProcessor. And the log files were getting enormous, with all the error messages from ImageProcessor. Something needed to be done. I could have turned off logging completely, but then I would miss valuable logs about other stuff in the project, so I asked in the [community Discord channel](https://community.umbraco.com/get-involved/community-discord-channel/). It wasn't long before the always helpful [Sebastiaan Janssen](https://github.com/nul800sebastiaan) suggested adding a filter to the log settings, to filter them out. What a simple and brilliant way of controlling this. So now, I have added the following to the projects Serilog config file, found in `/config/serilog.config`: ```xml <configuration> <appSettings> <add key="serilog:minimum-level:override:ImageProcessor.Web.HttpModules.ImageProcessingModule" value="Fatal" /> </appSettings> </configuration> ``` This setting tells Serilog that messages from `ImageProcessor.Web.HttpModules.ImageProcessingModule` should only be recorded when their level is at least `Fatal`. And since `Fatal` is higher than `Error`, I no longer get those exceptions. ## You can get the same effect in Umbraco 7 too Umbraco 7 uses log4net for logging instead of Serilog which is used in Umbraco 8, so the above example can't be used for Umbraco 7. However, [Matthew Hart](https://github.com/mattou07) jumped into the Discord conversation and suggested the following config setting for the log4net config in Umbraco 7 (located in /config/log4net.config), in case you have a similar problem on that version. ```xml <log4net> <appender name="rollingFile" type="log4net.Appender.RollingFileAppender"> <filter type="log4net.Filter.LoggerMatchFilter"> <loggerToMatch value="ImageProcessor.Web" /> <acceptOnMatch value="false" /> </filter> </appender> </log4net> ``` ## Another usecase for log filtering The config also revealed to me, that I could create a filter, letting `Debug` messages from my own code be recorded, while keeping the general level at `Info` or `Warning`. Thus, making it possible for me to actually find the debug messages from my own code when needed, without being buried in the huge pile of debug messages from all other parts of the project. Win-win! --- # Fixing broken Nested Content after upgrading from Umbraco 7 to 8 URL: https://skttl.dev/blog/fix-broken-nested-content-umbraco-7-to-8/ Date: 2022-03-03 Description: After upgrading to Umbraco 8, we saw weird problems with Nested Content values on the frontend. Tags: umbraco ![Cover](/images/blog/fix-broken-nested-content-umbraco-7-to-8/cover.jpg) Some time ago, I upgraded another project from Umbraco 7 to 8. This is a process I've been getting more and more used to. In this project, after upgrading we kept seeing weird bugs on the frontend, where Nested Content properties kept pulling the wrong content. To set the stage, the site was built with a "Global Texts Folder" containing pages with Nested Content values, including the global texts needed in various parts of the website. The site was also built in two languages, by the approach used before Vorto or the multilingual feature in Umbraco 8 by simply having two trees of content. One for Danish, and one for English. So, the bugs we were seeing the most, was that the Danish version of a page, was showing the English version of the global text selected - and sometimes the opposite. Every time, we could fix it by republishing the erring global text node. I thought something was simply corrupted in the cache upon the data migration. But then the same pages started acting up again. So, what could it be? One day another problem was noticed. A block in the nested content property was duplicated on one page, and another block from the same page was missing. I remembered having a similar problem with Doc Type Grid Editor earlier, where the problem was a duplicated key in the content items. And there I had my problem. ## Why Nested Content can be a PITA when upgrading from Umbraco 7 A little back story. Nested Content contains a hidden `key`, which is a unique Guid for every content item. Understandable, the editors had copied global text nodes from one language to another while building in Umbraco 7, so they didn't have to recreate every Nested Content item from scratch. Umbraco 7 didn't reset these keys, when pages with Nested Content items were copied. So, we now had duplicates of these keys scattered around the website. So, I started looking at the values of the language "variants" of some of the global text nodes - and yes, duplicate keys! One way to fix this, is to copy all nested content items and paste them in same place through the back office. While this works, it's boring, tedious, and prone to errors - all at the same time. So, I did a quick script to update all the global text nodes. ## Get the problematic content types and properties I wanted the script to be quite generic, as I will probably run into this problem again later, so to start, I query Umbraco for any content types that has Nested Content properties in them. I do this using the `ContentTypeService` with the following code: ```cs private List<ContentTypeWithNestedContent> GetContentTypesAndTheirNestedContentPropertyAliases() { var result = new List<ContentTypeWithNestedContent>(); var contentTypes = Services.ContentTypeService.GetAll(); foreach (var contentType in contentTypes) { var nestedContentPropertyTypes = contentType.CompositionPropertyTypes.Where(x => x.PropertyEditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.NestedContent)).ToList(); if (nestedContentPropertyTypes.Any()) { result.Add(new ContentTypeWithNestedContent() { Id = contentType.Id, Alias = contentType.Alias, NestedContentPropertyAliases = nestedContentPropertyTypes.Select(x => x.Alias).ToList() }); } } return result; } ``` This returns a list of objects, containg the id of the content type, the alias, and a list of aliases of the properties using Nested Content. I use this method, to save a list of content types in memory for later use. ## Find and fix potential problematic content nodes With the content types found and ready, I then need to find all content using those types. ```cs public void FindProblematicContentAndFixIt() { // in my constructor: // _contentTypes = GetContentTypesAndTheirNestedContentPropertyAliases(); var contentTypeIds = _contentTypes.Select(x => x.Id); foreach (var contentTypeId in contentTypeIds) { var nodes = Services.ContentService.GetPagedOfType(contentTypeId, 0, int.MaxValue, out long totalNodes, null, null); foreach (var node in nodes) { ResetKeys(node); } } } ``` This uses the before mentioned `GetContentTypesAndTheirNestedContentPropertyAliases` method, to search for content based on the found content types. For each content node found, it's passed into a new method, which will reset the keys of each nested content item. ```cs private void ResetKeys(IContent node) { var aliases = GetNestedContentPropertyAliasesByContentTypeAlias(node.ContentType.Alias); foreach (var alias in aliases) { if (node.HasProperty(alias)) { var input = node.GetValue<string>(alias); var output = ResetKeys(input); if (input != output) { node.SetValue(alias, output); } } } if (node.IsDirty()) { if (node.Published) { Services.ContentService.SaveAndPublish(node); } else { Services.ContentService.Save(node); } } } private List<string> GetNestedContentPropertyAliasesByContentTypeAlias(string contentTypeAlias) { // in my constructor: // _contentTypes = GetContentTypesAndTheirNestedContentPropertyAliases(); if (contentTypeAlias == null) return new List<string>(); var contentType = _contentTypes.FirstOrDefault(x => x.Alias == contentTypeAlias); if (contentType != null) { return contentType.NestedContentPropertyAliases; } return new List<string>(); } ``` So, in here, we first need to know which property aliases to update, so first, we get the aliases by calling another method, `GetNestedContentPropertyAliasesByContentTypeAlias`. In this case, I set up all my code in an API Controller, so in the constructor, I saved the output of the initial `GetContentTypesAndTheirNestedContentPropertyAliases`, so I wouldn't have to look all that up again or pass it through to every method. I can then look through my content type collection, to find the current type's property aliases. With the aliases ready, I then get the values of the properties, and run that through another `ResetKeys` method. ## Handling Nested Nested Content too Since Nested Content can nest content types with their own Nested Content values, I needed a recursive way of resetting keys. ```cs private string ResetKeys(string input) { // get the nested content value, deserialize the string var nestedItems = JsonConvert.DeserializeObject<List<Dictionary<string, string>>>(input); // only do anything if there actually is nested content items inside if (nestedItems != null && nestedItems.Count > 0) { // loop through the nested items, resetting the key nestedItems.ForEach(item => { item["key"] = Guid.NewGuid().ToString(); var aliases = GetNestedContentPropertyAliasesByContentTypeAlias(item["ncContentTypeAlias"]); foreach (var alias in aliases) { if (item.ContainsKey(alias)) { item[alias] = ResetKeys(item[alias]); } } }); return JsonConvert.SerializeObject(nestedItems); } return input; } ``` So, in here, I first deserialize the input string as the expected `Dictionary<string, string>` that Nested Content stores. I then loop through each item, resetting the key property on each. And because the content type of the item, can have its own Nested Content properties, I get the possible aliases, and reset the keys once again for the values in there. In the end, the items are serialized back to a string and returned to the original `ResetKeys` method. ## Only save or published if you need to Because I don't want to mess more than necessary with my content, I check if the content node now is dirty before I save or save and publish it. ```cs // from ResetKeys(IContent node) further up if (node.IsDirty()) { if (node.Published) { Services.ContentService.SaveAndPublish(node); } else { Services.ContentService.Save(node); } } ``` And after running this script - I no longer had any problems with content suddenly duplicating here and there on the site. This is going in the toolbelt for future migrations. If you are interested in the complete controller, I've added it [in a gist here](https://gist.github.com/skttl/5b2d543c4871b5bfbd28deb93ff2c9e6). --- # How to customize searching in Umbraco list views URL: https://skttl.dev/blog/customize-search-in-umbraco-list-views/ Date: 2022-01-13 Description: Learn how to make the search function of Umbracos list views, able to search in other things than the node names. Tags: umbraco, examine, csharp, angularjs ![Cover](/images/blog/customize-search-in-umbraco-list-views/cover.jpg) When dealing with large archives of content, like a blog section, product catalog or similar, using list views is a great way to declutter the content tree in Umbraco. Simply put, enabling the list view on the archive document type, all the children of that doc type will be hidden from the tree, and in stead shown in a "content app", in form of a sortable list. ![Umbraco List view of products](/images/blog/customize-search-in-umbraco-list-views/img-02.png) This makes it a lot easier to work with large archives, like the before mentioned blog section, product catalog etc. But they don't help when you need to find your needle in the haystack of productnodes. Out of the box, Umbracos list view comes with a search function, but it is unfortunately limited to only search in the node names. For something like a product catalog with thousands of products, it would be nice to be able to search for product nodes, by their SKUs. But this would require you to put the SKU in the node name. I would typically put the product name as the node name, and have the SKU on a property by itself. Below you can see an example of the product catalog in the default starter kit of Umbraco, where I have searched for a products SKU. Nothing is found. ![Umbraco List view where searching for a sku returns no product nodes](/images/blog/customize-search-in-umbraco-list-views/img-03.png) Luckily you can swap out the default search with your own, quite easily. Using the magic of $http interceptors in AngularJS, you simply listen for requests to the default API endpoint for searching child nodes, and swapping it out, with your own endpoint. ## Build your own search logic, by inheriting the default For this, I have created a controller, inheriting from Umbracos own `ContentController`. ```cs using System.Linq; using Examine; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Web; using Umbraco.Web.Editors; using Umbraco.Web.Models.ContentEditing; namespace skttl { public class CustomListViewSearchController : ContentController { public CustomListViewSearchController(PropertyEditorCollection propertyEditors, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(propertyEditors, globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) { } public PagedResult<ContentItemBasic<ContentPropertyBasic>> GetChildrenCustom(int id, string includeProperties, int pageNumber = 0, int pageSize = 0, string orderBy = "SortOrder", Direction orderDirection = Direction.Ascending, bool orderBySystemField = true, string filter = "", string cultureName = "") { // get the parent node, and its doctype alias from the content cache var parentNode = Services.ContentService.GetById(id); var parentNodeDocTypeAlias = parentNode != null ? parentNode.ContentType.Alias : null; // if the parent node is not "products", redirect to the core GetChildren() method if (parentNode?.ContentType.Alias != "products") { return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); } // if we can't get the InternalIndex, redirect to the core GetChildren() method, but log an error if (!ExamineManager.Instance.TryGetIndex("InternalIndex", out IIndex index)) { Logger.Error<CustomListViewSearchController>("Couldn't get InternalIndex for searching products in list view"); return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); } // find children using Examine // create search criteria var searcher = index.GetSearcher(); var searchCriteria = searcher.CreateQuery(); var searchQuery = searchCriteria.Field("parentID", id); if (!filter.IsNullOrWhiteSpace()) { searchQuery = searchQuery.And().GroupedOr(new [] { "nodeName", "sku" }, filter); } // do the search, but limit the results to the current page 👉 https://shazwazza.com/post/paging-with-examine/ // pageNumber is not zero indexed in this, so just multiply pageSize by pageNumber var searchResults = searchQuery.Execute(pageSize * pageNumber); // get the results on the current page // pageNumber is not zero indexed in this, so subtract 1 from the pageNumber var totalChildren = searchResults.TotalItemCount; var pagedResultIds = searchResults.Skip((pageNumber > 0 ? pageNumber - 1 : 0) * pageSize).Select(x => x.Id).Select(x => int.Parse(x)).ToList(); var children = Services.ContentService.GetByIds(pagedResultIds).ToList(); if (totalChildren == 0) { return new PagedResult<ContentItemBasic<ContentPropertyBasic>>(0, 0, 0); } var pagedResult = new PagedResult<ContentItemBasic<ContentPropertyBasic>>(totalChildren, pageNumber, pageSize); pagedResult.Items = children.Select(content => Mapper.Map<IContent, ContentItemBasic<ContentPropertyBasic>>(content)) .ToList(); // evaluate now return pagedResult; } } } ``` By inheriting from `ContentController`, I can easily restore the default functionality when I don't need anything custom. I have added a replication of the default `GetChildren` method from the `ContentController`, called `GetChildrenCustom`. It takes the same parameters, which enables me to just swap the url, when Umbraco is calling the API. But more on that later. ```cs // get the parent node, and its doctype alias from the content cache var parentNode = Services.ContentService.GetById(id); var parentNodeDocTypeAlias = parentNode != null ? parentNode.ContentType.Alias : null; // if the parent node is not "products", redirect to the core GetChildren() method if (parentNode?.ContentType.Alias != "products") { return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); } ``` At first I get the parent node from the `ContentService`, and verifies that the parent node is the product catalog. If not, I simply return the `GetChildren` method from the `ContentController`, restoring the default functionality. If I am in a context of a product catalog node, I can start doing my own logic. ```cs // if we can't get the InternalIndex, redirect to the core GetChildren() method, but log an error if (!ExamineManager.Instance.TryGetIndex("InternalIndex", out IIndex index)) { Logger.Error<CustomListViewSearchController>("Couldn't get InternalIndex for searching products in list view"); return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); } ``` At first I check I get the InternalIndex from Examine - if this fails, I revert to the default GetChildren again. ```cs // find children using Examine // create search criteria var searcher = index.GetSearcher(); var searchCriteria = searcher.CreateQuery(); var searchQuery = searchCriteria.Field("parentID", id); if (!filter.IsNullOrWhiteSpace()) { searchQuery = searchQuery.And().GroupedOr(new [] { "nodeName", "sku" }, filter); } ``` But in most cases, the `InternalIndex` works (I still have yet to see an Umbraco installation without the `InternalIndex`). I can then proceed with my search. I'm using Examine here, because it's faster than going through the `ContentService`, when dealing with property values. In this example, query for nodes, where the `parentId` field matches my parent node id. And if the `filter` parameter has a value (this is the search field in the interface), I add a search for that, looking in both the `nodeName`, and the `sku` fields. ```cs // do the search, but limit the results to the current page 👉 https://shazwazza.com/post/paging-with-examine/ // pageNumber is not zero indexed in this, so just multiply pageSize by pageNumber var searchResults = searchQuery.Execute(pageSize * pageNumber); // get the results on the current page // pageNumber is not zero indexed in this, so subtract 1 from the pageNumber var totalChildren = searchResults.TotalItemCount; var pagedResultIds = searchResults.Skip((pageNumber > 0 ? pageNumber - 1 : 0) * pageSize).Select(x => x.Id).Select(x => int.Parse(x)).ToList(); var children = Services.ContentService.GetByIds(pagedResultIds).ToList(); if (totalChildren == 0) { return new PagedResult<ContentItemBasic<ContentPropertyBasic>>(0, 0, 0); } var pagedResult = new PagedResult<ContentItemBasic<ContentPropertyBasic>>(totalChildren, pageNumber, pageSize); pagedResult.Items = children.Select(content => Mapper.Map<IContent, ContentItemBasic<ContentPropertyBasic>>(content)) .ToList(); // evaluate now return pagedResult; ``` Then onto the search. I don't want to return more nodes, than configured in the list view, so I implement paging on the search, as advised by [Shannon in his blogpost](https://shazwazza.com/post/paging-with-examine/). At last I replicate some of the code from the default `GetChildren` method, returning similar results, but based on my examine search. ## Getting the backoffice to use my search logic As I mentioned earlier, AngularJS comes with a concept called $http interceptors. In this, you can listen and react to different things, when AngularJS handles http requests. For this trick to work, I need to change requests for `/umbraco/backoffice/UmbracoApi/Content/GetChildren` (the default endpoint for child nodes), and change it to my newly created one, which is located at `/umbraco/backoffice/api/CustomListViewSearch/GetChildrenCustom`. This is done easily by adding a js file containing an interceptor like this. ```js angular.module('umbraco.services').config([ '$httpProvider', function ($httpProvider) { $httpProvider.interceptors.push(function ($q) { return { 'request': function (request) { // Redirect any requests for the listview to our custom list view UI if (request.url.indexOf("backoffice/UmbracoApi/Content/GetChildren?id=") > -1) request.url = request.url.replace("backoffice/UmbracoApi/Content/GetChildren", "backoffice/api/CustomListViewSearch/GetChildrenCustom"); return request || $q.when(request); } }; }); }]); ``` Note how I left out `/umbraco` from the urls being searched for. Some people like to change the backoffice folder name from `umbraco` to something else - security by obscurity and the likes. By just looking at the latter part of the url, I can support both. Lastly I have to make sure Umbraco finds and includes my interceptor. This is done with a `package.manifest` file in my `App_Plugins` folder. ```js { "javascript": [ "/App_Plugins/CustomListViewSearch/CustomListViewSearch.js" ] } ``` Restart your site, go to your product catalog, and voila. You can now search for product nodes, by typing SKUs. ![Umbraco List View where searching for a SKU returns a product node](/images/blog/customize-search-in-umbraco-list-views/img-04.png) For completeness, you could also implement an [ISearchableTree](https://our.umbraco.com/Documentation/Extending/Section-Trees/Searchable-Trees/). This powers the global backoffice search, and enables your editors to simply press Ctrl+Space on their keyboard, and start searching. I hope you liked this article, and if there is something you would like to know more about, feel free to comment, or tweet me :) --- <span>Photo by <a href="https://unsplash.com/@nepumuk?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Peter Kleinau</a> on <a href="https://unsplash.com/s/photos/haystack?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></span> --- # Bypassing property editor restrictions in Umbraco Element Types URL: https://skttl.dev/blog/bypass-property-editor-restrictions-in-umbraco-element-types/ Date: 2022-01-03 Description: How to use Tags in Element Types, and probably other disabled editors too. Tags: umbraco, csharp ![Cover](/images/blog/bypass-property-editor-restrictions-in-umbraco-element-types/cover.jpg) > **NOTE: This is broken in Umbraco 11** > Luckily, [Duane Gibbs provided a workaround](https://dev.to/dgibbs/comment/23pko), that looks even better! #h5yr Element Types in Umbraco is cool, and can be used as a user friendly way of building custom component based content for your pages. These can be built with the default Block List editor, Nested Content, Doc Type Grid Editor or other Element Type based editors. But they come with a few limitations. One of them being, that they prevent a bunch of built in property editors like Tags, from being used in an Element Type. ![A block with a property using the built in Tags editor](/images/blog/bypass-property-editor-restrictions-in-umbraco-element-types/img-02.png) In this article I will show you how you can bypass this, and still get to use tags in your Element Type based components. # But why can't I just use the default tag editor? Tags are disabled in element types, because the tag editor does much more than just adding some text labels in a specific property. When you save a node with tags, an index of tags and their related nodes are created, for you to search in. So as an example, you can find all nodes tagged with `codegarden`, by using a [TagQuery with the Umbraco Helper](https://our.umbraco.com/Documentation/Reference/Querying/UmbracoHelper/#working-with-tags). This requires the nodes to be actual nodes - which element type based nodes is not. They are just json blobs stored in your nodes, and the TagQuery can't find tags inside that. Since the prevention is based on the property editor alias, all you need is a tag-editor with a non-standard alias. # The solution - make a duplicate with another alias Typically - at least from my point of view - you want to use tags in Element Types, to filter content inside a specific component. Like a list of articles from a specific tag. Or you just like the UI of the editor, and want to use that. So in my case, I can live without the indexing of tags, and I just need the editor. Luckily you can create your own tag editor, without much work, by simply inheriting stuff from the default one. So open up your favorite code editor, and add a `package.manifest` in your desired `App_Plugins` folder. I have created this one in `App_Plugins\Tags.DataOnly\package.manifest`. [Don't know what a package.manifest is? The documentation has got you covered](https://our.umbraco.com/Documentation/Tutorials/Creating-a-Property-Editor/#setting-up-a-plugin). ```json { "propertyEditors": [ { "alias": "Umbraco.Tags.DataOnly", "name": "Tags (Data Only)", "icon": "icon-tags", "group": "tags", "editor": { "view": "tags" }, // ... ``` All the values is lifted from the [default tag definition](https://github.com/umbraco/Umbraco-CMS/blob/v8/contrib/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs) (lines 18-22), with just one modification. I added ".DataOnly" to the alias, and "(Data Only)" to the name, to separate it from the default tags editor. I also need some configuration fields, for specifying which group of tabs to select from, in addition to a storage type for the property. ```json // ... "prevalues": { "fields": [ { "label": "Tag group", "description": "Define a tag group", "key": "group", "view": "requiredfield" }, { "label": "Storage Type", "description": "Select whether to store the tags in cache as JSON (default) or as CSV. The only benefits of storage as JSON is that you are able to have commas in a tag value", "key": "storageType", "view": "views/propertyeditors/tags/tags.prevalues.html" } ] }, // ... ``` These are lifted from the [TagConfiguration class](https://github.com/umbraco/Umbraco-CMS/blob/v8/contrib/src/Umbraco.Core/PropertyEditors/TagConfiguration.cs) in the source code of Umbraco. Note how I just copy and paste everything, including the views. At last, I need some default configuration. I could leave this out, but I like that the experience is as close to the default, so this is added like this: ``` ... "defaultConfig": { "group": "default", "storageType": "Json" } ... ``` With the default config in place, I could also enable this editor to be used in macros, by simply adding the [`isParameterEditor` property to my manifest](https://our.umbraco.com/Documentation/Extending/Property-Editors/package-manifest#property-editors). With all this in place, I can now go to Umbraco, and use my new Tags (Data Only) editor as my tag editor in Element Types. ![A block using the Data Only Tags editor](/images/blog/bypass-property-editor-restrictions-in-umbraco-element-types/img-03.png) # Make ModelsBuilder behave - wrap the value converter But there is one thing missing. The default editor returns a list of strings, when used with ModelsBuilder, and I would like this one to do the same. Turns out, this is pretty simple too. I can add my own ValueConverter, that simply inherits from the default TagsValueConverter. ```cs using Umbraco.Core; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Core.Services; namespace TagsDataOnlyProject { public class TagsDataOnlyValueConverter : TagsValueConverter { public TagsDataOnlyValueConverter(IDataTypeService dataTypeService) : base(dataTypeService) { } public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Tags + ".DataOnly"); } } ``` Umbraco automatically picks this up when booting, and I now have a list of strings as the output type for all properties using my Tags (Data Only) editor. --- > *Photo by [rizki ramadhan](https://unsplash.com/@rizki28?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/s/photos/box-of-tags?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)* ---