diff --git a/Gemfile b/Gemfile index f24d2528913849d91bb3eb4c97857e3975d3b3d3..29eb0df5195856050dae694f080fe66a18fe6274 100644 --- a/Gemfile +++ b/Gemfile @@ -274,7 +274,7 @@ gem 'asciidoctor-kroki', '~> 0.10.0', require: false, feature_category: :markdow gem 'rouge', '~> 4.6.1', feature_category: :shared gem 'truncato', '~> 0.7.13', feature_category: :team_planning gem 'nokogiri', '~> 1.18', feature_category: :shared -gem 'gitlab-glfm-markdown', '~> 0.0.38', feature_category: :markdown +gem 'gitlab-glfm-markdown', '~> 0.0.39', feature_category: :markdown gem 'tanuki_emoji', '~> 0.13', feature_category: :markdown gem 'unicode-emoji', '~> 4.0', feature_category: :markdown diff --git a/Gemfile.checksum b/Gemfile.checksum index 2efcd20c363c9fb6c730a42bdfa7b56618f470ed..736f7bf5cc3e1ff705719bb8a56fc9a23b0ad706 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -227,13 +227,13 @@ {"name":"gitlab-dangerfiles","version":"4.10.0","platform":"ruby","checksum":"0adb9cfec58ffce42f68b1aef528503bdc89aed3994ba461c67e1d9246513e1c"}, {"name":"gitlab-experiment","version":"1.0.0","platform":"ruby","checksum":"9ff76fe55d30012f0531be97143f3af5f74dd1ef3f33d630f320de0ceec9eab9"}, {"name":"gitlab-fog-azure-rm","version":"2.4.0","platform":"ruby","checksum":"678b86e542a37eda10e63ca02d04c9ff998b771df4aabc1f87e5c20148cb360b"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"aarch64-linux-gnu","checksum":"cc07d942d61c2efab764aa5065e21dc336128d8562db7e0815d0a060689f50fb"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"aarch64-linux-musl","checksum":"4efb65768d641a920583c885d26f255f180e4fe4b3016c86e58e2dfa099fe480"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"arm64-darwin","checksum":"4599b09016698b38bf407fe99139893175bc5d83ccc9040bd77faeceaabdc96f"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"ruby","checksum":"8e57dd9283d655a40f6120dac0728f1d088900228977c139e3c969c756f40bc6"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-darwin","checksum":"0be7832fd9066e296abfd6c465c98e920d0f5722844c75ba0e47867b5b767161"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-linux-gnu","checksum":"6725a555a9d6b35e6a89570acb76b249e2248187d5a665c7581449a26fe2958f"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-linux-musl","checksum":"34a741398ba503873d18dac70994284fefae3b3da3ce0fe6c4e97dd4ac002fc0"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"aarch64-linux-gnu","checksum":"3c589bf98fa04b3ad7760564af0892d53b7cfc7058153a0aab331cf15ffbf06f"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"aarch64-linux-musl","checksum":"c3f1ccd9f958b8be0dfeb4f3cb58a185afdc602318748509f009da58fdd49a51"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"arm64-darwin","checksum":"efddd0a07a1b26a1b9af61dc4d9246b9599877a1221a9e0ceb2a5e69ab664cc2"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"ruby","checksum":"a7d7d237203ebe290c2519b742c2c536ee94401a038ad63f043db9f4166fa0c7"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-darwin","checksum":"9f326f6e6771e4fad240bf9bfb2acd85eb86eb44ef058c58d2ba81bc2e0075d5"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-linux-gnu","checksum":"90a12179eb5a9d311a840dccbab7f6d61c52fef5e526586bcfc70154016af683"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-linux-musl","checksum":"f353114bf503e3389159e22c8af1bd12fea7016081bf60ee05b269b80793c101"}, {"name":"gitlab-kas-grpc","version":"18.5.0.pre.rc4","platform":"ruby","checksum":"8efe8bc957572bad2ee90c22836b285eb65574591db5c8a7bc8080f69ddebcc6"}, {"name":"gitlab-labkit","version":"1.0.1","platform":"ruby","checksum":"6f354529ea9a898dc622bfd21ea4e0024dbab0d0d5a6496f0af7f3163013ecb2"}, {"name":"gitlab-license","version":"2.6.0","platform":"ruby","checksum":"2c1f8ae73835640ec77bf758c1d0c9730635043c01cf77902f7976e826d7d016"}, diff --git a/Gemfile.lock b/Gemfile.lock index 952927c84b9a57c3110ba35488c40841dc5a6f51..c1584dc6cff75230271ee3bcde79ef77c855f511 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -771,7 +771,7 @@ GEM mime-types net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - gitlab-glfm-markdown (0.0.38) + gitlab-glfm-markdown (0.0.39) rb_sys (~> 0.9.109) gitlab-kas-grpc (18.5.0.pre.rc4) grpc (~> 1.0) @@ -2192,7 +2192,7 @@ DEPENDENCIES gitlab-duo-workflow-service-client (~> 0.6)! gitlab-experiment (~> 1.0.0) gitlab-fog-azure-rm (~> 2.4.0) - gitlab-glfm-markdown (~> 0.0.38) + gitlab-glfm-markdown (~> 0.0.39) gitlab-grape-openapi! gitlab-housekeeper! gitlab-http! diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum index 47dd47c16f3b169d9960b597bedf06735b16da76..b0eb3807c39ab7ed43b8d4e1d6f1e15763881226 100644 --- a/Gemfile.next.checksum +++ b/Gemfile.next.checksum @@ -227,13 +227,13 @@ {"name":"gitlab-dangerfiles","version":"4.10.0","platform":"ruby","checksum":"0adb9cfec58ffce42f68b1aef528503bdc89aed3994ba461c67e1d9246513e1c"}, {"name":"gitlab-experiment","version":"1.0.0","platform":"ruby","checksum":"9ff76fe55d30012f0531be97143f3af5f74dd1ef3f33d630f320de0ceec9eab9"}, {"name":"gitlab-fog-azure-rm","version":"2.4.0","platform":"ruby","checksum":"678b86e542a37eda10e63ca02d04c9ff998b771df4aabc1f87e5c20148cb360b"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"aarch64-linux-gnu","checksum":"cc07d942d61c2efab764aa5065e21dc336128d8562db7e0815d0a060689f50fb"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"aarch64-linux-musl","checksum":"4efb65768d641a920583c885d26f255f180e4fe4b3016c86e58e2dfa099fe480"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"arm64-darwin","checksum":"4599b09016698b38bf407fe99139893175bc5d83ccc9040bd77faeceaabdc96f"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"ruby","checksum":"8e57dd9283d655a40f6120dac0728f1d088900228977c139e3c969c756f40bc6"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-darwin","checksum":"0be7832fd9066e296abfd6c465c98e920d0f5722844c75ba0e47867b5b767161"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-linux-gnu","checksum":"6725a555a9d6b35e6a89570acb76b249e2248187d5a665c7581449a26fe2958f"}, -{"name":"gitlab-glfm-markdown","version":"0.0.38","platform":"x86_64-linux-musl","checksum":"34a741398ba503873d18dac70994284fefae3b3da3ce0fe6c4e97dd4ac002fc0"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"aarch64-linux-gnu","checksum":"3c589bf98fa04b3ad7760564af0892d53b7cfc7058153a0aab331cf15ffbf06f"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"aarch64-linux-musl","checksum":"c3f1ccd9f958b8be0dfeb4f3cb58a185afdc602318748509f009da58fdd49a51"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"arm64-darwin","checksum":"efddd0a07a1b26a1b9af61dc4d9246b9599877a1221a9e0ceb2a5e69ab664cc2"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"ruby","checksum":"a7d7d237203ebe290c2519b742c2c536ee94401a038ad63f043db9f4166fa0c7"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-darwin","checksum":"9f326f6e6771e4fad240bf9bfb2acd85eb86eb44ef058c58d2ba81bc2e0075d5"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-linux-gnu","checksum":"90a12179eb5a9d311a840dccbab7f6d61c52fef5e526586bcfc70154016af683"}, +{"name":"gitlab-glfm-markdown","version":"0.0.39","platform":"x86_64-linux-musl","checksum":"f353114bf503e3389159e22c8af1bd12fea7016081bf60ee05b269b80793c101"}, {"name":"gitlab-kas-grpc","version":"18.5.0.pre.rc4","platform":"ruby","checksum":"8efe8bc957572bad2ee90c22836b285eb65574591db5c8a7bc8080f69ddebcc6"}, {"name":"gitlab-labkit","version":"1.0.1","platform":"ruby","checksum":"6f354529ea9a898dc622bfd21ea4e0024dbab0d0d5a6496f0af7f3163013ecb2"}, {"name":"gitlab-license","version":"2.6.0","platform":"ruby","checksum":"2c1f8ae73835640ec77bf758c1d0c9730635043c01cf77902f7976e826d7d016"}, diff --git a/Gemfile.next.lock b/Gemfile.next.lock index 46437ce6e3121ae1855eb87ce100ee66d8abcbd5..d19595c0f99171c3152607f50fbf195c6cbec683 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -765,7 +765,7 @@ GEM mime-types net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - gitlab-glfm-markdown (0.0.38) + gitlab-glfm-markdown (0.0.39) rb_sys (~> 0.9.109) gitlab-kas-grpc (18.5.0.pre.rc4) grpc (~> 1.0) @@ -2187,7 +2187,7 @@ DEPENDENCIES gitlab-duo-workflow-service-client (~> 0.6)! gitlab-experiment (~> 1.0.0) gitlab-fog-azure-rm (~> 2.4.0) - gitlab-glfm-markdown (~> 0.0.38) + gitlab-glfm-markdown (~> 0.0.39) gitlab-grape-openapi! gitlab-housekeeper! gitlab-http! diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index b5351130b2bce26cd9f493d20953abd9ee6c82a5..3a89a3eca168375c27b217af59288e06b9e1db69 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -65,14 +65,6 @@ min-height: 177px; padding: 10px 0; overflow-x: auto; - - a.anchor { - margin-left: -1rem !important; - } - - details a.anchor { - margin-left: -1.75rem !important; - } } .markdown-area { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 6ccd218411de7b17834df250d9a557a8352a3feb..4ea41798e7b4aea1e8f34c57015d1f28024c8753 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -83,16 +83,8 @@ @apply gl-mb-5; } - // Shift the anchor link for a heading inside a details summary to the left - // to make room for the summary toggle details summary { @apply gl-font-bold; - - h1, h2, h3, h4, h5, h6 { - a.anchor { - @apply -gl-ml-7; - } - } } summary > * { @@ -686,19 +678,35 @@ h4, h5, h6 { - $anchor-offset: 16px; - a.anchor { - margin-left: calc(-1 * $anchor-offset); - text-decoration: none; - outline: none; - position: absolute; - width: $anchor-offset; + &:not([aria-hidden=true]) { + &::after { + @apply gl-inline-block; + @apply gl-bg-cover; + @include gl-dark-invert-keep-hue; + content: ""; + background-image: url('icon_anchor.svg'); + height: 0.9em; + width: 0.9em; + margin-inline-start: 0.2em; + @apply gl-invisible; + } + } - &::after { - @include gl-dark-invert-keep-hue; - content: url('icon_anchor.svg'); - visibility: hidden; + &:is([aria-hidden=true]) { + $anchor-offset: 20px; + + margin-left: calc(-1 * $anchor-offset); + @apply gl-no-underline; + @apply gl-outline-none; + @apply gl-absolute; + width: $anchor-offset; + + &::after { + @include gl-dark-invert-keep-hue; + content: url('icon_anchor.svg'); + @apply gl-invisible; + } } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 966fcc0ce70563177602971e63abf39920fdbf6a..d9779367c4d5d188bb0ede18acf858cc97068d69 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -180,28 +180,6 @@ ul.related-merge-requests > li gl-emoji { } } -.issue-details { - .card-title { - a.anchor { - left: -16px; - top: 4px; - outline: none; - - &::after { - @include gl-dark-invert-keep-hue; - content: url('icon_anchor.svg'); - visibility: hidden; - } - } - - &:hover > a.anchor::after { - position: relative; - top: -3px; - visibility: visible; - } - } -} - .issue-sticky-header { left: var(--application-bar-left); right: var(--application-bar-right); diff --git a/doc/development/gitlab_flavored_markdown/banzai_pipeline_and_parsing.md b/doc/development/gitlab_flavored_markdown/banzai_pipeline_and_parsing.md index 692763de74957c9ac2bd078cff617402478a0d5f..01176b4be76bc2bd911f9c6b5a15e34b5b62aff6 100644 --- a/doc/development/gitlab_flavored_markdown/banzai_pipeline_and_parsing.md +++ b/doc/development/gitlab_flavored_markdown/banzai_pipeline_and_parsing.md @@ -85,7 +85,7 @@ We use our [`gitlab-glfm-markdown`](https://gitlab.com/gitlab-org/ruby/gems/gitl `comrak` provides 100% compatibility with GFM and CommonMark while allowing additional extensions to be added to it. For example, we were able to implement our multi-line blockquote and wikilink syntax directly in `comrak`. The goal is to move more of the Ruby filters into either `comrak` (if it makes sense) or into `gitlab-glfm-markdown`. -For more information about the various options that get passed into `comrak`, see [glfm_markdown.rb](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/banzai/filter/markdown_engines/glfm_markdown.rb#L12-L34). +For more information about the various options that get passed into `comrak`, see [glfm_markdown.rb](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/banzai/filter/markdown_engines/glfm_markdown.rb#L12-L52). ## Caching diff --git a/ee/spec/helpers/vulnerabilities_helper_spec.rb b/ee/spec/helpers/vulnerabilities_helper_spec.rb index c7e351500cd74e68daa972c1db53076006daf980..7a3b914142235fa62602582a050ce11dcae84b0a 100644 --- a/ee/spec/helpers/vulnerabilities_helper_spec.rb +++ b/ee/spec/helpers/vulnerabilities_helper_spec.rb @@ -603,7 +603,7 @@ end it 'returns finding information' do - rendered_markdown = '

