First Few Unified Embed implementations (Liquid Tags) (#15550)

* Adding {% embed %} for liquid tags

This commit introduces the crease for us to unify our embed experience.
The main concept is introducing a lookup function for the given "link".
And with that lookup function find the underlying `LiquidTagBase` that
can handle the specific URL.

This is part proof of concept and part laying the foundation for a
plugin architecture of different LiquidTags.

We have a lot more work ahead of us to normalize this:

- Update and migrate existing data (not required but hopefully helps us
  deprecate existing liquid tags)
- Move these "embeddable" tags into a module space that eases lookup.
- Document how someone might make a Railstie gem to inject new
  embeddable formats into their application.

Related to #15099

* start adding UnifiedEmbeds to LiqTag models

* add specs for asciinema and codepen liqtags

* new tags and their specs

* complete first pass of liqtag models

* complete implementation for JsFiddleTag

* fixing twittertimeline tag

* almost done with YoutubeTag embed

* remove Medium UnifiedEmbed due to broken image in prod

* remove semi-working unified embed

* update specs

* Address PR review feedback

* add missing spec

* implement PR feedback

* last of PR review-related changes

* fix failing spec

Co-authored-by: Jeremy Friesen <jeremy.n.friesen@gmail.com>
This commit is contained in:
Arit Amana 2021-12-01 19:33:34 -05:00 committed by GitHub
parent 61b94b5176
commit abec2e58e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 220 additions and 16 deletions

View file

@ -1,6 +1,6 @@
class AsciinemaTag < LiquidTagBase
PARTIAL = "liquids/asciinema".freeze
ASCIINEMA_URL_REGEX = %r{https://asciinema.org/a/(?<id>\d+)}
REGISTRY_REGEXP = %r{https://asciinema.org/a/(?<id>\d+)}
def initialize(_tag_name, id, _parse_context)
super
@ -20,7 +20,7 @@ class AsciinemaTag < LiquidTagBase
def parse_id(input)
sanitized_input = input.strip
match_data = sanitized_input.match(ASCIINEMA_URL_REGEX)
match_data = sanitized_input.match(REGISTRY_REGEXP)
match_data ? match_data["id"] : validate(sanitized_input)
end
@ -32,3 +32,5 @@ class AsciinemaTag < LiquidTagBase
end
Liquid::Template.register_tag("asciinema", AsciinemaTag)
UnifiedEmbed.register(AsciinemaTag, regexp: AsciinemaTag::REGISTRY_REGEXP)

View file

@ -1,6 +1,6 @@
class CodepenTag < LiquidTagBase
PARTIAL = "liquids/codepen".freeze
URL_REGEXP =
REGISTRY_REGEXP =
%r{\A(http|https)://(codepen\.io|codepen\.io/team)/[a-zA-Z0-9_\-]{1,30}/pen/([a-zA-Z0-9]{5,32})/{0,1}\z}
def initialize(_tag_name, link, _parse_context)
@ -48,7 +48,7 @@ class CodepenTag < LiquidTagBase
def valid_link?(link)
link_no_space = link.delete(" ")
(link_no_space =~ URL_REGEXP)&.zero?
(link_no_space =~ REGISTRY_REGEXP)&.zero?
end
def raise_error
@ -57,3 +57,5 @@ class CodepenTag < LiquidTagBase
end
Liquid::Template.register_tag("codepen", CodepenTag)
UnifiedEmbed.register(CodepenTag, regexp: CodepenTag::REGISTRY_REGEXP)

View file

@ -60,3 +60,5 @@ class GistTag < LiquidTagBase
end
Liquid::Template.register_tag("gist", GistTag)
UnifiedEmbed.register(GistTag, regexp: %r{https?://gist.github.com})

View file

@ -1,7 +1,7 @@
class JsFiddleTag < LiquidTagBase
PARTIAL = "liquids/jsfiddle".freeze
OPTION_REGEXP = /\A(js|html|css|result|,)*\z/
LINK_REGEXP = %r{\A(http|https)://(jsfiddle\.net)/[a-zA-Z0-9\-/]*\z}
REGISTRY_REGEXP = %r{\A(http|https)://(jsfiddle\.net)/[a-zA-Z0-9\-/]*\z}
def initialize(_tag_name, link, _parse_context)
super
@ -47,8 +47,10 @@ class JsFiddleTag < LiquidTagBase
def valid_link?(link)
link_no_space = link.delete(" ")
(link_no_space =~ LINK_REGEXP).zero?
(link_no_space =~ REGISTRY_REGEXP).zero?
end
end
Liquid::Template.register_tag("jsfiddle", JsFiddleTag)
UnifiedEmbed.register(JsFiddleTag, regexp: JsFiddleTag::REGISTRY_REGEXP)

View file

@ -1,5 +1,6 @@
class NextTechTag < LiquidTagBase
PARTIAL = "liquids/nexttech".freeze
REGISTRY_REGEXP = %r{https?://nt.dev/s/}
def initialize(_tag_name, share_url, _parse_context)
super
@ -35,3 +36,5 @@ class NextTechTag < LiquidTagBase
end
Liquid::Template.register_tag("nexttech", NextTechTag)
UnifiedEmbed.register(NextTechTag, regexp: NextTechTag::REGISTRY_REGEXP)

View file

@ -2,7 +2,7 @@ class RedditTag < LiquidTagBase
include ActionView::Helpers::SanitizeHelper
PARTIAL = "liquids/reddit".freeze
URL_REGEXP = %r{\Ahttps://(www.)?reddit.com}
REGISTRY_REGEXP = %r{\Ahttps://(www.)?reddit.com}
def initialize(_tag_name, url, _parse_context)
super
@ -61,7 +61,7 @@ class RedditTag < LiquidTagBase
end
def validate_url
return true if valid_url?(@url.delete(" ")) && (@url =~ URL_REGEXP)&.zero?
return true if valid_url?(@url.delete(" ")) && (@url =~ REGISTRY_REGEXP)&.zero?
raise StandardError, "Invalid Reddit link: #{@url}"
end
@ -73,3 +73,5 @@ class RedditTag < LiquidTagBase
end
Liquid::Template.register_tag("reddit", RedditTag)
UnifiedEmbed.register(RedditTag, regexp: RedditTag::REGISTRY_REGEXP)

View file

@ -1,5 +1,6 @@
class SoundcloudTag < LiquidTagBase
PARTIAL = "liquids/soundcloud".freeze
REGISTRY_REGEXP = %r{https?://soundcloud.com}
def initialize(_tag_name, link, _parse_context)
super
@ -41,3 +42,5 @@ class SoundcloudTag < LiquidTagBase
end
Liquid::Template.register_tag("soundcloud", SoundcloudTag)
UnifiedEmbed.register(SoundcloudTag, regexp: SoundcloudTag::REGISTRY_REGEXP)

View file

@ -4,6 +4,8 @@ class TwitterTimelineTag < LiquidTagBase
URL_REGEXP = %r{\Ahttps://twitter\.com/[a-zA-Z0-9]+/timelines/\d+\Z}
REGISTRY_REGEXP = %r{https://twitter\.com/[a-zA-Z0-9]+/timelines/\d+}
SCRIPT = <<~JAVASCRIPT.freeze
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
JAVASCRIPT
@ -44,3 +46,5 @@ class TwitterTimelineTag < LiquidTagBase
end
Liquid::Template.register_tag("twitter_timeline", TwitterTimelineTag)
UnifiedEmbed.register(TwitterTimelineTag, regexp: TwitterTimelineTag::REGISTRY_REGEXP)

View file

@ -0,0 +1,46 @@
# The purpose of this singleton class is to provide a registry for the
# numerous "link/embedded" type liquid tags.
#
# Each of those liquid tags must self-register their lookup regular
# expression.
class UnifiedEmbed
include Singleton
def self.current
instance
end
# @api public
#
# @param klass [Class] the LiquidTag class that we use when we match
# the given :regexp
# @param regexp [Regexp] the regular expression that when matched
# means we use the associated :klass
def self.register(klass, regexp:)
instance.register(klass, regexp: regexp)
end
# @api public
#
# @param link [String] the string that includes the URI for the
# embed and possibly additional attributes, depending on how
# the registered liquid tag parses this string.
# @return [Class] a descendant class of LiquidTagBase
def self.find_liquid_tag_for(link:)
instance.find_liquid_tag_for(link: link)
end
def initialize
@registry = []
end
def register(klass, regexp:)
@registry << [regexp, klass]
end
def find_liquid_tag_for(link:)
link = ActionController::Base.helpers.strip_tags(link).strip if link.include?("href=")
_regexp, klass = @registry.detect { |regexp, _tag_class| regexp.match?(link) }
klass
end
end

View file

@ -0,0 +1,42 @@
# This liquid tag is present to facilitate a unified user experience
# for declaring that they want a URL to have "embedded" behavior.
#
# What do we mean by embedded behavior? A more contextually rich
# rendering of the URL, instead of a simple "A-tag".
#
# @see https://github.com/forem/forem/issues/15099 for details on the
# purpose of this class.
class UnifiedEmbedTag < LiquidTagBase
# You will not get a UnifiedEmbedTag instance, as we are instead
# using this class as a lookup (e.g., Factory pattern?) for the
# LiquidTagBase instance that is applicable for the given :link.
#
# @param tag_name [String] in the UI, this was liquid tag name
# (e.g., `{% tag_name link %}`)
# @param link [String] the URL and additional options for that
# particular service.
# @param parse_context [Liquid::ParseContext]
#
# @return [LiquidTagBase]
def self.new(tag_name, link, parse_context)
klass = UnifiedEmbed.find_liquid_tag_for(link: link)
if klass
# Why the __send__? Because a LiquidTagBase class "privatizes"
# the `.new` method. And we want to instantiate the specific
# liquid tag for the given link.
klass.__send__(:new, tag_name, link, parse_context)
else
# If we don't know how to handle the embed, let's just give the
# user an A-tag.
super
end
end
def render(_context)
link, _options = ActionController::Base.helpers.strip_tags(@markup).split
%(<a href="#{link}">#{link}</a>)
end
end
Liquid::Template.register_tag("embed", UnifiedEmbedTag)

View file

@ -1,6 +1,6 @@
class WikipediaTag < LiquidTagBase
PARTIAL = "liquids/wikipedia".freeze
WIKI_REGEXP = %r{\Ahttps?://([a-z-]+)\.wikipedia.org/wiki/(\S+)\z}
REGISTRY_REGEXP = %r{\Ahttps?://([a-z-]+)\.wikipedia.org/wiki/(\S+)\z}
TEXT_CLEANUP_XPATH = "//div[contains(@class, 'noprint') or contains(@class, 'hatnote')] | " \
"//span[@class='mw-ref'] | //figure | //sup".freeze
@ -23,7 +23,7 @@ class WikipediaTag < LiquidTagBase
private
def valid_url?(input)
input.match?(WIKI_REGEXP)
input.match?(REGISTRY_REGEXP)
end
def get_data(input)
@ -100,3 +100,5 @@ class WikipediaTag < LiquidTagBase
end
Liquid::Template.register_tag("wikipedia", WikipediaTag)
UnifiedEmbed.register(WikipediaTag, regexp: WikipediaTag::REGISTRY_REGEXP)

View file

@ -1,5 +1,6 @@
class YoutubeTag < LiquidTagBase
PARTIAL = "liquids/youtube".freeze
REGISTRY_REGEXP = %r{https?://(www\.)?youtube\.(com|be)/(embed|watch)?(\?v=)?(/)?[a-zA-Z0-9_-]{11}((\?t=)?(\d{1,})?)?}
MARKER_TO_SECONDS_MAP = {
"h" => 60 * 60,
"m" => 60,
@ -8,7 +9,11 @@ class YoutubeTag < LiquidTagBase
def initialize(_tag_name, id, _parse_context)
super
@id = parse_id(id)
# for if id is an unstripped URL; doesn't appear to affect bare youtube ids
input = ActionController::Base.helpers.strip_tags(id).strip
@id = parse_id_or_url(input)
@width = 710
@height = 399
end
@ -26,12 +31,24 @@ class YoutubeTag < LiquidTagBase
private
def parse_id(input)
input_no_space = input.delete(" ")
raise StandardError, "Invalid YouTube ID" unless valid_id?(input_no_space)
return translate_start_time(input_no_space) if input_no_space.include?("?t=")
def parse_id_or_url(input)
if (input =~ REGISTRY_REGEXP)&.zero?
extract_youtube_id(input)
else
input_no_space = input.delete(" ")
raise StandardError, "Invalid YouTube ID" unless valid_id?(input_no_space)
return translate_start_time(input_no_space) if input_no_space.include?("?t=")
input_no_space
input_no_space
end
end
def extract_youtube_id(url)
url = url.gsub(/(>|<)/, "").split(%r{(vi/|v=|/v/|youtu\.be/|/embed/)})
raise StandardError, "Invalid YouTube URL" if url[2].nil?
id = url[2].split(/[^a-zA-Z0-9_-]/i) # tweak this to allow for time, fix youtube_tag_spec
id[0]
end
def valid_id?(id)
@ -55,3 +72,5 @@ class YoutubeTag < LiquidTagBase
end
Liquid::Template.register_tag("youtube", YoutubeTag)
UnifiedEmbed.register(YoutubeTag, regexp: YoutubeTag::REGISTRY_REGEXP)

View file

@ -0,0 +1,57 @@
require "rails_helper"
RSpec.describe UnifiedEmbed do
subject(:unified_embed) { described_class }
describe ".find_liquid_tag_for" do
it "returns GistTag for a gist url" do
expect(described_class.find_liquid_tag_for(link: "https://gist.github.com/jeremyf/662585f5c4d22184a6ae133a71bf891a"))
.to eq(GistTag)
end
it "returns AsciinemaTag for an asciinema url" do
expect(described_class.find_liquid_tag_for(link: "https://asciinema.org/a/330532"))
.to eq(AsciinemaTag)
end
it "returns CodepenTag for a codepen url" do
expect(described_class.find_liquid_tag_for(link: "https://codepen.io/elisavetTriant/pen/KKvRRyE"))
.to eq(CodepenTag)
end
it "returns JsFiddle for a jsfiddle url" do
expect(described_class.find_liquid_tag_for(link: "http://jsfiddle.net/link2twenty/v2kx9jcd"))
.to eq(JsFiddleTag)
end
it "returns NextTechTag for a nexttech url" do
expect(described_class.find_liquid_tag_for(link: "https://nt.dev/s/6ba1fffbd09e"))
.to eq(NextTechTag)
end
it "returns RedditTag for a reddit url" do
expect(described_class.find_liquid_tag_for(link: "https://www.reddit.com/r/Cricket/comments/qrkwol/match_thread_2nd_semifinal_australia_vs_pakistan/"))
.to eq(RedditTag)
end
it "returns SoundcloudTag for a soundcloud url" do
expect(described_class.find_liquid_tag_for(link: "https://soundcloud.com/before-30-tv/stranger-moni-lati-lo-1"))
.to eq(SoundcloudTag)
end
it "returns TwitterTimelineTag for a twitter timeline url" do
expect(described_class.find_liquid_tag_for(link: "https://twitter.com/FreyaHolmer/timelines/1215413954505297922"))
.to eq(TwitterTimelineTag)
end
it "returns WikipediaTag for a twitter timeline url" do
expect(described_class.find_liquid_tag_for(link: "https://en.wikipedia.org/wiki/Steve_Jobs"))
.to eq(WikipediaTag)
end
it "returns YoutubeTag for a youtube url" do
expect(described_class.find_liquid_tag_for(link: "https://www.youtube.com/embed/dQw4w9WgXcQ"))
.to eq(YoutubeTag)
end
end
end

View file

@ -0,0 +1,18 @@
require "rails_helper"
RSpec.describe UnifiedEmbedTag, type: :liquid_tag do
it "delegates parsing to the link-matching class" do
link = "https://gist.github.com/jeremyf/662585f5c4d22184a6ae133a71bf891a"
allow(GistTag).to receive(:new).and_call_original
parsed_tag = Liquid::Template.parse("{% embed #{link} %}")
expect { parsed_tag.render }.not_to raise_error
expect(GistTag).to have_received(:new)
end
it "renders an A-tag when no link-matching class is found" do
link = "https://takeonrules.com/about"
parsed_tag = Liquid::Template.parse("{% embed #{link} %}")
expect(parsed_tag.render).to eq(%(<a href="#{link}">#{link}</a>))
end
end