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:
parent
1b0cb47916
commit
dd84051ce4
9 changed files with 212 additions and 43 deletions
4
Envfile
4
Envfile
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
app/lib/html_css_to_image.rb
Normal file
27
app/lib/html_css_to_image.rb
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
50
spec/lib/html_css_to_image_spec.rb
Normal file
50
spec/lib/html_css_to_image_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue