├── Gemfile ├── lib ├── pot_markdown.rb ├── pot_markdown │ ├── version.rb │ ├── filters │ │ ├── sanitize_transformers │ │ │ ├── id_transformer.rb │ │ │ ├── list_transformer.rb │ │ │ └── table_transformer.rb │ │ ├── markdown_filter.rb │ │ ├── mention_filter.rb │ │ ├── sanitize_script_filter.rb │ │ ├── checkbox_filter.rb │ │ ├── sanitize_iframe_filter.rb │ │ ├── toc_filter.rb │ │ └── sanitize_html_filter.rb │ └── processor.rb └── kramdown │ └── parser │ ├── pot_markdown.rb │ └── pot_markdown │ ├── code_block.rb │ └── table.rb ├── bin ├── console └── setup ├── .gitignore ├── .travis.yml ├── Rakefile ├── test ├── test_helper.rb ├── files │ ├── sample_noscript_toc.html │ ├── sample_script_toc.html │ ├── sample.md │ ├── sample_noscript_body.html │ └── sample_script_body.html └── pot_markdown │ ├── benchmark_test.rb │ └── processor_test.rb ├── .rubocop_todo.yml ├── .rubocop.yml ├── pot_markdown.gemspec └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/pot_markdown.rb: -------------------------------------------------------------------------------- 1 | require 'pot_markdown/version' 2 | require 'pot_markdown/processor' 3 | -------------------------------------------------------------------------------- /lib/pot_markdown/version.rb: -------------------------------------------------------------------------------- 1 | module PotMarkdown 2 | VERSION = '0.1.6'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'pot_markdown' 5 | require 'pry' 6 | Pry.start 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - 2.2.5 5 | - 2.3.1 6 | - ruby-head 7 | before_install: 8 | - gem install bundler 9 | script: 10 | - bundle exec rake 11 | - bundle exec rubocop 12 | 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs << 'test' 6 | t.test_files = FileList['test/**/*_test.rb'] 7 | t.verbose = true 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'test/unit' 3 | require 'pot_markdown' 4 | require 'active_support' 5 | require 'active_support/core_ext' 6 | require 'diffy' 7 | 8 | def read_file(filename) 9 | File.read(File.expand_path("../files/#{filename}", __FILE__)) 10 | end 11 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2016-10-16 15:15:49 +0900 using RuboCop version 0.44.1. 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: 4 10 | Metrics/AbcSize: 11 | Max: 33 12 | -------------------------------------------------------------------------------- /lib/kramdown/parser/pot_markdown.rb: -------------------------------------------------------------------------------- 1 | require 'kramdown/parser' 2 | require 'rouge' 3 | 4 | module Kramdown 5 | module Parser 6 | class PotMarkdown < Kramdown::Parser::GFM 7 | def initialize(source, options) 8 | super 9 | 10 | # replace table 11 | @block_parsers.insert(@block_parsers.index(:table), :table_pot) 12 | @block_parsers.delete(:table) 13 | end 14 | 15 | require 'kramdown/parser/pot_markdown/code_block' 16 | require 'kramdown/parser/pot_markdown/table' 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - bin/**/* 4 | - tmp/**/* 5 | - vendor/**/* 6 | - test/files/**/* 7 | - lib/kramdown/parser/pot_markdown/table.rb 8 | 9 | AsciiComments: 10 | Enabled: false 11 | 12 | ClassLength: 13 | Max: 200 14 | 15 | Documentation: 16 | Enabled: false 17 | 18 | LineLength: 19 | Max: 120 20 | 21 | MethodLength: 22 | Max: 30 23 | 24 | BlockLength: 25 | Exclude: 26 | - test/**/* 27 | 28 | PerceivedComplexity: 29 | Exclude: 30 | - lib/kramdown/**/* 31 | 32 | inherit_from: .rubocop_todo.yml 33 | -------------------------------------------------------------------------------- /test/files/sample_noscript_toc.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/files/sample_script_toc.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/sanitize_transformers/id_transformer.rb: -------------------------------------------------------------------------------- 1 | module PotMarkdown 2 | module Filters 3 | module SanitizeTransformers 4 | class IdTransformer 5 | def self.call(*args) 6 | new(*args).transform 7 | end 8 | 9 | def initialize(env) 10 | @env = env 11 | @node = env[:node] 12 | end 13 | 14 | attr_reader :node 15 | 16 | def transform 17 | return unless node.attribute('id') 18 | return if node.attribute('id').to_s =~ /\A(?:fn|id\-)/ 19 | node.remove_attribute('id') 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/markdown_filter.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'kramdown' 3 | require 'kramdown/parser/pot_markdown' 4 | 5 | module PotMarkdown 6 | module Filters 7 | class MarkdownFilter < HTML::Pipeline::TextFilter 8 | def initialize(text, context = nil, result = nil) 9 | super 10 | @text = @text.delete("\r").strip 11 | end 12 | 13 | def call 14 | Nokogiri::HTML.fragment Kramdown::Document.new( 15 | @text, 16 | input: 'PotMarkdown', 17 | auto_id_prefix: 'id-', 18 | syntax_highlighter: 'rouge', 19 | math_engine: nil 20 | ).to_html.rstrip! 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/sanitize_transformers/list_transformer.rb: -------------------------------------------------------------------------------- 1 | module PotMarkdown 2 | module Filters 3 | module SanitizeTransformers 4 | class ListTransformer 5 | def self.call(*args) 6 | new(*args).transform 7 | end 8 | 9 | def initialize(env) 10 | @env = env 11 | @name = env[:node_name] 12 | @node = env[:node] 13 | end 14 | 15 | attr_reader :name, :node 16 | 17 | def transform 18 | return unless @name == NAME 19 | return if node.ancestors.any? { |n| LIST_PARENT.include?(n.name) } 20 | 21 | node.replace(node.children) 22 | end 23 | 24 | NAME = 'li'.freeze 25 | LIST_PARENT = Set.new(%w(ul ol)).freeze 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/sanitize_transformers/table_transformer.rb: -------------------------------------------------------------------------------- 1 | module PotMarkdown 2 | module Filters 3 | module SanitizeTransformers 4 | class TableTransformer 5 | def self.call(*args) 6 | new(*args).transform 7 | end 8 | 9 | def initialize(env) 10 | @env = env 11 | @name = env[:node_name] 12 | @node = env[:node] 13 | end 14 | 15 | attr_reader :name, :node 16 | 17 | def transform 18 | return unless NAMES.include?(name) 19 | return if node.ancestors.any? { |n| n.name == LIST_PARENT } 20 | 21 | node.replace(node.children) 22 | end 23 | 24 | NAMES = Set.new(%w(thead tbody tfoot tr td th)).freeze 25 | LIST_PARENT = 'table'.freeze 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/mention_filter.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'sanitize' 3 | 4 | module PotMarkdown 5 | module Filters 6 | class MentionFilter < HTML::Pipeline::MentionFilter 7 | USER_NAME_PATTERN = /[A-Za-z0-9][A-Za-z0-9\-\_]+/ 8 | 9 | def call 10 | result[:mentioned_usernames] ||= [] 11 | 12 | doc.xpath('.//text()').each do |node| 13 | content = node.text 14 | next unless content.include?('@') 15 | next if has_ancestor?(node, IGNORE_PARENTS) 16 | html = mention_link_filter(content, base_url, info_url, username_pattern) 17 | next if html == content 18 | node.replace(html) 19 | end 20 | doc 21 | end 22 | 23 | def username_pattern 24 | context[:username_pattern] || USER_NAME_PATTERN 25 | end 26 | 27 | def link_to_mentioned_user(login) 28 | result[:mentioned_usernames] |= [login] 29 | "@#{login}" 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/pot_markdown/benchmark_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper.rb' 2 | require 'benchmark/ips' 3 | 4 | class BenchmarkTest < Test::Unit::TestCase 5 | def text 6 | @text = File.read(File.expand_path('../../files/sample.md', __FILE__)) 7 | end 8 | 9 | test 'each filter' do 10 | text; # load 11 | context = { 12 | asset_root: '/' 13 | } 14 | Benchmark.ips do |x| 15 | PotMarkdown::Processor::DEFAULT_FILTERS.each do |filter| 16 | x.report(filter.name) { filter.new(text, context).call } 17 | end 18 | x.compare! 19 | end 20 | end 21 | 22 | test 'benchmark integration' do 23 | text; # load 24 | Benchmark.ips do |x| 25 | x.report('pot_markdown') { PotMarkdown::Processor.new.call(text)[:output].to_s } 26 | 27 | begin 28 | require 'qiita-markdown' 29 | x.report('qiita_markdown') { Qiita::Markdown::Processor.new.call(text)[:output].to_s } 30 | rescue LoadError 31 | puts 'skip benchmark test' 32 | end 33 | 34 | x.compare! 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/sanitize_script_filter.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'sanitize' 3 | 4 | module PotMarkdown 5 | module Filters 6 | class SanitizeScriptFilter < HTML::Pipeline::Filter 7 | def call 8 | doc.xpath('.//script').each do |element| 9 | if safe?(element) 10 | element.children.remove 11 | else 12 | element.remove 13 | end 14 | end 15 | doc 16 | end 17 | 18 | SAFE_SCRIPT_URL = [ 19 | # nicovideo 20 | %r{\Ahttps?://ext.nicovideo.jp/thumb_watch/[^/]+\z}, 21 | 22 | # speakerdeck 23 | %r{\A(https?:)?//speakerdeck.com/assets/embed.js\z}, 24 | 25 | # pixiv 26 | %r{\Ahttps?://source.pixiv.net/source/embed.js\z}, 27 | 28 | # twitter 29 | %r{\A(https:)?//platform.twitter.com/widgets.js\z}, 30 | 31 | # gist 32 | %r{\Ahttps://gist.github.com/[A-Za-z0-9\-]+/[a-f0-9]+\.js\z} 33 | ].freeze 34 | 35 | private 36 | 37 | def safe?(element) 38 | src = element['src'] 39 | safe_script_list.any? { |reg| src =~ reg } 40 | end 41 | 42 | def safe_script_list 43 | context[:safe_script_url] || SAFE_SCRIPT_URL 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/checkbox_filter.rb: -------------------------------------------------------------------------------- 1 | module PotMarkdown 2 | module Filters 3 | class CheckboxFilter < HTML::Pipeline::Filter 4 | def call 5 | doc.xpath('.//li').each do |node| 6 | child = node.children.first 7 | next unless child 8 | next unless child.name == 'text' 9 | checkbox, text = checkbox_filter(child.text) 10 | next unless checkbox 11 | node.children.first.replace(text) 12 | node.children.first.add_previous_sibling(checkbox) 13 | node.set_attribute('class', "#{node.attribute('class')} #{checkbox_class}".strip) 14 | end 15 | doc 16 | end 17 | 18 | def checkbox_filter(text) 19 | checkbox = nil 20 | text = text.gsub(CHECKBOX_PATTERN) do 21 | checked = (Regexp.last_match(1) == 'x') 22 | checkbox = 23 | "" 24 | '' 25 | end 26 | [checkbox, text] 27 | end 28 | 29 | def disable? 30 | context[:checkbox_enable].nil? || context[:checkbox_enable] 31 | end 32 | 33 | def checkbox_class 34 | context[:checkbox_class] || 'task-list-item' 35 | end 36 | 37 | CHECKBOX_PATTERN = /\A\[(?\s+|x)\]/ 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/kramdown/parser/pot_markdown/code_block.rb: -------------------------------------------------------------------------------- 1 | module Kramdown 2 | module Parser 3 | class PotMarkdown 4 | FENCED_CODEBLOCK_START = /^[ ]{0,3}[~`]{3,}/ 5 | FENCED_CODEBLOCK_MATCH = /^[ ]{0,3}(([~`]){3,})\s*?((\S+?)(?:\?\S*)?)?\s*?\n(.*?)^[ ]{0,3}\1\2*\s*?\n/m 6 | 7 | def parse_codeblock_fenced 8 | if @src.check(self.class::FENCED_CODEBLOCK_MATCH) 9 | start_line_number = @src.current_line_number 10 | @src.pos += @src.matched_size 11 | el = new_block_el(:codeblock, @src[5], nil, location: start_line_number) 12 | lang = @src[3].to_s.strip 13 | unless lang.empty? 14 | lang, filename = lang.split(':', 2) 15 | if filename.nil? && lang.include?('.') 16 | filename = lang 17 | lang = Rouge::Lexer.guess_by_filename(filename).name.match(/[^\:]+\z/).to_s.downcase 18 | end 19 | el.options[:lang] = lang 20 | el.attr['data-lang'] = lang 21 | if filename 22 | el.attr['class'] = "language-#{lang} has-filename" 23 | el.attr['data-filename'] = filename 24 | else 25 | el.attr['class'] = "language-#{lang}" 26 | end 27 | end 28 | @tree.children << el 29 | true 30 | else 31 | false 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/pot_markdown/processor.rb: -------------------------------------------------------------------------------- 1 | require 'html/pipeline' 2 | require 'rouge' 3 | 4 | require 'pot_markdown/filters/markdown_filter' 5 | require 'pot_markdown/filters/sanitize_html_filter' 6 | require 'pot_markdown/filters/sanitize_script_filter' 7 | require 'pot_markdown/filters/sanitize_iframe_filter' 8 | require 'pot_markdown/filters/mention_filter' 9 | require 'pot_markdown/filters/toc_filter' 10 | require 'pot_markdown/filters/checkbox_filter' 11 | 12 | module PotMarkdown 13 | class Processor 14 | def initialize(default_context = {}) 15 | @default_context = DEFAULT_CONTEXT.merge(default_context) 16 | end 17 | 18 | def call(str, context = {}) 19 | HTML::Pipeline.new(filters, @default_context.merge(context)).call(str) 20 | end 21 | 22 | def filters 23 | @filters ||= DEFAULT_FILTERS.dup 24 | end 25 | 26 | DEFAULT_CONTEXT = { 27 | asset_root: '/assets' 28 | }.freeze 29 | 30 | DEFAULT_FILTERS = [ 31 | PotMarkdown::Filters::MarkdownFilter, 32 | PotMarkdown::Filters::TOCFilter, 33 | PotMarkdown::Filters::MentionFilter, 34 | PotMarkdown::Filters::CheckboxFilter, 35 | HTML::Pipeline::AutolinkFilter, 36 | HTML::Pipeline::EmojiFilter, 37 | PotMarkdown::Filters::SanitizeHTMLFilter, 38 | PotMarkdown::Filters::SanitizeScriptFilter, 39 | PotMarkdown::Filters::SanitizeIframeFilter 40 | ].freeze 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /pot_markdown.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'pot_markdown/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'pot_markdown' 8 | spec.version = PotMarkdown::VERSION 9 | spec.authors = ['ru_shalm'] 10 | spec.email = ['ru_shalm@hazimu.com'] 11 | 12 | spec.summary = 'Markdown processor like GitHub' 13 | spec.homepage = 'https://github.com/rutan/pot_markdown' 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 16 | spec.bindir = 'exe' 17 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_dependency 'html-pipeline', '~> 2.4' 21 | spec.add_dependency 'kramdown', '~> 1.12' 22 | spec.add_dependency 'rouge', '~> 1.8' 23 | spec.add_dependency 'gemoji' 24 | spec.add_dependency 'rinku' 25 | spec.add_dependency 'sanitize' 26 | 27 | spec.add_development_dependency 'bundler', '~> 1.11' 28 | spec.add_development_dependency 'rake', '~> 10.0' 29 | spec.add_development_dependency 'pry' 30 | spec.add_development_dependency 'benchmark-ips' 31 | spec.add_development_dependency 'test-unit' 32 | spec.add_development_dependency 'diffy' 33 | spec.add_development_dependency 'rubocop', '0.44.1' 34 | spec.add_development_dependency 'activesupport' 35 | end 36 | -------------------------------------------------------------------------------- /lib/pot_markdown/filters/sanitize_iframe_filter.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'sanitize' 3 | 4 | module PotMarkdown 5 | module Filters 6 | class SanitizeIframeFilter < HTML::Pipeline::Filter 7 | def call 8 | doc.xpath('.//iframe').each do |element| 9 | element.remove unless safe?(element) 10 | end 11 | doc 12 | end 13 | 14 | SAFE_IFRAME_URL = [ 15 | # youtube 16 | %r{\Ahttps://www.youtube.com/embed/[^/]+\z}, 17 | 18 | # nicovideo 19 | %r{\Ahttps?://ext.nicovideo.jp/thumb/[^/]+\z}, 20 | 21 | # nicolive 22 | %r{\Ahttps?://live.nicovideo.jp/embed/.+\z}, 23 | 24 | # nicoseiga 25 | %r{\Ahttps?://ext.seiga.nicovideo.jp/thumb/[^/]+\z}, 26 | 27 | # niconisolid 28 | %r{\Ahttps?://3d.nicovideo.jp/externals/(?:widget|embedded)\?id=td\d+\z}, 29 | 30 | # niconare 31 | %r{\Ahttps?://niconare.nicovideo.jp/embed_works/kn\d+\z}, 32 | 33 | # slideshare 34 | %r{\A(https?:)?//www.slideshare.net/slideshow/embed_code/key/[^/]+\z}, 35 | 36 | # plicy 37 | %r{\Ahttps?://plicy.net/GameEmbed/\d+\z} 38 | ].freeze 39 | 40 | private 41 | 42 | def safe?(element) 43 | src = element['src'] 44 | safe_iframe_list.any? { |reg| src =~ reg } 45 | end 46 | 47 | def safe_iframe_list 48 | context[:safe_iframe_url] || SAFE_IFRAME_URL 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PotMarkdown 2 | 3 | [![Build Status](https://travis-ci.org/rutan/pot_markdown.svg)](https://travis-ci.org/rutan/pot_markdown) 4 | 5 | PotMarkdown is markdown processor for [Potmum](https://github.com/rutan/potmum). 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'pot_markdown' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install pot_markdown 22 | 23 | ## Usage 24 | 25 | ```ruby 26 | require 'pot_markdown' 27 | 28 | processor = PotMarkdown::Processor.new 29 | context = { 30 | safe_script_url: false 31 | } 32 | processor.call("# title\n\n Hello, **Potmum!** ...", context) 33 | # => { 34 | # toc: "