diff --git a/src/FluentCMS.Core/RichText/FormatRenderer.cs b/src/FluentCMS.Core/RichText/FormatRenderer.cs
new file mode 100644
index 000000000..5491cdf23
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/FormatRenderer.cs
@@ -0,0 +1,130 @@
+using System.Text.Json;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+internal abstract class FormatRenderer(
+ string format,
+ IReadOnlyDictionary nodeRenderers,
+ RichTextRenderOptions options)
+{
+ public abstract string Render(DocumentNode document);
+
+ protected abstract string RenderChildren(RichTextNode node);
+
+ protected string? RenderCustom(RichTextNode node, bool inline)
+ {
+ var key = node is InlineBlockNode block ? block.BlockType : node.Type;
+ if (!nodeRenderers.TryGetValue(key, out var renderer))
+ {
+ return null;
+ }
+
+ return renderer.Render(new NodeContext(format, key, node is InlineBlockNode b ? b.Data : node.Attributes, inline, () => RenderChildren(node)));
+ }
+
+ protected string? ResolveEntryUrl(EntryLinkMark mark) =>
+ SafeUrl.OrNull(options.EntryUrlResolver?.Invoke(mark.EntryType, mark.EntryId));
+
+ protected string? ResolveMediaUrl(MediaNode media) =>
+ SafeUrl.OrNull(options.MediaUrlResolver?.Invoke(media.MediaId));
+
+ protected static List SortMarks(IReadOnlyList marks) =>
+ [.. marks.OrderBy(Rank)];
+
+ protected static int CommonPrefix(List open, List marks)
+ {
+ var keep = 0;
+ while (keep < open.Count && keep < marks.Count && open[keep].SameAs(marks[keep]))
+ {
+ keep++;
+ }
+
+ return keep;
+ }
+
+ protected static string Compact(JsonElement element) => RichTextSerializer.Compact(element);
+
+ private static int Rank(RichTextMark mark) =>
+ mark switch
+ {
+ LinkMark => 0,
+ EntryLinkMark => 1,
+ BoldMark => 2,
+ ItalicMark => 3,
+ UnderlineMark => 4,
+ StrikeMark => 5,
+ _ => 6,
+ };
+
+ private sealed class NodeContext(
+ string format,
+ string nodeType,
+ IReadOnlyDictionary attributes,
+ bool isInline,
+ Func renderChildren) : IRichTextNodeContext
+ {
+ public string Format { get; } = format;
+
+ public string NodeType { get; } = nodeType;
+
+ public IReadOnlyDictionary Attributes { get; } = attributes;
+
+ public bool IsInline { get; } = isInline;
+
+ public string RenderChildren() => renderChildren();
+ }
+}
+
+internal static class SafeUrl
+{
+ private static readonly string[] allowedSchemes = ["http", "https", "mailto", "tel"];
+
+ public static string? OrNull(string? url) => url is not null && IsSafe(url) ? url : null;
+
+ public static bool IsSafe(string url)
+ {
+ var trimmed = url.Trim();
+ if (trimmed.Any(char.IsControl))
+ {
+ return false;
+ }
+
+ var end = SchemeEnd(trimmed);
+ var prefix = end < 0 ? trimmed : trimmed[..end];
+
+ // A character reference such as : can spell the colon once a consumer decodes the URL.
+ if (HasCharacterReference(prefix))
+ {
+ return false;
+ }
+
+ return end < 0 || trimmed[end] != ':' || allowedSchemes.Contains(prefix, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static int SchemeEnd(string url)
+ {
+ for (var index = 0; index < url.Length; index++)
+ {
+ if (url[index] is '/' or '?' or ':' || (url[index] == '#' && (index == 0 || url[index - 1] != '&')))
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private static bool HasCharacterReference(string text)
+ {
+ for (var index = text.IndexOf('&', StringComparison.Ordinal); index >= 0; index = text.IndexOf('&', index + 1))
+ {
+ if (index + 1 < text.Length && (text[index + 1] == '#' || char.IsAsciiLetter(text[index + 1])))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/HtmlRenderer.cs b/src/FluentCMS.Core/RichText/HtmlRenderer.cs
new file mode 100644
index 000000000..4b705c070
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/HtmlRenderer.cs
@@ -0,0 +1,239 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+internal sealed partial class HtmlRenderer(
+ IReadOnlyDictionary nodeRenderers,
+ RichTextRenderOptions options)
+ : FormatRenderer(RichTextFormats.Html, nodeRenderers, options)
+{
+ public override string Render(DocumentNode document) => Blocks(document.Content);
+
+ protected override string RenderChildren(RichTextNode node) =>
+ node.Spec.Content switch
+ {
+ ContentKind.Blocks or ContentKind.ListItems => Blocks(node.Content),
+ ContentKind.Inlines => Inlines(node.Content),
+ ContentKind.Text => Escape(((CodeBlockNode)node).Code),
+ _ => string.Empty,
+ };
+
+ private string Blocks(IReadOnlyList nodes) => string.Join('\n', nodes.Select(Block));
+
+ private string Block(RichTextNode node)
+ {
+ if (RenderCustom(node, false) is { } custom)
+ {
+ return custom;
+ }
+
+ return node switch
+ {
+ ParagraphNode paragraph => $"{Inlines(paragraph.Content)}
",
+ HeadingNode heading => $"{Inlines(heading.Content)}",
+ BulletListNode list => Wrap("", Blocks(list.Content)),
+ OrderedListNode list => Wrap(list.Start == 1 ? "" : $"", "
", Blocks(list.Content)),
+ ListItemNode item => $"- {Blocks(item.Content)}
",
+ BlockquoteNode quote => Wrap("", "
", Blocks(quote.Content)),
+ CodeBlockNode code => CodeBlock(code),
+ HorizontalRuleNode => "
",
+ MediaNode media => $"{Media(media)}",
+ InlineBlockNode block => PlaceholderBlock(block, "div"),
+ _ => string.Empty,
+ };
+ }
+
+ private static string Wrap(string open, string close, string inner) =>
+ inner.Length == 0 ? open + close : $"{open}\n{inner}\n{close}";
+
+ private static string CodeBlock(CodeBlockNode code)
+ {
+ var language = code.Language;
+ var attribute = language is not null && LanguagePattern().IsMatch(language)
+ ? $" class=\"language-{language}\""
+ : string.Empty;
+
+ return $"{Escape(code.Code)}
";
+ }
+
+ private string Inlines(IReadOnlyList nodes)
+ {
+ var builder = new StringBuilder();
+ var open = new List();
+
+ foreach (var node in nodes)
+ {
+ var marks = SortMarks(node.Marks);
+ var keep = CommonPrefix(open, marks);
+
+ for (var index = open.Count - 1; index >= keep; index--)
+ {
+ builder.Append(Close(open[index]));
+ }
+
+ open.RemoveRange(keep, open.Count - keep);
+
+ for (var index = keep; index < marks.Count; index++)
+ {
+ builder.Append(Open(marks[index]));
+ open.Add(marks[index]);
+ }
+
+ builder.Append(Inline(node));
+ }
+
+ for (var index = open.Count - 1; index >= 0; index--)
+ {
+ builder.Append(Close(open[index]));
+ }
+
+ return builder.ToString();
+ }
+
+ private string Inline(RichTextNode node)
+ {
+ if (node is not TextNode && RenderCustom(node, true) is { } custom)
+ {
+ return custom;
+ }
+
+ return node switch
+ {
+ TextNode text => Escape(text.Text),
+ HardBreakNode => "
",
+ MediaNode media => Media(media),
+ InlineBlockNode block => PlaceholderBlock(block, "span"),
+ _ => string.Empty,
+ };
+ }
+
+ private string Open(RichTextMark mark) =>
+ mark switch
+ {
+ BoldMark => "",
+ ItalicMark => "",
+ UnderlineMark => "",
+ StrikeMark => "",
+ CodeMark => "",
+ LinkMark link => OpenLink(link),
+ EntryLinkMark entry => OpenEntryLink(entry),
+ _ => string.Empty,
+ };
+
+ private static string Close(RichTextMark mark) =>
+ mark switch
+ {
+ BoldMark => "",
+ ItalicMark => "",
+ UnderlineMark => "",
+ StrikeMark => "",
+ CodeMark => "",
+ LinkMark link => SafeUrl.IsSafe(link.Href) ? "" : string.Empty,
+ EntryLinkMark => "",
+ _ => string.Empty,
+ };
+
+ private static string OpenLink(LinkMark link)
+ {
+ if (!SafeUrl.IsSafe(link.Href))
+ {
+ return string.Empty;
+ }
+
+ var tag = new StringBuilder("').ToString();
+ }
+
+ private string OpenEntryLink(EntryLinkMark entry)
+ {
+ var tag = new StringBuilder("")
+ .ToString();
+ }
+
+ private string Media(MediaNode media)
+ {
+ var tag = new StringBuilder("
").ToString();
+ }
+
+ private static string PlaceholderBlock(InlineBlockNode block, string tag)
+ {
+ var data = block.Attributes.TryGetValue("data", out var value) && value.ValueKind == System.Text.Json.JsonValueKind.Object
+ ? $" data-fcms-data=\"{EscapeAttribute(Compact(value))}\""
+ : string.Empty;
+
+ return $"<{tag} data-fcms-block=\"{EscapeAttribute(block.BlockType)}\"{data}>{tag}>";
+ }
+
+ private static string Escape(string text) => Encode(text, attribute: false);
+
+ private static string EscapeAttribute(string text) => Encode(text, attribute: true);
+
+ private static string Encode(string text, bool attribute)
+ {
+ var builder = new StringBuilder(text.Length);
+ foreach (var character in text)
+ {
+ switch (character)
+ {
+ case '&':
+ builder.Append("&");
+ break;
+ case '<':
+ builder.Append("<");
+ break;
+ case '>':
+ builder.Append(">");
+ break;
+ case '"' when attribute:
+ builder.Append(""");
+ break;
+ case '\'' when attribute:
+ builder.Append("'");
+ break;
+ case '\0':
+ builder.Append('�');
+ break;
+ default:
+ builder.Append(character);
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ [GeneratedRegex("^[A-Za-z0-9_+#.-]+$")]
+ private static partial Regex LanguagePattern();
+}
diff --git a/src/FluentCMS.Core/RichText/MarkdownRenderer.cs b/src/FluentCMS.Core/RichText/MarkdownRenderer.cs
new file mode 100644
index 000000000..ccc39fb04
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/MarkdownRenderer.cs
@@ -0,0 +1,425 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+internal sealed partial class MarkdownRenderer(
+ IReadOnlyDictionary nodeRenderers,
+ RichTextRenderOptions options)
+ : FormatRenderer(RichTextFormats.Markdown, nodeRenderers, options)
+{
+ public override string Render(DocumentNode document) => Blocks(document.Content);
+
+ protected override string RenderChildren(RichTextNode node) =>
+ node.Spec.Content switch
+ {
+ ContentKind.Blocks or ContentKind.ListItems => Blocks(node.Content),
+ ContentKind.Inlines => Inlines(node.Content, singleLine: false),
+ ContentKind.Text => ((CodeBlockNode)node).Code,
+ _ => string.Empty,
+ };
+
+ private string Blocks(IReadOnlyList nodes)
+ {
+ var parts = new List();
+ RichTextNode? previous = null;
+ var previousVariant = 0;
+
+ foreach (var node in nodes)
+ {
+ // Two lists in a row would merge into one, so the second one uses the other marker.
+ var variant = previous is not null && previous.GetType() == node.GetType() && node is BulletListNode or OrderedListNode
+ ? 1 - previousVariant
+ : 0;
+
+ var text = Block(node, variant);
+ if (text.Length > 0)
+ {
+ parts.Add(text);
+ }
+
+ previous = node;
+ previousVariant = variant;
+ }
+
+ return string.Join("\n\n", parts);
+ }
+
+ private string Block(RichTextNode node, int variant)
+ {
+ if (RenderCustom(node, false) is { } custom)
+ {
+ return custom;
+ }
+
+ return node switch
+ {
+ ParagraphNode paragraph => Inlines(paragraph.Content, singleLine: false),
+ HeadingNode heading => Heading(heading),
+ BulletListNode list => List(list, variant == 0 ? "-" : "*", _ => string.Empty),
+ OrderedListNode list => List(list, null, index => $"{list.Start + index}{(variant == 0 ? "." : ")")}"),
+ BlockquoteNode quote => Quote(Blocks(quote.Content)),
+ CodeBlockNode code => CodeBlock(code),
+ HorizontalRuleNode => "---",
+ MediaNode media => Media(media),
+ InlineBlockNode block => Placeholder(block),
+ _ => string.Empty,
+ };
+ }
+
+ private string Heading(HeadingNode heading)
+ {
+ var text = Inlines(heading.Content, singleLine: true);
+ var hashes = new string('#', heading.Level);
+ return text.Length == 0 ? hashes : $"{hashes} {text}";
+ }
+
+ private string List(RichTextNode list, string? bullet, Func ordered)
+ {
+ var items = list.Content.OfType().ToList();
+ var tight = items.All(item => item.Content.Count(child => child is not (BulletListNode or OrderedListNode)) <= 1);
+ var rendered = new List();
+
+ for (var index = 0; index < items.Count; index++)
+ {
+ var marker = bullet ?? ordered(index);
+ var body = ItemBody(items[index], tight);
+ rendered.Add(body.Length == 0 ? marker : Indent(body, marker + " ", new string(' ', marker.Length + 1)));
+ }
+
+ return string.Join(tight ? "\n" : "\n\n", rendered);
+ }
+
+ private string ItemBody(ListItemNode item, bool tight)
+ {
+ var builder = new StringBuilder();
+ RichTextNode? previous = null;
+ var bulletVariant = 0;
+ var orderedVariant = 0;
+
+ foreach (var child in item.Content)
+ {
+ var variant = child switch
+ {
+ BulletListNode when previous is BulletListNode => bulletVariant = 1 - bulletVariant,
+ OrderedListNode when previous is OrderedListNode => orderedVariant = 1 - orderedVariant,
+ _ => 0,
+ };
+
+ var text = Block(child, variant);
+ if (text.Length == 0)
+ {
+ continue;
+ }
+
+ if (builder.Length > 0)
+ {
+ builder.Append(tight && child is BulletListNode or OrderedListNode && previous is ParagraphNode ? "\n" : "\n\n");
+ }
+
+ builder.Append(text);
+ previous = child;
+ }
+
+ return builder.ToString();
+ }
+
+ private static string Indent(string text, string first, string rest)
+ {
+ var lines = text.Split('\n');
+ for (var index = 0; index < lines.Length; index++)
+ {
+ lines[index] = index == 0 ? first + lines[index] : lines[index].Length == 0 ? string.Empty : rest + lines[index];
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ private static string Quote(string text) =>
+ text.Length == 0
+ ? ">"
+ : string.Join('\n', text.Split('\n').Select(line => line.Length == 0 ? ">" : "> " + line));
+
+ private static string CodeBlock(CodeBlockNode node)
+ {
+ var code = node.Code;
+ var longest = 0;
+ var run = 0;
+ foreach (var character in code)
+ {
+ run = character == '`' ? run + 1 : 0;
+ longest = Math.Max(longest, run);
+ }
+
+ var fence = new string('`', Math.Max(3, longest + 1));
+ var language = node.Language is { } value && LanguagePattern().IsMatch(value) ? value : string.Empty;
+ return $"{fence}{language}\n{code}\n{fence}";
+ }
+
+ private string Media(MediaNode media)
+ {
+ var alt = (media.Alt ?? string.Empty).Replace('\n', ' ');
+ alt = alt.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("[", "\\[", StringComparison.Ordinal).Replace("]", "\\]", StringComparison.Ordinal);
+ var destination = ResolveMediaUrl(media)?.Trim() ?? $"fcms:media/{media.MediaId}";
+ return $"}{Title(media.Title)})";
+ }
+
+ private static string Placeholder(InlineBlockNode block) => $"";
+
+ private static string Destination(string url)
+ {
+ var builder = new StringBuilder(url.Length);
+ foreach (var character in url)
+ {
+ builder.Append(character switch
+ {
+ '&' => "&",
+ ' ' => "%20",
+ '(' => "%28",
+ ')' => "%29",
+ '<' => "%3C",
+ '>' => "%3E",
+ '\\' => "%5C",
+ _ => character.ToString(),
+ });
+ }
+
+ return builder.ToString();
+ }
+
+ private static string Title(string? title) =>
+ string.IsNullOrEmpty(title)
+ ? string.Empty
+ : " \"" + title.Replace("&", "&", StringComparison.Ordinal).Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace('\n', ' ') + "\"";
+
+ private string Inlines(IReadOnlyList nodes, bool singleLine)
+ {
+ var builder = new StringBuilder();
+ var open = new List();
+ var pending = string.Empty;
+
+ foreach (var node in nodes)
+ {
+ var marks = SortMarks(node.Marks).Where(mark => mark is not CodeMark).ToList();
+ var hasCode = node.Marks.Any(mark => mark is CodeMark);
+ var text = (node as TextNode)?.Text;
+
+ if (text is not null && text.AsSpan().Trim().IsEmpty && marks.Count > 0)
+ {
+ marks = marks.GetRange(0, Math.Min(marks.Count, CommonPrefix(open, marks)));
+ }
+
+ var keep = CommonPrefix(open, marks);
+ var closing = keep < open.Count;
+ var opening = keep < marks.Count;
+
+ var lead = string.Empty;
+ var trail = string.Empty;
+ if (text is not null && !hasCode)
+ {
+ var trimmedStart = text.TrimStart();
+ var trimmed = trimmedStart.TrimEnd();
+ if (trimmed.Length > 0)
+ {
+ lead = opening ? text[..(text.Length - trimmedStart.Length)] : string.Empty;
+ trail = marks.Count > 0 ? trimmedStart[trimmed.Length..] : string.Empty;
+ text = text[lead.Length..(text.Length - trail.Length)];
+ }
+ }
+
+ if (closing)
+ {
+ for (var index = open.Count - 1; index >= keep; index--)
+ {
+ builder.Append(Close(open[index]));
+ }
+
+ open.RemoveRange(keep, open.Count - keep);
+ }
+
+ builder.Append(EscapeText(pending, singleLine, AtLineStart(builder)));
+ pending = string.Empty;
+ builder.Append(EscapeText(lead, singleLine, AtLineStart(builder)));
+
+ for (var index = keep; index < marks.Count; index++)
+ {
+ builder.Append(Open(marks[index]));
+ open.Add(marks[index]);
+ }
+
+ builder.Append(Inline(node, text, hasCode, singleLine, builder));
+ pending = trail;
+ }
+
+ for (var index = open.Count - 1; index >= 0; index--)
+ {
+ builder.Append(Close(open[index]));
+ }
+
+ builder.Append(EscapeText(pending, singleLine, AtLineStart(builder)));
+ return builder.ToString();
+ }
+
+ private static bool AtLineStart(StringBuilder builder) => builder.Length == 0 || builder[^1] == '\n';
+
+ private string Inline(RichTextNode node, string? text, bool code, bool singleLine, StringBuilder before)
+ {
+ if (node is not TextNode && RenderCustom(node, true) is { } custom)
+ {
+ return custom;
+ }
+
+ switch (node)
+ {
+ case TextNode:
+ return code ? CodeSpan(text!) : EscapeText(text!, singleLine, AtLineStart(before));
+ case HardBreakNode:
+ return singleLine ? " " : "\\\n";
+ case MediaNode media:
+ return Media(media);
+ case InlineBlockNode block:
+ return Placeholder(block);
+ default:
+ return string.Empty;
+ }
+ }
+
+ private static string Open(RichTextMark mark) =>
+ mark switch
+ {
+ BoldMark => "**",
+ ItalicMark => "*",
+ UnderlineMark => "",
+ StrikeMark => "~~",
+ LinkMark link => SafeUrl.IsSafe(link.Href) ? "[" : string.Empty,
+ EntryLinkMark => "[",
+ _ => string.Empty,
+ };
+
+ private string Close(RichTextMark mark) =>
+ mark switch
+ {
+ BoldMark => "**",
+ ItalicMark => "*",
+ UnderlineMark => "",
+ StrikeMark => "~~",
+ LinkMark link => SafeUrl.IsSafe(link.Href) ? $"]({Destination(link.Href.Trim())}{Title(link.Title)})" : string.Empty,
+ EntryLinkMark entry => $"]({Destination(ResolveEntryUrl(entry)?.Trim() ?? $"fcms:entry/{entry.EntryType}/{entry.EntryId}")})",
+ _ => string.Empty,
+ };
+
+ private static string CodeSpan(string text)
+ {
+ var longest = 0;
+ var run = 0;
+ foreach (var character in text)
+ {
+ run = character == '`' ? run + 1 : 0;
+ longest = Math.Max(longest, run);
+ }
+
+ var fence = new string('`', longest == 0 ? 1 : longest + 1);
+ var padded = text.StartsWith('`') || text.EndsWith('`') ? $" {text} " : text;
+ return fence + padded.Replace('\n', ' ') + fence;
+ }
+
+ private static string EscapeText(string text, bool singleLine, bool atLineStart)
+ {
+ var builder = new StringBuilder(text.Length + 8);
+ var nextLineStart = atLineStart && !singleLine;
+
+ for (var index = 0; index < text.Length; index++)
+ {
+ var character = text[index];
+ var lineStart = nextLineStart;
+ nextLineStart = character == '\n' && !singleLine;
+
+ switch (character)
+ {
+ case ' ' or '\t' when lineStart:
+ // Indentation is dropped, four spaces would start a code block, and the line stays at its start.
+ nextLineStart = true;
+ break;
+ case '\\' or '`' or '*' or '_' or '[' or ']' or '<' or '~':
+ builder.Append('\\').Append(character);
+ break;
+ case '>' or '#' when lineStart:
+ builder.Append('\\').Append(character);
+ break;
+ case '+' or '-' when lineStart && IsMarkerLike(text, index):
+ builder.Append('\\').Append(character);
+ break;
+ case '=' when lineStart && IsSetextUnderline(text, index):
+ builder.Append('\\').Append(character);
+ break;
+ case '&' when StartsEntity(text, index):
+ builder.Append("\\&");
+ break;
+ case >= '0' and <= '9' when lineStart:
+ var end = index;
+ while (end < text.Length && char.IsAsciiDigit(text[end]))
+ {
+ end++;
+ }
+
+ builder.Append(text, index, end - index);
+ if (end < text.Length && text[end] is '.' or ')' && (end + 1 == text.Length || text[end + 1] is ' ' or '\n'))
+ {
+ builder.Append('\\').Append(text[end]);
+ end++;
+ }
+
+ index = end - 1;
+ break;
+ case '\n' when singleLine:
+ builder.Append(' ');
+ break;
+ case '\0':
+ builder.Append('�');
+ break;
+ default:
+ builder.Append(character);
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ private static bool IsMarkerLike(string text, int index) =>
+ index + 1 == text.Length || text[index + 1] is ' ' or '\n' || (text[index] == '-' && text[index + 1] == '-');
+
+ private static bool IsSetextUnderline(string text, int index)
+ {
+ for (var position = index; position < text.Length && text[position] != '\n'; position++)
+ {
+ if (text[position] is not ('=' or ' ' or '\t'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool StartsEntity(string text, int index)
+ {
+ var end = index + 1;
+ if (end < text.Length && text[end] == '#')
+ {
+ end++;
+ }
+
+ var start = end;
+ while (end < text.Length && char.IsAsciiLetterOrDigit(text[end]))
+ {
+ end++;
+ }
+
+ return end > start && end < text.Length && text[end] == ';';
+ }
+
+ [GeneratedRegex("^[A-Za-z0-9_+#.-]+$")]
+ private static partial Regex LanguagePattern();
+}
diff --git a/src/FluentCMS.Core/RichText/NodeSpec.cs b/src/FluentCMS.Core/RichText/NodeSpec.cs
new file mode 100644
index 000000000..474142b92
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/NodeSpec.cs
@@ -0,0 +1,46 @@
+namespace FluentCMS.Core.ProseMirror;
+
+internal enum NodePosition
+{
+ Root,
+ Block,
+ Inline,
+ BlockOrInline,
+ ListItem,
+}
+
+internal enum ContentKind
+{
+ None,
+ Blocks,
+ Inlines,
+ ListItems,
+ Text,
+}
+
+internal sealed record NodeSpec(string Type, NodePosition Position, ContentKind Content, bool AllowsMarks)
+{
+ public static readonly NodeSpec Doc = new(RichTextNodeTypes.Doc, NodePosition.Root, ContentKind.Blocks, false);
+ public static readonly NodeSpec Paragraph = new(RichTextNodeTypes.Paragraph, NodePosition.Block, ContentKind.Inlines, false);
+ public static readonly NodeSpec Heading = new(RichTextNodeTypes.Heading, NodePosition.Block, ContentKind.Inlines, false);
+ public static readonly NodeSpec BulletList = new(RichTextNodeTypes.BulletList, NodePosition.Block, ContentKind.ListItems, false);
+ public static readonly NodeSpec OrderedList = new(RichTextNodeTypes.OrderedList, NodePosition.Block, ContentKind.ListItems, false);
+ public static readonly NodeSpec ListItem = new(RichTextNodeTypes.ListItem, NodePosition.ListItem, ContentKind.Blocks, false);
+ public static readonly NodeSpec Blockquote = new(RichTextNodeTypes.Blockquote, NodePosition.Block, ContentKind.Blocks, false);
+ public static readonly NodeSpec CodeBlock = new(RichTextNodeTypes.CodeBlock, NodePosition.Block, ContentKind.Text, false);
+ public static readonly NodeSpec HorizontalRule = new(RichTextNodeTypes.HorizontalRule, NodePosition.Block, ContentKind.None, false);
+ public static readonly NodeSpec HardBreak = new(RichTextNodeTypes.HardBreak, NodePosition.Inline, ContentKind.None, true);
+ public static readonly NodeSpec Text = new(RichTextNodeTypes.Text, NodePosition.Inline, ContentKind.None, true);
+ public static readonly NodeSpec Media = new(RichTextNodeTypes.Media, NodePosition.BlockOrInline, ContentKind.None, true);
+ public static readonly NodeSpec InlineBlock = new(RichTextNodeTypes.InlineBlock, NodePosition.BlockOrInline, ContentKind.None, true);
+
+ private static readonly Dictionary byType = new[]
+ {
+ Doc, Paragraph, Heading, BulletList, OrderedList, ListItem, Blockquote, CodeBlock,
+ HorizontalRule, HardBreak, Text, Media, InlineBlock,
+ }.ToDictionary(spec => spec.Type, StringComparer.Ordinal);
+
+ public static bool TryGet(string type, out NodeSpec spec) => byType.TryGetValue(type, out spec!);
+
+ public static bool IsReserved(string name) => byType.ContainsKey(name);
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextAttributes.cs b/src/FluentCMS.Core/RichText/RichTextAttributes.cs
new file mode 100644
index 000000000..d977cfe22
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextAttributes.cs
@@ -0,0 +1,53 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace FluentCMS.Core.ProseMirror;
+
+internal static partial class RichTextAttributes
+{
+ // A list marker has at most nine digits in Markdown.
+ private const int MaxListStart = 999_999_999;
+
+ public static readonly ImmutableSortedDictionary Empty =
+ ImmutableSortedDictionary.Create(StringComparer.Ordinal);
+
+ public static ImmutableSortedDictionary Create(IEnumerable>? attributes) =>
+ attributes is null
+ ? Empty
+ : ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, attributes);
+
+ public static ImmutableSortedDictionary Create(params (string Name, JsonElement Value)[] attributes) =>
+ Create(attributes.Select(pair => KeyValuePair.Create(pair.Name, pair.Value)));
+
+ public static JsonElement Number(int value) => JsonSerializer.SerializeToElement(value);
+
+ public static JsonElement String(string? value) => JsonSerializer.SerializeToElement(value);
+
+ public static string? CheckHeadingLevel(int level) =>
+ level is >= 1 and <= 6 ? null : "The heading level must be between 1 and 6.";
+
+ public static string? CheckListStart(int start) =>
+ start is >= 0 and <= MaxListStart ? null : $"The start must be between 0 and {MaxListStart}.";
+
+ public static string? CheckBlockType(string? blockType)
+ {
+ if (blockType is null || !NamePattern().IsMatch(blockType))
+ {
+ return "The block type must start with a letter and contain only letters, digits, '-' and '_'.";
+ }
+
+ return NodeSpec.IsReserved(blockType) ? $"The block type '{blockType}' is a reserved node type name." : null;
+ }
+
+ public static string? CheckEntryType(string? entryType) =>
+ entryType is not null && EntryTypePattern().IsMatch(entryType)
+ ? null
+ : "The entry type must be a lower case kebab-case content type name.";
+
+ [GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]*$")]
+ private static partial Regex NamePattern();
+
+ [GeneratedRegex("^[a-z][a-z0-9-]*$")]
+ private static partial Regex EntryTypePattern();
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextMark.cs b/src/FluentCMS.Core/RichText/RichTextMark.cs
new file mode 100644
index 000000000..4266a1bb1
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextMark.cs
@@ -0,0 +1,169 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public abstract class RichTextMark
+{
+ private protected RichTextMark(string type, ImmutableSortedDictionary? attributes)
+ {
+ Type = type;
+ Attributes = attributes ?? RichTextAttributes.Empty;
+ }
+
+ public string Type { get; }
+
+ public IReadOnlyDictionary Attributes { get; }
+
+ internal bool SameAs(RichTextMark other)
+ {
+ if (Type != other.Type || Attributes.Count != other.Attributes.Count)
+ {
+ return false;
+ }
+
+ foreach (var (name, value) in Attributes)
+ {
+ if (!other.Attributes.TryGetValue(name, out var otherValue) || value.GetRawText() != otherValue.GetRawText())
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private protected string? OptionalString(string name) =>
+ Attributes.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+}
+
+public sealed class BoldMark : RichTextMark
+{
+ public BoldMark()
+ : base(RichTextMarkTypes.Bold, null)
+ {
+ }
+
+ internal BoldMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Bold, attributes)
+ {
+ }
+}
+
+public sealed class ItalicMark : RichTextMark
+{
+ public ItalicMark()
+ : base(RichTextMarkTypes.Italic, null)
+ {
+ }
+
+ internal ItalicMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Italic, attributes)
+ {
+ }
+}
+
+public sealed class UnderlineMark : RichTextMark
+{
+ public UnderlineMark()
+ : base(RichTextMarkTypes.Underline, null)
+ {
+ }
+
+ internal UnderlineMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Underline, attributes)
+ {
+ }
+}
+
+public sealed class StrikeMark : RichTextMark
+{
+ public StrikeMark()
+ : base(RichTextMarkTypes.Strike, null)
+ {
+ }
+
+ internal StrikeMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Strike, attributes)
+ {
+ }
+}
+
+public sealed class CodeMark : RichTextMark
+{
+ public CodeMark()
+ : base(RichTextMarkTypes.Code, null)
+ {
+ }
+
+ internal CodeMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Code, attributes)
+ {
+ }
+}
+
+public sealed class LinkMark : RichTextMark
+{
+ public LinkMark(string href, string? title = null, string? target = null)
+ : base(RichTextMarkTypes.Link, BuildAttributes(href, title, target))
+ {
+ }
+
+ internal LinkMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.Link, attributes)
+ {
+ }
+
+ public string Href => Attributes["href"].GetString()!;
+
+ public string? Title => OptionalString("title");
+
+ public string? Target => OptionalString("target");
+
+ private static ImmutableSortedDictionary BuildAttributes(string href, string? title, string? target)
+ {
+ ArgumentNullException.ThrowIfNull(href);
+
+ var attributes = new List<(string Name, JsonElement Value)> { ("href", RichTextAttributes.String(href)) };
+ if (title is not null)
+ {
+ attributes.Add(("title", RichTextAttributes.String(title)));
+ }
+
+ if (target is not null)
+ {
+ attributes.Add(("target", RichTextAttributes.String(target)));
+ }
+
+ return RichTextAttributes.Create([.. attributes]);
+ }
+}
+
+public sealed class EntryLinkMark : RichTextMark
+{
+ public EntryLinkMark(Guid entryId, string entryType)
+ : base(RichTextMarkTypes.EntryLink, BuildAttributes(entryId, entryType))
+ {
+ }
+
+ internal EntryLinkMark(ImmutableSortedDictionary attributes)
+ : base(RichTextMarkTypes.EntryLink, attributes)
+ {
+ }
+
+ public Guid EntryId => Guid.Parse(Attributes["entryId"].GetString()!);
+
+ public string EntryType => Attributes["entryType"].GetString()!;
+
+ private static ImmutableSortedDictionary BuildAttributes(Guid entryId, string entryType)
+ {
+ if (RichTextAttributes.CheckEntryType(entryType) is { } error)
+ {
+ throw new ArgumentException(error, nameof(entryType));
+ }
+
+ return RichTextAttributes.Create(
+ ("entryId", RichTextAttributes.String(entryId.ToString())),
+ ("entryType", RichTextAttributes.String(entryType)));
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextNode.cs b/src/FluentCMS.Core/RichText/RichTextNode.cs
new file mode 100644
index 000000000..e5aeb5c95
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextNode.cs
@@ -0,0 +1,293 @@
+using System.Collections.Immutable;
+using System.Text;
+using System.Text.Json;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public abstract class RichTextNode
+{
+ private readonly ImmutableArray content;
+ private readonly ImmutableArray marks;
+
+ private protected RichTextNode(
+ NodeSpec spec,
+ ImmutableSortedDictionary? attributes,
+ IEnumerable? content,
+ IEnumerable? marks)
+ {
+ Spec = spec;
+ Attributes = attributes ?? RichTextAttributes.Empty;
+ this.content = content is null ? [] : [.. content];
+ this.marks = marks is null ? [] : [.. marks];
+ }
+
+ internal NodeSpec Spec { get; }
+
+ public string Type => Spec.Type;
+
+ public IReadOnlyDictionary Attributes { get; }
+
+ public IReadOnlyList Content => content;
+
+ public IReadOnlyList Marks => marks;
+
+ private protected string? OptionalString(string name) =>
+ Attributes.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+}
+
+public sealed class DocumentNode : RichTextNode
+{
+ public DocumentNode(IEnumerable? content = null)
+ : base(NodeSpec.Doc, null, content, null)
+ {
+ }
+
+ internal DocumentNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.Doc, attributes, content, null)
+ {
+ }
+}
+
+public sealed class ParagraphNode : RichTextNode
+{
+ public ParagraphNode(IEnumerable? content = null)
+ : base(NodeSpec.Paragraph, null, content, null)
+ {
+ }
+
+ internal ParagraphNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.Paragraph, attributes, content, null)
+ {
+ }
+}
+
+public sealed class HeadingNode : RichTextNode
+{
+ public HeadingNode(int level, IEnumerable? content = null)
+ : base(NodeSpec.Heading, RichTextAttributes.Create(("level", RichTextAttributes.Number(Checked(level)))), content, null)
+ {
+ }
+
+ internal HeadingNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.Heading, attributes, content, null)
+ {
+ }
+
+ public int Level => Attributes["level"].GetInt32();
+
+ private static int Checked(int level) =>
+ RichTextAttributes.CheckHeadingLevel(level) is { } error
+ ? throw new ArgumentOutOfRangeException(nameof(level), level, error)
+ : level;
+}
+
+public sealed class BulletListNode : RichTextNode
+{
+ public BulletListNode(IEnumerable? items = null)
+ : base(NodeSpec.BulletList, null, items, null)
+ {
+ }
+
+ internal BulletListNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.BulletList, attributes, content, null)
+ {
+ }
+}
+
+public sealed class OrderedListNode : RichTextNode
+{
+ public OrderedListNode(IEnumerable? items = null, int? start = null)
+ : base(
+ NodeSpec.OrderedList,
+ start is null ? null : RichTextAttributes.Create(("start", RichTextAttributes.Number(CheckedStart(start.Value)))),
+ items,
+ null)
+ {
+ }
+
+ internal OrderedListNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.OrderedList, attributes, content, null)
+ {
+ }
+
+ public int Start => Attributes.TryGetValue("start", out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt32() : 1;
+
+ private static int CheckedStart(int start) =>
+ RichTextAttributes.CheckListStart(start) is { } error
+ ? throw new ArgumentOutOfRangeException(nameof(start), start, error)
+ : start;
+}
+
+public sealed class ListItemNode : RichTextNode
+{
+ public ListItemNode(IEnumerable? content = null)
+ : base(NodeSpec.ListItem, null, content, null)
+ {
+ }
+
+ internal ListItemNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.ListItem, attributes, content, null)
+ {
+ }
+}
+
+public sealed class BlockquoteNode : RichTextNode
+{
+ public BlockquoteNode(IEnumerable? content = null)
+ : base(NodeSpec.Blockquote, null, content, null)
+ {
+ }
+
+ internal BlockquoteNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.Blockquote, attributes, content, null)
+ {
+ }
+}
+
+public sealed class CodeBlockNode : RichTextNode
+{
+ public CodeBlockNode(string? code = null, string? language = null)
+ : base(
+ NodeSpec.CodeBlock,
+ language is null ? null : RichTextAttributes.Create(("language", RichTextAttributes.String(language))),
+ string.IsNullOrEmpty(code) ? null : [new TextNode(code)],
+ null)
+ {
+ }
+
+ internal CodeBlockNode(ImmutableSortedDictionary attributes, IEnumerable? content)
+ : base(NodeSpec.CodeBlock, attributes, content, null)
+ {
+ }
+
+ public string? Language => OptionalString("language");
+
+ public string Code
+ {
+ get
+ {
+ var builder = new StringBuilder();
+ foreach (var child in Content.OfType())
+ {
+ builder.Append(child.Text);
+ }
+
+ return builder.ToString();
+ }
+ }
+}
+
+public sealed class HorizontalRuleNode : RichTextNode
+{
+ public HorizontalRuleNode()
+ : base(NodeSpec.HorizontalRule, null, null, null)
+ {
+ }
+
+ internal HorizontalRuleNode(ImmutableSortedDictionary attributes)
+ : base(NodeSpec.HorizontalRule, attributes, null, null)
+ {
+ }
+}
+
+public sealed class HardBreakNode : RichTextNode
+{
+ public HardBreakNode(IEnumerable? marks = null)
+ : base(NodeSpec.HardBreak, null, null, marks)
+ {
+ }
+
+ internal HardBreakNode(ImmutableSortedDictionary attributes, IEnumerable? marks)
+ : base(NodeSpec.HardBreak, attributes, null, marks)
+ {
+ }
+}
+
+public sealed class TextNode : RichTextNode
+{
+ public TextNode(string text, IEnumerable? marks = null)
+ : base(NodeSpec.Text, null, null, marks)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(text);
+ Text = text;
+ }
+
+ internal TextNode(ImmutableSortedDictionary attributes, string text, IEnumerable? marks)
+ : base(NodeSpec.Text, attributes, null, marks)
+ {
+ Text = text;
+ }
+
+ public string Text { get; }
+}
+
+public sealed class MediaNode : RichTextNode
+{
+ public MediaNode(Guid mediaId, string? alt = null, string? title = null, IEnumerable? marks = null)
+ : base(NodeSpec.Media, BuildAttributes(mediaId, alt, title), null, marks)
+ {
+ }
+
+ internal MediaNode(ImmutableSortedDictionary attributes, IEnumerable? marks)
+ : base(NodeSpec.Media, attributes, null, marks)
+ {
+ }
+
+ public Guid MediaId => Guid.Parse(Attributes["mediaId"].GetString()!);
+
+ public string? Alt => OptionalString("alt");
+
+ public string? Title => OptionalString("title");
+
+ private static ImmutableSortedDictionary BuildAttributes(Guid mediaId, string? alt, string? title)
+ {
+ var attributes = new List<(string Name, JsonElement Value)> { ("mediaId", RichTextAttributes.String(mediaId.ToString())) };
+ if (alt is not null)
+ {
+ attributes.Add(("alt", RichTextAttributes.String(alt)));
+ }
+
+ if (title is not null)
+ {
+ attributes.Add(("title", RichTextAttributes.String(title)));
+ }
+
+ return RichTextAttributes.Create([.. attributes]);
+ }
+}
+
+public sealed class InlineBlockNode : RichTextNode
+{
+ public InlineBlockNode(string blockType, IReadOnlyDictionary? data = null, IEnumerable? marks = null)
+ : base(NodeSpec.InlineBlock, BuildAttributes(blockType, data), null, marks)
+ {
+ }
+
+ internal InlineBlockNode(ImmutableSortedDictionary attributes, IEnumerable? marks)
+ : base(NodeSpec.InlineBlock, attributes, null, marks)
+ {
+ }
+
+ public string BlockType => Attributes["blockType"].GetString()!;
+
+ public IReadOnlyDictionary Data =>
+ Attributes.TryGetValue("data", out var data) && data.ValueKind == JsonValueKind.Object
+ ? data.EnumerateObject().ToDictionary(property => property.Name, property => property.Value, StringComparer.Ordinal)
+ : ImmutableDictionary.Empty;
+
+ private static ImmutableSortedDictionary BuildAttributes(string blockType, IReadOnlyDictionary? data)
+ {
+ if (RichTextAttributes.CheckBlockType(blockType) is { } error)
+ {
+ throw new ArgumentException(error, nameof(blockType));
+ }
+
+ var attributes = new List<(string Name, JsonElement Value)> { ("blockType", RichTextAttributes.String(blockType)) };
+ if (data is not null)
+ {
+ attributes.Add(("data", JsonSerializer.SerializeToElement(data)));
+ }
+
+ return RichTextAttributes.Create([.. attributes]);
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextNodeTypes.cs b/src/FluentCMS.Core/RichText/RichTextNodeTypes.cs
new file mode 100644
index 000000000..ee248849d
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextNodeTypes.cs
@@ -0,0 +1,29 @@
+namespace FluentCMS.Core.ProseMirror;
+
+public static class RichTextNodeTypes
+{
+ public const string Doc = "doc";
+ public const string Paragraph = "paragraph";
+ public const string Heading = "heading";
+ public const string BulletList = "bulletList";
+ public const string OrderedList = "orderedList";
+ public const string ListItem = "listItem";
+ public const string Blockquote = "blockquote";
+ public const string CodeBlock = "codeBlock";
+ public const string HorizontalRule = "horizontalRule";
+ public const string HardBreak = "hardBreak";
+ public const string Text = "text";
+ public const string Media = "media";
+ public const string InlineBlock = "inlineBlock";
+}
+
+public static class RichTextMarkTypes
+{
+ public const string Bold = "bold";
+ public const string Italic = "italic";
+ public const string Underline = "underline";
+ public const string Strike = "strike";
+ public const string Code = "code";
+ public const string Link = "link";
+ public const string EntryLink = "entryLink";
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextParser.cs b/src/FluentCMS.Core/RichText/RichTextParser.cs
new file mode 100644
index 000000000..d859c93a1
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextParser.cs
@@ -0,0 +1,447 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public static class RichTextParser
+{
+ private static readonly JsonDocumentOptions options = new() { MaxDepth = 128, AllowDuplicateProperties = false };
+
+ public static DocumentNode Parse(RichText value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return Parse(value.Json);
+ }
+
+ public static DocumentNode Parse(string json) =>
+ TryParse(json, out var document, out var errors) ? document : throw new RichTextValidationException(errors);
+
+ public static bool TryParse(RichText value, [NotNullWhen(true)] out DocumentNode? document, out IReadOnlyList errors)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return TryParse(value.Json, out document, out errors);
+ }
+
+ public static bool TryParse(string json, [NotNullWhen(true)] out DocumentNode? document, out IReadOnlyList errors)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+
+ var found = new List();
+ document = null;
+
+ try
+ {
+ using var parsed = JsonDocument.Parse(json, options);
+ var node = new Reader(found).ReadNode(parsed.RootElement, "$");
+
+ if (node is DocumentNode root)
+ {
+ found.AddRange(RichTextValidator.Validate(root));
+ if (found.Count == 0)
+ {
+ document = root;
+ }
+ }
+ else if (node is not null)
+ {
+ found.Add(new RichTextValidationError("$", $"The root node must be of type '{RichTextNodeTypes.Doc}', not '{node.Type}'."));
+ }
+ }
+ catch (JsonException exception)
+ {
+ found.Add(new RichTextValidationError("$", $"The document is not valid JSON: {exception.Message}"));
+ }
+
+ errors = found;
+ return document is not null;
+ }
+
+ private sealed class Reader(List errors)
+ {
+ public RichTextNode? ReadNode(JsonElement element, string path)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ Add(path, "Expected an object.");
+ return null;
+ }
+
+ var before = errors.Count;
+ string? type = null;
+ JsonElement? attrs = null, content = null, marks = null, text = null;
+
+ foreach (var property in element.EnumerateObject())
+ {
+ switch (property.Name)
+ {
+ case "type":
+ type = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+ if (type is null)
+ {
+ Add($"{path}.type", "The type must be a string.");
+ }
+
+ break;
+ case "attrs":
+ attrs = property.Value;
+ break;
+ case "content":
+ content = property.Value;
+ break;
+ case "marks":
+ marks = property.Value;
+ break;
+ case "text":
+ text = property.Value;
+ break;
+ default:
+ Add(path, $"Unknown property '{property.Name}'.");
+ break;
+ }
+ }
+
+ if (type is null)
+ {
+ if (errors.Count == before)
+ {
+ Add(path, "The node has no type.");
+ }
+
+ return null;
+ }
+
+ if (!NodeSpec.TryGet(type, out var spec))
+ {
+ Add(path, $"Unknown node type '{type}'.");
+ return null;
+ }
+
+ var attributes = attrs is null ? RichTextAttributes.Empty : ReadAttributes(attrs.Value, $"{path}.attrs");
+ if (attributes is not null)
+ {
+ CheckNodeAttributes(spec, attributes, $"{path}.attrs");
+ }
+
+ var children = ReadChildren(spec, content, path);
+ var nodeMarks = ReadMarks(spec, marks, path);
+ var textValue = ReadText(spec, text, path);
+
+ if (errors.Count > before || attributes is null || children is null || nodeMarks is null)
+ {
+ return null;
+ }
+
+ return Create(spec, attributes, children, nodeMarks, textValue);
+ }
+
+ private List? ReadChildren(NodeSpec spec, JsonElement? content, string path)
+ {
+ var children = new List();
+ if (content is null)
+ {
+ return children;
+ }
+
+ if (content.Value.ValueKind != JsonValueKind.Array)
+ {
+ Add($"{path}.content", "The content must be an array.");
+ return null;
+ }
+
+ if (content.Value.GetArrayLength() > 0 && spec.Content == ContentKind.None)
+ {
+ Add($"{path}.content", $"A '{spec.Type}' node cannot have content.");
+ return null;
+ }
+
+ var ok = true;
+ var index = 0;
+ foreach (var item in content.Value.EnumerateArray())
+ {
+ var child = ReadNode(item, $"{path}.content[{index++}]");
+ if (child is null)
+ {
+ ok = false;
+ }
+ else
+ {
+ children.Add(child);
+ }
+ }
+
+ return ok ? children : null;
+ }
+
+ private List? ReadMarks(NodeSpec spec, JsonElement? marks, string path)
+ {
+ var result = new List();
+ if (marks is null)
+ {
+ return result;
+ }
+
+ if (marks.Value.ValueKind != JsonValueKind.Array)
+ {
+ Add($"{path}.marks", "The marks must be an array.");
+ return null;
+ }
+
+ if (marks.Value.GetArrayLength() > 0 && !spec.AllowsMarks)
+ {
+ Add($"{path}.marks", $"A '{spec.Type}' node cannot have marks.");
+ return null;
+ }
+
+ var ok = true;
+ var index = 0;
+ foreach (var item in marks.Value.EnumerateArray())
+ {
+ var mark = ReadMark(item, $"{path}.marks[{index++}]");
+ if (mark is null)
+ {
+ ok = false;
+ }
+ else
+ {
+ result.Add(mark);
+ }
+ }
+
+ return ok ? result : null;
+ }
+
+ private string? ReadText(NodeSpec spec, JsonElement? text, string path)
+ {
+ if (spec != NodeSpec.Text)
+ {
+ if (text is not null)
+ {
+ Add($"{path}.text", $"A '{spec.Type}' node cannot have text.");
+ }
+
+ return null;
+ }
+
+ if (text is null || text.Value.ValueKind != JsonValueKind.String || text.Value.GetString() is not { Length: > 0 } value)
+ {
+ Add($"{path}.text", "A text node needs a non-empty string.");
+ return null;
+ }
+
+ return value;
+ }
+
+ private RichTextMark? ReadMark(JsonElement element, string path)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ Add(path, "Expected an object.");
+ return null;
+ }
+
+ var before = errors.Count;
+ string? type = null;
+ JsonElement? attrs = null;
+
+ foreach (var property in element.EnumerateObject())
+ {
+ switch (property.Name)
+ {
+ case "type":
+ type = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+ if (type is null)
+ {
+ Add($"{path}.type", "The type must be a string.");
+ }
+
+ break;
+ case "attrs":
+ attrs = property.Value;
+ break;
+ default:
+ Add(path, $"Unknown property '{property.Name}'.");
+ break;
+ }
+ }
+
+ if (type is null)
+ {
+ if (errors.Count == before)
+ {
+ Add(path, "The mark has no type.");
+ }
+
+ return null;
+ }
+
+ if (type is not (RichTextMarkTypes.Bold or RichTextMarkTypes.Italic or RichTextMarkTypes.Underline
+ or RichTextMarkTypes.Strike or RichTextMarkTypes.Code or RichTextMarkTypes.Link or RichTextMarkTypes.EntryLink))
+ {
+ Add(path, $"Unknown mark type '{type}'.");
+ return null;
+ }
+
+ var attributes = attrs is null ? RichTextAttributes.Empty : ReadAttributes(attrs.Value, $"{path}.attrs");
+ if (attributes is not null)
+ {
+ CheckMarkAttributes(type, attributes, $"{path}.attrs");
+ }
+
+ if (errors.Count > before || attributes is null)
+ {
+ return null;
+ }
+
+ return type switch
+ {
+ RichTextMarkTypes.Bold => new BoldMark(attributes),
+ RichTextMarkTypes.Italic => new ItalicMark(attributes),
+ RichTextMarkTypes.Underline => new UnderlineMark(attributes),
+ RichTextMarkTypes.Strike => new StrikeMark(attributes),
+ RichTextMarkTypes.Code => new CodeMark(attributes),
+ RichTextMarkTypes.Link => new LinkMark(attributes),
+ _ => new EntryLinkMark(attributes),
+ };
+ }
+
+ private ImmutableSortedDictionary? ReadAttributes(JsonElement element, string path)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ Add(path, "The attributes must be an object.");
+ return null;
+ }
+
+ return RichTextAttributes.Create(element.EnumerateObject().Select(property =>
+ KeyValuePair.Create(property.Name, property.Value.Clone())));
+ }
+
+ private void CheckNodeAttributes(NodeSpec spec, ImmutableSortedDictionary attributes, string path)
+ {
+ switch (spec.Type)
+ {
+ case RichTextNodeTypes.Heading:
+ if (!attributes.TryGetValue("level", out var level)
+ || level.ValueKind != JsonValueKind.Number
+ || !level.TryGetInt32(out var levelValue)
+ || RichTextAttributes.CheckHeadingLevel(levelValue) is not null)
+ {
+ Add($"{path}.level", "The heading level must be an integer between 1 and 6.");
+ }
+
+ break;
+ case RichTextNodeTypes.OrderedList:
+ if (attributes.TryGetValue("start", out var start) && start.ValueKind != JsonValueKind.Null)
+ {
+ if (start.ValueKind != JsonValueKind.Number || !start.TryGetInt32(out var startValue))
+ {
+ Add($"{path}.start", "The start must be an integer.");
+ }
+ else if (RichTextAttributes.CheckListStart(startValue) is { } startError)
+ {
+ Add($"{path}.start", startError);
+ }
+ }
+
+ break;
+ case RichTextNodeTypes.CodeBlock:
+ CheckOptionalString(attributes, "language", path);
+ break;
+ case RichTextNodeTypes.Media:
+ CheckRequiredGuid(attributes, "mediaId", path);
+ CheckOptionalString(attributes, "alt", path);
+ CheckOptionalString(attributes, "title", path);
+ break;
+ case RichTextNodeTypes.InlineBlock:
+ if (!attributes.TryGetValue("blockType", out var blockType) || blockType.ValueKind != JsonValueKind.String)
+ {
+ Add($"{path}.blockType", "The block type must be a string.");
+ }
+ else if (RichTextAttributes.CheckBlockType(blockType.GetString()) is { } error)
+ {
+ Add($"{path}.blockType", error);
+ }
+
+ if (attributes.TryGetValue("data", out var data) && data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Null))
+ {
+ Add($"{path}.data", "The block data must be an object.");
+ }
+
+ break;
+ }
+ }
+
+ private void CheckMarkAttributes(string type, ImmutableSortedDictionary attributes, string path)
+ {
+ switch (type)
+ {
+ case RichTextMarkTypes.Link:
+ if (!attributes.TryGetValue("href", out var href) || href.ValueKind != JsonValueKind.String)
+ {
+ Add($"{path}.href", "The href must be a string.");
+ }
+
+ CheckOptionalString(attributes, "title", path);
+ CheckOptionalString(attributes, "target", path);
+ break;
+ case RichTextMarkTypes.EntryLink:
+ CheckRequiredGuid(attributes, "entryId", path);
+ if (!attributes.TryGetValue("entryType", out var entryType) || entryType.ValueKind != JsonValueKind.String)
+ {
+ Add($"{path}.entryType", "The entry type must be a string.");
+ }
+ else if (RichTextAttributes.CheckEntryType(entryType.GetString()) is { } error)
+ {
+ Add($"{path}.entryType", error);
+ }
+
+ break;
+ }
+ }
+
+ private void CheckOptionalString(ImmutableSortedDictionary attributes, string name, string path)
+ {
+ if (attributes.TryGetValue(name, out var value) && value.ValueKind is not (JsonValueKind.String or JsonValueKind.Null))
+ {
+ Add($"{path}.{name}", $"The {name} must be a string.");
+ }
+ }
+
+ private void CheckRequiredGuid(ImmutableSortedDictionary attributes, string name, string path)
+ {
+ if (!attributes.TryGetValue(name, out var value)
+ || value.ValueKind != JsonValueKind.String
+ || !Guid.TryParseExact(value.GetString(), "D", out _))
+ {
+ Add($"{path}.{name}", $"The {name} must be a GUID.");
+ }
+ }
+
+ private void Add(string path, string message) => errors.Add(new RichTextValidationError(path, message));
+
+ private static RichTextNode Create(
+ NodeSpec spec,
+ ImmutableSortedDictionary attributes,
+ List children,
+ List marks,
+ string? text) =>
+ spec.Type switch
+ {
+ RichTextNodeTypes.Doc => new DocumentNode(attributes, children),
+ RichTextNodeTypes.Paragraph => new ParagraphNode(attributes, children),
+ RichTextNodeTypes.Heading => new HeadingNode(attributes, children),
+ RichTextNodeTypes.BulletList => new BulletListNode(attributes, children),
+ RichTextNodeTypes.OrderedList => new OrderedListNode(attributes, children),
+ RichTextNodeTypes.ListItem => new ListItemNode(attributes, children),
+ RichTextNodeTypes.Blockquote => new BlockquoteNode(attributes, children),
+ RichTextNodeTypes.CodeBlock => new CodeBlockNode(attributes, children),
+ RichTextNodeTypes.HorizontalRule => new HorizontalRuleNode(attributes),
+ RichTextNodeTypes.HardBreak => new HardBreakNode(attributes, marks),
+ RichTextNodeTypes.Text => new TextNode(attributes, text!, marks),
+ RichTextNodeTypes.Media => new MediaNode(attributes, marks),
+ _ => new InlineBlockNode(attributes, marks),
+ };
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextReferences.cs b/src/FluentCMS.Core/RichText/RichTextReferences.cs
new file mode 100644
index 000000000..59076a57d
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextReferences.cs
@@ -0,0 +1,51 @@
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public sealed record RichTextEntryReference(Guid Id, string Type, string Path);
+
+public sealed record RichTextMediaReference(Guid Id, string Path);
+
+public sealed record RichTextReferences(
+ IReadOnlyList Entries,
+ IReadOnlyList Media);
+
+public static class RichTextReferenceExtractor
+{
+ public static RichTextReferences Extract(RichText value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return Extract(RichTextParser.Parse(value));
+ }
+
+ public static RichTextReferences Extract(DocumentNode document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var entries = new List();
+ var media = new List();
+ Visit(document, "$", entries, media);
+ return new RichTextReferences(entries, media);
+ }
+
+ private static void Visit(RichTextNode node, string path, List entries, List media)
+ {
+ if (node is MediaNode mediaNode)
+ {
+ media.Add(new RichTextMediaReference(mediaNode.MediaId, path));
+ }
+
+ for (var index = 0; index < node.Marks.Count; index++)
+ {
+ if (node.Marks[index] is EntryLinkMark link)
+ {
+ entries.Add(new RichTextEntryReference(link.EntryId, link.EntryType, $"{path}.marks[{index}]"));
+ }
+ }
+
+ for (var index = 0; index < node.Content.Count; index++)
+ {
+ Visit(node.Content[index], $"{path}.content[{index}]", entries, media);
+ }
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextRenderer.cs b/src/FluentCMS.Core/RichText/RichTextRenderer.cs
new file mode 100644
index 000000000..9a095343e
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextRenderer.cs
@@ -0,0 +1,76 @@
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public sealed class RichTextRenderer : IRichTextRenderer
+{
+ private static readonly string[] formats = [RichTextFormats.Html, RichTextFormats.Markdown];
+
+ private readonly Dictionary nodeRenderers = new(StringComparer.Ordinal);
+
+ public RichTextRenderer(IEnumerable? nodeRenderers = null)
+ {
+ foreach (var renderer in nodeRenderers ?? [])
+ {
+ Register(renderer);
+ }
+ }
+
+ public IReadOnlyCollection Formats => formats;
+
+ public string Render(RichText value, string format, RichTextRenderOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ var normalized = Normalize(format);
+ return Create(normalized, options).Render(RichTextParser.Parse(value));
+ }
+
+ public string Render(DocumentNode document, string format, RichTextRenderOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ var normalized = Normalize(format);
+
+ var errors = RichTextValidator.Validate(document);
+ if (errors.Count > 0)
+ {
+ throw new RichTextValidationException(errors);
+ }
+
+ return Create(normalized, options).Render(document);
+ }
+
+ private void Register(IRichTextNodeRenderer renderer)
+ {
+ var nodeType = renderer.NodeType;
+ if (string.IsNullOrWhiteSpace(nodeType))
+ {
+ throw new ArgumentException($"{renderer.GetType().Name} has no node type.", nameof(renderer));
+ }
+
+ if (nodeType is RichTextNodeTypes.Doc or RichTextNodeTypes.Text or RichTextNodeTypes.InlineBlock)
+ {
+ throw new ArgumentException($"A renderer cannot be registered for '{nodeType}'; register it for a block type name or another node type.", nameof(renderer));
+ }
+
+ if (!nodeRenderers.TryAdd(nodeType, renderer))
+ {
+ throw new ArgumentException($"More than one renderer is registered for '{nodeType}'.", nameof(renderer));
+ }
+ }
+
+ private FormatRenderer Create(string format, RichTextRenderOptions? options)
+ {
+ options ??= new RichTextRenderOptions();
+ return format == RichTextFormats.Html
+ ? new HtmlRenderer(nodeRenderers, options)
+ : new MarkdownRenderer(nodeRenderers, options);
+ }
+
+ private static string Normalize(string format)
+ {
+ ArgumentNullException.ThrowIfNull(format);
+
+ return formats.FirstOrDefault(known => string.Equals(known, format, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ArgumentException($"The format '{format}' is not supported. Supported formats: {string.Join(", ", formats)}.", nameof(format));
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextSerializer.cs b/src/FluentCMS.Core/RichText/RichTextSerializer.cs
new file mode 100644
index 000000000..437bb1cc7
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextSerializer.cs
@@ -0,0 +1,98 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public static class RichTextSerializer
+{
+ private static readonly JsonWriterOptions options = new()
+ {
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ Indented = false,
+ };
+
+ public static string Serialize(DocumentNode document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var buffer = new ArrayBufferWriter();
+ using (var writer = new Utf8JsonWriter(buffer, options))
+ {
+ WriteNode(writer, document);
+ }
+
+ return Encoding.UTF8.GetString(buffer.WrittenSpan);
+ }
+
+ public static RichText ToRichText(DocumentNode document) => new(Serialize(document));
+
+ internal static string Compact(JsonElement element)
+ {
+ var buffer = new ArrayBufferWriter();
+ using (var writer = new Utf8JsonWriter(buffer, options))
+ {
+ element.WriteTo(writer);
+ }
+
+ return Encoding.UTF8.GetString(buffer.WrittenSpan);
+ }
+
+ private static void WriteNode(Utf8JsonWriter writer, RichTextNode node)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", node.Type);
+ WriteAttributes(writer, node.Attributes);
+
+ if (node.Content.Count > 0)
+ {
+ writer.WriteStartArray("content");
+ foreach (var child in node.Content)
+ {
+ WriteNode(writer, child);
+ }
+
+ writer.WriteEndArray();
+ }
+
+ if (node.Marks.Count > 0)
+ {
+ writer.WriteStartArray("marks");
+ foreach (var mark in node.Marks)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", mark.Type);
+ WriteAttributes(writer, mark.Attributes);
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
+ }
+
+ if (node is TextNode text)
+ {
+ writer.WriteString("text", text.Text);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ private static void WriteAttributes(Utf8JsonWriter writer, IReadOnlyDictionary attributes)
+ {
+ if (attributes.Count == 0)
+ {
+ return;
+ }
+
+ writer.WriteStartObject("attrs");
+ foreach (var (name, value) in attributes)
+ {
+ writer.WritePropertyName(name);
+ value.WriteTo(writer);
+ }
+
+ writer.WriteEndObject();
+ }
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextValidationError.cs b/src/FluentCMS.Core/RichText/RichTextValidationError.cs
new file mode 100644
index 000000000..bb6a54a23
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextValidationError.cs
@@ -0,0 +1,22 @@
+namespace FluentCMS.Core.ProseMirror;
+
+public sealed record RichTextValidationError(string Path, string Message)
+{
+ public override string ToString() => $"{Path}: {Message}";
+}
+
+public sealed class RichTextValidationException : FormatException
+{
+ public RichTextValidationException(IReadOnlyList errors)
+ : base(Describe(errors))
+ {
+ Errors = errors;
+ }
+
+ public IReadOnlyList Errors { get; }
+
+ private static string Describe(IReadOnlyList errors) =>
+ errors.Count == 0
+ ? "The rich text document is not valid."
+ : "The rich text document is not valid. " + string.Join(' ', errors.Select(error => error.ToString()));
+}
diff --git a/src/FluentCMS.Core/RichText/RichTextValidator.cs b/src/FluentCMS.Core/RichText/RichTextValidator.cs
new file mode 100644
index 000000000..737d15ece
--- /dev/null
+++ b/src/FluentCMS.Core/RichText/RichTextValidator.cs
@@ -0,0 +1,72 @@
+using FluentCMS.Schema;
+
+namespace FluentCMS.Core.ProseMirror;
+
+public static class RichTextValidator
+{
+ public static IReadOnlyList Validate(RichText value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return RichTextParser.TryParse(value, out _, out var errors) ? [] : errors;
+ }
+
+ public static IReadOnlyList Validate(DocumentNode document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var errors = new List();
+ ValidateChildren(document, "$", errors);
+ return errors;
+ }
+
+ private static void ValidateChildren(RichTextNode parent, string path, List errors)
+ {
+ for (var index = 0; index < parent.Content.Count; index++)
+ {
+ var child = parent.Content[index];
+ var childPath = $"{path}.content[{index}]";
+
+ if (!IsAllowedIn(parent.Spec.Content, child))
+ {
+ errors.Add(new RichTextValidationError(childPath, $"A '{child.Type}' node is not allowed inside a '{parent.Type}' node."));
+ continue;
+ }
+
+ ValidateMarks(child, parent.Spec.Content, childPath, errors);
+ ValidateChildren(child, childPath, errors);
+ }
+ }
+
+ private static bool IsAllowedIn(ContentKind content, RichTextNode child) =>
+ content switch
+ {
+ ContentKind.Blocks => child.Spec.Position is NodePosition.Block or NodePosition.BlockOrInline,
+ ContentKind.Inlines => child.Spec.Position is NodePosition.Inline or NodePosition.BlockOrInline,
+ ContentKind.ListItems => child.Spec.Position == NodePosition.ListItem,
+ ContentKind.Text => child.Spec == NodeSpec.Text,
+ _ => false,
+ };
+
+ private static void ValidateMarks(RichTextNode node, ContentKind parentContent, string path, List errors)
+ {
+ if (node.Marks.Count == 0)
+ {
+ return;
+ }
+
+ if (parentContent != ContentKind.Inlines)
+ {
+ errors.Add(new RichTextValidationError($"{path}.marks", $"A '{node.Type}' node cannot have marks here; marks apply to inline content only."));
+ return;
+ }
+
+ var seen = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < node.Marks.Count; index++)
+ {
+ if (!seen.Add(node.Marks[index].Type))
+ {
+ errors.Add(new RichTextValidationError($"{path}.marks[{index}]", $"The '{node.Marks[index].Type}' mark appears more than once."));
+ }
+ }
+ }
+}
diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt
index f3c4630c8..826d5933d 100644
--- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt
+++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt
@@ -196,6 +196,18 @@ FluentCMS.Schema.IFieldBuilder.Searchable(bool searchable = true) -> FluentCMS.S
FluentCMS.Schema.IFieldBuilder.Setting(string! name, object! value) -> FluentCMS.Schema.IFieldBuilder!
FluentCMS.Schema.IFieldBuilder.Unique(bool unique = true) -> FluentCMS.Schema.IFieldBuilder!
FluentCMS.Schema.IFieldBuilder.VisibleWhen(FluentCMS.Schema.Condition! condition) -> FluentCMS.Schema.IFieldBuilder!
+FluentCMS.Schema.IRichTextNodeContext
+FluentCMS.Schema.IRichTextNodeContext.Attributes.get -> System.Collections.Generic.IReadOnlyDictionary!
+FluentCMS.Schema.IRichTextNodeContext.Format.get -> string!
+FluentCMS.Schema.IRichTextNodeContext.IsInline.get -> bool
+FluentCMS.Schema.IRichTextNodeContext.NodeType.get -> string!
+FluentCMS.Schema.IRichTextNodeContext.RenderChildren() -> string!
+FluentCMS.Schema.IRichTextNodeRenderer
+FluentCMS.Schema.IRichTextNodeRenderer.NodeType.get -> string!
+FluentCMS.Schema.IRichTextNodeRenderer.Render(FluentCMS.Schema.IRichTextNodeContext! context) -> string?
+FluentCMS.Schema.IRichTextRenderer
+FluentCMS.Schema.IRichTextRenderer.Formats.get -> System.Collections.Generic.IReadOnlyCollection!
+FluentCMS.Schema.IRichTextRenderer.Render(FluentCMS.Schema.RichText! value, string! format, FluentCMS.Schema.RichTextRenderOptions? options = null) -> string!
FluentCMS.Schema.ISchemaBuilder
FluentCMS.Schema.ISchemaBuilder.AddBlock(string! name, System.Action! configure) -> FluentCMS.Schema.ISchemaBuilder!
FluentCMS.Schema.ISchemaBuilder.AddCollection(string! name, System.Action! configure) -> FluentCMS.Schema.ISchemaBuilder!
@@ -273,6 +285,13 @@ FluentCMS.Schema.RichText.Json.get -> string!
FluentCMS.Schema.RichText.RichText(string! json) -> void
FluentCMS.Schema.RichTextAttribute
FluentCMS.Schema.RichTextAttribute.RichTextAttribute() -> void
+FluentCMS.Schema.RichTextFormats
+FluentCMS.Schema.RichTextRenderOptions
+FluentCMS.Schema.RichTextRenderOptions.EntryUrlResolver.get -> System.Func?
+FluentCMS.Schema.RichTextRenderOptions.EntryUrlResolver.init -> void
+FluentCMS.Schema.RichTextRenderOptions.MediaUrlResolver.get -> System.Func?
+FluentCMS.Schema.RichTextRenderOptions.MediaUrlResolver.init -> void
+FluentCMS.Schema.RichTextRenderOptions.RichTextRenderOptions() -> void
FluentCMS.Schema.SchemaField
FluentCMS.Schema.SchemaField.DefaultValue.get -> object?
FluentCMS.Schema.SchemaField.Display.get -> FluentCMS.Schema.FieldDisplay?
@@ -356,6 +375,8 @@ const FluentCMS.Schema.FieldSettings.Options = "options" -> string!
const FluentCMS.Schema.FieldSettings.Precision = "precision" -> string!
const FluentCMS.Schema.FieldSettings.Scale = "scale" -> string!
const FluentCMS.Schema.FieldSettings.Source = "source" -> string!
+const FluentCMS.Schema.RichTextFormats.Html = "html" -> string!
+const FluentCMS.Schema.RichTextFormats.Markdown = "markdown" -> string!
override FluentCMS.Content.OperationContext.Equals(object? obj) -> bool
override FluentCMS.Content.OperationContext.GetHashCode() -> int
override FluentCMS.Content.OperationContext.ToString() -> string!
diff --git a/src/FluentCMS.Sdk/Schema/IRichTextNodeContext.cs b/src/FluentCMS.Sdk/Schema/IRichTextNodeContext.cs
new file mode 100644
index 000000000..105741399
--- /dev/null
+++ b/src/FluentCMS.Sdk/Schema/IRichTextNodeContext.cs
@@ -0,0 +1,23 @@
+using System.Text.Json;
+
+namespace FluentCMS.Schema;
+
+/// What an is given about the node it renders.
+public interface IRichTextNodeContext
+{
+ /// The format being rendered, see .
+ string Format { get; }
+
+ /// The node type, or the block type name for a typed inline block.
+ string NodeType { get; }
+
+ /// The node attributes, or the block data for a typed inline block.
+ IReadOnlyDictionary Attributes { get; }
+
+ /// Whether the node sits inside inline content, such as a paragraph, rather than between blocks.
+ bool IsInline { get; }
+
+ /// Renders the content of the node in the current format.
+ /// The rendered content, or an empty string for a node without content.
+ string RenderChildren();
+}
diff --git a/src/FluentCMS.Sdk/Schema/IRichTextNodeRenderer.cs b/src/FluentCMS.Sdk/Schema/IRichTextNodeRenderer.cs
new file mode 100644
index 000000000..eef8e08df
--- /dev/null
+++ b/src/FluentCMS.Sdk/Schema/IRichTextNodeRenderer.cs
@@ -0,0 +1,20 @@
+namespace FluentCMS.Schema;
+
+///
+/// Renders one node type, which lets a plugin add typed inline blocks. A typed inline block is
+/// matched by its block type name, any other node by its node type, so a renderer registered for
+/// a built in node type replaces the built in output.
+///
+public interface IRichTextNodeRenderer
+{
+ /// The node type, or the block type name of a typed inline block, this renderer handles.
+ string NodeType { get; }
+
+ /// Renders the node.
+ /// The node and the format.
+ ///
+ /// The output, inserted as is, so a renderer for HTML encodes its own text. Return
+ /// to use the built in output, for example for a format it does not support.
+ ///
+ string? Render(IRichTextNodeContext context);
+}
diff --git a/src/FluentCMS.Sdk/Schema/IRichTextRenderer.cs b/src/FluentCMS.Sdk/Schema/IRichTextRenderer.cs
new file mode 100644
index 000000000..f63b0520c
--- /dev/null
+++ b/src/FluentCMS.Sdk/Schema/IRichTextRenderer.cs
@@ -0,0 +1,17 @@
+namespace FluentCMS.Schema;
+
+/// Renders a value into an output format.
+public interface IRichTextRenderer
+{
+ /// The formats this renderer supports, see .
+ IReadOnlyCollection Formats { get; }
+
+ /// Renders a rich text value.
+ /// The value to render.
+ /// One of , compared without regard to case.
+ /// How entry links and media embeds are resolved, or to render placeholders.
+ /// The rendered document.
+ /// The format is not supported.
+ /// The value is not a valid rich text document.
+ string Render(RichText value, string format, RichTextRenderOptions? options = null);
+}
diff --git a/src/FluentCMS.Sdk/Schema/RichTextFormats.cs b/src/FluentCMS.Sdk/Schema/RichTextFormats.cs
new file mode 100644
index 000000000..9c5a79dff
--- /dev/null
+++ b/src/FluentCMS.Sdk/Schema/RichTextFormats.cs
@@ -0,0 +1,11 @@
+namespace FluentCMS.Schema;
+
+/// The names of the formats an produces.
+public static class RichTextFormats
+{
+ /// HTML.
+ public const string Html = "html";
+
+ /// CommonMark Markdown with GitHub style strikethrough.
+ public const string Markdown = "markdown";
+}
diff --git a/src/FluentCMS.Sdk/Schema/RichTextRenderOptions.cs b/src/FluentCMS.Sdk/Schema/RichTextRenderOptions.cs
new file mode 100644
index 000000000..828abac0b
--- /dev/null
+++ b/src/FluentCMS.Sdk/Schema/RichTextRenderOptions.cs
@@ -0,0 +1,18 @@
+namespace FluentCMS.Schema;
+
+/// Per call settings for .
+public sealed class RichTextRenderOptions
+{
+ ///
+ /// Resolves the URL of an entry link from the content type name and the entry id. Return
+ /// when the entry cannot be resolved; the link is then rendered as a
+ /// placeholder that keeps the type and the id.
+ ///
+ public Func? EntryUrlResolver { get; init; }
+
+ ///
+ /// Resolves the URL of a media embed from the media id. Return when the
+ /// asset cannot be resolved; the embed is then rendered as a placeholder that keeps the id.
+ ///
+ public Func? MediaUrlResolver { get; init; }
+}
diff --git a/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj b/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj
index 01037fa40..2eed25f3c 100644
--- a/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj
+++ b/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj
@@ -15,4 +15,8 @@
+
+
+
+
diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/RichTextParserTests.cs b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextParserTests.cs
new file mode 100644
index 000000000..90fa988ce
--- /dev/null
+++ b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextParserTests.cs
@@ -0,0 +1,168 @@
+using FluentCMS.Core.ProseMirror;
+using FluentCMS.Schema;
+using Shouldly;
+using Xunit;
+
+namespace FluentCMS.Core.Tests.ProseMirror;
+
+public class RichTextParserTests
+{
+ [Theory]
+ [InlineData("article.json")]
+ [InlineData("marks.json")]
+ public void RoundTripIsLossless(string snapshot)
+ {
+ var json = Snapshot.Compact(Snapshot.Read(snapshot));
+
+ var document = RichTextParser.Parse(json);
+
+ RichTextSerializer.Serialize(document).ShouldBe(json);
+ RichTextSerializer.ToRichText(document).Json.ShouldBe(json);
+ }
+
+ [Fact]
+ public void TheEmptyDocumentRoundTrips()
+ {
+ var document = RichTextParser.Parse(RichText.Empty);
+
+ document.Content.ShouldHaveSingleItem().ShouldBeOfType();
+ RichTextSerializer.ToRichText(document).ShouldBe(RichText.Empty);
+ }
+
+ [Fact]
+ public void WritesTheCanonicalKeyOrderWhateverTheInputOrder()
+ {
+ const string input = """
+ {"content":[{"content":[{"text":"Hi","marks":[{"attrs":{"href":"/a","title":"T"},"type":"link"}],"type":"text"}],"type":"paragraph"}],"type":"doc"}
+ """;
+
+ RichTextSerializer.Serialize(RichTextParser.Parse(input)).ShouldBe(
+ """{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"/a","title":"T"}}],"text":"Hi"}]}]}""");
+ }
+
+ [Fact]
+ public void KeepsAttributesTheModelDoesNotInterpret()
+ {
+ const string json = """
+ {"type":"doc","content":[{"type":"heading","attrs":{"level":2,"textAlign":"center"},"content":[{"type":"text","marks":[{"type":"link","attrs":{"class":null,"href":"/a","rel":"nofollow"}}],"text":"Hi"}]}]}
+ """;
+
+ RichTextSerializer.Serialize(RichTextParser.Parse(json)).ShouldBe(json);
+ }
+
+ [Fact]
+ public void KeepsNumbersAndNestedBlockDataAsWritten()
+ {
+ const string json = """
+ {"type":"doc","content":[{"type":"inlineBlock","attrs":{"blockType":"chart","data":{"values":[1,2.50,1e3],"nested":{"b":true,"a":null}}}}]}
+ """;
+
+ RichTextSerializer.Serialize(RichTextParser.Parse(json)).ShouldBe(json);
+ }
+
+ [Fact]
+ public void ParsesTheTypedValues()
+ {
+ var document = RichTextParser.Parse(Snapshot.Read("article.json"));
+
+ document.Content[0].ShouldBeOfType().Level.ShouldBe(1);
+ document.Content[6].ShouldBeOfType().Start.ShouldBe(1);
+ document.Content[5].Content[1].Content[1].ShouldBeOfType().Start.ShouldBe(3);
+
+ var code = document.Content[8].ShouldBeOfType();
+ code.Language.ShouldBe("csharp");
+ code.Code.ShouldBe("var small = 1 < 2;\nConsole.WriteLine(small);");
+ document.Content[9].ShouldBeOfType().Language.ShouldBeNull();
+
+ var media = document.Content[11].ShouldBeOfType();
+ media.MediaId.ShouldBe(Guid.Parse("22222222-2222-2222-2222-222222222222"));
+ media.Alt.ShouldBe("A diagram");
+ media.Title.ShouldBe("Architecture");
+
+ var links = document.Content[3].Content;
+ var link = links[1].Marks.ShouldHaveSingleItem().ShouldBeOfType();
+ link.Href.ShouldBe("https://example.com/docs?a=1&b=2");
+ link.Target.ShouldBe("_blank");
+ var entry = links[3].Marks.ShouldHaveSingleItem().ShouldBeOfType();
+ entry.EntryId.ShouldBe(Guid.Parse("11111111-1111-1111-1111-111111111111"));
+ entry.EntryType.ShouldBe("article");
+
+ var block = document.Content[13].ShouldBeOfType();
+ block.BlockType.ShouldBe("callout");
+ block.Data["tone"].GetString().ShouldBe("warning");
+ }
+
+ [Fact]
+ public void AcceptsAnEmptyDocumentAndEmptyContentArrays()
+ {
+ RichTextParser.Parse("""{"type":"doc"}""").Content.ShouldBeEmpty();
+
+ RichTextSerializer.Serialize(RichTextParser.Parse("""{"type":"doc","content":[{"type":"horizontalRule","content":[]}]}"""))
+ .ShouldBe("""{"type":"doc","content":[{"type":"horizontalRule"}]}""");
+ }
+
+ [Theory]
+ [InlineData("{", "$", "not valid JSON")]
+ [InlineData("", "$", "not valid JSON")]
+ [InlineData("[]", "$", "Expected an object")]
+ [InlineData("""{"type":"paragraph"}""", "$", "root node must be of type 'doc'")]
+ [InlineData("""{"type":"doc","content":[{}]}""", "$.content[0]", "no type")]
+ [InlineData("""{"type":"doc","content":[{"type":5}]}""", "$.content[0].type", "must be a string")]
+ [InlineData("""{"type":"doc","content":{}}""", "$.content", "must be an array")]
+ [InlineData("""{"type":"doc","content":["x"]}""", "$.content[0]", "Expected an object")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","extra":1}]}""", "$.content[0]", "Unknown property 'extra'")]
+ [InlineData("""{"type":"doc","type":"doc"}""", "$", "not valid JSON")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","attrs":[]}]}""", "$.content[0].attrs", "must be an object")]
+ [InlineData("""{"type":"doc","content":[{"type":"heading","content":[]}]}""", "$.content[0].attrs.level", "between 1 and 6")]
+ [InlineData("""{"type":"doc","content":[{"type":"heading","attrs":{"level":7}}]}""", "$.content[0].attrs.level", "between 1 and 6")]
+ [InlineData("""{"type":"doc","content":[{"type":"heading","attrs":{"level":"2"}}]}""", "$.content[0].attrs.level", "between 1 and 6")]
+ [InlineData("""{"type":"doc","content":[{"type":"orderedList","attrs":{"start":"a"}}]}""", "$.content[0].attrs.start", "must be an integer")]
+ [InlineData("""{"type":"doc","content":[{"type":"orderedList","attrs":{"start":-3}}]}""", "$.content[0].attrs.start", "between 0 and 999999999")]
+ [InlineData("""{"type":"doc","content":[{"type":"orderedList","attrs":{"start":1000000000}}]}""", "$.content[0].attrs.start", "between 0 and 999999999")]
+ [InlineData("""{"type":"doc","content":[{"type":"codeBlock","attrs":{"language":3}}]}""", "$.content[0].attrs.language", "must be a string")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text"}]}]}""", "$.content[0].content[0].text", "non-empty string")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":""}]}]}""", "$.content[0].content[0].text", "non-empty string")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","text":"x"}]}""", "$.content[0].text", "cannot have text")]
+ [InlineData("""{"type":"doc","content":[{"type":"horizontalRule","content":[{"type":"paragraph"}]}]}""", "$.content[0].content", "cannot have content")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","marks":[{"type":"bold"}]}]}""", "$.content[0].marks", "cannot have marks")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a","marks":{}}]}]}""", "$.content[0].content[0].marks", "must be an array")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link"}]}]}]}""", "$.content[0].content[0].marks[0].attrs.href", "must be a string")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"link","attrs":{"href":"/a","title":4}}]}]}]}""", "$.content[0].content[0].marks[0].attrs.title", "must be a string")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"entryLink","attrs":{"entryId":"x","entryType":"article"}}]}]}]}""", "$.content[0].content[0].marks[0].attrs.entryId", "must be a GUID")]
+ [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a","marks":[{"type":"entryLink","attrs":{"entryId":"11111111-1111-1111-1111-111111111111","entryType":"Article"}}]}]}]}""", "$.content[0].content[0].marks[0].attrs.entryType", "kebab-case")]
+ [InlineData("""{"type":"doc","content":[{"type":"media","attrs":{"mediaId":"nope"}}]}""", "$.content[0].attrs.mediaId", "must be a GUID")]
+ [InlineData("""{"type":"doc","content":[{"type":"media"}]}""", "$.content[0].attrs.mediaId", "must be a GUID")]
+ [InlineData("""{"type":"doc","content":[{"type":"inlineBlock","attrs":{"blockType":"paragraph"}}]}""", "$.content[0].attrs.blockType", "reserved")]
+ [InlineData("""{"type":"doc","content":[{"type":"inlineBlock","attrs":{"blockType":"two words"}}]}""", "$.content[0].attrs.blockType", "must start with a letter")]
+ [InlineData("""{"type":"doc","content":[{"type":"inlineBlock"}]}""", "$.content[0].attrs.blockType", "must be a string")]
+ [InlineData("""{"type":"doc","content":[{"type":"inlineBlock","attrs":{"blockType":"callout","data":[]}}]}""", "$.content[0].attrs.data", "must be an object")]
+ public void ReportsInvalidDocumentsWithThePath(string json, string path, string message)
+ {
+ RichTextParser.TryParse(json, out var document, out var errors).ShouldBeFalse();
+
+ document.ShouldBeNull();
+ errors.ShouldContain(error => error.Path == path && error.Message.Contains(message, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ParseThrowsWithEveryError()
+ {
+ const string json = """{"type":"doc","content":[{"type":"heading","attrs":{"level":9}},{"type":"horizontalRule","marks":[{"type":"bold"}]}]}""";
+
+ var exception = Should.Throw(() => RichTextParser.Parse(json));
+
+ exception.ShouldBeAssignableTo();
+ exception.Errors.Select(error => error.Path).ShouldBe(["$.content[0].attrs.level", "$.content[1].marks"]);
+ exception.Message.ShouldContain("$.content[0].attrs.level");
+ }
+
+ [Fact]
+ public void RejectsDeeplyNestedJsonInsteadOfOverflowingTheStack()
+ {
+ var json = string.Concat(Enumerable.Repeat("""{"type":"blockquote","content":[""", 200)) + string.Concat(Enumerable.Repeat("]}", 200));
+
+ RichTextParser.TryParse($$"""{"type":"doc","content":[{{json}}]}""", out _, out var errors).ShouldBeFalse();
+
+ errors.ShouldHaveSingleItem().Path.ShouldBe("$");
+ }
+}
diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/RichTextReferenceExtractorTests.cs b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextReferenceExtractorTests.cs
new file mode 100644
index 000000000..ba2482ec2
--- /dev/null
+++ b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextReferenceExtractorTests.cs
@@ -0,0 +1,85 @@
+using FluentCMS.Core.ProseMirror;
+using FluentCMS.Schema;
+using Shouldly;
+using Xunit;
+
+namespace FluentCMS.Core.Tests.ProseMirror;
+
+public class RichTextReferenceExtractorTests
+{
+ [Fact]
+ public void ReturnsEntryAndMediaIdsWithTheirPaths()
+ {
+ var references = RichTextReferenceExtractor.Extract(new RichText(Snapshot.Read("article.json")));
+
+ references.Entries.ShouldBe(
+ [
+ new RichTextEntryReference(Guid.Parse("11111111-1111-1111-1111-111111111111"), "article", "$.content[3].content[3].marks[0]"),
+ ]);
+ references.Media.ShouldBe(
+ [
+ new RichTextMediaReference(Guid.Parse("22222222-2222-2222-2222-222222222222"), "$.content[11]"),
+ ]);
+ }
+
+ [Fact]
+ public void FindsMediaInsideLinksAndNestedContent()
+ {
+ var references = RichTextReferenceExtractor.Extract(new RichText(Snapshot.Read("marks.json")));
+
+ references.Media.ShouldBe(
+ [
+ new RichTextMediaReference(Guid.Parse("33333333-3333-3333-3333-333333333333"), "$.content[14].content[0]"),
+ ]);
+ references.Entries.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void KeepsEveryOccurrenceInDocumentOrder()
+ {
+ var first = Guid.NewGuid();
+ var second = Guid.NewGuid();
+ var media = Guid.NewGuid();
+ var document = new DocumentNode(
+ [
+ new ParagraphNode(
+ [
+ new TextNode("a", [new EntryLinkMark(first, "article")]),
+ new TextNode("b", [new EntryLinkMark(second, "author")]),
+ ]),
+ new BlockquoteNode([new ParagraphNode([new TextNode("c", [new EntryLinkMark(first, "article")])]), new MediaNode(media)]),
+ new MediaNode(media),
+ ]);
+
+ var references = RichTextReferenceExtractor.Extract(document);
+
+ references.Entries.Select(entry => (entry.Id, entry.Type, entry.Path)).ShouldBe(
+ [
+ (first, "article", "$.content[0].content[0].marks[0]"),
+ (second, "author", "$.content[0].content[1].marks[0]"),
+ (first, "article", "$.content[1].content[0].content[0].marks[0]"),
+ ]);
+ references.Media.Select(item => (item.Id, item.Path)).ShouldBe(
+ [
+ (media, "$.content[1].content[1]"),
+ (media, "$.content[2]"),
+ ]);
+ }
+
+ [Fact]
+ public void ReturnsNothingWhenThereAreNoReferences()
+ {
+ var references = RichTextReferenceExtractor.Extract(RichText.Empty);
+
+ references.Entries.ShouldBeEmpty();
+ references.Media.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void RefusesAnInvalidDocument()
+ {
+ var value = new RichText("""{"type":"doc","content":[{"type":"table"}]}""");
+
+ Should.Throw(() => RichTextReferenceExtractor.Extract(value));
+ }
+}
diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/RichTextRendererTests.cs b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextRendererTests.cs
new file mode 100644
index 000000000..36d974240
--- /dev/null
+++ b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextRendererTests.cs
@@ -0,0 +1,369 @@
+using System.Net;
+using FluentCMS.Core.ProseMirror;
+using FluentCMS.Schema;
+using Shouldly;
+using Xunit;
+
+namespace FluentCMS.Core.Tests.ProseMirror;
+
+public class RichTextRendererTests
+{
+ private static readonly RichTextRenderOptions resolving = new()
+ {
+ EntryUrlResolver = (type, id) => $"/{type}/{id}",
+ MediaUrlResolver = id => $"https://cdn.example.com/media/{id}.png",
+ };
+
+ private static readonly RichTextRenderer renderer = new();
+
+ public static TheoryData SnapshotCases()
+ {
+ var cases = new TheoryData();
+ foreach (var document in new[] { "article", "marks" })
+ {
+ cases.Add(document, RichTextFormats.Html, false, $"{document}.html");
+ cases.Add(document, RichTextFormats.Html, true, $"{document}.resolved.html");
+ cases.Add(document, RichTextFormats.Markdown, false, $"{document}.markdown");
+ cases.Add(document, RichTextFormats.Markdown, true, $"{document}.resolved.markdown");
+ }
+
+ return cases;
+ }
+
+ [Theory]
+ [MemberData(nameof(SnapshotCases))]
+ public void RendersTheSnapshot(string document, string format, bool resolve, string snapshot)
+ {
+ var value = new RichText(Snapshot.Read($"{document}.json"));
+
+ var output = renderer.Render(value, format, resolve ? resolving : null);
+
+ output.ShouldMatchSnapshot(snapshot);
+ }
+
+ [Fact]
+ public void OutputIsStable()
+ {
+ var value = new RichText(Snapshot.Read("article.json"));
+
+ renderer.Render(value, RichTextFormats.Html).ShouldBe(renderer.Render(value, RichTextFormats.Html));
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe(renderer.Render(value, RichTextFormats.Markdown));
+ }
+
+ [Fact]
+ public void ListsTheSupportedFormats()
+ {
+ renderer.Formats.ShouldBe([RichTextFormats.Html, RichTextFormats.Markdown], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void FormatNamesIgnoreCase()
+ {
+ renderer.Render(RichText.Empty, "HTML").ShouldBe("");
+ renderer.Render(RichText.Empty, "Markdown").ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public void RejectsAnUnsupportedFormat()
+ {
+ var exception = Should.Throw(() => renderer.Render(RichText.Empty, "pdf"));
+
+ exception.ParamName.ShouldBe("format");
+ exception.Message.ShouldContain("html");
+ }
+
+ [Fact]
+ public void RejectsAnInvalidDocumentInsteadOfDroppingNodes()
+ {
+ var value = new RichText("""{"type":"doc","content":[{"type":"paragraph"},{"type":"table"}]}""");
+
+ var exception = Should.Throw(() => renderer.Render(value, RichTextFormats.Html));
+
+ exception.Errors.ShouldHaveSingleItem().Path.ShouldBe("$.content[1]");
+ }
+
+ [Fact]
+ public void RendersADocumentBuiltInCode()
+ {
+ var document = new DocumentNode(
+ [
+ new HeadingNode(2, [new TextNode("Hello")]),
+ new ParagraphNode([new TextNode("World", [new BoldMark()])]),
+ ]);
+
+ renderer.Render(document, RichTextFormats.Html).ShouldBe("Hello
\nWorld
");
+ renderer.Render(document, RichTextFormats.Markdown).ShouldBe("## Hello\n\n**World**");
+ }
+
+ [Fact]
+ public void RejectsABuiltInDocumentThatBreaksTheStructure()
+ {
+ var document = new DocumentNode([new TextNode("loose")]);
+
+ Should.Throw(() => renderer.Render(document, RichTextFormats.Html));
+ }
+
+ [Fact]
+ public void EncodesTextAndAttributes()
+ {
+ var value = new RichText("""
+ {"type":"doc","content":[{"type":"paragraph","content":[
+ {"type":"text","text":" & more"},
+ {"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com/?a=1&b=\"2\"","title":"\" onmouseover=\"alert(1)"}}],"text":"link"}
+ ]}]}
+ """);
+
+ var html = renderer.Render(value, RichTextFormats.Html);
+
+ html.ShouldBe(
+ "<script>alert(\"x\")</script> & more" +
+ "link
");
+ }
+
+ [Theory]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("JaVaScRiPt:alert(1)")]
+ [InlineData(" javascript:alert(1)")]
+ [InlineData("java\tscript:alert(1)")]
+ [InlineData("data:text/html,")]
+ [InlineData("vbscript:msgbox(1)")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("javascript:alert(1)")]
+ public void DropsLinksWithUnsafeSchemes(string href)
+ {
+ var value = Linked(href);
+
+ renderer.Render(value, RichTextFormats.Html).ShouldBe("text
");
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe("text");
+ }
+
+ [Fact]
+ public void MarkdownEncodesAmpersandsInDestinationsAndTitles()
+ {
+ var entry = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var media = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var value = RichTextSerializer.ToRichText(new DocumentNode(
+ [
+ new ParagraphNode(
+ [
+ new TextNode("x", [new LinkMark("https://example.com/?a=1&b=2&c", title: "A & B")]),
+ new TextNode("e", [new EntryLinkMark(entry, "article")]),
+ ]),
+ new MediaNode(media),
+ ]));
+ var options = new RichTextRenderOptions
+ {
+ EntryUrlResolver = (_, _) => "/e?a=1&b=2",
+ MediaUrlResolver = _ => "https://cdn.example.com/m.png?w=1&h=2",
+ };
+
+ renderer.Render(value, RichTextFormats.Markdown, options).ShouldBe(
+ "[x](https://example.com/?a=1&b=2&c \"A & B\")[e](/e?a=1&b=2)\n\n" +
+ "");
+ }
+
+ [Theory]
+ [InlineData("foo", "===", "foo\\\n\\===")]
+ [InlineData("foo", "= = =", "foo\\\n\\= = =")]
+ [InlineData("foo", "=== bar", "foo\\\n=== bar")]
+ [InlineData("foo", " indented", "foo\\\nindented")]
+ [InlineData("foo", " # not a heading", "foo\\\n\\# not a heading")]
+ public void MarkdownKeepsTextAfterAHardBreakFromChangingTheBlock(string first, string second, string expected)
+ {
+ var value = RichTextSerializer.ToRichText(new DocumentNode(
+ [new ParagraphNode([new TextNode(first), new HardBreakNode(), new TextNode(second)])]));
+
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(" indented", "indented")]
+ [InlineData("\tindented", "indented")]
+ [InlineData("a\n b", "a\nb")]
+ [InlineData(" - not a bullet", "\\- not a bullet")]
+ [InlineData("a b", "a b")]
+ public void MarkdownDropsIndentationAtTheStartOfALine(string text, string expected)
+ {
+ var value = RichTextSerializer.ToRichText(new DocumentNode([new ParagraphNode([new TextNode(text)])]));
+
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void MarkdownDropsIndentationBeforeAMarkedRun()
+ {
+ var value = RichTextSerializer.ToRichText(new DocumentNode(
+ [new ParagraphNode([new TextNode(" bold", [new BoldMark()])])]));
+
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe("**bold**");
+ }
+
+ [Fact]
+ public void MarkdownNumbersAListFromItsStart()
+ {
+ var items = new[] { new ListItemNode([new ParagraphNode([new TextNode("a")])]), new ListItemNode([new ParagraphNode([new TextNode("b")])]) };
+ var value = RichTextSerializer.ToRichText(new DocumentNode([new OrderedListNode(items, start: 0)]));
+
+ renderer.Render(value, RichTextFormats.Markdown).ShouldBe("0. a\n1. b");
+ }
+
+ [Theory]
+ [InlineData("https://example.com/a")]
+ [InlineData("HTTP://example.com")]
+ [InlineData("mailto:a@example.com")]
+ [InlineData("tel:+123")]
+ [InlineData("/relative/path")]
+ [InlineData("relative/path")]
+ [InlineData("#anchor")]
+ [InlineData("?query=1")]
+ [InlineData("//cdn.example.com/x")]
+ public void KeepsLinksWithSafeTargets(string href)
+ {
+ renderer.Render(Linked(href), RichTextFormats.Html).ShouldContain("ab
");
+ }
+
+ [Fact]
+ public void IgnoresUnsafeUrlsFromResolvers()
+ {
+ var value = new RichText("""
+ {"type":"doc","content":[
+ {"type":"paragraph","content":[{"type":"text","marks":[{"type":"entryLink","attrs":{"entryId":"11111111-1111-1111-1111-111111111111","entryType":"article"}}],"text":"entry"}]},
+ {"type":"media","attrs":{"mediaId":"22222222-2222-2222-2222-222222222222"}}
+ ]}
+ """);
+ var options = new RichTextRenderOptions
+ {
+ EntryUrlResolver = (_, _) => "javascript:alert(1)",
+ MediaUrlResolver = _ => "javascript:alert(1)",
+ };
+
+ renderer.Render(value, RichTextFormats.Html, options).ShouldBe(
+ "entry
\n" +
+ "
");
+ }
+
+ [Fact]
+ public void KeepsPlaceholdersWhenAResolverReturnsNull()
+ {
+ var value = new RichText("""
+ {"type":"doc","content":[{"type":"media","attrs":{"mediaId":"22222222-2222-2222-2222-222222222222"}}]}
+ """);
+ var options = new RichTextRenderOptions { MediaUrlResolver = _ => null };
+
+ renderer.Render(value, RichTextFormats.Markdown, options).ShouldBe("");
+ }
+
+ [Fact]
+ public void ACustomRendererChangesTheOutputOfATypedInlineBlock()
+ {
+ var custom = new RichTextRenderer([new CalloutRenderer()]);
+ var value = new RichText(Snapshot.Read("article.json"));
+
+ var html = custom.Render(value, RichTextFormats.Html);
+ var markdown = custom.Render(value, RichTextFormats.Markdown);
+
+ html.ShouldContain("Heads up");
+ html.ShouldContain("");
+ html.ShouldNotContain("data-fcms-block");
+ markdown.ShouldContain("**Heads up**");
+ markdown.ShouldContain("> Block level");
+ markdown.ShouldNotContain("fcms-block");
+ }
+
+ [Fact]
+ public void ACustomRendererCanDeclineAFormat()
+ {
+ var custom = new RichTextRenderer([new CalloutRenderer(declineMarkdown: true)]);
+ var value = new RichText(Snapshot.Read("article.json"));
+
+ custom.Render(value, RichTextFormats.Html).ShouldContain("