Finding

' + rendered_markdown = '

Finding

' expect(subject[:description_html]).to eq(rendered_markdown) end @@ -616,7 +616,7 @@ end it 'returns finding information' do - rendered_markdown = '

Vulnerability

' + rendered_markdown = '

Vulnerability

' expect(subject[:description_html]).to eq(rendered_markdown) end diff --git a/ee/spec/requests/api/graphql/vulnerabilities/fields_spec.rb b/ee/spec/requests/api/graphql/vulnerabilities/fields_spec.rb index d4be9ecbe7827ad53a5ee91fd22b4773c9e1aa17..c4e50e90041c0b6652d4b96f15f00df79880290f 100644 --- a/ee/spec/requests/api/graphql/vulnerabilities/fields_spec.rb +++ b/ee/spec/requests/api/graphql/vulnerabilities/fields_spec.rb @@ -70,12 +70,15 @@ let(:finding_description) { '# Finding' } it 'returns finding information' do - html = '

' \ - '' \ - 'Finding

' + html = %(

+ Finding + + +

) expect(subject.first['description']).to eq('# Finding') - expect(subject.first['descriptionHtml']).to eq(html) + expect(subject.first['descriptionHtml']).to eq_html(html, trim_text_nodes: true) end end @@ -84,12 +87,15 @@ let(:finding_description) { '# Finding' } it 'returns vulnerability information' do - html = '

