From 3a4234be00414ac0d1fe79d1659def66e0110f93 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 Dec 2023 21:42:36 +0100 Subject: [PATCH] T --- .../RichText/ComplexText.html | 26 ++ .../RichText/ComplexText.json | 240 ++++++++++++++++++ .../RichText/ComplexText.md | 27 ++ .../RichText/Json/JsonExtensions.cs | 20 ++ .../RichText/Json/JsonMark.cs | 30 +-- .../RichText/Json/JsonNode.cs | 53 ++-- .../RichText/RenderUtils.cs | 6 +- .../RichText/RichTextComplexTests.cs | 22 +- .../RichText/RichTextInlineTests.cs | 4 +- .../RichText/RichTextTests.cs | 68 ++++- .../Squidex.Text.Tests.csproj | 9 + .../RichText/HtmlWriterVisitor.cs | 38 +-- text/Squidex.Text/RichText/IndentedWriter.cs | 46 ++++ text/Squidex.Text/RichText/MarkdownVisitor.cs | 89 ++++--- .../Squidex.Text/RichText/Model/Attributes.cs | 41 +++ .../Model/{Attributed.cs => IAttributed.cs} | 6 +- .../RichText/Model/{MarkBase.cs => IMark.cs} | 4 +- .../RichText/Model/{NodeBase.cs => INode.cs} | 12 +- text/Squidex.Text/RichText/Model/Mark.cs | 30 +-- text/Squidex.Text/RichText/Model/MarkType.cs | 1 + text/Squidex.Text/RichText/Model/Node.cs | 54 ++-- text/Squidex.Text/RichText/Model/NodeType.cs | 5 +- text/Squidex.Text/RichText/Visitor.cs | 93 ++++--- 23 files changed, 713 insertions(+), 211 deletions(-) create mode 100644 text/Squidex.Text.Tests/RichText/ComplexText.html create mode 100644 text/Squidex.Text.Tests/RichText/ComplexText.json create mode 100644 text/Squidex.Text.Tests/RichText/ComplexText.md create mode 100644 text/Squidex.Text/RichText/IndentedWriter.cs rename text/Squidex.Text/RichText/Model/{Attributed.cs => IAttributed.cs} (69%) rename text/Squidex.Text/RichText/Model/{MarkBase.cs => IMark.cs} (82%) rename text/Squidex.Text/RichText/Model/{NodeBase.cs => INode.cs} (60%) diff --git a/text/Squidex.Text.Tests/RichText/ComplexText.html b/text/Squidex.Text.Tests/RichText/ComplexText.html new file mode 100644 index 0000000..a8097ac --- /dev/null +++ b/text/Squidex.Text.Tests/RichText/ComplexText.html @@ -0,0 +1,26 @@ +

Header

+

Content with bold, italic, underline and code.

+
+

Quote

+
+
Code Block in Javascript
+

Just another paragraph

+
+ +
    +
  1. +

    Item A

    +
  2. +
  3. +

    Item B

    +
  4. +
+

A link

+

