diff --git a/config/feature_flags/development/markdown_image_attributes.yml b/config/feature_flags/development/markdown_image_attributes.yml new file mode 100644 index 0000000000000000000000000000000000000000..ddc2ca6ac6308e10e872b03fa6f63615fecf153e --- /dev/null +++ b/config/feature_flags/development/markdown_image_attributes.yml @@ -0,0 +1,8 @@ +--- +name: markdown_image_attributes +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104904 +rollout_issue_url: +milestone: '15.7' +type: development +group: group::project management +default_enabled: false diff --git a/doc/user/markdown.md b/doc/user/markdown.md index b6f3ba1cfddd62122a574127115076957514fe48..f0dbfb854bbf630c4c14fbf4708f337b1beada85 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -1017,8 +1017,31 @@ Do not change to a reference style link. ![alt text](img/markdown_logo.png "Title Text") -In the rare case where you must set a specific height or width for an image, -you can use the `img` HTML tag instead of Markdown and set its `height` and +#### Change the image dimensions + +> Introduced in GitLab 15.7 [with a flag](../administration/feature_flags.md) named `markdown_image_attributes`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `markdown_image_attributes`. +On GitLab.com, this feature is available. +The feature is not ready for production use. + +You can control the width and height of an image by following the image with +an attribute list. +The value must an integer with a unit of either `px` (default) or `%`. + +For example + +```markdown +![alt text](img/markdown_logo.png "Title Text"){width=100 height=100px} + +![alt text](img/markdown_logo.png "Title Text"){width=75%} +``` + +![alt text](img/markdown_logo.png "Title Text"){width=100 height=100px} + +You can also use the `img` HTML tag instead of Markdown and set its `height` and `width` parameters. #### Videos diff --git a/lib/banzai/filter/attributes_filter.rb b/lib/banzai/filter/attributes_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cf7155b80e2bd80c40c7ca8994a727be6b7ce3c --- /dev/null +++ b/lib/banzai/filter/attributes_filter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Banzai + module Filter + # Looks for attributes that are specified for an element. Follows the basic syntax laid out + # in https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/attributes.md + # For example, + # ![](http://example.com/image.jpg){width=50%} + # + # However we currently have the following limitations: + # - only support images + # - only support the `width` and `height` attributes + # - attributes can not span multiple lines + # - unsupported attributes are thrown away + class AttributesFilter < HTML::Pipeline::Filter + CSS = 'img' + XPATH = Gitlab::Utils::Nokogiri.css_to_xpath(CSS).freeze + + ATTRIBUTES_PATTERN = %r{\A(?\{(?.{1,100})\})}.freeze + WIDTH_HEIGHT_REGEX = %r{\A(?height|width)="?(?[\w%]{1,10})"?\z}.freeze + VALID_SIZE_REGEX = %r{\A\d{1,4}(%|px)?\z}.freeze + + def call + return doc unless Feature.enabled?(:markdown_image_attributes, group) + + doc.xpath(XPATH).each do |img| + sibling = img.next + next unless sibling && sibling.text? && sibling.content.first == '{' + + match = sibling.content.match(ATTRIBUTES_PATTERN) + next unless match && match[:attributes] + + match[:attributes].split(' ').each do |attribute| + next unless attribute.match?(WIDTH_HEIGHT_REGEX) + + attribute_match = attribute.match(WIDTH_HEIGHT_REGEX) + img[attribute_match[:name].to_sym] = attribute_match[:size] if valid_size?(attribute_match[:size]) + end + + sibling.content = sibling.content.sub(match[:matched], '') + end + + doc + end + + private + + def valid_size?(size) + size.match?(VALID_SIZE_REGEX) + end + + def group + context[:group] || context[:project]&.group + end + end + end +end diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index 9b73e413d44cfe1802ce0ff7495071b8664c2900..8ac4d0aadab126acdb84ec0a53aed8a59d6b44c8 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -22,6 +22,7 @@ def self.filters Filter::MermaidFilter, Filter::VideoLinkFilter, Filter::AudioLinkFilter, + Filter::AttributesFilter, Filter::ImageLazyLoadFilter, Filter::ImageLinkFilter, *metrics_filters, diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb index 08f9b8eda13ff2a68eba385085d53a17005695db..73670772402d2f69c39a71a6077c31a40a093259 100644 --- a/spec/features/markdown/markdown_spec.rb +++ b/spec/features/markdown/markdown_spec.rb @@ -290,6 +290,13 @@ def doc(html = @html) aggregate_failures 'KrokiFilter' do expect(doc).to parse_kroki end + + aggregate_failures 'AttributeFilter' do + img = doc.at_css('img[alt="Sized Image"]') + + expect(img.attr('width')).to eq('75%') + expect(img.attr('height')).to eq('100') + end end end diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index 14885813d9365c26a9c65826b1e74a96a2363b0c..38b2a8381bb29225ca68da17ace02e55697df5fe 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -399,3 +399,7 @@ Bob -> Sara : Hello [beard]-:>[foul mouth] ] ``` + +### Image Attributes + +![Sized Image](app/assets/images/touch-icon-ipad.png){width=75% height=100} diff --git a/spec/lib/banzai/filter/attributes_filter_spec.rb b/spec/lib/banzai/filter/attributes_filter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..86af3364feffc539e0945e296eb9fa44fc458b7e --- /dev/null +++ b/spec/lib/banzai/filter/attributes_filter_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Banzai::Filter::AttributesFilter, feature_category: :team_planning do + using RSpec::Parameterized::TableSyntax + include FilterSpecHelper + + def image + %() + end + + it 'does not recognize new syntax when feature flag is off' do + stub_feature_flags(markdown_image_attributes: false) + doc = filter("#{image}{width=100}") + + expect(doc.to_s).to eq "#{image}{width=100}" + end + + describe 'attribute syntax' do + context 'when attribute syntax is valid' do + where(:text, :result) do + "#{image}{width=100}" | '' + "#{image}{ width=100 }" | '' + "#{image}{width=\"100\"}" | '' + "#{image}{width=100 width=200}" | '' + + "#{image}{.test_class width=100 style=\"width:400\"}" | '' + "{width=100}" | '' + end + + with_them do + it 'adds them to the img' do + expect(filter(text).to_html).to eq result + end + end + end + + context 'when attribute syntax is invalid' do + where(:text, :result) do + "#{image} {width=100}" | ' {width=100}' + "#{image}{width=100\nheight=100}" | "{width=100\nheight=100}" + "{width=100 height=100}\n#{image}" | "{width=100 height=100}\n" + '

header

{width=100}' | '

header

{width=100}' + end + + with_them do + it 'does not recognize as attributes' do + expect(filter(text).to_html).to eq result + end + end + end + end + + describe 'height and width' do + context 'when size attributes are valid' do + where(:text, :result) do + "#{image}{width=100 height=200px}" | '' + "#{image}{width=100}" | '' + "#{image}{width=100px}" | '' + "#{image}{height=100%}" | '' + "#{image}{width=\"100%\"}" | '' + end + + with_them do + it 'adds them to the img' do + expect(filter(text).to_html).to eq result + end + end + end + + context 'when size attributes are invalid' do + where(:text, :result) do + "#{image}{width=100cs}" | '' + "#{image}{width=auto height=200}" | '' + end + + with_them do + it 'ignores them' do + expect(filter(text).to_html).to eq result + end + end + end + end +end