# frozen_string_literal: true require 'nokogiri' module MarkupHelper include ActionView::Helpers::TextHelper include ActionView::Context # Let's increase the render timeout # For a smaller one, a test that renders the blob content statically fails # We can consider removing this custom timeout when markup_rendering_timeout FF is removed: # https://gitlab.com/gitlab-org/gitlab/-/issues/365358 RENDER_TIMEOUT = 5.seconds # Use this in places where you would normally use link_to(gfm(...), ...). def link_to_markdown(body, url, html_options = {}) return '' if body.blank? link_to_html(markdown(body, pipeline: :single_line), url, html_options) end def link_to_markdown_field(object, field, url, html_options = {}) rendered_field = markdown_field(object, field) link_to_html(rendered_field, url, html_options) end # It solves a problem occurring with nested links (i.e. # "outer text gfm ref more outer text"). This will not be # interpreted as intended. Browsers will parse something like # "outer text gfm ref more outer text" (notice the last part is # not linked any more). link_to_html corrects that. It wraps all parts to # explicitly produce the correct linking behavior (i.e. # "outer text gfm ref more outer text"). def link_to_html(redacted, url, html_options = {}) fragment = Nokogiri::HTML::DocumentFragment.parse(redacted) if fragment.children.size == 1 && fragment.children[0].name == 'a' # Fragment has only one node, and it's a link generated by `gfm`. # Replace it with our requested link. text = fragment.children[0].text fragment.children[0].replace(link_to(text, url, html_options)) else # Traverse the fragment's first generation of children looking for # either pure text or emojis, wrapping anything found in the # requested link fragment.children.each do |node| if node.text? node.replace(link_to(node.text, url, html_options)) elsif node.name == 'gl-emoji' node.replace(link_to(node.to_html.html_safe, url, html_options)) end end end # Add any custom CSS classes to the GFM-generated reference links if html_options[:class] fragment.css('a.gfm').add_class(html_options[:class]) end fragment.to_html.html_safe end # Return the first line of +text+, up to +max_chars+, after parsing the line # as Markdown. HTML tags in the parsed output are not counted toward the # +max_chars+ limit. If the length limit falls within a tag's contents, then # the tag contents are truncated without removing the closing tag. def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) md = markdown_field(object, attribute, options.merge(post_process: false)) return unless md.present? tags = %w(a gl-emoji b strong i em pre code p span) tags << 'img' if options[:allow_images] context = markdown_field_render_context(object, attribute, options) context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length) text = prepare_for_rendering(md, context) text = sanitize( text, tags: tags, attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + %w( style data-src data-name data-unicode-version data-html data-reference-type data-project-path data-iid data-mr-title ) ) # since tags are stripped, this can leave empty tags hanging around # (as our markdown wraps images in links) options[:allow_images] ? text : strip_empty_link_tags(text).html_safe end def markdown(text, context = {}) return '' unless text.present? context[:project] ||= @project context[:group] ||= @group html = markdown_unsafe(text, context) prepare_for_rendering(html, context) end def markdown_field(object, field, context = {}) object = object.for_display if object.respond_to?(:for_display) return '' unless object.present? redacted_field_html = object.try(:"redacted_#{field}_html") return redacted_field_html if redacted_field_html render_markdown_field(object, field, context) end def markup(file_name, text, context = {}) context[:project] ||= @project context[:text_source] ||= :blob html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end def render_wiki_content(wiki_page, context = {}) text = wiki_page.content return '' unless text.present? context = render_wiki_content_context(wiki_page.wiki, wiki_page, context) html = markup_unsafe(wiki_page.path, text, context) prepare_for_rendering(html, context) end def markup_unsafe(file_name, text, context = {}) return '' unless text.present? markup = proc do if Gitlab::MarkupHelper.gitlab_markdown?(file_name) markdown_unsafe(text, context) elsif Gitlab::MarkupHelper.asciidoc?(file_name) asciidoc_unsafe(text, context) elsif Gitlab::MarkupHelper.plain?(file_name) plain_unsafe(text) else other_markup_unsafe(file_name, text, context) end end if Feature.enabled?(:markup_rendering_timeout, @project) Gitlab::RenderTimeout.timeout(foreground: RENDER_TIMEOUT, &markup) else markup.call end rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name) simple_format(text) end # Returns the text necessary to reference `entity` across projects # # project - Project to reference # entity - Object that responds to `to_reference` # # Examples: # # cross_project_reference(project, project.issues.first) # # => 'namespace1/project1#123' # # cross_project_reference(project, project.merge_requests.first) # # => 'namespace1/project1!345' # # Returns a String def cross_project_reference(project, entity) if entity.respond_to?(:to_reference) entity.to_reference(project, full: true) else '' end end private def render_wiki_content_context(wiki, wiki_page, context) context.merge( pipeline: :wiki, wiki: wiki, repository: wiki.repository, page_slug: wiki_page.slug, issuable_reference_expansion_enabled: true, requested_path: wiki_page.path ).merge(render_wiki_content_context_container(wiki)) end def render_wiki_content_context_container(wiki) { project: wiki.container } end def strip_empty_link_tags(text) scrubber = Loofah::Scrubber.new do |node| node.remove if node.name == 'a' && node.children.empty? end sanitize text, scrubber: scrubber end def markdown_toolbar_button(options = {}) data = options[:data].merge({ container: 'body' }) css_classes = %w[gl-button btn btn-default-tertiary btn-icon js-md has-tooltip] << options[:css_class].to_s content_tag :button, type: 'button', class: css_classes.join(' '), data: data, title: options[:title], aria: { label: options[:title] } do sprite_icon(options[:icon]) end end def markdown_unsafe(text, context = {}) Banzai.render(text, context) end def asciidoc_unsafe(text, context = {}) context.reverse_merge!( commit: @commit, ref: @ref, requested_path: @path ) Gitlab::Asciidoc.render(text, context) end def plain_unsafe(text) content_tag :pre, class: 'plain-readme' do text end end def other_markup_unsafe(file_name, text, context = {}) Gitlab::OtherMarkup.render(file_name, text, context) end def render_markdown_field(object, field, context = {}) post_process = context.delete(:post_process) post_process = true if post_process.nil? html = Banzai.render_field(object, field, context) return html unless post_process prepare_for_rendering(html, markdown_field_render_context(object, field, context)) end def markdown_field_render_context(object, field, base_context = {}) return base_context unless object.respond_to?(:banzai_render_context) base_context.reverse_merge(object.banzai_render_context(field)) end def prepare_for_rendering(html, context = {}) return '' unless html.present? context.reverse_merge!( current_user: (current_user if defined?(current_user)), # RepositoryLinkFilter and UploadLinkFilter commit: @commit, wiki: @wiki, ref: @ref, requested_path: @path ) html = Banzai.post_process(html, context) Hamlit::RailsHelpers.preserve(html) end extend self end MarkupHelper.prepend_mod_with('MarkupHelper')