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 => $"
  1. {Blocks(item.Content)}
  2. ", + 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}>"; + } + + 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 $"![{alt}]({Destination(destination)}{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

    \n

    World

    "); + 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&amp;c \"A & B\")[e](/e?a=1&b=2)\n\n" + + "![](https://cdn.example.com/m.png?w=1&h=2)"); + } + + [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("![](fcms:media/22222222-2222-2222-2222-222222222222)"); + } + + [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(""); + } + + [Fact] + public void ACustomRendererCanReplaceABuiltInNodeAndRenderItsChildren() + { + var custom = new RichTextRenderer([new HeadingRenderer()]); + var value = new RichText(""" + {"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Hi "},{"type":"text","marks":[{"type":"bold"}],"text":"there"}]}]} + """); + + custom.Render(value, RichTextFormats.Html).ShouldBe("
    Hi there
    "); + custom.Render(value, RichTextFormats.Markdown).ShouldBe("TITLE Hi **there**"); + } + + [Fact] + public void ABlockTypeRendererDoesNotTouchABuiltInNodeOfTheSameName() + { + var custom = new RichTextRenderer([new CalloutRenderer()]); + + custom.Render(RichText.Empty, RichTextFormats.Html).ShouldBe("

    "); + } + + [Fact] + public void RejectsRenderersThatCannotBeRegistered() + { + Should.Throw(() => new RichTextRenderer([new NamedRenderer("callout"), new NamedRenderer("callout")])); + Should.Throw(() => new RichTextRenderer([new NamedRenderer("")])); + Should.Throw(() => new RichTextRenderer([new NamedRenderer(RichTextNodeTypes.Text)])); + Should.Throw(() => new RichTextRenderer([new NamedRenderer(RichTextNodeTypes.Doc)])); + Should.Throw(() => new RichTextRenderer([new NamedRenderer(RichTextNodeTypes.InlineBlock)])); + } + + private static RichText Linked(string href) => + new(RichTextSerializer.Serialize(new DocumentNode([new ParagraphNode([new TextNode("text", [new LinkMark(href)])])]))); + + private sealed class NamedRenderer(string nodeType) : IRichTextNodeRenderer + { + public string NodeType { get; } = nodeType; + + public string? Render(IRichTextNodeContext context) => null; + } + + private sealed class CalloutRenderer(bool declineMarkdown = false) : IRichTextNodeRenderer + { + public string NodeType => "callout"; + + public string? Render(IRichTextNodeContext context) + { + var tone = context.Attributes["tone"].GetString(); + var text = context.Attributes["text"].GetString()!; + + return context.Format switch + { + RichTextFormats.Html when context.IsInline => $"{WebUtility.HtmlEncode(text)}", + RichTextFormats.Html => $"", + RichTextFormats.Markdown when declineMarkdown => null, + RichTextFormats.Markdown when context.IsInline => $"**{text}**", + RichTextFormats.Markdown => $"> {text}", + _ => null, + }; + } + } + + private sealed class HeadingRenderer : IRichTextNodeRenderer + { + public string NodeType => RichTextNodeTypes.Heading; + + public string? Render(IRichTextNodeContext context) => + context.Format == RichTextFormats.Html + ? $"
    {context.RenderChildren()}
    " + : $"TITLE {context.RenderChildren()}"; + } +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/RichTextSerializerTests.cs b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextSerializerTests.cs new file mode 100644 index 000000000..d1e7eb878 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextSerializerTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using FluentCMS.Core.ProseMirror; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.ProseMirror; + +public class RichTextSerializerTests +{ + [Fact] + public void SerializesABuiltInDocument() + { + var entry = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var media = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var data = new Dictionary { ["tone"] = JsonSerializer.SerializeToElement("info") }; + + var document = new DocumentNode( + [ + new HeadingNode(2, [new TextNode("Title")]), + new ParagraphNode( + [ + new TextNode("see "), + new TextNode("guide", [new EntryLinkMark(entry, "article"), new BoldMark()]), + new HardBreakNode(), + new InlineBlockNode("callout", data), + ]), + new OrderedListNode([new ListItemNode([new ParagraphNode([new TextNode("one")])])], start: 4), + new CodeBlockNode("x < y", "csharp"), + new HorizontalRuleNode(), + new MediaNode(media, "Alt", "Title"), + ]); + + RichTextSerializer.Serialize(document).ShouldBe( + """{"type":"doc","content":[""" + + """{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},""" + + """{"type":"paragraph","content":[{"type":"text","text":"see "},""" + + """{"type":"text","marks":[{"type":"entryLink","attrs":{"entryId":"11111111-1111-1111-1111-111111111111","entryType":"article"}},{"type":"bold"}],"text":"guide"},""" + + """{"type":"hardBreak"},{"type":"inlineBlock","attrs":{"blockType":"callout","data":{"tone":"info"}}}]},""" + + """{"type":"orderedList","attrs":{"start":4},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"one"}]}]}]},""" + + """{"type":"codeBlock","attrs":{"language":"csharp"},"content":[{"type":"text","text":"x < y"}]},""" + + """{"type":"horizontalRule"},""" + + """{"type":"media","attrs":{"alt":"Alt","mediaId":"22222222-2222-2222-2222-222222222222","title":"Title"}}]}"""); + } + + [Fact] + public void ABuiltInDocumentSurvivesAParse() + { + var document = new DocumentNode([new ParagraphNode([new TextNode("é — ünï", [new ItalicMark()])])]); + + var json = RichTextSerializer.Serialize(document); + + RichTextSerializer.Serialize(RichTextParser.Parse(json)).ShouldBe(json); + json.ShouldContain("é — ünï"); + } + + [Fact] + public void ConstructorsRefuseValuesTheParserWouldReject() + { + Should.Throw(() => new HeadingNode(0)); + Should.Throw(() => new HeadingNode(7)); + Should.Throw(() => new OrderedListNode(null, -1)); + Should.Throw(() => new OrderedListNode(null, 1_000_000_000)); + Should.Throw(() => new InlineBlockNode("paragraph")); + Should.Throw(() => new InlineBlockNode("not valid")); + Should.Throw(() => new EntryLinkMark(Guid.NewGuid(), "Not Kebab")); + Should.Throw(() => new TextNode(string.Empty)); + } +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/RichTextValidatorTests.cs b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextValidatorTests.cs new file mode 100644 index 000000000..16b397619 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/RichTextValidatorTests.cs @@ -0,0 +1,107 @@ +using FluentCMS.Core.ProseMirror; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.ProseMirror; + +public class RichTextValidatorTests +{ + [Theory] + [InlineData("article.json")] + [InlineData("marks.json")] + public void AcceptsTheRepresentativeDocuments(string snapshot) + { + RichTextValidator.Validate(new RichText(Snapshot.Read(snapshot))).ShouldBeEmpty(); + } + + [Fact] + public void AcceptsTheEmptyDocument() + { + RichTextValidator.Validate(RichText.Empty).ShouldBeEmpty(); + } + + [Fact] + public void RejectsAnUnknownNodeTypeAndNamesThePath() + { + var value = new RichText(""" + {"type":"doc","content":[ + {"type":"paragraph","content":[{"type":"text","text":"ok"}]}, + {"type":"blockquote","content":[{"type":"table"}]} + ]} + """); + + var error = RichTextValidator.Validate(value).ShouldHaveSingleItem(); + + error.Path.ShouldBe("$.content[1].content[0]"); + error.Message.ShouldBe("Unknown node type 'table'."); + Should.Throw(() => RichTextParser.Parse(value)); + } + + [Fact] + public void RejectsAnUnknownMarkAndNamesThePath() + { + var value = new RichText(""" + {"type":"doc","content":[{"type":"paragraph","content":[ + {"type":"text","text":"ok"}, + {"type":"text","text":"hi","marks":[{"type":"bold"},{"type":"highlight"}]} + ]}]} + """); + + var error = RichTextValidator.Validate(value).ShouldHaveSingleItem(); + + error.Path.ShouldBe("$.content[0].content[1].marks[1]"); + error.Message.ShouldBe("Unknown mark type 'highlight'."); + } + + [Fact] + public void ReportsEveryUnknownNodeInOnePass() + { + var value = new RichText(""" + {"type":"doc","content":[{"type":"table"},{"type":"paragraph"},{"type":"video"}]} + """); + + RichTextValidator.Validate(value).Select(error => error.Path).ShouldBe(["$.content[0]", "$.content[2]"]); + } + + [Theory] + [InlineData("""{"type":"doc","content":[{"type":"text","text":"loose"}]}""", "$.content[0]", "'text' node is not allowed inside a 'doc'")] + [InlineData("""{"type":"doc","content":[{"type":"listItem"}]}""", "$.content[0]", "'listItem' node is not allowed inside a 'doc'")] + [InlineData("""{"type":"doc","content":[{"type":"hardBreak"}]}""", "$.content[0]", "'hardBreak' node is not allowed inside a 'doc'")] + [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"paragraph"}]}]}""", "$.content[0].content[0]", "'paragraph' node is not allowed inside a 'paragraph'")] + [InlineData("""{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"bulletList"}]}]}""", "$.content[0].content[0]", "not allowed inside a 'heading'")] + [InlineData("""{"type":"doc","content":[{"type":"bulletList","content":[{"type":"paragraph"}]}]}""", "$.content[0].content[0]", "not allowed inside a 'bulletList'")] + [InlineData("""{"type":"doc","content":[{"type":"codeBlock","content":[{"type":"hardBreak"}]}]}""", "$.content[0].content[0]", "not allowed inside a 'codeBlock'")] + [InlineData("""{"type":"doc","content":[{"type":"codeBlock","content":[{"type":"text","text":"x","marks":[{"type":"bold"}]}]}]}""", "$.content[0].content[0].marks", "marks apply to inline content only")] + [InlineData("""{"type":"doc","content":[{"type":"media","attrs":{"mediaId":"22222222-2222-2222-2222-222222222222"},"marks":[{"type":"bold"}]}]}""", "$.content[0].marks", "marks apply to inline content only")] + [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"x","marks":[{"type":"bold"},{"type":"bold"}]}]}]}""", "$.content[0].content[0].marks[1]", "'bold' mark appears more than once")] + public void RejectsContentThatBreaksTheStructure(string json, string path, string message) + { + var errors = RichTextValidator.Validate(new RichText(json)); + + errors.ShouldContain(error => error.Path == path && error.Message.Contains(message, StringComparison.Ordinal)); + } + + [Fact] + public void ValidatesABuiltInDocument() + { + var invalid = new DocumentNode([new ParagraphNode([new ParagraphNode()])]); + var valid = new DocumentNode([new ParagraphNode([new TextNode("fine")])]); + + RichTextValidator.Validate(invalid).ShouldHaveSingleItem().Path.ShouldBe("$.content[0].content[0]"); + RichTextValidator.Validate(valid).ShouldBeEmpty(); + } + + [Fact] + public void InlineNodesMayAppearBetweenBlocks() + { + var value = new RichText(""" + {"type":"doc","content":[ + {"type":"media","attrs":{"mediaId":"22222222-2222-2222-2222-222222222222"}}, + {"type":"inlineBlock","attrs":{"blockType":"callout"}} + ]} + """); + + RichTextValidator.Validate(value).ShouldBeEmpty(); + } +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshot.cs b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshot.cs new file mode 100644 index 000000000..68aa4589c --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshot.cs @@ -0,0 +1,44 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Shouldly; + +namespace FluentCMS.Core.Tests.ProseMirror; + +internal static class Snapshot +{ + private const string UpdateVariable = "FLUENTCMS_UPDATE_SNAPSHOTS"; + + public static string Read(string name) => Normalize(File.ReadAllText(OutputPath(name))); + + public static string Compact(string json) + { + using var document = JsonDocument.Parse(json); + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + document.RootElement.WriteTo(writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + public static void ShouldMatchSnapshot(this string actual, string name, [CallerFilePath] string callerPath = "") + { + if (Environment.GetEnvironmentVariable(UpdateVariable) == "1") + { + var source = Path.Combine(Path.GetDirectoryName(callerPath)!, "Snapshots", name); + File.WriteAllText(source, actual + "\n", new UTF8Encoding(false)); + } + else + { + actual.ShouldBe(Read(name)); + } + } + + private static string OutputPath(string name) => + Path.Combine(AppContext.BaseDirectory, "ProseMirror", "Snapshots", name); + + private static string Normalize(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n'); +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.html b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.html new file mode 100644 index 000000000..7f859d193 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.html @@ -0,0 +1,27 @@ +

    Release notes

    +

    Plain, bold, italic, underlined, struck and code with <angle> & ampersand.

    +

    Links

    +

    Read the docs or the getting started guide.

    +

    Lists

    +
      +
    • First

    • +
    • Second

      +
        +
      1. Nested three

      2. +
      3. Nested four

      4. +
    • +
    +
      +
    1. One

    2. +
    3. Two

    4. +
    +
    +

    Quoted text.

    +
    +
    var small = 1 < 2;
    +Console.WriteLine(small);
    +
    no language
    +
    +
    A diagram
    +

    Line one
    line two with an inline block.

    +
    diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.json b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.json new file mode 100644 index 000000000..368acb0f8 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.json @@ -0,0 +1,133 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [ { "type": "text", "text": "Release notes" } ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Plain, " }, + { "type": "text", "marks": [ { "type": "bold" } ], "text": "bold" }, + { "type": "text", "text": ", " }, + { "type": "text", "marks": [ { "type": "italic" } ], "text": "italic" }, + { "type": "text", "text": ", " }, + { "type": "text", "marks": [ { "type": "underline" } ], "text": "underlined" }, + { "type": "text", "text": ", " }, + { "type": "text", "marks": [ { "type": "strike" } ], "text": "struck" }, + { "type": "text", "text": " and " }, + { "type": "text", "marks": [ { "type": "code" } ], "text": "code" }, + { "type": "text", "text": " with & ampersand." } + ] + }, + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [ { "type": "text", "text": "Links" } ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Read the " }, + { + "type": "text", + "marks": [ { "type": "link", "attrs": { "href": "https://example.com/docs?a=1&b=2", "target": "_blank" } } ], + "text": "docs" + }, + { "type": "text", "text": " or the " }, + { + "type": "text", + "marks": [ { "type": "entryLink", "attrs": { "entryId": "11111111-1111-1111-1111-111111111111", "entryType": "article" } } ], + "text": "getting started guide" + }, + { "type": "text", "text": "." } + ] + }, + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [ { "type": "text", "text": "Lists" } ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "First" } ] } ] + }, + { + "type": "listItem", + "content": [ + { "type": "paragraph", "content": [ { "type": "text", "text": "Second" } ] }, + { + "type": "orderedList", + "attrs": { "start": 3 }, + "content": [ + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Nested three" } ] } ] + }, + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Nested four" } ] } ] + } + ] + } + ] + } + ] + }, + { + "type": "orderedList", + "attrs": { "start": 1 }, + "content": [ + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "One" } ] } ] + }, + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Two" } ] } ] + } + ] + }, + { + "type": "blockquote", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Quoted text." } ] } ] + }, + { + "type": "codeBlock", + "attrs": { "language": "csharp" }, + "content": [ { "type": "text", "text": "var small = 1 < 2;\nConsole.WriteLine(small);" } ] + }, + { + "type": "codeBlock", + "attrs": { "language": null }, + "content": [ { "type": "text", "text": "no language" } ] + }, + { "type": "horizontalRule" }, + { + "type": "media", + "attrs": { "alt": "A diagram", "mediaId": "22222222-2222-2222-2222-222222222222", "title": "Architecture" } + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Line one" }, + { "type": "hardBreak" }, + { "type": "text", "text": "line two with an inline " }, + { + "type": "inlineBlock", + "attrs": { "blockType": "callout", "data": { "tone": "info", "text": "Heads up" } } + }, + { "type": "text", "text": " block." } + ] + }, + { + "type": "inlineBlock", + "attrs": { "blockType": "callout", "data": { "tone": "warning", "text": "Block level" } } + } + ] +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.markdown b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.markdown new file mode 100644 index 000000000..43444588d --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.markdown @@ -0,0 +1,37 @@ +# Release notes + +Plain, **bold**, *italic*, underlined, ~~struck~~ and `code` with \ & ampersand. + +## Links + +Read the [docs](https://example.com/docs?a=1&b=2) or the [getting started guide](fcms:entry/article/11111111-1111-1111-1111-111111111111). + +## Lists + +- First +- Second + 3. Nested three + 4. Nested four + +1. One +2. Two + +> Quoted text. + +```csharp +var small = 1 < 2; +Console.WriteLine(small); +``` + +``` +no language +``` + +--- + +![A diagram](fcms:media/22222222-2222-2222-2222-222222222222 "Architecture") + +Line one\ +line two with an inline block. + + diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.html b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.html new file mode 100644 index 000000000..14da197e7 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.html @@ -0,0 +1,27 @@ +

    Release notes

    +

    Plain, bold, italic, underlined, struck and code with <angle> & ampersand.

    +

    Links

    +

    Read the docs or the getting started guide.

    +

    Lists

    +
      +
    • First

    • +
    • Second

      +
        +
      1. Nested three

      2. +
      3. Nested four

      4. +
    • +
    +
      +
    1. One

    2. +
    3. Two

    4. +
    +
    +

    Quoted text.

    +
    +
    var small = 1 < 2;
    +Console.WriteLine(small);
    +
    no language
    +
    +
    A diagram
    +

    Line one
    line two with an inline block.

    +
    diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.markdown b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.markdown new file mode 100644 index 000000000..9e112bc27 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/article.resolved.markdown @@ -0,0 +1,37 @@ +# Release notes + +Plain, **bold**, *italic*, underlined, ~~struck~~ and `code` with \ & ampersand. + +## Links + +Read the [docs](https://example.com/docs?a=1&b=2) or the [getting started guide](/article/11111111-1111-1111-1111-111111111111). + +## Lists + +- First +- Second + 3. Nested three + 4. Nested four + +1. One +2. Two + +> Quoted text. + +```csharp +var small = 1 < 2; +Console.WriteLine(small); +``` + +``` +no language +``` + +--- + +![A diagram](https://cdn.example.com/media/22222222-2222-2222-2222-222222222222.png "Architecture") + +Line one\ +line two with an inline block. + + diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.html b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.html new file mode 100644 index 000000000..be2ec670a --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.html @@ -0,0 +1,33 @@ +

    Start bold both italic end

    +

    bold plain under and strike

    +

    link script relative mail

    +

    a`b x

    +

    # not a heading

    +

    1. not a list

    +

    - not a bullet +> not a quote ++ not a plus

    +

    *stars* _underscores_ [brackets] `ticks` <tags> &amp; AT&T 2 * 3 back\slash ~tilde~

    +

    +

    Title
    bold

    +
    ```js
    +x
    +```
    +
      +
    • Tight one

    • +
    • Tight two

    • +
    +
      +
    • Loose one

      +

      Second paragraph

    • +
    • Loose two

    • +
    +
    +

    First quoted

    +
      +
    • Quoted item

    • +
    +
    +

    caption

    +

    first line +second line

    diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.json b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.json new file mode 100644 index 000000000..fed00509b --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.json @@ -0,0 +1,149 @@ +{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Start " }, + { "type": "text", "marks": [ { "type": "bold" } ], "text": "bold " }, + { "type": "text", "marks": [ { "type": "bold" }, { "type": "italic" } ], "text": "both" }, + { "type": "text", "marks": [ { "type": "italic" } ], "text": " italic" }, + { "type": "text", "text": " end" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "marks": [ { "type": "bold" } ], "text": "bold " }, + { "type": "text", "text": "plain " }, + { "type": "text", "marks": [ { "type": "underline" }, { "type": "strike" } ], "text": "under and strike" } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ { "type": "link", "attrs": { "href": "https://example.com/a b(1)", "title": "Say \"hi\"" } }, { "type": "bold" } ], + "text": "link" + }, + { "type": "text", "text": " " }, + { "type": "text", "marks": [ { "type": "link", "attrs": { "href": "javascript:alert(1)" } } ], "text": "script" }, + { "type": "text", "text": " " }, + { "type": "text", "marks": [ { "type": "link", "attrs": { "href": "/docs/start#top" } } ], "text": "relative" }, + { "type": "text", "text": " " }, + { "type": "text", "marks": [ { "type": "link", "attrs": { "href": "mailto:team@example.com" } } ], "text": "mail" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "marks": [ { "type": "code" } ], "text": "a`b" }, + { "type": "text", "text": " " }, + { "type": "text", "marks": [ { "type": "bold" }, { "type": "code" } ], "text": "x" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "# not a heading" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "1. not a list" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "- not a bullet\n> not a quote\n+ not a plus" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "*stars* _underscores_ [brackets] `ticks` & AT&T 2 * 3 back\\slash ~tilde~" } + ] + }, + { + "type": "paragraph" + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [ + { "type": "text", "text": "Title" }, + { "type": "hardBreak" }, + { "type": "text", "marks": [ { "type": "bold" } ], "text": "bold" } + ] + }, + { + "type": "codeBlock", + "attrs": { "language": "md" }, + "content": [ { "type": "text", "text": "```js\nx\n```" } ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Tight one" } ] } ] + }, + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Tight two" } ] } ] + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { "type": "paragraph", "content": [ { "type": "text", "text": "Loose one" } ] }, + { "type": "paragraph", "content": [ { "type": "text", "text": "Second paragraph" } ] } + ] + }, + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Loose two" } ] } ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { "type": "paragraph", "content": [ { "type": "text", "text": "First quoted" } ] }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Quoted item" } ] } ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "media", + "attrs": { "mediaId": "33333333-3333-3333-3333-333333333333" }, + "marks": [ { "type": "link", "attrs": { "href": "https://example.com/full" } } ] + }, + { "type": "text", "text": " caption" } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "first line\nsecond line" } + ] + } + ] +} diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.markdown b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.markdown new file mode 100644 index 000000000..0a3d11948 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.markdown @@ -0,0 +1,43 @@ +Start **bold *both*** *italic* end + +**bold** plain ~~under and strike~~ + +[**link**](https://example.com/a%20b%281%29 "Say \"hi\"") script [relative](/docs/start#top) [mail](mailto:team@example.com) + +``a`b`` **`x`** + +\# not a heading + +1\. not a list + +\- not a bullet +\> not a quote +\+ not a plus + +\*stars\* \_underscores\_ \[brackets\] \`ticks\` \ \& AT&T 2 \* 3 back\\slash \~tilde\~ + +### Title **bold** + +````md +```js +x +``` +```` + +- Tight one +- Tight two + +* Loose one + + Second paragraph + +* Loose two + +> First quoted +> +> - Quoted item + +[![](fcms:media/33333333-3333-3333-3333-333333333333)](https://example.com/full) caption + +first line +second line diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.html b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.html new file mode 100644 index 000000000..647148bc6 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.html @@ -0,0 +1,33 @@ +

    Start bold both italic end

    +

    bold plain under and strike

    +

    link script relative mail

    +

    a`b x

    +

    # not a heading

    +

    1. not a list

    +

    - not a bullet +> not a quote ++ not a plus

    +

    *stars* _underscores_ [brackets] `ticks` <tags> &amp; AT&T 2 * 3 back\slash ~tilde~

    +

    +

    Title
    bold

    +
    ```js
    +x
    +```
    +
      +
    • Tight one

    • +
    • Tight two

    • +
    +
      +
    • Loose one

      +

      Second paragraph

    • +
    • Loose two

    • +
    +
    +

    First quoted

    +
      +
    • Quoted item

    • +
    +
    +

    caption

    +

    first line +second line

    diff --git a/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.markdown b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.markdown new file mode 100644 index 000000000..0ae4cbe42 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/ProseMirror/Snapshots/marks.resolved.markdown @@ -0,0 +1,43 @@ +Start **bold *both*** *italic* end + +**bold** plain ~~under and strike~~ + +[**link**](https://example.com/a%20b%281%29 "Say \"hi\"") script [relative](/docs/start#top) [mail](mailto:team@example.com) + +``a`b`` **`x`** + +\# not a heading + +1\. not a list + +\- not a bullet +\> not a quote +\+ not a plus + +\*stars\* \_underscores\_ \[brackets\] \`ticks\` \ \& AT&T 2 \* 3 back\\slash \~tilde\~ + +### Title **bold** + +````md +```js +x +``` +```` + +- Tight one +- Tight two + +* Loose one + + Second paragraph + +* Loose two + +> First quoted +> +> - Quoted item + +[![](https://cdn.example.com/media/33333333-3333-3333-3333-333333333333.png)](https://example.com/full) caption + +first line +second line diff --git a/tests/FluentCMS.Sdk.Tests/Schema/RichTextContractTests.cs b/tests/FluentCMS.Sdk.Tests/Schema/RichTextContractTests.cs new file mode 100644 index 000000000..549dcc4b6 --- /dev/null +++ b/tests/FluentCMS.Sdk.Tests/Schema/RichTextContractTests.cs @@ -0,0 +1,24 @@ +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Sdk.Tests.Schema; + +public class RichTextContractTests +{ + [Fact] + public void FormatNamesAreLowerCase() + { + RichTextFormats.Html.ShouldBe("html"); + RichTextFormats.Markdown.ShouldBe("markdown"); + } + + [Fact] + public void RenderOptionsResolveNothingByDefault() + { + var options = new RichTextRenderOptions(); + + options.EntryUrlResolver.ShouldBeNull(); + options.MediaUrlResolver.ShouldBeNull(); + } +}