Render embedded content
PostResponse::Paragraph's that are of type IFRAME have extra data in the iframe attribute to specify what's in the iframe. Not all data is the same, however. I've identified three types and am using the new EmbeddedConverter class to convert them: * EmbeddedContent, the full iframe experience * GithubGist, because medium or github treat embeds differently for whatever reason * EmbeddedLink, the old style, just a link to the content. Effectively a fallback The size of the original iframe is also specified as an attribute. This code resizes it. The resizing is determined by figuring out the width/height ratio and setting the width to 800. EmbeddedContent can be displayed if we have an embed.ly url, which most iframe response data has. GitHub gists are a notable exception. Gists instead can be embedded simply by taking the gist URL and attaching .js to the end. That becomes the iframe's src attribute. The PostResponse::Paragraph's iframe attribute is nillable. Previous code used lots of if-statements with variable bindings to work with the possible nil values: ```crystal if foo = obj.nillable_value # obj.nillable_value was not nil and foo contains the value else # obj.nillable_value was nil so do something else end ``` See https://crystal-lang.org/reference/syntax_and_semantics/if_var.html for more info In the EmbeddedConverter the monads library has been introduced to get rid of at least one level of nillability. This wraps values in Maybe which allows for a cleaner interface: ```crystal Monads::Try(Value).new(->{ obj.nillable_value }) .to_maybe .fmap(->(value: Value) { # do something with value }) .value_or(# value was nil, do something else) ``` This worked to get the iframe attribute from a Paragraph: ```crystal Monads::Try(PostResponse::IFrame).new(->{ paragraph.iframe }) .to_maybe .fmap(->(iframe : PostResponse::IFrame) { # iframe is not nil! }) .fmap(#and so on) .value_or(Empty.new) ``` iframe only has one attribute: mediaResource which contains the iframe data. That was used to determine one of the three types above. Finally, Tufte.css has options for iframes. They mostly look good except for tweets which are too small and weirdly in the center of the page which actually looks off-center. That's for another day though.
This commit is contained in:
parent
903f3f4b38
commit
a6cafaa1fc
13 changed files with 298 additions and 22 deletions
|
@ -60,6 +60,10 @@ shards:
|
||||||
git: https://github.com/luckyframework/lucky_task.git
|
git: https://github.com/luckyframework/lucky_task.git
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
|
|
||||||
|
monads:
|
||||||
|
git: https://github.com/alex-lairan/monads.git
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
pg:
|
pg:
|
||||||
git: https://github.com/will/crystal-pg.git
|
git: https://github.com/will/crystal-pg.git
|
||||||
version: 0.23.2
|
version: 0.23.2
|
||||||
|
|
|
@ -26,6 +26,8 @@ dependencies:
|
||||||
lucky_task:
|
lucky_task:
|
||||||
github: luckyframework/lucky_task
|
github: luckyframework/lucky_task
|
||||||
version: ~> 0.1.0
|
version: ~> 0.1.0
|
||||||
|
monads:
|
||||||
|
github: alex-lairan/monads
|
||||||
development_dependencies:
|
development_dependencies:
|
||||||
lucky_flow:
|
lucky_flow:
|
||||||
github: luckyframework/lucky_flow
|
github: luckyframework/lucky_flow
|
||||||
|
|
99
spec/classes/embedded_converter_spec.cr
Normal file
99
spec/classes/embedded_converter_spec.cr
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
require "../spec_helper"
|
||||||
|
|
||||||
|
include Nodes
|
||||||
|
|
||||||
|
describe EmbeddedConverter do
|
||||||
|
context "when the mediaResource has an iframeSrc value" do
|
||||||
|
it "returns an EmbeddedContent node" do
|
||||||
|
paragraph = PostResponse::Paragraph.from_json <<-JSON
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"type": "IFRAME",
|
||||||
|
"href": null,
|
||||||
|
"layout": "INSET_CENTER",
|
||||||
|
"markups": [],
|
||||||
|
"iframe": {
|
||||||
|
"mediaResource": {
|
||||||
|
"id": "abc123",
|
||||||
|
"href": "https://twitter.com/user/status/1",
|
||||||
|
"iframeSrc": "https://cdn.embedly.com/widgets/...",
|
||||||
|
"iframeWidth": 500,
|
||||||
|
"iframeHeight": 281
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": null
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
result = EmbeddedConverter.convert(paragraph)
|
||||||
|
|
||||||
|
result.should eq(
|
||||||
|
EmbeddedContent.new(
|
||||||
|
src: "https://cdn.embedly.com/widgets/...",
|
||||||
|
originalWidth: 500,
|
||||||
|
originalHeight: 281,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the mediaResource has a blank iframeSrc value" do
|
||||||
|
context "and the href is unknown" do
|
||||||
|
it "returns an EmbeddedLink node" do
|
||||||
|
paragraph = PostResponse::Paragraph.from_json <<-JSON
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"type": "IFRAME",
|
||||||
|
"href": null,
|
||||||
|
"layout": "INSET_CENTER",
|
||||||
|
"markups": [],
|
||||||
|
"iframe": {
|
||||||
|
"mediaResource": {
|
||||||
|
"id": "abc123",
|
||||||
|
"href": "https://example.com",
|
||||||
|
"iframeSrc": "",
|
||||||
|
"iframeWidth": 0,
|
||||||
|
"iframeHeight": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": null
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
result = EmbeddedConverter.convert(paragraph)
|
||||||
|
|
||||||
|
result.should eq(EmbeddedLink.new(href: "https://example.com"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "and the href is gist.github.com" do
|
||||||
|
it "returns an GithubGist node" do
|
||||||
|
paragraph = PostResponse::Paragraph.from_json <<-JSON
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"type": "IFRAME",
|
||||||
|
"href": null,
|
||||||
|
"layout": "INSET_CENTER",
|
||||||
|
"markups": [],
|
||||||
|
"iframe": {
|
||||||
|
"mediaResource": {
|
||||||
|
"id": "abc123",
|
||||||
|
"href": "https://gist.github.com/user/someid",
|
||||||
|
"iframeSrc": "",
|
||||||
|
"iframeWidth": 0,
|
||||||
|
"iframeHeight": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": null
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
result = EmbeddedConverter.convert(paragraph)
|
||||||
|
|
||||||
|
result.should eq(
|
||||||
|
GithubGist.new(href: "https://gist.github.com/user/someid")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -272,7 +272,10 @@ describe ParagraphConverter do
|
||||||
"markups": [],
|
"markups": [],
|
||||||
"iframe": {
|
"iframe": {
|
||||||
"mediaResource": {
|
"mediaResource": {
|
||||||
"href": "https://example.com"
|
"href": "https://example.com",
|
||||||
|
"iframeSrc": "",
|
||||||
|
"iframeWidth": 0,
|
||||||
|
"iframeHeight": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"layout": null,
|
"layout": null,
|
||||||
|
@ -312,7 +315,7 @@ describe ParagraphConverter do
|
||||||
Image.new(src: "1*miroimage.png", originalWidth: 618, originalHeight: 682),
|
Image.new(src: "1*miroimage.png", originalWidth: 618, originalHeight: 682),
|
||||||
FigureCaption.new(children: [Text.new("text")] of Child),
|
FigureCaption.new(children: [Text.new("text")] of Child),
|
||||||
] of Child),
|
] of Child),
|
||||||
IFrame.new(href: "https://example.com"),
|
EmbeddedLink.new(href: "https://example.com"),
|
||||||
MixtapeEmbed.new(children: [
|
MixtapeEmbed.new(children: [
|
||||||
Anchor.new(
|
Anchor.new(
|
||||||
children: [Text.new("Mixtape")] of Child,
|
children: [Text.new("Mixtape")] of Child,
|
||||||
|
|
|
@ -152,6 +152,24 @@ describe PageContent do
|
||||||
HTML
|
HTML
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "renders a GitHub Gist" do
|
||||||
|
page = Page.new(
|
||||||
|
title: "Title",
|
||||||
|
subtitle: nil,
|
||||||
|
author: "Author",
|
||||||
|
created_at: Time.local,
|
||||||
|
nodes: [
|
||||||
|
GithubGist.new(href: "https://gist.github.com/user/some_id"),
|
||||||
|
] of Child
|
||||||
|
)
|
||||||
|
|
||||||
|
html = PageContent.new(page: page).render_to_string
|
||||||
|
|
||||||
|
html.should eq stripped_html <<-HTML
|
||||||
|
<script src="https://gist.github.com/user/some_id.js"></script>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
it "renders an H3" do
|
it "renders an H3" do
|
||||||
page = Page.new(
|
page = Page.new(
|
||||||
title: "Title",
|
title: "Title",
|
||||||
|
@ -210,7 +228,32 @@ describe PageContent do
|
||||||
HTML
|
HTML
|
||||||
end
|
end
|
||||||
|
|
||||||
it "renders an iframe container" do
|
it "renders embedded content" do
|
||||||
|
page = Page.new(
|
||||||
|
title: "Title",
|
||||||
|
subtitle: nil,
|
||||||
|
author: "Author",
|
||||||
|
created_at: Time.local,
|
||||||
|
nodes: [
|
||||||
|
EmbeddedContent.new(
|
||||||
|
src: "https://example.com",
|
||||||
|
originalWidth: 1000,
|
||||||
|
originalHeight: 600,
|
||||||
|
),
|
||||||
|
] of Child
|
||||||
|
)
|
||||||
|
|
||||||
|
html = PageContent.new(page: page).render_to_string
|
||||||
|
|
||||||
|
html.should eq stripped_html <<-HTML
|
||||||
|
<div class="iframe-wrapper">
|
||||||
|
<iframe src="https://example.com" width="800" height="480" frameborder="0" allowfullscreen="true">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders an embedded link container" do
|
||||||
page = Page.new(
|
page = Page.new(
|
||||||
title: "Title",
|
title: "Title",
|
||||||
subtitle: nil,
|
subtitle: nil,
|
||||||
|
@ -218,7 +261,7 @@ describe PageContent do
|
||||||
created_at: Time.local,
|
created_at: Time.local,
|
||||||
nodes: [
|
nodes: [
|
||||||
Paragraph.new(children: [
|
Paragraph.new(children: [
|
||||||
IFrame.new(href: "https://example.com"),
|
EmbeddedLink.new(href: "https://example.com"),
|
||||||
] of Child),
|
] of Child),
|
||||||
] of Child
|
] of Child
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
require "../spec_helper"
|
require "../spec_helper"
|
||||||
|
|
||||||
module Nodes
|
module Nodes
|
||||||
describe IFrame do
|
describe EmbeddedLink do
|
||||||
it "returns embedded url with subdomains" do
|
it "returns embedded url with subdomains" do
|
||||||
iframe = IFrame.new(href: "https://dev.example.com/page")
|
iframe = EmbeddedLink.new(href: "https://dev.example.com/page")
|
||||||
|
|
||||||
iframe.domain.should eq("dev.example.com")
|
iframe.domain.should eq("dev.example.com")
|
||||||
end
|
end
|
||||||
|
@ -23,4 +23,17 @@ module Nodes
|
||||||
image.src.should eq("https://cdn-images-1.medium.com/fit/c/800/482/image.png")
|
image.src.should eq("https://cdn-images-1.medium.com/fit/c/800/482/image.png")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe EmbeddedContent do
|
||||||
|
it "adjusts the width and height proportionally" do
|
||||||
|
content = EmbeddedContent.new(
|
||||||
|
src: "https://example.com",
|
||||||
|
originalWidth: 1000,
|
||||||
|
originalHeight: 600,
|
||||||
|
)
|
||||||
|
|
||||||
|
content.width.should eq("800")
|
||||||
|
content.height.should eq("480")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
42
src/classes/embedded_converter.cr
Normal file
42
src/classes/embedded_converter.cr
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
class EmbeddedConverter
|
||||||
|
include Nodes
|
||||||
|
|
||||||
|
GIST_HOST = "https://gist.github.com"
|
||||||
|
|
||||||
|
getter paragraph : PostResponse::Paragraph
|
||||||
|
|
||||||
|
def self.convert(paragraph : PostResponse::Paragraph) : Embedded | Empty
|
||||||
|
new(paragraph).convert
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@paragraph : PostResponse::Paragraph)
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert : Embedded | Empty
|
||||||
|
Monads::Try(PostResponse::IFrame).new(->{ paragraph.iframe })
|
||||||
|
.to_maybe
|
||||||
|
.fmap(->(iframe : PostResponse::IFrame) { iframe.mediaResource })
|
||||||
|
.fmap(->media_to_embedded(PostResponse::MediaResource))
|
||||||
|
.value_or(Empty.new)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def media_to_embedded(media : PostResponse::MediaResource) : Embedded
|
||||||
|
if media.iframeSrc.blank?
|
||||||
|
custom_embed(media)
|
||||||
|
else
|
||||||
|
EmbeddedContent.new(
|
||||||
|
src: media.iframeSrc,
|
||||||
|
originalWidth: media.iframeWidth,
|
||||||
|
originalHeight: media.iframeHeight
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def custom_embed(media : PostResponse::MediaResource) : Embedded
|
||||||
|
if media.href.starts_with?(GIST_HOST)
|
||||||
|
GithubGist.new(href: media.href)
|
||||||
|
else
|
||||||
|
EmbeddedLink.new(href: media.href)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -20,11 +20,7 @@ class ParagraphConverter
|
||||||
node = Heading3.new(children: children)
|
node = Heading3.new(children: children)
|
||||||
when PostResponse::ParagraphType::IFRAME
|
when PostResponse::ParagraphType::IFRAME
|
||||||
paragraph = paragraphs.shift
|
paragraph = paragraphs.shift
|
||||||
if iframe = paragraph.iframe
|
node = EmbeddedConverter.convert(paragraph)
|
||||||
node = IFrame.new(href: iframe.mediaResource.href)
|
|
||||||
else
|
|
||||||
node = Empty.new
|
|
||||||
end
|
|
||||||
when PostResponse::ParagraphType::IMG
|
when PostResponse::ParagraphType::IMG
|
||||||
paragraph = paragraphs.shift
|
paragraph = paragraphs.shift
|
||||||
node = convert_img(paragraph)
|
node = convert_img(paragraph)
|
||||||
|
|
|
@ -43,6 +43,9 @@ class MediumClient
|
||||||
iframe {
|
iframe {
|
||||||
mediaResource {
|
mediaResource {
|
||||||
href
|
href
|
||||||
|
iframeSrc
|
||||||
|
iframeWidth
|
||||||
|
iframeHeight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata {
|
metadata {
|
||||||
|
|
|
@ -29,6 +29,26 @@ class PageContent < BaseComponent
|
||||||
raw "<!-- a Container was rendered -->"
|
raw "<!-- a Container was rendered -->"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_child(child : EmbeddedContent)
|
||||||
|
div class: "iframe-wrapper" do
|
||||||
|
iframe(
|
||||||
|
src: child.src,
|
||||||
|
width: child.width,
|
||||||
|
height: child.height,
|
||||||
|
frameborder: "0",
|
||||||
|
allowfullscreen: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_child(child : EmbeddedLink)
|
||||||
|
div class: "embedded" do
|
||||||
|
a href: child.href do
|
||||||
|
text "Embedded content at #{child.domain}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def render_child(node : Emphasis)
|
def render_child(node : Emphasis)
|
||||||
em { render_children(node.children) }
|
em { render_children(node.children) }
|
||||||
end
|
end
|
||||||
|
@ -55,6 +75,10 @@ class PageContent < BaseComponent
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_child(child : GithubGist)
|
||||||
|
script src: child.src
|
||||||
|
end
|
||||||
|
|
||||||
def render_child(node : Heading2)
|
def render_child(node : Heading2)
|
||||||
h2 { render_children(node.children) }
|
h2 { render_children(node.children) }
|
||||||
end
|
end
|
||||||
|
@ -63,14 +87,6 @@ class PageContent < BaseComponent
|
||||||
h3 { render_children(node.children) }
|
h3 { render_children(node.children) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_child(child : IFrame)
|
|
||||||
div class: "embedded" do
|
|
||||||
a href: child.href do
|
|
||||||
text "Embedded content at #{child.domain}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_child(child : Image)
|
def render_child(child : Image)
|
||||||
img src: child.src, width: child.width
|
img src: child.src, width: child.width
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
module Nodes
|
module Nodes
|
||||||
alias Leaf = Text | Image | IFrame
|
alias Embedded = EmbeddedLink | EmbeddedContent | GithubGist
|
||||||
|
alias Leaf = Text | Image | Embedded
|
||||||
alias Child = Container | Leaf | Empty
|
alias Child = Container | Leaf | Empty
|
||||||
alias Children = Array(Child)
|
alias Children = Array(Child)
|
||||||
|
|
||||||
|
@ -120,7 +121,40 @@ module Nodes
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class IFrame
|
class EmbeddedContent
|
||||||
|
MAX_WIDTH = 800
|
||||||
|
|
||||||
|
getter src : String
|
||||||
|
|
||||||
|
def initialize(@src : String, @originalWidth : Int32, @originalHeight : Int32)
|
||||||
|
end
|
||||||
|
|
||||||
|
def width
|
||||||
|
[@originalWidth, MAX_WIDTH].min.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def height
|
||||||
|
if @originalWidth > MAX_WIDTH
|
||||||
|
(@originalHeight * ratio).round.to_i.to_s
|
||||||
|
else
|
||||||
|
@originalHeight.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def ratio
|
||||||
|
MAX_WIDTH / @originalWidth
|
||||||
|
end
|
||||||
|
|
||||||
|
def ==(other : EmbeddedContent)
|
||||||
|
other.src == src && other.width == width && other.height == height
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class EmbeddedLink
|
||||||
getter href : String
|
getter href : String
|
||||||
|
|
||||||
def initialize(@href : String)
|
def initialize(@href : String)
|
||||||
|
@ -130,7 +164,7 @@ module Nodes
|
||||||
URI.parse(href).host
|
URI.parse(href).host
|
||||||
end
|
end
|
||||||
|
|
||||||
def ==(other : IFrame)
|
def ==(other : EmbeddedLink)
|
||||||
other.href == href
|
other.href == href
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -171,4 +205,21 @@ module Nodes
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class GithubGist
|
||||||
|
def initialize(@href : String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def src
|
||||||
|
"#{@href}.js"
|
||||||
|
end
|
||||||
|
|
||||||
|
def ==(other : GithubGist)
|
||||||
|
other.src == src
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -82,6 +82,9 @@ class PostResponse
|
||||||
|
|
||||||
class MediaResource < Base
|
class MediaResource < Base
|
||||||
property href : String
|
property href : String
|
||||||
|
property iframeSrc : String
|
||||||
|
property iframeWidth : Int32
|
||||||
|
property iframeHeight : Int32
|
||||||
end
|
end
|
||||||
|
|
||||||
class Metadata < Base
|
class Metadata < Base
|
||||||
|
|
|
@ -7,3 +7,4 @@ require "avram"
|
||||||
require "lucky"
|
require "lucky"
|
||||||
require "carbon"
|
require "carbon"
|
||||||
require "authentic"
|
require "authentic"
|
||||||
|
require "monads"
|
||||||
|
|
Loading…
Reference in a new issue