From d90d141c24228b8df6333b03d26a1723480837ba Mon Sep 17 00:00:00 2001 From: Tony Rom Date: Wed, 20 Dec 2017 14:39:40 +0300 Subject: [PATCH 1/2] Add Colors to GitLab Flavored Markdown --- app/assets/javascripts/gfm_auto_complete.js | 2 +- app/assets/stylesheets/framework/gfm.scss | 30 +++++++ changelogs/unreleased/24167__color_label.yml | 5 ++ doc/user/markdown.md | 41 ++++++++- lib/banzai/color_parser.rb | 50 +++++++++++ lib/banzai/filter/color_filter.rb | 31 +++++++ .../pipeline/broadcast_message_pipeline.rb | 1 + lib/banzai/pipeline/gfm_pipeline.rb | 1 + spec/features/markdown_spec.rb | 8 ++ spec/fixtures/markdown.md.erb | 12 +++ spec/javascripts/gfm_auto_complete_spec.js | 9 ++ spec/lib/banzai/color_parser_spec.rb | 90 +++++++++++++++++++ spec/lib/banzai/filter/color_filter_spec.rb | 61 +++++++++++++ spec/support/matchers/markdown_matchers.rb | 21 +++++ 14 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/24167__color_label.yml create mode 100644 lib/banzai/color_parser.rb create mode 100644 lib/banzai/filter/color_filter.rb create mode 100644 spec/lib/banzai/color_parser_spec.rb create mode 100644 spec/lib/banzai/filter/color_filter_spec.rb diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index df20e1e9c88..57a1fa107e5 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -461,7 +461,7 @@ class GfmAutoComplete { const accentAChar = decodeURI('%C3%80'); const accentYChar = decodeURI('%C3%BF'); - const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi'); + const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi'); return regexp.exec(targetSubtext); } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 5621505996d..6eff57157cc 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -16,3 +16,33 @@ background-color: $user-mention-bg-hover; } } + +.gfm-color_chip { + display: inline-block; + margin-left: 4px; + margin-bottom: 2px; + vertical-align: middle; + border-radius: 3px; + + $side: 0.9em; + $bg-size: $side / 0.9; + $bg-pos: $bg-size / 2; + $bg-color: $gray-dark; + + width: $side; + height: $side; + background: $white-light; + background-image: linear-gradient(135deg, $bg-color 25%, transparent 0%, transparent 75%, $bg-color 0%), + linear-gradient(135deg, $bg-color 25%, transparent 0%, transparent 75%, $bg-color 0%); + background-size: $bg-size $bg-size; + background-position: 0 0, $bg-pos $bg-pos; + + > span { + display: inline-block; + width: 100%; + height: 100%; + margin-bottom: 2px; + border-radius: 3px; + border: 1px solid $black-transparent; + } +} diff --git a/changelogs/unreleased/24167__color_label.yml b/changelogs/unreleased/24167__color_label.yml new file mode 100644 index 00000000000..68c6c731163 --- /dev/null +++ b/changelogs/unreleased/24167__color_label.yml @@ -0,0 +1,5 @@ +--- +title: Add Colors to GitLab Flavored Markdown +merge_request: 16095 +author: Tony Rom +type: added diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 552abac747b..b590dfa0d40 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -253,7 +253,7 @@ GFM will recognize the following: | `@user_name` | specific user | | `@group_name` | specific group | | `@all` | entire team | -| `#123` | issue | +| `#12345` | issue | | `!123` | merge request | | `$123` | snippet | | `~123` | label by ID | @@ -379,6 +379,45 @@ _Be advised that KaTeX only supports a [subset][katex-subset] of LaTeX._ >**Note:** This also works for the asciidoctor `:stem: latexmath`. For details see the [asciidoctor user manual][asciidoctor-manual]. +### Colors + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#colors + +It is possible to have color written in HEX, RGB or HSL format rendered with a color indicator. + +Color written inside backticks will be followed by a color "chip". + +Examples: + + `#F00` + `#F00A` + `#FF0000` + `#FF0000AA` + `RGB(0,255,0)` + `RGB(0%,100%,0%)` + `RGBA(0,255,0,0.7)` + `HSL(540,70%,50%)` + `HSLA(540,70%,50%,0.7)` + +Becomes: + +`#F00` +`#F00A` +`#FF0000` +`#FF0000AA` +`RGB(0,255,0)` +`RGB(0%,100%,0%)` +`RGBA(0,255,0,0.7)` +`HSL(540,70%,50%)` +`HSLA(540,70%,50%,0.7)` + +#### Supported formats: + +* HEX: `` `#RGB[A]` `` or `` `#RRGGBB[AA]` `` +* RGB: `` `RGB[A](R, G, B[, A])` `` +* HSL: `` `HSL[A](H, S, L[, A])` `` + ### Mermaid > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15107) in diff --git a/lib/banzai/color_parser.rb b/lib/banzai/color_parser.rb new file mode 100644 index 00000000000..d96c0a1ed1f --- /dev/null +++ b/lib/banzai/color_parser.rb @@ -0,0 +1,50 @@ +module Banzai + module ColorParser + ALPHA = /0(?:\.\d+)?|\.\d+|1(?:\.0+)?/ # 0.0..1.0 + PERCENTS = /(?:\d{1,2}|100)%/ # 00%..100% + ALPHA_CHANNEL = /(?:,\s*(?:#{ALPHA}|#{PERCENTS}))?/ + BITS = /\d{1,2}|1\d\d|2(?:[0-4]\d|5[0-5])/ # 00..255 + DEGS = /-?\d+(?:deg)?/i # [-]digits[deg] + RADS = /-?(?:\d+(?:\.\d+)?|\.\d+)rad/i # [-](digits[.digits] OR .digits)rad + HEX_FORMAT = /\#(?:\h{3}|\h{4}|\h{6}|\h{8})/ + RGB_FORMAT = / + (?:rgba? + \( + (?: + (?:(?:#{BITS},\s*){2}#{BITS}) + | + (?:(?:#{PERCENTS},\s*){2}#{PERCENTS}) + ) + #{ALPHA_CHANNEL} + \) + ) + /xi + HSL_FORMAT = / + (?:hsla? + \( + (?:#{DEGS}|#{RADS}),\s*#{PERCENTS},\s*#{PERCENTS} + #{ALPHA_CHANNEL} + \) + ) + /xi + + FORMATS = [HEX_FORMAT, RGB_FORMAT, HSL_FORMAT].freeze + + class << self + # Public: Analyzes whether the String is a color code. + # + # text - The String to be parsed. + # + # Returns the recognized color String or nil if none was found. + def parse(text) + text if color_format =~ text + end + + private + + def color_format + @color_format ||= /\A(#{Regexp.union(FORMATS)})\z/ix + end + end + end +end diff --git a/lib/banzai/filter/color_filter.rb b/lib/banzai/filter/color_filter.rb new file mode 100644 index 00000000000..6ab29ac281f --- /dev/null +++ b/lib/banzai/filter/color_filter.rb @@ -0,0 +1,31 @@ +module Banzai + module Filter + # HTML filter that renders `color` followed by a color "chip". + # + class ColorFilter < HTML::Pipeline::Filter + COLOR_CHIP_CLASS = 'gfm-color_chip'.freeze + + def call + doc.css('code').each do |node| + color = ColorParser.parse(node.content) + node << color_chip(color) if color + end + + doc + end + + private + + def color_chip(color) + checkerboard = doc.document.create_element('span', class: COLOR_CHIP_CLASS) + chip = doc.document.create_element('span', style: inline_styles(color: color)) + + checkerboard << chip + end + + def inline_styles(color:) + "background-color: #{color};" + end + end + end +end diff --git a/lib/banzai/pipeline/broadcast_message_pipeline.rb b/lib/banzai/pipeline/broadcast_message_pipeline.rb index adc09c8afbd..5dd572de3a1 100644 --- a/lib/banzai/pipeline/broadcast_message_pipeline.rb +++ b/lib/banzai/pipeline/broadcast_message_pipeline.rb @@ -7,6 +7,7 @@ module Banzai Filter::SanitizationFilter, Filter::EmojiFilter, + Filter::ColorFilter, Filter::AutolinkFilter, Filter::ExternalLinkFilter ] diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index c746f6f64e9..4001b8a85e3 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -14,6 +14,7 @@ module Banzai Filter::SyntaxHighlightFilter, Filter::MathFilter, + Filter::ColorFilter, Filter::MermaidFilter, Filter::VideoLinkFilter, Filter::ImageLazyLoadFilter, diff --git a/spec/features/markdown_spec.rb b/spec/features/markdown_spec.rb index a2b78a5e021..f13d78d24e3 100644 --- a/spec/features/markdown_spec.rb +++ b/spec/features/markdown_spec.rb @@ -259,6 +259,10 @@ describe 'GitLab Markdown' do it 'includes VideoLinkFilter' do expect(doc).to parse_video_links end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end end context 'wiki pipeline' do @@ -320,6 +324,10 @@ describe 'GitLab Markdown' do it 'includes VideoLinkFilter' do expect(doc).to parse_video_links end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end end # Fake a `current_user` helper diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index 71abb6da607..da32a46675f 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -280,6 +280,18 @@ However the wrapping tags cannot be mixed as such: ![My Video](/assets/videos/gitlab-demo.mp4) +### Colors + +`#F00` +`#F00A` +`#FF0000` +`#FF0000AA` +`RGB(0,255,0)` +`RGB(0%,100%,0%)` +`RGBA(0,255,0,0.7)` +`HSL(540,70%,50%)` +`HSLA(540,70%,50%,0.7)` + ### Mermaid > If this is not rendered correctly, see diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index 6f357306ec7..182880d42e9 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -131,6 +131,7 @@ describe('GfmAutoComplete', function () { describe('should not match special sequences', () => { const ShouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']); + const ShouldNotBePrependedBy = ['`']; flagsUseDefaultMatcher.forEach((atSign) => { ShouldNotBeFollowedBy.forEach((followedSymbol) => { @@ -140,6 +141,14 @@ describe('GfmAutoComplete', function () { expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); }); }); + + ShouldNotBePrependedBy.forEach((prependedSymbol) => { + const seq = prependedSymbol + atSign; + + it(`should not match "${seq}"`, () => { + expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); + }); + }); }); }); }); diff --git a/spec/lib/banzai/color_parser_spec.rb b/spec/lib/banzai/color_parser_spec.rb new file mode 100644 index 00000000000..a1cb0c07b06 --- /dev/null +++ b/spec/lib/banzai/color_parser_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe Banzai::ColorParser do + describe '.parse' do + context 'HEX format' do + [ + '#abc', '#ABC', + '#d2d2d2', '#D2D2D2', + '#123a', '#123A', + '#123456aa', '#123456AA' + ].each do |color| + it "parses the valid hex color #{color}" do + expect(subject.parse(color)).to eq(color) + end + end + + [ + '#', '#1', '#12', '#12g', '#12G', + '#12345', '#r2r2r2', '#R2R2R2', '#1234567', + '# 123', '# 1234', '# 123456', '# 12345678', + '#1 2 3', '#123 4', '#12 34 56', '#123456 78' + ].each do |color| + it "does not parse the invalid hex color #{color}" do + expect(subject.parse(color)).to be_nil + end + end + end + + context 'RGB format' do + [ + 'rgb(0,0,0)', 'rgb(255,255,255)', + 'rgb(0, 0, 0)', 'RGB(0,0,0)', + 'rgb(0,0,0,0)', 'rgb(0,0,0,0.0)', 'rgb(0,0,0,.0)', + 'rgb(0,0,0, 0)', 'rgb(0,0,0, 0.0)', 'rgb(0,0,0, .0)', + 'rgb(0,0,0,1)', 'rgb(0,0,0,1.0)', + 'rgba(0,0,0)', 'rgba(0,0,0,0)', 'RGBA(0,0,0)', + 'rgb(0%,0%,0%)', 'rgba(0%,0%,0%,0%)' + ].each do |color| + it "parses the valid rgb color #{color}" do + expect(subject.parse(color)).to eq(color) + end + end + + [ + 'FOOrgb(0,0,0)', 'rgb(0,0,0)BAR', + 'rgb(0,0,-1)', 'rgb(0,0,-0)', 'rgb(0,0,256)', + 'rgb(0,0,0,-0.1)', 'rgb(0,0,0,-0.0)', 'rgb(0,0,0,-.1)', + 'rgb(0,0,0,1.1)', 'rgb(0,0,0,2)', + 'rgba(0,0,0,)', 'rgba(0,0,0,0.)', 'rgba(0,0,0,1.)', + 'rgb(0,0,0%)', 'rgb(101%,0%,0%)' + ].each do |color| + it "does not parse the invalid rgb color #{color}" do + expect(subject.parse(color)).to be_nil + end + end + end + + context 'HSL format' do + [ + 'hsl(0,0%,0%)', 'hsl(0,100%,100%)', + 'hsl(540,0%,0%)', 'hsl(-720,0%,0%)', + 'hsl(0deg,0%,0%)', 'hsl(0DEG,0%,0%)', + 'hsl(0, 0%, 0%)', 'HSL(0,0%,0%)', + 'hsl(0,0%,0%,0)', 'hsl(0,0%,0%,0.0)', 'hsl(0,0%,0%,.0)', + 'hsl(0,0%,0%, 0)', 'hsl(0,0%,0%, 0.0)', 'hsl(0,0%,0%, .0)', + 'hsl(0,0%,0%,1)', 'hsl(0,0%,0%,1.0)', + 'hsla(0,0%,0%)', 'hsla(0,0%,0%,0)', 'HSLA(0,0%,0%)', + 'hsl(1rad,0%,0%)', 'hsl(1.1rad,0%,0%)', 'hsl(.1rad,0%,0%)', + 'hsl(-1rad,0%,0%)', 'hsl(1RAD,0%,0%)' + ].each do |color| + it "parses the valid hsl color #{color}" do + expect(subject.parse(color)).to eq(color) + end + end + + [ + 'hsl(+0,0%,0%)', 'hsl(0,0,0%)', 'hsl(0,0%,0)', 'hsl(0 deg,0%,0%)', + 'hsl(0,-0%,0%)', 'hsl(0,101%,0%)', 'hsl(0,-1%,0%)', + 'hsl(0,0%,0%,-0.1)', 'hsl(0,0%,0%,-.1)', + 'hsl(0,0%,0%,1.1)', 'hsl(0,0%,0%,2)', + 'hsl(0,0%,0%,)', 'hsl(0,0%,0%,0.)', 'hsl(0,0%,0%,1.)', + 'hsl(deg,0%,0%)', 'hsl(rad,0%,0%)' + ].each do |color| + it "does not parse the invalid hsl color #{color}" do + expect(subject.parse(color)).to be_nil + end + end + end + end +end diff --git a/spec/lib/banzai/filter/color_filter_spec.rb b/spec/lib/banzai/filter/color_filter_spec.rb new file mode 100644 index 00000000000..a098b037510 --- /dev/null +++ b/spec/lib/banzai/filter/color_filter_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Banzai::Filter::ColorFilter, lib: true do + include FilterSpecHelper + + let(:color) { '#F00' } + let(:color_chip_selector) { 'code > span.gfm-color_chip > span' } + + ['#123', '#1234', '#123456', '#12345678', + 'rgb(0,0,0)', 'RGB(0, 0, 0)', 'rgba(0,0,0,1)', 'RGBA(0,0,0,0.7)', + 'hsl(270,30%,50%)', 'HSLA(270, 30%, 50%, .7)'].each do |color| + it "inserts color chip for supported color format #{color}" do + content = code_tag(color) + doc = filter(content) + color_chip = doc.at_css(color_chip_selector) + + expect(color_chip.content).to be_empty + expect(color_chip.parent[:class]).to eq 'gfm-color_chip' + expect(color_chip[:style]).to eq "background-color: #{color};" + end + end + + it 'ignores valid color code without backticks(code tags)' do + doc = filter(color) + + expect(doc.css('span.gfm-color_chip').size).to be_zero + end + + it 'ignores valid color code with prepended space' do + content = code_tag(' ' + color) + doc = filter(content) + + expect(doc.css(color_chip_selector).size).to be_zero + end + + it 'ignores valid color code with appended space' do + content = code_tag(color + ' ') + doc = filter(content) + + expect(doc.css(color_chip_selector).size).to be_zero + end + + it 'ignores valid color code surrounded by spaces' do + content = code_tag(' ' + color + ' ') + doc = filter(content) + + expect(doc.css(color_chip_selector).size).to be_zero + end + + it 'ignores invalid color code' do + invalid_color = '#BAR' + content = code_tag(invalid_color) + doc = filter(content) + + expect(doc.css(color_chip_selector).size).to be_zero + end + + def code_tag(string) + "#{string}" + end +end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index d12b2757427..ec4ec6f4038 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -190,6 +190,27 @@ module MarkdownMatchers expect(video['src']).to end_with('/assets/videos/gitlab-demo.mp4') end end + + # ColorFilter + matcher :parse_colors do + set_default_markdown_messages + + match do |actual| + color_chips = actual.css('code > span.gfm-color_chip > span') + + expect(color_chips.count).to eq(9) + + [ + '#F00', '#F00A', '#FF0000', '#FF0000AA', 'RGB(0,255,0)', + 'RGB(0%,100%,0%)', 'RGBA(0,255,0,0.7)', 'HSL(540,70%,50%)', + 'HSLA(540,70%,50%,0.7)' + ].each_with_index do |color, i| + parsed_color = Banzai::ColorParser.parse(color) + expect(color_chips[i]['style']).to match("background-color: #{parsed_color};") + expect(color_chips[i].parent.parent.content).to match(color) + end + end + end end # Monkeypatch the matcher DSL so that we can reduce some noisy duplication for From 57f8b24fe2d54df4dfcc3ddcb5796e41093f0fd7 Mon Sep 17 00:00:00 2001 From: Tony Rom Date: Thu, 1 Feb 2018 13:35:03 +0300 Subject: [PATCH 2/2] Fix minors --- app/assets/stylesheets/framework/gfm.scss | 16 +++++++--------- lib/banzai/color_parser.rb | 22 ++++++++-------------- spec/javascripts/gfm_auto_complete_spec.js | 8 ++++---- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 6eff57157cc..e378e84ca1b 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -19,21 +19,19 @@ .gfm-color_chip { display: inline-block; - margin-left: 4px; - margin-bottom: 2px; + margin: 0 0 2px 4px; vertical-align: middle; border-radius: 3px; - $side: 0.9em; - $bg-size: $side / 0.9; + $chip-size: 0.9em; + $bg-size: $chip-size / 0.9; $bg-pos: $bg-size / 2; - $bg-color: $gray-dark; - width: $side; - height: $side; + width: $chip-size; + height: $chip-size; background: $white-light; - background-image: linear-gradient(135deg, $bg-color 25%, transparent 0%, transparent 75%, $bg-color 0%), - linear-gradient(135deg, $bg-color 25%, transparent 0%, transparent 75%, $bg-color 0%); + background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%), + linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%); background-size: $bg-size $bg-size; background-position: 0 0, $bg-pos $bg-pos; diff --git a/lib/banzai/color_parser.rb b/lib/banzai/color_parser.rb index d96c0a1ed1f..0cd9085190c 100644 --- a/lib/banzai/color_parser.rb +++ b/lib/banzai/color_parser.rb @@ -30,21 +30,15 @@ module Banzai FORMATS = [HEX_FORMAT, RGB_FORMAT, HSL_FORMAT].freeze - class << self - # Public: Analyzes whether the String is a color code. - # - # text - The String to be parsed. - # - # Returns the recognized color String or nil if none was found. - def parse(text) - text if color_format =~ text - end + COLOR_FORMAT = /\A(#{Regexp.union(FORMATS)})\z/ix - private - - def color_format - @color_format ||= /\A(#{Regexp.union(FORMATS)})\z/ix - end + # Public: Analyzes whether the String is a color code. + # + # text - The String to be parsed. + # + # Returns the recognized color String or nil if none was found. + def self.parse(text) + text if COLOR_FORMAT =~ text end end end diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index 182880d42e9..50a587ef351 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -130,11 +130,11 @@ describe('GfmAutoComplete', function () { }); describe('should not match special sequences', () => { - const ShouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']); - const ShouldNotBePrependedBy = ['`']; + const shouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']); + const shouldNotBePrependedBy = ['`']; flagsUseDefaultMatcher.forEach((atSign) => { - ShouldNotBeFollowedBy.forEach((followedSymbol) => { + shouldNotBeFollowedBy.forEach((followedSymbol) => { const seq = atSign + followedSymbol; it(`should not match "${seq}"`, () => { @@ -142,7 +142,7 @@ describe('GfmAutoComplete', function () { }); }); - ShouldNotBePrependedBy.forEach((prependedSymbol) => { + shouldNotBePrependedBy.forEach((prependedSymbol) => { const seq = prependedSymbol + atSign; it(`should not match "${seq}"`, () => {