From e1105c46b0db3cec2e2df5b7e724aaa7f9f72b77 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 23:39:22 -0400 Subject: [PATCH 1/6] Serve published entries and entities over a read-only HTTP API --- docs/README.md | 1 + docs/api/delivery.md | 58 ++++++ .../ApiServiceCollectionExtensions.cs | 2 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 115 ++++++++++++ src/FluentCMS.Api/FluentCMS.Api.csproj | 4 +- .../Delivery/IPublishedContent.cs | 36 ++++ src/FluentCMS.Host/FluentCmsHost.cs | 1 + .../Content/EntryResolution.cs | 116 ++++++++++++ .../Content/ResolvedEntries.cs | 70 +------ .../Delivery/PublishedContentReader.cs | 106 +++++++++++ ...frastructureServiceCollectionExtensions.cs | 3 + .../Delivery/DeliveryEndpointsTests.cs | 147 +++++++++++++++ .../Delivery/PublishedContentTests.cs | 174 ++++++++++++++++++ 13 files changed, 763 insertions(+), 70 deletions(-) create mode 100644 docs/api/delivery.md create mode 100644 src/FluentCMS.Api/DeliveryEndpoints.cs create mode 100644 src/FluentCMS.Core/Delivery/IPublishedContent.cs create mode 100644 src/FluentCMS.Infrastructure/Content/EntryResolution.cs create mode 100644 src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs diff --git a/docs/README.md b/docs/README.md index 2c65c44ab..293d11ea1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ | [`requirements/`](requirements/) | What v2 must do. Owned by the product side; changed through issues, not feature pull requests. | | [`adr/`](adr/README.md) | Why v2 is built the way it is. Architecture decision records, one file per decision. | | [`spikes/`](spikes/) | Time-boxed experiments that settled an open point in an ADR, with their evidence. | +| [`api/`](api/delivery.md) | How frontends and agents read published content over HTTP. | ## Requirements diff --git a/docs/api/delivery.md b/docs/api/delivery.md new file mode 100644 index 000000000..5b8122d04 --- /dev/null +++ b/docs/api/delivery.md @@ -0,0 +1,58 @@ +# Delivery API + +The delivery API serves published content to frontends and agents over HTTP (API-01). It is read-only and anonymous: anyone who can reach the host can read what is published, and nothing else. + +## What it serves + +Only the published version of each entry is served, and only while the entry is not deleted. Drafts, unpublished entries, archived entries and deleted entries answer 404, the same as an id that never existed. + +Each item is a resolved view (CNT-04): + +- A reference to an entity field holds that entity's current published value. A newer draft of the entity changes nothing until it is published. +- A reference that has no published value is `null`, and `unresolved` says why: `notPublished`, `noValue` or `deleted`. +- References to whole entries and to media stay ids. Read a referenced entry at `/api/v1/entries/{id}`. + +Entities are content too, so their fields hold facts as stored: each value with its source, owner and validity. + +## Endpoints + +| Request | Returns | +| --- | --- | +| `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | +| `GET /api/v1/entries/{id}` | One published entry. | + +`GET /api/v1/content/{type}` takes these query parameters: + +| Parameter | Meaning | +| --- | --- | +| `limit` | Items per page, from 1 to 100. Defaults to 20. | +| `after` | The `next` cursor of the previous page. | +| `references` | Keeps the entries whose published version points at this entry, entity or media asset, such as every product page that shows one product. | + +A list looks like this: + +```json +{ + "items": [ + { + "id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", + "type": "product-page", + "version": 3, + "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "unresolved": [] + } + ], + "next": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e" +} +``` + +`next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. + +## Errors + +Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`). `errors` lists each error with a stable `code`: + +| Status | Codes | +| --- | --- | +| 400 | `limit_invalid`, `parameter_invalid` | +| 404 | `entry_not_found`, `content_type_not_found` | diff --git a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs index 64e34f94c..1e8c53a30 100644 --- a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs +++ b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace FluentCMS.Api; public static class ApiServiceCollectionExtensions { - // The delivery API arrives with F-API; until then this registers nothing. + // The delivery API reads through Core services that Infrastructure registers, so it adds none of its own yet. public static IServiceCollection AddFluentCmsApi(this IServiceCollection services) { return services; diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs new file mode 100644 index 000000000..e9d2fb30c --- /dev/null +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.Json; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FluentCMS.Api; + +// The read-only delivery API (API-01). Anyone may read published content, so no endpoint asks who the caller is. +public static class DeliveryEndpoints +{ + public const string Prefix = "/api/v1"; + + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) + { + var api = endpoints.MapGroup(Prefix).AllowAnonymous(); + api.MapGet("/content/{type}", ListAsync); + api.MapGet("/entries/{id:guid}", GetAsync); + return api; + } + + private static async Task ListAsync( + string type, + string? limit, + string? after, + string? references, + IPublishedContent content, + CancellationToken cancellationToken) + { + var errors = new List(); + var size = PublishedQuery.DefaultLimit; + if (limit is not null && !int.TryParse(limit, NumberStyles.None, CultureInfo.InvariantCulture, out size)) + { + errors.Add(new Error(DeliveryErrorCodes.LimitInvalid, $"'limit' is '{limit}'; it must be a whole number from 1 to {PublishedQuery.MaxLimit}.")); + } + + var cursor = Id(after, "after", "the next cursor of a previous page", errors); + var target = Id(references, "references", "the id of an entry, entity or media asset", errors); + if (errors.Count > 0) + { + return Problem(errors); + } + + var page = await content.ListAsync(new PublishedQuery(type, size, cursor, target), cancellationToken); + return page.IsSuccess + ? Results.Json(new PageResponse([.. page.Value.Items.Select(Item)], page.Value.Next)) + : Problem(page.Errors); + } + + private static async Task GetAsync(Guid id, IPublishedContent content, CancellationToken cancellationToken) + { + var item = await content.GetAsync(id, cancellationToken); + return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); + } + + private static Guid? Id(string? value, string name, string form, List errors) + { + if (value is null) + { + return null; + } + + if (Guid.TryParse(value, out var id)) + { + return id; + } + + errors.Add(new Error(DeliveryErrorCodes.ParameterInvalid, $"'{name}' is '{value}'; it must be {form}, a UUID.")); + return null; + } + + private static ItemResponse Item(PublishedItem item) + { + return new ItemResponse( + item.Id, + item.ContentType, + item.Version, + item.Fields, + [.. item.Unresolved.Select(reference => new UnresolvedResponse( + reference.Path, + reference.EntityId, + reference.Field, + JsonNamingPolicy.CamelCase.ConvertName(reference.Reason.ToString())))]); + } + + // RFC 9457 problem details with every error, so a body is always written and the admin's not-found page never answers instead. + private static IResult Problem(IReadOnlyList errors) + { + var status = errors[0].Code switch + { + ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound => StatusCodes.Status404NotFound, + AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest, + }; + + return Results.Problem( + statusCode: status, + detail: string.Join(" ", errors.Select(error => error.Message)), + extensions: new Dictionary + { + ["errors"] = errors.Select(error => new { error.Code, error.Message }).ToList(), + }); + } + + private sealed record PageResponse(IReadOnlyList Items, Guid? Next); + + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + + private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); +} diff --git a/src/FluentCMS.Api/FluentCMS.Api.csproj b/src/FluentCMS.Api/FluentCMS.Api.csproj index 460b63b29..9fe0bd32d 100644 --- a/src/FluentCMS.Api/FluentCMS.Api.csproj +++ b/src/FluentCMS.Api/FluentCMS.Api.csproj @@ -1,11 +1,11 @@ - + - + diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs new file mode 100644 index 000000000..7f9650374 --- /dev/null +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using FluentCMS.Core.Content; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Delivery; + +// What the delivery API serves (API-01): the published version of each entry that has one and is not deleted, as a resolved view +// (CNT-04). Drafts, unpublished and deleted entries are not found, so a reader cannot tell them from ids that never existed. A read: +// it appends no audit event. +public interface IPublishedContent +{ + // The published entries of one content type, by id, which follows the order of creation. + Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); + + Task> GetAsync(Guid id, CancellationToken cancellationToken = default); +} + +// After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or +// media asset, read from the reference index (ADR-0005). +public sealed record PublishedQuery(string ContentType, int Limit = PublishedQuery.DefaultLimit, Guid? After = null, Guid? References = null) +{ + public const int DefaultLimit = 20; + + public const int MaxLimit = 100; +} + +// Next is null on the last page. +public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); + +public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); + +public static class DeliveryErrorCodes +{ + public const string LimitInvalid = "limit_invalid"; + public const string ParameterInvalid = "parameter_invalid"; +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index fb11e874a..f3d39ee5c 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -67,6 +67,7 @@ public static WebApplication BuildWebApplication(string[] args, Action> ResolveAsync( + FluentCmsDbContext context, + IReadOnlyList versions, + CancellationToken cancellationToken) + { + if (versions.Count == 0) + { + return []; + } + + var ids = versions.Select(version => version.EntryId).Distinct().ToList(); + var numbers = versions.Select(version => version.Version).Distinct().ToList(); + var wanted = versions.Select(version => (version.EntryId, version.Version)).ToHashSet(); + + // Every pair of a wanted entry and a wanted number is fetched, and the pairs not asked for are dropped here. + var stored = (await context.EntryVersions + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) && numbers.Contains(row.Version)) + .Select(row => new { row.EntryId, row.Version, row.Fields }) + .ToListAsync(cancellationToken)) + .Where(row => wanted.Contains((row.EntryId, row.Version))) + .ToDictionary(row => (row.EntryId, row.Version), row => row.Fields); + + var references = (await context.EntryReferences + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) + && numbers.Contains(row.Version) + && row.TargetKind == ReferenceKind.Entry + && row.TargetField != null) + .Select(row => new { row.EntryId, row.Version, row.Path, row.TargetId, Field = row.TargetField! }) + .ToListAsync(cancellationToken)) + .Where(row => wanted.Contains((row.EntryId, row.Version))) + .ToLookup(row => (row.EntryId, row.Version)); + + var published = await PublishedFieldsAsync(context, references.SelectMany(group => group).Select(row => row.TargetId).Distinct().ToList(), cancellationToken); + + return versions.Select(version => + { + var fields = stored.GetValueOrDefault((version.EntryId, version.Version)) + ?? throw new InvalidOperationException($"Entry {version.EntryId} has no stored version {version.Version}."); + var values = new Dictionary(StringComparer.Ordinal); + var unresolved = new List(); + + foreach (var reference in references[(version.EntryId, version.Version)].OrderBy(reference => reference.Path, StringComparer.Ordinal)) + { + var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); + var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; + values[reference.Path] = value; + + if (value is null) + { + var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; + unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); + } + } + + using var document = JsonDocument.Parse(fields); + return new ResolvedEntry(version.EntryId, version.ContentType, version.Version, ResolvedFields.Replace(document.RootElement, values), unresolved); + }).ToList(); + } + + // Each entity's published fields, read in one query: none when it is deleted or has no published version. + private static async Task> PublishedFieldsAsync( + FluentCmsDbContext context, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + var rows = await ( + from entity in context.Entries.AsNoTracking() + where ids.Contains(entity.Id) && entity.SiteId == SiteId + from version in context.EntryVersions.AsNoTracking() + .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) + .DefaultIfEmpty() + select new { entity.Id, Deleted = entity.DeletedAt != null, Fields = version == null ? null : version.Fields }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary( + row => row.Id, + row => (row.Deleted, row.Deleted || row.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize(row.Fields))); + } + + // An entity stores each field as a fact; the view shows the fact's value (SCH-03). + private static JsonElement? FactValue(JsonElement entity, string field) + { + return entity.TryGetProperty(field, out var fact) + && fact.ValueKind == JsonValueKind.Object + && fact.TryGetProperty(FactMetadata.Value, out var value) + && value.ValueKind != JsonValueKind.Null + ? value + : null; + } +} + +internal sealed record VersionToResolve(Guid EntryId, string ContentType, int Version); diff --git a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs index ed05fa9ef..f762663f9 100644 --- a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs +++ b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs @@ -1,8 +1,6 @@ -using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Results; -using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -35,76 +33,14 @@ join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals typ } var number = version ?? entry.LatestVersion; - var fields = await context.EntryVersions - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number) - .Select(row => row.Fields) - .SingleOrDefaultAsync(cancellationToken); - if (fields is null) + if (!await context.EntryVersions.AnyAsync(row => row.EntryId == entryId && row.Version == number, cancellationToken)) { return Result.Failure( ContentErrorCodes.VersionNotFound, $"Entry {entryId} has no version {number}; its latest version is {entry.LatestVersion}."); } - var references = await context.EntryReferences - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number && row.TargetKind == ReferenceKind.Entry && row.TargetField != null) - .Select(row => new { row.Path, row.TargetId, Field = row.TargetField! }) - .ToListAsync(cancellationToken); - - var published = await PublishedFieldsAsync(references.Select(reference => reference.TargetId).Distinct().ToList(), cancellationToken); - var values = new Dictionary(StringComparer.Ordinal); - var unresolved = new List(); - - foreach (var reference in references.OrderBy(reference => reference.Path, StringComparer.Ordinal)) - { - var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); - var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; - values[reference.Path] = value; - - if (value is null) - { - var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; - unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); - } - } - - using var document = JsonDocument.Parse(fields); - return Result.Success( - new ResolvedEntry(entryId, entry.Slug, number, ResolvedFields.Replace(document.RootElement, values), unresolved)); - } - - // Each entity's published fields, read in one query: none when it is deleted or has no published version. - private async Task> PublishedFieldsAsync(List ids, CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - var rows = await ( - from entity in context.Entries.AsNoTracking() - where ids.Contains(entity.Id) && entity.SiteId == SiteId - from version in context.EntryVersions.AsNoTracking() - .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) - .DefaultIfEmpty() - select new { entity.Id, Deleted = entity.DeletedAt != null, Fields = version == null ? null : version.Fields }) - .ToListAsync(cancellationToken); - - return rows.ToDictionary( - row => row.Id, - row => (row.Deleted, row.Deleted || row.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize(row.Fields))); - } - - // An entity stores each field as a fact; the view shows the fact's value (SCH-03). - private static JsonElement? FactValue(JsonElement entity, string field) - { - return entity.TryGetProperty(field, out var fact) - && fact.ValueKind == JsonValueKind.Object - && fact.TryGetProperty(FactMetadata.Value, out var value) - && value.ValueKind != JsonValueKind.Null - ? value - : null; + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(entryId, entry.Slug, number)], cancellationToken); + return Result.Success(resolved[0]); } } diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs new file mode 100644 index 000000000..43bc58ad3 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -0,0 +1,106 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Delivery; + +// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. +internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent +{ + // Until multi-site (MST-01), every entry belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Limit is < 1 or > PublishedQuery.MaxLimit) + { + return Result.Failure( + DeliveryErrorCodes.LimitInvalid, + $"The limit is {query.Limit}; it must be from 1 to {PublishedQuery.MaxLimit}."); + } + + var allowed = await permissions.CheckContentAsync(query.ContentType, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var typeId = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) + .Select(row => (Guid?)row.Id) + .SingleOrDefaultAsync(cancellationToken); + if (typeId is null) + { + return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); + } + + var entries = context.Entries + .AsNoTracking() + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + + // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. + if (query.After is { } after) + { + entries = entries.Where(entry => entry.Id > after); + } + + if (query.References is { } target) + { + entries = entries.Where(entry => context.EntryReferences + .Any(reference => reference.EntryId == entry.Id && reference.Version == entry.PublishedVersion && reference.TargetId == target)); + } + + var rows = await entries + .OrderBy(entry => entry.Id) + .Take(query.Limit + 1) + .Select(entry => new { entry.Id, Version = entry.PublishedVersion!.Value }) + .ToListAsync(cancellationToken); + + var page = rows.Take(query.Limit).ToList(); + var resolved = await EntryResolution.ResolveAsync( + context, + [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + cancellationToken); + + return Result.Success(new PublishedPage( + [.. resolved.Select(Item)], + rows.Count > query.Limit ? page[^1].Id : null)); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var entry = await ( + from row in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id + where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null + select new { type.Slug, Version = row.PublishedVersion!.Value }) + .SingleOrDefaultAsync(cancellationToken); + if (entry is null) + { + return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); + } + + var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0])); + } + + private static PublishedItem Item(ResolvedEntry entry) + { + return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index d9dd9264e..c55faddeb 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; using FluentCMS.Core.Entities; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; @@ -16,6 +17,7 @@ using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Delivery; using FluentCMS.Infrastructure.Entities; using FluentCMS.Infrastructure.Identity; using FluentCMS.Infrastructure.Jobs; @@ -115,6 +117,7 @@ private static void AddContent(IServiceCollection services, IConfiguration confi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs new file mode 100644 index 000000000..14aedeb01 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Api; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class DeliveryEndpointsTests +{ + private static readonly Guid Page = Guid.CreateVersion7(); + private static readonly Guid Product = Guid.CreateVersion7(); + + [Fact] + public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_cursor() + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetFromJsonAsync($"/api/v1/content/product-page?limit=1&after={Product}&references={Product}"); + + Assert.Equal(new PublishedQuery("product-page", 1, Product, Product), content.Query); + var item = Assert.Single(response!["items"]!.AsArray())!; + Assert.Equal((Page.ToString(), "product-page", 3), ((string?)item["id"], (string?)item["type"], (int)item["version"]!)); + Assert.Equal(99, (int)item["fields"]!["price"]!); + var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; + Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal(Page.ToString(), (string?)response["next"]); + } + + [Fact] + public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var found = await api.Client.GetFromJsonAsync($"/api/v1/entries/{Page}"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var unknownType = await api.Client.GetAsync("/api/v1/content/news"); + + Assert.Equal(Page.ToString(), (string?)found!["id"]); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + } + + [Theory] + [InlineData("limit=ten", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("limit=-1", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("after=page-2", DeliveryErrorCodes.ParameterInvalid, "'after'")] + [InlineData("references=drill", DeliveryErrorCodes.ParameterInvalid, "'references'")] + public async Task A_parameter_that_does_not_parse_is_a_problem_with_400_that_names_it(string query, string code, string name) + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetAsync($"/api/v1/content/product-page?{query}"); + + var problem = await AssertProblemAsync(response, HttpStatusCode.BadRequest, code); + Assert.Contains(name, (string?)problem["detail"]); + Assert.Null(content.Query); + } + + [Fact] + public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + var unknownType = await host.Client.GetAsync("/api/v1/content/product-page"); + var unknownEntry = await host.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var badLimit = await host.Client.GetAsync("/api/v1/content/product-page?limit=0"); + + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + await AssertProblemAsync(unknownEntry, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(badLimit, HttpStatusCode.BadRequest, DeliveryErrorCodes.LimitInvalid); + } + + private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) + { + Assert.Equal(status, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + var problem = (await response.Content.ReadFromJsonAsync())!; + Assert.Equal(code, (string?)problem["errors"]![0]!["code"]); + return problem; + } + + // Answers like the reader would for one published page, and records the query it was asked. + private sealed class StubContent : IPublishedContent + { + public PublishedQuery? Query { get; private set; } + + public Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + Query = query; + return Task.FromResult(query.ContentType == "product-page" + ? Result.Success(new PublishedPage([Item()], Page)) + : Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site.")); + } + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + return Task.FromResult(id == Page + ? Result.Success(Item()) + : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); + } + + private static PublishedItem Item() + { + return new PublishedItem( + Page, + "product-page", + 3, + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); + } + } + + // The endpoints alone on a free port, over the content given. + private sealed class StubApi(WebApplication app) : IAsyncDisposable + { + public HttpClient Client { get; } = new() { BaseAddress = new Uri(app.Urls.Single()) }; + + public static async Task StartAsync(IPublishedContent content) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(content); + var app = builder.Build(); + app.MapFluentCmsApi(); + await app.StartAsync(); + return new StubApi(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs new file mode 100644 index 000000000..c174c5269 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -0,0 +1,174 @@ +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class PublishedContentTests +{ + // The F-API acceptance: a frontend reads a product page with the product's price as published, whatever its drafts say. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_page_reads_as_published_with_its_price_resolved_to_the_published_value(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + await PublishAsync(scenario, product, 1); + await scenario.UpdateAsync(product, 1, ProductFields("109")); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Draft headline"}"""); + + var read = (await ContentAsync(scenario, content => content.GetAsync(page))).Value; + var entity = (await ContentAsync(scenario, content => content.GetAsync(product))).Value; + + Assert.Equal((page, "product-page", 1), (read.Id, read.ContentType, read.Version)); + AssertSameFields($$"""{"product": "{{product}}", "price": 99, "headline": "A sturdy drill"}""", read.Fields); + Assert.Empty(read.Unresolved); + Assert.Equal(("product", 1), (entity.ContentType, entity.Version)); + Assert.Equal(99, entity.Fields.GetProperty("price").GetProperty("value").GetInt32()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Drafts_unpublished_and_deleted_entries_are_neither_listed_nor_read(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var live = await PageAsync(scenario, product, "Live"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + await PublishAsync(scenario, live, 1); + await PublishAsync(scenario, deleted, 1); + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + + var listed = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page")))).Value; + + Assert.Equal([live], listed.Items.Select(item => item.Id)); + Assert.Null(listed.Next); + Assert.Equal("/price", Assert.Single(listed.Items[0].Unresolved).Path); + foreach (var hidden in new[] { draft, deleted, product, Guid.CreateVersion7() }) + { + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(await ContentAsync(scenario, content => content.GetAsync(hidden)))); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Pages_follow_the_order_of_creation_and_end_without_a_next_cursor(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var pages = new List(); + for (var number = 1; number <= 5; number++) + { + pages.Add(await PageAsync(scenario, product, $"Page {number}")); + await PageAsync(scenario, product, $"Draft {number}"); + await PublishAsync(scenario, pages[^1], 1); + } + + var read = new List(); + var cursors = new List(); + Guid? after = null; + do + { + var page = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 2, after)))).Value; + read.AddRange(page.Items.Select(item => item.Id)); + cursors.Add(page.Next); + after = page.Next; + } + while (after is not null); + + Assert.Equal(pages, read); + Assert.Equal([pages[1], pages[3], null], cursors); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task References_keeps_the_entries_whose_published_version_points_at_the_target(string provider) + { + await using var scenario = await CreateAsync(provider); + var (drill, saw) = (await ProductAsync(scenario, "99"), await ProductAsync(scenario, "49")); + var drillPage = await PageAsync(scenario, drill, "Drill"); + var sawPage = await PageAsync(scenario, saw, "Saw"); + var movedPage = await PageAsync(scenario, saw, "Was a saw"); + await PublishAsync(scenario, drillPage, 1); + await PublishAsync(scenario, sawPage, 1); + await PublishAsync(scenario, movedPage, 1); + await scenario.UpdateAsync(movedPage, 1, $$"""{"product": "{{drill}}", "price": "{{drill}}", "headline": "Now a drill"}"""); + + var drillPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: drill)))).Value; + var sawPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: saw)))).Value; + + Assert.Equal([drillPage], drillPages.Items.Select(item => item.Id)); + Assert.Equal([sawPage, movedPage], sawPages.Items.Select(item => item.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_limit_out_of_range_or_an_unknown_type_fails(string provider) + { + await using var scenario = await CreateAsync(provider); + + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 0))))); + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 101))))); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("news"))))); + Assert.Empty((await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 100)))).Value.Items); + } + + private static async Task CreateAsync(string provider) + { + var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + return scenario; + } + + private static string ProductFields(string price) + { + return $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact(price)}}}"""; + } + + private static async Task ProductAsync(EntryScenario scenario, string price) + { + var created = await scenario.CreateAsync("product", ProductFields(price)); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + private static async Task PageAsync(EntryScenario scenario, Guid product, string headline) + { + var created = await scenario.CreateAsync("product-page", $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "{{headline}}"}"""); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + // Publish moves the pointer through change sets (#2519); these tests only need published versions. + private static Task PublishAsync(EntryScenario scenario, Guid entry, int version) + { + return scenario.Schema.QueryAsync(context => context.Entries + .Where(row => row.Id == entry) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, version))); + } + + private static string Code(Result result) + { + return Assert.Single(result.Errors).Code; + } + + private static async Task ContentAsync(EntryScenario scenario, Func> read) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } +} From 8e5bff32ca3e3f21514b726860073814a16c4837 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 23:57:56 -0400 Subject: [PATCH 2/6] Serve each published entry as Markdown and add the site index --- docs/api/delivery.md | 18 +++ src/FluentCMS.Api/DeliveryEndpoints.cs | 67 +++++++- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 151 ++++++++++++++++++ .../Delivery/IPublishedContent.cs | 17 +- .../Delivery/PublishedContentReader.cs | 95 ++++++++--- .../Delivery/DeliveryEndpointsTests.cs | 52 +++++- .../Delivery/PublishedContentTests.cs | 34 ++++ .../Delivery/EntryMarkdownTests.cs | 148 +++++++++++++++++ 8 files changed, 560 insertions(+), 22 deletions(-) create mode 100644 src/FluentCMS.Core/Delivery/EntryMarkdown.cs create mode 100644 tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 5b8122d04..ec20b5a26 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -20,6 +20,9 @@ Entities are content too, so their fields hold facts as stored: each value with | --- | --- | | `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | | `GET /api/v1/entries/{id}` | One published entry. | +| `GET /api/v1/entries/{id}.md` | The same entry as Markdown. | +| `GET /api/v1/index` | Every published entry with its type, title and URLs, as JSON. | +| `GET /api/v1/index.md` and `GET /llms.txt` | The same index as Markdown, grouped by content type. | `GET /api/v1/content/{type}` takes these query parameters: @@ -39,6 +42,7 @@ A list looks like this: "type": "product-page", "version": 3, "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "markdown": "/api/v1/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e.md", "unresolved": [] } ], @@ -48,6 +52,20 @@ A list looks like this: `next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. +## Markdown and the site index + +Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. + +- Front matter gives the id, type, version and URL. +- The heading is the entry's title: its first text field that holds text. +- Each other field follows under its name, in the order its content type defines them: + - rich text as written, since it is stored as Markdown; + - lists as bullets, and JSON in a fenced block; + - references to whole entries as links to their Markdown. +- On an entity, each fact's value is followed by its source. + +The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. + ## Errors Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`). `errors` lists each error with a stable `code`: diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index e9d2fb30c..63e1d337d 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; @@ -16,14 +17,32 @@ public static class DeliveryEndpoints { public const string Prefix = "/api/v1"; + private const string Markdown = EntryMarkdown.MediaType + "; charset=utf-8"; + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) { var api = endpoints.MapGroup(Prefix).AllowAnonymous(); api.MapGet("/content/{type}", ListAsync); api.MapGet("/entries/{id:guid}", GetAsync); + api.MapGet("/entries/{id:guid}.md", GetMarkdownAsync); + api.MapGet("/index", IndexAsync); + api.MapGet("/index.md", IndexMarkdownAsync); + + // Where agents look for a site's index first, by the llms.txt convention. + endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); return api; } + public static string EntryUrl(Guid id) + { + return $"{Prefix}/entries/{id}"; + } + + public static string MarkdownUrl(Guid id) + { + return $"{Prefix}/entries/{id}.md"; + } + private static async Task ListAsync( string type, string? limit, @@ -58,6 +77,47 @@ private static async Task GetAsync(Guid id, IPublishedContent content, return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); } + // The URL is the entry's id, so it stays the same across versions and always shows the published one (API-03). + private static async Task GetMarkdownAsync(Guid id, IPublishedContent content, CancellationToken cancellationToken) + { + var item = await content.GetAsync(id, cancellationToken); + return item.IsSuccess + ? Results.Text(EntryMarkdown.Render(item.Value, MarkdownUrl(id), MarkdownUrl), Markdown) + : Problem(item.Errors); + } + + private static async Task IndexAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + return Results.Json(new IndexResponse([.. index.Select(entry => new IndexEntryResponse( + entry.Id, + entry.ContentType, + entry.Version, + entry.Title, + EntryUrl(entry.Id), + MarkdownUrl(entry.Id)))])); + } + + private static async Task IndexMarkdownAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + var markdown = new StringBuilder() + .Append("# Site index\n\n") + .Append(CultureInfo.InvariantCulture, $"Every published entry of this site, by content type, linked to its Markdown. The same index in JSON is at {Prefix}/index.\n"); + + foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) + { + markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + foreach (var entry in type) + { + var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); + } + } + + return Results.Text(markdown.ToString(), Markdown); + } + private static Guid? Id(string? value, string name, string form, List errors) { if (value is null) @@ -81,6 +141,7 @@ private static ItemResponse Item(PublishedItem item) item.ContentType, item.Version, item.Fields, + MarkdownUrl(item.Id), [.. item.Unresolved.Select(reference => new UnresolvedResponse( reference.Path, reference.EntityId, @@ -109,7 +170,11 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); - private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); + + private sealed record IndexResponse(IReadOnlyList Items); + + private sealed record IndexEntryResponse(Guid Id, string Type, int Version, string? Title, string Url, string Markdown); private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); } diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs new file mode 100644 index 000000000..521f2833c --- /dev/null +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Delivery; + +// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; +// every other value is written out by its field's type, in the order the content type defines its fields. +public static class EntryMarkdown +{ + public const string MediaType = "text/markdown"; + + // Markdown lines end in \n whatever the platform, JSON blocks included. + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, NewLine = "\n" }; + + // The first text field that holds text, read from its fact on an entity; null when there is none. + public static string? Title(JsonElement fields, ContentTypeDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + return TitleField(fields, definition)?.Text; + } + + // EntryUrl gives the Markdown URL of another entry, for references to whole entries. + public static string Render(PublishedItem item, string url, Func entryUrl) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(entryUrl); + + var definition = item.Definition; + var title = TitleField(item.Fields, definition); + var markdown = new StringBuilder() + .Append("---\n") + .Append(CultureInfo.InvariantCulture, $"id: {item.Id}\n") + .Append(CultureInfo.InvariantCulture, $"type: {item.ContentType}\n") + .Append(CultureInfo.InvariantCulture, $"version: {item.Version}\n") + .Append(CultureInfo.InvariantCulture, $"url: {url}\n") + .Append("---\n\n") + .Append(CultureInfo.InvariantCulture, $"# {title?.Text ?? $"{item.ContentTypeName} {item.Id}"}\n"); + + // The heading shows the title, so its field is not repeated below it. + foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) + { + if (Value(item.Fields, field.Name, definition.IsEntity) is not { } value) + { + continue; + } + + markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); + + if (definition.IsEntity && Source(item.Fields, field.Name) is { } source) + { + markdown.Append(CultureInfo.InvariantCulture, $"\nSource: {source}\n"); + } + } + + return markdown.ToString(); + } + + private static (string Field, string Text)? TitleField(JsonElement fields, ContentTypeDefinition definition) + { + foreach (var field in definition.Fields.Where(field => field.Type == "text")) + { + if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value + && !string.IsNullOrWhiteSpace(value.GetString())) + { + return (field.Name, Line(value.GetString()!)); + } + } + + return null; + } + + // The field's value: on an entity, the value inside its fact. Null, blank and missing values are left out. + private static JsonElement? Value(JsonElement fields, string name, bool isEntity) + { + if (fields.ValueKind != JsonValueKind.Object || !fields.TryGetProperty(name, out var value)) + { + return null; + } + + if (isEntity && value.ValueKind == JsonValueKind.Object && !value.TryGetProperty(FactMetadata.Value, out value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Null || (value.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(value.GetString())) + ? null + : value; + } + + private static string? Source(JsonElement fields, string name) + { + return fields.TryGetProperty(name, out var fact) + && fact.ValueKind == JsonValueKind.Object + && fact.TryGetProperty(FactMetadata.Source, out var source) + && source.ValueKind == JsonValueKind.Object + && source.TryGetProperty(FactMetadata.SourceReference, out var reference) + && reference.ValueKind == JsonValueKind.String + ? source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String + ? $"{reference.GetString()} ({kind.GetString()})" + : reference.GetString() + : null; + } + + private static string Block(string type, JsonElement settings, JsonElement value, Func entryUrl) + { + if (type == "list" && value.ValueKind == JsonValueKind.Array) + { + var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) ? item.GetString() ?? "" : ""; + var itemSettings = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemSettings", out var nested) ? nested : FieldDefinition.EmptySettings; + + return string.Join('\n', value.EnumerateArray() + .Where(element => element.ValueKind != JsonValueKind.Null) + .Select(element => $"- {Line(Inline(itemType, itemSettings, element, entryUrl))}")); + } + + return type switch + { + "richText" when value.ValueKind == JsonValueKind.String => value.GetString()!.Trim(), + "json" => Fenced(value), + _ when value.ValueKind is JsonValueKind.Object or JsonValueKind.Array => Fenced(value), + _ => Inline(type, settings, value, entryUrl), + }; + } + + private static string Inline(string type, JsonElement settings, JsonElement value, Func entryUrl) + { + // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. + var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); + + return value.ValueKind switch + { + JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", + JsonValueKind.String when type == "media" => $"Media asset `{value.GetString()}`", + JsonValueKind.String => value.GetString()!, + _ => value.GetRawText(), + }; + } + + private static string Fenced(JsonElement value) + { + return $"```json\n{JsonSerializer.Serialize(value, Indented)}\n```"; + } + + private static string Line(string text) + { + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 7f9650374..750fc279e 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -1,6 +1,7 @@ using System.Text.Json; using FluentCMS.Core.Content; using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; @@ -13,6 +14,9 @@ public interface IPublishedContent Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); Task> GetAsync(Guid id, CancellationToken cancellationToken = default); + + // Every published entry of every type the caller may read, by type and then by id, for the site index (API-03). + Task> IndexAsync(CancellationToken cancellationToken = default); } // After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or @@ -27,7 +31,18 @@ public sealed record PublishedQuery(string ContentType, int Limit = PublishedQue // Next is null on the last page. public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); -public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); +// Title is EntryMarkdown.Title of the published version, null when it has no text. +public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); + +// The content type's name and current definition come along, since the published version is read against them. +public sealed record PublishedItem( + Guid Id, + string ContentType, + string ContentTypeName, + ContentTypeDefinition Definition, + int Version, + JsonElement Fields, + IReadOnlyList Unresolved); public static class DeliveryErrorCodes { diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index 43bc58ad3..bcd9616c7 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; @@ -33,19 +34,14 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - var typeId = await context.ContentTypes - .AsNoTracking() - .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) - .Select(row => (Guid?)row.Id) - .SingleOrDefaultAsync(cancellationToken); - if (typeId is null) + if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } var entries = context.Entries .AsNoTracking() - .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == type.Id && entry.PublishedVersion != null && entry.DeletedAt == null); // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. if (query.After is { } after) @@ -68,39 +64,100 @@ public async Task> ListAsync(PublishedQuery query, Cancell var page = rows.Take(query.Limit).ToList(); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(Item)], + [.. resolved.Select(entry => Item(entry, type))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await ( - from row in context.Entries.AsNoTracking() - join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id - where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null - select new { type.Slug, Version = row.PublishedVersion!.Value }) + var entry = await context.Entries + .AsNoTracking() + .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) + .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; + var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0])); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], type)); + } + + public async Task> IndexAsync(CancellationToken cancellationToken = default) + { + var types = await TypesAsync(null, null, cancellationToken); + + // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. + var rows = await ( + from entry in context.Entries.AsNoTracking() + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null + select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + .ToListAsync(cancellationToken); + + var readable = await permissions.ReadableTypesAsync(cancellationToken); + + return rows + .Select(row => (Row: row, Type: types[row.ContentTypeId])) + .Where(item => readable(item.Type.Slug)) + .Select(item => + { + using var fields = JsonDocument.Parse(item.Row.Fields); + return new PublishedSummary( + item.Row.Id, + item.Type.Slug, + item.Type.Name, + item.Row.Version, + EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + }) + .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) + .ThenBy(summary => summary.Id) + .ToList(); + } + + // The site's content types with their current definitions, which a published version is read against: every type, the one + // with the slug, or the one with the id. + private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + { + var rows = await ( + from type in context.ContentTypes.AsNoTracking() + join version in context.ContentTypeVersions.AsNoTracking() + on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } + where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) + select new { type.Id, type.Slug, type.Name, version.Definition }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); } - private static PublishedItem Item(ResolvedEntry entry) + private static ContentTypeDefinition ReadDefinition(string slug, string json) { - return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + var definition = ContentTypeDefinition.Parse(json); + + return definition.IsSuccess + ? definition.Value + : throw new InvalidOperationException( + $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); } + + private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + { + return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + } + + private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 14aedeb01..fc7cbe55e 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -32,9 +32,43 @@ public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_c Assert.Equal(99, (int)item["fields"]!["price"]!); var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal($"/api/v1/entries/{Page}.md", (string?)item["markdown"]); Assert.Equal(Page.ToString(), (string?)response["next"]); } + [Fact] + public async Task An_entry_reads_as_markdown_at_its_id() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var response = await api.Client.GetAsync($"/api/v1/entries/{Page}.md"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}.md"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + var markdown = await response.Content.ReadAsStringAsync(); + Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + } + + [Fact] + public async Task The_site_index_lists_every_entry_in_json_markdown_and_llms_txt() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var json = await api.Client.GetFromJsonAsync("/api/v1/index"); + var markdown = await api.Client.GetStringAsync("/api/v1/index.md"); + var llms = await api.Client.GetStringAsync("/llms.txt"); + + var entries = json!["items"]!.AsArray(); + Assert.Equal([Product.ToString(), Page.ToString()], entries.Select(entry => (string?)entry!["id"])); + Assert.Equal(("Drill [cordless]", $"/api/v1/entries/{Product}", $"/api/v1/entries/{Product}.md"), ((string?)entries[0]!["title"], (string?)entries[0]!["url"], (string?)entries[0]!["markdown"])); + Assert.Contains($@"## Product{"\n\n"}- [Drill \[cordless\]](/api/v1/entries/{Product}.md){"\n"}", markdown, StringComparison.Ordinal); + Assert.Contains($"## Product page\n\n- [Product page {Page}](/api/v1/entries/{Page}.md)\n", markdown, StringComparison.Ordinal); + Assert.Equal(markdown, llms); + } + [Fact] public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() { @@ -110,13 +144,29 @@ public Task> GetAsync(Guid id, CancellationToken cancellat : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); } + public Task> IndexAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>( + [ + new PublishedSummary(Product, "product", "Product", 1, "Drill [cordless]"), + new PublishedSummary(Page, "product-page", "Product page", 3, null), + ]); + } + private static PublishedItem Item() { return new PublishedItem( Page, "product-page", + "Product page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", settings: JsonSerializer.Deserialize("""{"target": "product"}""")), + new FieldDefinition("price", "number"), + new FieldDefinition("headline", "text"), + ]), 3, - JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null, "headline": "A sturdy drill"}"""), [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); } } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index c174c5269..020d5af71 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -127,6 +127,40 @@ public async Task A_limit_out_of_range_or_an_unknown_type_fails(string provider) Assert.Empty((await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 100)))).Value.Items); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_index_lists_each_published_entry_once_with_the_title_of_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "First headline"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + var post = (await scenario.CreateAsync("blog-post", """{"title": "Launch"}""")).Value.Id; + foreach (var entry in new[] { product, page, deleted, post }) + { + await PublishAsync(scenario, entry, 1); + } + + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Second headline"}"""); + + var before = await ContentAsync(scenario, content => content.IndexAsync()); + await PublishAsync(scenario, page, 2); + var after = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal( + [ + new PublishedSummary(post, "blog-post", "Blog Post", 1, "Launch"), + new PublishedSummary(product, "product", "Product", 1, "Drill"), + new PublishedSummary(page, "product-page", "Product Page", 1, "First headline"), + ], + before); + Assert.Equal((2, "Second headline"), (after[^1].Version, after[^1].Title)); + Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs new file mode 100644 index 000000000..492d3e3d2 --- /dev/null +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Tests.Delivery; + +public sealed class EntryMarkdownTests +{ + private static readonly Guid Page = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"); + private static readonly Guid Product = Guid.Parse("0192f3a1-0000-7000-8000-000000000001"); + private static readonly Guid Hero = Guid.Parse("0192f3a1-0000-7000-8000-000000000002"); + + private static readonly ContentTypeDefinition PageType = new( + [ + Field("product", "reference", """{"target": "product"}"""), + Field("price", "reference", """{"target": "product", "field": "price"}"""), + Field("headline", "text"), + Field("body", "richText"), + Field("tags", "list", """{"itemType": "text"}"""), + Field("related", "list", """{"itemType": "reference", "itemSettings": {"target": "product"}}"""), + Field("hero", "media"), + Field("featured", "boolean"), + Field("extra", "json"), + Field("note", "text"), + ]); + + private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number")], isEntity: true); + + [Fact] + public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() + { + var item = Item(PageType, "product-page", $$""" + { + "product": "{{Product}}", + "price": 99.5, + "headline": "A sturdy\ndrill", + "body": "Built for **daily** use.\n\n- Two speeds\n", + "tags": ["tools", "drills"], + "related": ["{{Product}}"], + "hero": "{{Hero}}", + "featured": true, + "extra": {"warranty": 2}, + "note": null + } + """); + + var markdown = EntryMarkdown.Render(item, $"/api/v1/entries/{Page}.md", id => $"/api/v1/entries/{id}.md"); + + Assert.Equal( + $$""" + --- + id: {{Page}} + type: product-page + version: 3 + url: /api/v1/entries/{{Page}}.md + --- + + # A sturdy drill + + ## product + + [{{Product}}](/api/v1/entries/{{Product}}.md) + + ## price + + 99.5 + + ## body + + Built for **daily** use. + + - Two speeds + + ## tags + + - tools + - drills + + ## related + + - [{{Product}}](/api/v1/entries/{{Product}}.md) + + ## hero + + Media asset `{{Hero}}` + + ## featured + + true + + ## extra + + ```json + { + "warranty": 2 + } + ``` + + """.Replace("\r\n", "\n", StringComparison.Ordinal), + markdown); + } + + [Fact] + public void An_entity_shows_each_facts_value_with_its_source() + { + var item = Item(ProductType, "product", """ + { + "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\nSource: https://example.com/prices (url)\n", markdown, StringComparison.Ordinal); + Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); + } + + [Fact] + public void Without_text_the_heading_is_the_type_name_and_the_id() + { + var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "headline": " "}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains($"# Product page {Page}\n", markdown, StringComparison.Ordinal); + Assert.Null(EntryMarkdown.Title(item.Fields, PageType)); + Assert.DoesNotContain("## headline", markdown, StringComparison.Ordinal); + } + + private static PublishedItem Item(ContentTypeDefinition definition, string type, string fields) + { + return new PublishedItem( + Page, + type, + type == "product" ? "Product" : "Product page", + definition, + 3, + JsonSerializer.Deserialize(fields), + []); + } + + private static FieldDefinition Field(string name, string type, string settings = "{}") + { + return new FieldDefinition(name, type, settings: JsonSerializer.Deserialize(settings)); + } +} From fc739d9012f1202ef0585f28ae54d01cdd6c9108 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:51:20 -0400 Subject: [PATCH 3/6] Serve each published entry as Markdown and add the site index, on the reviewed read endpoints --- docs/api/delivery.md | 18 ++++ src/FluentCMS.Api/DeliveryEndpoints.cs | 73 +++++++++++++- .../Delivery/IPublishedContent.cs | 17 +++- .../Delivery/PublishedContentReader.cs | 95 +++++++++++++++---- .../Delivery/DeliveryEndpointsTests.cs | 53 ++++++++++- .../Delivery/PublishedContentTests.cs | 34 +++++++ 6 files changed, 268 insertions(+), 22 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 5b8122d04..ec20b5a26 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -20,6 +20,9 @@ Entities are content too, so their fields hold facts as stored: each value with | --- | --- | | `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | | `GET /api/v1/entries/{id}` | One published entry. | +| `GET /api/v1/entries/{id}.md` | The same entry as Markdown. | +| `GET /api/v1/index` | Every published entry with its type, title and URLs, as JSON. | +| `GET /api/v1/index.md` and `GET /llms.txt` | The same index as Markdown, grouped by content type. | `GET /api/v1/content/{type}` takes these query parameters: @@ -39,6 +42,7 @@ A list looks like this: "type": "product-page", "version": 3, "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "markdown": "/api/v1/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e.md", "unresolved": [] } ], @@ -48,6 +52,20 @@ A list looks like this: `next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. +## Markdown and the site index + +Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. + +- Front matter gives the id, type, version and URL. +- The heading is the entry's title: its first text field that holds text. +- Each other field follows under its name, in the order its content type defines them: + - rich text as written, since it is stored as Markdown; + - lists as bullets, and JSON in a fenced block; + - references to whole entries as links to their Markdown. +- On an entity, each fact's value is followed by its source. + +The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. + ## Errors Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`). `errors` lists each error with a stable `code`: diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 230f668d9..6e42d0a31 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; @@ -16,19 +17,37 @@ public static class DeliveryEndpoints { public const string Prefix = "/api/v1"; + private const string Markdown = EntryMarkdown.MediaType + "; charset=utf-8"; + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) { var api = endpoints.MapGroup(Prefix).AllowAnonymous(); api.MapGet("/content/{type}", ListAsync); api.MapGet("/entries/{id}", GetAsync); + api.MapGet("/entries/{id}.md", GetMarkdownAsync); + api.MapGet("/index", IndexAsync); + api.MapGet("/index.md", IndexMarkdownAsync); // Ids are parsed by the handlers and every other path under the prefix is answered here, so no API call falls through to the // admin's not-found page. api.MapGet("/{**path}", (string? path) => Problem( [new Error(DeliveryErrorCodes.EndpointNotFound, $"There is no delivery endpoint at {Prefix}/{path}; see docs/api/delivery.md.")])); + + // Where agents look for a site's index first, by the llms.txt convention. + endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous(); return api; } + public static string EntryUrl(Guid id) + { + return $"{Prefix}/entries/{id}"; + } + + public static string MarkdownUrl(Guid id) + { + return $"{Prefix}/entries/{id}.md"; + } + private static async Task ListAsync( string type, string? limit, @@ -69,6 +88,53 @@ private static async Task GetAsync(string id, IPublishedContent content return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); } + // The URL is the entry's id, so it stays the same across versions and always shows the published one (API-03). + private static async Task GetMarkdownAsync(string id, IPublishedContent content, CancellationToken cancellationToken) + { + var errors = new List(); + if (Id(id, "id", "the id of an entry", errors) is not { } entryId) + { + return Problem(errors); + } + + var item = await content.GetAsync(entryId, cancellationToken); + return item.IsSuccess + ? Results.Text(EntryMarkdown.Render(item.Value, MarkdownUrl(entryId), MarkdownUrl), Markdown) + : Problem(item.Errors); + } + + private static async Task IndexAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + return Results.Json(new IndexResponse([.. index.Select(entry => new IndexEntryResponse( + entry.Id, + entry.ContentType, + entry.Version, + entry.Title, + EntryUrl(entry.Id), + MarkdownUrl(entry.Id)))])); + } + + private static async Task IndexMarkdownAsync(IPublishedContent content, CancellationToken cancellationToken) + { + var index = await content.IndexAsync(cancellationToken); + var markdown = new StringBuilder() + .Append("# Site index\n\n") + .Append(CultureInfo.InvariantCulture, $"Every published entry of this site, by content type, linked to its Markdown. The same index in JSON is at {Prefix}/index.\n"); + + foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) + { + markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + foreach (var entry in type) + { + var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); + } + } + + return Results.Text(markdown.ToString(), Markdown); + } + private static Guid? Id(string? value, string name, string form, List errors) { if (value is null) @@ -92,6 +158,7 @@ private static ItemResponse Item(PublishedItem item) item.ContentType, item.Version, item.Fields, + MarkdownUrl(item.Id), [.. item.Unresolved.Select(reference => new UnresolvedResponse( reference.Path, reference.EntityId, @@ -120,7 +187,11 @@ private static IResult Problem(IReadOnlyList errors) private sealed record PageResponse(IReadOnlyList Items, Guid? Next); - private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList Unresolved); + + private sealed record IndexResponse(IReadOnlyList Items); + + private sealed record IndexEntryResponse(Guid Id, string Type, int Version, string? Title, string Url, string Markdown); private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); } diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 8ebe61661..2fe67d547 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -1,6 +1,7 @@ using System.Text.Json; using FluentCMS.Core.Content; using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; @@ -13,6 +14,9 @@ public interface IPublishedContent Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); Task> GetAsync(Guid id, CancellationToken cancellationToken = default); + + // Every published entry of every type the caller may read, by type and then by id, for the site index (API-03). + Task> IndexAsync(CancellationToken cancellationToken = default); } // After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or @@ -27,7 +31,18 @@ public sealed record PublishedQuery(string ContentType, int Limit = PublishedQue // Next is null on the last page. public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); -public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); +// Title is EntryMarkdown.Title of the published version, null when it has no text. +public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); + +// The content type's name and current definition come along, since the published version is read against them. +public sealed record PublishedItem( + Guid Id, + string ContentType, + string ContentTypeName, + ContentTypeDefinition Definition, + int Version, + JsonElement Fields, + IReadOnlyList Unresolved); public static class DeliveryErrorCodes { diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index 43bc58ad3..bcd9616c7 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; @@ -33,19 +34,14 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - var typeId = await context.ContentTypes - .AsNoTracking() - .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) - .Select(row => (Guid?)row.Id) - .SingleOrDefaultAsync(cancellationToken); - if (typeId is null) + if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } var entries = context.Entries .AsNoTracking() - .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == type.Id && entry.PublishedVersion != null && entry.DeletedAt == null); // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. if (query.After is { } after) @@ -68,39 +64,100 @@ public async Task> ListAsync(PublishedQuery query, Cancell var page = rows.Take(query.Limit).ToList(); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(Item)], + [.. resolved.Select(entry => Item(entry, type))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await ( - from row in context.Entries.AsNoTracking() - join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id - where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null - select new { type.Slug, Version = row.PublishedVersion!.Value }) + var entry = await context.Entries + .AsNoTracking() + .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) + .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; + var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0])); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], type)); + } + + public async Task> IndexAsync(CancellationToken cancellationToken = default) + { + var types = await TypesAsync(null, null, cancellationToken); + + // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. + var rows = await ( + from entry in context.Entries.AsNoTracking() + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null + select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + .ToListAsync(cancellationToken); + + var readable = await permissions.ReadableTypesAsync(cancellationToken); + + return rows + .Select(row => (Row: row, Type: types[row.ContentTypeId])) + .Where(item => readable(item.Type.Slug)) + .Select(item => + { + using var fields = JsonDocument.Parse(item.Row.Fields); + return new PublishedSummary( + item.Row.Id, + item.Type.Slug, + item.Type.Name, + item.Row.Version, + EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + }) + .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) + .ThenBy(summary => summary.Id) + .ToList(); + } + + // The site's content types with their current definitions, which a published version is read against: every type, the one + // with the slug, or the one with the id. + private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + { + var rows = await ( + from type in context.ContentTypes.AsNoTracking() + join version in context.ContentTypeVersions.AsNoTracking() + on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } + where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) + select new { type.Id, type.Slug, type.Name, version.Definition }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); } - private static PublishedItem Item(ResolvedEntry entry) + private static ContentTypeDefinition ReadDefinition(string slug, string json) { - return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + var definition = ContentTypeDefinition.Parse(json); + + return definition.IsSuccess + ? definition.Value + : throw new InvalidOperationException( + $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); } + + private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + { + return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + } + + private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 6fb0bf28b..97bd7f26c 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -32,9 +32,43 @@ public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_c Assert.Equal(99, (int)item["fields"]!["price"]!); var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal($"/api/v1/entries/{Page}.md", (string?)item["markdown"]); Assert.Equal(Page.ToString(), (string?)response["next"]); } + [Fact] + public async Task An_entry_reads_as_markdown_at_its_id() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var response = await api.Client.GetAsync($"/api/v1/entries/{Page}.md"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}.md"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + var markdown = await response.Content.ReadAsStringAsync(); + Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + } + + [Fact] + public async Task The_site_index_lists_every_entry_in_json_markdown_and_llms_txt() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var json = await api.Client.GetFromJsonAsync("/api/v1/index"); + var markdown = await api.Client.GetStringAsync("/api/v1/index.md"); + var llms = await api.Client.GetStringAsync("/llms.txt"); + + var entries = json!["items"]!.AsArray(); + Assert.Equal([Product.ToString(), Page.ToString()], entries.Select(entry => (string?)entry!["id"])); + Assert.Equal(("Drill [cordless]", $"/api/v1/entries/{Product}", $"/api/v1/entries/{Product}.md"), ((string?)entries[0]!["title"], (string?)entries[0]!["url"], (string?)entries[0]!["markdown"])); + Assert.Contains($@"## Product{"\n\n"}- [Drill \[cordless\]](/api/v1/entries/{Product}.md){"\n"}", markdown, StringComparison.Ordinal); + Assert.Contains($"## Product page\n\n- [Product page {Page}](/api/v1/entries/{Page}.md)\n", markdown, StringComparison.Ordinal); + Assert.Equal(markdown, llms); + } + [Fact] public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() { @@ -84,6 +118,7 @@ public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database // The admin's not-found page answers every other path of the host, so the API answers each of its own. [Theory] [InlineData("/api/v1/entries/not-a-guid", HttpStatusCode.BadRequest, DeliveryErrorCodes.ParameterInvalid)] + [InlineData("/api/v1/entries/not-a-guid.md", HttpStatusCode.BadRequest, DeliveryErrorCodes.ParameterInvalid)] [InlineData("/api/v1/entries/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] [InlineData("/api/v1/content/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] [InlineData("/api/v1/nothing/here", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] @@ -124,13 +159,29 @@ public Task> GetAsync(Guid id, CancellationToken cancellat : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); } + public Task> IndexAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>( + [ + new PublishedSummary(Product, "product", "Product", 1, "Drill [cordless]"), + new PublishedSummary(Page, "product-page", "Product page", 3, null), + ]); + } + private static PublishedItem Item() { return new PublishedItem( Page, "product-page", + "Product page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", settings: JsonSerializer.Deserialize("""{"target": "product"}""")), + new FieldDefinition("price", "number"), + new FieldDefinition("headline", "text"), + ]), 3, - JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null, "headline": "A sturdy drill"}"""), [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); } } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index b49951b77..f2f77cce9 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -159,6 +159,40 @@ await scenario.PublishAsync( Assert.Equal([product], (await content.ListAsync(new PublishedQuery("product"))).Value.Items.Select(item => item.Id)); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_index_lists_each_published_entry_once_with_the_title_of_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "First headline"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + var post = (await scenario.CreateAsync("blog-post", """{"title": "Launch"}""")).Value.Id; + foreach (var entry in new[] { product, page, deleted, post }) + { + await PublishAsync(scenario, entry, 1); + } + + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Second headline"}"""); + + var before = await ContentAsync(scenario, content => content.IndexAsync()); + await PublishAsync(scenario, page, 2); + var after = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal( + [ + new PublishedSummary(post, "blog-post", "Blog Post", 1, "Launch"), + new PublishedSummary(product, "product", "Product", 1, "Drill"), + new PublishedSummary(page, "product-page", "Product Page", 1, "First headline"), + ], + before); + Assert.Equal((2, "Second headline"), (after[^1].Version, after[^1].Title)); + Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); From 08477f0581898056ddeb1f8e2b54eff4b4a6fc1a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:35:58 -0400 Subject: [PATCH 4/6] Escape plain text in Markdown and read each version against its own definition --- docs/api/delivery.md | 10 +- src/FluentCMS.Api/DeliveryEndpoints.cs | 4 +- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 135 +++++++++++++----- .../Delivery/PublishedContentReader.cs | 103 +++++++------ .../Delivery/DeliveryEndpointsTests.cs | 2 +- .../Delivery/PublishedContentTests.cs | 50 +++++++ .../Delivery/EntryMarkdownTests.cs | 99 +++++++++++-- 7 files changed, 307 insertions(+), 96 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index ec20b5a26..990eb2623 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -56,13 +56,15 @@ A list looks like this: Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version. -- Front matter gives the id, type, version and URL. +- Front matter gives the id, type, version and URL, as quoted strings apart from the version number. - The heading is the entry's title: its first text field that holds text. -- Each other field follows under its name, in the order its content type defines them: +- Each other field follows under its name, in the order of the content type definition that the published version was written against: - rich text as written, since it is stored as Markdown; - - lists as bullets, and JSON in a fenced block; + - other text escaped so it reads as written, which means a value can never pose as a heading, a field, a link or HTML; + - lists as bullets, each item keeping its own paragraphs, and JSON in a fenced block; - references to whole entries as links to their Markdown. -- On an entity, each fact's value is followed by its source. +- On an entity, each fact's value is followed by a `**Source:**` item, which also says when the fact is unsourced. +- References that could not be resolved are listed under "Unresolved references", each with its reason. The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs index 6e42d0a31..55bebd23c 100644 --- a/src/FluentCMS.Api/DeliveryEndpoints.cs +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -124,10 +124,10 @@ private static async Task IndexMarkdownAsync(IPublishedContent content, foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName))) { - markdown.Append(CultureInfo.InvariantCulture, $"\n## {type.Key.ContentTypeName}\n\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n## {EntryMarkdown.InlineText(type.Key.ContentTypeName)}\n\n"); foreach (var entry in type) { - var title = (entry.Title ?? $"{entry.ContentTypeName} {entry.Id}").Replace("[", @"\[", StringComparison.Ordinal).Replace("]", @"\]", StringComparison.Ordinal); + var title = EntryMarkdown.InlineText(entry.Title ?? $"{entry.ContentTypeName} {entry.Id}"); markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n"); } } diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index 521f2833c..12c3e82b5 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -1,12 +1,14 @@ using System.Globalization; using System.Text; using System.Text.Json; +using FluentCMS.Core.Content; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Delivery; // The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; -// every other value is written out by its field's type, in the order the content type defines its fields. +// every other value is written as text that reads as written, so no value can pose as a heading, a field, a source or a link. +// Fields follow in the order of the definition the published version was written against. public static class EntryMarkdown { public const string MediaType = "text/markdown"; @@ -14,7 +16,7 @@ public static class EntryMarkdown // Markdown lines end in \n whatever the platform, JSON blocks included. private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, NewLine = "\n" }; - // The first text field that holds text, read from its fact on an entity; null when there is none. + // The first text field that holds text, read from its fact on an entity, on one line; null when there is none. public static string? Title(JsonElement fields, ContentTypeDefinition definition) { ArgumentNullException.ThrowIfNull(definition); @@ -22,6 +24,49 @@ public static class EntryMarkdown return TitleField(fields, definition)?.Text; } + // Plain text as Markdown that reads as written (CommonMark lets a backslash escape any punctuation). The characters that open + // emphasis, code, links, HTML, entities, headings and tables are escaped wherever they are, and those that open a list, a quote + // or a heading underline where a line starts. Lines lose their indentation, so none becomes a code block; breaks stay. + public static string Text(string text) + { + ArgumentNullException.ThrowIfNull(text); + + var escaped = new StringBuilder(text.Length + 16); + var lines = text.ReplaceLineEndings("\n").Split('\n'); + for (var index = 0; index < lines.Length; index++) + { + if (index > 0) + { + escaped.Append('\n'); + } + + var line = lines[index].TrimStart(); + var digits = line.TakeWhile(char.IsAsciiDigit).Count(); + for (var position = 0; position < line.Length; position++) + { + var character = line[position]; + var opensLine = position == 0 && character is '-' or '+' or '=' or '>'; + var endsListNumber = position == digits && digits > 0 && character is '.' or ')'; + if (character is '\\' or '`' or '*' or '_' or '[' or ']' or '<' or '>' or '&' or '#' or '|' or '~' || opensLine || endsListNumber) + { + escaped.Append('\\'); + } + + escaped.Append(character); + } + } + + return escaped.ToString(); + } + + // Text for one line, such as a heading or a link's text: its whitespace collapsed, then escaped as Text is. + public static string InlineText(string text) + { + ArgumentNullException.ThrowIfNull(text); + + return Text(Line(text)); + } + // EntryUrl gives the Markdown URL of another entry, for references to whole entries. public static string Render(PublishedItem item, string url, Func entryUrl) { @@ -32,12 +77,12 @@ public static string Render(PublishedItem item, string url, Func e var title = TitleField(item.Fields, definition); var markdown = new StringBuilder() .Append("---\n") - .Append(CultureInfo.InvariantCulture, $"id: {item.Id}\n") - .Append(CultureInfo.InvariantCulture, $"type: {item.ContentType}\n") + .Append(CultureInfo.InvariantCulture, $"id: \"{item.Id}\"\n") + .Append(CultureInfo.InvariantCulture, $"type: \"{item.ContentType}\"\n") .Append(CultureInfo.InvariantCulture, $"version: {item.Version}\n") - .Append(CultureInfo.InvariantCulture, $"url: {url}\n") + .Append(CultureInfo.InvariantCulture, $"url: \"{url}\"\n") .Append("---\n\n") - .Append(CultureInfo.InvariantCulture, $"# {title?.Text ?? $"{item.ContentTypeName} {item.Id}"}\n"); + .Append(CultureInfo.InvariantCulture, $"# {InlineText(title?.Text ?? $"{item.ContentTypeName} {item.Id}")}\n"); // The heading shows the title, so its field is not repeated below it. foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) @@ -49,9 +94,19 @@ public static string Render(PublishedItem item, string url, Func e markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); - if (definition.IsEntity && Source(item.Fields, field.Name) is { } source) + // A list item with a strong label, which an escaped value cannot form, so a value cannot pass for a source. + if (definition.IsEntity) { - markdown.Append(CultureInfo.InvariantCulture, $"\nSource: {source}\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n- **Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); + } + } + + if (item.Unresolved.Count > 0) + { + markdown.Append("\n## Unresolved references\n\n"); + foreach (var reference in item.Unresolved) + { + markdown.Append(CultureInfo.InvariantCulture, $"- `{reference.Path}`: {reference.Field} of entry {reference.EntityId} {Reason(reference.Reason)}\n"); } } @@ -62,8 +117,7 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte { foreach (var field in definition.Fields.Where(field => field.Type == "text")) { - if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value - && !string.IsNullOrWhiteSpace(value.GetString())) + if (Value(fields, field.Name, definition.IsEntity) is { ValueKind: JsonValueKind.String } value) { return (field.Name, Line(value.GetString()!)); } @@ -92,53 +146,64 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte private static string? Source(JsonElement fields, string name) { - return fields.TryGetProperty(name, out var fact) - && fact.ValueKind == JsonValueKind.Object - && fact.TryGetProperty(FactMetadata.Source, out var source) - && source.ValueKind == JsonValueKind.Object - && source.TryGetProperty(FactMetadata.SourceReference, out var reference) - && reference.ValueKind == JsonValueKind.String - ? source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String - ? $"{reference.GetString()} ({kind.GetString()})" - : reference.GetString() - : null; + if (!fields.TryGetProperty(name, out var fact) + || fact.ValueKind != JsonValueKind.Object + || !fact.TryGetProperty(FactMetadata.Source, out var source) + || source.ValueKind != JsonValueKind.Object + || !source.TryGetProperty(FactMetadata.SourceReference, out var reference) + || reference.ValueKind != JsonValueKind.String) + { + return null; + } + + var text = InlineText(reference.GetString()!); + return source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String + ? $"{text} \\({InlineText(kind.GetString()!)}\\)" + : text; + } + + private static string Reason(UnresolvedReason reason) + { + return reason switch + { + UnresolvedReason.NotPublished => "is not published", + UnresolvedReason.NoValue => "has no value", + UnresolvedReason.Deleted => "is deleted", + _ => "cannot be resolved", + }; } private static string Block(string type, JsonElement settings, JsonElement value, Func entryUrl) { if (type == "list" && value.ValueKind == JsonValueKind.Array) { - var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) ? item.GetString() ?? "" : ""; + var itemType = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemType", out var item) && item.ValueKind == JsonValueKind.String + ? item.GetString()! + : ""; var itemSettings = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("itemSettings", out var nested) ? nested : FieldDefinition.EmptySettings; + // Each item keeps its own block, indented under its bullet so paragraphs and fences stay inside the item. return string.Join('\n', value.EnumerateArray() .Where(element => element.ValueKind != JsonValueKind.Null) - .Select(element => $"- {Line(Inline(itemType, itemSettings, element, entryUrl))}")); + .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); } - return type switch - { - "richText" when value.ValueKind == JsonValueKind.String => value.GetString()!.Trim(), - "json" => Fenced(value), - _ when value.ValueKind is JsonValueKind.Object or JsonValueKind.Array => Fenced(value), - _ => Inline(type, settings, value, entryUrl), - }; - } - - private static string Inline(string type, JsonElement settings, JsonElement value, Func entryUrl) - { // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); return value.ValueKind switch { + JsonValueKind.String when type == "richText" => value.GetString()!.Trim(), JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", - JsonValueKind.String when type == "media" => $"Media asset `{value.GetString()}`", - JsonValueKind.String => value.GetString()!, + JsonValueKind.String when type == "media" && value.TryGetGuid(out var asset) => $"Media asset `{asset}`", + JsonValueKind.String => Text(value.GetString()!), + JsonValueKind.Object or JsonValueKind.Array => Fenced(value), + _ when type == "json" => Fenced(value), _ => value.GetRawText(), }; } + // The serializer writes a backtick in a string as `, so no value inside can close the fence. private static string Fenced(JsonElement value) { return $"```json\n{JsonSerializer.Serialize(value, Indented)}\n```"; diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs index bcd9616c7..0e8ffa7b1 100644 --- a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -11,7 +11,8 @@ namespace FluentCMS.Infrastructure.Delivery; -// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. +// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. Each item +// carries the definition its version was written against, so a revision of the type waiting for review changes nothing it shows. internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent { // Until multi-site (MST-01), every entry belongs to the one site (SCH-07). @@ -34,7 +35,12 @@ public async Task> ListAsync(PublishedQuery query, Cancell return Result.Failure(allowed.Errors); } - if ((await TypesAsync(query.ContentType, null, cancellationToken)).Values.SingleOrDefault() is not { } type) + var type = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) + .Select(row => new { row.Id, row.Name }) + .SingleOrDefaultAsync(cancellationToken); + if (type is null) { return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); } @@ -55,109 +61,118 @@ public async Task> ListAsync(PublishedQuery query, Cancell .Any(reference => reference.EntryId == entry.Id && reference.Version == entry.PublishedVersion && reference.TargetId == target)); } - var rows = await entries - .OrderBy(entry => entry.Id) + var rows = await ( + from entry in entries + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + orderby entry.Id + select new { entry.Id, version.Version, version.ContentTypeVersion }) .Take(query.Limit + 1) - .Select(entry => new { entry.Id, Version = entry.PublishedVersion!.Value }) .ToListAsync(cancellationToken); var page = rows.Take(query.Limit).ToList(); + var definitions = await DefinitionsAsync([.. page.Select(row => (type.Id, row.ContentTypeVersion))], cancellationToken); var resolved = await EntryResolution.ResolveAsync( context, - [.. page.Select(row => new VersionToResolve(row.Id, type.Slug, row.Version))], + [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], cancellationToken); return Result.Success(new PublishedPage( - [.. resolved.Select(entry => Item(entry, type))], + [.. resolved.Select((entry, index) => Item(entry, type.Name, definitions[(type.Id, page[index].ContentTypeVersion)]))], rows.Count > query.Limit ? page[^1].Id : null)); } public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var entry = await context.Entries - .AsNoTracking() - .Where(row => row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null) - .Select(row => new { row.ContentTypeId, Version = row.PublishedVersion!.Value }) + var entry = await ( + from row in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id + join version in context.EntryVersions.AsNoTracking() + on new { EntryId = row.Id, Version = row.PublishedVersion!.Value } equals new { version.EntryId, version.Version } + where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null + select new { row.ContentTypeId, type.Slug, type.Name, version.Version, version.ContentTypeVersion }) .SingleOrDefaultAsync(cancellationToken); if (entry is null) { return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); } - var type = (await TypesAsync(null, entry.ContentTypeId, cancellationToken))[entry.ContentTypeId]; - var allowed = await permissions.CheckContentAsync(type.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, type.Slug, entry.Version)], cancellationToken); - return Result.Success(Item(resolved[0], type)); + var definitions = await DefinitionsAsync([(entry.ContentTypeId, entry.ContentTypeVersion)], cancellationToken); + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0], entry.Name, definitions[(entry.ContentTypeId, entry.ContentTypeVersion)])); } public async Task> IndexAsync(CancellationToken cancellationToken = default) { - var types = await TypesAsync(null, null, cancellationToken); - // The stored documents, not resolved views: a title is a text field of the entry itself, never a resolved reference. var rows = await ( from entry in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on entry.ContentTypeId equals type.Id join version in context.EntryVersions.AsNoTracking() on new { EntryId = entry.Id, Version = entry.PublishedVersion!.Value } equals new { version.EntryId, version.Version } where entry.SiteId == SiteId && entry.PublishedVersion != null && entry.DeletedAt == null - select new { entry.Id, entry.ContentTypeId, version.Version, version.Fields }) + select new { entry.Id, entry.ContentTypeId, type.Slug, type.Name, version.Version, version.ContentTypeVersion, version.Fields }) .ToListAsync(cancellationToken); var readable = await permissions.ReadableTypesAsync(cancellationToken); + var shown = rows.Where(row => readable(row.Slug)).ToList(); + var definitions = await DefinitionsAsync([.. shown.Select(row => (row.ContentTypeId, row.ContentTypeVersion))], cancellationToken); - return rows - .Select(row => (Row: row, Type: types[row.ContentTypeId])) - .Where(item => readable(item.Type.Slug)) - .Select(item => + return shown + .Select(row => { - using var fields = JsonDocument.Parse(item.Row.Fields); + using var fields = JsonDocument.Parse(row.Fields); return new PublishedSummary( - item.Row.Id, - item.Type.Slug, - item.Type.Name, - item.Row.Version, - EntryMarkdown.Title(fields.RootElement, item.Type.Definition)); + row.Id, + row.Slug, + row.Name, + row.Version, + EntryMarkdown.Title(fields.RootElement, definitions[(row.ContentTypeId, row.ContentTypeVersion)])); }) .OrderBy(summary => summary.ContentType, StringComparer.Ordinal) .ThenBy(summary => summary.Id) .ToList(); } - // The site's content types with their current definitions, which a published version is read against: every type, the one - // with the slug, or the one with the id. - private async Task> TypesAsync(string? slug, Guid? id, CancellationToken cancellationToken) + // The definitions of the type versions given, read in one query; every pair must be stored. + private async Task> DefinitionsAsync( + IReadOnlyCollection<(Guid TypeId, int Version)> wanted, + CancellationToken cancellationToken) { - var rows = await ( - from type in context.ContentTypes.AsNoTracking() - join version in context.ContentTypeVersions.AsNoTracking() - on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } - where type.SiteId == SiteId && (slug == null || type.Slug == slug) && (id == null || type.Id == id) - select new { type.Id, type.Slug, type.Name, version.Definition }) + var pairs = wanted.ToHashSet(); + var typeIds = pairs.Select(pair => pair.TypeId).Distinct().ToList(); + var versions = pairs.Select(pair => pair.Version).Distinct().ToList(); + + var rows = await context.ContentTypeVersions + .AsNoTracking() + .Where(row => typeIds.Contains(row.ContentTypeId) && versions.Contains(row.Version)) + .Select(row => new { row.ContentTypeId, row.Version, row.Definition }) .ToListAsync(cancellationToken); - return rows.ToDictionary(row => row.Id, row => new PublishedType(row.Id, row.Slug, row.Name, ReadDefinition(row.Slug, row.Definition))); + return rows + .Where(row => pairs.Contains((row.ContentTypeId, row.Version))) + .ToDictionary(row => (row.ContentTypeId, row.Version), row => ReadDefinition(row.ContentTypeId, row.Version, row.Definition)); } - private static ContentTypeDefinition ReadDefinition(string slug, string json) + private static ContentTypeDefinition ReadDefinition(Guid typeId, int version, string json) { var definition = ContentTypeDefinition.Parse(json); return definition.IsSuccess ? definition.Value : throw new InvalidOperationException( - $"The current definition of content type '{slug}' is stored in a form that cannot be read: " + $"Version {version} of content type {typeId} is stored in a form that cannot be read: " + string.Join("; ", definition.Errors.Select(error => error.Message))); } - private static PublishedItem Item(ResolvedEntry entry, PublishedType type) + private static PublishedItem Item(ResolvedEntry entry, string typeName, ContentTypeDefinition definition) { - return new PublishedItem(entry.EntryId, type.Slug, type.Name, type.Definition, entry.Version, entry.Fields, entry.Unresolved); + return new PublishedItem(entry.EntryId, entry.ContentType, typeName, definition, entry.Version, entry.Fields, entry.Unresolved); } - - private sealed record PublishedType(Guid Id, string Slug, string Name, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs index 97bd7f26c..e29b0294a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -47,7 +47,7 @@ public async Task An_entry_reads_as_markdown_at_its_id() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); var markdown = await response.Content.ReadAsStringAsync(); - Assert.StartsWith($"---\nid: {Page}\ntype: product-page\nversion: 3\nurl: /api/v1/entries/{Page}.md\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); + Assert.StartsWith($"---\nid: \"{Page}\"\ntype: \"product-page\"\nversion: 3\nurl: \"/api/v1/entries/{Page}.md\"\n---\n\n# A sturdy drill\n", markdown, StringComparison.Ordinal); Assert.Contains($"[{Product}](/api/v1/entries/{Product}.md)", markdown, StringComparison.Ordinal); await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); } diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index f2f77cce9..e0986c67a 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -7,6 +7,7 @@ using FluentCMS.Infrastructure.Persistence; using FluentCMS.IntegrationTests.ChangeSets; using FluentCMS.IntegrationTests.Content; +using FluentCMS.IntegrationTests.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using static FluentCMS.IntegrationTests.Content.EntryScenario; @@ -193,6 +194,55 @@ public async Task The_index_lists_each_published_entry_once_with_the_title_of_it Assert.DoesNotContain(after, summary => summary.Id == draft || summary.Id == deleted); } + // API-03: the Markdown URL is the entry's id, so it stays the same across versions and always shows the published one. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_markdown_of_a_published_page_follows_its_published_version(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + await PublishAsync(scenario, product, 1); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Now cheaper"}"""); + + async Task MarkdownAsync() => + EntryMarkdown.Render((await ContentAsync(scenario, content => content.GetAsync(page))).Value, $"/api/v1/entries/{page}.md", id => $"/api/v1/entries/{id}.md"); + + var draft = await MarkdownAsync(); + await PublishAsync(scenario, page, 2); + var published = await MarkdownAsync(); + + Assert.Contains("# A sturdy drill\n", draft, StringComparison.Ordinal); + Assert.Contains("## price\n\n99\n", draft, StringComparison.Ordinal); + Assert.Contains($"## product\n\n[{product}](/api/v1/entries/{product}.md)\n", draft, StringComparison.Ordinal); + Assert.Contains("# Now cheaper\n", published, StringComparison.Ordinal); + Assert.Contains($"url: \"/api/v1/entries/{page}.md\"\n", published, StringComparison.Ordinal); + Assert.Contains("version: 2\n", published, StringComparison.Ordinal); + } + + // A revision of the type waiting for review changes nothing a reader sees: each version is read against the definition it was + // written against. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_published_version_is_read_against_the_definition_it_was_written_against(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + var revised = new ContentTypeDefinition([SchemaScenario.Reference("product", "product"), SchemaScenario.Field("body", "richText")]); + Assert.True((await scenario.Schema.ReviseAsync("product-page", 1, revised)).IsSuccess); + + var read = (await ContentAsync(scenario, content => content.GetAsync(page))).Value; + var index = await ContentAsync(scenario, content => content.IndexAsync()); + + Assert.Equal(["product", "price", "headline", "body"], read.Definition.Fields.Select(field => field.Name)); + Assert.Equal("A sturdy drill", Assert.Single(index).Title); + } + private static async Task CreateAsync(string provider) { var scenario = await EntryScenario.CreateAsync(provider); diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index 492d3e3d2..ee6aca3ff 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentCMS.Core.Content; using FluentCMS.Core.Delivery; using FluentCMS.Core.Schema; @@ -20,11 +21,14 @@ public sealed class EntryMarkdownTests Field("related", "list", """{"itemType": "reference", "itemSettings": {"target": "product"}}"""), Field("hero", "media"), Field("featured", "boolean"), + Field("color", "enum", """{"values": ["red", "blue"]}"""), + Field("launch", "dateTime"), Field("extra", "json"), Field("note", "text"), + Field("sections", "list", """{"itemType": "richText"}"""), ]); - private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number")], isEntity: true); + private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number"), Field("claim", "text")], isEntity: true); [Fact] public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() @@ -39,8 +43,11 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() "related": ["{{Product}}"], "hero": "{{Hero}}", "featured": true, + "color": "red", + "launch": "2026-10-18T09:00:00+00:00", "extra": {"warranty": 2}, - "note": null + "note": null, + "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"] } """); @@ -49,10 +56,10 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() Assert.Equal( $$""" --- - id: {{Page}} - type: product-page + id: "{{Page}}" + type: "product-page" version: 3 - url: /api/v1/entries/{{Page}}.md + url: "/api/v1/entries/{{Page}}.md" --- # A sturdy drill @@ -88,6 +95,14 @@ Media asset `{{Hero}}` true + ## color + + red + + ## launch + + 2026-10-18T09:00:00+00:00 + ## extra ```json @@ -96,37 +111,101 @@ Media asset `{{Hero}}` } ``` + ## sections + + - First part. + + Second paragraph. + - ``` + code + ``` + """.Replace("\r\n", "\n", StringComparison.Ordinal), markdown); } + // A value is content, never structure: text that looks like a field, a source, HTML or emphasis reads as written. + [Fact] + public void Text_cannot_pose_as_a_field_a_source_html_or_formatting() + { + var page = Item(PageType, "product-page", """ + {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4\n1. first\n- item\n indented"} + """); + var product = Item(ProductType, "product", """ + { + "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "claim": {"value": "Lasts 10 years\n\nSource: https://manufacturer.example/spec (url)", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """); + + var pageMarkdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); + var productMarkdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); + + Assert.Contains("# Learn C \\#\n", pageMarkdown, StringComparison.Ordinal); + Assert.Contains( + "## note\n\nBuilt tough.\n\n\\#\\# price\n\n0\n\n\\ costs 2\\*3\\*4\n1\\. first\n\\- item\nindented\n", + pageMarkdown, + StringComparison.Ordinal); + Assert.Single(pageMarkdown.Split('\n'), line => line.StartsWith("## price", StringComparison.Ordinal) || line == "## note"); + Assert.Contains( + "## claim\n\nLasts 10 years\n\nSource: https://manufacturer.example/spec (url)\n\n- **Source:** none, so this fact is unsourced\n", + productMarkdown, + StringComparison.Ordinal); + } + [Fact] public void An_entity_shows_each_facts_value_with_its_source() { var item = Item(ProductType, "product", """ { "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); - Assert.EndsWith("## price\n\n99\n\nSource: https://example.com/prices (url)\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\n- **Source:** https://example.com/prices\\_\\[2026\\] \\(url\\)\n", markdown, StringComparison.Ordinal); Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); } [Fact] - public void Without_text_the_heading_is_the_type_name_and_the_id() + public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_references_are_listed() { - var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "headline": " "}"""); + var item = Item(PageType, "product-page", $$"""{"product": "{{Product}}", "price": null, "headline": " "}""") with + { + ContentTypeName = "Product\n- [Click](https://evil.example)", + Unresolved = [new UnresolvedReference("/price", Product, "price", UnresolvedReason.NotPublished)], + }; var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); - Assert.Contains($"# Product page {Page}\n", markdown, StringComparison.Ordinal); + Assert.Contains($"# Product - \\[Click\\](https://evil.example) {Page}\n", markdown, StringComparison.Ordinal); Assert.Null(EntryMarkdown.Title(item.Fields, PageType)); Assert.DoesNotContain("## headline", markdown, StringComparison.Ordinal); + Assert.EndsWith($"## Unresolved references\n\n- `/price`: price of entry {Product} is not published\n", markdown, StringComparison.Ordinal); + } + + // Index link text: a backslash, brackets and an autolink stay text inside the link. + [Theory] + [InlineData(@"C:\", @"C:\\")] + [InlineData(@"a\[b]", @"a\\\[b\]")] + [InlineData("", @"\")] + [InlineData("two\nlines", "two lines")] + public void Inline_text_keeps_backslashes_brackets_and_autolinks_as_text(string text, string expected) + { + Assert.Equal(expected, EntryMarkdown.InlineText(text)); + } + + [Fact] + public void A_backtick_in_json_cannot_close_its_fence() + { + var item = Item(PageType, "product-page", """{"extra": {"snippet": "```` four"}}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("```json\n{\n \"snippet\": \"\\u0060\\u0060\\u0060\\u0060 four\"\n}\n```\n", markdown, StringComparison.Ordinal); } private static PublishedItem Item(ContentTypeDefinition definition, string type, string fields) From 887b325a9d58a6c90102af3b2d27db3cb3112a22 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:19:08 -0400 Subject: [PATCH 5/6] Quote rich text in Markdown, give every fact its source, and keep URLs exact --- docs/api/delivery.md | 6 +- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 92 +++++++++++++++---- .../Delivery/PublishedContentTests.cs | 1 + .../Delivery/EntryMarkdownTests.cs | 85 +++++++++++------ 4 files changed, 135 insertions(+), 49 deletions(-) diff --git a/docs/api/delivery.md b/docs/api/delivery.md index 990eb2623..aac8eda73 100644 --- a/docs/api/delivery.md +++ b/docs/api/delivery.md @@ -59,11 +59,11 @@ Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` - Front matter gives the id, type, version and URL, as quoted strings apart from the version number. - The heading is the entry's title: its first text field that holds text. - Each other field follows under its name, in the order of the content type definition that the published version was written against: - - rich text as written, since it is stored as Markdown; - - other text escaped so it reads as written, which means a value can never pose as a heading, a field, a link or HTML; + - rich text as written, since it is stored as Markdown, inside a block quote so that whatever it opens ends with its field; + - other text escaped where it could open structure, which means a value can never pose as a heading, a field, a link or HTML, while words and URLs keep their characters; - lists as bullets, each item keeping its own paragraphs, and JSON in a fenced block; - references to whole entries as links to their Markdown. -- On an entity, each fact's value is followed by a `**Source:**` item, which also says when the fact is unsourced. +- On an entity, every fact, the title's included, is followed by a `**Source:**` paragraph, which also says when the fact is unsourced. A source that is a web address is a link. - References that could not be resolved are listed under "Unresolved references", each with its reason. The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first. diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index 12c3e82b5..f3d7cf64d 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -6,9 +6,10 @@ namespace FluentCMS.Core.Delivery; -// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written; -// every other value is written as text that reads as written, so no value can pose as a heading, a field, a source or a link. -// Fields follow in the order of the definition the published version was written against. +// The Markdown representation of a published entry (API-03). Rich text is stored as Markdown (SCH-02), so it goes in as written, but +// quoted, so whatever it opens ends with its field. Every other value is written as text that reads as written. No value can pose +// as a heading, a field, a source or a link outside its own field. Fields follow in the order of the definition the published +// version was written against. public static class EntryMarkdown { public const string MediaType = "text/markdown"; @@ -24,9 +25,10 @@ public static class EntryMarkdown return TitleField(fields, definition)?.Text; } - // Plain text as Markdown that reads as written (CommonMark lets a backslash escape any punctuation). The characters that open - // emphasis, code, links, HTML, entities, headings and tables are escaped wherever they are, and those that open a list, a quote - // or a heading underline where a line starts. Lines lose their indentation, so none becomes a code block; breaks stay. + // Plain text as Markdown that reads as written; CommonMark lets a backslash escape any punctuation. Escaped wherever they are: + // what opens code, emphasis, links, autolinks, HTML and strikethrough, an underscore that is not inside a word, and an ampersand + // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table or a heading underline. + // Lines lose their indentation, so none becomes a code block; breaks stay. URLs and words keep their characters. public static string Text(string text) { ArgumentNullException.ThrowIfNull(text); @@ -45,9 +47,7 @@ public static string Text(string text) for (var position = 0; position < line.Length; position++) { var character = line[position]; - var opensLine = position == 0 && character is '-' or '+' or '=' or '>'; - var endsListNumber = position == digits && digits > 0 && character is '.' or ')'; - if (character is '\\' or '`' or '*' or '_' or '[' or ']' or '<' or '>' or '&' or '#' or '|' or '~' || opensLine || endsListNumber) + if (NeedsEscape(line, position, digits)) { escaped.Append('\\'); } @@ -84,8 +84,8 @@ public static string Render(PublishedItem item, string url, Func e .Append("---\n\n") .Append(CultureInfo.InvariantCulture, $"# {InlineText(title?.Text ?? $"{item.ContentTypeName} {item.Id}")}\n"); - // The heading shows the title, so its field is not repeated below it. - foreach (var field in definition.Fields.Where(field => field.Name != title?.Field)) + // The heading shows the title, so its field is not repeated below it, unless it is a fact, whose source belongs with it. + foreach (var field in definition.Fields.Where(field => definition.IsEntity || field.Name != title?.Field)) { if (Value(item.Fields, field.Name, definition.IsEntity) is not { } value) { @@ -94,10 +94,10 @@ public static string Render(PublishedItem item, string url, Func e markdown.Append(CultureInfo.InvariantCulture, $"\n## {field.Name}\n\n").Append(Block(field.Type, field.Settings, value, entryUrl)).Append('\n'); - // A list item with a strong label, which an escaped value cannot form, so a value cannot pass for a source. + // A paragraph with a strong label after the value, which escaped text cannot form and quoted rich text cannot reach. if (definition.IsEntity) { - markdown.Append(CultureInfo.InvariantCulture, $"\n- **Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); + markdown.Append(CultureInfo.InvariantCulture, $"\n**Source:** {Source(item.Fields, field.Name) ?? "none, so this fact is unsourced"}\n"); } } @@ -113,6 +113,53 @@ public static string Render(PublishedItem item, string url, Func e return markdown.ToString(); } + private static bool NeedsEscape(string line, int position, int digits) + { + var character = line[position]; + if (character is '\\' or '`' or '*' or '[' or ']' or '<' or '>' or '~') + { + return true; + } + + if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':') + { + return true; + } + + if (position == digits && digits > 0 && character is '.' or ')') + { + return true; + } + + // A run of hashes that ends a line after a space would close a heading and be dropped from it. + if (character == '#' && char.IsWhiteSpace(line[position - 1]) && line[position..].TrimEnd().All(each => each == '#')) + { + return true; + } + + // An underscore inside a word opens no emphasis, as in a URL or an identifier. + if (character == '_') + { + return position == 0 || position == line.Length - 1 || !char.IsLetterOrDigit(line[position - 1]) || !char.IsLetterOrDigit(line[position + 1]); + } + + // Only an ampersand that starts a named or numeric entity reads as anything but itself. + if (character == '&') + { + var rest = line.AsSpan(position + 1); + var name = rest.StartsWith("#") ? rest[1..] : rest; + var length = 0; + while (length < name.Length && char.IsAsciiLetterOrDigit(name[length])) + { + length++; + } + + return length > 0 && length < name.Length && name[length] == ';'; + } + + return false; + } + private static (string Field, string Text)? TitleField(JsonElement fields, ContentTypeDefinition definition) { foreach (var field in definition.Fields.Where(field => field.Type == "text")) @@ -144,6 +191,7 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte : value; } + // A source that is an absolute web address is written as an autolink, which keeps it exact; anything else is escaped text. private static string? Source(JsonElement fields, string name) { if (!fields.TryGetProperty(name, out var fact) @@ -156,7 +204,12 @@ private static (string Field, string Text)? TitleField(JsonElement fields, Conte return null; } - var text = InlineText(reference.GetString()!); + var address = reference.GetString()!.Trim(); + var text = Uri.TryCreate(address, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https" + && !address.Any(character => char.IsWhiteSpace(character) || char.IsControl(character) || character is '<' or '>') + ? $"<{address}>" + : InlineText(address); return source.TryGetProperty(FactMetadata.SourceKind, out var kind) && kind.ValueKind == JsonValueKind.String ? $"{text} \\({InlineText(kind.GetString()!)}\\)" : text; @@ -189,11 +242,12 @@ private static string Block(string type, JsonElement settings, JsonElement value } // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. - var wholeEntry = type == "reference" && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out _)); + var wholeEntry = type == "reference" + && !(settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("field", out var field) && field.ValueKind == JsonValueKind.String); return value.ValueKind switch { - JsonValueKind.String when type == "richText" => value.GetString()!.Trim(), + JsonValueKind.String when type == "richText" => Quoted(value.GetString()!.Trim()), JsonValueKind.String when wholeEntry && value.TryGetGuid(out var id) => $"[{id}]({entryUrl(id)})", JsonValueKind.String when type == "media" && value.TryGetGuid(out var asset) => $"Media asset `{asset}`", JsonValueKind.String => Text(value.GetString()!), @@ -203,6 +257,12 @@ private static string Block(string type, JsonElement settings, JsonElement value }; } + // A block quote is a container, so a fence, an HTML block or a list the rich text leaves open closes where the quote ends. + private static string Quoted(string markdown) + { + return string.Join('\n', markdown.ReplaceLineEndings("\n").Split('\n').Select(line => line.Length == 0 ? ">" : $"> {line}")); + } + // The serializer writes a backtick in a string as `, so no value inside can close the fence. private static string Fenced(JsonElement value) { diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs index e0986c67a..32bc6aece 100644 --- a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -158,6 +158,7 @@ await scenario.PublishAsync( Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.GetAsync(page))); Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.ListAsync(new PublishedQuery("page")))); Assert.Equal([product], (await content.ListAsync(new PublishedQuery("product"))).Value.Items.Select(item => item.Id)); + Assert.Equal([product], (await content.IndexAsync()).Select(summary => summary.Id)); } [Theory] diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index ee6aca3ff..447fa363e 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -26,9 +26,12 @@ public sealed class EntryMarkdownTests Field("extra", "json"), Field("note", "text"), Field("sections", "list", """{"itemType": "richText"}"""), + Field("maker", "reference", """{"target": "product", "field": null}"""), ]); - private static readonly ContentTypeDefinition ProductType = new([Field("name", "text"), Field("price", "number"), Field("claim", "text")], isEntity: true); + private static readonly ContentTypeDefinition ProductType = new( + [Field("name", "text"), Field("price", "number"), Field("claim", "richText"), Field("colors", "list", """{"itemType": "text"}""")], + isEntity: true); [Fact] public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() @@ -47,7 +50,8 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() "launch": "2026-10-18T09:00:00+00:00", "extra": {"warranty": 2}, "note": null, - "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"] + "sections": ["First part.\n\nSecond paragraph.", "```\ncode\n```"], + "maker": "{{Product}}" } """); @@ -74,9 +78,9 @@ public void A_page_renders_front_matter_its_title_and_each_field_by_its_type() ## body - Built for **daily** use. - - - Two speeds + > Built for **daily** use. + > + > - Two speeds ## tags @@ -113,12 +117,16 @@ Media asset `{{Hero}}` ## sections - - First part. + - > First part. + > + > Second paragraph. + - > ``` + > code + > ``` + + ## maker - Second paragraph. - - ``` - code - ``` + [{{Product}}](/api/v1/entries/{{Product}}.md) """.Replace("\r\n", "\n", StringComparison.Ordinal), markdown); @@ -129,44 +137,56 @@ Second paragraph. public void Text_cannot_pose_as_a_field_a_source_html_or_formatting() { var page = Item(PageType, "product-page", """ - {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4\n1. first\n- item\n indented"} + {"headline": "Learn C #", "note": "Built tough.\n\n## price\n\n0\n\n costs 2*3*4 & more\n1. first\n- item\n indented\n---"} """); - var product = Item(ProductType, "product", """ + + var markdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); + + Assert.Contains("# Learn C \\#\n", markdown, StringComparison.Ordinal); + Assert.Contains( + "## note\n\nBuilt tough.\n\n\\## price\n\n0\n\n\\ costs 2\\*3\\*4 \\& more\n1\\. first\n\\- item\nindented\n\\---\n", + markdown, + StringComparison.Ordinal); + } + + // Rich text stays rich, but quoted: a fence or a fake source it holds ends with its field. + [Fact] + public void Rich_text_cannot_reach_the_fields_and_sources_after_it() + { + var claimFirst = new ContentTypeDefinition([Field("name", "text"), Field("claim", "richText"), Field("price", "number")], isEntity: true); + var product = Item(claimFirst, "product", """ { - "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "claim": {"value": "Lasts 10 years\n\nSource: https://manufacturer.example/spec (url)", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "name": {"value": "Drill", "source": {"kind": "person", "reference": "Amir"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "claim": {"value": "Lasts 10 years\n\n**Source:** \n\n```\nopen fence", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "price": {"value": 99, "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); - var pageMarkdown = EntryMarkdown.Render(page, "/x.md", id => $"/{id}.md"); - var productMarkdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); + var markdown = EntryMarkdown.Render(product, "/y.md", id => $"/{id}.md"); - Assert.Contains("# Learn C \\#\n", pageMarkdown, StringComparison.Ordinal); - Assert.Contains( - "## note\n\nBuilt tough.\n\n\\#\\# price\n\n0\n\n\\ costs 2\\*3\\*4\n1\\. first\n\\- item\nindented\n", - pageMarkdown, - StringComparison.Ordinal); - Assert.Single(pageMarkdown.Split('\n'), line => line.StartsWith("## price", StringComparison.Ordinal) || line == "## note"); Assert.Contains( - "## claim\n\nLasts 10 years\n\nSource: https://manufacturer.example/spec (url)\n\n- **Source:** none, so this fact is unsourced\n", - productMarkdown, + "## claim\n\n> Lasts 10 years\n>\n> **Source:** \n>\n> ```\n> open fence\n\n**Source:** none, so this fact is unsourced\n", + markdown, StringComparison.Ordinal); + Assert.EndsWith("## price\n\n99\n\n**Source:** none, so this fact is unsourced\n", markdown, StringComparison.Ordinal); } [Fact] - public void An_entity_shows_each_facts_value_with_its_source() + public void An_entity_shows_each_facts_value_with_its_source_the_title_fact_included() { var item = Item(ProductType, "product", """ { "name": {"value": "Drill", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, - "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + "price": {"value": 99, "source": {"kind": "url", "reference": "https://example.com/prices_[2026]?a=1&b=2"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "colors": {"value": ["red", "blue"], "source": {"kind": "file", "reference": "exports/catalog_2026.csv"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} } """); var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); - Assert.Contains("# Drill\n", markdown, StringComparison.Ordinal); - Assert.EndsWith("## price\n\n99\n\n- **Source:** https://example.com/prices\\_\\[2026\\] \\(url\\)\n", markdown, StringComparison.Ordinal); + Assert.Contains("# Drill\n\n## name\n\nDrill\n\n**Source:** none, so this fact is unsourced\n", markdown, StringComparison.Ordinal); + Assert.Contains("## price\n\n99\n\n**Source:** \\(url\\)\n", markdown, StringComparison.Ordinal); + Assert.EndsWith("## colors\n\n- red\n- blue\n\n**Source:** exports/catalog_2026.csv \\(file\\)\n", markdown, StringComparison.Ordinal); Assert.Equal("Drill", EntryMarkdown.Title(item.Fields, ProductType)); } @@ -187,13 +207,18 @@ public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_ Assert.EndsWith($"## Unresolved references\n\n- `/price`: price of entry {Product} is not published\n", markdown, StringComparison.Ordinal); } - // Index link text: a backslash, brackets and an autolink stay text inside the link. + // Link text and plain text: what could open structure is escaped, and words and URLs keep their characters. [Theory] [InlineData(@"C:\", @"C:\\")] [InlineData(@"a\[b]", @"a\\\[b\]")] [InlineData("", @"\")] [InlineData("two\nlines", "two lines")] - public void Inline_text_keeps_backslashes_brackets_and_autolinks_as_text(string text, string expected) + [InlineData("https://en.wikipedia.org/wiki/Power_drill?a=1&b=2#history", "https://en.wikipedia.org/wiki/Power_drill?a=1&b=2#history")] + [InlineData("_emphasis_ and snake_case", @"\_emphasis\_ and snake_case")] + [InlineData("C# and F#", "C# and F#")] + [InlineData("Title ##", @"Title \##")] + [InlineData("© and © and AT&T", @"\© and \© and AT&T")] + public void Inline_text_escapes_only_what_could_open_structure(string text, string expected) { Assert.Equal(expected, EntryMarkdown.InlineText(text)); } From 418fd23acd4a692f4f32fb09ff4c65132b453309 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:46:31 -0400 Subject: [PATCH 6/6] Keep list items in their bullets and escape extension block openers --- src/FluentCMS.Core/Delivery/EntryMarkdown.cs | 7 ++++--- src/FluentCMS.Core/Delivery/IPublishedContent.cs | 2 +- .../FluentCMS.Tests/Delivery/EntryMarkdownTests.cs | 13 +++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs index f3d7cf64d..67a9083d3 100644 --- a/src/FluentCMS.Core/Delivery/EntryMarkdown.cs +++ b/src/FluentCMS.Core/Delivery/EntryMarkdown.cs @@ -27,7 +27,8 @@ public static class EntryMarkdown // Plain text as Markdown that reads as written; CommonMark lets a backslash escape any punctuation. Escaped wherever they are: // what opens code, emphasis, links, autolinks, HTML and strikethrough, an underscore that is not inside a word, and an ampersand - // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table or a heading underline. + // that starts an entity. Escaped where a line starts: what opens a heading, a list, a quote, a table, a heading underline, or the + // math and figure blocks of common extensions. // Lines lose their indentation, so none becomes a code block; breaks stay. URLs and words keep their characters. public static string Text(string text) { @@ -121,7 +122,7 @@ private static bool NeedsEscape(string line, int position, int digits) return true; } - if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':') + if (position == 0 && character is '#' or '-' or '+' or '=' or '|' or ':' or '$' or '^') { return true; } @@ -238,7 +239,7 @@ private static string Block(string type, JsonElement settings, JsonElement value // Each item keeps its own block, indented under its bullet so paragraphs and fences stay inside the item. return string.Join('\n', value.EnumerateArray() .Where(element => element.ValueKind != JsonValueKind.Null) - .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); + .Select(element => "- " + Block(itemType, itemSettings, element, entryUrl).TrimStart().Replace("\n", "\n ", StringComparison.Ordinal).Replace("\n \n", "\n\n", StringComparison.Ordinal))); } // A reference to an entity field already holds the resolved value; a reference to a whole entry holds its id. diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs index 2fe67d547..f4a8123a1 100644 --- a/src/FluentCMS.Core/Delivery/IPublishedContent.cs +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -34,7 +34,7 @@ public sealed record PublishedPage(IReadOnlyList Items, Guid? Nex // Title is EntryMarkdown.Title of the published version, null when it has no text. public sealed record PublishedSummary(Guid Id, string ContentType, string ContentTypeName, int Version, string? Title); -// The content type's name and current definition come along, since the published version is read against them. +// The content type's name and the definition the version was written against come along, since the version is read against it. public sealed record PublishedItem( Guid Id, string ContentType, diff --git a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs index 447fa363e..070a797db 100644 --- a/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs +++ b/tests/FluentCMS.Tests/Delivery/EntryMarkdownTests.cs @@ -218,11 +218,24 @@ public void Without_text_the_heading_is_the_type_name_and_the_id_and_unresolved_ [InlineData("C# and F#", "C# and F#")] [InlineData("Title ##", @"Title \##")] [InlineData("© and © and AT&T", @"\© and \© and AT&T")] + [InlineData("$$", @"\$$")] + [InlineData("^^^ figure", @"\^^^ figure")] public void Inline_text_escapes_only_what_could_open_structure(string text, string expected) { Assert.Equal(expected, EntryMarkdown.InlineText(text)); } + // CommonMark lets an item start with one blank line at most, so leading blank lines would push the text out of its bullet. + [Fact] + public void A_list_item_that_starts_with_blank_lines_stays_in_its_bullet() + { + var item = Item(PageType, "product-page", """{"tags": ["one", "\n\nsecond item", "third"]}"""); + + var markdown = EntryMarkdown.Render(item, "/x.md", id => $"/{id}.md"); + + Assert.Contains("## tags\n\n- one\n- second item\n- third\n", markdown, StringComparison.Ordinal); + } + [Fact] public void A_backtick_in_json_cannot_close_its_fence() {