# encoding: UTF-8 require 'set' require 'twitter-text/core_ext/hash' module Twitter # A module for including Tweet auto-linking in a class. The primary use of this is for helpers/views so they can auto-link # usernames, lists, hashtags and URLs. module Autolink extend self # Default CSS class for auto-linked lists DEFAULT_LIST_CLASS = "tweet-url list-slug".freeze # Default CSS class for auto-linked usernames DEFAULT_USERNAME_CLASS = "tweet-url username".freeze # Default CSS class for auto-linked hashtags DEFAULT_HASHTAG_CLASS = "tweet-url hashtag".freeze # Default CSS class for auto-linked cashtags DEFAULT_CASHTAG_CLASS = "tweet-url cashtag".freeze # Default URL base for auto-linked usernames DEFAULT_USERNAME_URL_BASE = "https://twitter.com/".freeze # Default URL base for auto-linked lists DEFAULT_LIST_URL_BASE = "https://twitter.com/".freeze # Default URL base for auto-linked hashtags DEFAULT_HASHTAG_URL_BASE = "https://twitter.com/#!/search?q=%23".freeze # Default URL base for auto-linked cashtags DEFAULT_CASHTAG_URL_BASE = "https://twitter.com/#!/search?q=%24".freeze # Default attributes for invisible span tag DEFAULT_INVISIBLE_TAG_ATTRS = "style='position:absolute;left:-9999px;'".freeze DEFAULT_OPTIONS = { :list_class => DEFAULT_LIST_CLASS, :username_class => DEFAULT_USERNAME_CLASS, :hashtag_class => DEFAULT_HASHTAG_CLASS, :cashtag_class => DEFAULT_CASHTAG_CLASS, :username_url_base => DEFAULT_USERNAME_URL_BASE, :list_url_base => DEFAULT_LIST_URL_BASE, :hashtag_url_base => DEFAULT_HASHTAG_URL_BASE, :cashtag_url_base => DEFAULT_CASHTAG_URL_BASE, :invisible_tag_attrs => DEFAULT_INVISIBLE_TAG_ATTRS }.freeze def auto_link_with_json(text, json, options = {}) # concatenate entities entities = json.values().flatten() # map JSON entity to twitter-text entity entities.each do |entity| entity.symbolize_keys! # hashtag entity[:hashtag] = entity[:text] if entity[:text] end auto_link_entities(text, entities, options) end def auto_link_entities(text, entities, options = {}, &block) return text if entities.empty? # NOTE deprecate these attributes not options keys in options hash, then use html_attrs options = DEFAULT_OPTIONS.merge(options) options[:html_attrs] = extract_html_attrs_from_options!(options) options[:html_attrs][:rel] ||= "nofollow" unless options[:suppress_no_follow] Twitter::Rewriter.rewrite_entities(text, entities) do |entity, chars| if entity[:url] link_to_url(entity, chars, options, &block) elsif entity[:hashtag] link_to_hashtag(entity, chars, options, &block) elsif entity[:screen_name] link_to_screen_name(entity, chars, options, &block) elsif entity[:cashtag] link_to_cashtag(entity, chars, options, &block) end end end # Add tags around the usernames, lists, hashtags and URLs in the provided text. # The tags can be controlled with the following entries in the options hash: # Also any elements in the options hash will be converted to HTML attributes # and place in the tag. # # :url_class:: class to add to url tags # :list_class:: class to add to list tags # :username_class:: class to add to username tags # :hashtag_class:: class to add to hashtag tags # :cashtag_class:: class to add to cashtag tags # :username_url_base:: the value for href attribute on username links. The @username (minus the @) will be appended at the end of this. # :list_url_base:: the value for href attribute on list links. The @username/list (minus the @) will be appended at the end of this. # :hashtag_url_base:: the value for href attribute on hashtag links. The #hashtag (minus the #) will be appended at the end of this. # :cashtag_url_base:: the value for href attribute on cashtag links. The $cashtag (minus the $) will be appended at the end of this. # :invisible_tag_attrs:: HTML attribute to add to invisible span tags # :username_include_symbol:: place the @ symbol within username and list links # :suppress_lists:: disable auto-linking to lists # :suppress_no_follow:: do not add rel="nofollow" to auto-linked items # :symbol_tag:: tag to apply around symbol (@, #, $) in username / hashtag / cashtag links # :text_with_symbol_tag:: tag to apply around text part in username / hashtag / cashtag links # :url_target:: the value for target attribute on URL links. # :link_attribute_block:: function to modify the attributes of a link based on the entity. called with |entity, attributes| params, and should modify the attributes hash. # :link_text_block:: function to modify the text of a link based on the entity. called with |entity, text| params, and should return a modified text. def auto_link(text, options = {}, &block) auto_link_entities(text, Extractor.extract_entities_with_indices(text, :extract_url_without_protocol => false), options, &block) end # Add tags around the usernames and lists in the provided text. The # tags can be controlled with the following entries in the options hash. # Also any elements in the options hash will be converted to HTML attributes # and place in the tag. # # :list_class:: class to add to list tags # :username_class:: class to add to username tags # :username_url_base:: the value for href attribute on username links. The @username (minus the @) will be appended at the end of this. # :list_url_base:: the value for href attribute on list links. The @username/list (minus the @) will be appended at the end of this. # :username_include_symbol:: place the @ symbol within username and list links # :suppress_lists:: disable auto-linking to lists # :suppress_no_follow:: do not add rel="nofollow" to auto-linked items # :symbol_tag:: tag to apply around symbol (@, #, $) in username / hashtag / cashtag links # :text_with_symbol_tag:: tag to apply around text part in username / hashtag / cashtag links # :link_attribute_block:: function to modify the attributes of a link based on the entity. called with |entity, attributes| params, and should modify the attributes hash. # :link_text_block:: function to modify the text of a link based on the entity. called with |entity, text| params, and should return a modified text. def auto_link_usernames_or_lists(text, options = {}, &block) # :yields: list_or_username auto_link_entities(text, Extractor.extract_mentions_or_lists_with_indices(text), options, &block) end # Add tags around the hashtags in the provided text. # The tags can be controlled with the following entries in the options hash. # Also any elements in the options hash will be converted to HTML attributes # and place in the tag. # # :hashtag_class:: class to add to hashtag tags # :hashtag_url_base:: the value for href attribute. The hashtag text (minus the #) will be appended at the end of this. # :suppress_no_follow:: do not add rel="nofollow" to auto-linked items # :symbol_tag:: tag to apply around symbol (@, #, $) in username / hashtag / cashtag links # :text_with_symbol_tag:: tag to apply around text part in username / hashtag / cashtag links # :link_attribute_block:: function to modify the attributes of a link based on the entity. called with |entity, attributes| params, and should modify the attributes hash. # :link_text_block:: function to modify the text of a link based on the entity. called with |entity, text| params, and should return a modified text. def auto_link_hashtags(text, options = {}, &block) # :yields: hashtag_text auto_link_entities(text, Extractor.extract_hashtags_with_indices(text), options, &block) end # Add tags around the cashtags in the provided text. # The tags can be controlled with the following entries in the options hash. # Also any elements in the options hash will be converted to HTML attributes # and place in the tag. # # :cashtag_class:: class to add to cashtag tags # :cashtag_url_base:: the value for href attribute. The cashtag text (minus the $) will be appended at the end of this. # :suppress_no_follow:: do not add rel="nofollow" to auto-linked items # :symbol_tag:: tag to apply around symbol (@, #, $) in username / hashtag / cashtag links # :text_with_symbol_tag:: tag to apply around text part in username / hashtag / cashtag links # :link_attribute_block:: function to modify the attributes of a link based on the entity. called with |entity, attributes| params, and should modify the attributes hash. # :link_text_block:: function to modify the text of a link based on the entity. called with |entity, text| params, and should return a modified text. def auto_link_cashtags(text, options = {}, &block) # :yields: cashtag_text auto_link_entities(text, Extractor.extract_cashtags_with_indices(text), options, &block) end # Add tags around the URLs in the provided text. # The tags can be controlled with the following entries in the options hash. # Also any elements in the options hash will be converted to HTML attributes # and place in the tag. # # :url_class:: class to add to url tags # :invisible_tag_attrs:: HTML attribute to add to invisible span tags # :suppress_no_follow:: do not add rel="nofollow" to auto-linked items # :symbol_tag:: tag to apply around symbol (@, #, $) in username / hashtag / cashtag links # :text_with_symbol_tag:: tag to apply around text part in username / hashtag / cashtag links # :url_target:: the value for target attribute on URL links. # :link_attribute_block:: function to modify the attributes of a link based on the entity. called with |entity, attributes| params, and should modify the attributes hash. # :link_text_block:: function to modify the text of a link based on the entity. called with |entity, text| params, and should return a modified text. def auto_link_urls(text, options = {}, &block) auto_link_entities(text, Extractor.extract_urls_with_indices(text, :extract_url_without_protocol => false), options, &block) end # These methods are deprecated, will be removed in future. extend Deprecation # Deprecated: Please use auto_link_urls instead. # Add tags around the URLs in the provided text. # Any elements in the href_options hash will be converted to HTML attributes # and place in the tag. # Unless href_options contains :suppress_no_follow # the rel="nofollow" attribute will be added. alias :auto_link_urls_custom :auto_link_urls deprecate :auto_link_urls_custom, :auto_link_urls private HTML_ENTITIES = { '&' => '&', '>' => '>', '<' => '<', '"' => '"', "'" => ''' } def html_escape(text) text && text.to_s.gsub(/[&"'><]/) do |character| HTML_ENTITIES[character] end end # NOTE We will make this private in future. public :html_escape # Options which should not be passed as HTML attributes OPTIONS_NOT_ATTRIBUTES = Set.new([ :url_class, :list_class, :username_class, :hashtag_class, :cashtag_class, :username_url_base, :list_url_base, :hashtag_url_base, :cashtag_url_base, :username_url_block, :list_url_block, :hashtag_url_block, :cashtag_url_block, :link_url_block, :username_include_symbol, :suppress_lists, :suppress_no_follow, :url_entities, :invisible_tag_attrs, :symbol_tag, :text_with_symbol_tag, :url_target, :link_attribute_block, :link_text_block ]).freeze def extract_html_attrs_from_options!(options) html_attrs = {} options.reject! do |key, value| unless OPTIONS_NOT_ATTRIBUTES.include?(key) html_attrs[key] = value true end end html_attrs end def url_entities_hash(url_entities) (url_entities || {}).inject({}) do |entities, entity| entity = entity.symbolize_keys entities[entity[:url]] = entity entities end end def link_to_url(entity, chars, options = {}) url = entity[:url] href = if options[:link_url_block] options[:link_url_block].call(url) else url end # NOTE auto link to urls do not use any default values and options # like url_class but use suppress_no_follow. html_attrs = options[:html_attrs].dup html_attrs[:class] = options[:url_class] if options.key?(:url_class) # add target attribute only if :url_target is specified html_attrs[:target] = options[:url_target] if options.key?(:url_target) url_entities = url_entities_hash(options[:url_entities]) # use entity from urlEntities if available url_entity = url_entities[url] || entity link_text = if url_entity[:display_url] html_attrs[:title] ||= url_entity[:expanded_url] link_url_with_entity(url_entity, options) else html_escape(url) end link_to_text(entity, link_text, href, html_attrs, options) end def link_url_with_entity(entity, options) display_url = entity[:display_url] expanded_url = entity[:expanded_url] invisible_tag_attrs = options[:invisible_tag_attrs] || DEFAULT_INVISIBLE_TAG_ATTRS # Goal: If a user copies and pastes a tweet containing t.co'ed link, the resulting paste # should contain the full original URL (expanded_url), not the display URL. # # Method: Whenever possible, we actually emit HTML that contains expanded_url, and use # font-size:0 to hide those parts that should not be displayed (because they are not part of display_url). # Elements with font-size:0 get copied even though they are not visible. # Note that display:none doesn't work here. Elements with display:none don't get copied. # # Additionally, we want to *display* ellipses, but we don't want them copied. To make this happen we # wrap the ellipses in a tco-ellipsis class and provide an onCopy handler that sets display:none on # everything with the tco-ellipsis class. # # Exception: pic.twitter.com images, for which expandedUrl = "https://twitter.com/#!/username/status/1234/photo/1 # For those URLs, display_url is not a substring of expanded_url, so we don't do anything special to render the elided parts. # For a pic.twitter.com URL, the only elided part will be the "https://", so this is fine. display_url_sans_ellipses = display_url.gsub("…", "") if expanded_url.include?(display_url_sans_ellipses) before_display_url, after_display_url = expanded_url.split(display_url_sans_ellipses, 2) preceding_ellipsis = /\A…/.match(display_url).to_s following_ellipsis = /…\z/.match(display_url).to_s # As an example: The user tweets "hi http://longdomainname.com/foo" # This gets shortened to "hi http://t.co/xyzabc", with display_url = "…nname.com/foo" # This will get rendered as: # # … # # http://longdomai # # # nname.com/foo # # #   # … # %(#{preceding_ellipsis} ) << %(#{html_escape(before_display_url)}) << %(#{html_escape(display_url_sans_ellipses)}) << %(#{html_escape(after_display_url)}) << %( #{following_ellipsis}) else html_escape(display_url) end end def link_to_hashtag(entity, chars, options = {}) hash = chars[entity[:indices].first] hashtag = entity[:hashtag] hashtag = yield(hashtag) if block_given? hashtag_class = options[:hashtag_class] if hashtag.match Twitter::Regex::REGEXEN[:rtl_chars] hashtag_class += ' rtl' end href = if options[:hashtag_url_block] options[:hashtag_url_block].call(hashtag) else "#{options[:hashtag_url_base]}#{hashtag}" end html_attrs = { :class => hashtag_class, # FIXME As our conformance test, hash in title should be half-width, # this should be bug of conformance data. :title => "##{hashtag}" }.merge(options[:html_attrs]) link_to_text_with_symbol(entity, hash, hashtag, href, html_attrs, options) end def link_to_cashtag(entity, chars, options = {}) dollar = chars[entity[:indices].first] cashtag = entity[:cashtag] cashtag = yield(cashtag) if block_given? href = if options[:cashtag_url_block] options[:cashtag_url_block].call(cashtag) else "#{options[:cashtag_url_base]}#{cashtag}" end html_attrs = { :class => "#{options[:cashtag_class]}", :title => "$#{cashtag}" }.merge(options[:html_attrs]) link_to_text_with_symbol(entity, dollar, cashtag, href, html_attrs, options) end def link_to_screen_name(entity, chars, options = {}) name = "#{entity[:screen_name]}#{entity[:list_slug]}" chunk = name chunk = yield(name) if block_given? name.downcase! at = chars[entity[:indices].first] html_attrs = options[:html_attrs].dup if entity[:list_slug] && !entity[:list_slug].empty? && !options[:suppress_lists] href = if options[:list_url_block] options[:list_url_block].call(name) else "#{options[:list_url_base]}#{name}" end html_attrs[:class] ||= "#{options[:list_class]}" else href = if options[:username_url_block] options[:username_url_block].call(chunk) else "#{options[:username_url_base]}#{name}" end html_attrs[:class] ||= "#{options[:username_class]}" end link_to_text_with_symbol(entity, at, chunk, href, html_attrs, options) end def link_to_text_with_symbol(entity, symbol, text, href, attributes = {}, options = {}) tagged_symbol = options[:symbol_tag] ? "<#{options[:symbol_tag]}>#{symbol}" : symbol text = html_escape(text) tagged_text = options[:text_with_symbol_tag] ? "<#{options[:text_with_symbol_tag]}>#{text}" : text if options[:username_include_symbol] || symbol !~ Twitter::Regex::REGEXEN[:at_signs] "#{link_to_text(entity, tagged_symbol + tagged_text, href, attributes, options)}" else "#{tagged_symbol}#{link_to_text(entity, tagged_text, href, attributes, options)}" end end def link_to_text(entity, text, href, attributes = {}, options = {}) attributes[:href] = href options[:link_attribute_block].call(entity, attributes) if options[:link_attribute_block] text = options[:link_text_block].call(entity, text) if options[:link_text_block] %(#{text}) end BOOLEAN_ATTRIBUTES = Set.new([:disabled, :readonly, :multiple, :checked]).freeze def tag_attrs(attributes) attributes.keys.sort_by{|k| k.to_s}.inject("") do |attrs, key| value = attributes[key] if BOOLEAN_ATTRIBUTES.include?(key) value = value ? key : nil end unless value.nil? value = case value when Array value.compact.join(" ") else value end attrs << %( #{html_escape(key)}="#{html_escape(value)}") end attrs end end end end