From 5a5f68bcf807ba5ebf98b2caeef51190174b38d1 Mon Sep 17 00:00:00 2001 From: Edward Loveall Date: Sun, 16 May 2021 14:14:25 -0400 Subject: [PATCH] First step rendering a page The API responds with a bunch of paragraphs which the client converts into Paragraph objects. This turns the paragraphs in a PostResponse's Paragraph objects into the form needed to render them on a page. This includes converting flat list elements into list elements nested by a UL. And adding a limited markups along the way. The array of paragraphs is passed to a recursive function. The function takes the first paragraph and either wraps the (marked up) contents in a container tag (like Paragraph or Heading3), and then moves onto the next tag. If it finds a list, it starts parsing the next paragraphs as a list instead. Originally, this was implemented like so: ```crystal paragraph = paragraphs.shift if list? convert_list([paragraph] + paragraphs) end ``` However, passing the `paragraphs` after adding it to the already shifted `paragraph` creates a new object. This means `paragraphs` won't be mutated and once the list is parsed, it starts with the next element of the list. Instead, the element is `shift`ed inside each converter. ```crystal if paragraphs.first == list? convert_list(paragraphs) end def convert_list(paragraphs) paragraph = paragraphs.shift # ... end ``` When rendering, there is an Empty and Container object. These represent a kind of "null object" for both leafs and parent objects respectively. They should never actually render. Emptys are filtered out, and Containers are never created explicitly but this will make the types pass. IFrames are a bit of a special case. Each IFrame has custom data on it that this system would need to be aware of. For now, instead of trying to parse the seemingly large number of iframe variations and dealing with embedded iframe problems, this will just keep track of the source page URL and send the user there with a link. --- shard.yml | 2 +- spec/classes/iframe_media_resolver_spec.cr | 27 ++ spec/classes/markup_converter_spec.cr | 99 ++++++++ spec/classes/paragraph_converter_spec.cr | 271 +++++++++++++++++++++ spec/components/page_content_spec.cr | 189 ++++++++++++++ spec/support/fake_medium_client.cr | 9 + src/actions/articles/show.cr | 8 +- src/app.cr | 3 + src/classes/iframe_media_resolver.cr | 13 + src/classes/markup_converter.cr | 74 ++++++ src/classes/paragraph_converter.cr | 79 ++++++ src/{actions => }/clients/local_client.cr | 0 src/{actions => }/clients/medium_client.cr | 3 - src/components/page_content.cr | 84 +++++++ src/components/post.cr | 29 --- src/constants.cr | 2 + src/models/media_response.cr | 1 - src/models/nodes.cr | 107 ++++++++ src/models/page.cr | 6 + src/models/post_response.cr | 36 ++- src/pages/articles/show_page.cr | 4 +- 21 files changed, 1003 insertions(+), 43 deletions(-) create mode 100644 spec/classes/iframe_media_resolver_spec.cr create mode 100644 spec/classes/markup_converter_spec.cr create mode 100644 spec/classes/paragraph_converter_spec.cr create mode 100644 spec/components/page_content_spec.cr create mode 100644 spec/support/fake_medium_client.cr create mode 100644 src/classes/iframe_media_resolver.cr create mode 100644 src/classes/markup_converter.cr create mode 100644 src/classes/paragraph_converter.cr rename src/{actions => }/clients/local_client.cr (100%) rename src/{actions => }/clients/medium_client.cr (94%) create mode 100644 src/components/page_content.cr delete mode 100644 src/components/post.cr create mode 100644 src/constants.cr create mode 100644 src/models/nodes.cr create mode 100644 src/models/page.cr diff --git a/shard.yml b/shard.yml index c19ca00..64132a9 100644 --- a/shard.yml +++ b/shard.yml @@ -29,4 +29,4 @@ dependencies: development_dependencies: lucky_flow: github: luckyframework/lucky_flow - version: ~> 0.7.3 \ No newline at end of file + version: ~> 0.7.3 diff --git a/spec/classes/iframe_media_resolver_spec.cr b/spec/classes/iframe_media_resolver_spec.cr new file mode 100644 index 0000000..ed167a8 --- /dev/null +++ b/spec/classes/iframe_media_resolver_spec.cr @@ -0,0 +1,27 @@ +require "../spec_helper" + +include Nodes + +describe IFrameMediaResolver do + around_each do |example| + original_client = IFrameMediaResolver.http_client + IFrameMediaResolver.http_client = FakeMediumClient + example.run + IFrameMediaResolver.http_client = original_client + end + + it "returns a url of the embedded page" do + iframe = PostResponse::IFrame.from_json <<-JSON + { + "mediaResource": { + "id": "d4515fff7ecd02786e75fc8997c94bbf" + } + } + JSON + resolver = IFrameMediaResolver.new(iframe: iframe) + + result = resolver.fetch_href + + result.should eq("https://example.com") + end +end diff --git a/spec/classes/markup_converter_spec.cr b/spec/classes/markup_converter_spec.cr new file mode 100644 index 0000000..a87a2b2 --- /dev/null +++ b/spec/classes/markup_converter_spec.cr @@ -0,0 +1,99 @@ +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) + + result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + + 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": [ + { + "title": null, + "type": "STRONG", + "href": null, + "start": 0, + "end": 6, + "rel": null, + "anchorType": null + }, + { + "title": null, + "type": "EM", + "href": null, + "start": 11, + "end": 21, + "rel": null, + "anchorType": null + } + ], + "href": null, + "iframe": null, + "layout": null, + "metadata": null + } + JSON + paragraph = PostResponse::Paragraph.from_json(json) + + result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.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 + + it "returns just text with a code markup" do + json = <<-JSON + { + "text": "inline code", + "type": "P", + "markups": [ + { + "title": null, + "type": "CODE", + "href": null, + "start": 7, + "end": 11, + "rel": null, + "anchorType": null + } + ], + "href": null, + "iframe": null, + "layout": null, + "metadata": null + } + JSON + paragraph = PostResponse::Paragraph.from_json(json) + + result = MarkupConverter.convert(text: paragraph.text, markups: paragraph.markups) + + result.should eq([ + Text.new(content: "inline "), + Code.new(children: [Text.new(content: "code")] of Child), + ]) + end +end diff --git a/spec/classes/paragraph_converter_spec.cr b/spec/classes/paragraph_converter_spec.cr new file mode 100644 index 0000000..14faef2 --- /dev/null +++ b/spec/classes/paragraph_converter_spec.cr @@ -0,0 +1,271 @@ +require "../spec_helper" + +include Nodes + +describe ParagraphConverter do + around_each do |example| + original_client = IFrameMediaResolver.http_client + IFrameMediaResolver.http_client = FakeMediumClient + example.run + IFrameMediaResolver.http_client = original_client + end + + it "converts a simple structure with no markups" do + paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON + [ + { + "text": "Title", + "type": "H3", + "markups": [], + "href": null, + "iframe": null, + "layout": null, + "metadata": null + } + ] + JSON + expected = [Heading3.new(children: [Text.new(content: "Title")] of Child)] + + result = ParagraphConverter.new.convert(paragraphs) + + result.should eq expected + end + + it "converts a simple structure with a markup" do + paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON + [ + { + "text": "inline code", + "type": "P", + "markups": [ + { + "name": null, + "title": null, + "type": "CODE", + "href": null, + "start": 7, + "end": 11, + "rel": null, + "anchorType": null + } + ], + "href": null, + "iframe": null, + "layout": null, + "metadata": null + } + ] + JSON + expected = [ + Paragraph.new(children: [ + Text.new(content: "inline "), + Code.new(children: [Text.new(content: "code")] of Child), + ] of Child) + ] + + result = ParagraphConverter.new.convert(paragraphs) + + result.should eq expected + end + + it "groups