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.
This commit is contained in:
parent
fe2f3ebe80
commit
5a5f68bcf8
21 changed files with 1003 additions and 43 deletions
|
@ -29,4 +29,4 @@ dependencies:
|
||||||
development_dependencies:
|
development_dependencies:
|
||||||
lucky_flow:
|
lucky_flow:
|
||||||
github: luckyframework/lucky_flow
|
github: luckyframework/lucky_flow
|
||||||
version: ~> 0.7.3
|
version: ~> 0.7.3
|
||||||
|
|
27
spec/classes/iframe_media_resolver_spec.cr
Normal file
27
spec/classes/iframe_media_resolver_spec.cr
Normal file
|
@ -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
|
99
spec/classes/markup_converter_spec.cr
Normal file
99
spec/classes/markup_converter_spec.cr
Normal file
|
@ -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
|
271
spec/classes/paragraph_converter_spec.cr
Normal file
271
spec/classes/paragraph_converter_spec.cr
Normal file
|
@ -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 <ul> 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 <ol> 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
|
189
spec/components/page_content_spec.cr
Normal file
189
spec/components/page_content_spec.cr
Normal file
|
@ -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 %(<p>hi</p>)
|
||||||
|
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 %(<p>Hello, <em>World!</em></p><ul><li>List!</li><li>Again!</li></ul>)
|
||||||
|
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 %(<blockquote>Wayne Gretzky. Michael Scott.</blockquote>)
|
||||||
|
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 %(<code>foo = bar</code>)
|
||||||
|
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 %(<p>This is <em>neat!</em></p>)
|
||||||
|
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 %(<h3>Title!</h3>)
|
||||||
|
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 %(<h4>In Conclusion...</h4>)
|
||||||
|
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 %(<p><img src="https://cdn-images-1.medium.com/image.png"></p>)
|
||||||
|
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 %(<p><div class="embedded"><a href="https://example.com">Click to visit embedded content</a></div></p>)
|
||||||
|
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 %(<ol><li>One</li><li>Two</li></ol>)
|
||||||
|
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 %(<p>Hello, world!</p>)
|
||||||
|
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 %(<pre>New\nline</pre>)
|
||||||
|
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 %(<strong>Oh yeah!</strong>)
|
||||||
|
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 %(<ul><li>Apple</li><li>Banana</li></ul>)
|
||||||
|
end
|
||||||
|
end
|
9
spec/support/fake_medium_client.cr
Normal file
9
spec/support/fake_medium_client.cr
Normal file
|
@ -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
|
|
@ -1,12 +1,16 @@
|
||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
class Articles::Show < BrowserAction
|
class Articles::Show < BrowserAction
|
||||||
get "/post/:post_id" do
|
get "/posts/:post_id" do
|
||||||
if Lucky::Env.use_local?
|
if Lucky::Env.use_local?
|
||||||
response = LocalClient.post_data(post_id)
|
response = LocalClient.post_data(post_id)
|
||||||
else
|
else
|
||||||
response = MediumClient.post_data(post_id)
|
response = MediumClient.post_data(post_id)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,7 @@ require "./shards"
|
||||||
Lucky::AssetHelpers.load_manifest
|
Lucky::AssetHelpers.load_manifest
|
||||||
|
|
||||||
require "./app_database"
|
require "./app_database"
|
||||||
|
require "./constants"
|
||||||
require "./models/base_model"
|
require "./models/base_model"
|
||||||
require "./models/mixins/**"
|
require "./models/mixins/**"
|
||||||
require "./models/**"
|
require "./models/**"
|
||||||
|
@ -16,6 +17,8 @@ require "./actions/mixins/**"
|
||||||
require "./actions/**"
|
require "./actions/**"
|
||||||
require "./components/base_component"
|
require "./components/base_component"
|
||||||
require "./components/**"
|
require "./components/**"
|
||||||
|
require "./classes/**"
|
||||||
|
require "./clients/**"
|
||||||
require "./pages/**"
|
require "./pages/**"
|
||||||
require "../config/env"
|
require "../config/env"
|
||||||
require "../config/**"
|
require "../config/**"
|
||||||
|
|
13
src/classes/iframe_media_resolver.cr
Normal file
13
src/classes/iframe_media_resolver.cr
Normal file
|
@ -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
|
74
src/classes/markup_converter.cr
Normal file
74
src/classes/markup_converter.cr
Normal file
|
@ -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
|
79
src/classes/paragraph_converter.cr
Normal file
79
src/classes/paragraph_converter.cr
Normal file
|
@ -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
|
|
@ -1,9 +1,6 @@
|
||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
class MediumClient
|
class MediumClient
|
||||||
# https://stackoverflow.com/questions/2669690/
|
|
||||||
JSON_HIJACK_STRING = "])}while(1);</x>"
|
|
||||||
|
|
||||||
def self.post_data(post_id : String) : PostResponse::Root
|
def self.post_data(post_id : String) : PostResponse::Root
|
||||||
client = HTTP::Client.new("medium.com", tls: true)
|
client = HTTP::Client.new("medium.com", tls: true)
|
||||||
response = client.post("/_/graphql", headers: headers, body: body(post_id))
|
response = client.post("/_/graphql", headers: headers, body: body(post_id))
|
84
src/components/page_content.cr
Normal file
84
src/components/page_content.cr
Normal file
|
@ -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 "<!-- a Container was rendered -->"
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_child(node : Emphasis)
|
||||||
|
em { render_children(node.children) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_child(container : Empty)
|
||||||
|
# Should never get called
|
||||||
|
raw "<!-- an Empty was rendered -->"
|
||||||
|
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
|
|
@ -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
|
|
2
src/constants.cr
Normal file
2
src/constants.cr
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# https://stackoverflow.com/questions/2669690/
|
||||||
|
JSON_HIJACK_STRING = "])}while(1);</x>"
|
|
@ -13,6 +13,5 @@ class MediaResponse
|
||||||
|
|
||||||
class Value < Base
|
class Value < Base
|
||||||
property href : String
|
property href : String
|
||||||
property iframeSrc : String
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
107
src/models/nodes.cr
Normal file
107
src/models/nodes.cr
Normal file
|
@ -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
|
6
src/models/page.cr
Normal file
6
src/models/page.cr
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class Page
|
||||||
|
getter nodes : Nodes::Children
|
||||||
|
|
||||||
|
def initialize(@nodes : Nodes::Children)
|
||||||
|
end
|
||||||
|
end
|
|
@ -33,20 +33,43 @@ class PostResponse
|
||||||
class Paragraph < Base
|
class Paragraph < Base
|
||||||
property text : String
|
property text : String
|
||||||
property type : ParagraphType
|
property type : ParagraphType
|
||||||
|
property markups : Array(Markup)
|
||||||
property iframe : IFrame?
|
property iframe : IFrame?
|
||||||
property layout : String?
|
property layout : String?
|
||||||
|
property metadata : Metadata?
|
||||||
end
|
end
|
||||||
|
|
||||||
enum ParagraphType
|
enum ParagraphType
|
||||||
|
BQ
|
||||||
H3
|
H3
|
||||||
H4
|
H4
|
||||||
P
|
|
||||||
PRE
|
|
||||||
BQ
|
|
||||||
ULI
|
|
||||||
OLI
|
|
||||||
IFRAME
|
IFRAME
|
||||||
IMG
|
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
|
end
|
||||||
|
|
||||||
class IFrame < Base
|
class IFrame < Base
|
||||||
|
@ -58,5 +81,8 @@ class PostResponse
|
||||||
end
|
end
|
||||||
|
|
||||||
class Metadata < Base
|
class Metadata < Base
|
||||||
|
property id : String
|
||||||
|
property originalWidth : Int32
|
||||||
|
property originalHeight : Int32
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class Articles::ShowPage < MainLayout
|
class Articles::ShowPage < MainLayout
|
||||||
needs post_response : PostResponse::Root
|
needs page : Page
|
||||||
|
|
||||||
def content
|
def content
|
||||||
mount Post::Post, response: post_response
|
mount PageContent, page: page
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue