Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
769597d
Store media assets with versioned metadata, folders and tags
pournasserian Oct 19, 2026
7232e12
Stop reading an upload one byte past the limit
pournasserian Oct 19, 2026
53368ac
Put media in change sets and add the media tools
pournasserian Oct 19, 2026
ed49ec0
Name only media fields when a field is not one
pournasserian Oct 19, 2026
f0aed6f
Check alt text of the assets media fields name
pournasserian Oct 19, 2026
110a5e4
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
6b95164
Merge branch '2542-media-assets' into 2544-media-change-sets
pournasserian Oct 19, 2026
3ef7658
Merge branch '2544-media-change-sets' into 2548-media-alt-text
pournasserian Oct 19, 2026
4f62696
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
3782b3e
Accept only allowed media types whose bytes match them, and validate …
pournasserian Oct 19, 2026
2eff3fd
Merge branch '2542-media-assets' into 2544-media-change-sets
pournasserian Oct 19, 2026
43d4160
Refuse an oversized upload before decoding it
pournasserian Oct 19, 2026
fad2ed1
Merge branch '2544-media-change-sets' into 2548-media-alt-text
pournasserian Oct 19, 2026
da00d21
Check the alt text of the version that goes live, and only for images
pournasserian Oct 19, 2026
3ceaa12
Refuse control characters in alt text and caption, and match folders …
pournasserian Oct 19, 2026
af8eff8
Merge remote-tracking branch 'origin/v2' into 2542-media-assets
pournasserian Oct 19, 2026
f045838
Merge remote-tracking branch 'origin/2542-media-assets' into 2544-med…
pournasserian Oct 19, 2026
ecdba9b
Merge remote-tracking branch 'origin/v2' into 2544-media-change-sets
pournasserian Oct 19, 2026
f68a3cb
Leave the content model's own change set out of the count
pournasserian Oct 19, 2026
8f96312
Merge remote-tracking branch 'origin/v2' into 2544-media-change-sets
pournasserian Oct 19, 2026
cb71531
Merge remote-tracking branch 'origin/2544-media-change-sets' into 254…
pournasserian Oct 19, 2026
474f385
Merge remote-tracking branch 'origin/v2' into 2548-media-alt-text
pournasserian Oct 19, 2026
b6b307f
Let a set's own checks read the media versions it proposes
pournasserian Oct 19, 2026
f06478b
Let update_media extend an upload in the same set, and fail an image …
pournasserian Oct 19, 2026
00eeca2
Merge remote-tracking branch 'origin/v2' into 2548-media-alt-text
pournasserian Oct 19, 2026
73629fb
Merge remote-tracking branch 'origin/v2' into 2548-media-alt-text
pournasserian Oct 26, 2026
62cc567
Count a person's library write as live in the alt text check and read…
pournasserian Oct 26, 2026
833493d
Keep every media version a set proposes so only a person's library wr…
pournasserian Oct 26, 2026
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
75 changes: 72 additions & 3 deletions src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Checks;
using FluentCMS.Core.Media;
using FluentCMS.Core.Results;
using Microsoft.Extensions.DependencyInjection;

namespace FluentCMS.Core.Builtin.Checks;

// Every image in the text has alt text that describes it (CHK-01): Markdown images, inline or by reference, and HTML img elements.
// Alt text of assets in the media library is checked there (F-MED).
// A media field that names an image passes when the version readers will see has alt text (MED-02): the live one once the content's
// change set publishes what it proposes, so a person's later write in the library counts too. An image only another set proposes
// fails, since nothing checks what goes live with it later. Other media, such as a PDF, needs no alt text. An asset the library does
// not hold is left to the reference checks of entries.
internal sealed class AltTextCheckType : ICheckType
{
private const RegexOptions Linear = RegexOptions.NonBacktracking | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase;
Expand All @@ -29,7 +35,7 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings)
return new CheckSettingsReader(Name, settings).Errors();
}

public Task<CheckOutcome> EvaluateAsync(CheckInput input, CancellationToken cancellationToken)
public async Task<CheckOutcome> EvaluateAsync(CheckInput input, CancellationToken cancellationToken)
{
var findings = new List<CheckFinding>();

Expand All @@ -46,7 +52,70 @@ public Task<CheckOutcome> EvaluateAsync(CheckInput input, CancellationToken canc
image.Value)));
}

return Task.FromResult(findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings));
findings.AddRange(await MediaFindingsAsync(input, cancellationToken));

return findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings);
}

