From a6cafaa1fc77f98723b8d82da2eaedc5c4a09aab Mon Sep 17 00:00:00 2001
From: Edward Loveall <edward@edwardloveall.com>
Date: Mon, 13 Sep 2021 13:27:52 -0400
Subject: [PATCH] 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.
---
 shard.lock                               |  4 +
 shard.yml                                |  2 +
 spec/classes/embedded_converter_spec.cr  | 99 ++++++++++++++++++++++++
 spec/classes/paragraph_converter_spec.cr |  7 +-
 spec/components/page_content_spec.cr     | 47 ++++++++++-
 spec/models/nodes_spec.cr                | 17 +++-
 src/classes/embedded_converter.cr        | 42 ++++++++++
 src/classes/paragraph_converter.cr       |  6 +-
 src/clients/medium_client.cr             |  3 +
 src/components/page_content.cr           | 32 ++++++--
 src/models/nodes.cr                      | 57 +++++++++++++-
 src/models/post_response.cr              |  3 +
 src/shards.cr                            |  1 +
 13 files changed, 298 insertions(+), 22 deletions(-)
 create mode 100644 spec/classes/embedded_converter_spec.cr
 create mode 100644 src/classes/embedded_converter.cr

diff --git a/shard.lock b/shard.lock
index 42a8367..124a443 100644
--- a/shard.lock
+++ b/shard.lock
@@ -60,6 +60,10 @@ shards:
     git: https://github.com/luckyframework/lucky_task.git
     version: 0.1.0
 
+  monads:
+    git: https://github.com/alex-lairan/monads.git
+    version: 1.0.0
+
   pg:
     git: https://github.com/will/crystal-pg.git
     version: 0.23.2
diff --git a/shard.yml b/shard.yml
index 64132a9..54fdc4a 100644
--- a/shard.yml
+++ b/shard.yml
@@ -26,6 +26,8 @@ dependencies:
   lucky_task:
     github: luckyframework/lucky_task
     version: ~> 0.1.0
+  monads:
+    github: alex-lairan/monads
 development_dependencies:
   lucky_flow:
     github: luckyframework/lucky_flow
diff --git a/spec/classes/embedded_converter_spec.cr b/spec/classes/embedded_converter_spec.cr
new file mode 100644
index 0000000..4b7ec07
--- /dev/null
+++ b/spec/classes/embedded_converter_spec.cr
@@ -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
diff --git a/spec/classes/paragraph_converter_spec.cr b/spec/classes/paragraph_converter_spec.cr
index 1d31bbb..bac37f5 100644
--- a/spec/classes/paragraph_converter_spec.cr
+++ b/spec/classes/paragraph_converter_spec.cr
@@ -272,7 +272,10 @@ describe ParagraphConverter do
           "markups": [],
           "iframe": {
             "mediaResource": {
-              "href": "https://example.com"
+              "href": "https://example.com",
+              "iframeSrc": "",
+              "iframeWidth": 0,
+              "iframeHeight": 0
             }
           },
           "layout": null,
@@ -312,7 +315,7 @@ describe ParagraphConverter do
         Image.new(src: "1*miroimage.png", originalWidth: 618, originalHeight: 682),
         FigureCaption.new(children: [Text.new("text")] of Child),
       ] of Child),