\ No newline at end of file diff --git a/text/Squidex.Text.Tests/RichText/ComplexText.json b/text/Squidex.Text.Tests/RichText/ComplexText.json new file mode 100644 index 0000000..0b733d8 --- /dev/null +++ b/text/Squidex.Text.Tests/RichText/ComplexText.json @@ -0,0 +1,240 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Header" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Content with " + }, + { + "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": "underline" + }, + { + "type": "text", + "text": " and " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "code" + }, + { + "type": "text", + "text": "." + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Quote" + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "javascript", + "wrap": false + }, + "content": [ + { + "type": "text", + "text": "Code Block in Javascript" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Just another paragraph" + } + ] + }, + { + "type": "horizontalRule" + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "attrs": { + "closed": false, + "nested": false + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Item 1" + } + ] + } + ] + }, + { + "type": "listItem", + "attrs": { + "closed": false, + "nested": false + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Item 2" + } + ] + } + ] + } + ] + }, + { + "type": "orderedList", + "attrs": { + "order": 1 + }, + "content": [ + { + "type": "listItem", + "attrs": { + "closed": false, + "nested": false + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Item A" + } + ] + } + ] + }, + { + "type": "listItem", + "attrs": { + "closed": false, + "nested": false + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Item B" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "Link Content", + "target": null, + "auto": false + } + } + ], + "text": "A link" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "image", + "attrs": { + "alt": "", + "crop": null, + "height": null, + "width": null, + "rotate": null, + "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Adams_The_Tetons_and_the_Snake_River.jpg/1280px-Adams_The_Tetons_and_the_Snake_River.jpg", + "title": "My Image", + "fileName": null, + "resizable": false + } + } + ] + } + ] +} \ No newline at end of file diff --git a/text/Squidex.Text.Tests/RichText/ComplexText.md b/text/Squidex.Text.Tests/RichText/ComplexText.md new file mode 100644 index 0000000..9d39919 --- /dev/null +++ b/text/Squidex.Text.Tests/RichText/ComplexText.md @@ -0,0 +1,27 @@ +# Header + +Content with **bold**, *italic*, underline and `code`. + +> Quote + +```javascript +Code Block in Javascript +``` + +Just another paragraph + +--- + +* Item 1 + +* Item 2 + + +1. Item A + +2. Item B + + +[A link](Link Content) + +![](https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Adams_The_Tetons_and_the_Snake_River.jpg/1280px-Adams_The_Tetons_and_the_Snake_River.jpg "My Image") \ No newline at end of file diff --git a/text/Squidex.Text.Tests/RichText/Json/JsonExtensions.cs b/text/Squidex.Text.Tests/RichText/Json/JsonExtensions.cs index 077b5fd..667a687 100644 --- a/text/Squidex.Text.Tests/RichText/Json/JsonExtensions.cs +++ b/text/Squidex.Text.Tests/RichText/Json/JsonExtensions.cs @@ -16,6 +16,26 @@ public static bool TryGetEnum(this object value, out T enumValue) where T : s return value is string text && Enum.TryParse(text, true, out enumValue); } + public static int GetIntAttr(this JsonObject? attrs, string name, int defaultValue = 0) + { + if (attrs?.TryGetValue(name, out var value) == true && value is int attr) + { + return attr; + } + + return defaultValue; + } + + public static string GetStringAttr(this JsonObject? attrs, string name, string defaultValue = "") + { + if (attrs?.TryGetValue(name, out var value) == true && value is string attr) + { + return attr; + } + + return defaultValue; + } + public static bool TryGetArrayOfObject(this object value, out JsonArray array) { array = default!; diff --git a/text/Squidex.Text.Tests/RichText/Json/JsonMark.cs b/text/Squidex.Text.Tests/RichText/Json/JsonMark.cs index 2761886..22c6993 100644 --- a/text/Squidex.Text.Tests/RichText/Json/JsonMark.cs +++ b/text/Squidex.Text.Tests/RichText/Json/JsonMark.cs @@ -9,10 +9,11 @@ namespace Squidex.RichText.Json; -internal sealed class JsonMark : MarkBase +internal sealed class JsonMark : IMark { private JsonObject? attrs; - private MarkType type; + + public MarkType Type { get; private set; } public bool TryUse(JsonObject source) { @@ -24,7 +25,7 @@ public bool TryUse(JsonObject source) switch (key) { case "type" when value.TryGetEnum(out var type): - this.type = type; + Type = type; break; case "attrs" when value is JsonObject attrs: this.attrs = attrs; @@ -38,28 +39,13 @@ public bool TryUse(JsonObject source) return isValid; } - public override MarkType GetMarkType() + public int GetIntAttr(string name, int defaultValue = 0) { - return type; - } - - public override int GetIntAttr(string name, int defaultValue = 0) - { - if (attrs?.TryGetValue(name, out var value) == true && value is int attr) - { - return attr; - } - - return defaultValue; + return attrs.GetIntAttr(name, defaultValue); } - public override string GetStringAttr(string name, string defaultValue = "") + public string GetStringAttr(string name, string defaultValue = "") { - if (attrs?.TryGetValue(name, out var value) == true && value is string attr) - { - return attr; - } - - return defaultValue; + return attrs.GetStringAttr(name, defaultValue); } } diff --git a/text/Squidex.Text.Tests/RichText/Json/JsonNode.cs b/text/Squidex.Text.Tests/RichText/Json/JsonNode.cs index 6fb3777..49d5e27 100644 --- a/text/Squidex.Text.Tests/RichText/Json/JsonNode.cs +++ b/text/Squidex.Text.Tests/RichText/Json/JsonNode.cs @@ -9,21 +9,31 @@ namespace Squidex.RichText.Json; -internal class JsonNode : NodeBase +internal class JsonNode : INode { private readonly JsonMark mark = new JsonMark(); private State currentState; internal struct State { + public NodeType Type; public JsonArray? Marks; public JsonObject? Attrs; public JsonArray? Content; - public NodeType Type; public string? Text; public int MarkIndex; } + public NodeType Type + { + get => currentState.Type; + } + + public string? Text + { + get => currentState.Text; + } + public bool TryUse(JsonObject source, bool recursive) { State state = default; @@ -78,37 +88,17 @@ public bool TryUse(JsonObject source, bool recursive) return isValid; } - public override NodeType GetNodeType() + public int GetIntAttr(string name, int defaultValue = 0) { - return currentState.Type; + return currentState.Attrs.GetIntAttr(name, defaultValue); } - public override string GetText() + public string GetStringAttr(string name, string defaultValue = "") { - return currentState.Text ?? string.Empty; + return currentState.Attrs.GetStringAttr(name, defaultValue); } - public override int GetIntAttr(string name, int defaultValue = 0) - { - if (currentState.Attrs?.TryGetValue(name, out var value) == true && value is double attr) - { - return (int)attr; - } - - return defaultValue; - } - - public override string GetStringAttr(string name, string defaultValue = "") - { - if (currentState.Attrs?.TryGetValue(name, out var value) == true && value is string attr) - { - return attr; - } - - return defaultValue; - } - - public override MarkBase? GetNextMark() + public IMark? GetNextMark() { if (currentState.Marks == null || currentState.MarkIndex >= currentState.Marks.Count) { @@ -119,7 +109,7 @@ public override string GetStringAttr(string name, string defaultValue = "") return mark; } - public override void IterateContent(T state, Action action) + public void IterateContent(T state, Action action) { if (currentState.Content == null) { @@ -128,10 +118,15 @@ public override void IterateContent(T state, Action action) var prevState = currentState; + var i = 0; foreach (var item in currentState.Content) { + var isFirst = i == 0; + var isLast = i == currentState.Content.Count - 1; + TryUse((JsonObject)item, false); - action(this, state); + action(this, state, isFirst, isLast); + i++; } currentState = prevState; diff --git a/text/Squidex.Text.Tests/RichText/RenderUtils.cs b/text/Squidex.Text.Tests/RichText/RenderUtils.cs index c74611e..cb8d129 100644 --- a/text/Squidex.Text.Tests/RichText/RenderUtils.cs +++ b/text/Squidex.Text.Tests/RichText/RenderUtils.cs @@ -11,7 +11,7 @@ namespace Squidex.Text.RichText; internal static class RenderUtils { - public static (string Markdown, string Html) Render(NodeBase node) + public static (string Markdown, string Html) Render(INode node) { return (RenderMarkdown(node), RenderHtml(node)); } @@ -25,7 +25,7 @@ public static string TrimExpected(this string result) return result; } - public static string RenderHtml(NodeBase node) + public static string RenderHtml(INode node) { var htmlString = new StringWriter(); @@ -34,7 +34,7 @@ public static string RenderHtml(NodeBase node) return htmlString.ToString().TrimExpected(); } - public static string RenderMarkdown(NodeBase node) + public static string RenderMarkdown(INode node) { var markdownString = new StringWriter(); diff --git a/text/Squidex.Text.Tests/RichText/RichTextComplexTests.cs b/text/Squidex.Text.Tests/RichText/RichTextComplexTests.cs index bd881cb..112f616 100644 --- a/text/Squidex.Text.Tests/RichText/RichTextComplexTests.cs +++ b/text/Squidex.Text.Tests/RichText/RichTextComplexTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.Json; using Squidex.RichText.Json; using Squidex.Text.RichText.Model; using Xunit; @@ -18,7 +19,7 @@ public void Should_render_complex_state() { var source = new Node { - Type = NodeType.Document, + Type = NodeType.Doc, Content = [ new Node @@ -136,7 +137,7 @@ public void Should_render_complex_state_from_json() { var source = new JsonObject { - ["type"] = "document", + ["type"] = "doc", ["content"] = new JsonArray { new JsonObject @@ -254,4 +255,21 @@ public void Should_render_complex_state_from_json() Assert.Equal(expectedHtml.TrimExpected(), html); } + + [Fact] + public void Should_render_from_files() + { + var inputJson = File.ReadAllText("RichText/ComplexText.json"); + var inputNode = JsonSerializer.Deserialize(inputJson)!; + + var (markdown, html) = RenderUtils.Render(inputNode); + + Assert.Equal( + File.ReadAllText("RichText/ComplexText.md").TrimExpected(), + markdown); + + Assert.Equal( + File.ReadAllText("RichText/ComplexText.html").TrimExpected(), + html); + } } diff --git a/text/Squidex.Text.Tests/RichText/RichTextInlineTests.cs b/text/Squidex.Text.Tests/RichText/RichTextInlineTests.cs index 41b2b7e..e630037 100644 --- a/text/Squidex.Text.Tests/RichText/RichTextInlineTests.cs +++ b/text/Squidex.Text.Tests/RichText/RichTextInlineTests.cs @@ -89,7 +89,7 @@ public void Should_render_underline() var (markdown, html) = RenderUtils.Render(source); var expectedMarkdown = @" -_Text1_"; +Text1"; Assert.Equal(expectedMarkdown.TrimExpected(), markdown); @@ -159,7 +159,7 @@ public void Should_render_nested() var (markdown, html) = RenderUtils.Render(source); var expectedMarkdown = @" -**_*`Text1`*_**"; +***`Text1`***"; Assert.Equal(expectedMarkdown.TrimExpected(), markdown); diff --git a/text/Squidex.Text.Tests/RichText/RichTextTests.cs b/text/Squidex.Text.Tests/RichText/RichTextTests.cs index cc1b8a1..d0bbb23 100644 --- a/text/Squidex.Text.Tests/RichText/RichTextTests.cs +++ b/text/Squidex.Text.Tests/RichText/RichTextTests.cs @@ -48,7 +48,7 @@ public void Should_render_paragraphs() { var source = new Node { - Type = NodeType.Document, + Type = NodeType.Doc, Content = [ new Node @@ -146,7 +146,7 @@ public void Should_render_horizontal_line() { var source = new Node { - Type = NodeType.HorizontalLine + Type = NodeType.HorizontalRule }; var (markdown, html) = RenderUtils.Render(source); @@ -162,6 +162,70 @@ public void Should_render_horizontal_line() Assert.Equal(expectedHtml.TrimExpected(), html); } + [Fact] + public void Should_render_block_quote() + { + var source = new Node + { + Type = NodeType.Doc, + Content = + [ + new Node + { + Type = NodeType.Blockquote, + Content = + [ + new Node + { + Type = NodeType.Paragraph, + Content = + [ + new Node + { + Type = NodeType.Text, + Text = "Text1" + }, + ] + }, + ], + }, + new Node + { + Type = NodeType.Paragraph, + Content = + [ + new Node + { + Type = NodeType.Text, + Text = "Text2" + }, + ] + }, + ] + }; + + var (markdown, html) = RenderUtils.Render(source); + + var expectedMarkdown = @" +> Text1 + +Text2"; + + Assert.Equal(expectedMarkdown.TrimExpected(), markdown); + + var expectedHtml = @" +
+