private static async Task<List<CheckFinding>> MediaFindingsAsync(CheckInput input, CancellationToken cancellationToken)
{
var findings = new List<CheckFinding>();
if (input.Subject.Definition is not { } definition || input.Services.GetService<IMediaLibrary>() is not { } library)
{
return findings;
}

var proposals = (input.Subject.SetOperations ?? [])
.Where(operation => operation.ItemKind == ItemKind.Media)
.DistinctBy(operation => operation.ItemId)
.ToDictionary(operation => operation.ItemId, operation => operation.ProposedVersion);
foreach (var (pointer, value) in CheckContent.FieldValues(input.Subject))
{
var field = definition.Fields.FirstOrDefault(candidate => candidate.Name == CheckContent.FieldOf(pointer));
if (field is null
|| !MediaFields.Holds(field)
|| value.ValueKind != JsonValueKind.String
|| !Guid.TryParse(value.GetString(), out var id)
|| await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset
|| !asset.Value.Latest.Blob.ContentType.StartsWith("image/", StringComparison.Ordinal))
{
continue;
}

// A set that takes the asset offline leaves nothing to show with this content.
var held = proposals.TryGetValue(id, out var proposed);
if (held && proposed is null)
{
continue;
}

var shown = (await library.LiveVersionAsync(id, proposed, cancellationToken)) is { IsSuccess: true } live ? live.Value : null;
if (shown is null)
{
// Only a change set proposes it: whichever version some other set publishes later, this content is not checked
// against it, so the image goes live here with no alt text a reader can rely on.
if (!held)
{
findings.Add(new CheckFinding(
$"The image '{asset.Value.Latest.Metadata.FileName}' in {field.Name} is not published; give it alt text with update_media "
+ "in the change set this content goes into, so both go live together.",
pointer,
id.ToString()));
}

continue;
}

if (string.IsNullOrWhiteSpace(shown.Metadata.AltText))
{
findings.Add(new CheckFinding(
$"Add alt text to the image '{shown.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.",
pointer,
id.ToString()));
}
}

return findings;
}

// The value of the element's alt attribute, read attribute by attribute so a quoted value or a data-alt is not taken for it.
Expand Down
30 changes: 28 additions & 2 deletions src/FluentCMS.Core/Builtin/Tools/CheckTools.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Checks;
using FluentCMS.Core.Content;
using FluentCMS.Core.Results;
Expand Down Expand Up @@ -39,7 +40,11 @@ public sealed class CheckTools : IToolProvider
"exports/catalog-2026-10-18.csv",
required: false),
ToolSchemas.String("checks", "Which checks to run: \"all\", the default, or \"deterministic\".", "deterministic", required: false),
ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false)),
ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false),
ToolSchemas.Uuid(
"change_set_id",
"The change set the draft will go into, so checks read the media versions it proposes, such as alt text added with update_media.",
required: false)),

