Social Card upgrade - Part 1 (#2090)

* Add HtmlCssToImage for generating images

This will be used by social_previews to convert the templates into images.

Setup a fallback image primarily for local dev, so that this won't cause
errors if authentication is missing.

Caching is setup to use contents of the html/css. So it with
autobust/generate a new image whenever the markup changes.

* Update Article social preview template to not be random

+ Being random breaks caching (since we generate cachekey by a SHA of html content)
+ Use Roboto for when generating via HTML/CSS to Image

* Update Tag social preview template to not be random

+ Make rand deterministic so it doesn't break caching

* Update user social preview template for html/csstoimage

* Update shecoded template to work with both url2png and htmlcsstoimage

* Add ability to respond to .png for social previews controller

When adding .png to the url, these routes will now generate an image and
redirect to it.

This leaves the existing pages as they were, so we're backwards
compatible with all the existing links out in the world.

* Fix merge conflicts

* Remove some duplication

* Improve spec descriptions/read better

* clean up setting template with a ternary
This commit is contained in:
Mike Coutermarsh 2019-03-22 13:28:02 -07:00 committed by Ben Halpern
parent 1b0cb47916
commit dd84051ce4
9 changed files with 212 additions and 43 deletions

View file

@ -84,6 +84,10 @@ variable :CLOUDINARY_API_SECRET, :String, default: "Optional"
variable :CLOUDINARY_CLOUD_NAME, :String, default: "Optional"
variable :CLOUDINARY_SECURE, :String, default: "Optional"
# HTML/CSS to Image for generating social preview images
variable :HCTI_API_USER_ID, :String, default: "Optional"
variable :HCTI_API_KEY, :String, default: "Optional"
# Dacast for streaming
variable :DACAST_STREAM_CODE, :String, default: "Optional"

View file

@ -1,28 +1,65 @@
class SocialPreviewsController < ApplicationController
# No authorization required for entirely public controller
PNG_CSS = "body { transform: scale(0.3); } .preview-div-wrapper { overflow: unset; margin: 5vw; }".freeze
SHE_CODED_TAGS = %w[shecoded theycoded shecodedally].freeze
def article
@article = Article.find(params[:id])
not_found unless @article.published
if (@article.decorate.cached_tag_list_array & %w[shecoded theycoded shecodedally]).any?
render "shecoded", layout: false
else
render layout: false
template = (@article.decorate.cached_tag_list_array & SHE_CODED_TAGS).any? ? "shecoded" : "article"
respond_to do |format|
format.html do
render template, layout: false
end
format.png do
html = render_to_string(template, formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto|Roboto+Condensed"), status: 302
end
end
end
def user
@user = User.find(params[:id]) || not_found
render layout: false
respond_to do |format|
format.html do
render layout: false
end
format.png do
html = render_to_string(formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto"), status: 302
end
end
end
def organization
@user = Organization.find(params[:id]) || not_found
render "user", layout: false
respond_to do |format|
format.html do
render "user", layout: false
end
format.png do
html = render_to_string("user", formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto"), status: 302
end
end
end
def tag
@tag = Tag.find(params[:id]) || not_found
render layout: false
respond_to do |format|
format.html do
render layout: false
end
format.png do
html = render_to_string(formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto"), status: 302
end
end
end
end

View file

@ -0,0 +1,27 @@
module HtmlCssToImage
AUTH = { username: ApplicationConfig["HCTI_API_USER_ID"],
password: ApplicationConfig["HCTI_API_KEY"] }.freeze
FALLBACK_IMAGE = "https://thepracticaldev.s3.amazonaws.com/i/g355ol6qsrg0j2mhngz9.png".freeze
def self.url(html:, css: nil, google_fonts: nil)
image = HTTParty.post("https://hcti.io/v1/image",
body: { html: html, css: css, google_fonts: google_fonts },
basic_auth: AUTH)
image["url"] || FALLBACK_IMAGE
end
def self.fetch_url(html:, css: nil, google_fonts: nil)
cache_key = "htmlcssimage/#{html}/#{css}/#{google_fonts}"
cached_url = Rails.cache.read(cache_key)
return cached_url if cached_url.present?
image_url = url(html: html, css: css, google_fonts: google_fonts)
Rails.cache.write(cache_key, image_url) unless image_url == FALLBACK_IMAGE
image_url
end
end

View file

@ -1,6 +1,7 @@
<% accent_color = HexComparer.new([user_colors(@article.user)[:bg], user_colors(@article.user)[:text]]).biggest %>
<% color = HexComparer.new([user_colors(@article.user)[:bg], user_colors(@article.user)[:text]]).brightness(1.4) %>
<% dark_color = HexComparer.new([user_colors(@article.user)[:bg], user_colors(@article.user)[:text]]).brightness(0.7) %>
<% not_so_rand = Random.new(@article.id) # Using ID as seed ensures we get the same "random" numbers on each page load, Improves caching %>
<style>
body {
background: white;
@ -22,22 +23,22 @@
.circle1 {
height: 40vw;
width: 40vw;
right: -<%= rand(2..15) %>vw;
bottom: -<%= rand(2..15) %>vw;
right: -<%= not_so_rand.rand(2..15) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle2 {
height: 30vw;
width: 30vw;
left: <%= rand(2..16) %>vw;
bottom: 0vw;
left: <%= not_so_rand.rand(2..16) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle3 {
height: 24vw;
width: 24vw;
left: -8vw;
top: -<%= rand(2..6) %>vw;
top: -<%= not_so_rand.rand(2..6) %>vw;
}
.preview-div {
@ -54,7 +55,7 @@
.preview-info-header {
color: black;
margin: 2vw auto 0vw;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, "Roboto", monospace;
font-size: 3.5vw;
width: 92%;
}
@ -70,7 +71,7 @@
color: <%= dark_color %>;
width: 92%;
margin: 1vw auto;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", "Roboto", sans-serif;
}
.preview-user {

View file

@ -8,20 +8,24 @@
background-repeat: repeat;
<% end %>
}
.content {
.preview-div-wrapper {
background: white;
position: absolute;
top: 5vw;
bottom: 5vw;
left: 5vw;
right: 5vw;
width: 80vw;
height: 35vw;
margin: 2.5vw auto 2.5vw;
padding: 4vw;
}
.avatar {
margin-left: auto;
margin-right: auto;
margin-bottom: 2.5vw;
width: 12vw;
}
img {
border-radius: 200vw;
border: 0.6vw solid #70589b;
margin: 3.5vw auto 2.5vw;
display: block;
height: 10vw;
border-radius: 12vw;
border: .75vw solid #70589b;
height: 12vw;
width: 12vw;
}
.title {
font-size: <%= 7 - @article.title.size / 40 %>vw;
@ -29,12 +33,15 @@
width: 80%;
color: #70589b;
text-align: center;
font-family: "HelveticaNeue-CondensedBold", "HelveticaNeueBoldCondensed", "HelveticaNeue-Bold-Condensed", "Helvetica Neue Bold Condensed", "HelveticaNeueBold", "HelveticaNeue-Bold", "Helvetica Neue Bold", "HelveticaNeue", "Helvetica Neue", 'TeXGyreHerosCnBold', "Helvetica", "Tahoma", "Geneva", "Arial Narrow", "Arial", sans-serif;
font-family: "HelveticaNeue-CondensedBold", "HelveticaNeueBoldCondensed", "HelveticaNeue-Bold-Condensed", "Helvetica Neue Bold Condensed", "HelveticaNeueBold", "HelveticaNeue-Bold", "Helvetica Neue Bold", "HelveticaNeue", "Helvetica Neue", 'TeXGyreHerosCnBold', "Helvetica", "Tahoma", "Geneva", "Arial Narrow", "Arial", "Roboto Condensed", sans-serif;
}
</style>
<div class="content">
<img src="<%= ProfileImage.new(@article.user).get(320) %>" />
<div class="preview-div-wrapper">
<div class="avatar">
<img src="<%= ProfileImage.new(@article.user).get(320) %>" />
</div>
<div class="title">
<%= @article.title %>
<strong><%= @article.title %></strong>
</div>
</div>

View file

@ -1,6 +1,7 @@
<% accent_color = HexComparer.new([@tag.bg_color_hex, @tag.text_color_hex]).biggest %>
<% color = HexComparer.new([@tag.bg_color_hex, @tag.text_color_hex]).brightness(1.4) %>
<% dark_color = HexComparer.new([@tag.bg_color_hex, @tag.text_color_hex]).brightness(0.7) %>
<% not_so_rand = Random.new(@tag.id) # Using ID as seed ensures we get the same "random" numbers on each page load, Improves caching %>
<style>
body {
background: white;
@ -22,22 +23,22 @@
.circle1 {
height: 40vw;
width: 40vw;
right: -<%= rand(2..15) %>vw;
bottom: -<%= rand(2..15) %>vw;
right: -<%= not_so_rand.rand(2..15) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle2 {
height: 30vw;
width: 30vw;
left: <%= rand(2..16) %>vw;
bottom: 0vw;
left: <%= not_so_rand.rand(2..16) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle3 {
height: 24vw;
width: 24vw;
left: -8vw;
top: -<%= rand(2..6) %>vw;
top: -<%= not_so_rand.rand(2..6) %>vw;
}
.preview-div {
@ -55,7 +56,7 @@
color: <%= dark_color %>;
width: 92%;
margin: 10vw auto 0vw;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", "Roboto", sans-serif;
font-size: 12vw;
}

View file

@ -1,6 +1,7 @@
<% accent_color = HexComparer.new([user_colors(@user)[:bg], user_colors(@user)[:text]]).biggest %>
<% color = HexComparer.new([user_colors(@user)[:bg], user_colors(@user)[:text]]).brightness(1.4) %>
<% dark_color = HexComparer.new([user_colors(@user)[:bg], user_colors(@user)[:text]]).brightness(0.7) %>
<% not_so_rand = Random.new(@user.id) # Using ID as seed ensures we get the same "random" numbers on each page load, Improves caching %>
<style>
body {
background: white;
@ -22,22 +23,22 @@
.circle1 {
height: 40vw;
width: 40vw;
right: -<%= rand(2..15) %>vw;
bottom: -<%= rand(2..15) %>vw;
right: -<%= not_so_rand.rand(2..15) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle2 {
height: 30vw;
width: 30vw;
left: <%= rand(2..16) %>vw;
bottom: 0vw;
left: <%= not_so_rand.rand(2..16) %>vw;
bottom: -<%= not_so_rand.rand(2..15) %>vw;
}
.circle3 {
height: 24vw;
width: 24vw;
left: -8vw;
top: -<%= rand(2..6) %>vw;
top: -<%= not_so_rand.rand(2..6) %>vw;
}
.preview-div {
@ -55,7 +56,7 @@
color: <%= dark_color %>;
width: 92%;
margin: 10vw auto 1vw;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", "Roboto", sans-serif;
font-size: 8vw;
}

View file

@ -0,0 +1,50 @@
require "rails_helper"
RSpec.describe HtmlCssToImage do
describe ".url" do
it "returns the url to the created image" do
stub_request(:post, /hcti.io/).
to_return(status: 200,
body: '{ "url": "https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1" }',
headers: { "Content-Type" => "application/json" })
expect(described_class.url(html: "test")).to eq("https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1")
end
it "returns fallback image if the request fails" do
stub_request(:post, /hcti.io/).
to_return(status: 429,
body: '{ "error": "Plan limit exceeded" }',
headers: { "Content-Type" => "application/json" })
expect(described_class.url(html: "test")).to eq described_class::FALLBACK_IMAGE
end
end
describe ".fetch_url" do
before do
allow(Rails.cache).to receive(:write)
allow(Rails.cache).to receive(:read)
end
it "caches the image url when successful" do
stub_request(:post, /hcti.io/).
to_return(status: 200,
body: '{ "url": "https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1" }',
headers: { "Content-Type" => "application/json" })
expect(described_class.fetch_url(html: "test")).to eq("https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1")
expect(Rails.cache).to have_received(:write).once
end
it "does not cache errors" do
stub_request(:post, /hcti.io/).
to_return(status: 429,
body: '{ "error": "Plan limit exceeded" }',
headers: { "Content-Type" => "application/json" })
expect(described_class.fetch_url(html: "test")).to eq described_class::FALLBACK_IMAGE
expect(Rails.cache).not_to have_received(:write)
end
end
end

View file

@ -5,12 +5,35 @@ RSpec.describe "SocialPreviews", type: :request do
let(:tag) { create(:tag) }
let(:organization) { create(:organization) }
let(:article) { create(:article, user_id: user.id) }
let(:image_url) { "https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1" }
before do
stub_request(:post, /hcti.io/).
to_return(status: 200,
body: "{ \"url\": \"#{image_url}\" }",
headers: { "Content-Type" => "application/json" })
end
describe "GET /social_previews/article/:id" do
it "renders proper article title" do
get "/social_previews/article/#{article.id}"
expect(response.body).to include CGI.escapeHTML(article.title)
end
it "renders shecoded template when tagged with shecoded" do
she_coded_article = create(:article, tags: "shecoded")
get "/social_previews/article/#{she_coded_article.id}"
expect(response).to render_template(:shecoded)
expect(response.body).to include CGI.escapeHTML(she_coded_article.title)
end
it "renders an image when requested and redirects to image url" do
get "/social_previews/article/#{article.id}.png"
expect(response).to redirect_to(image_url)
end
end
describe "GET /social_previews/user/:id" do
@ -18,19 +41,37 @@ RSpec.describe "SocialPreviews", type: :request do
get "/social_previews/user/#{user.id}"
expect(response.body).to include CGI.escapeHTML(user.name)
end
it "renders an image when requested and redirects to image url" do
get "/social_previews/user/#{user.id}.png"
expect(response).to redirect_to(image_url)
end
end
describe "GET /social_previews/user/:id" do
describe "GET /social_previews/organization/:id" do
it "renders proper organization name" do
get "/social_previews/organization/#{organization.id}"
expect(response.body).to include CGI.escapeHTML(organization.name)
end
it "renders an image when requested and redirects to image url" do
get "/social_previews/organization/#{organization.id}.png"
expect(response).to redirect_to(image_url)
end
end
describe "GET /social_previews/user/:id" do
describe "GET /social_previews/tag/:id" do
it "renders proper tag name" do
get "/social_previews/tag/#{tag.id}"
expect(response.body).to include CGI.escapeHTML(tag.name)
end
it "renders an image when requested and redirects to image url" do
get "/social_previews/tag/#{tag.id}.png"
expect(response).to redirect_to(image_url)
end
end
end