├── lib ├── slack_markdown │ ├── version.rb │ ├── filters │ │ ├── ignorable_ancestor_tags.rb │ │ ├── line_break_filter.rb │ │ ├── bold_filter.rb │ │ ├── italic_filter.rb │ │ ├── strike_filter.rb │ │ ├── code_filter.rb │ │ ├── multiple_code_filter.rb │ │ ├── multiple_quote_filter.rb │ │ ├── emoji_filter.rb │ │ ├── quote_filter.rb │ │ └── convert_filter.rb │ └── processor.rb └── slack_markdown.rb ├── Gemfile ├── spec ├── spec_helper.rb └── slack_markdown │ ├── filters │ ├── code_filter_spec.rb │ ├── multiple_quote_filter_spec.rb │ ├── bold_filter_spec.rb │ ├── italic_filter_spec.rb │ ├── strike_filter_spec.rb │ ├── multiple_code_filter_spec.rb │ ├── quote_filter_spec.rb │ └── emoji_filter_spec.rb │ └── processor_spec.rb ├── Rakefile ├── .rubocop.yml ├── .github └── workflows │ └── spec.yml ├── LICENSE.txt ├── README.md ├── slack_markdown.gemspec ├── .rubocop_todo.yml └── .gitignore /lib/slack_markdown/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SlackMarkdown 4 | VERSION = '0.4.1' 5 | end 6 | -------------------------------------------------------------------------------- /lib/slack_markdown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'slack_markdown/version' 4 | require 'slack_markdown/processor' 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in slack_markdown.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec' 4 | require 'slack_markdown' 5 | 6 | Dir.glob('./support/**/*.rb').sort.each { |f| require f } 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new('spec') 7 | task default: :spec 8 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | 6 | Style/Documentation: 7 | Enabled: false 8 | 9 | Layout/LineLength: 10 | Max: 140 11 | Exclude: 12 | - spec/**/* 13 | 14 | Metrics/BlockLength: 15 | Exclude: 16 | - spec/**/* 17 | -------------------------------------------------------------------------------- /.github/workflows/spec.yml: -------------------------------------------------------------------------------- 1 | name: spec 2 | on: [push, pull_request] 3 | jobs: 4 | spec: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby: [2.6, 2.7, 3.0, head] 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: ${{ matrix.ruby }} 15 | bundler-cache: true 16 | - run: bundle exec rubocop 17 | - run: bundle exec rake spec 18 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/ignorable_ancestor_tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SlackMarkdown 4 | module Filters 5 | module IgnorableAncestorTags 6 | DEFAULT_IGNORED_ANCESTOR_TAGS = %w[pre code tt].freeze 7 | def ignored_ancestor_tags 8 | if context[:ignored_ancestor_tags] 9 | DEFAULT_IGNORED_ANCESTOR_TAGS | context[:ignored_ancestor_tags] 10 | else 11 | DEFAULT_IGNORED_ANCESTOR_TAGS 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/code_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::CodeFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::CodeFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '`hoge`' do 12 | let(:text) { '`hoge`' } 13 | it { should eq 'hoge' } 14 | end 15 | 16 | context '`hoge` fuga `piyo`' do 17 | let(:text) { '`hoge` fuga `piyo`' } 18 | it { should eq 'hoge fuga piyo' } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/multiple_quote_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::MultipleQuoteFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::MultipleQuoteFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '>>> hoge' do 12 | let(:text) { '>>> hoge' } 13 | it { should eq '
hoge
' } 14 | end 15 | 16 | context 'multiline' do 17 | let(:text) { ">>>\nhoge\nfuga" } 18 | it { should eq "
hoge\nfuga
" } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/bold_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::BoldFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::BoldFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '*hoge*' do 12 | let(:text) { '*hoge*' } 13 | it { should eq 'hoge' } 14 | end 15 | 16 | context 'hoge*fuga*poyo' do 17 | let(:text) { 'hoge*fuga*poyo' } 18 | it { should eq 'hoge*fuga*poyo' } 19 | end 20 | 21 | context 'hoge *fuga* poyo' do 22 | let(:text) { 'hoge *fuga* poyo' } 23 | it { should eq 'hoge fuga poyo' } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/italic_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::ItalicFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::ItalicFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '_hoge_' do 12 | let(:text) { '_hoge_' } 13 | it { should eq 'hoge' } 14 | end 15 | 16 | context 'hoge_fuga_poyo' do 17 | let(:text) { 'hoge_fuga_poyo' } 18 | it { should eq 'hoge_fuga_poyo' } 19 | end 20 | 21 | context 'hoge _fuga_ poyo' do 22 | let(:text) { 'hoge _fuga_ poyo' } 23 | it { should eq 'hoge fuga poyo' } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/strike_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::StrikeFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::StrikeFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '~hoge~' do 12 | let(:text) { '~hoge~' } 13 | it { should eq 'hoge' } 14 | end 15 | 16 | context 'hoge~fuga~poyo' do 17 | let(:text) { 'hoge~fuga~poyo' } 18 | it { should eq 'hoge~fuga~poyo' } 19 | end 20 | 21 | context 'hoge ~fuga~ poyo' do 22 | let(:text) { 'hoge ~fuga~ poyo' } 23 | it { should eq 'hoge fuga poyo' } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/line_break_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class LineBreakFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?("\n") 16 | 17 | html = content.gsub("\n", '
') 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/multiple_code_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::MultipleCodeFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::MultipleCodeFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context 'multiple code' do 12 | let(:text) { "```\ndef hoge\n 1 + 1\nend\n```" } 13 | it { should eq "
def hoge\n  1 + 1\nend\n
" } 14 | end 15 | 16 | context 'multiple multiple code' do 17 | let(:text) { "```\ndef hoge\n 1 + 1\nend\n``` fuga ```\ndef piyo\n 1 * 1\nend\n```" } 18 | it { 19 | should eq "
def hoge\n  1 + 1\nend\n
fuga
def piyo\n  1 * 1\nend\n
" 20 | } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/quote_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::QuoteFilter do 6 | subject do 7 | filter = SlackMarkdown::Filters::QuoteFilter.new(text) 8 | filter.call.to_s 9 | end 10 | 11 | context '> hoge' do 12 | let(:text) { '> hoge' } 13 | it { should eq "
hoge\n
" } 14 | end 15 | 16 | context 'multiline' do 17 | let(:text) { "> hoge\n> fuga" } 18 | it { should eq "
hoge\nfuga\n
" } 19 | end 20 | 21 | context 'include text element' do 22 | let(:text) { "> hoge\n> fuga\ntext\n> hai" } 23 | it { should eq "
hoge\nfuga\n
text\n
hai\n
" } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/bold_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class BoldFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('*') 16 | 17 | html = bold_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | def bold_filter(text) 26 | text.gsub(BOLD_PATTERN) do 27 | "#{Regexp.last_match(1)}" 28 | end 29 | end 30 | 31 | BOLD_PATTERN = /(?<=^|\W)\*(.+)\*(?=\W|$)/.freeze 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/italic_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class ItalicFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('_') 16 | 17 | html = italic_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | def italic_filter(text) 26 | text.gsub(ITALIC_PATTERN) do 27 | "#{Regexp.last_match(1)}" 28 | end 29 | end 30 | 31 | ITALIC_PATTERN = /(?<=^|\W)_(.+)_(?=\W|$)/.freeze 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/strike_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class StrikeFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('~') 16 | 17 | html = strike_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | def strike_filter(text) 26 | text.gsub(STRIKE_PATTERN) do 27 | "#{Regexp.last_match(1)}" 28 | end 29 | end 30 | 31 | STRIKE_PATTERN = /(?<=^|\W)~(.+)~(?=\W|$)/.freeze 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/code_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class CodeFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('`') 16 | 17 | html = code_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | private 26 | 27 | def code_filter(text) 28 | text.gsub(CODE_PATTERN) do 29 | "#{Regexp.last_match(1)}" 30 | end 31 | end 32 | 33 | CODE_PATTERN = /(?<=^|\W)`(.+?)`(?=\W|$)/.freeze 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/multiple_code_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class MultipleCodeFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('`') 16 | 17 | html = multiple_code_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | private 26 | 27 | def multiple_code_filter(text) 28 | text.gsub(CODE_PATTERN) do 29 | "
#{Regexp.last_match(1)}
" 30 | end 31 | end 32 | 33 | CODE_PATTERN = /(?<=^|\W)```\n?((?:.|\n)+?)```(?=\W|$)/.freeze 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/multiple_quote_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class MultipleQuoteFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | doc.search('.//text()').each do |node| 13 | content = node.to_html 14 | next if has_ancestor?(node, ignored_ancestor_tags) 15 | next unless content.include?('>>>') 16 | 17 | html = multiple_quote_filter(content) 18 | next if html == content 19 | 20 | node.replace(html) 21 | end 22 | doc 23 | end 24 | 25 | private 26 | 27 | def multiple_quote_filter(text) 28 | lines = text.split(/^>>>(?:\s|\n)*/, 2) 29 | if lines.size < 2 30 | text 31 | else 32 | "#{lines.join('
')}
" 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/emoji_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | 5 | module SlackMarkdown 6 | module Filters 7 | class EmojiFilter < ::HTML::Pipeline::EmojiFilter 8 | def emoji_url(name) 9 | emoji_names.include?(name) ? super : original_emoji_path(name) 10 | end 11 | 12 | def emoji_pattern 13 | @emoji_pattern ||= /:(#{(emoji_names + original_emoji_names).map { |name| Regexp.escape(name) }.join('|')}):/ 14 | end 15 | 16 | def emoji_names 17 | self.class.superclass.emoji_names 18 | end 19 | 20 | def original_emoji_set 21 | context[:original_emoji_set] || {} 22 | end 23 | 24 | def original_emoji_names 25 | original_emoji_set.keys 26 | end 27 | 28 | def original_emoji_path(name) 29 | path = original_emoji_set[name] 30 | 31 | if (matches = path.match(/\Aalias:(.+)\z/)) 32 | emoji_url(matches[1]) 33 | else 34 | path 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/quote_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/ignorable_ancestor_tags' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | class QuoteFilter < ::HTML::Pipeline::Filter 9 | include IgnorableAncestorTags 10 | 11 | def call 12 | html = replace_quote_line(doc.to_s) 13 | collect_blockquote(html) 14 | end 15 | 16 | private 17 | 18 | def replace_quote_line(str) 19 | str.gsub(/^>\s*(.+)(?:\n|$)/) do 20 | "
#{Regexp.last_match(1)}\n
" 21 | end 22 | end 23 | 24 | def collect_blockquote(html) 25 | doc = Nokogiri::HTML.fragment(html) 26 | doc.search('blockquote + blockquote').each do |node| 27 | next unless node.previous.name == 'blockquote' 28 | 29 | html = "
#{node.previous.inner_html}#{node.inner_html}
" 30 | node.previous.remove 31 | node.replace(html) 32 | end 33 | doc 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Ru/MuckRu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlackMarkdown 2 | 3 | SlackMarkdown (https://api.slack.com/docs/formatting) to HTML converter. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'slack_markdown' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install slack_markdown 20 | 21 | ## Usage 22 | 23 | ```ruby 24 | require 'slack_markdown' 25 | 26 | processor = SlackMarkdown::Processor.new( 27 | on_slack_channel_id: -> (uid) { 28 | # TODO: use Slack API 29 | return { url: '/general', text: 'general' } 30 | }, 31 | on_slack_user_id: -> (uid) { 32 | # TODO: use Slack API 33 | return { url: '/ru_shalm', text: 'ru_shalm' } 34 | }, 35 | asset_root: '/', 36 | original_emoji_set: { ... }, 37 | ) 38 | 39 | processor.call("<@U12345> hello *world*")[:output].to_s 40 | # => "@ru_shalm hello world" 41 | ``` 42 | 43 | ## Contributing 44 | 45 | 1. Fork it ( https://github.com/rutan/slack_markdown/fork ) 46 | 2. Create your feature branch (`git checkout -b my-new-feature`) 47 | 3. Commit your changes (`git commit -am 'Add some feature'`) 48 | 4. Push to the branch (`git push origin my-new-feature`) 49 | 5. Create a new Pull Request 50 | -------------------------------------------------------------------------------- /slack_markdown.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'slack_markdown/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'slack_markdown' 9 | spec.version = SlackMarkdown::VERSION 10 | spec.authors = ['Ru/MuckRu'] 11 | spec.email = ['ru_shalm@hazimu.com'] 12 | 13 | spec.summary = 'Convert Slack message markdown to HTML.' 14 | spec.description = 'Convert Slack message markdown to HTML.' 15 | spec.homepage = 'https://github.com/rutan/slack_markdown' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'escape_utils' 24 | spec.add_dependency 'gemoji' 25 | spec.add_dependency 'html-pipeline', '~> 2.0' 26 | 27 | spec.add_development_dependency 'bundler' 28 | spec.add_development_dependency 'pry' 29 | spec.add_development_dependency 'rake' 30 | spec.add_development_dependency 'rspec', '~> 3.2' 31 | spec.add_development_dependency 'rubocop', '~> 1.22.3' 32 | end 33 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2021-11-01 14:17:02 UTC using RuboCop version 1.22.3. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: Include. 11 | # Include: **/*.gemspec 12 | Gemspec/RequiredRubyVersion: 13 | Exclude: 14 | - 'slack_markdown.gemspec' 15 | 16 | # Offense count: 1 17 | # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. 18 | Metrics/AbcSize: 19 | Max: 43 20 | 21 | # Offense count: 1 22 | # Configuration parameters: IgnoredMethods. 23 | Metrics/CyclomaticComplexity: 24 | Max: 16 25 | 26 | # Offense count: 2 27 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 28 | Metrics/MethodLength: 29 | Max: 42 30 | 31 | # Offense count: 1 32 | # Configuration parameters: IgnoredMethods. 33 | Metrics/PerceivedComplexity: 34 | Max: 19 35 | 36 | # Offense count: 1 37 | # Configuration parameters: ForbiddenDelimiters. 38 | # ForbiddenDelimiters: (?-mix:(^|\s)(EO[A-Z]{1}|END)(\s|$)) 39 | Naming/HeredocDelimiterNaming: 40 | Exclude: 41 | - 'spec/slack_markdown/processor_spec.rb' 42 | -------------------------------------------------------------------------------- /lib/slack_markdown/processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'slack_markdown/filters/convert_filter' 5 | require 'slack_markdown/filters/multiple_quote_filter' 6 | require 'slack_markdown/filters/quote_filter' 7 | require 'slack_markdown/filters/multiple_code_filter' 8 | require 'slack_markdown/filters/code_filter' 9 | require 'slack_markdown/filters/emoji_filter' 10 | require 'slack_markdown/filters/bold_filter' 11 | require 'slack_markdown/filters/italic_filter' 12 | require 'slack_markdown/filters/strike_filter' 13 | require 'slack_markdown/filters/line_break_filter' 14 | 15 | module SlackMarkdown 16 | class Processor 17 | def initialize(context = {}) 18 | @context = context 19 | end 20 | attr_reader :context 21 | 22 | def filters 23 | @filters ||= [ 24 | SlackMarkdown::Filters::ConvertFilter, # must first run 25 | SlackMarkdown::Filters::MultipleQuoteFilter, 26 | SlackMarkdown::Filters::QuoteFilter, 27 | SlackMarkdown::Filters::MultipleCodeFilter, 28 | SlackMarkdown::Filters::CodeFilter, 29 | SlackMarkdown::Filters::EmojiFilter, 30 | SlackMarkdown::Filters::BoldFilter, 31 | SlackMarkdown::Filters::ItalicFilter, 32 | SlackMarkdown::Filters::StrikeFilter, 33 | SlackMarkdown::Filters::LineBreakFilter 34 | ] 35 | end 36 | 37 | def call(src_text, context = {}, result = nil) 38 | HTML::Pipeline.new(filters, self.context).call(src_text, context, result) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/slack_markdown/filters/emoji_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Filters::EmojiFilter do 6 | subject do 7 | context = { 8 | asset_root: '/assets', 9 | original_emoji_set: { 10 | 'ru_shalm' => 'http://toripota.com/img/ru_shalm.png', 11 | 'shalm' => 'alias:ru_shalm', 12 | 'happy' => 'alias:smile' 13 | } 14 | } 15 | filter = SlackMarkdown::Filters::EmojiFilter.new(text, context) 16 | filter.call.to_s 17 | end 18 | 19 | context 'Hello :smile:' do 20 | let(:text) { 'Hello :smile:' } 21 | it { 22 | should eq 'Hello :smile:' 23 | } 24 | end 25 | 26 | context ':ru_shalm: is my avatar' do 27 | let(:text) { ':ru_shalm: is my avatar' } 28 | it { 29 | should eq ':ru_shalm: is my avatar' 30 | } 31 | end 32 | 33 | context ':shalm: is an emoji alias' do 34 | let(:text) { ':shalm: is an emoji alias' } 35 | it { 36 | should eq ':shalm: is an emoji alias' 37 | } 38 | end 39 | 40 | context ':happy: is aliased to a standard unicode emoji' do 41 | let(:text) { ':happy: is aliased to a standard unicode emoji' } 42 | it { 43 | should eq ':happy: is aliased to a standard unicode emoji' 44 | } 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/slack_markdown/processor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe SlackMarkdown::Processor do 6 | subject do 7 | context = { 8 | asset_root: '/assets', 9 | original_emoji_set: { 10 | 'ru_shalm' => 'http://toripota.com/img/ru_shalm.png' 11 | }, 12 | on_slack_user_id: lambda do |uid| 13 | { text: 'ru_shalm', url: '/@ru_shalm' } if uid == 'U12345' 14 | end, 15 | on_slack_channel_id: lambda do |uid| 16 | { text: 'ru_shalm', url: 'http://toripota.com' } if uid == 'C01S1JQMYKV' 17 | end, 18 | cushion_link: 'http://localhost/?url=' 19 | } 20 | processor = SlackMarkdown::Processor.new(context) 21 | processor.call(text)[:output].to_s 22 | end 23 | 24 | let :text do 25 | <<~EOS 26 | <@U12345> <@U23456> <#C01S1JQMYKV> *SlackMarkdown* is `text formatter` ~package~ _gem_ . 27 | > :ru_shalm: is 28 | <@U12345|dont_override_me> <@U23456|override_me> <#C01S1JQMYKV|dont_override_me> <#C12345|override_me> 29 | EOS 30 | end 31 | 32 | it do 33 | should eq "@ru_shalm @U23456 #ru_shalm SlackMarkdown is text formatter package gem .
34 | \":ru_shalm:\" is http://toripota.com/img/ru_shalm.png
35 |
@ru_shalm override_me #ru_shalm override_me
" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/slack_markdown/filters/convert_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'html/pipeline' 4 | require 'escape_utils' 5 | 6 | module SlackMarkdown 7 | module Filters 8 | # https://api.slack.com/docs/formatting 9 | class ConvertFilter < ::HTML::Pipeline::TextFilter 10 | def call 11 | html = @text.gsub(/<([^>|]+)(?:\|([^>]+))?>/) do |_match| 12 | link_data = Regexp.last_match(1) 13 | link_text = Regexp.last_match(2) 14 | create_link(link_data, link_text) 15 | end 16 | Nokogiri::HTML.fragment(html) 17 | end 18 | 19 | private 20 | 21 | def create_link(data, override_text = nil) 22 | klass, link, text = 23 | case data 24 | when /\A#(C.+)\z/ # channel 25 | channel = context.include?(:on_slack_channel_id) ? context[:on_slack_channel_id].call(Regexp.last_match(1)) : nil 26 | if channel 27 | override_text = nil 28 | ['channel', channel[:url], "##{channel[:text]}"] 29 | else 30 | ['channel', data, data] 31 | end 32 | when /\A@((?:U|B).+)/ # user or bot 33 | user = context.include?(:on_slack_user_id) ? context[:on_slack_user_id].call(Regexp.last_match(1)) : nil 34 | if user 35 | override_text = nil 36 | ['mention', user[:url], "@#{user[:text]}"] 37 | else 38 | ['mention', nil, data] 39 | end 40 | when /\A@(.+)/ # user name 41 | user = context.include?(:on_slack_user_name) ? context[:on_slack_user_name].call(Regexp.last_match(1)) : nil 42 | if user 43 | override_text = nil 44 | ['mention', user[:url], "@#{user[:text]}"] 45 | else 46 | ['mention', nil, data] 47 | end 48 | when /\A!/ # special command 49 | ['link', nil, data] 50 | else # normal link 51 | ['link', data, data] 52 | end 53 | 54 | if link 55 | escaped_link = 56 | if context[:cushion_link] && link.match(%r{\A([A-Za-z0-9]+:)?//}) 57 | "#{EscapeUtils.escape_html context[:cushion_link]}#{EscapeUtils.escape_url link}" 58 | else 59 | EscapeUtils.escape_html(link).to_s 60 | end 61 | "#{EscapeUtils.escape_html(override_text || text)}" 62 | else 63 | EscapeUtils.escape_html(override_text || text) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # Created by https://www.gitignore.io 12 | 13 | ### OSX ### 14 | .DS_Store 15 | .AppleDouble 16 | .LSOverride 17 | 18 | # Icon must end with two \r 19 | Icon 20 | 21 | 22 | # Thumbnails 23 | ._* 24 | 25 | # Files that might appear in the root of a volume 26 | .DocumentRevisions-V100 27 | .fseventsd 28 | .Spotlight-V100 29 | .TemporaryItems 30 | .Trashes 31 | .VolumeIcon.icns 32 | 33 | # Directories potentially created on remote AFP share 34 | .AppleDB 35 | .AppleDesktop 36 | Network Trash Folder 37 | Temporary Items 38 | .apdisk 39 | 40 | 41 | ### Windows ### 42 | # Windows image file caches 43 | Thumbs.db 44 | ehthumbs.db 45 | 46 | # Folder config file 47 | Desktop.ini 48 | 49 | # Recycle Bin used on file shares 50 | $RECYCLE.BIN/ 51 | 52 | # Windows Installer files 53 | *.cab 54 | *.msi 55 | *.msm 56 | *.msp 57 | 58 | # Windows shortcuts 59 | *.lnk 60 | 61 | 62 | ### Linux ### 63 | *~ 64 | 65 | # KDE directory preferences 66 | .directory 67 | 68 | # Linux trash folder which might appear on any partition or disk 69 | .Trash-* 70 | 71 | 72 | ### Vim ### 73 | [._]*.s[a-w][a-z] 74 | [._]s[a-w][a-z] 75 | *.un~ 76 | Session.vim 77 | .netrwhist 78 | *~ 79 | 80 | 81 | ### Emacs ### 82 | # -*- mode: gitignore; -*- 83 | *~ 84 | \#*\# 85 | /.emacs.desktop 86 | /.emacs.desktop.lock 87 | *.elc 88 | auto-save-list 89 | tramp 90 | .\#* 91 | 92 | # Org-mode 93 | .org-id-locations 94 | *_archive 95 | 96 | # flymake-mode 97 | *_flymake.* 98 | 99 | # eshell files 100 | /eshell/history 101 | /eshell/lastdir 102 | 103 | # elpa packages 104 | /elpa/ 105 | 106 | # reftex files 107 | *.rel 108 | 109 | # AUCTeX auto folder 110 | /auto/ 111 | 112 | # cask packages 113 | .cask/ 114 | 115 | 116 | ### SublimeText ### 117 | # cache files for sublime text 118 | *.tmlanguage.cache 119 | *.tmPreferences.cache 120 | *.stTheme.cache 121 | 122 | # workspace files are user-specific 123 | *.sublime-workspace 124 | 125 | # project files should be checked into the repository, unless a significant 126 | # proportion of contributors will probably not be using SublimeText 127 | # *.sublime-project 128 | 129 | # sftp configuration file 130 | sftp-config.json 131 | 132 | 133 | ### Intellij ### 134 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 135 | 136 | *.iml 137 | 138 | ## Directory-based project format: 139 | .idea/ 140 | # if you remove the above rule, at least ignore the following: 141 | 142 | # User-specific stuff: 143 | # .idea/workspace.xml 144 | # .idea/tasks.xml 145 | # .idea/dictionaries 146 | 147 | # Sensitive or high-churn files: 148 | # .idea/dataSources.ids 149 | # .idea/dataSources.xml 150 | # .idea/sqlDataSources.xml 151 | # .idea/dynamic.xml 152 | # .idea/uiDesigner.xml 153 | 154 | # Gradle: 155 | # .idea/gradle.xml 156 | # .idea/libraries 157 | 158 | # Mongo Explorer plugin: 159 | # .idea/mongoSettings.xml 160 | 161 | ## File-based project format: 162 | *.ipr 163 | *.iws 164 | 165 | ## Plugin-specific files: 166 | 167 | # IntelliJ 168 | /out/ 169 | 170 | # mpeltonen/sbt-idea plugin 171 | .idea_modules/ 172 | 173 | # JIRA plugin 174 | atlassian-ide-plugin.xml 175 | 176 | # Crashlytics plugin (for Android Studio and IntelliJ) 177 | com_crashlytics_export_strings.xml 178 | crashlytics.properties 179 | crashlytics-build.properties 180 | 181 | 182 | ### RubyMine ### 183 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 184 | 185 | *.iml 186 | 187 | ## Directory-based project format: 188 | .idea/ 189 | # if you remove the above rule, at least ignore the following: 190 | 191 | # User-specific stuff: 192 | # .idea/workspace.xml 193 | # .idea/tasks.xml 194 | # .idea/dictionaries 195 | 196 | # Sensitive or high-churn files: 197 | # .idea/dataSources.ids 198 | # .idea/dataSources.xml 199 | # .idea/sqlDataSources.xml 200 | # .idea/dynamic.xml 201 | # .idea/uiDesigner.xml 202 | 203 | # Gradle: 204 | # .idea/gradle.xml 205 | # .idea/libraries 206 | 207 | # Mongo Explorer plugin: 208 | # .idea/mongoSettings.xml 209 | 210 | ## File-based project format: 211 | *.ipr 212 | *.iws 213 | 214 | ## Plugin-specific files: 215 | 216 | # IntelliJ 217 | /out/ 218 | 219 | # mpeltonen/sbt-idea plugin 220 | .idea_modules/ 221 | 222 | # JIRA plugin 223 | atlassian-ide-plugin.xml 224 | 225 | # Crashlytics plugin (for Android Studio and IntelliJ) 226 | com_crashlytics_export_strings.xml 227 | crashlytics.properties 228 | crashlytics-build.properties 229 | 230 | 231 | ### Ruby ### 232 | *.gem 233 | *.rbc 234 | /.config 235 | /coverage/ 236 | /InstalledFiles 237 | /pkg/ 238 | /spec/reports/ 239 | /test/tmp/ 240 | /test/version_tmp/ 241 | /tmp/ 242 | 243 | ## Specific to RubyMotion: 244 | .dat* 245 | .repl_history 246 | build/ 247 | 248 | ## Documentation cache and generated files: 249 | /.yardoc/ 250 | /_yardoc/ 251 | /doc/ 252 | /rdoc/ 253 | 254 | ## Environment normalisation: 255 | /.bundle/ 256 | /vendor/bundle 257 | /lib/bundler/man/ 258 | 259 | # for a library or gem, you might want to ignore these files since the code is 260 | # intended to run in multiple environments; otherwise, check them in: 261 | # Gemfile.lock 262 | # .ruby-version 263 | # .ruby-gemset 264 | 265 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 266 | .rvmrc 267 | 268 | 269 | ### Rails ### 270 | *.rbc 271 | capybara-*.html 272 | .rspec 273 | /log 274 | /tmp 275 | /db/*.sqlite3 276 | /db/*.sqlite3-journal 277 | /public/system 278 | /coverage/ 279 | /spec/tmp 280 | **.orig 281 | rerun.txt 282 | pickle-email-*.html 283 | 284 | # TODO Comment out these rules if you are OK with secrets being uploaded to the repo 285 | #config/initializers/secret_token.rb 286 | #config/secrets.yml 287 | 288 | ## Environment normalisation: 289 | /.bundle 290 | /vendor/bundle 291 | 292 | # these should all be checked in to normalise the environment: 293 | # Gemfile.lock, .ruby-version, .ruby-gemset 294 | 295 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 296 | .rvmrc 297 | 298 | # if using bower-rails ignore default bower_components path bower.json files 299 | /vendor/assets/bower_components 300 | *.bowerrc 301 | bower.json 302 | 303 | # Ignore pow environment settings 304 | .powenv 305 | 306 | --------------------------------------------------------------------------------