diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..49fbc667
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,71 @@
+# Changelog
+
+All notable code and behaviour changes to uSync are recorded here.
+
+This file tracks changes to how uSync _behaves_. For changes to the on-disk
+`.config` file format (which can cause items to report as changed and prompt a
+re-export), see [`changes/format.md`](changes/format.md).
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+History is backfilled from the v18 release history starting at `v18.0.0`.
+
+## [Unreleased]
+
+### Changed
+
+- **Handler settings now inherit from `HandlerDefaults`.** When a handler has its
+ own settings block, its values are layered _over_ the set's `HandlerDefaults`
+ instead of replacing them wholesale. A handler now only needs to specify the
+ settings it wants to change from the defaults.
+ - The additional `Settings` dictionary is merged key-by-key (the handler's own
+ keys win), so a per-key default such as `CreateOnly` set in `HandlerDefaults`
+ now cascades to handlers that define their own block.
+ - The strongly typed properties (`UseFlatStructure`, `GuidNames`, etc.) also
+ cascade, via a new `IServiceCollection.ConfigureHandlerSet(...)` extension
+ that binds each handler's configuration on top of a clone of the defaults at
+ options-binding time.
+
+ > **Breaking:** Previously a handler that defined its own block ignored
+ > `HandlerDefaults` entirely. Configurations that relied on that replacement
+ > behaviour (i.e. expected a handler block to _reset_ settings back to their
+ > built-in defaults rather than inherit the set defaults) will now see the
+ > inherited values instead. Review any set that mixes `HandlerDefaults` with
+ > per-handler blocks.
+
+### Fixed
+
+- `HandlerSettings.Clone()` no longer drops the `CreateClean` and
+ `FullFileOnDifference` properties. Handlers that set either value in their own
+ block were previously resolved as `false` regardless; they are now honoured.
+
+## [18.0.3] - 2026-07-22
+
+### Fixed
+
+- Culture-variant property values were dropped on import because of a
+ `culture`/`cultures` typo. Variant properties now import correctly. (#1000)
+
+## [18.0.2] - 2026-07-13
+
+### Added
+
+- `ISyncContainerHandler` — lets a handler whose items live in container (folder)
+ items export those containers one at a time. The Library **Element** handler
+ implements it, so container folders are no longer left behind when items are
+ exported individually (e.g. a dependency-based push); previously the folders
+ were only written during a full export. (#980)
+
+### Changed
+
+- **Extender API:** `ISyncContainerHandler.ExportContainer` now takes a `Udi`.
+
+### Fixed
+
+- Element container nodes now resolve to the Element handler by type name.
+- Merged the latest `v17/main` fixes and performance improvements into v18.
+
+## [18.0.0] - 2026-06-25
+
+### Added
+
+- Initial uSync release for **Umbraco 18**.
diff --git a/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs
new file mode 100644
index 00000000..e7c734d4
--- /dev/null
+++ b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs
@@ -0,0 +1,62 @@
+using System.Linq;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace uSync.BackOffice.Configuration;
+
+///
+/// Service collection helpers for registering a uSync handler set from configuration.
+///
+public static class HandlerSetConfigurationExtensions
+{
+ ///
+ /// Bind a handler set from configuration, and layer each named handler's own settings
+ /// over the top of the set's .
+ ///
+ ///
+ ///
+ /// This is a drop-in replacement for services.Configure<uSyncHandlerSetSettings>(setName, section).
+ ///
+ ///
+ /// After the normal bind, each handler that has its own block is re-bound on top of a clone of
+ /// the resolved . Because configuration binding
+ /// only writes the keys that are actually present, a handler inherits every default it does not
+ /// explicitly override - including the strongly typed properties (UseFlatStructure, GuidNames, etc.)
+ /// that can't be merged after binding (an unset boolean is indistinguishable from one set to its
+ /// default value once bound).
+ ///
+ ///
+ /// The additional dictionary is also merged here, and is
+ /// merged again (idempotently) at resolution time in
+ /// so that keys added to
+ /// by later post-configure steps still cascade.
+ ///
+ ///
+ public static IServiceCollection ConfigureHandlerSet(this IServiceCollection services, string setName, IConfigurationSection section)
+ {
+ services.Configure(setName, section);
+ services.PostConfigure(setName, options => MergeHandlerDefaults(options, section));
+ return services;
+ }
+
+ ///
+ /// Re-bind every named handler in the set on top of a clone of the handler defaults.
+ ///
+ internal static void MergeHandlerDefaults(uSyncHandlerSetSettings options, IConfigurationSection section)
+ {
+ if (options.Handlers is null || options.Handlers.Count == 0)
+ return;
+
+ var handlersSection = section.GetSection("Handlers");
+
+ foreach (var alias in options.Handlers.Keys.ToList())
+ {
+ // start from the resolved defaults, then bind the handler's own raw config over the
+ // top - only the keys present in the handler block will override the defaults.
+ var merged = options.HandlerDefaults.Clone();
+ handlersSection.GetSection(alias).Bind(merged);
+ options.Handlers[alias] = merged;
+ }
+ }
+}
diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs
index 868c21b8..e728edf5 100644
--- a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs
+++ b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs
@@ -48,10 +48,16 @@ public class uSyncHandlerSetSettings
public static class HandlerSetSettingsExtensions
{
///
- /// Get the handler settings for the named handler - (will load defaults if no specific handler settings are found)
+ /// Get the handler settings for the named handler.
///
+ ///
+ /// When the handler has its own settings block, those settings are merged over the top of
+ /// (see ),
+ /// so a handler only needs to specify the additional settings it wants to change from the defaults.
+ /// If the handler has no block of its own, the defaults are used as-is.
+ ///
public static HandlerSettings GetHandlerSettings(this uSyncHandlerSetSettings handlerSet, string alias)
=> handlerSet.Handlers.TryGetValue(alias, out var value)
- ? value.Clone()
+ ? value.MergeWithDefaults(handlerSet.HandlerDefaults)
: handlerSet.HandlerDefaults.Clone();
}
diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs
index 4fe46a19..91047977 100644
--- a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs
+++ b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs
@@ -128,8 +128,51 @@ public static HandlerSettings Clone(this HandlerSettings settings)
UseFlatStructure = settings.UseFlatStructure,
Group = settings.Group,
GuidNames = settings.GuidNames,
- Settings = new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase)
+ CreateClean = settings.CreateClean,
+ FullFileOnDifference = settings.FullFileOnDifference,
+ Settings = settings.Settings is not null
+ ? new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase)
+ : new Dictionary(StringComparer.InvariantCultureIgnoreCase)
};
}
+ ///
+ /// Merge a handler's own settings over the top of a set of default settings.
+ ///
+ ///
+ ///
+ /// Returns a new so neither input is mutated.
+ ///
+ ///
+ /// The strongly typed properties (Enabled, UseFlatStructure, GuidNames, etc.) are taken from
+ /// . Configuration binding cannot tell an unset boolean from
+ /// one explicitly set to its default value, so we can't reliably layer these over the defaults
+ /// without risking overriding a deliberately-set value - the handler's own block wins for them.
+ ///
+ ///
+ /// The additional dictionary is merged, because a
+ /// key is only present when it has been explicitly configured. The
+ /// provide the base and the handler's own keys take precedence - so a per-key default (e.g.
+ /// CreateOnly) set in HandlerDefaults now cascades to handlers that define their own block.
+ ///
+ ///
+ public static HandlerSettings MergeWithDefaults(this HandlerSettings handlerSettings, HandlerSettings defaults)
+ {
+ var merged = handlerSettings.Clone();
+
+ // start from the defaults, then layer the handler's own keys on top.
+ var settings = defaults.Settings is not null
+ ? new Dictionary(defaults.Settings, StringComparer.InvariantCultureIgnoreCase)
+ : new Dictionary(StringComparer.InvariantCultureIgnoreCase);
+
+ if (handlerSettings.Settings is not null)
+ {
+ foreach (var setting in handlerSettings.Settings)
+ settings[setting.Key] = setting.Value;
+ }
+
+ merged.Settings = settings;
+ return merged;
+ }
+
}
diff --git a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs
index 917fcbdb..632ca13c 100644
--- a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs
+++ b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs
@@ -55,9 +55,11 @@ public static IUmbracoBuilder AdduSync(this IUmbracoBuilder builder, Action(uSync.Sets.DefaultSet,
+ // default handler options, other people can load their own names handler options and
+ // they can be used throughout uSync (so complete will do this).
+ // ConfigureHandlerSet also layers each handler's own settings over the HandlerDefaults,
+ // so a handler only needs to specify the settings it wants to change from the defaults.
+ builder.Services.ConfigureHandlerSet(uSync.Sets.DefaultSet,
builder.Config.GetSection(uSync.Configuration.ConfigDefaultSet));
// Setup uSync core.
diff --git a/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs
new file mode 100644
index 00000000..32bd7cbf
--- /dev/null
+++ b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+using NUnit.Framework;
+
+using uSync.BackOffice.Configuration;
+using uSync.BackOffice.Extensions;
+
+namespace uSync.Tests.Configuration;
+
+///
+/// Tests that layers a handler's
+/// own configuration over the top of the set's HandlerDefaults - including the strongly typed
+/// properties that can only be merged at bind time.
+///
+[TestFixture]
+public class HandlerSetOptionsBindingTests
+{
+ private const string SetPath = "uSync:Sets:Default";
+
+ private static uSyncHandlerSetSettings ResolveSet(Dictionary config)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(config)
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddOptions();
+ services.ConfigureHandlerSet("Default", configuration.GetSection(SetPath));
+
+ var provider = services.BuildServiceProvider();
+ return provider.GetRequiredService>().Get("Default");
+ }
+
+ [Test]
+ public void StronglyTypedPropertiesCascadeFromDefaults()
+ {
+ // ContentHandler only sets CreateOnly - everything else should come from HandlerDefaults.
+ var set = ResolveSet(new()
+ {
+ [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false",
+ [$"{SetPath}:HandlerDefaults:GuidNames"] = "true",
+ [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "false",
+ [$"{SetPath}:HandlerDefaults:Settings:FromDefaults"] = "default-value",
+ [$"{SetPath}:Handlers:ContentHandler:Settings:CreateOnly"] = "true",
+ });
+
+ var settings = set.GetHandlerSettings("ContentHandler");
+
+ Assert.Multiple(() =>
+ {
+ // strongly typed defaults now cascade (the bit that needed the options layer)
+ Assert.That(settings.UseFlatStructure, Is.False, "UseFlatStructure should inherit from defaults");
+ Assert.That(settings.GuidNames, Is.True, "GuidNames should inherit from defaults");
+ // dictionary merge
+ Assert.That(settings.IsCreateOnly(), Is.True, "handler's own CreateOnly should win");
+ Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value"));
+ });
+ }
+
+ [Test]
+ public void HandlerCanOverrideStronglyTypedDefault()
+ {
+ var set = ResolveSet(new()
+ {
+ [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false",
+ [$"{SetPath}:HandlerDefaults:GuidNames"] = "true",
+ [$"{SetPath}:Handlers:FlatHandler:UseFlatStructure"] = "true",
+ });
+
+ var settings = set.GetHandlerSettings("FlatHandler");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.UseFlatStructure, Is.True, "handler override should win over default");
+ Assert.That(settings.GuidNames, Is.True, "unset property should still inherit from defaults");
+ });
+ }
+
+ [Test]
+ public void MinimalBlockInheritsAllUnsetDefaults()
+ {
+ // a handler that only sets one property (here Enabled) must still inherit every
+ // other default - this is the "lots of handlers with near-empty blocks" case.
+ var set = ResolveSet(new()
+ {
+ [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false",
+ [$"{SetPath}:HandlerDefaults:GuidNames"] = "true",
+ [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true",
+ [$"{SetPath}:Handlers:MinimalHandler:Enabled"] = "true",
+ });
+
+ var settings = set.GetHandlerSettings("MinimalHandler");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.Enabled, Is.True);
+ Assert.That(settings.UseFlatStructure, Is.False, "should inherit default, not the C# default of true");
+ Assert.That(settings.GuidNames, Is.True);
+ Assert.That(settings.IsCreateOnly(), Is.True, "should inherit default settings key");
+ });
+ }
+
+ [Test]
+ public void EmptyBlockHandlerIsNotEvenBound()
+ {
+ // an empty {} block emits no config leaves, so the handler never enters options.Handlers
+ // and is resolved purely from the defaults via the Clone() fallback.
+ var set = ResolveSet(new()
+ {
+ [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false",
+ [$"{SetPath}:HandlerDefaults:GuidNames"] = "true",
+ // note: no leaf keys under EmptyBlockHandler are possible from an empty object.
+ });
+
+ Assert.That(set.Handlers.ContainsKey("EmptyBlockHandler"), Is.False);
+
+ var settings = set.GetHandlerSettings("EmptyBlockHandler");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.UseFlatStructure, Is.False);
+ Assert.That(settings.GuidNames, Is.True);
+ });
+ }
+
+ [Test]
+ public void HandlerWithoutOwnBlockStillUsesDefaults()
+ {
+ var set = ResolveSet(new()
+ {
+ [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false",
+ [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true",
+ });
+
+ var settings = set.GetHandlerSettings("SomeHandlerWithNoBlock");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.UseFlatStructure, Is.False);
+ Assert.That(settings.IsCreateOnly(), Is.True);
+ });
+ }
+}
diff --git a/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs
new file mode 100644
index 00000000..fc613762
--- /dev/null
+++ b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs
@@ -0,0 +1,128 @@
+using NUnit.Framework;
+
+using uSync.BackOffice.Configuration;
+using uSync.BackOffice.Extensions;
+
+namespace uSync.Tests.Configuration;
+
+///
+/// Tests for how a handler's own settings are merged with the HandlerDefaults
+/// when resolved via .
+///
+[TestFixture]
+public class HandlerSettingsMergeTests
+{
+ private static uSyncHandlerSetSettings BuildSet()
+ {
+ var set = new uSyncHandlerSetSettings
+ {
+ HandlerDefaults = new HandlerSettings
+ {
+ UseFlatStructure = false,
+ }
+ };
+
+ set.HandlerDefaults.Settings["CreateOnly"] = "false";
+ set.HandlerDefaults.Settings["FromDefaults"] = "default-value";
+
+ return set;
+ }
+
+ [Test]
+ public void HandlerWithoutOwnBlock_UsesDefaults()
+ {
+ var set = BuildSet();
+
+ var settings = set.GetHandlerSettings("ContentHandler");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.IsCreateOnly(), Is.False);
+ Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value"));
+ Assert.That(settings.UseFlatStructure, Is.False);
+ });
+ }
+
+ [Test]
+ public void HandlerOwnKey_OverridesDefaultKey()
+ {
+ // the #953 scenario: HandlerDefaults set CreateOnly false,
+ // the named handler explicitly turns it on.
+ var set = BuildSet();
+ var handler = new HandlerSettings();
+ handler.Settings["CreateOnly"] = "true";
+ set.Handlers["ContentHandler"] = handler;
+
+ var settings = set.GetHandlerSettings("ContentHandler");
+
+ Assert.That(settings.IsCreateOnly(), Is.True);
+ }
+
+ [Test]
+ public void HandlerWithOwnBlock_InheritsDefaultKeysItDoesNotSet()
+ {
+ // previously a handler with its own block ignored HandlerDefaults entirely.
+ var set = BuildSet();
+ var handler = new HandlerSettings();
+ handler.Settings["CreateOnly"] = "true";
+ set.Handlers["ContentHandler"] = handler;
+
+ var settings = set.GetHandlerSettings("ContentHandler");
+
+ Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value"));
+ }
+
+ [Test]
+ public void Merge_DoesNotMutateInputs()
+ {
+ var set = BuildSet();
+ var handler = new HandlerSettings();
+ handler.Settings["CreateOnly"] = "true";
+ set.Handlers["ContentHandler"] = handler;
+
+ _ = set.GetHandlerSettings("ContentHandler");
+
+ Assert.Multiple(() =>
+ {
+ // defaults should not have gained the handler's key
+ Assert.That(set.HandlerDefaults.Settings.ContainsKey("CreateOnly"), Is.True);
+ Assert.That(set.HandlerDefaults.Settings["CreateOnly"], Is.EqualTo("false"));
+ // handler block should not have gained the default's key
+ Assert.That(handler.Settings.ContainsKey("FromDefaults"), Is.False);
+ });
+ }
+
+ [Test]
+ public void Clone_PreservesCreateCleanAndFullFileOnDifference()
+ {
+ // regression: Clone() used to drop these two properties.
+ var settings = new HandlerSettings
+ {
+ CreateClean = true,
+ FullFileOnDifference = true,
+ };
+
+ var clone = settings.Clone();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(clone.CreateClean, Is.True);
+ Assert.That(clone.FullFileOnDifference, Is.True);
+ });
+ }
+
+ [Test]
+ public void MergeWithDefaults_TakesStronglyTypedPropertiesFromHandler()
+ {
+ var defaults = new HandlerSettings { GuidNames = true, CreateClean = true };
+ var handler = new HandlerSettings { GuidNames = false, CreateClean = false };
+
+ var merged = handler.MergeWithDefaults(defaults);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(merged.GuidNames, Is.False);
+ Assert.That(merged.CreateClean, Is.False);
+ });
+ }
+}