// Not read-only: it stores nothing, but an LLM-judged check spends the agent's model budget.
readOnly: false,
Expand All @@ -63,6 +68,7 @@ private static async Task<ToolResult> CheckDraftAsync(ToolInvocation call, Cance
var sources = arguments.OptionalStrings("sources", "exports/catalog-2026-10-18.csv") ?? [];
var checks = arguments.OptionalString("checks", "deterministic");
var id = arguments.OptionalGuid("id");
var changeSetId = arguments.OptionalGuid("change_set_id");
var errors = arguments.Errors().ToList();
if (checks is not (null or "all" or "deterministic"))
{
Expand All @@ -81,6 +87,26 @@ private static async Task<ToolResult> CheckDraftAsync(ToolInvocation call, Cance
return ToolResult.Failure(type.Errors, type.Errors.Any(error => error.Code == SchemaErrorCodes.ContentTypeNotFound) ? FindContentTypes : null);
}

// Checks read what the set proposes, so it must be a set the agent may read and one that can still go live.
IReadOnlyList<ChangeOperation>? setOperations = null;
if (changeSetId is { } setId)
{
var set = await services.GetRequiredService<IChangeSetDirectory>().GetAsync(setId, cancellationToken);
if (!set.IsSuccess)
{
return ToolResult.Failure(set.Errors);
}

if (set.Value.State is ChangeSetState.Published or ChangeSetState.Rejected or ChangeSetState.Reverted)
{
return ToolResult.Failure([new Error(
ToolErrorCodes.ArgumentInvalid,
$"Change set {setId} is {ToolJson.Name(set.Value.State)}, so nothing goes live with it any more; give the set the draft goes into.")]);
}

setOperations = set.Value.Operations;
}

IReadOnlyList<string>? changed = null;
if (id is { } entryId)
{
Expand All @@ -103,7 +129,7 @@ [new Error(ToolErrorCodes.ArgumentInvalid, $"The entry {entryId} is of the conte
var definition = type.Value.Definition;
var refused = EntryFields.Validate(fields!.Value, definition, services.GetRequiredService<FieldTypeRegistry>()).Errors;
var run = await services.GetRequiredService<ICheckRunner>().RunAsync(
new CheckSubject(fields.Value, sources, slug, definition, changed),
new CheckSubject(fields.Value, sources, slug, definition, changed, setOperations),
checks == "deterministic" ? CheckSelection.Deterministic : CheckSelection.All,
cancellationToken: cancellationToken);

Expand Down
11 changes: 2 additions & 9 deletions src/FluentCMS.Core/Builtin/Tools/MediaTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,12 @@ private static async Task<Result<Entry>> AttachToEntryAsync(

var definition = type.Value.Definition;
var media = definition.Fields.FirstOrDefault(candidate => candidate.Name == field);
if (media is null || !IsMediaField(media))
if (media is null || !MediaFields.Holds(media))
{
return Result<Entry>.Failure(
MediaErrorCodes.FieldNotMedia,
$"'{field}' is not a media field of '{entry.Value.ContentType}'; its media fields are "
+ string.Join(", ", definition.Fields.Where(IsMediaField).Select(candidate => candidate.Name))
+ string.Join(", ", definition.Fields.Where(MediaFields.Holds).Select(candidate => candidate.Name))
+ ".");
}

Expand Down Expand Up @@ -329,13 +329,6 @@ private static async Task<Result<Entry>> AttachToEntryAsync(
return await entries.UpdateAsync(entryId, baseVersion, JsonSerializer.SerializeToElement(fields), cancellationToken);
}

// A single media field or a list of media.
private static bool IsMediaField(FieldDefinition field)
{
return field.Type == "media"
|| (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media");
}

// The version an agent writes and its operation commit together or not at all; uploaded bytes that end up in no asset are deleted
// again, so a refused upload leaves nothing in the storage.
private static async Task<ToolResult> ProposeAsync(
Expand Down
6 changes: 3 additions & 3 deletions src/FluentCMS.Core/ChangeSets/ChangeSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ public Result AddOperation(ChangeOperation operation)
}

// One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). A newer version of an
// entry or entity the set already proposes, as when a reopened set is fixed, moves that operation on to it.
// entry, entity or media asset the set already proposes, as when a reopened set is fixed, moves that operation on to it.
var position = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId);
if (position >= 0)
{
Expand Down Expand Up @@ -345,7 +345,7 @@ private static string Describe(ChangeSetState state)
private static string Duplicate(ChangeOperation existing, ChangeOperation operation)
{
var item = $"The {Describe(operation.ItemKind)} {operation.ItemId}";
if (operation.ItemKind is not (ItemKind.Entry or ItemKind.Entity) || existing.ProposedVersion is not { } proposed)
if (operation.ItemKind is not (ItemKind.Entry or ItemKind.Entity or ItemKind.Media) || existing.ProposedVersion is not { } proposed)
{
return $"{item} already has an operation in this change set.";
}
Expand All @@ -358,7 +358,7 @@ private static string Duplicate(ChangeOperation existing, ChangeOperation operat
// The same change from the same published version, proposing a later draft.
private static bool Supersedes(ChangeOperation existing, ChangeOperation operation)
{
return operation.ItemKind is ItemKind.Entry or ItemKind.Entity
return operation.ItemKind is ItemKind.Entry or ItemKind.Entity or ItemKind.Media
&& operation.Kind is OperationKind.Create or OperationKind.Update
&& operation.Kind == existing.Kind
&& operation.BaseVersion == existing.BaseVersion
Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Checks/ChangeSetChecks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ private async Task<bool> AnyJudgedAsync(CancellationToken cancellationToken)
}

var changed = before is null ? null : Changed(proposed.Value.Fields, before.Value.Fields);
subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed)));
subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed, set.Operations)));
}

return (subjects, problems);
Expand Down
7 changes: 5 additions & 2 deletions src/FluentCMS.Core/Checks/CheckInput.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Identity;
using FluentCMS.Core.Schema;

Expand All @@ -11,10 +12,12 @@ public sealed record CheckInput(JsonElement Settings, CheckSubject Subject, ISer

// One piece of content as a check sees it: its fields as an entry stores them, the sources its change cites, and its content type
// when it has one. The fields hold only the ones the check's scope names. ChangedFields names the fields the change touches, and is
// null when every field counts as changed, as for new content or a draft.
// null when every field counts as changed, as for new content or a draft. SetOperations are those of the change set the content
// goes live with, so a check can see what that set proposes for the items the content refers to; null outside a set.
public sealed record CheckSubject(
JsonElement Fields,
IReadOnlyList<string> Sources,
string? ContentType = null,
ContentTypeDefinition? Definition = null,
IReadOnlyList<string>? ChangedFields = null);
IReadOnlyList<string>? ChangedFields = null,
IReadOnlyList<ChangeOperation>? SetOperations = null);
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/Media/IMediaLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public interface IMediaLibrary

Task<Result<MediaAssetVersion>> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default);

// The version readers see: the newest a person wrote in the library after the published one, since those take effect at once,
// else the published one. A version any change set has proposed goes live only when its set publishes it. Publishing names the
// version a set will publish, to read what readers see once it has. Null when none is live.
Task<Result<MediaAssetVersion?>> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default);

