diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2f83626fe..e7165e6f2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
diff --git a/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs b/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs
new file mode 100644
index 000000000..d306b586d
--- /dev/null
+++ b/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs
@@ -0,0 +1,58 @@
+using System.Globalization;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.Content.Validation;
+
+public static class ConditionEvaluator
+{
+ public static bool Evaluate(Condition condition, IReadOnlyDictionary values)
+ {
+ ArgumentNullException.ThrowIfNull(condition);
+ ArgumentNullException.ThrowIfNull(values);
+
+ switch (condition.Operator)
+ {
+ case ConditionOperator.Equal:
+ return Matches(Get(values, condition), condition.Values[0]);
+ case ConditionOperator.In:
+ var actual = Get(values, condition);
+ return condition.Values.Any(expected => Matches(actual, expected));
+ case ConditionOperator.IsEmpty:
+ return FieldValue.IsEmpty(Get(values, condition));
+ case ConditionOperator.And:
+ return condition.Operands.All(operand => Evaluate(operand, values));
+ case ConditionOperator.Or:
+ return condition.Operands.Any(operand => Evaluate(operand, values));
+ case ConditionOperator.Not:
+ return !Evaluate(condition.Operands[0], values);
+ default:
+ throw new ArgumentException($"Unknown condition operator {condition.Operator}.", nameof(condition));
+ }
+ }
+
+ // An unknown field is null.
+ private static object? Get(IReadOnlyDictionary values, Condition condition) =>
+ condition.FieldName is { } name && values.TryGetValue(name, out var value) ? value : null;
+
+ // A list matches when one of its items does (a multi select "equals" a value it contains), and an empty list matches null.
+ private static bool Matches(object? actual, object? expected)
+ {
+ if (FieldValue.Items(actual) is { } items)
+ {
+ var list = items.ToList();
+ return expected is null ? list.Count == 0 : list.Any(item => Same(FieldValue.Normalize(item), expected));
+ }
+
+ return Same(FieldValue.Normalize(actual), expected);
+ }
+
+ private static bool Same(object? actual, object? expected) => (actual, expected) switch
+ {
+ (null, null) => true,
+ (null, _) or (_, null) => false,
+ (string left, string right) => string.Equals(left, right, StringComparison.Ordinal),
+ (bool left, bool right) => left == right,
+ (long or decimal, long or decimal) => Convert.ToDecimal(actual, CultureInfo.InvariantCulture) == Convert.ToDecimal(expected, CultureInfo.InvariantCulture),
+ _ => false,
+ };
+}
diff --git a/src/FluentCMS.Core/Content/Validation/ConditionJson.cs b/src/FluentCMS.Core/Content/Validation/ConditionJson.cs
new file mode 100644
index 000000000..5a5d42613
--- /dev/null
+++ b/src/FluentCMS.Core/Content/Validation/ConditionJson.cs
@@ -0,0 +1,53 @@
+using System.Text;
+using System.Text.Json;
+using FluentCMS.Core.Schema;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.Content.Validation;
+
+// A condition in the JSON shape schema.json holds, by way of the schema reader and writer, so there is one format.
+public static class ConditionJson
+{
+ public static void Write(Condition condition, Utf8JsonWriter writer)
+ {
+ ArgumentNullException.ThrowIfNull(condition);
+ ArgumentNullException.ThrowIfNull(writer);
+ SchemaJsonWriter.WriteCondition(writer, condition);
+ }
+
+ public static string ToJson(Condition condition)
+ {
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ Write(condition, writer);
+ }
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ public static Condition Read(JsonElement element)
+ {
+ // The reader reports problems on a builder; one without types fails its build with exactly those problems.
+ var builder = new SchemaBuilder();
+ var condition = new SchemaJsonReader(builder).ReadCondition(element, "condition");
+
+ try
+ {
+ builder.Build();
+ }
+ catch (SchemaValidationException exception)
+ {
+ throw new JsonException($"The condition is not valid: {string.Join(" ", exception.Errors.Select(error => error.Message.TrimStart(':', ' ')))}");
+ }
+
+ return condition ?? throw new JsonException("The condition is not valid.");
+ }
+
+ public static Condition Parse(string json)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+ using var document = JsonDocument.Parse(json);
+ return Read(document.RootElement);
+ }
+}
diff --git a/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs
new file mode 100644
index 000000000..00f397a17
--- /dev/null
+++ b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs
@@ -0,0 +1,264 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.RegularExpressions;
+using FluentCMS.Content;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.Content.Validation;
+
+// A field that was checked, so the custom field validators see exactly the fields the declarative rules saw.
+internal sealed record FieldVisit(SchemaType Owner, SchemaField Field, string Path, object? Value, IReadOnlyDictionary Siblings);
+
+// Required, RequiredWhen, length, range, pattern and select options, for every visible field, recursing into
+// components and blocks. A block or component instance is a dictionary keyed by API field name (a block carries
+// its block name under "type") or a CLR object whose public properties are the fields.
+internal sealed class DeclarativeValidator(SchemaModel model)
+{
+ private const string TypeKey = "type";
+ private const int MaxNestingDepth = 8;
+ private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);
+
+ private readonly ConcurrentDictionary _patterns = new();
+
+ public void Validate(SchemaType type, IReadOnlyDictionary values, ValidationContext context, List visits) =>
+ ValidateFields(type, values, "", context, visits, 0);
+
+ private void ValidateFields(SchemaType type, IReadOnlyDictionary values, string prefix, ValidationContext context, List visits, int depth)
+ {
+ foreach (var field in type.Fields)
+ {
+ // A hidden field is not part of the entry, whatever it holds.
+ if (field.VisibleWhen is not null && !ConditionEvaluator.Evaluate(field.VisibleWhen, values))
+ {
+ continue;
+ }
+
+ values.TryGetValue(field.Name, out var value);
+ var path = prefix + field.Name;
+ visits.Add(new FieldVisit(type, field, path, value, values));
+
+ if (FieldValue.IsEmpty(value))
+ {
+ CheckRequired(field, path, values, context);
+ continue;
+ }
+
+ CheckLength(field, path, value, context);
+ CheckItems(field, path, value, context);
+
+ if (depth < MaxNestingDepth)
+ {
+ Recurse(field, path, value, context, visits, depth + 1);
+ }
+ }
+ }
+
+ private static void CheckRequired(SchemaField field, string path, IReadOnlyDictionary values, ValidationContext context)
+ {
+ if (context.Mode != ValidationMode.Publish)
+ {
+ return;
+ }
+
+ if (field.Required)
+ {
+ context.AddError(path, ValidationErrorCodes.Required, "A value is required.");
+ }
+ else if (field.RequiredWhen is not null && ConditionEvaluator.Evaluate(field.RequiredWhen, values))
+ {
+ context.AddError(path, ValidationErrorCodes.RequiredByCondition, $"A value is required when {field.RequiredWhen}.");
+ }
+ }
+
+ // A text is limited in characters; a multiple field, and blocks, in items.
+ private static void CheckLength(SchemaField field, string path, object? value, ValidationContext context)
+ {
+ int length;
+ string unit;
+ if (IsList(field) && FieldValue.Items(value) is { } items)
+ {
+ length = items.Count();
+ unit = "items";
+ }
+ else if (!field.Multiple && value is string text)
+ {
+ length = text.Length;
+ unit = "characters";
+ }
+ else
+ {
+ return;
+ }
+
+ if (field.MinLength is { } min && length < min)
+ {
+ context.AddError(path, ValidationErrorCodes.MinLength, $"Must have at least {min} {unit}, has {length}.");
+ }
+
+ if (field.MaxLength is { } max && length > max)
+ {
+ context.AddError(path, ValidationErrorCodes.MaxLength, $"Must have at most {max} {unit}, has {length}.");
+ }
+ }
+
+ // The rules on the value itself, applied to each item of a multiple field at its own path (tags[1]).
+ private void CheckItems(SchemaField field, string path, object? value, ValidationContext context)
+ {
+ if (field.FieldType is ValidatedFieldTypes.Component or ValidatedFieldTypes.Blocks)
+ {
+ return;
+ }
+
+ if (field.Multiple && FieldValue.Items(value) is { } items)
+ {
+ var index = 0;
+ foreach (var item in items)
+ {
+ CheckValue(field, $"{path}[{index++}]", item, context);
+ }
+ }
+ else if (!field.Multiple)
+ {
+ CheckValue(field, path, value, context);
+ }
+ }
+
+ private void CheckValue(SchemaField field, string path, object? value, ValidationContext context)
+ {
+ if (FieldValue.IsEmpty(value))
+ {
+ return;
+ }
+
+ if (RangeValue(value) is { } number)
+ {
+ if (field.Minimum is { } min && number < min)
+ {
+ context.AddError(path, ValidationErrorCodes.BelowMinimum, $"Must be at least {Format(min)}.");
+ }
+
+ if (field.Maximum is { } max && number > max)
+ {
+ context.AddError(path, ValidationErrorCodes.AboveMaximum, $"Must be at most {Format(max)}.");
+ }
+ }
+
+ if (field.Pattern is not null && value is string text && !Matches(field, text))
+ {
+ context.AddError(path, ValidationErrorCodes.PatternMismatch, $"Does not match the pattern {field.Pattern}.");
+ }
+
+ if (field.FieldType == ValidatedFieldTypes.Select
+ && field.Settings.TryGetValue(FieldSettings.Options, out var setting)
+ && setting is IEnumerable options
+ && FieldValue.Normalize(value) is { } option
+ && !options.Contains(option as string, StringComparer.Ordinal))
+ {
+ context.AddError(path, ValidationErrorCodes.NotAnAllowedOption, $"{option} is not one of: {string.Join(", ", options)}.");
+ }
+ }
+
+ private void Recurse(SchemaField field, string path, object? value, ValidationContext context, List visits, int depth)
+ {
+ if (field.FieldType == ValidatedFieldTypes.Component
+ && field.Settings.TryGetValue(FieldSettings.Component, out var name)
+ && model.FindComponent((string)name) is { } component)
+ {
+ if (field.Multiple)
+ {
+ var index = 0;
+ foreach (var item in FieldValue.Items(value) ?? [])
+ {
+ ValidateInstance(component, item, $"{path}[{index++}].", context, visits, depth);
+ }
+ }
+ else
+ {
+ ValidateInstance(component, value, $"{path}.", context, visits, depth);
+ }
+ }
+ else if (field.FieldType == ValidatedFieldTypes.Blocks)
+ {
+ var allowed = field.Settings.TryGetValue(FieldSettings.Blocks, out var setting) && setting is IEnumerable names ? names.ToList() : [];
+ var index = 0;
+ foreach (var item in FieldValue.Items(value) ?? [])
+ {
+ var itemPath = $"{path}[{index++}]";
+ var block = FindBlock(item);
+ if (block is null || !allowed.Contains(block.Name))
+ {
+ context.AddError(itemPath, ValidationErrorCodes.NotAnAllowedOption, $"The block type {BlockName(item) ?? "(none)"} is not one of: {string.Join(", ", allowed)}.");
+ continue;
+ }
+
+ ValidateInstance(block, item, $"{itemPath}.", context, visits, depth);
+ }
+ }
+ }
+
+ private void ValidateInstance(SchemaType type, object? instance, string prefix, ValidationContext context, List visits, int depth)
+ {
+ if (FieldValue.ReadInstance(instance) is { } values)
+ {
+ ValidateFields(type, values, prefix, context, visits, depth);
+ }
+ }
+
+ private SchemaType? FindBlock(object? item) => BlockName(item) is { } name ? model.FindBlock(name) : null;
+
+ private string? BlockName(object? item)
+ {
+ if (item is IReadOnlyDictionary dictionary)
+ {
+ return dictionary.TryGetValue(TypeKey, out var name) ? name as string : null;
+ }
+
+ if (item is IDictionary mutable)
+ {
+ return mutable.TryGetValue(TypeKey, out var name) ? name as string : null;
+ }
+
+ return item is null ? null : model.Blocks.FirstOrDefault(block => block.ClrType == item.GetType())?.Name;
+ }
+
+ private bool Matches(SchemaField field, string text)
+ {
+ var regex = _patterns.GetOrAdd(field, static f => new Regex(f.Pattern!, RegexOptions.CultureInvariant, MatchTimeout));
+ try
+ {
+ return regex.IsMatch(text);
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsList(SchemaField field) => field.Multiple || field.FieldType == ValidatedFieldTypes.Blocks;
+
+ // Only numbers have a range; the model does not say whether a bound on a date means days or seconds.
+ // A float beyond what a decimal holds counts as the nearest decimal bound, so it is out of range in its direction.
+ private static decimal? RangeValue(object? value) => value switch
+ {
+ byte or sbyte or short or ushort or int or uint or long or ulong or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture),
+ float or double => Clamp(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
+ _ => null,
+ };
+
+ private static decimal? Clamp(double number) => number switch
+ {
+ double.NaN => null,
+ >= (double)decimal.MaxValue => decimal.MaxValue,
+ <= (double)decimal.MinValue => decimal.MinValue,
+ _ => (decimal)number,
+ };
+
+ private static string Format(decimal number) => number.ToString("G29", CultureInfo.InvariantCulture);
+}
+
+internal static class ValidatedFieldTypes
+{
+ public const string Select = "select";
+ public const string Component = "component";
+ public const string Blocks = "blocks";
+}
diff --git a/src/FluentCMS.Core/Content/Validation/FieldValue.cs b/src/FluentCMS.Core/Content/Validation/FieldValue.cs
new file mode 100644
index 000000000..fc28792ff
--- /dev/null
+++ b/src/FluentCMS.Core/Content/Validation/FieldValue.cs
@@ -0,0 +1,80 @@
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Reflection;
+using FluentCMS.Media;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.Content.Validation;
+
+// How validation reads a value: whether it is empty, what it compares as, and how a component or block instance
+// is read, either a dictionary keyed by API field name or a CLR object whose public properties are the fields.
+internal static class FieldValue
+{
+ private static readonly ConcurrentDictionary Properties = new();
+
+ public static bool IsEmpty(object? value) => value switch
+ {
+ null => true,
+ string text => string.IsNullOrWhiteSpace(text),
+ Guid id => id == Guid.Empty,
+ MediaRef media => media.IsEmpty,
+ IEnumerable items => !items.GetEnumerator().MoveNext(),
+ _ => IsEmptyRef(value),
+ };
+
+ // The value as a schema stores it: null, string, bool, long or decimal. An enum member is its camelCase
+ // name, a reference is its id. Anything else cannot be compared with a condition value.
+ public static object? Normalize(object? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (IsRef(value.GetType()))
+ {
+ return value.GetType().GetProperty("Id")!.GetValue(value)?.ToString();
+ }
+
+ try
+ {
+ return SchemaValue.Normalize(value);
+ }
+ catch (ArgumentException)
+ {
+ return value;
+ }
+ }
+
+ public static IEnumerable