+ Text1 +

+
+

+ Text2 +

"; + + Assert.Equal(expectedHtml.TrimExpected(), html); + } + [Fact] public void Should_render_code_block() { diff --git a/text/Squidex.Text.Tests/Squidex.Text.Tests.csproj b/text/Squidex.Text.Tests/Squidex.Text.Tests.csproj index 973c043..01fe571 100644 --- a/text/Squidex.Text.Tests/Squidex.Text.Tests.csproj +++ b/text/Squidex.Text.Tests/Squidex.Text.Tests.csproj @@ -43,6 +43,15 @@ + + Always + + + Always + + + Always + Always diff --git a/text/Squidex.Text/RichText/HtmlWriterVisitor.cs b/text/Squidex.Text/RichText/HtmlWriterVisitor.cs index 8bc9f48..276acfb 100644 --- a/text/Squidex.Text/RichText/HtmlWriterVisitor.cs +++ b/text/Squidex.Text/RichText/HtmlWriterVisitor.cs @@ -19,56 +19,56 @@ private HtmlWriterVisitor(HtmlTextWriter writer) this.writer = writer; } - public static void Render(NodeBase node, TextWriter textWriter) + public static void Render(INode node, TextWriter textWriter) { var newWriter = new HtmlTextWriter(textWriter, new string(' ', 4)); new HtmlWriterVisitor(newWriter).Visit(node); } - protected override void VisitHardBreak(NodeBase node) + protected override void VisitHardBreak(INode node) { writer.WriteLine(); writer.WriteBreak(); writer.WriteLine(); } - protected override void VisitHorizontalLine(NodeBase node) + protected override void VisitHorizontalRule(INode node) { writer.WriteFullBeginTag("hr"); } - protected override void VisitBlockquote(NodeBase node) + protected override void VisitBlockquote(INode node) { EmbedInTag(node, "blockquote"); } - protected override void VisitBulletList(NodeBase node) + protected override void VisitBulletList(INode node) { EmbedInTag(node, "ul"); } - protected override void VisitHeading(NodeBase node, int level) + protected override void VisitHeading(INode node, int level) { EmbedInTag(node, $"h{level}"); } - protected override void VisitListItem(NodeBase node) + protected override void VisitListItem(INode node) { EmbedInTag(node, "li"); } - protected override void VisitOrderedList(NodeBase node) + protected override void VisitOrderedList(INode node) { EmbedInTag(node, "ol"); } - protected override void VisitParagraph(NodeBase node) + protected override void VisitParagraph(INode node) { EmbedInTag(node, "p"); } - protected override void VisitImage(NodeBase node, string? src, string? alt, string? title) + protected override void VisitImage(INode node, string? src, string? alt, string? title) { writer.AddNonEmptyAttribute(nameof(src), src); writer.AddNonEmptyAttribute(nameof(alt), alt); @@ -77,7 +77,7 @@ protected override void VisitImage(NodeBase node, string? src, string? alt, stri writer.RenderEndTag(); } - protected override void VisitCodeBlock(NodeBase node, string? language) + protected override void VisitCodeBlock(INode node, string? language) { writer.AddNonEmptyAttribute("spellcheck", "false"); writer.AddNonEmptyAttribute("class", language, l => $"language-{l}"); @@ -91,27 +91,27 @@ protected override void VisitCodeBlock(NodeBase node, string? language) writer.RenderEndTag(); } - protected override void VisitBold(MarkBase mark, Action inner) + protected override void VisitBold(IMark mark, Action inner) { EmbedInTag(inner, "strong"); } - protected override void VisitCode(MarkBase mark, Action inner) + protected override void VisitCode(IMark mark, Action inner) { EmbedInTag(inner, "code"); } - protected override void VisitItalic(MarkBase mark, Action inner) + protected override void VisitItalic(IMark mark, Action inner) { EmbedInTag(inner, "em"); } - protected override void VisitUnderline(MarkBase mark, Action inner) + protected override void VisitUnderline(IMark mark, Action inner) { EmbedInTag(inner, "u"); } - protected override void VisitLink(MarkBase mark, Action inner, string? href, string? target) + protected override void VisitLink(IMark mark, Action inner, string? href, string? target) { writer.WriteBeginTag("a"); writer.WriteNonEmptyAttribute(nameof(href), href); @@ -121,12 +121,12 @@ protected override void VisitLink(MarkBase mark, Action inner, string? href, str writer.WriteEndTag("a"); } - protected override void VisitText(NodeBase node) + protected override void VisitText(INode node) { - writer.Write(node.GetText()); + writer.Write(node.Text ?? string.Empty); } - private void EmbedInTag(NodeBase node, string tag) + private void EmbedInTag(INode node, string tag) { writer.RenderBeginTag(tag); VisitChildren(node); diff --git a/text/Squidex.Text/RichText/IndentedWriter.cs b/text/Squidex.Text/RichText/IndentedWriter.cs new file mode 100644 index 0000000..f8ca03d --- /dev/null +++ b/text/Squidex.Text/RichText/IndentedWriter.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using static System.Net.Mime.MediaTypeNames; + +namespace Squidex.Text.RichText; + +internal sealed class IndentedWriter +{ + private readonly StringWriter writer; + private readonly HashSet indents = new HashSet(); + + public IndentedWriter(StringWriter writer) + { + this.writer = writer; + } + + public void WriteLine(string text) + { + WriteIndentsCore(); + + writer.WriteLine(text); + } + + public void WriteLine() + { + writer.WriteLine(); + } + + public void Write(string text) + { + writer.Write(text); + } + + private void WriteIndentsCore() + { + foreach (var indent in indents) + { + writer.Write(indent); + } + } +} diff --git a/text/Squidex.Text/RichText/MarkdownVisitor.cs b/text/Squidex.Text/RichText/MarkdownVisitor.cs index 466df53..fce014d 100644 --- a/text/Squidex.Text/RichText/MarkdownVisitor.cs +++ b/text/Squidex.Text/RichText/MarkdownVisitor.cs @@ -21,55 +21,73 @@ public MarkdownVisitor(NormalizeRenderer renderer) this.renderer = renderer; } - public static void Render(NodeBase node, TextWriter textWriter) + public static void Render(INode node, TextWriter textWriter) { var newRenderer = new NormalizeRenderer(textWriter); new MarkdownVisitor(newRenderer).Visit(node); } - protected override void VisitBlockquote(NodeBase node) + protected override void VisitBlockquote(INode node) { renderer.PushIndent("> "); VisitChildren(node); renderer.PopIndent(); + + FinishBlock(true); } - protected override void VisitBulletList(NodeBase node) + protected override void VisitBulletList(INode node) { - node.IterateContent(this, (node, self) => + IterateChildren(node, this, (child, self) => { self.renderer.EnsureLine(); self.renderer.Write('*'); - self.renderer.Write(' '); - self.renderer.PushIndent(" "); - self.Visit(node); + self.renderer.Write(" "); + self.renderer.PushIndent(" "); + self.Visit(child); self.renderer.PopIndent(); + + if (!IsLastInContainer) + { + self.renderer.EnsureLine(); + self.renderer.WriteLine(); + } }); - renderer.FinishBlock(true); + renderer.EnsureLine(); + + FinishBlock(true); } - protected override void VisitOrderedList(NodeBase node) + protected override void VisitOrderedList(INode node) { currentIndex = 0; - node.IterateContent(this, (node, self) => + IterateChildren(node, this, (child, self) => { self.currentIndex++; self.renderer.EnsureLine(); self.renderer.Write(self.currentIndex.ToString(CultureInfo.InvariantCulture)); self.renderer.Write('.'); - self.renderer.Write(' '); - self.renderer.PushIndent(new string(' ', IntLog10Fast(currentIndex) + 3)); - self.Visit(node); + self.renderer.Write(" "); + self.renderer.PushIndent(new string(' ', IntLog10Fast(currentIndex) + 4)); + self.Visit(child); self.renderer.PopIndent(); + + if (!IsLastInContainer) + { + self.renderer.EnsureLine(); + self.renderer.WriteLine(); + } }); - renderer.FinishBlock(true); + renderer.EnsureLine(); + + FinishBlock(true); } - protected override void VisitCodeBlock(NodeBase node, string? language) + protected override void VisitCodeBlock(INode node, string? language) { renderer.Write("```"); renderer.Write(language ?? string.Empty); @@ -77,9 +95,11 @@ protected override void VisitCodeBlock(NodeBase node, string? language) VisitChildren(node); renderer.WriteLine(); renderer.Write("```"); + + FinishBlock(true); } - protected override void VisitImage(NodeBase node, string? src, string? alt, string? title) + protected override void VisitImage(INode node, string? src, string? alt, string? title) { renderer.Write('!'); renderer.Write('['); @@ -99,17 +119,19 @@ protected override void VisitImage(NodeBase node, string? src, string? alt, stri renderer.Write(')'); } - protected override void VisitHorizontalLine(NodeBase node) + protected override void VisitHorizontalRule(INode node) { renderer.WriteLine("---"); + + FinishBlock(false); } - protected override void VisitHardBreak(NodeBase node) + protected override void VisitHardBreak(INode node) { renderer.WriteLine(); } - protected override void VisitHeading(NodeBase node, int level) + protected override void VisitHeading(INode node, int level) { for (var i = 0; i < level; i++) { @@ -118,16 +140,18 @@ protected override void VisitHeading(NodeBase node, int level) renderer.Write(' '); VisitChildren(node); - renderer.FinishBlock(false); + + FinishBlock(true); } - protected override void VisitParagraph(NodeBase node) + protected override void VisitParagraph(INode node) { VisitChildren(node); - renderer.FinishBlock(true); + + FinishBlock(true); } - protected override void VisitLink(MarkBase mark, Action inner, string? href, string? target) + protected override void VisitLink(IMark mark, Action inner, string? href, string? target) { if (string.IsNullOrWhiteSpace(href)) { @@ -143,37 +167,38 @@ protected override void VisitLink(MarkBase mark, Action inner, string? href, str renderer.Write(')'); } - protected override void VisitCode(MarkBase mark, Action inner) + protected override void VisitCode(IMark mark, Action inner) { renderer.Write('`'); inner(); renderer.Write('`'); } - protected override void VisitBold(MarkBase mark, Action inner) + protected override void VisitBold(IMark mark, Action inner) { renderer.Write("**"); inner(); renderer.Write("**"); } - protected override void VisitItalic(MarkBase mark, Action inner) + protected override void VisitItalic(IMark mark, Action inner) { renderer.Write('*'); inner(); renderer.Write('*'); } - protected override void VisitUnderline(MarkBase mark, Action inner) + protected override void VisitText(INode node) { - renderer.Write('_'); - inner(); - renderer.Write('_'); + renderer.Write(node.Text); } - protected override void VisitText(NodeBase node) + private void FinishBlock(bool newLine) { - renderer.Write(node.GetText()); + if (!IsLastInContainer) + { + renderer.FinishBlock(newLine); + } } private static int IntLog10Fast(int input) => diff --git a/text/Squidex.Text/RichText/Model/Attributes.cs b/text/Squidex.Text/RichText/Model/Attributes.cs index c92ef2c..8fb492b 100644 --- a/text/Squidex.Text/RichText/Model/Attributes.cs +++ b/text/Squidex.Text/RichText/Model/Attributes.cs @@ -5,8 +5,49 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.Json; + namespace Squidex.Text.RichText.Model; public sealed class Attributes : Dictionary { + public int GetIntAttr(string name, int defaultValue = 0) + { + if (!TryGetValue(name, out var attr)) + { + return defaultValue; + } + + if (attr is int value) + { + return value; + } + + if (attr is JsonElement element && element.ValueKind == JsonValueKind.Number) + { + return element.GetInt32()!; + } + + return defaultValue; + } + + public string GetStringAttr(string name, string defaultValue = "") + { + if (!TryGetValue(name, out var attr)) + { + return defaultValue; + } + + if (attr is string value) + { + return value; + } + + if (attr is JsonElement element && element.ValueKind == JsonValueKind.String) + { + return element.GetString()!; + } + + return defaultValue; + } } diff --git a/text/Squidex.Text/RichText/Model/Attributed.cs b/text/Squidex.Text/RichText/Model/IAttributed.cs similarity index 69% rename from text/Squidex.Text/RichText/Model/Attributed.cs rename to text/Squidex.Text/RichText/Model/IAttributed.cs index e003b0b..47c9973 100644 --- a/text/Squidex.Text/RichText/Model/Attributed.cs +++ b/text/Squidex.Text/RichText/Model/IAttributed.cs @@ -7,9 +7,9 @@ namespace Squidex.Text.RichText.Model; -public abstract class Attributed +public interface IAttributed { - public abstract int GetIntAttr(string name, int defaultValue = 0); + int GetIntAttr(string name, int defaultValue = 0); - public abstract string GetStringAttr(string name, string defaultValue = ""); + string GetStringAttr(string name, string defaultValue = ""); } diff --git a/text/Squidex.Text/RichText/Model/MarkBase.cs b/text/Squidex.Text/RichText/Model/IMark.cs similarity index 82% rename from text/Squidex.Text/RichText/Model/MarkBase.cs rename to text/Squidex.Text/RichText/Model/IMark.cs index b348cb7..91db79b 100644 --- a/text/Squidex.Text/RichText/Model/MarkBase.cs +++ b/text/Squidex.Text/RichText/Model/IMark.cs @@ -7,7 +7,7 @@ namespace Squidex.Text.RichText.Model; -public abstract class MarkBase : Attributed +public interface IMark : IAttributed { - public abstract MarkType GetMarkType(); + MarkType Type { get; } } diff --git a/text/Squidex.Text/RichText/Model/NodeBase.cs b/text/Squidex.Text/RichText/Model/INode.cs similarity index 60% rename from text/Squidex.Text/RichText/Model/NodeBase.cs rename to text/Squidex.Text/RichText/Model/INode.cs index d311533..3564a3e 100644 --- a/text/Squidex.Text/RichText/Model/NodeBase.cs +++ b/text/Squidex.Text/RichText/Model/INode.cs @@ -7,17 +7,17 @@ namespace Squidex.Text.RichText.Model; -public abstract class NodeBase : Attributed +public interface INode : IAttributed { - public abstract NodeType GetNodeType(); + NodeType Type { get; } - public abstract string GetText(); + string? Text { get; } - public abstract MarkBase? GetNextMark(); + IMark? GetNextMark(); - public abstract void IterateContent(T state, Action action); + void IterateContent(T state, Action action); - public virtual void Reset() + public void Reset() { } } diff --git a/text/Squidex.Text/RichText/Model/Mark.cs b/text/Squidex.Text/RichText/Model/Mark.cs index 6ed63b7..b3a2b64 100644 --- a/text/Squidex.Text/RichText/Model/Mark.cs +++ b/text/Squidex.Text/RichText/Model/Mark.cs @@ -5,36 +5,26 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.Json.Serialization; + namespace Squidex.Text.RichText.Model; -public class Mark : MarkBase +public class Mark : IMark { + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] public MarkType Type { get; set; } + [JsonPropertyName("attrs")] public Attributes? Attributes { get; set; } - public override MarkType GetMarkType() - { - return Type; - } - - public override int GetIntAttr(string name, int defaultValue = 0) + public int GetIntAttr(string name, int defaultValue = 0) { - if (Attributes?.TryGetValue(name, out var attr) == true && attr is int value) - { - return value; - } - - return defaultValue; + return Attributes?.GetIntAttr(name, defaultValue) ?? defaultValue; } - public override string GetStringAttr(string name, string defaultValue = "") + public string GetStringAttr(string name, string defaultValue = "") { - if (Attributes?.TryGetValue(name, out var attr) == true && attr is string value) - { - return value; - } - - return defaultValue; + return Attributes?.GetStringAttr(name, defaultValue) ?? defaultValue; } } diff --git a/text/Squidex.Text/RichText/Model/MarkType.cs b/text/Squidex.Text/RichText/Model/MarkType.cs index 137343c..861330a 100644 --- a/text/Squidex.Text/RichText/Model/MarkType.cs +++ b/text/Squidex.Text/RichText/Model/MarkType.cs @@ -9,6 +9,7 @@ namespace Squidex.Text.RichText.Model; public enum MarkType { + Undefined, Bold, Code, Italic, diff --git a/text/Squidex.Text/RichText/Model/Node.cs b/text/Squidex.Text/RichText/Model/Node.cs index 56e0cc2..477590a 100644 --- a/text/Squidex.Text/RichText/Model/Node.cs +++ b/text/Squidex.Text/RichText/Model/Node.cs @@ -5,38 +5,37 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.Json; +using System.Text.Json.Serialization; + namespace Squidex.Text.RichText.Model; -public sealed class Node : NodeBase +public sealed class Node : INode { private int currentMark; + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] public NodeType Type { get; set; } - public string? Text { get; set; } + [JsonPropertyName("text")] + public string Text { get; set; } + [JsonPropertyName("marks")] public Mark[]? Marks { get; set; } + [JsonPropertyName("content")] public Node[]? Content { get; set; } + [JsonPropertyName("attrs")] public Attributes? Attributes { get; set; } - public override NodeType GetNodeType() - { - return Type; - } - - public override string GetText() - { - return Text ?? string.Empty; - } - - public override void Reset() + public void Reset() { currentMark = 0; } - public override MarkBase? GetNextMark() + public IMark? GetNextMark() { if (Marks == null || currentMark >= Marks.Length) { @@ -46,36 +45,31 @@ public override void Reset() return Marks[currentMark++]; } - public override void IterateContent(T state, Action action) + public void IterateContent(T state, Action action) { if (Content == null) { return; } + var i = 0; foreach (var item in Content) { - action(item, state); + var isFirst = i == 0; + var isLast = i == Content.Length - 1; + + action(item, state, isFirst, isLast); + i++; } } - public override int GetIntAttr(string name, int defaultValue = 0) + public int GetIntAttr(string name, int defaultValue = 0) { - if (Attributes?.TryGetValue(name, out var attr) == true && attr is int value) - { - return value; - } - - return defaultValue; + return Attributes?.GetIntAttr(name, defaultValue) ?? defaultValue; } - public override string GetStringAttr(string name, string defaultValue = "") + public string GetStringAttr(string name, string defaultValue = "") { - if (Attributes?.TryGetValue(name, out var attr) == true && attr is string value) - { - return value; - } - - return defaultValue; + return Attributes?.GetStringAttr(name, defaultValue) ?? defaultValue; } } diff --git a/text/Squidex.Text/RichText/Model/NodeType.cs b/text/Squidex.Text/RichText/Model/NodeType.cs index 13c3e96..1e3f96b 100644 --- a/text/Squidex.Text/RichText/Model/NodeType.cs +++ b/text/Squidex.Text/RichText/Model/NodeType.cs @@ -9,12 +9,13 @@ namespace Squidex.Text.RichText.Model; public enum NodeType { + Undefined, Blockquote, BulletList, CodeBlock, - Document, + Doc, HardBreak, - HorizontalLine, + HorizontalRule, Image, ListItem, OrderedList, diff --git a/text/Squidex.Text/RichText/Visitor.cs b/text/Squidex.Text/RichText/Visitor.cs index 681c872..8e87b85 100644 --- a/text/Squidex.Text/RichText/Visitor.cs +++ b/text/Squidex.Text/RichText/Visitor.cs @@ -12,14 +12,26 @@ namespace Squidex.Text.RichText; public abstract class Visitor { private readonly Action visitInner; - private NodeBase currentNode; + private INode currentNode; + + public bool IsLastInContainer { get; private set; } + + public bool IsFirstInContainer { get; private set; } protected Visitor() { visitInner = VisitCurrentMarkOrNode; } - public void Visit(NodeBase node) + public void VisitRoot(INode node) + { + IsLastInContainer = true; + IsFirstInContainer = true; + + Visit(node); + } + + public void Visit(INode node) { currentNode = node; currentNode.Reset(); @@ -41,9 +53,9 @@ private void VisitCurrentMarkOrNode() } } - private void VisitMark(MarkBase mark) + private void VisitMark(IMark mark) { - var type = mark.GetMarkType(); + var type = mark.Type; switch (type) { @@ -65,39 +77,39 @@ private void VisitMark(MarkBase mark) VisitUnderline(mark, visitInner); break; default: - ThrowInvalidType(type); + visitInner(); break; } } - protected virtual void VisitBold(MarkBase mark, Action inner) + protected virtual void VisitBold(IMark mark, Action inner) { inner(); } - protected virtual void VisitCode(MarkBase mark, Action inner) + protected virtual void VisitCode(IMark mark, Action inner) { inner(); } - protected virtual void VisitItalic(MarkBase mark, Action inner) + protected virtual void VisitItalic(IMark mark, Action inner) { inner(); } - protected virtual void VisitLink(MarkBase mark, Action inner, string? href, string? target) + protected virtual void VisitLink(IMark mark, Action inner, string? href, string? target) { inner(); } - protected virtual void VisitUnderline(MarkBase mark, Action inner) + protected virtual void VisitUnderline(IMark mark, Action inner) { inner(); } - private void VisitNode(NodeBase node) + private void VisitNode(INode node) { - var type = node.GetNodeType(); + var type = node.Type; switch (type) { @@ -121,14 +133,14 @@ private void VisitNode(NodeBase node) case NodeType.BulletList: VisitBulletList(node); break; - case NodeType.Document: + case NodeType.Doc: VisitDocument(node); break; case NodeType.HardBreak: VisitHardBreak(node); break; - case NodeType.HorizontalLine: - VisitHorizontalLine(node); + case NodeType.HorizontalRule: + VisitHorizontalRule(node); break; case NodeType.ListItem: VisitListItem(node); @@ -143,86 +155,93 @@ private void VisitNode(NodeBase node) VisitText(node); break; default: - ThrowInvalidType(type); + VisitChildren(node); break; } } - protected virtual void VisitBlockquote(NodeBase node) + protected virtual void VisitBlockquote(INode node) { VisitChildren(node); } - protected virtual void VisitBulletList(NodeBase node) + protected virtual void VisitBulletList(INode node) { VisitChildren(node); } - protected virtual void VisitCodeBlock(NodeBase node, string? language) + protected virtual void VisitCodeBlock(INode node, string? language) { VisitChildren(node); } - protected virtual void VisitDocument(NodeBase node) + protected virtual void VisitDocument(INode node) { VisitChildren(node); } - protected virtual void VisitHardBreak(NodeBase node) + protected virtual void VisitHardBreak(INode node) { VisitChildren(node); } - protected virtual void VisitHeading(NodeBase node, int level) + protected virtual void VisitHeading(INode node, int level) { VisitChildren(node); } - protected virtual void VisitHorizontalLine(NodeBase node) + protected virtual void VisitHorizontalRule(INode node) { VisitChildren(node); } - protected virtual void VisitImage(NodeBase node, string? src, string? alt, string? title) + protected virtual void VisitImage(INode node, string? src, string? alt, string? title) { VisitChildren(node); } - protected virtual void VisitListItem(NodeBase node) + protected virtual void VisitListItem(INode node) { VisitChildren(node); } - protected virtual void VisitOrderedList(NodeBase node) + protected virtual void VisitOrderedList(INode node) { VisitChildren(node); } - protected virtual void VisitParagraph(NodeBase node) + protected virtual void VisitParagraph(INode node) { VisitChildren(node); } - protected virtual void VisitText(NodeBase node) + protected virtual void VisitText(INode node) { VisitChildren(node); } - protected void VisitChildren(NodeBase node) + protected void IterateChildren(INode node, T state, Action action) { - node.IterateContent(this, (child, self) => + var prevIsLastInContainer = IsLastInContainer; + var prevIsFirstInContainer = IsFirstInContainer; + + node.IterateContent((self: this, state, action), (child, _, isFirst, isLast) => { - self.Visit(child); + _.self.IsFirstInContainer = isFirst; + _.self.IsLastInContainer = isLast; + + _.action(child, _.state); }); - } - private static void ThrowInvalidType(MarkType type) - { - throw new InvalidOperationException($"Invalid type '{type}'."); + IsLastInContainer = prevIsLastInContainer; + IsFirstInContainer = prevIsFirstInContainer; } - private static void ThrowInvalidType(NodeType type) + protected void VisitChildren(INode node) { - throw new InvalidOperationException($"Invalid type '{type}'."); + IterateChildren(node, this, (child, self) => + { + self.Visit(child); + }); } }