// By the latest versions' metadata; oldest asset first.
Task<IReadOnlyList<MediaAssetSummary>> ListAsync(MediaQuery query, CancellationToken cancellationToken = default);

Expand Down
13 changes: 13 additions & 0 deletions src/FluentCMS.Core/Media/MediaFields.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentCMS.Core.Schema;

namespace FluentCMS.Core.Media;

internal static class MediaFields
{
// A field that holds an asset of the media library, or a list of them.
public static bool Holds(FieldDefinition field)
{
return field.Type == "media"
|| (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media");
}
}
18 changes: 18 additions & 0 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ private async Task InsertOperationAsync(ChangeSet set, int position, Guid? previ
BaseVersion = operation.BaseVersion,
ProposedVersion = operation.ProposedVersion,
});
KeepProposedMedia(set.Id, operation);

await auditLog.AppendAsync(
new NewAuditEvent("change_set.operation_added")
Expand All @@ -675,6 +676,7 @@ private async Task ExtendOperationAsync(ChangeSet set, int position, Guid? previ
await context.ChangeSetOperations
.Where(row => row.ChangeSetId == set.Id && row.Position == position)
.ExecuteUpdateAsync(setters => setters.SetProperty(row => row.ProposedVersion, operation.ProposedVersion), cancellationToken);
KeepProposedMedia(set.Id, operation);

await auditLog.AppendAsync(
new NewAuditEvent("change_set.operation_updated")
Expand All @@ -688,6 +690,22 @@ await auditLog.AppendAsync(
cancellationToken);
}

// The operation keeps only the version a set proposes last; the media library tells a person's write from a set's by every
// version a set has proposed (IMediaLibrary.LiveVersionAsync).
private void KeepProposedMedia(Guid setId, ChangeOperation operation)
{
if (operation.ItemKind == ItemKind.Media && operation.ProposedVersion is { } version)
{
context.ProposedMediaVersions.Add(new ProposedMediaVersionRow
{
Id = Guid.CreateVersion7(),
ChangeSetId = setId,
AssetId = operation.ItemId,
Version = version,
});
}
}

// Writes only over the revision this call read, so a concurrent writer that committed first leaves nothing to update.
private async Task<bool> UpdateAsync(int revision, ChangeSet set, CancellationToken cancellationToken)
{
Expand Down
16 changes: 16 additions & 0 deletions src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentCMS.Core.Media;
using FluentCMS.Core.Schema;
using FluentCMS.Infrastructure.ChangeSets;
using FluentCMS.Infrastructure.Sites;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
Expand Down Expand Up @@ -54,3 +55,18 @@ public void Configure(EntityTypeBuilder<MediaAssetVersionRow> row)
row.HasIndex(version => version.Folder);
}
}

internal sealed class ProposedMediaVersionConfiguration : IEntityTypeConfiguration<ProposedMediaVersionRow>
{
public void Configure(EntityTypeBuilder<ProposedMediaVersionRow> row)
{
row.ToTable("ProposedMediaVersions", table => table.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"));
row.HasKey(proposed => proposed.Id);
row.Property(proposed => proposed.Id).ValueGeneratedNever();

row.HasOne<ChangeSetRow>().WithMany().HasForeignKey(proposed => proposed.ChangeSetId).OnDelete(DeleteBehavior.Restrict);
row.HasOne<MediaAssetRow>().WithMany().HasForeignKey(proposed => proposed.AssetId).OnDelete(DeleteBehavior.Restrict);

row.HasIndex(proposed => new { proposed.AssetId, proposed.Version, proposed.ChangeSetId }).IsUnique();
}
}
13 changes: 13 additions & 0 deletions src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ public sealed class MediaAssetRow
public int? PublishedVersion { get; set; }
}

// A version of an asset that a change set proposed, kept when the set moves on to a newer version, so a version no set ever proposed
// is known to be a person's write in the library (MED-02). Version rows cannot record it themselves, since they never change.
public sealed class ProposedMediaVersionRow
{
public Guid Id { get; set; }

public Guid ChangeSetId { get; set; }

public Guid AssetId { get; set; }

public int Version { get; set; }
}

// One version of an asset's metadata with the bytes it points at; inserted once and never changed, which a trigger enforces.
public sealed class MediaAssetVersionRow
{
Expand Down
Loading
Loading