Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot

Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension<TContract, TImplementation>()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable<TContract>` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass.

Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly.
Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly.

## Code style

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="bunit" Version="2.11.3" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="NJsonSchema" Version="11.6.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
Expand Down
6 changes: 6 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -12,4 +13,9 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
{
return new FieldSettingsReader(Name, settings).Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return new FieldValueDescription(new JsonObject { ["type"] = "boolean" }, true, "true or false.");
}
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -16,4 +17,17 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return new FieldSettingsReader(Name, settings).Flag("dateOnly")
? new FieldValueDescription(
new JsonObject { ["type"] = "string", ["format"] = "date" },
"2026-01-31",
"A calendar date as YYYY-MM-DD.")
: new FieldValueDescription(
new JsonObject { ["type"] = "string", ["format"] = "date-time" },
"2026-01-31T09:30:00Z",
"A point in time in ISO 8601 with an offset, such as 2026-01-31T09:30:00Z.");
}
}
11 changes: 11 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -16,4 +17,14 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var values = new FieldSettingsReader(Name, settings).RequiredStringList("values") ?? [];

return new FieldValueDescription(
new JsonObject { ["type"] = "string", ["enum"] = new JsonArray([.. values.Select(value => (JsonNode?)value)]) },
values.FirstOrDefault(),
$"One of: {string.Join(", ", values)}.");
}
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace FluentCMS.Core.Builtin.FieldTypes;

internal static class Examples
{
public static string Fit(string text, int? maxLength)
{
return maxLength is { } limit && text.Length > limit ? text[..limit] : text;
}

public static string AtMost(int? maxLength)
{
return maxLength is { } limit ? $" of at most {limit} characters" : string.Empty;
}
}
6 changes: 6 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -13,4 +14,9 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
{
return new FieldSettingsReader(Name, settings).Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return new FieldValueDescription(new JsonObject(), new JsonObject { ["key"] = "value" }, "Any JSON value.");
}
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand Down Expand Up @@ -30,6 +31,19 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object
? value
: FieldDefinition.EmptySettings;
var item = fieldTypes.DescribeValue(settings.GetProperty("itemType").GetString()!, itemSettings);

return new FieldValueDescription(
new JsonObject { ["type"] = "array", ["items"] = item.Schema },
new JsonArray(item.Example),
$"A list of values. Each one: {item.Description}");
}

public IReadOnlyList<ReferenceTarget> ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object
Expand Down
9 changes: 9 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -13,4 +14,12 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
{
return new FieldSettingsReader(Name, settings).Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return new FieldValueDescription(
new JsonObject { ["type"] = "string", ["format"] = "uuid" },
FieldValueDescription.ExampleId,
"The id of an asset in the media library.");
}
}
43 changes: 43 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand Down Expand Up @@ -32,4 +34,45 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var reader = new FieldSettingsReader(Name, settings);
var integer = reader.Flag("integer");
var min = reader.Number("min");
var max = reader.Number("max");

var schema = new JsonObject { ["type"] = integer ? "integer" : "number" };
if (min is { } low)
{
schema["minimum"] = low;
}

if (max is { } high)
{
schema["maximum"] = high;
}

var range = (min, max) switch
{
(not null, not null) => string.Create(CultureInfo.InvariantCulture, $" from {min} to {max}"),
(not null, null) => string.Create(CultureInfo.InvariantCulture, $" of at least {min}"),
(null, not null) => string.Create(CultureInfo.InvariantCulture, $" of at most {max}"),
_ => string.Empty,
};

return new FieldValueDescription(schema, Example(integer, min ?? max ?? 0m), $"{(integer ? "A whole number" : "A number")}{range}.");
}

// A whole number is written without the ".0" a setting may carry, so the example reads as an integer.
private static JsonNode Example(bool integer, decimal value)
{
if (!integer)
{
return value;
}

var whole = decimal.Truncate(value);
return whole is >= long.MinValue and <= long.MaxValue ? decimal.ToInt64(whole) : whole;
}
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand Down Expand Up @@ -29,6 +30,19 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var target = ReferenceTargets(settings, fieldTypes)[0];

// A field reference stores the entity, not the value, so the page always shows the entity's current fact (SCH-04, CNT-04).
return new FieldValueDescription(
new JsonObject { ["type"] = "string", ["format"] = "uuid" },
FieldValueDescription.ExampleId,
target.Field is null
? $"The id of an entry of the content type '{target.Type}'."
: $"The id of a '{target.Type}' entity; the field shows that entity's '{target.Field}' instead of a copy of it.");
}

public IReadOnlyList<ReferenceTarget> ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var field = settings.TryGetProperty("field", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
Expand Down
16 changes: 16 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -16,4 +17,19 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength");
var schema = new JsonObject { ["type"] = "string", ["contentMediaType"] = "text/markdown" };
if (maxLength is { } limit)
{
schema["maxLength"] = limit;
}

return new FieldValueDescription(
schema,
Examples.Fit("Some **Markdown** text.", maxLength),
$"Formatted text written as Markdown{Examples.AtMost(maxLength)}.");
}
}
13 changes: 13 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;

Expand All @@ -16,4 +17,16 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength");
var schema = new JsonObject { ["type"] = "string" };
if (maxLength is { } limit)
{
schema["maxLength"] = limit;
}

return new FieldValueDescription(schema, Examples.Fit("Example text", maxLength), $"Plain text{Examples.AtMost(maxLength)}.");
}
}
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic
// Reads through IAuditReader, which Infrastructure provides.
services.AddScoped<IAuditExporter, AuditExporter>();

// Reads through IContentTypeDirectory, which Infrastructure provides.
services.AddScoped<ISchemaDocuments, SchemaDocuments>();

return services;
}
}
Loading
Loading