' \ - '' \ - 'Vulnerability

' + html = %(

+ Vulnerability + + +

) expect(subject.first['description']).to eq('# Vulnerability') - expect(subject.first['descriptionHtml']).to eq(html) + expect(subject.first['descriptionHtml']).to eq_html(html, trim_text_nodes: true) end end end diff --git a/lib/banzai/filter/heading_accessibility_filter.rb b/lib/banzai/filter/heading_accessibility_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..fdf90b825d006ad1218ffcad31bad2e6a1a2facf --- /dev/null +++ b/lib/banzai/filter/heading_accessibility_filter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Banzai + module Filter + # Finds heading anchors and localises the accessible labels on them. + # + # Headings with anchors as generated by our Markdown parser look as follows. + # Given the input: + # + # ```markdown + # ## so _cool_ h2 + # ``` + # + # The HTML generated is (newlines and spaces added for readability): + # + # ```html + #

+ # so cool h2 + # + #

+ # ``` + # + # We use [data-heading-content] to rewrite aria-label in a localisable way. + class HeadingAccessibilityFilter < HTML::Pipeline::Filter + prepend Concerns::PipelineTimingCheck + + SUBANCHOR_CSS = '> a:last-child.anchor[aria-label][data-heading-content]' + ANCHOR_CSS = %w[h1 h2 h3 h4 h5 h6].map { |h| "#{h} #{SUBANCHOR_CSS}" }.join(', ') + ANCHOR_XPATH = Gitlab::Utils::Nokogiri.css_to_xpath(ANCHOR_CSS).freeze + + def call + doc.xpath(ANCHOR_XPATH).each do |node| + node['aria-label'] = format(_("Link to heading '%{heading}'"), heading: node['data-heading-content']) + end + + doc + end + end + end +end diff --git a/lib/banzai/filter/include_filter.rb b/lib/banzai/filter/include_filter.rb index 8fdf55962d2cd427fddf56ca3923ad9bc87b6181..f6400a5b5c114900a53011705571c6de2e11f721 100644 --- a/lib/banzai/filter/include_filter.rb +++ b/lib/banzai/filter/include_filter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'glfm_markdown' +require 'gitlab-glfm-markdown' module Banzai module Filter diff --git a/lib/banzai/filter/markdown_engines/glfm_markdown.rb b/lib/banzai/filter/markdown_engines/glfm_markdown.rb index 01bdbab94a46976b2bb56f283de488d6d915034a..fc891d9919bfa23686e6d4c0eba2b7cfbe1bb953 100644 --- a/lib/banzai/filter/markdown_engines/glfm_markdown.rb +++ b/lib/banzai/filter/markdown_engines/glfm_markdown.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -require 'glfm_markdown' +require 'gitlab-glfm-markdown' -# Use the glfm_markdown gem (https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown) -# to interface with the Rust based `comrak` parser -# https://github.com/kivikakk/comrak +# Use the gitlab-glfm-markdown gem (https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown) +# to interface with the Rust-based Comrak parser (https://github.com/kivikakk/comrak). module Banzai module Filter module MarkdownEngines @@ -23,6 +22,7 @@ class GlfmMarkdown < Base full_info_string: true, github_pre_lang: true, hardbreaks: false, + header_accessibility: true, header_ids: Banzai::Renderer::USER_CONTENT_ID_PREFIX, math_code: true, math_dollars: true, @@ -36,6 +36,7 @@ class GlfmMarkdown < Base table: true, tagfilter: false, tasklist: false, # still handled by a banzai filter/gem, + taskfilter_in_table: false, wikilinks_title_before_pipe: true, unsafe: true }.freeze diff --git a/lib/banzai/filter/markdown_filter.rb b/lib/banzai/filter/markdown_filter.rb index e6827d5d1c234309ac5399268038531399aee21c..78bef87b46bcebe6e8bab1998fae7a340ec4d5ee 100644 --- a/lib/banzai/filter/markdown_filter.rb +++ b/lib/banzai/filter/markdown_filter.rb @@ -3,7 +3,7 @@ module Banzai module Filter class MarkdownFilter < HTML::Pipeline::TextFilter - GLFM_ENGINE = :glfm_markdown # glfm_markdown/comrak + GLFM_ENGINE = :glfm_markdown # gitlab-glfm-markdown/Comrak DEFAULT_ENGINE = GLFM_ENGINE def initialize(text, context = nil, result = nil) diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index b64ce6520d09fbec7a9ecb515ad7fcbd7ef216bc..5e768163716a55f854cf5decd7b451a9d8cc3f89 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -9,6 +9,7 @@ class SanitizationFilter < Banzai::Filter::BaseSanitizationFilter # Styles used by Markdown for table alignment TABLE_ALIGNMENT_PATTERN = /text-align: (?center|left|right)/ ALLOWED_IDIFF_CLASSES = %w[idiff left right deletion addition].freeze + HEADER_NODE_NAMES = %w[h1 h2 h3 h4 h5 h6].freeze def customize_allowlist(allowlist) allowlist[:allow_comments] = context[:allow_comments] @@ -19,6 +20,7 @@ def customize_allowlist(allowlist) allow_id_attributes(allowlist) allow_class_attributes(allowlist) allow_section_footnotes(allowlist) + allow_anchor_data_heading_content(allowlist) allowlist end @@ -32,7 +34,7 @@ def allow_table_alignment(allowlist) allowlist[:css] = { properties: ['text-align'] } # Remove any `style` properties not required for table alignment - allowlist[:transformers].push(self.class.remove_unsafe_table_style) + allowlist[:transformers].push(self.class.method(:remove_unsafe_table_style)) end def allow_json_table_attributes(allowlist) @@ -47,11 +49,13 @@ def allow_sourcepos_and_escaped_char(allowlist) end def allow_id_attributes(allowlist) - # Allow `id` in `a` and `li` elements for footnotes and `a` elements for header anchors. + # Allow `id` in `a` and `li` elements for footnotes and `h1`~`h6` elements for header anchors. # Remove any `id` properties not matching these patterns via transformer allowlist[:attributes]['a'].push('id') - allowlist[:attributes]['li'] = %w[id] - allowlist[:transformers].push(self.class.remove_id_attributes) + (["li"] + HEADER_NODE_NAMES).each do |tag| + allowlist[:attributes][tag] = %w[id] + end + allowlist[:transformers].push(self.class.method(:remove_id_attributes)) end def allow_class_attributes(allowlist) @@ -61,7 +65,7 @@ def allow_class_attributes(allowlist) allowlist[:attributes]['p'] = %w[class] allowlist[:attributes]['span'].push('class') allowlist[:attributes]['code'].push('class') - allowlist[:transformers].push(self.class.remove_unsafe_classes) + allowlist[:transformers].push(self.class.method(:remove_unsafe_classes)) end def allow_section_footnotes(allowlist) @@ -71,40 +75,40 @@ def allow_section_footnotes(allowlist) allowlist[:attributes]['a'].push('data-footnote-ref', 'data-footnote-backref', 'data-footnote-backref-idx') end + def allow_anchor_data_heading_content(allowlist) + allowlist[:attributes]['a'].push('data-heading-content') + end + class << self - def remove_unsafe_table_style - ->(env) do - node = env[:node] - - return unless node.name == 'th' || node.name == 'td' - return unless node.has_attribute?('style') - - if node['style'] =~ TABLE_ALIGNMENT_PATTERN - node['style'] = "text-align: #{$~[:alignment]}" - else - node.remove_attribute('style') - end + def remove_unsafe_table_style(env) + node = env[:node] + + return unless node.name == 'th' || node.name == 'td' + return unless node.has_attribute?('style') + + if node['style'] =~ TABLE_ALIGNMENT_PATTERN + node['style'] = "text-align: #{$~[:alignment]}" + else + node.remove_attribute('style') end end - def remove_unsafe_classes - ->(env) do - node = env[:node] - - return unless node.has_attribute?('class') - - case node.name - when 'a' - node.remove_attribute('class') if remove_link_class?(node) - when 'div' - node.remove_attribute('class') if remove_div_class?(node) - when 'p' - node.remove_attribute('class') if remove_p_class?(node) - when 'span' - node.remove_attribute('class') if remove_span_class?(node) - when 'code' - node.remove_attribute('class') if remove_code_class?(node) - end + def remove_unsafe_classes(env) + node = env[:node] + + return unless node.has_attribute?('class') + + case node.name + when 'a' + node.remove_attribute('class') if remove_link_class?(node) + when 'div' + node.remove_attribute('class') if remove_div_class?(node) + when 'p' + node.remove_attribute('class') if remove_p_class?(node) + when 'span' + node.remove_attribute('class') if remove_span_class?(node) + when 'code' + node.remove_attribute('class') if remove_code_class?(node) end end @@ -134,24 +138,27 @@ def remove_code_class?(node) node['class'] != 'idiff' end - def remove_id_attributes - ->(env) do - node = env[:node] + def remove_id_attributes(env) + node = env[:node] + return unless node.has_attribute?('id') - return unless node.name == 'a' || node.name == 'li' - return unless node.has_attribute?('id') + id = node['id'] + case node.name + when 'a' # footnote ids should not be removed - return if node.name == 'li' && node['id'].start_with?(Banzai::Filter::FootnoteFilter::FOOTNOTE_ID_PREFIX) - return if node.name == 'a' && - node['id'].start_with?(Banzai::Filter::FootnoteFilter::FOOTNOTE_LINK_ID_PREFIX) - - # links with generated header anchors should not be removed - return if node.name == 'a' && node['class'] == 'anchor' && - node['id'].start_with?(Banzai::Renderer::USER_CONTENT_ID_PREFIX) - - node.remove_attribute('id') + return if id.start_with?(Banzai::Filter::FootnoteFilter::FOOTNOTE_LINK_ID_PREFIX) + when 'li' + # footnote ids should not be removed + return if id.start_with?(Banzai::Filter::FootnoteFilter::FOOTNOTE_ID_PREFIX) + when *HEADER_NODE_NAMES + # headers with generated header anchors should not be removed + return if id.start_with?(Banzai::Renderer::USER_CONTENT_ID_PREFIX) + else + return end + + node.remove_attribute('id') end end end diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index b9ef83eb498fb4ad697fc4099b742ed97111c1ab..60b87273231e5a28540d640ba27540fcc5144143 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -32,6 +32,7 @@ def self.filters Filter::VideoLinkFilter, Filter::AudioLinkFilter, Filter::IframeLinkFilter, + Filter::HeadingAccessibilityFilter, Filter::TableOfContentsTagFilter, Filter::AutolinkFilter, Filter::SuggestionFilter, diff --git a/lib/gitlab/string_placeholder_replacer.rb b/lib/gitlab/string_placeholder_replacer.rb index 3f2b66e96d683218073cd7d110df30ed27544a37..c5181acef0b8d1aec81fc4be320c4bd7501fccef 100644 --- a/lib/gitlab/string_placeholder_replacer.rb +++ b/lib/gitlab/string_placeholder_replacer.rb @@ -17,7 +17,7 @@ def self.replace_string_placeholders(string, placeholder_regex = nil, limit: 0, end def self.placeholder_full_regex(placeholder_regex) - /%(\{|%7B)(#{placeholder_regex})(\}|%7D)/ + /%(\{|25%7B)(#{placeholder_regex})(\}|%7D)/ end class << self diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 902262e2c958226ddc96127e812311238405e56b..9200310730a68ec59c80e9e771c390094d2dedaa 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -39373,6 +39373,9 @@ msgstr "" msgid "Link to go to GitLab pipeline documentation" msgstr "" +msgid "Link to heading '%{heading}'" +msgstr "" + msgid "Link to your Grafana instance." msgstr "" diff --git a/spec/features/incidents/user_views_incident_spec.rb b/spec/features/incidents/user_views_incident_spec.rb index 0626178cd11d145a8d0e9111f19317892dbd65d2..720cc6309c8fd9508f955dbab01e3eaa1ab5ea90 100644 --- a/spec/features/incidents/user_views_incident_spec.rb +++ b/spec/features/incidents/user_views_incident_spec.rb @@ -27,7 +27,7 @@ expect(page).to have_header_with_correct_id_and_link(1, 'Description header', 'description-header') end - it_behaves_like 'page meta description', ' Description header Lorem ipsum dolor sit amet' + it_behaves_like 'page meta description', 'Description header Lorem ipsum dolor sit amet' describe 'user actions' do it 'shows the merge request and incident actions', :js, :aggregate_failures do diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb index ee3f6836b083ada887dc8a68afa9ca473bc5b1d4..e088f6b8fb64e16c9a3927dd788ebdca22218d44 100644 --- a/spec/features/issues/user_views_issue_spec.rb +++ b/spec/features/issues/user_views_issue_spec.rb @@ -16,7 +16,7 @@ it { expect(page).to have_header_with_correct_id_and_link(1, "Description header", "description-header") } - it_behaves_like 'page meta description', ' Description header Lorem ipsum dolor sit amet' + it_behaves_like 'page meta description', 'Description header Lorem ipsum dolor sit amet' it 'shows the merge request and issue actions', :aggregate_failures do click_button 'More actions', match: :first diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb index 3c2bc7a2be3a665133b4540da9e74e1209771ef5..c3d2dcd39df9b0a73006c745572b23602de00a06 100644 --- a/spec/features/merge_request/user_views_open_merge_request_spec.rb +++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb @@ -15,7 +15,7 @@ end it 'renders both the title and the description' do - node = find('.md h1 a#user-content-description-header') + node = find('.md h1#user-content-description-header a') expect(node[:href]).to end_with('#description-header') # Work around a weird Capybara behavior where calling `parent` on a node diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index d196172a3b88d9cafda15f8ab575b97fc331a780..7f4bb3be9c107a920e3740746d62b24dc54a4388 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -397,7 +397,8 @@ result = helper.render_wiki_content(wiki_page) - expect(result).to include('Header') + expect(result).to include('>Header ') end end diff --git a/spec/lib/banzai/filter/heading_accessibility_filter_spec.rb b/spec/lib/banzai/filter/heading_accessibility_filter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a9ad714e2a9fd4fb1b5cae8bca52c9780987fb0b --- /dev/null +++ b/spec/lib/banzai/filter/heading_accessibility_filter_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Banzai::Filter::HeadingAccessibilityFilter, feature_category: :markdown do + include FilterSpecHelper + + def filter(markdown) + Banzai::PipelineBase.new([Banzai::Filter::MarkdownFilter, described_class]).call(markdown)[:output] + end + + def pipeline_filter(text, context = {}) + context = { project: nil, no_sourcepos: true }.merge(context) + + doc = Banzai::Pipeline::PreProcessPipeline.call(text, {}) + doc = Banzai::Pipeline::FullPipeline.call(doc[:output], context) + + doc[:output] + end + + it 'rewrites heading aria-labels' do + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:_).and_return("Link pealkirjale '%{heading}'") + end + doc = filter("# Tere, maailm!") + a = doc.css('h1 > a.anchor').first + expect(a['aria-label']).to eq("Link pealkirjale 'Tere, maailm!'") + end + + # Check that (a) it actually runs, and (b) the needed attributes aren't sanitised out. + it 'rewrites heading aria-labels in the full pipeline' do + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:_).and_return("Link pealkirjale '%{heading}'") + end + doc = pipeline_filter("# Tere, maailm!") + a = doc.css('h1 > a.anchor').first + expect(a['aria-label']).to eq("Link pealkirjale 'Tere, maailm!'") + end +end diff --git a/spec/lib/banzai/filter/markdown_engines/glfm_markdown_spec.rb b/spec/lib/banzai/filter/markdown_engines/glfm_markdown_spec.rb index 0b658c6484ec3b1a69208703f326416ad5ed23f6..57694055e135b2794c803a7c8c36f5d42a5f5edd 100644 --- a/spec/lib/banzai/filter/markdown_engines/glfm_markdown_spec.rb +++ b/spec/lib/banzai/filter/markdown_engines/glfm_markdown_spec.rb @@ -5,54 +5,52 @@ RSpec.describe Banzai::Filter::MarkdownEngines::GlfmMarkdown, feature_category: :markdown do it 'defaults to generating sourcepos' do engine = described_class.new({}) - expected = <<~TEXT -

