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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/FluentCMS.Core/RichText/FormatRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Text.Json;
using FluentCMS.Schema;

namespace FluentCMS.Core.ProseMirror;

internal abstract class FormatRenderer(
string format,
IReadOnlyDictionary<string, IRichTextNodeRenderer> 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<RichTextMark> SortMarks(IReadOnlyList<RichTextMark> marks) =>
[.. marks.OrderBy(Rank)];

protected static int CommonPrefix(List<RichTextMark> open, List<RichTextMark> 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<string, JsonElement> attributes,
bool isInline,
Func<string> renderChildren) : IRichTextNodeContext
{
public string Format { get; } = format;

public string NodeType { get; } = nodeType;

public IReadOnlyDictionary<string, JsonElement> 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 &#58; 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;
}
}
239 changes: 239 additions & 0 deletions src/FluentCMS.Core/RichText/HtmlRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
using System.Text;
using System.Text.RegularExpressions;
using FluentCMS.Schema;

namespace FluentCMS.Core.ProseMirror;

internal sealed partial class HtmlRenderer(
IReadOnlyDictionary<string, IRichTextNodeRenderer> 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<RichTextNode> 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 => $"<p>{Inlines(paragraph.Content)}</p>",
HeadingNode heading => $"<h{heading.Level}>{Inlines(heading.Content)}</h{heading.Level}>",
BulletListNode list => Wrap("<ul>", "</ul>", Blocks(list.Content)),
OrderedListNode list => Wrap(list.Start == 1 ? "<ol>" : $"<ol start=\"{list.Start}\">", "</ol>", Blocks(list.Content)),
ListItemNode item => $"<li>{Blocks(item.Content)}</li>",
BlockquoteNode quote => Wrap("<blockquote>", "</blockquote>", Blocks(quote.Content)),
CodeBlockNode code => CodeBlock(code),
HorizontalRuleNode => "<hr>",
MediaNode media => $"<figure>{Media(media)}</figure>",
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 $"<pre><code{attribute}>{Escape(code.Code)}</code></pre>";
}

private string Inlines(IReadOnlyList<RichTextNode> nodes)
{
var builder = new StringBuilder();
var open = new List<RichTextMark>();

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 => "<br>",
MediaNode media => Media(media),
InlineBlockNode block => PlaceholderBlock(block, "span"),
_ => string.Empty,
};
}

private string Open(RichTextMark mark) =>
mark switch
{
BoldMark => "<strong>",
ItalicMark => "<em>",
UnderlineMark => "<u>",
StrikeMark => "<s>",
CodeMark => "<code>",
LinkMark link => OpenLink(link),
EntryLinkMark entry => OpenEntryLink(entry),
_ => string.Empty,
};

private static string Close(RichTextMark mark) =>
mark switch
{
BoldMark => "</strong>",
ItalicMark => "</em>",
UnderlineMark => "</u>",
StrikeMark => "</s>",
CodeMark => "</code>",
LinkMark link => SafeUrl.IsSafe(link.Href) ? "</a>" : string.Empty,
EntryLinkMark => "</a>",
_ => string.Empty,
};

private static string OpenLink(LinkMark link)
{
if (!SafeUrl.IsSafe(link.Href))
{
return string.Empty;
}

var tag = new StringBuilder("<a href=\"").Append(EscapeAttribute(link.Href.Trim())).Append('"');
if (!string.IsNullOrEmpty(link.Title))
{
tag.Append(" title=\"").Append(EscapeAttribute(link.Title)).Append('"');
}

if (link.Target == "_blank")
{
tag.Append(" target=\"_blank\" rel=\"noopener noreferrer\"");
}

return tag.Append('>').ToString();
}

private string OpenEntryLink(EntryLinkMark entry)
{
var tag = new StringBuilder("<a");
if (ResolveEntryUrl(entry) is { } url)
{
tag.Append(" href=\"").Append(EscapeAttribute(url.Trim())).Append('"');
}

return tag
.Append(" data-entry-type=\"").Append(EscapeAttribute(entry.EntryType))
.Append("\" data-entry-id=\"").Append(entry.EntryId).Append("\">")
.ToString();
}

private string Media(MediaNode media)
{
var tag = new StringBuilder("<img");
if (ResolveMediaUrl(media) is { } url)
{
tag.Append(" src=\"").Append(EscapeAttribute(url.Trim())).Append('"');
}

tag.Append(" alt=\"").Append(EscapeAttribute(media.Alt ?? string.Empty)).Append('"');
if (!string.IsNullOrEmpty(media.Title))
{
tag.Append(" title=\"").Append(EscapeAttribute(media.Title)).Append('"');
}

return tag.Append(" data-media-id=\"").Append(media.MediaId).Append("\">").ToString();
}

private static string PlaceholderBlock(InlineBlockNode block, string tag)
{
var data = block.Attributes.TryGetValue("data", out var value) && value.ValueKind == System.Text.Json.JsonValueKind.Object
? $" data-fcms-data=\"{EscapeAttribute(Compact(value))}\""
: string.Empty;

return $"<{tag} data-fcms-block=\"{EscapeAttribute(block.BlockType)}\"{data}></{tag}>";
}

private static string Escape(string text) => Encode(text, attribute: false);

private static string EscapeAttribute(string text) => Encode(text, attribute: true);

private static string Encode(string text, bool attribute)
{
var builder = new StringBuilder(text.Length);
foreach (var character in text)
{
switch (character)
{
case '&':
builder.Append("&amp;");
break;
case '<':
builder.Append("&lt;");
break;
case '>':
builder.Append("&gt;");
break;
case '"' when attribute:
builder.Append("&quot;");
break;
case '\'' when attribute:
builder.Append("&#39;");
break;
case '\0':
builder.Append('�');
break;
default:
builder.Append(character);
break;
}
}

return builder.ToString();
}

[GeneratedRegex("^[A-Za-z0-9_+#.-]+$")]
private static partial Regex LanguagePattern();
}
Loading
Loading