From 09995cde5c63fae559d6b988ff8dd5c46e3cca8e Mon Sep 17 00:00:00 2001 From: Edward Loveall Date: Sun, 18 Jul 2021 20:21:44 -0400 Subject: [PATCH] Overlapping refactor Example: * Text: "strong and emphasized only" * Markups: * Strong: 0..10 * Emphasis: 7..21 First, get all the borders of the markups, including the start (0) and end (text.size) indexes of the text in order: ``` [0, 7, 10, 21, 26] ``` Then attach markups to each range. Note that the ranges are exclusive; they don't include the final number: * 0...7: Strong * 7...10: Strong, Emphasized * 10...21: Emphasized * 21...26: N/A Bundle each range and it's related markups into a value object RangeWithMarkup and return the list. Loop through that list and recursively apply each markup to each segment of text: * Apply a `Strong` markup to the text "strong " * Apply a `Strong` markup to the text "and" * Wrap that in an `Emphasis` markup * Apply an `Emphasis` markup to the text " emphasized" * Leave the text " only" as is --- This has the side effect of breaking up the nodes more than they need to be broken up. For example right now the algorithm creates this HTML: ``` strong and ``` instead of: ``` strong and ``` But that's a task for another day. --- spec/classes/markup_converter_spec.cr | 227 +++++++++++++++----------- src/classes/markup_converter.cr | 98 +++++------ 2 files changed, 172 insertions(+), 153 deletions(-) diff --git a/spec/classes/markup_converter_spec.cr b/spec/classes/markup_converter_spec.cr index f77022e..20546df 100644 --- a/spec/classes/markup_converter_spec.cr +++ b/spec/classes/markup_converter_spec.cr @@ -3,31 +3,18 @@ require "../spec_helper" include Nodes describe MarkupConverter do - it "returns just text with no markups" do - json = <<-JSON - { - "text": "Hello, world", - "type": "P", - "markups": [], - "href": null, - "iframe": null, - "layout": null, - "metadata": null - } - JSON - paragraph = PostResponse::Paragraph.from_json(json) + describe "#convert" do + it "returns just text with no markups" do + markups = [] of PostResponse::Markup - result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + result = MarkupConverter.convert(text: "Hello, world", markups: markups) - result.should eq([Text.new(content: "Hello, world")]) - end + result.should eq([Text.new(content: "Hello, world")]) + end - it "returns just text with multiple markups" do - json = <<-JSON - { - "text": "strong and emphasized only", - "type": "P", - "markups": [ + it "returns text with multiple markups" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ { "title": null, "type": "STRONG", @@ -46,31 +33,22 @@ describe MarkupConverter do "rel": null, "anchorType": null } - ], - "href": null, - "iframe": null, - "layout": null, - "metadata": null - } - JSON - paragraph = PostResponse::Paragraph.from_json(json) + ] + JSON - result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + result = MarkupConverter.convert(text: "strong and emphasized only", markups: markups) - result.should eq([ - Strong.new(children: [Text.new(content: "strong")] of Child), - Text.new(content: " and "), - Emphasis.new(children: [Text.new(content: "emphasized")] of Child), - Text.new(content: " only"), - ]) - end + result.should eq([ + Strong.new(children: [Text.new(content: "strong")] of Child), + Text.new(content: " and "), + Emphasis.new(children: [Text.new(content: "emphasized")] of Child), + Text.new(content: " only"), + ]) + end - it "returns just text with a code markup" do - json = <<-JSON - { - "text": "inline code", - "type": "P", - "markups": [ + it "returns text with a code markup" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ { "title": null, "type": "CODE", @@ -80,29 +58,20 @@ describe MarkupConverter do "rel": null, "anchorType": null } - ], - "href": null, - "iframe": null, - "layout": null, - "metadata": null - } - JSON - paragraph = PostResponse::Paragraph.from_json(json) + ] + JSON - result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + result = MarkupConverter.convert(text: "inline code", markups: markups) - result.should eq([ - Text.new(content: "inline "), - Code.new(children: [Text.new(content: "code")] of Child), - ]) - end + result.should eq([ + Text.new(content: "inline "), + Code.new(children: [Text.new(content: "code")] of Child), + ]) + end - it "renders an A LINK markup" do - json = <<-JSON - { - "text": "I am a Link", - "type": "P", - "markups": [ + it "renders an A-LINK markup" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ { "title": "", "type": "A", @@ -112,30 +81,20 @@ describe MarkupConverter do "rel": "", "anchorType": "LINK" } - ], - "href": null, - "iframe": null, - "layout": null, - "metadata": null - } - JSON + ] + JSON - paragraph = PostResponse::Paragraph.from_json(json) + result = MarkupConverter.convert(text: "I am a Link", markups: markups) - result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + result.should eq([ + Text.new("I am a "), + Anchor.new(children: [Text.new("Link")] of Child, href: "https://example.com"), + ]) + end - result.should eq([ - Text.new("I am a "), - Anchor.new(children: [Text.new("Link")] of Child, href: "https://example.com"), - ]) - end - - it "renders an A USER markup" do - json = <<-JSON - { - "text": "Hi Dr Nick!", - "type": "P", - "markups": [ + it "renders an A-USER markup" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ { "title": null, "type": "A", @@ -146,22 +105,92 @@ describe MarkupConverter do "rel": null, "anchorType": "USER" } - ], - "href": null, - "iframe": null, - "layout": null, - "metadata": null - } - JSON + ] + JSON - paragraph = PostResponse::Paragraph.from_json(json) + result = MarkupConverter.convert(text: "Hi Dr Nick!", markups: markups) - result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + result.should eq([ + Text.new("Hi "), + UserAnchor.new(children: [Text.new("Dr Nick")] of Child, userId: "abc123"), + Text.new("!"), + ]) + end - result.should eq([ - Text.new("Hi "), - UserAnchor.new(children: [Text.new("Dr Nick")] of Child, userId: "abc123"), - Text.new("!"), - ]) + it "renders overlapping markups" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ + { + "title": null, + "type": "STRONG", + "href": null, + "userId": null, + "start": 7, + "end": 15, + "rel": null, + "anchorType": null + }, + { + "title": null, + "type": "EM", + "href": null, + "userId": null, + "start": 0, + "end": 10, + "rel": null, + "anchorType": null + } + ] + JSON + + result = MarkupConverter.convert(text: "Italic and bold", markups: markups) + + result.should eq([ + Emphasis.new(children: [Text.new("Italic ")] of Child), + Emphasis.new(children: [ + Strong.new(children: [Text.new("and")] of Child), + ] of Child), + Strong.new(children: [Text.new(" bold")] of Child), + ]) + end + end + + describe "#wrap_in_markups" do + it "returns text wrapped in multiple markups" do + markups = Array(PostResponse::Markup).from_json <<-JSON + [ + { + "title": null, + "type": "STRONG", + "href": null, + "start": 0, + "end": 17, + "rel": null, + "anchorType": null + }, + { + "title": null, + "type": "A", + "href": null, + "userId": "abc123", + "start": 13, + "end": 17, + "rel": null, + "anchorType": "USER" + } + ] + JSON + converter = MarkupConverter.new(text: "it's ya boi, jack", markups: markups) + + result = converter.wrap_in_markups("jack", markups) + + result.should eq([ + UserAnchor.new(children: [ + Strong.new([ + Text.new("jack"), + ] of Child), + ] of Child, userId: "abc123"), + ]) + end end end diff --git a/src/classes/markup_converter.cr b/src/classes/markup_converter.cr index 56ebfe9..50ba18c 100644 --- a/src/classes/markup_converter.cr +++ b/src/classes/markup_converter.cr @@ -1,7 +1,8 @@ -struct StringSplit - getter pre, content, post +struct RangeWithMarkup + getter range : Range(Int32, Int32) + getter markups : Array(PostResponse::Markup) - def initialize(@pre : String, @content : String, @post : String) + def initialize(@range : Range, @markups : Array(PostResponse::Markup)) end end @@ -19,64 +20,53 @@ class MarkupConverter end def convert : Array(Child) - if markups.empty? - return [Text.new(content: text)] of Child + ranges.flat_map do |range_with_markups| + text_to_wrap = text[range_with_markups.range] + wrap_in_markups(text_to_wrap, range_with_markups.markups) end - offset = 0 - text_splits = markups.reduce([text]) do |splits, markup| - individual_split = split_string(markup.start - offset, markup.end - offset, splits.pop) - offset = markup.end - splits.push(individual_split.pre) - splits.push(individual_split.content) - splits.push(individual_split.post) - end - text_splits.in_groups_of(2, "").map_with_index do |split, index| - plain, to_be_marked = split - markup = markups.fetch(index, Text.new("")) - if markup.is_a?(Text) - [Text.new(plain)] of Child - else - case markup.type - when PostResponse::MarkupType::A - if href = markup.href - container = Anchor.new(children: [Text.new(to_be_marked)] of Child, href: href) - elsif userId = markup.userId - container = UserAnchor.new(children: [Text.new(to_be_marked)] of Child, userId: userId) - else - container = Empty.new - end - when PostResponse::MarkupType::CODE - container = construct_markup(text: to_be_marked, container: Code) - when PostResponse::MarkupType::EM - container = construct_markup(text: to_be_marked, container: Emphasis) - when PostResponse::MarkupType::STRONG - container = construct_markup(text: to_be_marked, container: Strong) - else - container = construct_markup(text: to_be_marked, container: Code) - end - [Text.new(plain), container] of Child + end + + private def ranges + markup_boundaries = markups.flat_map { |markup| [markup.start, markup.end] } + bookended_markup_boundaries = ([0] + markup_boundaries + [text.size]).uniq.sort + bookended_markup_boundaries.each_cons(2).map do |boundaries| + range = (boundaries[0]...boundaries[1]) + covered_markups = markups.select do |markup| + range.covers?(markup.start) || range.covers?(markup.end - 1) end - end.flatten.reject(&.empty?) + RangeWithMarkup.new(range, covered_markups) + end.to_a end - private def construct_markup(text : String, container : Container.class) : Child - container.new(children: [Text.new(content: text)] of Child) + def wrap_in_markups(child : String | Child, markups : Array(PostResponse::Markup)) : Array(Child) + if child.is_a?(String) + child = Text.new(child) + end + if markups.first?.nil? + return [child] of Child + end + marked_up = markup_node_in_container(child, markups[0]) + wrap_in_markups(marked_up, markups[1..]) end - private def split_string(start : Int32, finish : Int32, string : String) - if start.zero? - pre = "" + private def markup_node_in_container(child : Child, markup : PostResponse::Markup) + case markup.type + when PostResponse::MarkupType::A + if href = markup.href + Anchor.new(href: href, children: [child] of Child) + elsif userId = markup.userId + UserAnchor.new(userId: userId, children: [child] of Child) + else + Empty.new + end + when PostResponse::MarkupType::CODE + Code.new(children: [child] of Child) + when PostResponse::MarkupType::EM + Emphasis.new(children: [child] of Child) + when PostResponse::MarkupType::STRONG + Strong.new(children: [child] of Child) else - pre = string[0...start] + Code.new(children: [child] of Child) end - - if finish == string.size - post = "" - else - post = string[finish...string.size] - end - - content = string[start...finish] - StringSplit.new(pre, content, post) end end