hi

- TEXT + html = engine.render('# hi') - expect(engine.render('# hi')).to eq expected + fragment = Nokogiri::HTML.fragment(html) + expect(fragment.css('h1').first['data-sourcepos']).to eq('1:1-1:4') end it 'turns off sourcepos' do engine = described_class.new({ no_sourcepos: true }) - expected = <<~TEXT -

hi

- TEXT + html = engine.render('# hi') - expect(engine.render('# hi')).to eq expected + fragment = Nokogiri::HTML.fragment(html) + expect(fragment.css('h1').first['data-sourcepos']).to be_nil end it 'turns off header anchors' do engine = described_class.new({ no_header_anchors: true, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

hi

- TEXT + HTML expect(engine.render('# hi')).to eq expected end it 'turns off autolinking' do engine = described_class.new({ autolink: false, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

http://example.com

- TEXT + HTML expect(engine.render('http://example.com')).to eq expected end it 'returns proper inline sourcepos' do engine = described_class.new({}) - expected = <<~TEXT + expected = <<~HTML

code

- TEXT + HTML expect(engine.render('`code`')).to eq expected end it 'turns on minimal markdown options' do engine = described_class.new({ minimum_markdown: true }) - expected = <<~TEXT + expected = <<~HTML

http://example.com emphasis $x + y$

- TEXT + HTML expect(engine.render('http://example.com _emphasis_ $x + y$')).to eq expected end @@ -66,9 +64,9 @@ shared_examples 'enables placeholder rendering by default' do it 'processes %{} syntax as placeholders' do engine = described_class.new({ project: project_reference, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

%{test}

- TEXT + HTML expect(engine.render('%{test}')).to eq expected end @@ -84,18 +82,18 @@ it 'turns off placeholder detection when :disable_placeholders' do engine = described_class.new({ disable_placeholders: true, project: project, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

%{test}

- TEXT + HTML expect(engine.render('%{test}')).to eq expected end it 'turns off placeholder detection when :broadcast_message_placeholders' do engine = described_class.new({ broadcast_message_placeholders: true, project: project, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

%{test}

- TEXT + HTML expect(engine.render('%{test}')).to eq expected end @@ -104,9 +102,9 @@ stub_feature_flags(markdown_placeholders: false) engine = described_class.new({ project: project, no_sourcepos: true }) - expected = <<~TEXT + expected = <<~HTML

%{test}

- TEXT + HTML expect(engine.render('%{test}')).to eq expected end diff --git a/spec/lib/banzai/filter/placeholders_post_filter_spec.rb b/spec/lib/banzai/filter/placeholders_post_filter_spec.rb index cbde1df2cdb91e6330487e1333fcbf8bbcb25017..47bb1899eae99269d1fc00290504c0c94b3d9e26 100644 --- a/spec/lib/banzai/filter/placeholders_post_filter_spec.rb +++ b/spec/lib/banzai/filter/placeholders_post_filter_spec.rb @@ -284,28 +284,25 @@ def expect_replaces_placeholder(markdown, expected) it 'does not allow project_title in a link href' do markdown = '[test](%{gitlab_server}/%{project_title}/foo.png)' expected = '

test

' + 'data-placeholder="%25%7Bgitlab_server%7D/%25%7Bproject_title%7D/foo.png" ' \ + 'data-canonical-src="%25%7Bgitlab_server%7D/%25%7Bproject_title%7D/foo.png">test

' expect(run_pipeline(markdown)).to eq expected end it 'does not recognize unknown placeholder in a link href' do markdown = '[test](%{gitlab_server}/%{foo}/foo.png)' - expected = '

test

' + expected = '

test

' expect(run_pipeline(markdown)).to eq expected end - it 'does not allow placeholders in link text (parser limitation)' do + it 'allows placeholders in link text' do markdown = '[%{gitlab_server}](%{gitlab_server})' - expected = '

%{gitlab_server}

' + expected = '

localhost

' expect(run_pipeline(markdown)).to eq expected end @@ -336,9 +333,9 @@ def expect_replaces_placeholder(markdown, expected) it 'generates the correct attributes' do markdown = '![](https://%{gitlab_server}/%{project_name}/foo.png)' expected = "

' + 'data-canonical-src="https://%25%7Bgitlab_server%7D/%25%7Bproject_name%7D/foo.png">

' # can't use the pipeline because the image_link_filter modifies `src` expect(run_filter(markdown)).to eq expected @@ -347,9 +344,9 @@ def expect_replaces_placeholder(markdown, expected) it 'does not allow project_title in a src' do markdown = '![](https://%{gitlab_server}/%{project_title}/foo.png)' expected = "

' + 'data-canonical-src="https://%25%7Bgitlab_server%7D/%25%7Bproject_title%7D/foo.png">

' expect(run_filter(markdown)).to eq expected end @@ -359,9 +356,9 @@ def expect_replaces_placeholder(markdown, expected) markdown = '![](https://%{gitlab_server}/%{project_name}/foo.png)' expected = "

' + 'data-canonical-src="https://%25%7Bgitlab_server%7D/%25%7Bproject_name%7D/foo.png">

' expect(run_filter(markdown)).to eq expected end @@ -379,7 +376,7 @@ def expect_replaces_placeholder(markdown, expected) expect(result).to include "href=\"https://#{Gitlab.config.gitlab.host}/#{project.path}/foo.png\"" expect(result).to include "data-src=\"https://#{Gitlab.config.gitlab.host}/#{project.path}/foo.png\"" - expect(result).to include 'data-canonical-src="https://%%7Bgitlab_server%7D/%%7Bproject_name%7D/foo.png"' + expect(result).to include 'data-canonical-src="https://%25%7Bgitlab_server%7D/%25%7Bproject_name%7D/foo.png"' expect(result) .to include ') + describe 'heading anchors' do + it 'allows id property on headings' do + exp = %q(

) act = filter(exp) expect(act.to_html).to eq exp end - it 'removes id property for non-anchor links' do - exp = %q() - act = filter(%q()) - - expect(act.to_html).to eq exp - end - it 'removes id property for non-user-content links' do - exp = %q() - act = filter(%q()) + act = filter(%q(

)) + exp = %q(

) expect(act.to_html).to eq exp end diff --git a/spec/lib/banzai/filter/table_of_contents_tag_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_tag_filter_spec.rb index 6eee6950f1faf7b04a5ccf3a24670811a1c29059..b996d2112e405907e5dd17c7b226e3cb82aa2283 100644 --- a/spec/lib/banzai/filter/table_of_contents_tag_filter_spec.rb +++ b/spec/lib/banzai/filter/table_of_contents_tag_filter_spec.rb @@ -69,7 +69,7 @@ doc = pipeline_filter("this [[_toc_]]\n\n# Foo") expect(doc.to_html).to include('this _toc_') - expect(doc.to_html).to include('Foo') + expect(doc.to_html).to include('>Foo