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 list items into one list" do
+ paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+ [
+ {
+ "text": "One",
+ "type": "ULI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "Two",
+ "type": "ULI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "Not a list item",
+ "type": "P",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ }
+ ]
+ JSON
+ expected = [
+ UnorderedList.new(children: [
+ ListItem.new(children: [Text.new(content: "One")] of Child),
+ ListItem.new(children: [Text.new(content: "Two")] of Child),
+ ] of Child),
+ Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
+ ]
+
+ result = ParagraphConverter.new.convert(paragraphs)
+
+ result.should eq expected
+ end
+
+ it "groups list items into one list" do
+ paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+ [
+ {
+ "text": "One",
+ "type": "OLI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "Two",
+ "type": "OLI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "Not a list item",
+ "type": "P",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ }
+ ]
+ JSON
+ expected = [
+ OrderedList.new(children: [
+ ListItem.new(children: [Text.new(content: "One")] of Child),
+ ListItem.new(children: [Text.new(content: "Two")] of Child),
+ ] of Child),
+ Paragraph.new(children: [Text.new(content: "Not a list item")] of Child),
+ ]
+
+ result = ParagraphConverter.new.convert(paragraphs)
+
+ result.should eq expected
+ end
+
+ it "converts all the tags" do
+ paragraphs = Array(PostResponse::Paragraph).from_json <<-JSON
+ [
+ {
+ "text": "text",
+ "type": "H3",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "H4",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "P",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "PRE",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "BQ",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "ULI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "OLI",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": null
+ },
+ {
+ "text": "text",
+ "type": "IMG",
+ "markups": [],
+ "href": null,
+ "iframe": null,
+ "layout": null,
+ "metadata": {
+ "id": "1*miroimage.png",
+ "originalWidth": 618,
+ "originalHeight": 682
+ }
+ },
+ {
+ "text": "",
+ "type": "IFRAME",
+ "markups": [],
+ "href": null,
+ "iframe": {
+ "mediaResource": {
+ "id": "7c6231d165bf9fc1853f259a7b55bd14"
+ }
+ },
+ "layout": null,
+ "metadata": null
+ }
+ ]
+ JSON
+ expected = [
+ Heading3.new([Text.new("text")] of Child),
+ Heading4.new([Text.new("text")] of Child),
+ Paragraph.new([Text.new("text")] of Child),
+ Preformatted.new([Text.new("text")] of Child),
+ BlockQuote.new([Text.new("text")] of Child),
+ UnorderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
+ OrderedList.new([ListItem.new([Text.new("text")] of Child)] of Child),
+ Image.new(src: "1*miroimage.png"),
+ IFrame.new(href: "https://example.com"),
+ ]
+
+ result = ParagraphConverter.new.convert(paragraphs)
+
+ result.should eq expected
+ end
+end
diff --git a/spec/components/page_content_spec.cr b/spec/components/page_content_spec.cr
new file mode 100644
index 0000000..3bab1c1
--- /dev/null
+++ b/spec/components/page_content_spec.cr
@@ -0,0 +1,189 @@
+require "../spec_helper"
+
+include Nodes
+
+describe PageContent do
+ it "renders a single parent/child node structure" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ Text.new(content: "hi"),
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(hi
)
+ end
+
+ it "renders multiple childrens" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ Text.new(content: "Hello, "),
+ Emphasis.new(children: [
+ Text.new(content: "World!")
+ ] of Child)
+ ] of Child),
+ UnorderedList.new(children: [
+ ListItem.new(children: [
+ Text.new(content: "List!")
+ ] of Child),
+ ListItem.new(children: [
+ Text.new(content: "Again!"),
+ ] of Child)
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(Hello, World!
)
+ end
+
+ it "renders a blockquote" do
+ page = Page.new(nodes: [
+ BlockQuote.new(children: [
+ Text.new("Wayne Gretzky. Michael Scott.")
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(Wayne Gretzky. Michael Scott.
)
+ end
+
+ it "renders code" do
+ page = Page.new(nodes: [
+ Code.new(children: [
+ Text.new("foo = bar")
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(foo = bar
)
+ end
+
+ it "renders empasis" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ Text.new(content: "This is "),
+ Emphasis.new(children: [
+ Text.new(content: "neat!")
+ ] of Child),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(This is neat!
)
+ end
+
+ it "renders an H3" do
+ page = Page.new(nodes: [
+ Heading3.new(children: [
+ Text.new(content: "Title!"),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(Title!
)
+ end
+
+ it "renders an H4" do
+ page = Page.new(nodes: [
+ Heading4.new(children: [
+ Text.new(content: "In Conclusion..."),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(In Conclusion...
)
+ end
+
+ it "renders an image" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ Image.new(src: "image.png"),
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %()
+ end
+
+ it "renders an iframe container" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ IFrame.new(href: "https://example.com"),
+ ] of Child)
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(
)
+ end
+
+ it "renders an ordered list" do
+ page = Page.new(nodes: [
+ OrderedList.new(children: [
+ ListItem.new(children: [Text.new("One")] of Child),
+ ListItem.new(children: [Text.new("Two")] of Child),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(- One
- Two
)
+ end
+
+ it "renders an preformatted text" do
+ page = Page.new(nodes: [
+ Paragraph.new(children: [
+ Text.new("Hello, world!"),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(Hello, world!
)
+ end
+
+ it "renders an preformatted text" do
+ page = Page.new(nodes: [
+ Preformatted.new(children: [
+ Text.new("New\nline"),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(New\nline
)
+ end
+
+ it "renders strong text" do
+ page = Page.new(nodes: [
+ Strong.new(children: [
+ Text.new("Oh yeah!"),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %(Oh yeah!)
+ end
+
+ it "renders an unordered list" do
+ page = Page.new(nodes: [
+ UnorderedList.new(children: [
+ ListItem.new(children: [Text.new("Apple")] of Child),
+ ListItem.new(children: [Text.new("Banana")] of Child),
+ ] of Child),
+ ] of Child)
+
+ html = PageContent.new(page: page).render_to_string
+
+ html.should eq %()
+ end
+end
diff --git a/spec/support/fake_medium_client.cr b/spec/support/fake_medium_client.cr
new file mode 100644
index 0000000..9ae65e2
--- /dev/null
+++ b/spec/support/fake_medium_client.cr
@@ -0,0 +1,9 @@
+class FakeMediumClient < MediumClient
+ def self.media_data(media_id : String) : MediaResponse::Root
+ MediaResponse::Root.from_json(
+ <<-JSON
+ {"payload": {"value": {"href": "https://example.com"}}}
+ JSON
+ )
+ end
+end
diff --git a/src/actions/articles/show.cr b/src/actions/articles/show.cr
index efa10d8..842e17f 100644
--- a/src/actions/articles/show.cr
+++ b/src/actions/articles/show.cr
@@ -1,12 +1,16 @@
require "json"
class Articles::Show < BrowserAction
- get "/post/:post_id" do
+ get "/posts/:post_id" do
if Lucky::Env.use_local?
response = LocalClient.post_data(post_id)
else
response = MediumClient.post_data(post_id)
end
- html ShowPage, post_response: response
+ content = ParagraphConverter.new.convert(
+ response.data.post.content.bodyModel.paragraphs
+ )
+ page = Page.new(nodes: content)
+ html ShowPage, page: page
end
end
diff --git a/src/app.cr b/src/app.cr
index 1a2f4d1..b5f93a5 100644
--- a/src/app.cr
+++ b/src/app.cr
@@ -3,6 +3,7 @@ require "./shards"
Lucky::AssetHelpers.load_manifest
require "./app_database"
+require "./constants"
require "./models/base_model"
require "./models/mixins/**"
require "./models/**"
@@ -16,6 +17,8 @@ require "./actions/mixins/**"
require "./actions/**"
require "./components/base_component"
require "./components/**"
+require "./classes/**"
+require "./clients/**"
require "./pages/**"
require "../config/env"
require "../config/**"
diff --git a/src/classes/iframe_media_resolver.cr b/src/classes/iframe_media_resolver.cr
new file mode 100644
index 0000000..06d6e61
--- /dev/null
+++ b/src/classes/iframe_media_resolver.cr
@@ -0,0 +1,13 @@
+class IFrameMediaResolver
+ class_property http_client : MediumClient.class = MediumClient
+
+ getter iframe
+
+ def initialize(@iframe : PostResponse::IFrame)
+ end
+
+ def fetch_href
+ response = @@http_client.media_data(iframe.mediaResource.id)
+ response.payload.value.href
+ end
+end
diff --git a/src/classes/markup_converter.cr b/src/classes/markup_converter.cr
new file mode 100644
index 0000000..0e40f53
--- /dev/null
+++ b/src/classes/markup_converter.cr
@@ -0,0 +1,74 @@
+struct StringSplit
+ getter pre, content, post
+
+ def initialize(@pre : String, @content : String, @post : String)
+ end
+end
+
+class MarkupConverter
+ include Nodes
+
+ getter markups : Array(PostResponse::Markup)
+ getter text : String
+
+ def self.convert(text : String, markups : Array(PostResponse::Markup))
+ new(text, markups).convert
+ end
+
+ def initialize(@text : String, @markups : Array(PostResponse::Markup))
+ end
+
+ def convert : Array(Child)
+ if markups.empty?
+ return [Text.new(content: text)] of Child
+ 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::CODE
+ container = construct_markup(to_be_marked, Code)
+ when PostResponse::MarkupType::EM
+ container = construct_markup(to_be_marked, Emphasis)
+ when PostResponse::MarkupType::STRONG
+ container = construct_markup(to_be_marked, Strong)
+ else
+ container = construct_markup(to_be_marked, Code)
+ end
+ [Text.new(plain), container] of Child
+ end
+ end.flatten.reject(&.empty?)
+ end
+
+ private def construct_markup(text : String, container : Container.class) : Child
+ container.new(children: [Text.new(content: text)] of Child)
+ end
+
+ private def split_string(start : Int32, finish : Int32, string : String)
+ if start.zero?
+ pre = ""
+ else
+ pre = string[0...start]
+ end
+
+ if finish == string.size
+ post = ""
+ else
+ post = string[finish...string.size]
+ end
+
+ content = string[start...finish]
+ StringSplit.new(pre, content, post)
+ end
+end
diff --git a/src/classes/paragraph_converter.cr b/src/classes/paragraph_converter.cr
new file mode 100644
index 0000000..1288a04
--- /dev/null
+++ b/src/classes/paragraph_converter.cr
@@ -0,0 +1,79 @@
+class ParagraphConverter
+ include Nodes
+
+ def convert(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+ if paragraphs.first?.nil?
+ return [Empty.new] of Child
+ else
+ case paragraphs.first.type
+ when PostResponse::ParagraphType::BQ
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ node = BlockQuote.new(children: children)
+ when PostResponse::ParagraphType::H3
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ node = Heading3.new(children: children)
+ when PostResponse::ParagraphType::H4
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ node = Heading4.new(children: children)
+ when PostResponse::ParagraphType::IFRAME
+ paragraph = paragraphs.shift
+ if iframe = paragraph.iframe
+ resolver = IFrameMediaResolver.new(iframe: iframe)
+ href = resolver.fetch_href
+ node = IFrame.new(href: href)
+ else
+ node = Empty.new
+ end
+ when PostResponse::ParagraphType::IMG
+ paragraph = paragraphs.shift
+ if metadata = paragraph.metadata
+ node = Image.new(src: metadata.id)
+ else
+ node = Empty.new
+ end
+ when PostResponse::ParagraphType::OLI
+ list_items = convert_oli(paragraphs)
+ node = OrderedList.new(children: list_items)
+ when PostResponse::ParagraphType::P
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ node = Paragraph.new(children: children)
+ when PostResponse::ParagraphType::PRE
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ node = Preformatted.new(children: children)
+ when PostResponse::ParagraphType::ULI
+ list_items = convert_uli(paragraphs)
+ node = UnorderedList.new(children: list_items)
+ else
+ paragraphs.shift # so we don't recurse infinitely
+ node = Empty.new
+ end
+
+ [node, convert(paragraphs)].flatten.reject(&.empty?)
+ end
+ end
+
+ private def convert_uli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+ if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::ULI)
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ [ListItem.new(children: children)] + convert_uli(paragraphs)
+ else
+ [] of Child
+ end
+ end
+
+ private def convert_oli(paragraphs : Array(PostResponse::Paragraph)) : Array(Child)
+ if paragraphs.first? && paragraphs.first.type.is_a?(PostResponse::ParagraphType::OLI)
+ paragraph = paragraphs.shift
+ children = MarkupConverter.convert(paragraph.text, paragraph.markups)
+ [ListItem.new(children: children)] + convert_oli(paragraphs)
+ else
+ [] of Child
+ end
+ end
+end
diff --git a/src/actions/clients/local_client.cr b/src/clients/local_client.cr
similarity index 100%
rename from src/actions/clients/local_client.cr
rename to src/clients/local_client.cr
diff --git a/src/actions/clients/medium_client.cr b/src/clients/medium_client.cr
similarity index 94%
rename from src/actions/clients/medium_client.cr
rename to src/clients/medium_client.cr
index 4d140e1..6a2328d 100644
--- a/src/actions/clients/medium_client.cr
+++ b/src/clients/medium_client.cr
@@ -1,9 +1,6 @@
require "json"
class MediumClient
- # https://stackoverflow.com/questions/2669690/
- JSON_HIJACK_STRING = "])}while(1);"
-
def self.post_data(post_id : String) : PostResponse::Root
client = HTTP::Client.new("medium.com", tls: true)
response = client.post("/_/graphql", headers: headers, body: body(post_id))
diff --git a/src/components/page_content.cr b/src/components/page_content.cr
new file mode 100644
index 0000000..49fe832
--- /dev/null
+++ b/src/components/page_content.cr
@@ -0,0 +1,84 @@
+class PageContent < BaseComponent
+ include Nodes
+ needs page : Page
+
+ def render
+ page.nodes.each do |node|
+ render_child(node)
+ end
+ end
+
+ def render_children(children : Children)
+ children.each { |child| render_child(child) }
+ end
+
+ def render_child(node : BlockQuote)
+ blockquote { render_children(node.children) }
+ end
+
+ def render_child(node : Code)
+ code { render_children(node.children) }
+ end
+
+ def render_child(container : Container)
+ # Should never get called
+ raw ""
+ end
+
+ def render_child(node : Emphasis)
+ em { render_children(node.children) }
+ end
+
+ def render_child(container : Empty)
+ # Should never get called
+ raw ""
+ end
+
+ def render_child(node : Heading3)
+ h3 { render_children(node.children) }
+ end
+
+ def render_child(node : Heading4)
+ h4 { render_children(node.children) }
+ end
+
+ def render_child(child : IFrame)
+ div class: "embedded" do
+ a href: child.href do
+ text "Click to visit embedded content"
+ end
+ end
+ end
+
+ def render_child(child : Image)
+ img src: child.src
+ end
+
+ def render_child(node : ListItem)
+ li { render_children(node.children) }
+ end
+
+ def render_child(node : OrderedList)
+ ol { render_children(node.children) }
+ end
+
+ def render_child(node : Paragraph)
+ para { render_children(node.children) }
+ end
+
+ def render_child(node : Preformatted)
+ pre { render_children(node.children) }
+ end
+
+ def render_child(node : Strong)
+ strong { render_children(node.children) }
+ end
+
+ def render_child(child : Text)
+ text child.content
+ end
+
+ def render_child(node : UnorderedList)
+ ul { render_children(node.children) }
+ end
+end
diff --git a/src/components/post.cr b/src/components/post.cr
deleted file mode 100644
index 587d463..0000000
--- a/src/components/post.cr
+++ /dev/null
@@ -1,29 +0,0 @@
-class Post::Post < BaseComponent
- needs response : PostResponse::Root
-
- def render
- data = response.data.post.content.bodyModel.paragraphs
- data.each do |paragraph|
- case paragraph.type
- when PostResponse::ParagraphType::H3
- h3 paragraph.text
- when PostResponse::ParagraphType::H4
- h4 paragraph.text
- when PostResponse::ParagraphType::P
- para paragraph.text
- when PostResponse::ParagraphType::PRE
- pre paragraph.text
- when PostResponse::ParagraphType::BQ
- blockquote paragraph.text
- when PostResponse::ParagraphType::OLI
- li paragraph.text
- when PostResponse::ParagraphType::ULI
- li paragraph.text
- when PostResponse::ParagraphType::IFRAME
- mount IFrame, paragraph: paragraph
- else
- para "#{paragraph.type} not yet implimented"
- end
- end
- end
-end
diff --git a/src/constants.cr b/src/constants.cr
new file mode 100644
index 0000000..a136d90
--- /dev/null
+++ b/src/constants.cr
@@ -0,0 +1,2 @@
+# https://stackoverflow.com/questions/2669690/
+JSON_HIJACK_STRING = "])}while(1);"
diff --git a/src/models/media_response.cr b/src/models/media_response.cr
index 8ee915d..16d6749 100644
--- a/src/models/media_response.cr
+++ b/src/models/media_response.cr
@@ -13,6 +13,5 @@ class MediaResponse
class Value < Base
property href : String
- property iframeSrc : String
end
end
diff --git a/src/models/nodes.cr b/src/models/nodes.cr
new file mode 100644
index 0000000..99d02a1
--- /dev/null
+++ b/src/models/nodes.cr
@@ -0,0 +1,107 @@
+module Nodes
+ alias Leaf = Text | Image | IFrame
+ alias Child = Container | Leaf | Empty
+ alias Children = Array(Child)
+
+ class Container
+ getter children : Children
+
+ def initialize(@children : Children)
+ end
+
+ def ==(other : Container)
+ other.children == children
+ end
+
+ def empty?
+ children.empty? || children.each(&.empty?)
+ end
+ end
+
+ class Empty
+ def empty?
+ true
+ end
+ end
+
+ class BlockQuote < Container
+ end
+
+ class Code < Container
+ end
+
+ class Emphasis < Container
+ end
+
+ class Heading3 < Container
+ end
+
+ class Heading4 < Container
+ end
+
+ class ListItem < Container
+ end
+
+ class OrderedList < Container
+ end
+
+ class Paragraph < Container
+ end
+
+ class Preformatted < Container
+ end
+
+ class Strong < Container
+ end
+
+ class UnorderedList < Container
+ end
+
+ class Text
+ getter content : String
+
+ def initialize(@content : String)
+ end
+
+ def ==(other : Text)
+ other.content == content
+ end
+
+ def empty?
+ content.empty?
+ end
+ end
+
+ class Image
+ IMAGE_HOST = "https://cdn-images-1.medium.com"
+
+ getter src : String
+
+ def initialize(src : String)
+ @src = "#{IMAGE_HOST}/#{src}"
+ end
+
+ def ==(other : Image)
+ other.src == src
+ end
+
+ def empty?
+ false
+ end
+ end
+
+ class IFrame
+ getter href : String
+
+ def initialize(@href : String)
+ end
+
+ def ==(other : IFrame)
+ other.href == href
+ end
+
+ def empty?
+ false
+ end
+ end
+end
diff --git a/src/models/page.cr b/src/models/page.cr
new file mode 100644
index 0000000..06e103d
--- /dev/null
+++ b/src/models/page.cr
@@ -0,0 +1,6 @@
+class Page
+ getter nodes : Nodes::Children
+
+ def initialize(@nodes : Nodes::Children)
+ end
+end
diff --git a/src/models/post_response.cr b/src/models/post_response.cr
index 93c92f5..c8447cc 100644
--- a/src/models/post_response.cr
+++ b/src/models/post_response.cr
@@ -33,20 +33,43 @@ class PostResponse
class Paragraph < Base
property text : String
property type : ParagraphType
+ property markups : Array(Markup)
property iframe : IFrame?
property layout : String?
+ property metadata : Metadata?
end
enum ParagraphType
+ BQ
H3
H4
- P
- PRE
- BQ
- ULI
- OLI
IFRAME
IMG
+ OLI
+ P
+ PRE
+ ULI
+ end
+
+ class Markup < Base
+ property title : String?
+ property type : MarkupType
+ property href : String?
+ property start : Int32
+ property end : Int32
+ property anchorType : AnchorType?
+ end
+
+ enum MarkupType
+ A
+ CODE
+ EM
+ STRONG
+ end
+
+ enum AnchorType
+ LINK
+ USER
end
class IFrame < Base
@@ -58,5 +81,8 @@ class PostResponse
end
class Metadata < Base
+ property id : String
+ property originalWidth : Int32
+ property originalHeight : Int32
end
end
diff --git a/src/pages/articles/show_page.cr b/src/pages/articles/show_page.cr
index d6e78bb..c5c2b0e 100644
--- a/src/pages/articles/show_page.cr
+++ b/src/pages/articles/show_page.cr
@@ -1,7 +1,7 @@
class Articles::ShowPage < MainLayout
- needs post_response : PostResponse::Root
+ needs page : Page
def content
- mount Post::Post, response: post_response
+ mount PageContent, page: page
end
end