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:
Edward Loveall 2021-09-13 13:27:52 -04:00
parent 903f3f4b38
commit a6cafaa1fc
No known key found for this signature in database
GPG key ID: 789A4AE983AC8901
13 changed files with 298 additions and 22 deletions

View file

@ -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

View file

@ -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

View 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

View file

@ -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,

View file

@ -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
) )

View file

@ -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

View 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

View file

@ -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)

View file

@ -43,6 +43,9 @@ class MediumClient
iframe { iframe {
mediaResource { mediaResource {
href href
iframeSrc
iframeWidth
iframeHeight
} }
} }
metadata { metadata {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -7,3 +7,4 @@ require "avram"
require "lucky" require "lucky"
require "carbon" require "carbon"
require "authentic" require "authentic"
require "monads"