-      IFrame.new(href: "https://example.com"),
+      EmbeddedLink.new(href: "https://example.com"),
       MixtapeEmbed.new(children: [
         Anchor.new(
           children: [Text.new("Mixtape")] of Child,
diff --git a/spec/components/page_content_spec.cr b/spec/components/page_content_spec.cr
index bbb5877..6272a43 100644
--- a/spec/components/page_content_spec.cr
+++ b/spec/components/page_content_spec.cr
@@ -152,6 +152,24 @@ describe PageContent do
     HTML
   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
     page = Page.new(
       title: "Title",
@@ -210,7 +228,32 @@ describe PageContent do
     HTML
   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(
       title: "Title",
       subtitle: nil,
@@ -218,7 +261,7 @@ describe PageContent do
       created_at: Time.local,
       nodes: [
         Paragraph.new(children: [
-          IFrame.new(href: "https://example.com"),
+          EmbeddedLink.new(href: "https://example.com"),
         ] of Child),
       ] of Child
     )
diff --git a/spec/models/nodes_spec.cr b/spec/models/nodes_spec.cr
index f9a43ac..1eb20b1 100644
--- a/spec/models/nodes_spec.cr
+++ b/spec/models/nodes_spec.cr
@@ -1,9 +1,9 @@
 require "../spec_helper"
 
 module Nodes
-  describe IFrame do
+  describe EmbeddedLink 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")
     end
@@ -23,4 +23,17 @@ module Nodes
       image.src.should eq("https://cdn-images-1.medium.com/fit/c/800/482/image.png")
     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
diff --git a/src/classes/embedded_converter.cr b/src/classes/embedded_converter.cr
new file mode 100644
index 0000000..6848bfc
--- /dev/null
+++ b/src/classes/embedded_converter.cr
@@ -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
diff --git a/src/classes/paragraph_converter.cr b/src/classes/paragraph_converter.cr
index 0596aff..6070cf6 100644
--- a/src/classes/paragraph_converter.cr
+++ b/src/classes/paragraph_converter.cr
@@ -20,11 +20,7 @@ class ParagraphConverter
         node = Heading3.new(children: children)
       when PostResponse::ParagraphType::IFRAME
         paragraph = paragraphs.shift
-        if iframe = paragraph.iframe
-          node = IFrame.new(href: iframe.mediaResource.href)
-        else
-          node = Empty.new
-        end
+        node = EmbeddedConverter.convert(paragraph)
       when PostResponse::ParagraphType::IMG
         paragraph = paragraphs.shift
         node = convert_img(paragraph)
diff --git a/src/clients/medium_client.cr b/src/clients/medium_client.cr
index 60fe41a..1898497 100644
--- a/src/clients/medium_client.cr
+++ b/src/clients/medium_client.cr
@@ -43,6 +43,9 @@ class MediumClient
                 iframe {
                   mediaResource {
                     href
+                    iframeSrc
+                    iframeWidth
+                    iframeHeight
                   }
                 }
                 metadata {
diff --git a/src/components/page_content.cr b/src/components/page_content.cr
index 248e544..980b424 100644
--- a/src/components/page_content.cr
+++ b/src/components/page_content.cr
@@ -29,6 +29,26 @@ class PageContent < BaseComponent
     raw "<!-- a Container was rendered -->"
   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)
     em { render_children(node.children) }
   end
@@ -55,6 +75,10 @@ class PageContent < BaseComponent
     end
   end
 
+  def render_child(child : GithubGist)
+    script src: child.src
+  end
+
   def render_child(node : Heading2)
     h2 { render_children(node.children) }
   end
@@ -63,14 +87,6 @@ class PageContent < BaseComponent
     h3 { render_children(node.children) }
   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)
     img src: child.src, width: child.width
   end
diff --git a/src/models/nodes.cr b/src/models/nodes.cr
index 3c141d8..4721e33 100644
--- a/src/models/nodes.cr
+++ b/src/models/nodes.cr
@@ -1,5 +1,6 @@
 module Nodes
-  alias Leaf = Text | Image | IFrame
+  alias Embedded = EmbeddedLink | EmbeddedContent | GithubGist
+  alias Leaf = Text | Image | Embedded
   alias Child = Container | Leaf | Empty
   alias Children = Array(Child)
 
@@ -120,7 +121,40 @@ module Nodes
     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
 
     def initialize(@href : String)
@@ -130,7 +164,7 @@ module Nodes
       URI.parse(href).host
     end
 
-    def ==(other : IFrame)
+    def ==(other : EmbeddedLink)
       other.href == href
     end
 
@@ -171,4 +205,21 @@ module Nodes
       false
     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
diff --git a/src/models/post_response.cr b/src/models/post_response.cr
index d1411de..28c6768 100644
--- a/src/models/post_response.cr
+++ b/src/models/post_response.cr
@@ -82,6 +82,9 @@ class PostResponse
 
   class MediaResource < Base
     property href : String
+    property iframeSrc : String
+    property iframeWidth : Int32
+    property iframeHeight : Int32
   end
 
   class Metadata < Base
diff --git a/src/shards.cr b/src/shards.cr
index ac3bf5c..30fb81f 100644
--- a/src/shards.cr
+++ b/src/shards.cr
@@ -7,3 +7,4 @@ require "avram"
 require "lucky"
 require "carbon"
 require "authentic"
+require "monads"